Skip to main content

wsb_github/
client.rs

1//! The GitHub API: REST for repositories, issues, gists and the rest, and
2//! GraphQL for Projects boards.
3
4use std::collections::BTreeMap;
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8
9fn github_status_error(action: &str, status: reqwest::StatusCode) -> anyhow::Error {
10    anyhow::anyhow!(
11        "{} failed with GitHub API status {}",
12        action,
13        status.as_u16()
14    )
15}
16
17fn require_success(action: &str, response: reqwest::Response) -> Result<reqwest::Response> {
18    let status = response.status();
19    if status.is_success() {
20        Ok(response)
21    } else {
22        Err(github_status_error(action, status))
23    }
24}
25
26/// GitHub REST API client for issues management.
27#[derive(Clone)]
28pub struct GitHubClient {
29    client: reqwest::Client,
30    token: String,
31    /// Where the API lives.
32    ///
33    /// `https://api.github.com` everywhere but in tests, which point it at a
34    /// server they control. Conditional requests are the whole economics of
35    /// mirroring — an unchanged repository must cost a 304 and nothing else —
36    /// and a claim like that is worth proving against a server that can
37    /// actually answer 304, rather than asserting the header is set and hoping.
38    base: String,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct GitHubUser {
43    pub login: String,
44    pub avatar_url: Option<String>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct GitHubLabel {
49    pub name: String,
50    pub color: String,
51    pub description: Option<String>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct GitHubIssue {
56    pub number: i64,
57    pub title: String,
58    pub body: Option<String>,
59    pub state: String,
60    pub user: GitHubUser,
61    pub labels: Vec<GitHubLabel>,
62    pub comments: i64,
63    pub created_at: String,
64    pub updated_at: String,
65    pub closed_at: Option<String>,
66    pub html_url: String,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct GitHubComment {
71    pub id: i64,
72    pub body: String,
73    pub user: GitHubUser,
74    pub created_at: String,
75    pub updated_at: String,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct GitHubGistFile {
80    pub filename: Option<String>,
81    #[serde(rename = "type")]
82    pub mime_type: Option<String>,
83    pub language: Option<String>,
84    pub raw_url: Option<String>,
85    pub size: Option<u64>,
86    pub truncated: Option<bool>,
87    pub content: Option<String>,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct GitHubGist {
92    pub id: String,
93    pub description: Option<String>,
94    pub public: bool,
95    pub html_url: String,
96    pub created_at: String,
97    pub updated_at: String,
98    pub owner: Option<GitHubUser>,
99    pub files: BTreeMap<String, GitHubGistFile>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct GistFileInput {
104    pub content: String,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct CreateGistRequest {
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub description: Option<String>,
111    pub public: bool,
112    pub files: BTreeMap<String, GistFileInput>,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct UpdateGistRequest {
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub description: Option<String>,
119    pub files: BTreeMap<String, GistFileInput>,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct CreateIssueRequest {
124    pub title: String,
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub body: Option<String>,
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub labels: Option<Vec<String>>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct UpdateIssueRequest {
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub title: Option<String>,
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub body: Option<String>,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub state: Option<String>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub labels: Option<Vec<String>>,
141}
142
143// ---------------------------------------------------------------------------
144// Everything else the workbench mirrors
145// ---------------------------------------------------------------------------
146//
147// Each of these is stored whole, so the fields named here are only the ones
148// something QUERIES on — a state to filter by, a time to order by, a name to
149// show. Anything a page merely renders comes out of the payload, which is why
150// adding a column to a card does not mean changing this file.
151
152/// A pull request. GitHub's issues endpoint returns these too, which is why
153/// they are filtered out of the issue list — a pull request is a different
154/// thing to a person looking at either list.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct GitHubPull {
157    pub number: i64,
158    pub title: String,
159    pub body: Option<String>,
160    /// `open` or `closed`. Merged is not a state GitHub reports here — a
161    /// merged pull request is closed with `merged_at` set — so a page that
162    /// wants to say "merged" reads that.
163    pub state: String,
164    #[serde(default)]
165    pub draft: bool,
166    pub user: GitHubUser,
167    #[serde(default)]
168    pub labels: Vec<GitHubLabel>,
169    pub created_at: String,
170    pub updated_at: String,
171    pub closed_at: Option<String>,
172    pub merged_at: Option<String>,
173    pub html_url: String,
174    pub head: GitHubPullRef,
175    pub base: GitHubPullRef,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct GitHubPullRef {
180    /// The branch name.
181    #[serde(rename = "ref")]
182    pub branch: String,
183    pub sha: String,
184}
185
186/// What CI says about one commit.
187///
188/// Two GitHub mechanisms in one shape: the older commit STATUSES (a context
189/// and a state) and the newer CHECK RUNS (a name, a status and a conclusion).
190/// A repository can use either or both, and a person wants one answer.
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192pub struct GitHubChecks {
193    /// The commit these are about.
194    pub sha: String,
195    /// `success` | `failure` | `pending` | `neutral` — the whole commit's
196    /// verdict, decided by the rules in [`GitHubChecks::rollup`].
197    pub state: String,
198    pub runs: Vec<GitHubCheckRun>,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct GitHubCheckRun {
203    pub name: String,
204    /// `queued` | `in_progress` | `completed` for a check run; for a commit
205    /// status, `completed` — it has no lifecycle to report.
206    pub status: String,
207    /// `success` | `failure` | `cancelled` | `skipped` | `neutral` | …, or
208    /// absent while it is still running.
209    pub conclusion: Option<String>,
210    pub url: Option<String>,
211}
212
213impl GitHubChecks {
214    /// One verdict for a commit, from every run reported about it.
215    ///
216    /// The order is what a person cares about, worst first: anything failing
217    /// makes the commit failing, anything still running makes it pending, and
218    /// only then is it a success. A rollup that let a green check hide a red
219    /// one would be worse than showing nothing.
220    pub fn rollup(runs: &[GitHubCheckRun]) -> String {
221        if runs.is_empty() {
222            return "neutral".to_string();
223        }
224        let failed = runs.iter().any(|run| {
225            matches!(
226                run.conclusion.as_deref(),
227                Some("failure") | Some("timed_out") | Some("action_required") | Some("startup_failure")
228            )
229        });
230        if failed {
231            return "failure".to_string();
232        }
233        let running = runs
234            .iter()
235            .any(|run| run.status != "completed" || run.conclusion.is_none());
236        if running {
237            return "pending".to_string();
238        }
239        let succeeded = runs
240            .iter()
241            .any(|run| run.conclusion.as_deref() == Some("success"));
242        if succeeded {
243            "success".to_string()
244        } else {
245            // Everything skipped, cancelled or neutral: nothing failed and
246            // nothing passed, and calling that a success would be a lie a
247            // release gate could act on.
248            "neutral".to_string()
249        }
250    }
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct GitHubRelease {
255    pub id: i64,
256    pub tag_name: String,
257    pub name: Option<String>,
258    pub body: Option<String>,
259    pub draft: bool,
260    pub prerelease: bool,
261    pub created_at: String,
262    pub published_at: Option<String>,
263    pub html_url: String,
264    #[serde(default)]
265    pub assets: Vec<GitHubReleaseAsset>,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct GitHubReleaseAsset {
270    pub name: String,
271    pub size: i64,
272    pub download_count: i64,
273    pub browser_download_url: String,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct GitHubTag {
278    pub name: String,
279    pub commit: GitHubTagCommit,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct GitHubTagCommit {
284    pub sha: String,
285}
286
287/// What GitHub says a repository IS.
288///
289/// `private` is the one the explorer draws: a lock on a row is the difference
290/// between "this is ours" and "this is the world's", and it is the sort of
291/// thing somebody should not have to open a browser to check before pasting.
292#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct GitHubRepository {
294    pub full_name: String,
295    pub private: bool,
296    pub archived: bool,
297    pub fork: bool,
298    pub default_branch: String,
299    pub description: Option<String>,
300    pub html_url: String,
301    #[serde(default)]
302    pub topics: Vec<String>,
303    pub stargazers_count: i64,
304    pub watchers_count: i64,
305    pub forks_count: i64,
306    pub open_issues_count: i64,
307    pub pushed_at: Option<String>,
308    pub updated_at: Option<String>,
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct GitHubMilestone {
313    pub number: i64,
314    pub title: String,
315    pub description: Option<String>,
316    pub state: String,
317    pub open_issues: i64,
318    pub closed_issues: i64,
319    pub due_on: Option<String>,
320    pub updated_at: String,
321    pub html_url: String,
322}
323
324/// One thing GitHub thinks needs this person.
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct GitHubNotification {
327    pub id: String,
328    pub unread: bool,
329    /// `mention` | `review_requested` | `assign` | `subscribed` | …
330    pub reason: String,
331    pub updated_at: String,
332    pub subject: GitHubNotificationSubject,
333    pub repository: GitHubNotificationRepo,
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct GitHubNotificationSubject {
338    pub title: String,
339    /// `Issue` | `PullRequest` | `Release` | `Discussion` | …
340    #[serde(rename = "type")]
341    pub kind: String,
342    /// The API url of the thing. Absent for some subjects (a discussion, a
343    /// security alert), which is why a page cannot assume it can link.
344    pub url: Option<String>,
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct GitHubNotificationRepo {
349    pub full_name: String,
350    pub private: bool,
351}
352
353/// How much of the API budget is left.
354///
355/// Mirrored like everything else, because "why is nothing refreshing" is a
356/// question with an answer, and the answer is usually this.
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
358pub struct GitHubRateLimit {
359    pub limit: i64,
360    pub remaining: i64,
361    /// Unix seconds. When the budget comes back.
362    pub reset: i64,
363    pub used: i64,
364}
365
366/// How much the world looked at a repository.
367///
368/// Needs push access, so a repository somebody merely reads answers 403. That
369/// is recorded as this scope's error and nothing else — traffic is the one
370/// thing here whose absence means nothing at all.
371#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
372pub struct GitHubTraffic {
373    pub views: i64,
374    pub unique_views: i64,
375    pub clones: i64,
376    pub unique_clones: i64,
377    /// Per day, newest last, as GitHub reports it.
378    #[serde(default)]
379    pub view_days: Vec<GitHubTrafficDay>,
380    #[serde(default)]
381    pub clone_days: Vec<GitHubTrafficDay>,
382}
383
384#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
385pub struct GitHubTrafficDay {
386    pub timestamp: String,
387    pub count: i64,
388    pub uniques: i64,
389}
390
391/// GitHub's own shape for one traffic series, which this folds into
392/// [`GitHubTraffic`].
393#[derive(Debug, Clone, Deserialize)]
394pub struct TrafficSeries {
395    pub count: i64,
396    pub uniques: i64,
397    #[serde(default)]
398    pub views: Vec<GitHubTrafficDay>,
399    #[serde(default)]
400    pub clones: Vec<GitHubTrafficDay>,
401}
402
403/// A repository ruleset, as much of it as anything here reads.
404#[derive(Debug, Clone, Deserialize)]
405pub struct RepositoryRuleset {
406    pub id: i64,
407    pub name: String,
408    #[serde(default)]
409    pub target: Option<String>,
410    #[serde(default)]
411    pub enforcement: Option<String>,
412}
413
414#[derive(Debug, Clone, Serialize)]
415pub struct CreateCommentRequest {
416    pub body: String,
417}
418
419/// How long one request to the API may take, from sending it to the last byte
420/// of the answer.
421///
422/// Every call here is one page of JSON, which GitHub answers in about a second.
423/// Without a bound, a connection that died under the client answered nothing
424/// and the call waited for ever: on 19 Oct 2026 a daemon's sync sat for hours
425/// on a pooled connection from an address the Mac no longer had, and since the
426/// sweep reads its scopes one after another, no board, issue or release was
427/// read again until the daemon restarted. Bounded, that request fails, the
428/// mirror records why beside what it holds, and the sweep goes on.
429const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
430
431/// How long opening a connection may take. A route that is gone fails here
432/// rather than spending the whole request bound.
433const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
434
435impl GitHubClient {
436    pub fn new(token: &str) -> Self {
437        Self::against("https://api.github.com", token)
438    }
439
440    /// A client for an API at `base`. Tests use it; nothing else does.
441    pub fn against(base: &str, token: &str) -> Self {
442        Self::bounded(base, token, REQUEST_TIMEOUT)
443    }
444
445    /// [`against`](Self::against) with a request bound of the caller's
446    /// choosing, so a test can watch a silent server be given up on without
447    /// waiting out the real one.
448    pub fn against_within(base: &str, token: &str, request: std::time::Duration) -> Self {
449        Self::bounded(base, token, request)
450    }
451
452    fn bounded(base: &str, token: &str, request: std::time::Duration) -> Self {
453        let client = reqwest::Client::builder()
454            .user_agent(concat!("wsb-github/", env!("CARGO_PKG_VERSION")))
455            .no_proxy()
456            .connect_timeout(CONNECT_TIMEOUT.min(request))
457            .timeout(request)
458            .build()
459            .expect("failed to build reqwest client");
460
461        Self {
462            client,
463            token: token.to_string(),
464            base: base.trim_end_matches('/').to_string(),
465        }
466    }
467
468    /// One API URL.
469    fn url(&self, path: &str) -> String {
470        format!("{}{}", self.base, path)
471    }
472
473    /// Validate the token by fetching the authenticated user.
474    pub async fn validate_token(&self) -> Result<String> {
475        let response = require_success(
476            "GitHub token validation",
477            self.client
478                .get(self.url("/user"))
479                .bearer_auth(&self.token)
480                .send()
481                .await
482                .context("GitHub API request failed")?,
483        )?;
484        let resp: GitHubUser = response
485            .json()
486            .await
487            .context("failed to parse GitHub user response")?;
488        Ok(resp.login)
489    }
490
491    /// Every repository ruleset, by name and id.
492    ///
493    /// Read before writing so applying protection twice is applying it once:
494    /// GitHub's create endpoint does not upsert, and a second run would leave
495    /// two rulesets with the same name saying the same thing.
496    pub async fn list_rulesets(&self, owner: &str, repo: &str) -> Result<Vec<RepositoryRuleset>> {
497        let response = require_success(
498            "listing rulesets",
499            self.client
500                .get(self.url(&format!("/repos/{owner}/{repo}/rulesets")))
501                .bearer_auth(&self.token)
502                .send()
503                .await
504                .context("GitHub API request failed")?,
505        )?;
506        response
507            .json()
508            .await
509            .context("failed to parse the ruleset list")
510    }
511
512    /// Create a ruleset, or replace the one that already has this name.
513    ///
514    /// The body is passed through rather than modelled: GitHub's rule shapes
515    /// are a tagged union that grows, and a partial model here would silently
516    /// drop the parts of a rule it did not know about.
517    pub async fn put_ruleset(
518        &self,
519        owner: &str,
520        repo: &str,
521        name: &str,
522        body: &serde_json::Value,
523    ) -> Result<RepositoryRuleset> {
524        let existing = self
525            .list_rulesets(owner, repo)
526            .await?
527            .into_iter()
528            .find(|ruleset| ruleset.name == name);
529
530        let response = match &existing {
531            Some(ruleset) => {
532                self.client
533                    .put(self.url(&format!(
534                        "/repos/{owner}/{repo}/rulesets/{}",
535                        ruleset.id
536                    )))
537                    .bearer_auth(&self.token)
538                    .json(body)
539                    .send()
540                    .await
541            }
542            None => {
543                self.client
544                    .post(self.url(&format!("/repos/{owner}/{repo}/rulesets")))
545                    .bearer_auth(&self.token)
546                    .json(body)
547                    .send()
548                    .await
549            }
550        }
551        .context("GitHub API request failed")?;
552
553        let response = require_success(
554            if existing.is_some() {
555                "updating a ruleset"
556            } else {
557                "creating a ruleset"
558            },
559            response,
560        )?;
561        response
562            .json()
563            .await
564            .context("failed to parse the ruleset GitHub wrote")
565    }
566}
567
568/// What a conditional request answered.
569///
570/// `Unchanged` is not an empty answer and must never be stored as one: it says
571/// the copy already held is still correct. Collapsing the two is how a mirror
572/// empties itself every time nothing has happened — which, for a repository
573/// nobody has touched today, is every single sync.
574#[derive(Debug, Clone, PartialEq, Eq)]
575pub enum Fetched<T> {
576    /// GitHub answered with data, and the ETag to send with the next request.
577    Fresh { value: T, etag: Option<String> },
578    /// GitHub answered 304: what is held is current. Costs no rate limit,
579    /// which is what makes syncing forty repositories every few minutes
580    /// affordable.
581    Unchanged,
582}
583
584impl<T> Fetched<T> {
585    pub fn value(self) -> Option<T> {
586        match self {
587            Self::Fresh { value, .. } => Some(value),
588            Self::Unchanged => None,
589        }
590    }
591}
592
593/// How this side asks for "only what I do not already have".
594#[derive(Debug, Clone, Default)]
595pub struct Conditions {
596    /// The ETag of the last answer, replayed as `If-None-Match`.
597    pub etag: Option<String>,
598    /// Only things updated at or after this time — GitHub's `since`.
599    pub since: Option<String>,
600}
601
602impl GitHubClient {
603    /// List issues, asking only for what changed.
604    ///
605    /// Two mechanisms, and both matter: `since` keeps the RESPONSE small on a
606    /// repository with thousands of issues, and the ETag makes the whole
607    /// request free when nothing moved at all. Neither is an optimisation for
608    /// its own sake — a workbench that mirrors forty repositories continuously
609    /// is rate-limited into uselessness without them.
610    ///
611    /// `since` is inclusive, so the issue that set the mark comes back once
612    /// more and is written over itself. One row rewritten beats one update
613    /// missed.
614    pub async fn list_issues_changed(
615        &self,
616        owner: &str,
617        repo: &str,
618        conditions: &Conditions,
619    ) -> Result<Fetched<Vec<GitHubIssue>>> {
620        let mut url = self.url(&format!(
621            "/repos/{owner}/{repo}/issues?state=all&per_page=100&sort=updated&direction=desc"
622        ));
623        if let Some(since) = &conditions.since {
624            url.push_str(&format!("&since={since}"));
625        }
626
627        let mut request = self.client.get(&url).bearer_auth(&self.token);
628        if let Some(etag) = &conditions.etag {
629            request = request.header(reqwest::header::IF_NONE_MATCH, etag);
630        }
631        let response = request
632            .send()
633            .await
634            .with_context(|| format!("failed to list issues for {owner}/{repo}"))?;
635
636        if response.status() == reqwest::StatusCode::NOT_MODIFIED {
637            return Ok(Fetched::Unchanged);
638        }
639        let etag = header_value(&response, reqwest::header::ETAG);
640        let response = require_success(
641            &format!("GitHub API error listing issues for {owner}/{repo}"),
642            response,
643        )?;
644        let issues: Vec<GitHubIssue> = response
645            .json()
646            .await
647            .context("failed to parse issues response")?;
648
649        Ok(Fetched::Fresh {
650            // Pull requests come back from the issues endpoint too, and a pull
651            // request is not an issue to anybody reading this list.
652            value: issues
653                .into_iter()
654                .filter(|issue| !issue.html_url.contains("/pull/"))
655                .collect(),
656            etag,
657        })
658    }
659
660    /// List the authenticated user's gists, conditionally.
661    ///
662    /// No `since` here on purpose: the answer is authoritative about which
663    /// gists EXIST, and a partial answer cannot be — a gist absent from an
664    /// incremental page would read as deleted.
665    pub async fn list_gists_changed(
666        &self,
667        conditions: &Conditions,
668    ) -> Result<Fetched<Vec<GitHubGist>>> {
669        let mut request = self
670            .client
671            .get(self.url("/gists?per_page=100"))
672            .bearer_auth(&self.token);
673        if let Some(etag) = &conditions.etag {
674            request = request.header(reqwest::header::IF_NONE_MATCH, etag);
675        }
676        let response = request.send().await.context("failed to list gists")?;
677
678        if response.status() == reqwest::StatusCode::NOT_MODIFIED {
679            return Ok(Fetched::Unchanged);
680        }
681        let etag = header_value(&response, reqwest::header::ETAG);
682        let response = require_success("failed to list gists", response)?;
683        let gists: Vec<GitHubGist> = response
684            .json()
685            .await
686            .context("failed to parse gists response")?;
687        Ok(Fetched::Fresh { value: gists, etag })
688    }
689}
690
691impl GitHubClient {
692    /// A conditional GET that decodes into `T`.
693    ///
694    /// Every mirrored kind goes through this, so "ask only for what I do not
695    /// already have" is written once. A 304 is an ANSWER — the copy held is
696    /// current — and never an empty result, which is the distinction the whole
697    /// mirror rests on.
698    async fn conditional<T: serde::de::DeserializeOwned>(
699        &self,
700        what: &str,
701        path: &str,
702        conditions: &Conditions,
703    ) -> Result<Fetched<T>> {
704        let mut request = self.client.get(self.url(path)).bearer_auth(&self.token);
705        if let Some(etag) = &conditions.etag {
706            request = request.header(reqwest::header::IF_NONE_MATCH, etag);
707        }
708        let response = request
709            .send()
710            .await
711            .with_context(|| format!("failed {what}"))?;
712
713        if response.status() == reqwest::StatusCode::NOT_MODIFIED {
714            return Ok(Fetched::Unchanged);
715        }
716        let etag = header_value(&response, reqwest::header::ETAG);
717        let response = require_success(what, response)?;
718        let value: T = response
719            .json()
720            .await
721            .with_context(|| format!("failed to parse the answer to {what}"))?;
722        Ok(Fetched::Fresh { value, etag })
723    }
724
725    /// Pull requests, newest movement first.
726    ///
727    /// A separate request from issues even though GitHub's issues endpoint
728    /// returns both: that endpoint's pull requests are issue-shaped, missing
729    /// the branch, the draft flag and the merge — everything that makes a pull
730    /// request worth listing separately.
731    pub async fn list_pulls_changed(
732        &self,
733        owner: &str,
734        repo: &str,
735        conditions: &Conditions,
736    ) -> Result<Fetched<Vec<GitHubPull>>> {
737        self.conditional(
738            &format!("listing pull requests for {owner}/{repo}"),
739            &format!("/repos/{owner}/{repo}/pulls?state=all&per_page=100&sort=updated&direction=desc"),
740            conditions,
741        )
742        .await
743    }
744
745    /// What CI says about one commit: check runs AND commit statuses.
746    ///
747    /// Both, because a repository can use either — GitHub Actions writes check
748    /// runs, most other systems write statuses — and a workbench that read one
749    /// would report "no checks" for half the fleet.
750    pub async fn checks_for(&self, owner: &str, repo: &str, commit: &str) -> Result<GitHubChecks> {
751        #[derive(Deserialize)]
752        struct CheckRuns {
753            check_runs: Vec<RawCheckRun>,
754        }
755        #[derive(Deserialize)]
756        struct RawCheckRun {
757            name: String,
758            status: String,
759            conclusion: Option<String>,
760            html_url: Option<String>,
761        }
762        #[derive(Deserialize)]
763        struct CombinedStatus {
764            statuses: Vec<RawStatus>,
765        }
766        #[derive(Deserialize)]
767        struct RawStatus {
768            context: String,
769            state: String,
770            target_url: Option<String>,
771        }
772
773        let runs: CheckRuns = require_success(
774            &format!("listing check runs for {owner}/{repo}@{commit}"),
775            self.client
776                .get(self.url(&format!(
777                    "/repos/{owner}/{repo}/commits/{commit}/check-runs?per_page=100"
778                )))
779                .bearer_auth(&self.token)
780                .send()
781                .await
782                .context("GitHub API request failed")?,
783        )?
784        .json()
785        .await
786        .context("failed to parse the check runs")?;
787
788        let combined: CombinedStatus = require_success(
789            &format!("listing commit statuses for {owner}/{repo}@{commit}"),
790            self.client
791                .get(self.url(&format!(
792                    "/repos/{owner}/{repo}/commits/{commit}/status"
793                )))
794                .bearer_auth(&self.token)
795                .send()
796                .await
797                .context("GitHub API request failed")?,
798        )?
799        .json()
800        .await
801        .context("failed to parse the commit statuses")?;
802
803        let mut all: Vec<GitHubCheckRun> = runs
804            .check_runs
805            .into_iter()
806            .map(|run| GitHubCheckRun {
807                name: run.name,
808                status: run.status,
809                conclusion: run.conclusion,
810                url: run.html_url,
811            })
812            .collect();
813        all.extend(combined.statuses.into_iter().map(|status| GitHubCheckRun {
814            name: status.context,
815            // A commit status has no lifecycle: it is written when it is
816            // decided, so it is always complete.
817            status: "completed".to_string(),
818            conclusion: Some(match status.state.as_str() {
819                "success" => "success".to_string(),
820                "failure" | "error" => "failure".to_string(),
821                other => other.to_string(),
822            }),
823            url: status.target_url,
824        }));
825
826        Ok(GitHubChecks {
827            sha: commit.to_string(),
828            state: GitHubChecks::rollup(&all),
829            runs: all,
830        })
831    }
832
833    pub async fn list_releases_changed(
834        &self,
835        owner: &str,
836        repo: &str,
837        conditions: &Conditions,
838    ) -> Result<Fetched<Vec<GitHubRelease>>> {
839        self.conditional(
840            &format!("listing releases for {owner}/{repo}"),
841            &format!("/repos/{owner}/{repo}/releases?per_page=100"),
842            conditions,
843        )
844        .await
845    }
846
847    pub async fn list_tags_changed(
848        &self,
849        owner: &str,
850        repo: &str,
851        conditions: &Conditions,
852    ) -> Result<Fetched<Vec<GitHubTag>>> {
853        self.conditional(
854            &format!("listing tags for {owner}/{repo}"),
855            &format!("/repos/{owner}/{repo}/tags?per_page=100"),
856            conditions,
857        )
858        .await
859    }
860
861    pub async fn repository_changed(
862        &self,
863        owner: &str,
864        repo: &str,
865        conditions: &Conditions,
866    ) -> Result<Fetched<GitHubRepository>> {
867        self.conditional(
868            &format!("reading {owner}/{repo}"),
869            &format!("/repos/{owner}/{repo}"),
870            conditions,
871        )
872        .await
873    }
874
875    pub async fn list_milestones_changed(
876        &self,
877        owner: &str,
878        repo: &str,
879        conditions: &Conditions,
880    ) -> Result<Fetched<Vec<GitHubMilestone>>> {
881        self.conditional(
882            &format!("listing milestones for {owner}/{repo}"),
883            &format!("/repos/{owner}/{repo}/milestones?state=all&per_page=100"),
884            conditions,
885        )
886        .await
887    }
888
889    pub async fn list_labels_changed(
890        &self,
891        owner: &str,
892        repo: &str,
893        conditions: &Conditions,
894    ) -> Result<Fetched<Vec<GitHubLabel>>> {
895        self.conditional(
896            &format!("listing labels for {owner}/{repo}"),
897            &format!("/repos/{owner}/{repo}/labels?per_page=100"),
898            conditions,
899        )
900        .await
901    }
902
903    /// Comments on one issue or pull request.
904    pub async fn list_comments_changed(
905        &self,
906        owner: &str,
907        repo: &str,
908        number: i64,
909        conditions: &Conditions,
910    ) -> Result<Fetched<Vec<GitHubComment>>> {
911        self.conditional(
912            &format!("listing comments on {owner}/{repo}#{number}"),
913            &format!("/repos/{owner}/{repo}/issues/{number}/comments?per_page=100"),
914            conditions,
915        )
916        .await
917    }
918
919    /// What GitHub thinks needs this person, across every repository.
920    pub async fn list_notifications_changed(
921        &self,
922        conditions: &Conditions,
923    ) -> Result<Fetched<Vec<GitHubNotification>>> {
924        self.conditional(
925            "listing notifications",
926            "/notifications?all=false&per_page=50",
927            conditions,
928        )
929        .await
930    }
931
932    /// How much API budget is left.
933    ///
934    /// Never conditional and never counted: GitHub exempts this endpoint from
935    /// the limit it reports, which is the only reason it can be asked when the
936    /// budget is gone — exactly when somebody needs to know.
937    pub async fn rate_limit(&self) -> Result<GitHubRateLimit> {
938        #[derive(Deserialize)]
939        struct Envelope {
940            resources: Resources,
941        }
942        #[derive(Deserialize)]
943        struct Resources {
944            core: GitHubRateLimit,
945        }
946        let envelope: Envelope = require_success(
947            "reading the rate limit",
948            self.client
949                .get(self.url("/rate_limit"))
950                .bearer_auth(&self.token)
951                .send()
952                .await
953                .context("GitHub API request failed")?,
954        )?
955        .json()
956        .await
957        .context("failed to parse the rate limit")?;
958        Ok(envelope.resources.core)
959    }
960
961    /// Views and clones over the last fortnight, which is all GitHub keeps.
962    ///
963    /// Needs push access. A repository somebody only reads answers 403, and
964    /// that is recorded as this scope's error rather than as an empty series —
965    /// zero views and "you may not ask" are different facts.
966    pub async fn traffic(&self, owner: &str, repo: &str) -> Result<GitHubTraffic> {
967        let views: TrafficSeries = require_success(
968            &format!("reading traffic for {owner}/{repo}"),
969            self.client
970                .get(self.url(&format!("/repos/{owner}/{repo}/traffic/views")))
971                .bearer_auth(&self.token)
972                .send()
973                .await
974                .context("GitHub API request failed")?,
975        )?
976        .json()
977        .await
978        .context("failed to parse the view traffic")?;
979
980        let clones: TrafficSeries = require_success(
981            &format!("reading clone traffic for {owner}/{repo}"),
982            self.client
983                .get(self.url(&format!("/repos/{owner}/{repo}/traffic/clones")))
984                .bearer_auth(&self.token)
985                .send()
986                .await
987                .context("GitHub API request failed")?,
988        )?
989        .json()
990        .await
991        .context("failed to parse the clone traffic")?;
992
993        Ok(GitHubTraffic {
994            views: views.count,
995            unique_views: views.uniques,
996            clones: clones.count,
997            unique_clones: clones.uniques,
998            view_days: views.views,
999            clone_days: clones.clones,
1000        })
1001    }
1002}
1003
1004// ---------------------------------------------------------------------------
1005// Projects v2
1006// ---------------------------------------------------------------------------
1007//
1008// GraphQL, because GitHub retired the REST endpoints for it. That makes this
1009// the one part of the mirror that cannot be conditional: GraphQL answers 200
1010// to everything and carries no ETag, so a project is re-read whole each sweep.
1011// It is one request per project per sweep, which is affordable because a
1012// person has a handful of projects and forty repositories.
1013//
1014// A token needs the `project` scope to read any of this. Without it GitHub
1015// answers a permission error, which is recorded as this scope's error — the
1016// board keeps showing what it last held, and says why it is not refreshing.
1017
1018/// The account a token belongs to, and the organisations it is a member of.
1019#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1020pub struct GitHubViewer {
1021    pub login: String,
1022    /// By login, in the order GitHub lists them.
1023    pub organizations: Vec<String>,
1024}
1025
1026/// A project board.
1027#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1028pub struct GitHubProject {
1029    /// The GraphQL node id, which is what every mutation names.
1030    pub id: String,
1031    pub number: i64,
1032    pub title: String,
1033    pub url: String,
1034    pub short_description: Option<String>,
1035    pub closed: bool,
1036    pub updated_at: String,
1037    /// Who it belongs to, as a login. A project is an owner's, not a
1038    /// repository's — which is why the board is its own place in the workbench
1039    /// rather than a section of a repository's page.
1040    pub owner: String,
1041    /// Every field the board defines. The single-select ones are its columns.
1042    pub fields: Vec<GitHubProjectField>,
1043}
1044
1045impl GitHubProject {
1046    /// The field a board is grouped by: its Status, or the first single-select
1047    /// it has.
1048    ///
1049    /// GitHub's own board groups by Status when it exists, and a project that
1050    /// renamed it is still a board with columns. Returning `None` is honest:
1051    /// a project with no single-select field has no columns, and inventing one
1052    /// would put every item in a made-up bucket.
1053    pub fn column_field(&self) -> Option<&GitHubProjectField> {
1054        self.fields
1055            .iter()
1056            .find(|field| field.kind == "single_select" && field.name == "Status")
1057            .or_else(|| {
1058                self.fields
1059                    .iter()
1060                    .find(|field| field.kind == "single_select")
1061            })
1062    }
1063}
1064
1065#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1066pub struct GitHubProjectField {
1067    pub id: String,
1068    pub name: String,
1069    /// `single_select` | `text` | `number` | `date` | `iteration`.
1070    pub kind: String,
1071    /// The choices, for a single-select. Empty for every other kind.
1072    #[serde(default)]
1073    pub options: Vec<GitHubProjectFieldOption>,
1074}
1075
1076#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1077pub struct GitHubProjectFieldOption {
1078    pub id: String,
1079    pub name: String,
1080    /// GitHub's colour name for the option — what makes a board recognisable.
1081    pub color: String,
1082    /// Kept even when empty: updating the option list replaces the whole
1083    /// configuration, so omitting a description would erase one GitHub holds.
1084    ///
1085    /// A column with no description is the ordinary case, and GitHub says so
1086    /// with `null` — not with `""` and not by leaving the field out. As a bare
1087    /// `String` that failed to parse, so a board whose columns had no
1088    /// descriptions could not be read at all: "a project column has no
1089    /// description", about every board anybody makes without typing one.
1090    /// `null` is read as empty. ABSENT is not, and that difference is the
1091    /// whole of it: a key GitHub did not send means this build never asked for
1092    /// it, and reading that as `""` writes an empty description back over one
1093    /// that exists the next time the column list is updated. `default` used to
1094    /// erase exactly that distinction.
1095    #[serde(deserialize_with = "text_or_nothing")]
1096    pub description: String,
1097}
1098
1099/// A string GitHub may answer with `null`, read as empty.
1100///
1101/// `null` is GitHub saying the column has no description, which is the
1102/// ordinary case and the truth. It is NOT the same as the key being absent:
1103/// that means nothing here knows what GitHub holds, and since a refresh writes
1104/// the whole column back, guessing empty erases a real description. Absence is
1105/// refused by there being no `default` on the field.
1106fn text_or_nothing<'de, D>(deserializer: D) -> Result<String, D::Error>
1107where
1108    D: serde::Deserializer<'de>,
1109{
1110    Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_default())
1111}
1112
1113#[derive(Debug, Clone, Serialize)]
1114struct ProjectColumnOptionInput {
1115    #[serde(skip_serializing_if = "Option::is_none")]
1116    id: Option<String>,
1117    name: String,
1118    color: String,
1119    description: String,
1120}
1121
1122impl From<&GitHubProjectFieldOption> for ProjectColumnOptionInput {
1123    fn from(option: &GitHubProjectFieldOption) -> Self {
1124        Self {
1125            id: Some(option.id.clone()),
1126            name: option.name.clone(),
1127            color: option.color.clone(),
1128            description: option.description.clone(),
1129        }
1130    }
1131}
1132
1133/// One card.
1134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1135pub struct GitHubProjectItem {
1136    pub id: String,
1137    /// `ISSUE` | `PULL_REQUEST` | `DRAFT_ISSUE` | `REDACTED`.
1138    pub kind: String,
1139    pub title: String,
1140    /// What the card POINTS AT — the issue, the pull request, or the draft
1141    /// itself. Distinct from the card's own id: a draft is edited by its
1142    /// content id and moved by its item id, and using one for the other is an
1143    /// error GitHub reports as "could not resolve to a node".
1144    pub content_id: Option<String>,
1145    /// The issue or pull request number, absent for a draft item — a draft
1146    /// lives only on the board and has nothing to open.
1147    pub number: Option<i64>,
1148    pub url: Option<String>,
1149    /// `owner/repo`, absent for a draft item.
1150    pub repository: Option<String>,
1151    /// `OPEN` | `CLOSED` | `MERGED`, absent for a draft.
1152    pub state: Option<String>,
1153    /// Why it is closed, when GitHub says: `COMPLETED` | `NOT_PLANNED`. The
1154    /// difference between finished and abandoned is not something a board
1155    /// should make a person open GitHub to learn.
1156    pub state_reason: Option<String>,
1157    /// The description, as markdown. A draft card's body is the whole of it —
1158    /// there is nowhere else to read it.
1159    pub body: Option<String>,
1160    pub author: Option<GitHubUser>,
1161    #[serde(default)]
1162    pub assignees: Vec<GitHubUser>,
1163    #[serde(default)]
1164    pub labels: Vec<GitHubLabel>,
1165    pub milestone: Option<String>,
1166    /// How many comments the conversation has. The comments themselves are
1167    /// read from the repository's own mirror when a card is opened — one
1168    /// mirror per conversation, not one per board.
1169    #[serde(default)]
1170    pub comment_count: i64,
1171    pub created_at: Option<String>,
1172    pub closed_at: Option<String>,
1173    /// Whether the pull request is a draft, and whether it was merged. Absent
1174    /// state on a merged PR reads as "closed", which loses the distinction
1175    /// that matters most about a pull request.
1176    #[serde(default)]
1177    pub is_draft: bool,
1178    #[serde(default)]
1179    pub merged: bool,
1180    /// `APPROVED` | `CHANGES_REQUESTED` | `REVIEW_REQUIRED`, for a pull
1181    /// request that has been looked at.
1182    pub review_decision: Option<String>,
1183    /// Archived on the board. An archived card is still an item and still has
1184    /// its field values; it is simply not shown unless it is asked for.
1185    #[serde(default)]
1186    pub archived: bool,
1187    /// Every field value this item carries, by FIELD id. Single-select values
1188    /// are the option id, so a column change is a value change and the page
1189    /// never has to match on names.
1190    #[serde(default)]
1191    pub field_values: BTreeMap<String, GitHubProjectValue>,
1192    pub updated_at: String,
1193}
1194
1195impl GitHubProjectItem {
1196    /// Which column this item is in, for a given field.
1197    pub fn column(&self, field_id: &str) -> Option<&str> {
1198        match self.field_values.get(field_id)? {
1199            GitHubProjectValue::SingleSelect { option_id, .. } => Some(option_id),
1200            _ => None,
1201        }
1202    }
1203}
1204
1205/// One field's value on one item.
1206/// `PartialEq` without `Eq`: a number field holds a float, and floats have no
1207/// total equality. Comparing two values for "did this change" is exactly what
1208/// `PartialEq` is for here.
1209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1210#[serde(tag = "kind", rename_all = "snake_case")]
1211pub enum GitHubProjectValue {
1212    /// The option id AND its name: the id is what a mutation needs, the name
1213    /// is what a person reads, and looking one up from the other on every
1214    /// render is how a board gets slow.
1215    SingleSelect {
1216        option_id: String,
1217        name: String,
1218    },
1219    Text {
1220        text: String,
1221    },
1222    Number {
1223        number: f64,
1224    },
1225    Date {
1226        date: String,
1227    },
1228    Iteration {
1229        title: String,
1230        start_date: String,
1231    },
1232}
1233
1234/// What a card carries, wherever it is read from.
1235///
1236/// One fragment rather than two queries that drift: the board reads a hundred
1237/// of these and a single card is re-read through the same shape after every
1238/// write, so a field added here appears in both without anybody remembering to
1239/// add it twice.
1240const ITEM_FRAGMENT: &str = r#"
1241fragment Card on ProjectV2Item {
1242  id type updatedAt isArchived
1243  fieldValues(first: 30) {
1244    nodes {
1245      ... on ProjectV2ItemFieldSingleSelectValue { name optionId field { ... on ProjectV2FieldCommon { id } } }
1246      ... on ProjectV2ItemFieldTextValue { text field { ... on ProjectV2FieldCommon { id } } }
1247      ... on ProjectV2ItemFieldNumberValue { number field { ... on ProjectV2FieldCommon { id } } }
1248      ... on ProjectV2ItemFieldDateValue { date field { ... on ProjectV2FieldCommon { id } } }
1249      ... on ProjectV2ItemFieldIterationValue { title startDate field { ... on ProjectV2FieldCommon { id } } }
1250    }
1251  }
1252  content {
1253    ... on DraftIssue {
1254      id title body createdAt updatedAt
1255      creator { login avatarUrl }
1256      assignees(first: 10) { nodes { login avatarUrl } }
1257    }
1258    ... on Issue {
1259      id title body number url state stateReason createdAt closedAt
1260      repository { nameWithOwner }
1261      author { login avatarUrl }
1262      assignees(first: 10) { nodes { login avatarUrl } }
1263      labels(first: 20) { nodes { name color description } }
1264      milestone { title }
1265      comments { totalCount }
1266    }
1267    ... on PullRequest {
1268      id title body number url state createdAt closedAt isDraft merged reviewDecision
1269      repository { nameWithOwner }
1270      author { login avatarUrl }
1271      assignees(first: 10) { nodes { login avatarUrl } }
1272      labels(first: 20) { nodes { name color description } }
1273      milestone { title }
1274      comments { totalCount }
1275    }
1276  }
1277}"#;
1278
1279impl GitHubClient {
1280    /// One GraphQL request.
1281    ///
1282    /// GraphQL answers 200 with an `errors` array rather than a status, so a
1283    /// caller that only checked the status would treat "you may not read this
1284    /// project" as an empty project. That is the failure mode this whole
1285    /// mirror exists to prevent, so errors are raised here.
1286    async fn graphql(
1287        &self,
1288        what: &str,
1289        query: &str,
1290        variables: serde_json::Value,
1291    ) -> Result<serde_json::Value> {
1292        let response = require_success(
1293            what,
1294            self.client
1295                .post(self.url("/graphql"))
1296                .bearer_auth(&self.token)
1297                .json(&serde_json::json!({ "query": query, "variables": variables }))
1298                .send()
1299                .await
1300                .with_context(|| format!("GitHub API request failed while {what}"))?,
1301        )?;
1302        let body: serde_json::Value = response
1303            .json()
1304            .await
1305            .with_context(|| format!("failed to parse the answer to {what}"))?;
1306
1307        if let Some(errors) = body.get("errors").and_then(|errors| errors.as_array()) {
1308            if !errors.is_empty() {
1309                let said = errors
1310                    .iter()
1311                    .filter_map(|error| error.get("message").and_then(|m| m.as_str()))
1312                    .collect::<Vec<_>>()
1313                    .join("; ");
1314                anyhow::bail!("{what}: {said}");
1315            }
1316        }
1317        body.get("data")
1318            .cloned()
1319            .ok_or_else(|| anyhow::anyhow!("{what}: the answer carried no data"))
1320    }
1321
1322    /// Every project one owner has.
1323    ///
1324    /// `login` may be a user or an organisation, and `repositoryOwner` resolves
1325    /// either — which is the point. Asking `organization(login:)` and
1326    /// `user(login:)` in one query looks like it does the same thing and does
1327    /// not: GitHub answers the field that does not match with an error, and a
1328    /// GraphQL answer carrying errors AND data is a failed query as far as
1329    /// anything reading it is concerned. So every owner failed with "could not
1330    /// resolve to an Organization" (for a user) or "to a User" (for an
1331    /// organisation), and no board ever arrived.
1332    ///
1333    /// A login that is neither comes back as a null owner and no error, which
1334    /// is an owner with no boards rather than a failure — and a real failure,
1335    /// a token without `read:project`, still arrives as an error and is still
1336    /// raised.
1337    ///
1338    /// Every page of them. The list is authoritative — a board absent from it
1339    /// is a board that is gone, and its cards are forgotten — so stopping at a
1340    /// first page would delete the boards past it.
1341    pub async fn list_projects(&self, login: &str) -> Result<Vec<GitHubProject>> {
1342        const QUERY: &str = r#"
1343query($login: String!, $after: String) {
1344  repositoryOwner(login: $login) {
1345    __typename
1346    ... on Organization { projectsV2(first: 100, after: $after, orderBy: {field: UPDATED_AT, direction: DESC}) { ...Page } }
1347    ... on User { projectsV2(first: 100, after: $after, orderBy: {field: UPDATED_AT, direction: DESC}) { ...Page } }
1348  }
1349}
1350fragment Page on ProjectV2Connection {
1351  pageInfo { hasNextPage endCursor }
1352  nodes { ...P }
1353}
1354fragment P on ProjectV2 {
1355  id number title url shortDescription closed updatedAt
1356  fields(first: 50) {
1357    nodes {
1358      ... on ProjectV2FieldCommon { id name dataType }
1359      ... on ProjectV2SingleSelectField { id name dataType options { id name color description } }
1360    }
1361  }
1362}"#;
1363        let mut projects = Vec::new();
1364        let mut after: Option<String> = None;
1365        loop {
1366            let data = self
1367                .graphql(
1368                    &format!("listing projects for {login}"),
1369                    QUERY,
1370                    serde_json::json!({ "login": login, "after": after }),
1371                )
1372                .await?;
1373            let page = projects_of_owner(&data, login)?;
1374            projects.extend(page.items);
1375            match page.next {
1376                Some(cursor) => after = Some(cursor),
1377                None => break,
1378            }
1379        }
1380        Ok(projects)
1381    }
1382
1383    /// Who the token belongs to, and every organisation they are a member of.
1384    ///
1385    /// What "all of this person's boards" is made from: a board belongs to a
1386    /// user or an organisation, and the token's own account and its
1387    /// organisations are every owner whose boards this person can reach
1388    /// without anybody having to name them first.
1389    ///
1390    /// Membership the token cannot see — an organisation that hides it from
1391    /// third-party access — is simply absent, as GitHub answers it.
1392    pub async fn viewer(&self) -> Result<GitHubViewer> {
1393        const QUERY: &str = r#"
1394query($after: String) {
1395  viewer {
1396    login
1397    organizations(first: 100, after: $after) {
1398      pageInfo { hasNextPage endCursor }
1399      nodes { login }
1400    }
1401  }
1402}"#;
1403        let mut organizations = Vec::new();
1404        let mut after: Option<String> = None;
1405        let login = loop {
1406            let data = self
1407                .graphql(
1408                    "reading who the token belongs to",
1409                    QUERY,
1410                    serde_json::json!({ "after": after }),
1411                )
1412                .await?;
1413            let page = viewer_page(&data)?;
1414            organizations.extend(page.organizations.items);
1415            match page.organizations.next {
1416                Some(cursor) => after = Some(cursor),
1417                None => break page.login,
1418            }
1419        };
1420        Ok(GitHubViewer { login, organizations })
1421    }
1422
1423    /// One project in the complete shape a board write returns.
1424    async fn project(&self, project_id: &str) -> Result<GitHubProject> {
1425        const QUERY: &str = r#"
1426query($id: ID!) {
1427  node(id: $id) {
1428    ... on ProjectV2 {
1429      id number title url shortDescription closed updatedAt
1430      owner {
1431        ... on Organization { login }
1432        ... on User { login }
1433      }
1434      fields(first: 50) {
1435        nodes {
1436          ... on ProjectV2FieldCommon { id name dataType }
1437          ... on ProjectV2SingleSelectField {
1438            id name dataType options { id name color description }
1439          }
1440        }
1441      }
1442    }
1443  }
1444}"#;
1445        let data = self
1446            .graphql(
1447                &format!("reading project {project_id}"),
1448                QUERY,
1449                serde_json::json!({ "id": project_id }),
1450            )
1451            .await?;
1452        let node = data
1453            .get("node")
1454            .filter(|node| !node.is_null())
1455            .ok_or_else(|| anyhow::anyhow!("project {project_id} no longer exists"))?;
1456        let owner = node
1457            .get("owner")
1458            .and_then(|owner| owner.get("login"))
1459            .and_then(|login| login.as_str())
1460            .ok_or_else(|| anyhow::anyhow!("project {project_id} has no readable owner"))?;
1461        parse_project(node, owner)
1462    }
1463
1464    /// Add one option to the board's column field.
1465    ///
1466    /// The project is read immediately before constructing the replacement
1467    /// list. Sending the browser's possibly stale list would let a second
1468    /// editor's new column disappear. Existing ids, colours and descriptions
1469    /// are carried exactly so GitHub preserves their identity and card values.
1470    pub async fn add_project_column(
1471        &self,
1472        project_id: &str,
1473        field_id: &str,
1474        name: &str,
1475        color: &str,
1476        description: &str,
1477    ) -> Result<GitHubProject> {
1478        validate_column_color(color)?;
1479        let name = name.trim();
1480        if name.is_empty() {
1481            anyhow::bail!("a project column needs a name");
1482        }
1483        let project = self.project(project_id).await?;
1484        let field = project
1485            .fields
1486            .iter()
1487            .find(|field| field.id == field_id)
1488            .ok_or_else(|| anyhow::anyhow!("project {project_id} has no field {field_id}"))?;
1489        if field.kind != "single_select" {
1490            anyhow::bail!(
1491                "field {field_id} is {}, not a single-select column field",
1492                field.kind
1493            );
1494        }
1495        if field
1496            .options
1497            .iter()
1498            .any(|option| option.name.eq_ignore_ascii_case(name))
1499        {
1500            anyhow::bail!("project {project_id} already has a column named '{name}'");
1501        }
1502
1503        let mut options: Vec<ProjectColumnOptionInput> = field
1504            .options
1505            .iter()
1506            .map(ProjectColumnOptionInput::from)
1507            .collect();
1508        options.push(ProjectColumnOptionInput {
1509            id: None,
1510            name: name.to_string(),
1511            color: color.to_string(),
1512            description: description.trim().to_string(),
1513        });
1514        self.update_project_columns(field_id, &options).await?;
1515        self.project(project_id).await
1516    }
1517
1518    /// Remove one option from the board's column field.
1519    ///
1520    /// GitHub clears the field value on cards that used the removed option.
1521    /// The handler consequently re-reads every item after this returns rather
1522    /// than patching those cards locally.
1523    pub async fn remove_project_column(
1524        &self,
1525        project_id: &str,
1526        field_id: &str,
1527        option_id: &str,
1528    ) -> Result<GitHubProject> {
1529        let project = self.project(project_id).await?;
1530        let field = project
1531            .fields
1532            .iter()
1533            .find(|field| field.id == field_id)
1534            .ok_or_else(|| anyhow::anyhow!("project {project_id} has no field {field_id}"))?;
1535        if field.kind != "single_select" {
1536            anyhow::bail!(
1537                "field {field_id} is {}, not a single-select column field",
1538                field.kind
1539            );
1540        }
1541        if !field.options.iter().any(|option| option.id == option_id) {
1542            anyhow::bail!("field {field_id} has no column {option_id}");
1543        }
1544        if field.options.len() == 1 {
1545            anyhow::bail!("the last project column cannot be removed; a single-select field requires an option");
1546        }
1547        let options: Vec<ProjectColumnOptionInput> = field
1548            .options
1549            .iter()
1550            .filter(|option| option.id != option_id)
1551            .map(ProjectColumnOptionInput::from)
1552            .collect();
1553        self.update_project_columns(field_id, &options).await?;
1554        self.project(project_id).await
1555    }
1556
1557    async fn update_project_columns(
1558        &self,
1559        field_id: &str,
1560        options: &[ProjectColumnOptionInput],
1561    ) -> Result<()> {
1562        const MUTATION: &str = r#"
1563mutation($field: ID!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {
1564  updateProjectV2Field(input: { fieldId: $field, singleSelectOptions: $options }) {
1565    projectV2Field { ... on ProjectV2SingleSelectField { id } }
1566  }
1567}"#;
1568        self.graphql(
1569            &format!("updating the columns of field {field_id}"),
1570            MUTATION,
1571            serde_json::json!({ "field": field_id, "options": options }),
1572        )
1573        .await?;
1574        Ok(())
1575    }
1576
1577    /// Every card on one board, following GitHub's pagination to the end.
1578    ///
1579    /// All of them rather than the first page: a board is read as columns, and
1580    /// a column missing its tail is a column with the wrong count in its
1581    /// header — which is worse than a slower sync.
1582    pub async fn project_items(&self, project_id: &str) -> Result<Vec<GitHubProjectItem>> {
1583        let query = format!(
1584            r#"
1585query($id: ID!, $after: String) {{
1586  node(id: $id) {{
1587    ... on ProjectV2 {{
1588      items(first: 50, after: $after) {{
1589        pageInfo {{ hasNextPage endCursor }}
1590        nodes {{ ...Card }}
1591      }}
1592    }}
1593  }}
1594}}
1595{ITEM_FRAGMENT}"#
1596        );
1597        let mut items = Vec::new();
1598        let mut after: Option<String> = None;
1599        loop {
1600            let data = self
1601                .graphql(
1602                    &format!("reading the items of project {project_id}"),
1603                    &query,
1604                    serde_json::json!({ "id": project_id, "after": after }),
1605                )
1606                .await?;
1607            let page = data
1608                .get("node")
1609                .and_then(|node| node.get("items"))
1610                .ok_or_else(|| anyhow::anyhow!("project {project_id} has no items to read"))?;
1611
1612            for node in page
1613                .get("nodes")
1614                .and_then(|nodes| nodes.as_array())
1615                .unwrap_or(&Vec::new())
1616            {
1617                items.push(parse_project_item(node)?);
1618            }
1619
1620            let more = page
1621                .get("pageInfo")
1622                .and_then(|info| info.get("hasNextPage"))
1623                .and_then(|more| more.as_bool())
1624                .unwrap_or(false);
1625            if !more {
1626                break;
1627            }
1628            after = page
1629                .get("pageInfo")
1630                .and_then(|info| info.get("endCursor"))
1631                .and_then(|cursor| cursor.as_str())
1632                .map(str::to_string);
1633            if after.is_none() {
1634                // More pages and no cursor to ask with: GitHub contradicting
1635                // itself. Stopping here would silently truncate the board.
1636                anyhow::bail!("project {project_id} says there is another page and gives no cursor");
1637            }
1638        }
1639        Ok(items)
1640    }
1641
1642    /// One card, re-read.
1643    ///
1644    /// What every write here ends with. The alternative is patching the held
1645    /// payload by hand — which was how a move worked, and which quietly
1646    /// invents the parts of the answer it does not know: GitHub stamps
1647    /// `updatedAt`, may close an item, may have had another person change a
1648    /// field a second earlier. Asking costs one request and is never wrong.
1649    pub async fn project_item(&self, item_id: &str) -> Result<GitHubProjectItem> {
1650        let query = format!(
1651            r#"
1652query($id: ID!) {{ node(id: $id) {{ ...Card }} }}
1653{ITEM_FRAGMENT}"#
1654        );
1655        let data = self
1656            .graphql(
1657                &format!("re-reading item {item_id}"),
1658                &query,
1659                serde_json::json!({ "id": item_id }),
1660            )
1661            .await?;
1662        let node = data
1663            .get("node")
1664            .filter(|node| !node.is_null())
1665            .ok_or_else(|| anyhow::anyhow!("item {item_id} is not on this board any more"))?;
1666        parse_project_item(node)
1667    }
1668
1669    /// Set one field on one card.
1670    ///
1671    /// Every field kind through one door, because they are one act: a person
1672    /// setting a due date and a person dragging a card between columns are
1673    /// both saying "this item's F is now V", and two code paths for that is
1674    /// how one of them ends up not refreshing what the other does.
1675    pub async fn set_project_field(
1676        &self,
1677        project_id: &str,
1678        item_id: &str,
1679        field_id: &str,
1680        value: &ProjectFieldWrite,
1681    ) -> Result<GitHubProjectItem> {
1682        const MUTATION: &str = r#"
1683mutation($project: ID!, $item: ID!, $field: ID!, $value: ProjectV2FieldValue!) {
1684  updateProjectV2ItemFieldValue(input: {
1685    projectId: $project, itemId: $item, fieldId: $field, value: $value
1686  }) { projectV2Item { id } }
1687}"#;
1688        self.graphql(
1689            &format!("setting a field on item {item_id}"),
1690            MUTATION,
1691            serde_json::json!({
1692                "project": project_id,
1693                "item": item_id,
1694                "field": field_id,
1695                "value": value.as_input(),
1696            }),
1697        )
1698        .await?;
1699        self.project_item(item_id).await
1700    }
1701
1702    /// Take a field's value off a card.
1703    ///
1704    /// Distinct from setting it to empty: an empty text value is a value, and
1705    /// a board that could not tell "no due date" from "a due date of nothing"
1706    /// would show a column of blanks where it means to show none.
1707    pub async fn clear_project_field(
1708        &self,
1709        project_id: &str,
1710        item_id: &str,
1711        field_id: &str,
1712    ) -> Result<GitHubProjectItem> {
1713        const MUTATION: &str = r#"
1714mutation($project: ID!, $item: ID!, $field: ID!) {
1715  clearProjectV2ItemFieldValue(input: {
1716    projectId: $project, itemId: $item, fieldId: $field
1717  }) { projectV2Item { id } }
1718}"#;
1719        self.graphql(
1720            &format!("clearing a field on item {item_id}"),
1721            MUTATION,
1722            serde_json::json!({ "project": project_id, "item": item_id, "field": field_id }),
1723        )
1724        .await?;
1725        self.project_item(item_id).await
1726    }
1727
1728    /// Put a card after another one, or first in the board's order.
1729    ///
1730    /// `after` is the card it lands behind; `None` puts it at the top. This is
1731    /// what a drag WITHIN a column means, and without it a board's order is
1732    /// whatever GitHub last decided.
1733    pub async fn move_project_item_after(
1734        &self,
1735        project_id: &str,
1736        item_id: &str,
1737        after: Option<&str>,
1738    ) -> Result<GitHubProjectItem> {
1739        const MUTATION: &str = r#"
1740mutation($project: ID!, $item: ID!, $after: ID) {
1741  updateProjectV2ItemPosition(input: {
1742    projectId: $project, itemId: $item, afterId: $after
1743  }) { items { totalCount } }
1744}"#;
1745        self.graphql(
1746            &format!("ordering item {item_id}"),
1747            MUTATION,
1748            serde_json::json!({ "project": project_id, "item": item_id, "after": after }),
1749        )
1750        .await?;
1751        self.project_item(item_id).await
1752    }
1753
1754    /// Add a card that exists only on the board.
1755    ///
1756    /// A draft is a note with a place in the plan and no repository yet. It is
1757    /// the cheapest way to put something on a board, and refusing to create
1758    /// one is what sends a person to the website mid-thought.
1759    pub async fn add_project_draft(
1760        &self,
1761        project_id: &str,
1762        title: &str,
1763        body: &str,
1764    ) -> Result<GitHubProjectItem> {
1765        const MUTATION: &str = r#"
1766mutation($project: ID!, $title: String!, $body: String!) {
1767  addProjectV2DraftIssue(input: { projectId: $project, title: $title, body: $body }) {
1768    projectItem { id }
1769  }
1770}"#;
1771        let data = self
1772            .graphql(
1773                "adding a draft card",
1774                MUTATION,
1775                serde_json::json!({ "project": project_id, "title": title, "body": body }),
1776            )
1777            .await?;
1778        let id = data
1779            .get("addProjectV2DraftIssue")
1780            .and_then(|added| added.get("projectItem"))
1781            .and_then(|item| item.get("id"))
1782            .and_then(|id| id.as_str())
1783            .ok_or_else(|| {
1784                anyhow::anyhow!("the draft was created and GitHub did not say its id")
1785            })?;
1786        self.project_item(id).await
1787    }
1788
1789    /// Rewrite a draft's title and body.
1790    ///
1791    /// Only a draft: an issue's title belongs to the issue and is edited where
1792    /// issues are, not on a board that happens to show it.
1793    pub async fn update_project_draft(
1794        &self,
1795        item_id: &str,
1796        draft_id: &str,
1797        title: &str,
1798        body: &str,
1799    ) -> Result<GitHubProjectItem> {
1800        const MUTATION: &str = r#"
1801mutation($draft: ID!, $title: String!, $body: String!) {
1802  updateProjectV2DraftIssue(input: { draftIssueId: $draft, title: $title, body: $body }) {
1803    draftIssue { id }
1804  }
1805}"#;
1806        self.graphql(
1807            &format!("editing draft {draft_id}"),
1808            MUTATION,
1809            serde_json::json!({ "draft": draft_id, "title": title, "body": body }),
1810        )
1811        .await?;
1812        self.project_item(item_id).await
1813    }
1814
1815    /// Put an existing issue or pull request on the board, by its URL.
1816    ///
1817    /// The URL is what a person has in hand — it is what GitHub's own "add
1818    /// item" takes, and what is in the clipboard after reading an issue. It is
1819    /// resolved to a node id here rather than asking anybody to find one.
1820    pub async fn add_project_item_by_url(
1821        &self,
1822        project_id: &str,
1823        url: &str,
1824    ) -> Result<GitHubProjectItem> {
1825        const RESOLVE: &str = r#"
1826query($url: URI!) {
1827  resource(url: $url) {
1828    __typename
1829    ... on Issue { id }
1830    ... on PullRequest { id }
1831  }
1832}"#;
1833        let found = self
1834            .graphql(
1835                &format!("looking up {url}"),
1836                RESOLVE,
1837                serde_json::json!({ "url": url }),
1838            )
1839            .await?;
1840        let resource = found
1841            .get("resource")
1842            .filter(|resource| !resource.is_null())
1843            .ok_or_else(|| {
1844                anyhow::anyhow!("{url} is not something on GitHub this token can see")
1845            })?;
1846        let kind = resource
1847            .get("__typename")
1848            .and_then(|kind| kind.as_str())
1849            .unwrap_or("that");
1850        let content = resource.get("id").and_then(|id| id.as_str()).ok_or_else(|| {
1851            anyhow::anyhow!("{url} is a {kind}, and only an issue or a pull request can go on a board")
1852        })?;
1853
1854        const MUTATION: &str = r#"
1855mutation($project: ID!, $content: ID!) {
1856  addProjectV2ItemById(input: { projectId: $project, contentId: $content }) {
1857    item { id }
1858  }
1859}"#;
1860        let data = self
1861            .graphql(
1862                &format!("adding {url} to the board"),
1863                MUTATION,
1864                serde_json::json!({ "project": project_id, "content": content }),
1865            )
1866            .await?;
1867        let id = data
1868            .get("addProjectV2ItemById")
1869            .and_then(|added| added.get("item"))
1870            .and_then(|item| item.get("id"))
1871            .and_then(|id| id.as_str())
1872            .ok_or_else(|| anyhow::anyhow!("the item was added and GitHub did not say its id"))?;
1873        self.project_item(id).await
1874    }
1875
1876    /// Archive a card, or bring it back.
1877    ///
1878    /// Archiving is what a board does instead of deleting: the item keeps its
1879    /// field values and its history, and stops taking up a column. Deleting is
1880    /// separate, and is the one that cannot be undone.
1881    pub async fn archive_project_item(
1882        &self,
1883        project_id: &str,
1884        item_id: &str,
1885        archived: bool,
1886    ) -> Result<GitHubProjectItem> {
1887        const ARCHIVE: &str = r#"
1888mutation($project: ID!, $item: ID!) {
1889  archiveProjectV2Item(input: { projectId: $project, itemId: $item }) { item { id } }
1890}"#;
1891        const UNARCHIVE: &str = r#"
1892mutation($project: ID!, $item: ID!) {
1893  unarchiveProjectV2Item(input: { projectId: $project, itemId: $item }) { item { id } }
1894}"#;
1895        self.graphql(
1896            &format!("archiving item {item_id}"),
1897            if archived { ARCHIVE } else { UNARCHIVE },
1898            serde_json::json!({ "project": project_id, "item": item_id }),
1899        )
1900        .await?;
1901        self.project_item(item_id).await
1902    }
1903
1904    /// Take a card off the board.
1905    ///
1906    /// The issue itself is untouched — this removes the card, which is the
1907    /// only thing a board owns. A draft has nowhere else to be, so deleting
1908    /// one deletes it.
1909    pub async fn delete_project_item(&self, project_id: &str, item_id: &str) -> Result<()> {
1910        const MUTATION: &str = r#"
1911mutation($project: ID!, $item: ID!) {
1912  deleteProjectV2Item(input: { projectId: $project, itemId: $item }) { deletedItemId }
1913}"#;
1914        self.graphql(
1915            &format!("removing item {item_id}"),
1916            MUTATION,
1917            serde_json::json!({ "project": project_id, "item": item_id }),
1918        )
1919        .await?;
1920        Ok(())
1921    }
1922}
1923
1924/// A value being written to a field.
1925///
1926/// The kinds GitHub takes, named. A write that carried a bare string would
1927/// have to guess which kind the field is, and guessing wrong sets a date field
1928/// to the text "2026-08-19" — which GitHub accepts and nothing shows.
1929#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
1930#[serde(tag = "kind", rename_all = "snake_case")]
1931pub enum ProjectFieldWrite {
1932    SingleSelect {
1933        option_id: String,
1934    },
1935    Text {
1936        text: String,
1937    },
1938    Number {
1939        number: f64,
1940    },
1941    /// `YYYY-MM-DD`, which is what GitHub's `Date` scalar takes.
1942    Date {
1943        date: String,
1944    },
1945    Iteration {
1946        iteration_id: String,
1947    },
1948}
1949
1950impl ProjectFieldWrite {
1951    /// The `ProjectV2FieldValue` input this is, as GitHub spells it.
1952    fn as_input(&self) -> serde_json::Value {
1953        match self {
1954            Self::SingleSelect { option_id } => {
1955                serde_json::json!({ "singleSelectOptionId": option_id })
1956            }
1957            Self::Text { text } => serde_json::json!({ "text": text }),
1958            Self::Number { number } => serde_json::json!({ "number": number }),
1959            Self::Date { date } => serde_json::json!({ "date": date }),
1960            Self::Iteration { iteration_id } => {
1961                serde_json::json!({ "iterationId": iteration_id })
1962            }
1963        }
1964    }
1965}
1966
1967/// One page of a GraphQL connection: what it held, and the cursor of the next
1968/// page when there is one.
1969#[derive(Debug, Clone, PartialEq)]
1970struct Page<T> {
1971    items: Vec<T>,
1972    next: Option<String>,
1973}
1974
1975/// Where a connection continues, from its `pageInfo`.
1976///
1977/// A connection that says there is another page and gives no cursor is GitHub
1978/// contradicting itself. Stopping there would silently truncate a list this
1979/// mirror treats as authoritative, so it is raised.
1980fn next_page(connection: &serde_json::Value, what: &str) -> Result<Option<String>> {
1981    let info = connection.get("pageInfo");
1982    let more = info
1983        .and_then(|info| info.get("hasNextPage"))
1984        .and_then(|more| more.as_bool())
1985        .unwrap_or(false);
1986    if !more {
1987        return Ok(None);
1988    }
1989    info.and_then(|info| info.get("endCursor"))
1990        .and_then(|cursor| cursor.as_str())
1991        .map(|cursor| Some(cursor.to_string()))
1992        .ok_or_else(|| anyhow::anyhow!("{what} says there is another page and gives no cursor"))
1993}
1994
1995/// The boards in an answer to the owner query.
1996///
1997/// Split out from the request so the shape GitHub actually returns is
1998/// something a test can hold: an owner with boards, an owner with none, and a
1999/// login that is nobody all come back through here, and only the first is a
2000/// board.
2001fn projects_of_owner(data: &serde_json::Value, login: &str) -> Result<Page<GitHubProject>> {
2002    let nothing = Page { items: Vec::new(), next: None };
2003    // A login that resolves to nobody. Not an error: an owner this workspace
2004    // has repositories under may simply not exist as an account any more, and
2005    // a sweep that failed over it would stop mirroring the owners that do.
2006    let Some(owner) = data.get("repositoryOwner").filter(|owner| !owner.is_null()) else {
2007        return Ok(nothing);
2008    };
2009    // An owner kind with no boards at all — `projectsV2` is absent when the
2010    // owner is neither a User nor an Organization, which is a shape GitHub can
2011    // grow without this breaking.
2012    let Some(connection) = owner.get("projectsV2") else {
2013        return Ok(nothing);
2014    };
2015    let Some(nodes) = connection.get("nodes").and_then(|nodes| nodes.as_array()) else {
2016        return Ok(nothing);
2017    };
2018    Ok(Page {
2019        items: nodes
2020            .iter()
2021            .map(|node| parse_project(node, login))
2022            .collect::<Result<_>>()?,
2023        next: next_page(connection, &format!("{login}'s board list"))?,
2024    })
2025}
2026
2027/// One page of the viewer query.
2028#[derive(Debug, Clone, PartialEq)]
2029struct ViewerPage {
2030    login: String,
2031    organizations: Page<String>,
2032}
2033
2034/// The viewer in an answer to the viewer query.
2035///
2036/// A token always belongs to somebody, so an answer with no viewer, or a
2037/// viewer with no login, is a broken answer rather than an account with no
2038/// organisations.
2039fn viewer_page(data: &serde_json::Value) -> Result<ViewerPage> {
2040    let viewer = data
2041        .get("viewer")
2042        .filter(|viewer| !viewer.is_null())
2043        .ok_or_else(|| anyhow::anyhow!("GitHub did not say who the token belongs to"))?;
2044    let login = string_at(viewer, "login", "the token's account")?;
2045    let connection = viewer
2046        .get("organizations")
2047        .ok_or_else(|| anyhow::anyhow!("GitHub did not list {login}'s organisations"))?;
2048    let organizations = connection
2049        .get("nodes")
2050        .and_then(|nodes| nodes.as_array())
2051        .ok_or_else(|| anyhow::anyhow!("{login}'s organisations are not a list"))?
2052        .iter()
2053        .map(|node| string_at(node, "login", "an organisation"))
2054        .collect::<Result<Vec<_>>>()?;
2055    Ok(ViewerPage {
2056        organizations: Page {
2057            items: organizations,
2058            next: next_page(connection, &format!("{login}'s organisation list"))?,
2059        },
2060        login,
2061    })
2062}
2063
2064fn validate_column_color(color: &str) -> Result<()> {
2065    const COLORS: &[&str] = &[
2066        "GRAY", "BLUE", "GREEN", "YELLOW", "ORANGE", "RED", "PINK", "PURPLE",
2067    ];
2068    if COLORS.contains(&color) {
2069        return Ok(());
2070    }
2071    anyhow::bail!(
2072        "'{color}' is not a GitHub project column color; expected {}",
2073        COLORS.join(", ")
2074    )
2075}
2076
2077fn parse_project(node: &serde_json::Value, login: &str) -> Result<GitHubProject> {
2078    let field_nodes = node
2079        .get("fields")
2080        .and_then(|fields| fields.get("nodes"))
2081        .and_then(|nodes| nodes.as_array())
2082        .ok_or_else(|| anyhow::anyhow!("a project has no field list"))?;
2083    let fields = field_nodes
2084        .iter()
2085        .map(|field| -> Result<GitHubProjectField> {
2086            let options = match field.get("options") {
2087                None => Vec::new(),
2088                Some(options) => options
2089                    .as_array()
2090                    .ok_or_else(|| anyhow::anyhow!("a project field's options are not a list"))?
2091                    .iter()
2092                    .map(|option| -> Result<GitHubProjectFieldOption> {
2093                        Ok(GitHubProjectFieldOption {
2094                            id: string_at(option, "id", "a project column")?,
2095                            name: string_at(option, "name", "a project column")?,
2096                            color: string_at(option, "color", "a project column")?,
2097                            description: optional_string_at(option, "description", "a project column")?,
2098                        })
2099                    })
2100                    .collect::<Result<Vec<_>>>()?,
2101            };
2102            Ok(GitHubProjectField {
2103                id: string_at(field, "id", "a project field")?,
2104                name: string_at(field, "name", "a project field")?,
2105                kind: string_at(field, "dataType", "a project field")?.to_ascii_lowercase(),
2106                options,
2107            })
2108        })
2109        .collect::<Result<Vec<_>>>()?;
2110
2111    Ok(GitHubProject {
2112        id: string_at(node, "id", "a project")?,
2113        number: node
2114            .get("number")
2115            .and_then(|number| number.as_i64())
2116            .ok_or_else(|| anyhow::anyhow!("a project has no number"))?,
2117        title: string_at(node, "title", "a project")?,
2118        url: string_at(node, "url", "a project")?,
2119        short_description: node
2120            .get("shortDescription")
2121            .and_then(|text| text.as_str())
2122            .map(str::to_string),
2123        closed: node
2124            .get("closed")
2125            .and_then(|closed| closed.as_bool())
2126            .unwrap_or(false),
2127        updated_at: string_at(node, "updatedAt", "a project")?,
2128        owner: login.to_string(),
2129        fields,
2130    })
2131}
2132
2133fn parse_project_item(node: &serde_json::Value) -> Result<GitHubProjectItem> {
2134    let content = node.get("content");
2135    let mut values = BTreeMap::new();
2136    if let Some(nodes) = node
2137        .get("fieldValues")
2138        .and_then(|values| values.get("nodes"))
2139        .and_then(|nodes| nodes.as_array())
2140    {
2141        for value in nodes {
2142            // A field value with no field is GitHub's answer for a value the
2143            // token may not read. Skipped rather than guessed at.
2144            let Some(field_id) = value
2145                .get("field")
2146                .and_then(|field| field.get("id"))
2147                .and_then(|id| id.as_str())
2148            else {
2149                continue;
2150            };
2151            let parsed = if let (Some(option), Some(name)) = (
2152                value.get("optionId").and_then(|id| id.as_str()),
2153                value.get("name").and_then(|name| name.as_str()),
2154            ) {
2155                GitHubProjectValue::SingleSelect {
2156                    option_id: option.to_string(),
2157                    name: name.to_string(),
2158                }
2159            } else if let Some(text) = value.get("text").and_then(|text| text.as_str()) {
2160                GitHubProjectValue::Text {
2161                    text: text.to_string(),
2162                }
2163            } else if let Some(number) = value.get("number").and_then(|number| number.as_f64()) {
2164                GitHubProjectValue::Number { number }
2165            } else if let Some(date) = value.get("date").and_then(|date| date.as_str()) {
2166                GitHubProjectValue::Date {
2167                    date: date.to_string(),
2168                }
2169            } else if let (Some(title), Some(start)) = (
2170                value.get("title").and_then(|title| title.as_str()),
2171                value.get("startDate").and_then(|date| date.as_str()),
2172            ) {
2173                GitHubProjectValue::Iteration {
2174                    title: title.to_string(),
2175                    start_date: start.to_string(),
2176                }
2177            } else {
2178                continue;
2179            };
2180            values.insert(field_id.to_string(), parsed);
2181        }
2182    }
2183
2184    Ok(GitHubProjectItem {
2185        id: string_at(node, "id", "a project item")?,
2186        kind: string_at(node, "type", "a project item")?,
2187        title: content
2188            .and_then(|content| content.get("title"))
2189            .and_then(|title| title.as_str())
2190            .unwrap_or("(untitled)")
2191            .to_string(),
2192        content_id: text(content, "id"),
2193        number: content
2194            .and_then(|content| content.get("number"))
2195            .and_then(|number| number.as_i64()),
2196        url: content
2197            .and_then(|content| content.get("url"))
2198            .and_then(|url| url.as_str())
2199            .map(str::to_string),
2200        repository: content
2201            .and_then(|content| content.get("repository"))
2202            .and_then(|repo| repo.get("nameWithOwner"))
2203            .and_then(|name| name.as_str())
2204            .map(str::to_string),
2205        state: content
2206            .and_then(|content| content.get("state"))
2207            .and_then(|state| state.as_str())
2208            .map(str::to_string),
2209        state_reason: text(content, "stateReason"),
2210        body: text(content, "body"),
2211        // A draft card has a creator where an issue has an author. Both are
2212        // "who put this here", and a card that showed one and not the other
2213        // would look like nobody made it.
2214        author: content
2215            .and_then(|content| content.get("author").or_else(|| content.get("creator")))
2216            .and_then(parse_user),
2217        assignees: people(content, "assignees"),
2218        labels: content
2219            .and_then(|content| content.get("labels"))
2220            .and_then(|labels| labels.get("nodes"))
2221            .and_then(|nodes| nodes.as_array())
2222            .map(|nodes| {
2223                nodes
2224                    .iter()
2225                    .filter_map(|label| {
2226                        Some(GitHubLabel {
2227                            name: label.get("name")?.as_str()?.to_string(),
2228                            color: label
2229                                .get("color")
2230                                .and_then(|color| color.as_str())
2231                                .unwrap_or_default()
2232                                .to_string(),
2233                            description: label
2234                                .get("description")
2235                                .and_then(|text| text.as_str())
2236                                .map(str::to_string),
2237                        })
2238                    })
2239                    .collect()
2240            })
2241            .unwrap_or_default(),
2242        milestone: content
2243            .and_then(|content| content.get("milestone"))
2244            .and_then(|milestone| milestone.get("title"))
2245            .and_then(|title| title.as_str())
2246            .map(str::to_string),
2247        comment_count: content
2248            .and_then(|content| content.get("comments"))
2249            .and_then(|comments| comments.get("totalCount"))
2250            .and_then(|count| count.as_i64())
2251            .unwrap_or(0),
2252        created_at: text(content, "createdAt"),
2253        closed_at: text(content, "closedAt"),
2254        is_draft: flag(content, "isDraft"),
2255        merged: flag(content, "merged"),
2256        review_decision: text(content, "reviewDecision"),
2257        archived: node
2258            .get("isArchived")
2259            .and_then(|archived| archived.as_bool())
2260            .unwrap_or(false),
2261        field_values: values,
2262        updated_at: string_at(node, "updatedAt", "a project item")?,
2263    })
2264}
2265
2266/// A string under the content, when the content is a kind that has one.
2267///
2268/// Every one of these is absent for some card kind — a draft has no state, an
2269/// issue has no review decision — so absence is the ordinary answer and never
2270/// a parse failure.
2271fn text(content: Option<&serde_json::Value>, key: &str) -> Option<String> {
2272    content
2273        .and_then(|content| content.get(key))
2274        .and_then(|value| value.as_str())
2275        .map(str::to_string)
2276}
2277
2278fn flag(content: Option<&serde_json::Value>, key: &str) -> bool {
2279    content
2280        .and_then(|content| content.get(key))
2281        .and_then(|value| value.as_bool())
2282        .unwrap_or(false)
2283}
2284
2285fn parse_user(node: &serde_json::Value) -> Option<GitHubUser> {
2286    Some(GitHubUser {
2287        login: node.get("login")?.as_str()?.to_string(),
2288        avatar_url: node
2289            .get("avatarUrl")
2290            .and_then(|url| url.as_str())
2291            .map(str::to_string),
2292    })
2293}
2294
2295fn people(content: Option<&serde_json::Value>, key: &str) -> Vec<GitHubUser> {
2296    content
2297        .and_then(|content| content.get(key))
2298        .and_then(|people| people.get("nodes"))
2299        .and_then(|nodes| nodes.as_array())
2300        .map(|nodes| nodes.iter().filter_map(parse_user).collect())
2301        .unwrap_or_default()
2302}
2303
2304fn string_at(node: &serde_json::Value, key: &str, what: &str) -> Result<String> {
2305    node.get(key)
2306        .and_then(|value| value.as_str())
2307        .map(str::to_string)
2308        .ok_or_else(|| anyhow::anyhow!("{what} has no {key}"))
2309}
2310
2311/// A string a caller may legitimately have left unset, when the answer says so.
2312///
2313/// `null` and MISSING are not the same thing here, and treating them alike
2314/// broke this in both directions. A board column's description is the case:
2315/// nobody types one, GitHub answers `"description": null`, and reading that as
2316/// a missing field made every ordinary board unreadable — "a project column
2317/// has no description", about something the person never filled in.
2318///
2319/// The other direction costs more. A key that is not in the answer at all
2320/// means this query did not ask for it, so nothing here knows what GitHub
2321/// holds. A refresh writes the whole column back, so reading absence as `""`
2322/// erases a description that exists, on the next edit, without anybody
2323/// touching it. That is refused.
2324fn optional_string_at(node: &serde_json::Value, key: &str, what: &str) -> Result<String> {
2325    match node.get(key) {
2326        Some(serde_json::Value::Null) => Ok(String::new()),
2327        Some(serde_json::Value::String(text)) => Ok(text.clone()),
2328        Some(other) => Err(anyhow::anyhow!(
2329            "{what} has a {key} that is not text: {other}"
2330        )),
2331        None => Err(anyhow::anyhow!(
2332            "{what} came back without a {key}, so this cannot say what GitHub \
2333             holds for it — and a refresh writes the whole column back, which \
2334             would erase it. Refresh Projects to read them again."
2335        )),
2336    }
2337}
2338
2339/// One header, as text, when it is there and readable.
2340fn header_value(response: &reqwest::Response, name: reqwest::header::HeaderName) -> Option<String> {
2341    response
2342        .headers()
2343        .get(name)
2344        .and_then(|value| value.to_str().ok())
2345        .map(str::to_string)
2346}
2347
2348impl GitHubClient {
2349    /// List issues for a repository.
2350    pub async fn list_issues(
2351        &self,
2352        owner: &str,
2353        repo: &str,
2354        state: &str,
2355    ) -> Result<Vec<GitHubIssue>> {
2356        let url = self.url(&format!(
2357            "/repos/{}/{}/issues?state={}&per_page=100&sort=updated&direction=desc",
2358            owner, repo, state
2359        ));
2360        let response = require_success(
2361            &format!("GitHub API error listing issues for {}/{}", owner, repo),
2362            self.client
2363                .get(&url)
2364                .bearer_auth(&self.token)
2365                .send()
2366                .await
2367                .with_context(|| format!("failed to list issues for {}/{}", owner, repo))?,
2368        )?;
2369        let resp: Vec<GitHubIssue> = response
2370            .json()
2371            .await
2372            .context("failed to parse issues response")?;
2373
2374        // Filter out pull requests (GitHub's issues API returns PRs too)
2375        Ok(resp
2376            .into_iter()
2377            .filter(|i| !i.html_url.contains("/pull/"))
2378            .collect())
2379    }
2380
2381    /// Get a single issue.
2382    pub async fn get_issue(&self, owner: &str, repo: &str, number: i64) -> Result<GitHubIssue> {
2383        let url = self.url(&format!("/repos/{}/{}/issues/{}", owner, repo, number));
2384        let issue: GitHubIssue = require_success(
2385            &format!("failed to get issue {}/{}#{}", owner, repo, number),
2386            self.client
2387                .get(&url)
2388                .bearer_auth(&self.token)
2389                .send()
2390                .await?,
2391        )?
2392        .json()
2393        .await?;
2394        Ok(issue)
2395    }
2396
2397    /// Create an issue.
2398    pub async fn create_issue(
2399        &self,
2400        owner: &str,
2401        repo: &str,
2402        req: &CreateIssueRequest,
2403    ) -> Result<GitHubIssue> {
2404        let url = self.url(&format!("/repos/{}/{}/issues", owner, repo));
2405        let issue: GitHubIssue = require_success(
2406            &format!("failed to create issue in {}/{}", owner, repo),
2407            self.client
2408                .post(&url)
2409                .bearer_auth(&self.token)
2410                .json(req)
2411                .send()
2412                .await?,
2413        )?
2414        .json()
2415        .await?;
2416        Ok(issue)
2417    }
2418
2419    /// Update an issue (close, reopen, edit title/body/labels).
2420    pub async fn update_issue(
2421        &self,
2422        owner: &str,
2423        repo: &str,
2424        number: i64,
2425        req: &UpdateIssueRequest,
2426    ) -> Result<GitHubIssue> {
2427        let url = self.url(&format!("/repos/{}/{}/issues/{}", owner, repo, number));
2428        let issue: GitHubIssue = require_success(
2429            &format!("failed to update issue {}/{}#{}", owner, repo, number),
2430            self.client
2431                .patch(&url)
2432                .bearer_auth(&self.token)
2433                .json(req)
2434                .send()
2435                .await?,
2436        )?
2437        .json()
2438        .await?;
2439        Ok(issue)
2440    }
2441
2442    /// List comments on an issue.
2443    pub async fn list_comments(
2444        &self,
2445        owner: &str,
2446        repo: &str,
2447        number: i64,
2448    ) -> Result<Vec<GitHubComment>> {
2449        let url = self.url(&format!(
2450            "/repos/{}/{}/issues/{}/comments?per_page=100",
2451            owner, repo, number
2452        ));
2453        let comments: Vec<GitHubComment> = require_success(
2454            &format!("failed to list comments for {}/{}#{}", owner, repo, number),
2455            self.client
2456                .get(&url)
2457                .bearer_auth(&self.token)
2458                .send()
2459                .await?,
2460        )?
2461        .json()
2462        .await?;
2463        Ok(comments)
2464    }
2465
2466    /// Add a comment to an issue.
2467    pub async fn add_comment(
2468        &self,
2469        owner: &str,
2470        repo: &str,
2471        number: i64,
2472        body: &str,
2473    ) -> Result<GitHubComment> {
2474        let url = self.url(&format!("/repos/{}/{}/issues/{}/comments", owner, repo, number));
2475        let comment: GitHubComment = require_success(
2476            &format!("failed to add comment to {}/{}#{}", owner, repo, number),
2477            self.client
2478                .post(&url)
2479                .bearer_auth(&self.token)
2480                .json(&CreateCommentRequest {
2481                    body: body.to_string(),
2482                })
2483                .send()
2484                .await?,
2485        )?
2486        .json()
2487        .await?;
2488        Ok(comment)
2489    }
2490
2491    /// List published gists for the authenticated user.
2492    pub async fn list_gists(&self) -> Result<Vec<GitHubGist>> {
2493        let response = require_success(
2494            "failed to list gists",
2495            self.client
2496                .get(self.url("/gists?per_page=100"))
2497                .bearer_auth(&self.token)
2498                .send()
2499                .await?,
2500        )?;
2501        let gists: Vec<GitHubGist> = response
2502            .json()
2503            .await
2504            .context("failed to parse gists response")?;
2505        Ok(gists)
2506    }
2507
2508    /// Get a single gist including file contents.
2509    pub async fn get_gist(&self, gist_id: &str) -> Result<GitHubGist> {
2510        let url = self.url(&format!("/gists/{}", gist_id));
2511        let gist: GitHubGist = require_success(
2512            &format!("failed to get gist {}", gist_id),
2513            self.client
2514                .get(&url)
2515                .bearer_auth(&self.token)
2516                .send()
2517                .await?,
2518        )?
2519        .json()
2520        .await
2521        .context("failed to parse gist response")?;
2522        Ok(gist)
2523    }
2524
2525    /// Create a gist.
2526    pub async fn create_gist(&self, req: &CreateGistRequest) -> Result<GitHubGist> {
2527        let gist: GitHubGist = require_success(
2528            "failed to create gist",
2529            self.client
2530                .post(&self.url("/gists"))
2531                .bearer_auth(&self.token)
2532                .json(req)
2533                .send()
2534                .await?,
2535        )?
2536        .json()
2537        .await
2538        .context("failed to parse create gist response")?;
2539        Ok(gist)
2540    }
2541
2542    /// Update a gist.
2543    pub async fn update_gist(&self, gist_id: &str, req: &UpdateGistRequest) -> Result<GitHubGist> {
2544        let url = self.url(&format!("/gists/{}", gist_id));
2545        let gist: GitHubGist = require_success(
2546            &format!("failed to update gist {}", gist_id),
2547            self.client
2548                .patch(&url)
2549                .bearer_auth(&self.token)
2550                .json(req)
2551                .send()
2552                .await?,
2553        )?
2554        .json()
2555        .await
2556        .context("failed to parse update gist response")?;
2557        Ok(gist)
2558    }
2559}
2560
2561/// Parse a GitHub URL into (owner, repo).
2562/// Handles: https://github.com/owner/repo[.git]
2563///          git@github.com:owner/repo[.git]
2564///          ssh://git@github.com/owner/repo[.git]
2565pub fn parse_github_url(url: &str) -> Option<(String, String)> {
2566    let url = url.trim();
2567
2568    // HTTPS format
2569    if let Some(rest) = url
2570        .strip_prefix("https://github.com/")
2571        .or_else(|| url.strip_prefix("http://github.com/"))
2572    {
2573        return parse_owner_repo(rest);
2574    }
2575
2576    // SSH formats: scp-like, and the URL form git writes for `ssh://`.
2577    if let Some(rest) = url
2578        .strip_prefix("git@github.com:")
2579        .or_else(|| url.strip_prefix("ssh://git@github.com/"))
2580    {
2581        return parse_owner_repo(rest);
2582    }
2583
2584    None
2585}
2586
2587fn parse_owner_repo(path: &str) -> Option<(String, String)> {
2588    let path = path.trim_end_matches(".git").trim_end_matches('/');
2589    let parts: Vec<&str> = path.splitn(3, '/').collect();
2590    if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
2591        Some((parts[0].to_string(), parts[1].to_string()))
2592    } else {
2593        None
2594    }
2595}
2596
2597#[cfg(test)]
2598mod tests {
2599    use super::*;
2600
2601    #[test]
2602    fn test_parse_github_url_https() {
2603        assert_eq!(
2604            parse_github_url("https://github.com/wesm/agentsview"),
2605            Some(("wesm".to_string(), "agentsview".to_string()))
2606        );
2607    }
2608
2609    #[test]
2610    fn test_parse_github_url_https_with_git_suffix() {
2611        assert_eq!(
2612            parse_github_url("https://github.com/wesm/agentsview.git"),
2613            Some(("wesm".to_string(), "agentsview".to_string()))
2614        );
2615    }
2616
2617    #[test]
2618    fn test_parse_github_url_ssh() {
2619        assert_eq!(
2620            parse_github_url("git@github.com:wesm/agentsview.git"),
2621            Some(("wesm".to_string(), "agentsview".to_string()))
2622        );
2623    }
2624
2625    /// The URL form of SSH is how `git clone ssh://…` records a remote, and a
2626    /// repository cloned that way is still on GitHub.
2627    #[test]
2628    fn test_parse_github_url_ssh_url_form() {
2629        assert_eq!(
2630            parse_github_url("ssh://git@github.com/wesm/agentsview.git"),
2631            Some(("wesm".to_string(), "agentsview".to_string()))
2632        );
2633    }
2634
2635    #[test]
2636    fn test_parse_github_url_non_github() {
2637        assert_eq!(parse_github_url("https://gitlab.com/user/repo"), None);
2638    }
2639
2640    #[test]
2641    fn test_parse_github_url_invalid() {
2642        assert_eq!(parse_github_url("not a url"), None);
2643    }
2644
2645    /// One board, as GitHub returns it under `repositoryOwner`.
2646    fn owner_with_a_board(kind: &str) -> serde_json::Value {
2647        serde_json::json!({
2648            "repositoryOwner": {
2649                "__typename": kind,
2650                "projectsV2": { "nodes": [{
2651                    "id": "PVT_1",
2652                    "number": 7,
2653                    "title": "Roadmap",
2654                    "url": "https://github.com/orgs/machinefabric/projects/7",
2655                    "shortDescription": null,
2656                    "closed": false,
2657                    "updatedAt": "2026-08-19T09:00:00Z",
2658                    "fields": { "nodes": [] }
2659                }]}
2660            }
2661        })
2662    }
2663
2664    /// TEST10074: a board is read whether its owner is a user or an
2665    /// organisation.
2666    ///
2667    /// The query asked `organization(login:)` and `user(login:)` in one go and
2668    /// took whichever came back. It looks equivalent to asking for the owner
2669    /// and is not: GitHub answers the field that does not match with an ERROR,
2670    /// and an answer carrying errors is a failed query — so every owner failed
2671    /// with "could not resolve to an Organization" or "to a User", and the
2672    /// Projects tab was empty on a workspace whose owners both had boards.
2673    #[test]
2674    fn test10074_a_board_is_read_for_either_kind_of_owner() {
2675        for kind in ["Organization", "User"] {
2676            let boards = projects_of_owner(&owner_with_a_board(kind), "machinefabric")
2677                .expect("the answer parses")
2678                .items;
2679            assert_eq!(boards.len(), 1, "no board came back for a {kind} owner");
2680            assert_eq!(boards[0].number, 7);
2681            assert_eq!(boards[0].title, "Roadmap");
2682            assert_eq!(boards[0].owner, "machinefabric");
2683        }
2684    }
2685
2686    /// TEST10075: an owner who is nobody has no boards, and is not a failure.
2687    ///
2688    /// `repositoryOwner` answers null rather than erroring for a login that
2689    /// does not resolve. That is an owner with nothing to mirror — an account
2690    /// that was renamed or closed — and treating it as a failure would stop
2691    /// the sweep over the owners that do exist. A REAL failure, a token
2692    /// without `read:project`, arrives as a GraphQL error instead and is
2693    /// raised before this is ever reached.
2694    #[test]
2695    fn test10075_an_owner_who_does_not_resolve_has_no_boards() {
2696        let nobody = serde_json::json!({ "repositoryOwner": serde_json::Value::Null });
2697        assert!(projects_of_owner(&nobody, "gone")
2698            .expect("a null owner is an answer")
2699            .items
2700            .is_empty());
2701
2702        // An owner kind that carries no boards field at all — a shape GitHub
2703        // can grow — is the same thing and not a parse failure.
2704        let unknown = serde_json::json!({
2705            "repositoryOwner": { "__typename": "Mannequin" }
2706        });
2707        assert!(projects_of_owner(&unknown, "someone")
2708            .expect("an unknown owner kind is an answer")
2709            .items
2710            .is_empty());
2711    }
2712
2713    /// TEST20001: a board list says where it continues, and a list that
2714    /// claims more without saying where is refused.
2715    ///
2716    /// The list was one page of twenty. It is authoritative — a board absent
2717    /// from it is forgotten with its cards — so an owner with a twenty-first
2718    /// board lost it on every sweep.
2719    #[test]
2720    fn test20001_a_board_list_continues_or_is_refused() {
2721        let mut answer = owner_with_a_board("Organization");
2722        answer["repositoryOwner"]["projectsV2"]["pageInfo"] =
2723            serde_json::json!({ "hasNextPage": true, "endCursor": "Y3Vyc29y" });
2724        let page = projects_of_owner(&answer, "machinefabric").expect("the answer parses");
2725        assert_eq!(page.items.len(), 1);
2726        assert_eq!(page.next.as_deref(), Some("Y3Vyc29y"), "the next page was not carried");
2727
2728        answer["repositoryOwner"]["projectsV2"]["pageInfo"] =
2729            serde_json::json!({ "hasNextPage": false, "endCursor": "Y3Vyc29y" });
2730        assert_eq!(
2731            projects_of_owner(&answer, "machinefabric").expect("parses").next,
2732            None,
2733            "the last page named a next one"
2734        );
2735
2736        answer["repositoryOwner"]["projectsV2"]["pageInfo"] =
2737            serde_json::json!({ "hasNextPage": true, "endCursor": null });
2738        let said = format!(
2739            "{:#}",
2740            projects_of_owner(&answer, "machinefabric").expect_err("a contradiction is refused")
2741        );
2742        assert!(said.contains("machinefabric's board list"), "{said}");
2743    }
2744
2745    /// TEST20002: the viewer answer is the token's login and every
2746    /// organisation on the page, and a missing viewer is refused.
2747    ///
2748    /// These are the owners whose boards are shown without anybody naming
2749    /// them, so an answer read as "nobody, in no organisations" would show no
2750    /// boards at all and look like a person who has none.
2751    #[test]
2752    fn test20002_the_viewer_is_a_login_and_its_organisations() {
2753        let answer = serde_json::json!({ "viewer": {
2754            "login": "bahram",
2755            "organizations": {
2756                "pageInfo": { "hasNextPage": true, "endCursor": "T1JH" },
2757                "nodes": [{ "login": "machinefabric" }, { "login": "floom" }]
2758            }
2759        }});
2760        let page = viewer_page(&answer).expect("the answer parses");
2761        assert_eq!(page.login, "bahram");
2762        assert_eq!(page.organizations.items, vec!["machinefabric".to_string(), "floom".to_string()]);
2763        assert_eq!(page.organizations.next.as_deref(), Some("T1JH"));
2764
2765        let nobody = serde_json::json!({ "viewer": serde_json::Value::Null });
2766        assert!(viewer_page(&nobody).is_err(), "a token with no account was read as one");
2767        let unlisted = serde_json::json!({ "viewer": { "login": "bahram" } });
2768        assert!(
2769            viewer_page(&unlisted).is_err(),
2770            "an answer without the organisation list was read as no organisations"
2771        );
2772    }
2773
2774    /**
2775     * TEST10196: replacing a single-select option list must preserve every
2776     * existing option's identity, colour and description. GitHub clears card
2777     * values when an existing id is omitted, so a partial input is data loss,
2778     * not a cosmetic update.
2779     */
2780    #[test]
2781    fn test10196_a_column_update_preserves_the_complete_existing_options() {
2782        let option = GitHubProjectFieldOption {
2783            id: "OPT_TODO".to_string(),
2784            name: "Todo".to_string(),
2785            color: "GRAY".to_string(),
2786            description: "not started".to_string(),
2787        };
2788        let input = serde_json::to_value(ProjectColumnOptionInput::from(&option))
2789            .expect("the GraphQL input serializes");
2790        assert_eq!(input["id"], "OPT_TODO");
2791        assert_eq!(input["name"], "Todo");
2792        assert_eq!(input["color"], "GRAY");
2793        assert_eq!(input["description"], "not started");
2794
2795        let new_option = ProjectColumnOptionInput {
2796            id: None,
2797            name: "Blocked".to_string(),
2798            color: "RED".to_string(),
2799            description: String::new(),
2800        };
2801        let new_input = serde_json::to_value(new_option).expect("new option serializes");
2802        assert!(
2803            new_input.get("id").is_none(),
2804            "GitHub assigns a new option id"
2805        );
2806        assert!(validate_column_color("RED").is_ok());
2807        assert!(validate_column_color("#ff0000").is_err());
2808    }
2809}
2810
2811#[cfg(test)]
2812mod column_description_tests {
2813    use super::*;
2814
2815    fn option(json: serde_json::Value) -> Result<GitHubProjectFieldOption, serde_json::Error> {
2816        serde_json::from_value(json)
2817    }
2818
2819    /// TEST11607: a column GitHub says has no description reads as having none.
2820    ///
2821    /// `null` is the ordinary answer for a column nobody described, and it was
2822    /// once refused — so a board whose columns had no descriptions could not be
2823    /// read at all, which is most boards.
2824    #[test]
2825    fn test11607_a_null_description_is_no_description() {
2826        let read = option(serde_json::json!({
2827            "id": "OPT_1", "name": "Todo", "color": "GRAY", "description": null
2828        }))
2829        .expect("a column with no description is an ordinary column");
2830        assert_eq!(read.description, "");
2831    }
2832
2833    /// TEST11608: a column whose description was never asked for is refused.
2834    ///
2835    /// The opposite direction, and the one that costs data. An absent key means
2836    /// this build did not ask GitHub for the field, so nothing here knows what
2837    /// it holds — and updating a column list writes the WHOLE configuration
2838    /// back, so reading absence as "" erases a description nobody touched.
2839    ///
2840    /// These two were the same case for a while, because the field carried a
2841    /// serde `default` as well as a null-reader. Fixing the first direction is
2842    /// what quietly enabled the second.
2843    #[test]
2844    fn test11608_an_absent_description_is_not_read_as_empty() {
2845        let refusal = option(serde_json::json!({
2846            "id": "OPT_1", "name": "Todo", "color": "GRAY"
2847        }))
2848        .expect_err("a column with no description FIELD was read as having none");
2849        assert!(
2850            refusal.to_string().contains("description"),
2851            "the refusal does not say which field: {refusal}"
2852        );
2853    }
2854
2855    /// TEST11609: a description that is there is kept exactly.
2856    ///
2857    /// The control. A reader that refused everything, or emptied everything,
2858    /// would satisfy one of the two tests above and lose the actual text.
2859    #[test]
2860    fn test11609_a_real_description_survives() {
2861        let read = option(serde_json::json!({
2862            "id": "OPT_1", "name": "Todo", "color": "GRAY",
2863            "description": "not started yet"
2864        }))
2865        .expect("a described column reads");
2866        assert_eq!(read.description, "not started yet");
2867    }
2868}