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 body = self.graphql_answer(what, query, variables).await?;
1293        if let Some(said) = errors_said(&body, |_| false) {
1294            anyhow::bail!("{what}: {said}");
1295        }
1296        body.get("data")
1297            .cloned()
1298            .ok_or_else(|| anyhow::anyhow!("{what}: the answer carried no data"))
1299    }
1300
1301    /// One GraphQL request whose `errors` the caller reads: the whole answer.
1302    async fn graphql_answer(
1303        &self,
1304        what: &str,
1305        query: &str,
1306        variables: serde_json::Value,
1307    ) -> Result<serde_json::Value> {
1308        let response = require_success(
1309            what,
1310            self.client
1311                .post(self.url("/graphql"))
1312                .bearer_auth(&self.token)
1313                .json(&serde_json::json!({ "query": query, "variables": variables }))
1314                .send()
1315                .await
1316                .with_context(|| format!("GitHub API request failed while {what}"))?,
1317        )?;
1318        response
1319            .json()
1320            .await
1321            .with_context(|| format!("failed to parse the answer to {what}"))
1322    }
1323
1324    /// Every project one owner has.
1325    ///
1326    /// `login` may be a user or an organisation, and `repositoryOwner` resolves
1327    /// either — which is the point. Asking `organization(login:)` and
1328    /// `user(login:)` in one query looks like it does the same thing and does
1329    /// not: GitHub answers the field that does not match with an error, and a
1330    /// GraphQL answer carrying errors AND data is a failed query as far as
1331    /// anything reading it is concerned. So every owner failed with "could not
1332    /// resolve to an Organization" (for a user) or "to a User" (for an
1333    /// organisation), and no board ever arrived.
1334    ///
1335    /// A login that is neither comes back as a null owner and no error, which
1336    /// is an owner with no boards rather than a failure — and a real failure,
1337    /// a token without `read:project`, still arrives as an error and is still
1338    /// raised.
1339    ///
1340    /// Every page of them. The list is authoritative — a board absent from it
1341    /// is a board that is gone, and its cards are forgotten — so stopping at a
1342    /// first page would delete the boards past it.
1343    pub async fn list_projects(&self, login: &str) -> Result<Vec<GitHubProject>> {
1344        const QUERY: &str = r#"
1345query($login: String!, $after: String) {
1346  repositoryOwner(login: $login) {
1347    __typename
1348    ... on Organization { projectsV2(first: 100, after: $after, orderBy: {field: UPDATED_AT, direction: DESC}) { ...Page } }
1349    ... on User { projectsV2(first: 100, after: $after, orderBy: {field: UPDATED_AT, direction: DESC}) { ...Page } }
1350  }
1351}
1352fragment Page on ProjectV2Connection {
1353  pageInfo { hasNextPage endCursor }
1354  nodes { ...P }
1355}
1356fragment P on ProjectV2 {
1357  id number title url shortDescription closed updatedAt
1358  fields(first: 50) {
1359    nodes {
1360      ... on ProjectV2FieldCommon { id name dataType }
1361      ... on ProjectV2SingleSelectField { id name dataType options { id name color description } }
1362    }
1363  }
1364}"#;
1365        let mut projects = Vec::new();
1366        let mut after: Option<String> = None;
1367        loop {
1368            let data = self
1369                .graphql(
1370                    &format!("listing projects for {login}"),
1371                    QUERY,
1372                    serde_json::json!({ "login": login, "after": after }),
1373                )
1374                .await?;
1375            let page = projects_of_owner(&data, login)?;
1376            projects.extend(page.items);
1377            match page.next {
1378                Some(cursor) => after = Some(cursor),
1379                None => break,
1380            }
1381        }
1382        Ok(projects)
1383    }
1384
1385    /// Who the token belongs to, and every organisation they are a member of.
1386    ///
1387    /// What "all of this person's boards" is made from: a board belongs to a
1388    /// user or an organisation, and the token's own account and its
1389    /// organisations are every owner whose boards this person can reach
1390    /// without anybody having to name them first.
1391    ///
1392    /// Membership the token cannot see — an organisation that hides it from
1393    /// third-party access — is simply absent, as GitHub answers it.
1394    pub async fn viewer(&self) -> Result<GitHubViewer> {
1395        const QUERY: &str = r#"
1396query($after: String) {
1397  viewer {
1398    login
1399    organizations(first: 100, after: $after) {
1400      pageInfo { hasNextPage endCursor }
1401      nodes { login }
1402    }
1403  }
1404}"#;
1405        let mut organizations = Vec::new();
1406        let mut after: Option<String> = None;
1407        let login = loop {
1408            let data = self
1409                .graphql(
1410                    "reading who the token belongs to",
1411                    QUERY,
1412                    serde_json::json!({ "after": after }),
1413                )
1414                .await?;
1415            let page = viewer_page(&data)?;
1416            organizations.extend(page.organizations.items);
1417            match page.organizations.next {
1418                Some(cursor) => after = Some(cursor),
1419                None => break page.login,
1420            }
1421        };
1422        Ok(GitHubViewer { login, organizations })
1423    }
1424
1425    /// One project in the complete shape a board write returns.
1426    async fn project(&self, project_id: &str) -> Result<GitHubProject> {
1427        const QUERY: &str = r#"
1428query($id: ID!) {
1429  node(id: $id) {
1430    ... on ProjectV2 {
1431      id number title url shortDescription closed updatedAt
1432      owner {
1433        ... on Organization { login }
1434        ... on User { login }
1435      }
1436      fields(first: 50) {
1437        nodes {
1438          ... on ProjectV2FieldCommon { id name dataType }
1439          ... on ProjectV2SingleSelectField {
1440            id name dataType options { id name color description }
1441          }
1442        }
1443      }
1444    }
1445  }
1446}"#;
1447        let data = self
1448            .graphql(
1449                &format!("reading project {project_id}"),
1450                QUERY,
1451                serde_json::json!({ "id": project_id }),
1452            )
1453            .await?;
1454        let node = data
1455            .get("node")
1456            .filter(|node| !node.is_null())
1457            .ok_or_else(|| anyhow::anyhow!("project {project_id} no longer exists"))?;
1458        let owner = node
1459            .get("owner")
1460            .and_then(|owner| owner.get("login"))
1461            .and_then(|login| login.as_str())
1462            .ok_or_else(|| anyhow::anyhow!("project {project_id} has no readable owner"))?;
1463        parse_project(node, owner)
1464    }
1465
1466    /// Add one option to the board's column field.
1467    ///
1468    /// The project is read immediately before constructing the replacement
1469    /// list. Sending the browser's possibly stale list would let a second
1470    /// editor's new column disappear. Existing ids, colours and descriptions
1471    /// are carried exactly so GitHub preserves their identity and card values.
1472    pub async fn add_project_column(
1473        &self,
1474        project_id: &str,
1475        field_id: &str,
1476        name: &str,
1477        color: &str,
1478        description: &str,
1479    ) -> Result<GitHubProject> {
1480        validate_column_color(color)?;
1481        let name = name.trim();
1482        if name.is_empty() {
1483            anyhow::bail!("a project column needs a name");
1484        }
1485        let project = self.project(project_id).await?;
1486        let field = project
1487            .fields
1488            .iter()
1489            .find(|field| field.id == field_id)
1490            .ok_or_else(|| anyhow::anyhow!("project {project_id} has no field {field_id}"))?;
1491        if field.kind != "single_select" {
1492            anyhow::bail!(
1493                "field {field_id} is {}, not a single-select column field",
1494                field.kind
1495            );
1496        }
1497        if field
1498            .options
1499            .iter()
1500            .any(|option| option.name.eq_ignore_ascii_case(name))
1501        {
1502            anyhow::bail!("project {project_id} already has a column named '{name}'");
1503        }
1504
1505        let mut options: Vec<ProjectColumnOptionInput> = field
1506            .options
1507            .iter()
1508            .map(ProjectColumnOptionInput::from)
1509            .collect();
1510        options.push(ProjectColumnOptionInput {
1511            id: None,
1512            name: name.to_string(),
1513            color: color.to_string(),
1514            description: description.trim().to_string(),
1515        });
1516        self.update_project_columns(field_id, &options).await?;
1517        self.project(project_id).await
1518    }
1519
1520    /// Remove one option from the board's column field.
1521    ///
1522    /// GitHub clears the field value on cards that used the removed option.
1523    /// The handler consequently re-reads every item after this returns rather
1524    /// than patching those cards locally.
1525    pub async fn remove_project_column(
1526        &self,
1527        project_id: &str,
1528        field_id: &str,
1529        option_id: &str,
1530    ) -> Result<GitHubProject> {
1531        let project = self.project(project_id).await?;
1532        let field = project
1533            .fields
1534            .iter()
1535            .find(|field| field.id == field_id)
1536            .ok_or_else(|| anyhow::anyhow!("project {project_id} has no field {field_id}"))?;
1537        if field.kind != "single_select" {
1538            anyhow::bail!(
1539                "field {field_id} is {}, not a single-select column field",
1540                field.kind
1541            );
1542        }
1543        if !field.options.iter().any(|option| option.id == option_id) {
1544            anyhow::bail!("field {field_id} has no column {option_id}");
1545        }
1546        if field.options.len() == 1 {
1547            anyhow::bail!("the last project column cannot be removed; a single-select field requires an option");
1548        }
1549        let options: Vec<ProjectColumnOptionInput> = field
1550            .options
1551            .iter()
1552            .filter(|option| option.id != option_id)
1553            .map(ProjectColumnOptionInput::from)
1554            .collect();
1555        self.update_project_columns(field_id, &options).await?;
1556        self.project(project_id).await
1557    }
1558
1559    async fn update_project_columns(
1560        &self,
1561        field_id: &str,
1562        options: &[ProjectColumnOptionInput],
1563    ) -> Result<()> {
1564        const MUTATION: &str = r#"
1565mutation($field: ID!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {
1566  updateProjectV2Field(input: { fieldId: $field, singleSelectOptions: $options }) {
1567    projectV2Field { ... on ProjectV2SingleSelectField { id } }
1568  }
1569}"#;
1570        self.graphql(
1571            &format!("updating the columns of field {field_id}"),
1572            MUTATION,
1573            serde_json::json!({ "field": field_id, "options": options }),
1574        )
1575        .await?;
1576        Ok(())
1577    }
1578
1579    /// Every card on one board, following GitHub's pagination to the end.
1580    ///
1581    /// All of them rather than the first page: a board is read as columns, and
1582    /// a column missing its tail is a column with the wrong count in its
1583    /// header — which is worse than a slower sync.
1584    pub async fn project_items(&self, project_id: &str) -> Result<Vec<GitHubProjectItem>> {
1585        let query = format!(
1586            r#"
1587query($id: ID!, $after: String) {{
1588  node(id: $id) {{
1589    ... on ProjectV2 {{
1590      items(first: 50, after: $after) {{
1591        pageInfo {{ hasNextPage endCursor }}
1592        nodes {{ ...Card }}
1593      }}
1594    }}
1595  }}
1596}}
1597{ITEM_FRAGMENT}"#
1598        );
1599        let mut items = Vec::new();
1600        let mut after: Option<String> = None;
1601        loop {
1602            let data = self
1603                .graphql(
1604                    &format!("reading the items of project {project_id}"),
1605                    &query,
1606                    serde_json::json!({ "id": project_id, "after": after }),
1607                )
1608                .await?;
1609            let page = data
1610                .get("node")
1611                .and_then(|node| node.get("items"))
1612                .ok_or_else(|| anyhow::anyhow!("project {project_id} has no items to read"))?;
1613
1614            for node in page
1615                .get("nodes")
1616                .and_then(|nodes| nodes.as_array())
1617                .unwrap_or(&Vec::new())
1618            {
1619                items.push(parse_project_item(node)?);
1620            }
1621
1622            let more = page
1623                .get("pageInfo")
1624                .and_then(|info| info.get("hasNextPage"))
1625                .and_then(|more| more.as_bool())
1626                .unwrap_or(false);
1627            if !more {
1628                break;
1629            }
1630            after = page
1631                .get("pageInfo")
1632                .and_then(|info| info.get("endCursor"))
1633                .and_then(|cursor| cursor.as_str())
1634                .map(str::to_string);
1635            if after.is_none() {
1636                // More pages and no cursor to ask with: GitHub contradicting
1637                // itself. Stopping here would silently truncate the board.
1638                anyhow::bail!("project {project_id} says there is another page and gives no cursor");
1639            }
1640        }
1641        Ok(items)
1642    }
1643
1644    /// One card, re-read.
1645    ///
1646    /// What every write here ends with. The alternative is patching the held
1647    /// payload by hand — which was how a move worked, and which quietly
1648    /// invents the parts of the answer it does not know: GitHub stamps
1649    /// `updatedAt`, may close an item, may have had another person change a
1650    /// field a second earlier. Asking costs one request and is never wrong.
1651    pub async fn project_item(&self, item_id: &str) -> Result<GitHubProjectItem> {
1652        let query = format!(
1653            r#"
1654query($id: ID!) {{ node(id: $id) {{ ...Card }} }}
1655{ITEM_FRAGMENT}"#
1656        );
1657        let data = self
1658            .graphql(
1659                &format!("re-reading item {item_id}"),
1660                &query,
1661                serde_json::json!({ "id": item_id }),
1662            )
1663            .await?;
1664        let node = data
1665            .get("node")
1666            .filter(|node| !node.is_null())
1667            .ok_or_else(|| anyhow::anyhow!("item {item_id} is not on this board any more"))?;
1668        parse_project_item(node)
1669    }
1670
1671    /// Of the cards `item_ids` names, the ones that are on `project_id`,
1672    /// each asked for by id.
1673    ///
1674    /// The board's own listing is not enough to know that: GitHub's listing
1675    /// of a board leaves out cards added to it for hours after they were
1676    /// added, on boards of every size, while asking for such a card by id
1677    /// finds it on its board (22 Oct 2026). An id GitHub cannot resolve is a
1678    /// card that is gone, and one on another board or not a card at all is
1679    /// not one of this board's; any other error is raised.
1680    pub async fn project_items_on(&self, project_id: &str, item_ids: &[String]) -> Result<Vec<GitHubProjectItem>> {
1681        let query = format!(
1682            r#"
1683query($ids: [ID!]!) {{ nodes(ids: $ids) {{ ... on ProjectV2Item {{ project {{ id }} ...Card }} }} }}
1684{ITEM_FRAGMENT}"#
1685        );
1686        let mut items = Vec::new();
1687        // GitHub resolves at most 100 ids in one request.
1688        for ids in item_ids.chunks(100) {
1689            let what = format!("reading {} cards of project {project_id} by id", ids.len());
1690            let body = self
1691                .graphql_answer(&what, &query, serde_json::json!({ "ids": ids }))
1692                .await?;
1693            if let Some(said) = errors_said(&body, |error| {
1694                error.get("type").and_then(|kind| kind.as_str()) == Some("NOT_FOUND")
1695                    && error
1696                        .get("path")
1697                        .and_then(|path| path.get(0))
1698                        .and_then(|first| first.as_str())
1699                        == Some("nodes")
1700            }) {
1701                anyhow::bail!("{what}: {said}");
1702            }
1703            let nodes = body
1704                .get("data")
1705                .and_then(|data| data.get("nodes"))
1706                .and_then(|nodes| nodes.as_array())
1707                .ok_or_else(|| anyhow::anyhow!("{what}: the answer carried no nodes"))?;
1708            for node in nodes {
1709                let on_board = node
1710                    .get("project")
1711                    .and_then(|project| project.get("id"))
1712                    .and_then(|id| id.as_str())
1713                    == Some(project_id);
1714                if on_board {
1715                    items.push(parse_project_item(node)?);
1716                }
1717            }
1718        }
1719        Ok(items)
1720    }
1721
1722    /// Set one field on one card.
1723    ///
1724    /// Every field kind through one door, because they are one act: a person
1725    /// setting a due date and a person dragging a card between columns are
1726    /// both saying "this item's F is now V", and two code paths for that is
1727    /// how one of them ends up not refreshing what the other does.
1728    pub async fn set_project_field(
1729        &self,
1730        project_id: &str,
1731        item_id: &str,
1732        field_id: &str,
1733        value: &ProjectFieldWrite,
1734    ) -> Result<GitHubProjectItem> {
1735        const MUTATION: &str = r#"
1736mutation($project: ID!, $item: ID!, $field: ID!, $value: ProjectV2FieldValue!) {
1737  updateProjectV2ItemFieldValue(input: {
1738    projectId: $project, itemId: $item, fieldId: $field, value: $value
1739  }) { projectV2Item { id } }
1740}"#;
1741        self.graphql(
1742            &format!("setting a field on item {item_id}"),
1743            MUTATION,
1744            serde_json::json!({
1745                "project": project_id,
1746                "item": item_id,
1747                "field": field_id,
1748                "value": value.as_input(),
1749            }),
1750        )
1751        .await?;
1752        self.project_item(item_id).await
1753    }
1754
1755    /// Take a field's value off a card.
1756    ///
1757    /// Distinct from setting it to empty: an empty text value is a value, and
1758    /// a board that could not tell "no due date" from "a due date of nothing"
1759    /// would show a column of blanks where it means to show none.
1760    pub async fn clear_project_field(
1761        &self,
1762        project_id: &str,
1763        item_id: &str,
1764        field_id: &str,
1765    ) -> Result<GitHubProjectItem> {
1766        const MUTATION: &str = r#"
1767mutation($project: ID!, $item: ID!, $field: ID!) {
1768  clearProjectV2ItemFieldValue(input: {
1769    projectId: $project, itemId: $item, fieldId: $field
1770  }) { projectV2Item { id } }
1771}"#;
1772        self.graphql(
1773            &format!("clearing a field on item {item_id}"),
1774            MUTATION,
1775            serde_json::json!({ "project": project_id, "item": item_id, "field": field_id }),
1776        )
1777        .await?;
1778        self.project_item(item_id).await
1779    }
1780
1781    /// Put a card after another one, or first in the board's order.
1782    ///
1783    /// `after` is the card it lands behind; `None` puts it at the top. This is
1784    /// what a drag WITHIN a column means, and without it a board's order is
1785    /// whatever GitHub last decided.
1786    pub async fn move_project_item_after(
1787        &self,
1788        project_id: &str,
1789        item_id: &str,
1790        after: Option<&str>,
1791    ) -> Result<GitHubProjectItem> {
1792        const MUTATION: &str = r#"
1793mutation($project: ID!, $item: ID!, $after: ID) {
1794  updateProjectV2ItemPosition(input: {
1795    projectId: $project, itemId: $item, afterId: $after
1796  }) { items { totalCount } }
1797}"#;
1798        self.graphql(
1799            &format!("ordering item {item_id}"),
1800            MUTATION,
1801            serde_json::json!({ "project": project_id, "item": item_id, "after": after }),
1802        )
1803        .await?;
1804        self.project_item(item_id).await
1805    }
1806
1807    /// Add a card that exists only on the board.
1808    ///
1809    /// A draft is a note with a place in the plan and no repository yet. It is
1810    /// the cheapest way to put something on a board, and refusing to create
1811    /// one is what sends a person to the website mid-thought.
1812    pub async fn add_project_draft(
1813        &self,
1814        project_id: &str,
1815        title: &str,
1816        body: &str,
1817    ) -> Result<GitHubProjectItem> {
1818        const MUTATION: &str = r#"
1819mutation($project: ID!, $title: String!, $body: String!) {
1820  addProjectV2DraftIssue(input: { projectId: $project, title: $title, body: $body }) {
1821    projectItem { id }
1822  }
1823}"#;
1824        let data = self
1825            .graphql(
1826                "adding a draft card",
1827                MUTATION,
1828                serde_json::json!({ "project": project_id, "title": title, "body": body }),
1829            )
1830            .await?;
1831        let id = data
1832            .get("addProjectV2DraftIssue")
1833            .and_then(|added| added.get("projectItem"))
1834            .and_then(|item| item.get("id"))
1835            .and_then(|id| id.as_str())
1836            .ok_or_else(|| {
1837                anyhow::anyhow!("the draft was created and GitHub did not say its id")
1838            })?;
1839        self.project_item(id).await
1840    }
1841
1842    /// Rewrite a draft's title and body.
1843    ///
1844    /// Only a draft: an issue's title belongs to the issue and is edited where
1845    /// issues are, not on a board that happens to show it.
1846    pub async fn update_project_draft(
1847        &self,
1848        item_id: &str,
1849        draft_id: &str,
1850        title: &str,
1851        body: &str,
1852    ) -> Result<GitHubProjectItem> {
1853        const MUTATION: &str = r#"
1854mutation($draft: ID!, $title: String!, $body: String!) {
1855  updateProjectV2DraftIssue(input: { draftIssueId: $draft, title: $title, body: $body }) {
1856    draftIssue { id }
1857  }
1858}"#;
1859        self.graphql(
1860            &format!("editing draft {draft_id}"),
1861            MUTATION,
1862            serde_json::json!({ "draft": draft_id, "title": title, "body": body }),
1863        )
1864        .await?;
1865        self.project_item(item_id).await
1866    }
1867
1868    /// Put an existing issue or pull request on the board, by its URL.
1869    ///
1870    /// The URL is what a person has in hand — it is what GitHub's own "add
1871    /// item" takes, and what is in the clipboard after reading an issue. It is
1872    /// resolved to a node id here rather than asking anybody to find one.
1873    pub async fn add_project_item_by_url(
1874        &self,
1875        project_id: &str,
1876        url: &str,
1877    ) -> Result<GitHubProjectItem> {
1878        const RESOLVE: &str = r#"
1879query($url: URI!) {
1880  resource(url: $url) {
1881    __typename
1882    ... on Issue { id }
1883    ... on PullRequest { id }
1884  }
1885}"#;
1886        let found = self
1887            .graphql(
1888                &format!("looking up {url}"),
1889                RESOLVE,
1890                serde_json::json!({ "url": url }),
1891            )
1892            .await?;
1893        let resource = found
1894            .get("resource")
1895            .filter(|resource| !resource.is_null())
1896            .ok_or_else(|| {
1897                anyhow::anyhow!("{url} is not something on GitHub this token can see")
1898            })?;
1899        let kind = resource
1900            .get("__typename")
1901            .and_then(|kind| kind.as_str())
1902            .unwrap_or("that");
1903        let content = resource.get("id").and_then(|id| id.as_str()).ok_or_else(|| {
1904            anyhow::anyhow!("{url} is a {kind}, and only an issue or a pull request can go on a board")
1905        })?;
1906
1907        const MUTATION: &str = r#"
1908mutation($project: ID!, $content: ID!) {
1909  addProjectV2ItemById(input: { projectId: $project, contentId: $content }) {
1910    item { id }
1911  }
1912}"#;
1913        let data = self
1914            .graphql(
1915                &format!("adding {url} to the board"),
1916                MUTATION,
1917                serde_json::json!({ "project": project_id, "content": content }),
1918            )
1919            .await?;
1920        let id = data
1921            .get("addProjectV2ItemById")
1922            .and_then(|added| added.get("item"))
1923            .and_then(|item| item.get("id"))
1924            .and_then(|id| id.as_str())
1925            .ok_or_else(|| anyhow::anyhow!("the item was added and GitHub did not say its id"))?;
1926        self.project_item(id).await
1927    }
1928
1929    /// Archive a card, or bring it back.
1930    ///
1931    /// Archiving is what a board does instead of deleting: the item keeps its
1932    /// field values and its history, and stops taking up a column. Deleting is
1933    /// separate, and is the one that cannot be undone.
1934    pub async fn archive_project_item(
1935        &self,
1936        project_id: &str,
1937        item_id: &str,
1938        archived: bool,
1939    ) -> Result<GitHubProjectItem> {
1940        const ARCHIVE: &str = r#"
1941mutation($project: ID!, $item: ID!) {
1942  archiveProjectV2Item(input: { projectId: $project, itemId: $item }) { item { id } }
1943}"#;
1944        const UNARCHIVE: &str = r#"
1945mutation($project: ID!, $item: ID!) {
1946  unarchiveProjectV2Item(input: { projectId: $project, itemId: $item }) { item { id } }
1947}"#;
1948        self.graphql(
1949            &format!("archiving item {item_id}"),
1950            if archived { ARCHIVE } else { UNARCHIVE },
1951            serde_json::json!({ "project": project_id, "item": item_id }),
1952        )
1953        .await?;
1954        self.project_item(item_id).await
1955    }
1956
1957    /// Take a card off the board.
1958    ///
1959    /// The issue itself is untouched — this removes the card, which is the
1960    /// only thing a board owns. A draft has nowhere else to be, so deleting
1961    /// one deletes it.
1962    pub async fn delete_project_item(&self, project_id: &str, item_id: &str) -> Result<()> {
1963        const MUTATION: &str = r#"
1964mutation($project: ID!, $item: ID!) {
1965  deleteProjectV2Item(input: { projectId: $project, itemId: $item }) { deletedItemId }
1966}"#;
1967        self.graphql(
1968            &format!("removing item {item_id}"),
1969            MUTATION,
1970            serde_json::json!({ "project": project_id, "item": item_id }),
1971        )
1972        .await?;
1973        Ok(())
1974    }
1975}
1976
1977/// A value being written to a field.
1978///
1979/// The kinds GitHub takes, named. A write that carried a bare string would
1980/// have to guess which kind the field is, and guessing wrong sets a date field
1981/// to the text "2026-08-19" — which GitHub accepts and nothing shows.
1982#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
1983#[serde(tag = "kind", rename_all = "snake_case")]
1984pub enum ProjectFieldWrite {
1985    SingleSelect {
1986        option_id: String,
1987    },
1988    Text {
1989        text: String,
1990    },
1991    Number {
1992        number: f64,
1993    },
1994    /// `YYYY-MM-DD`, which is what GitHub's `Date` scalar takes.
1995    Date {
1996        date: String,
1997    },
1998    Iteration {
1999        iteration_id: String,
2000    },
2001}
2002
2003impl ProjectFieldWrite {
2004    /// The `ProjectV2FieldValue` input this is, as GitHub spells it.
2005    fn as_input(&self) -> serde_json::Value {
2006        match self {
2007            Self::SingleSelect { option_id } => {
2008                serde_json::json!({ "singleSelectOptionId": option_id })
2009            }
2010            Self::Text { text } => serde_json::json!({ "text": text }),
2011            Self::Number { number } => serde_json::json!({ "number": number }),
2012            Self::Date { date } => serde_json::json!({ "date": date }),
2013            Self::Iteration { iteration_id } => {
2014                serde_json::json!({ "iterationId": iteration_id })
2015            }
2016        }
2017    }
2018}
2019
2020/// One page of a GraphQL connection: what it held, and the cursor of the next
2021/// page when there is one.
2022#[derive(Debug, Clone, PartialEq)]
2023struct Page<T> {
2024    items: Vec<T>,
2025    next: Option<String>,
2026}
2027
2028/// Where a connection continues, from its `pageInfo`.
2029///
2030/// A connection that says there is another page and gives no cursor is GitHub
2031/// contradicting itself. Stopping there would silently truncate a list this
2032/// mirror treats as authoritative, so it is raised.
2033fn next_page(connection: &serde_json::Value, what: &str) -> Result<Option<String>> {
2034    let info = connection.get("pageInfo");
2035    let more = info
2036        .and_then(|info| info.get("hasNextPage"))
2037        .and_then(|more| more.as_bool())
2038        .unwrap_or(false);
2039    if !more {
2040        return Ok(None);
2041    }
2042    info.and_then(|info| info.get("endCursor"))
2043        .and_then(|cursor| cursor.as_str())
2044        .map(|cursor| Some(cursor.to_string()))
2045        .ok_or_else(|| anyhow::anyhow!("{what} says there is another page and gives no cursor"))
2046}
2047
2048/// The boards in an answer to the owner query.
2049///
2050/// Split out from the request so the shape GitHub actually returns is
2051/// something a test can hold: an owner with boards, an owner with none, and a
2052/// login that is nobody all come back through here, and only the first is a
2053/// board.
2054fn projects_of_owner(data: &serde_json::Value, login: &str) -> Result<Page<GitHubProject>> {
2055    let nothing = Page { items: Vec::new(), next: None };
2056    // A login that resolves to nobody. Not an error: an owner this worktree
2057    // has repositories under may simply not exist as an account any more, and
2058    // a sweep that failed over it would stop mirroring the owners that do.
2059    let Some(owner) = data.get("repositoryOwner").filter(|owner| !owner.is_null()) else {
2060        return Ok(nothing);
2061    };
2062    // An owner kind with no boards at all — `projectsV2` is absent when the
2063    // owner is neither a User nor an Organization, which is a shape GitHub can
2064    // grow without this breaking.
2065    let Some(connection) = owner.get("projectsV2") else {
2066        return Ok(nothing);
2067    };
2068    let Some(nodes) = connection.get("nodes").and_then(|nodes| nodes.as_array()) else {
2069        return Ok(nothing);
2070    };
2071    Ok(Page {
2072        items: nodes
2073            .iter()
2074            .map(|node| parse_project(node, login))
2075            .collect::<Result<_>>()?,
2076        next: next_page(connection, &format!("{login}'s board list"))?,
2077    })
2078}
2079
2080/// One page of the viewer query.
2081#[derive(Debug, Clone, PartialEq)]
2082struct ViewerPage {
2083    login: String,
2084    organizations: Page<String>,
2085}
2086
2087/// The viewer in an answer to the viewer query.
2088///
2089/// A token always belongs to somebody, so an answer with no viewer, or a
2090/// viewer with no login, is a broken answer rather than an account with no
2091/// organisations.
2092fn viewer_page(data: &serde_json::Value) -> Result<ViewerPage> {
2093    let viewer = data
2094        .get("viewer")
2095        .filter(|viewer| !viewer.is_null())
2096        .ok_or_else(|| anyhow::anyhow!("GitHub did not say who the token belongs to"))?;
2097    let login = string_at(viewer, "login", "the token's account")?;
2098    let connection = viewer
2099        .get("organizations")
2100        .ok_or_else(|| anyhow::anyhow!("GitHub did not list {login}'s organisations"))?;
2101    let organizations = connection
2102        .get("nodes")
2103        .and_then(|nodes| nodes.as_array())
2104        .ok_or_else(|| anyhow::anyhow!("{login}'s organisations are not a list"))?
2105        .iter()
2106        .map(|node| string_at(node, "login", "an organisation"))
2107        .collect::<Result<Vec<_>>>()?;
2108    Ok(ViewerPage {
2109        organizations: Page {
2110            items: organizations,
2111            next: next_page(connection, &format!("{login}'s organisation list"))?,
2112        },
2113        login,
2114    })
2115}
2116
2117fn validate_column_color(color: &str) -> Result<()> {
2118    const COLORS: &[&str] = &[
2119        "GRAY", "BLUE", "GREEN", "YELLOW", "ORANGE", "RED", "PINK", "PURPLE",
2120    ];
2121    if COLORS.contains(&color) {
2122        return Ok(());
2123    }
2124    anyhow::bail!(
2125        "'{color}' is not a GitHub project column color; expected {}",
2126        COLORS.join(", ")
2127    )
2128}
2129
2130fn parse_project(node: &serde_json::Value, login: &str) -> Result<GitHubProject> {
2131    let field_nodes = node
2132        .get("fields")
2133        .and_then(|fields| fields.get("nodes"))
2134        .and_then(|nodes| nodes.as_array())
2135        .ok_or_else(|| anyhow::anyhow!("a project has no field list"))?;
2136    let fields = field_nodes
2137        .iter()
2138        .map(|field| -> Result<GitHubProjectField> {
2139            let options = match field.get("options") {
2140                None => Vec::new(),
2141                Some(options) => options
2142                    .as_array()
2143                    .ok_or_else(|| anyhow::anyhow!("a project field's options are not a list"))?
2144                    .iter()
2145                    .map(|option| -> Result<GitHubProjectFieldOption> {
2146                        Ok(GitHubProjectFieldOption {
2147                            id: string_at(option, "id", "a project column")?,
2148                            name: string_at(option, "name", "a project column")?,
2149                            color: string_at(option, "color", "a project column")?,
2150                            description: optional_string_at(option, "description", "a project column")?,
2151                        })
2152                    })
2153                    .collect::<Result<Vec<_>>>()?,
2154            };
2155            Ok(GitHubProjectField {
2156                id: string_at(field, "id", "a project field")?,
2157                name: string_at(field, "name", "a project field")?,
2158                kind: string_at(field, "dataType", "a project field")?.to_ascii_lowercase(),
2159                options,
2160            })
2161        })
2162        .collect::<Result<Vec<_>>>()?;
2163
2164    Ok(GitHubProject {
2165        id: string_at(node, "id", "a project")?,
2166        number: node
2167            .get("number")
2168            .and_then(|number| number.as_i64())
2169            .ok_or_else(|| anyhow::anyhow!("a project has no number"))?,
2170        title: string_at(node, "title", "a project")?,
2171        url: string_at(node, "url", "a project")?,
2172        short_description: node
2173            .get("shortDescription")
2174            .and_then(|text| text.as_str())
2175            .map(str::to_string),
2176        closed: node
2177            .get("closed")
2178            .and_then(|closed| closed.as_bool())
2179            .unwrap_or(false),
2180        updated_at: string_at(node, "updatedAt", "a project")?,
2181        owner: login.to_string(),
2182        fields,
2183    })
2184}
2185
2186/// What a GraphQL answer's errors say, leaving out those `expected` accepts;
2187/// `None` when nothing else went wrong.
2188fn errors_said(body: &serde_json::Value, expected: impl Fn(&serde_json::Value) -> bool) -> Option<String> {
2189    let errors: Vec<&serde_json::Value> = body
2190        .get("errors")
2191        .and_then(|errors| errors.as_array())
2192        .into_iter()
2193        .flatten()
2194        .filter(|error| !expected(error))
2195        .collect();
2196    (!errors.is_empty()).then(|| {
2197        errors
2198            .iter()
2199            .filter_map(|error| error.get("message").and_then(|m| m.as_str()))
2200            .collect::<Vec<_>>()
2201            .join("; ")
2202    })
2203}
2204
2205fn parse_project_item(node: &serde_json::Value) -> Result<GitHubProjectItem> {
2206    let content = node.get("content");
2207    let mut values = BTreeMap::new();
2208    if let Some(nodes) = node
2209        .get("fieldValues")
2210        .and_then(|values| values.get("nodes"))
2211        .and_then(|nodes| nodes.as_array())
2212    {
2213        for value in nodes {
2214            // A field value with no field is GitHub's answer for a value the
2215            // token may not read. Skipped rather than guessed at.
2216            let Some(field_id) = value
2217                .get("field")
2218                .and_then(|field| field.get("id"))
2219                .and_then(|id| id.as_str())
2220            else {
2221                continue;
2222            };
2223            let parsed = if let (Some(option), Some(name)) = (
2224                value.get("optionId").and_then(|id| id.as_str()),
2225                value.get("name").and_then(|name| name.as_str()),
2226            ) {
2227                GitHubProjectValue::SingleSelect {
2228                    option_id: option.to_string(),
2229                    name: name.to_string(),
2230                }
2231            } else if let Some(text) = value.get("text").and_then(|text| text.as_str()) {
2232                GitHubProjectValue::Text {
2233                    text: text.to_string(),
2234                }
2235            } else if let Some(number) = value.get("number").and_then(|number| number.as_f64()) {
2236                GitHubProjectValue::Number { number }
2237            } else if let Some(date) = value.get("date").and_then(|date| date.as_str()) {
2238                GitHubProjectValue::Date {
2239                    date: date.to_string(),
2240                }
2241            } else if let (Some(title), Some(start)) = (
2242                value.get("title").and_then(|title| title.as_str()),
2243                value.get("startDate").and_then(|date| date.as_str()),
2244            ) {
2245                GitHubProjectValue::Iteration {
2246                    title: title.to_string(),
2247                    start_date: start.to_string(),
2248                }
2249            } else {
2250                continue;
2251            };
2252            values.insert(field_id.to_string(), parsed);
2253        }
2254    }
2255
2256    Ok(GitHubProjectItem {
2257        id: string_at(node, "id", "a project item")?,
2258        kind: string_at(node, "type", "a project item")?,
2259        title: content
2260            .and_then(|content| content.get("title"))
2261            .and_then(|title| title.as_str())
2262            .unwrap_or("(untitled)")
2263            .to_string(),
2264        content_id: text(content, "id"),
2265        number: content
2266            .and_then(|content| content.get("number"))
2267            .and_then(|number| number.as_i64()),
2268        url: content
2269            .and_then(|content| content.get("url"))
2270            .and_then(|url| url.as_str())
2271            .map(str::to_string),
2272        repository: content
2273            .and_then(|content| content.get("repository"))
2274            .and_then(|repo| repo.get("nameWithOwner"))
2275            .and_then(|name| name.as_str())
2276            .map(str::to_string),
2277        state: content
2278            .and_then(|content| content.get("state"))
2279            .and_then(|state| state.as_str())
2280            .map(str::to_string),
2281        state_reason: text(content, "stateReason"),
2282        body: text(content, "body"),
2283        // A draft card has a creator where an issue has an author. Both are
2284        // "who put this here", and a card that showed one and not the other
2285        // would look like nobody made it.
2286        author: content
2287            .and_then(|content| content.get("author").or_else(|| content.get("creator")))
2288            .and_then(parse_user),
2289        assignees: people(content, "assignees"),
2290        labels: content
2291            .and_then(|content| content.get("labels"))
2292            .and_then(|labels| labels.get("nodes"))
2293            .and_then(|nodes| nodes.as_array())
2294            .map(|nodes| {
2295                nodes
2296                    .iter()
2297                    .filter_map(|label| {
2298                        Some(GitHubLabel {
2299                            name: label.get("name")?.as_str()?.to_string(),
2300                            color: label
2301                                .get("color")
2302                                .and_then(|color| color.as_str())
2303                                .unwrap_or_default()
2304                                .to_string(),
2305                            description: label
2306                                .get("description")
2307                                .and_then(|text| text.as_str())
2308                                .map(str::to_string),
2309                        })
2310                    })
2311                    .collect()
2312            })
2313            .unwrap_or_default(),
2314        milestone: content
2315            .and_then(|content| content.get("milestone"))
2316            .and_then(|milestone| milestone.get("title"))
2317            .and_then(|title| title.as_str())
2318            .map(str::to_string),
2319        comment_count: content
2320            .and_then(|content| content.get("comments"))
2321            .and_then(|comments| comments.get("totalCount"))
2322            .and_then(|count| count.as_i64())
2323            .unwrap_or(0),
2324        created_at: text(content, "createdAt"),
2325        closed_at: text(content, "closedAt"),
2326        is_draft: flag(content, "isDraft"),
2327        merged: flag(content, "merged"),
2328        review_decision: text(content, "reviewDecision"),
2329        archived: node
2330            .get("isArchived")
2331            .and_then(|archived| archived.as_bool())
2332            .unwrap_or(false),
2333        field_values: values,
2334        updated_at: string_at(node, "updatedAt", "a project item")?,
2335    })
2336}
2337
2338/// A string under the content, when the content is a kind that has one.
2339///
2340/// Every one of these is absent for some card kind — a draft has no state, an
2341/// issue has no review decision — so absence is the ordinary answer and never
2342/// a parse failure.
2343fn text(content: Option<&serde_json::Value>, key: &str) -> Option<String> {
2344    content
2345        .and_then(|content| content.get(key))
2346        .and_then(|value| value.as_str())
2347        .map(str::to_string)
2348}
2349
2350fn flag(content: Option<&serde_json::Value>, key: &str) -> bool {
2351    content
2352        .and_then(|content| content.get(key))
2353        .and_then(|value| value.as_bool())
2354        .unwrap_or(false)
2355}
2356
2357fn parse_user(node: &serde_json::Value) -> Option<GitHubUser> {
2358    Some(GitHubUser {
2359        login: node.get("login")?.as_str()?.to_string(),
2360        avatar_url: node
2361            .get("avatarUrl")
2362            .and_then(|url| url.as_str())
2363            .map(str::to_string),
2364    })
2365}
2366
2367fn people(content: Option<&serde_json::Value>, key: &str) -> Vec<GitHubUser> {
2368    content
2369        .and_then(|content| content.get(key))
2370        .and_then(|people| people.get("nodes"))
2371        .and_then(|nodes| nodes.as_array())
2372        .map(|nodes| nodes.iter().filter_map(parse_user).collect())
2373        .unwrap_or_default()
2374}
2375
2376fn string_at(node: &serde_json::Value, key: &str, what: &str) -> Result<String> {
2377    node.get(key)
2378        .and_then(|value| value.as_str())
2379        .map(str::to_string)
2380        .ok_or_else(|| anyhow::anyhow!("{what} has no {key}"))
2381}
2382
2383/// A string a caller may legitimately have left unset, when the answer says so.
2384///
2385/// `null` and MISSING are not the same thing here, and treating them alike
2386/// broke this in both directions. A board column's description is the case:
2387/// nobody types one, GitHub answers `"description": null`, and reading that as
2388/// a missing field made every ordinary board unreadable — "a project column
2389/// has no description", about something the person never filled in.
2390///
2391/// The other direction costs more. A key that is not in the answer at all
2392/// means this query did not ask for it, so nothing here knows what GitHub
2393/// holds. A refresh writes the whole column back, so reading absence as `""`
2394/// erases a description that exists, on the next edit, without anybody
2395/// touching it. That is refused.
2396fn optional_string_at(node: &serde_json::Value, key: &str, what: &str) -> Result<String> {
2397    match node.get(key) {
2398        Some(serde_json::Value::Null) => Ok(String::new()),
2399        Some(serde_json::Value::String(text)) => Ok(text.clone()),
2400        Some(other) => Err(anyhow::anyhow!(
2401            "{what} has a {key} that is not text: {other}"
2402        )),
2403        None => Err(anyhow::anyhow!(
2404            "{what} came back without a {key}, so this cannot say what GitHub \
2405             holds for it — and a refresh writes the whole column back, which \
2406             would erase it. Refresh Projects to read them again."
2407        )),
2408    }
2409}
2410
2411/// One header, as text, when it is there and readable.
2412fn header_value(response: &reqwest::Response, name: reqwest::header::HeaderName) -> Option<String> {
2413    response
2414        .headers()
2415        .get(name)
2416        .and_then(|value| value.to_str().ok())
2417        .map(str::to_string)
2418}
2419
2420impl GitHubClient {
2421    /// List issues for a repository.
2422    pub async fn list_issues(
2423        &self,
2424        owner: &str,
2425        repo: &str,
2426        state: &str,
2427    ) -> Result<Vec<GitHubIssue>> {
2428        let url = self.url(&format!(
2429            "/repos/{}/{}/issues?state={}&per_page=100&sort=updated&direction=desc",
2430            owner, repo, state
2431        ));
2432        let response = require_success(
2433            &format!("GitHub API error listing issues for {}/{}", owner, repo),
2434            self.client
2435                .get(&url)
2436                .bearer_auth(&self.token)
2437                .send()
2438                .await
2439                .with_context(|| format!("failed to list issues for {}/{}", owner, repo))?,
2440        )?;
2441        let resp: Vec<GitHubIssue> = response
2442            .json()
2443            .await
2444            .context("failed to parse issues response")?;
2445
2446        // Filter out pull requests (GitHub's issues API returns PRs too)
2447        Ok(resp
2448            .into_iter()
2449            .filter(|i| !i.html_url.contains("/pull/"))
2450            .collect())
2451    }
2452
2453    /// Get a single issue.
2454    pub async fn get_issue(&self, owner: &str, repo: &str, number: i64) -> Result<GitHubIssue> {
2455        let url = self.url(&format!("/repos/{}/{}/issues/{}", owner, repo, number));
2456        let issue: GitHubIssue = require_success(
2457            &format!("failed to get issue {}/{}#{}", owner, repo, number),
2458            self.client
2459                .get(&url)
2460                .bearer_auth(&self.token)
2461                .send()
2462                .await?,
2463        )?
2464        .json()
2465        .await?;
2466        Ok(issue)
2467    }
2468
2469    /// Create an issue.
2470    pub async fn create_issue(
2471        &self,
2472        owner: &str,
2473        repo: &str,
2474        req: &CreateIssueRequest,
2475    ) -> Result<GitHubIssue> {
2476        let url = self.url(&format!("/repos/{}/{}/issues", owner, repo));
2477        let issue: GitHubIssue = require_success(
2478            &format!("failed to create issue in {}/{}", owner, repo),
2479            self.client
2480                .post(&url)
2481                .bearer_auth(&self.token)
2482                .json(req)
2483                .send()
2484                .await?,
2485        )?
2486        .json()
2487        .await?;
2488        Ok(issue)
2489    }
2490
2491    /// Update an issue (close, reopen, edit title/body/labels).
2492    pub async fn update_issue(
2493        &self,
2494        owner: &str,
2495        repo: &str,
2496        number: i64,
2497        req: &UpdateIssueRequest,
2498    ) -> Result<GitHubIssue> {
2499        let url = self.url(&format!("/repos/{}/{}/issues/{}", owner, repo, number));
2500        let issue: GitHubIssue = require_success(
2501            &format!("failed to update issue {}/{}#{}", owner, repo, number),
2502            self.client
2503                .patch(&url)
2504                .bearer_auth(&self.token)
2505                .json(req)
2506                .send()
2507                .await?,
2508        )?
2509        .json()
2510        .await?;
2511        Ok(issue)
2512    }
2513
2514    /// List comments on an issue.
2515    pub async fn list_comments(
2516        &self,
2517        owner: &str,
2518        repo: &str,
2519        number: i64,
2520    ) -> Result<Vec<GitHubComment>> {
2521        let url = self.url(&format!(
2522            "/repos/{}/{}/issues/{}/comments?per_page=100",
2523            owner, repo, number
2524        ));
2525        let comments: Vec<GitHubComment> = require_success(
2526            &format!("failed to list comments for {}/{}#{}", owner, repo, number),
2527            self.client
2528                .get(&url)
2529                .bearer_auth(&self.token)
2530                .send()
2531                .await?,
2532        )?
2533        .json()
2534        .await?;
2535        Ok(comments)
2536    }
2537
2538    /// Add a comment to an issue.
2539    pub async fn add_comment(
2540        &self,
2541        owner: &str,
2542        repo: &str,
2543        number: i64,
2544        body: &str,
2545    ) -> Result<GitHubComment> {
2546        let url = self.url(&format!("/repos/{}/{}/issues/{}/comments", owner, repo, number));
2547        let comment: GitHubComment = require_success(
2548            &format!("failed to add comment to {}/{}#{}", owner, repo, number),
2549            self.client
2550                .post(&url)
2551                .bearer_auth(&self.token)
2552                .json(&CreateCommentRequest {
2553                    body: body.to_string(),
2554                })
2555                .send()
2556                .await?,
2557        )?
2558        .json()
2559        .await?;
2560        Ok(comment)
2561    }
2562
2563    /// List published gists for the authenticated user.
2564    pub async fn list_gists(&self) -> Result<Vec<GitHubGist>> {
2565        let response = require_success(
2566            "failed to list gists",
2567            self.client
2568                .get(self.url("/gists?per_page=100"))
2569                .bearer_auth(&self.token)
2570                .send()
2571                .await?,
2572        )?;
2573        let gists: Vec<GitHubGist> = response
2574            .json()
2575            .await
2576            .context("failed to parse gists response")?;
2577        Ok(gists)
2578    }
2579
2580    /// Get a single gist including file contents.
2581    pub async fn get_gist(&self, gist_id: &str) -> Result<GitHubGist> {
2582        let url = self.url(&format!("/gists/{}", gist_id));
2583        let gist: GitHubGist = require_success(
2584            &format!("failed to get gist {}", gist_id),
2585            self.client
2586                .get(&url)
2587                .bearer_auth(&self.token)
2588                .send()
2589                .await?,
2590        )?
2591        .json()
2592        .await
2593        .context("failed to parse gist response")?;
2594        Ok(gist)
2595    }
2596
2597    /// Create a gist.
2598    pub async fn create_gist(&self, req: &CreateGistRequest) -> Result<GitHubGist> {
2599        let gist: GitHubGist = require_success(
2600            "failed to create gist",
2601            self.client
2602                .post(&self.url("/gists"))
2603                .bearer_auth(&self.token)
2604                .json(req)
2605                .send()
2606                .await?,
2607        )?
2608        .json()
2609        .await
2610        .context("failed to parse create gist response")?;
2611        Ok(gist)
2612    }
2613
2614    /// Update a gist.
2615    pub async fn update_gist(&self, gist_id: &str, req: &UpdateGistRequest) -> Result<GitHubGist> {
2616        let url = self.url(&format!("/gists/{}", gist_id));
2617        let gist: GitHubGist = require_success(
2618            &format!("failed to update gist {}", gist_id),
2619            self.client
2620                .patch(&url)
2621                .bearer_auth(&self.token)
2622                .json(req)
2623                .send()
2624                .await?,
2625        )?
2626        .json()
2627        .await
2628        .context("failed to parse update gist response")?;
2629        Ok(gist)
2630    }
2631}
2632
2633/// Parse a GitHub URL into (owner, repo).
2634/// Handles: https://github.com/owner/repo[.git]
2635///          git@github.com:owner/repo[.git]
2636///          ssh://git@github.com/owner/repo[.git]
2637pub fn parse_github_url(url: &str) -> Option<(String, String)> {
2638    let url = url.trim();
2639
2640    // HTTPS format
2641    if let Some(rest) = url
2642        .strip_prefix("https://github.com/")
2643        .or_else(|| url.strip_prefix("http://github.com/"))
2644    {
2645        return parse_owner_repo(rest);
2646    }
2647
2648    // SSH formats: scp-like, and the URL form git writes for `ssh://`.
2649    if let Some(rest) = url
2650        .strip_prefix("git@github.com:")
2651        .or_else(|| url.strip_prefix("ssh://git@github.com/"))
2652    {
2653        return parse_owner_repo(rest);
2654    }
2655
2656    None
2657}
2658
2659fn parse_owner_repo(path: &str) -> Option<(String, String)> {
2660    let path = path.trim_end_matches(".git").trim_end_matches('/');
2661    let parts: Vec<&str> = path.splitn(3, '/').collect();
2662    if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
2663        Some((parts[0].to_string(), parts[1].to_string()))
2664    } else {
2665        None
2666    }
2667}
2668
2669#[cfg(test)]
2670mod tests {
2671    use super::*;
2672
2673    #[test]
2674    fn test_parse_github_url_https() {
2675        assert_eq!(
2676            parse_github_url("https://github.com/wesm/agentsview"),
2677            Some(("wesm".to_string(), "agentsview".to_string()))
2678        );
2679    }
2680
2681    #[test]
2682    fn test_parse_github_url_https_with_git_suffix() {
2683        assert_eq!(
2684            parse_github_url("https://github.com/wesm/agentsview.git"),
2685            Some(("wesm".to_string(), "agentsview".to_string()))
2686        );
2687    }
2688
2689    #[test]
2690    fn test_parse_github_url_ssh() {
2691        assert_eq!(
2692            parse_github_url("git@github.com:wesm/agentsview.git"),
2693            Some(("wesm".to_string(), "agentsview".to_string()))
2694        );
2695    }
2696
2697    /// The URL form of SSH is how `git clone ssh://…` records a remote, and a
2698    /// repository cloned that way is still on GitHub.
2699    #[test]
2700    fn test_parse_github_url_ssh_url_form() {
2701        assert_eq!(
2702            parse_github_url("ssh://git@github.com/wesm/agentsview.git"),
2703            Some(("wesm".to_string(), "agentsview".to_string()))
2704        );
2705    }
2706
2707    #[test]
2708    fn test_parse_github_url_non_github() {
2709        assert_eq!(parse_github_url("https://gitlab.com/user/repo"), None);
2710    }
2711
2712    #[test]
2713    fn test_parse_github_url_invalid() {
2714        assert_eq!(parse_github_url("not a url"), None);
2715    }
2716
2717    /// One board, as GitHub returns it under `repositoryOwner`.
2718    fn owner_with_a_board(kind: &str) -> serde_json::Value {
2719        serde_json::json!({
2720            "repositoryOwner": {
2721                "__typename": kind,
2722                "projectsV2": { "nodes": [{
2723                    "id": "PVT_1",
2724                    "number": 7,
2725                    "title": "Roadmap",
2726                    "url": "https://github.com/orgs/machinefabric/projects/7",
2727                    "shortDescription": null,
2728                    "closed": false,
2729                    "updatedAt": "2026-08-19T09:00:00Z",
2730                    "fields": { "nodes": [] }
2731                }]}
2732            }
2733        })
2734    }
2735
2736    /// TEST10074: a board is read whether its owner is a user or an
2737    /// organisation.
2738    ///
2739    /// The query asked `organization(login:)` and `user(login:)` in one go and
2740    /// took whichever came back. It looks equivalent to asking for the owner
2741    /// and is not: GitHub answers the field that does not match with an ERROR,
2742    /// and an answer carrying errors is a failed query — so every owner failed
2743    /// with "could not resolve to an Organization" or "to a User", and the
2744    /// Projects tab was empty on a worktree whose owners both had boards.
2745    #[test]
2746    fn test10074_a_board_is_read_for_either_kind_of_owner() {
2747        for kind in ["Organization", "User"] {
2748            let boards = projects_of_owner(&owner_with_a_board(kind), "machinefabric")
2749                .expect("the answer parses")
2750                .items;
2751            assert_eq!(boards.len(), 1, "no board came back for a {kind} owner");
2752            assert_eq!(boards[0].number, 7);
2753            assert_eq!(boards[0].title, "Roadmap");
2754            assert_eq!(boards[0].owner, "machinefabric");
2755        }
2756    }
2757
2758    /// TEST10075: an owner who is nobody has no boards, and is not a failure.
2759    ///
2760    /// `repositoryOwner` answers null rather than erroring for a login that
2761    /// does not resolve. That is an owner with nothing to mirror — an account
2762    /// that was renamed or closed — and treating it as a failure would stop
2763    /// the sweep over the owners that do exist. A REAL failure, a token
2764    /// without `read:project`, arrives as a GraphQL error instead and is
2765    /// raised before this is ever reached.
2766    #[test]
2767    fn test10075_an_owner_who_does_not_resolve_has_no_boards() {
2768        let nobody = serde_json::json!({ "repositoryOwner": serde_json::Value::Null });
2769        assert!(projects_of_owner(&nobody, "gone")
2770            .expect("a null owner is an answer")
2771            .items
2772            .is_empty());
2773
2774        // An owner kind that carries no boards field at all — a shape GitHub
2775        // can grow — is the same thing and not a parse failure.
2776        let unknown = serde_json::json!({
2777            "repositoryOwner": { "__typename": "Mannequin" }
2778        });
2779        assert!(projects_of_owner(&unknown, "someone")
2780            .expect("an unknown owner kind is an answer")
2781            .items
2782            .is_empty());
2783    }
2784
2785    /// TEST20001: a board list says where it continues, and a list that
2786    /// claims more without saying where is refused.
2787    ///
2788    /// The list was one page of twenty. It is authoritative — a board absent
2789    /// from it is forgotten with its cards — so an owner with a twenty-first
2790    /// board lost it on every sweep.
2791    #[test]
2792    fn test20001_a_board_list_continues_or_is_refused() {
2793        let mut answer = owner_with_a_board("Organization");
2794        answer["repositoryOwner"]["projectsV2"]["pageInfo"] =
2795            serde_json::json!({ "hasNextPage": true, "endCursor": "Y3Vyc29y" });
2796        let page = projects_of_owner(&answer, "machinefabric").expect("the answer parses");
2797        assert_eq!(page.items.len(), 1);
2798        assert_eq!(page.next.as_deref(), Some("Y3Vyc29y"), "the next page was not carried");
2799
2800        answer["repositoryOwner"]["projectsV2"]["pageInfo"] =
2801            serde_json::json!({ "hasNextPage": false, "endCursor": "Y3Vyc29y" });
2802        assert_eq!(
2803            projects_of_owner(&answer, "machinefabric").expect("parses").next,
2804            None,
2805            "the last page named a next one"
2806        );
2807
2808        answer["repositoryOwner"]["projectsV2"]["pageInfo"] =
2809            serde_json::json!({ "hasNextPage": true, "endCursor": null });
2810        let said = format!(
2811            "{:#}",
2812            projects_of_owner(&answer, "machinefabric").expect_err("a contradiction is refused")
2813        );
2814        assert!(said.contains("machinefabric's board list"), "{said}");
2815    }
2816
2817    /// TEST20002: the viewer answer is the token's login and every
2818    /// organisation on the page, and a missing viewer is refused.
2819    ///
2820    /// These are the owners whose boards are shown without anybody naming
2821    /// them, so an answer read as "nobody, in no organisations" would show no
2822    /// boards at all and look like a person who has none.
2823    #[test]
2824    fn test20002_the_viewer_is_a_login_and_its_organisations() {
2825        let answer = serde_json::json!({ "viewer": {
2826            "login": "bahram",
2827            "organizations": {
2828                "pageInfo": { "hasNextPage": true, "endCursor": "T1JH" },
2829                "nodes": [{ "login": "machinefabric" }, { "login": "floom" }]
2830            }
2831        }});
2832        let page = viewer_page(&answer).expect("the answer parses");
2833        assert_eq!(page.login, "bahram");
2834        assert_eq!(page.organizations.items, vec!["machinefabric".to_string(), "floom".to_string()]);
2835        assert_eq!(page.organizations.next.as_deref(), Some("T1JH"));
2836
2837        let nobody = serde_json::json!({ "viewer": serde_json::Value::Null });
2838        assert!(viewer_page(&nobody).is_err(), "a token with no account was read as one");
2839        let unlisted = serde_json::json!({ "viewer": { "login": "bahram" } });
2840        assert!(
2841            viewer_page(&unlisted).is_err(),
2842            "an answer without the organisation list was read as no organisations"
2843        );
2844    }
2845
2846    /**
2847     * TEST10196: replacing a single-select option list must preserve every
2848     * existing option's identity, colour and description. GitHub clears card
2849     * values when an existing id is omitted, so a partial input is data loss,
2850     * not a cosmetic update.
2851     */
2852    #[test]
2853    fn test10196_a_column_update_preserves_the_complete_existing_options() {
2854        let option = GitHubProjectFieldOption {
2855            id: "OPT_TODO".to_string(),
2856            name: "Todo".to_string(),
2857            color: "GRAY".to_string(),
2858            description: "not started".to_string(),
2859        };
2860        let input = serde_json::to_value(ProjectColumnOptionInput::from(&option))
2861            .expect("the GraphQL input serializes");
2862        assert_eq!(input["id"], "OPT_TODO");
2863        assert_eq!(input["name"], "Todo");
2864        assert_eq!(input["color"], "GRAY");
2865        assert_eq!(input["description"], "not started");
2866
2867        let new_option = ProjectColumnOptionInput {
2868            id: None,
2869            name: "Blocked".to_string(),
2870            color: "RED".to_string(),
2871            description: String::new(),
2872        };
2873        let new_input = serde_json::to_value(new_option).expect("new option serializes");
2874        assert!(
2875            new_input.get("id").is_none(),
2876            "GitHub assigns a new option id"
2877        );
2878        assert!(validate_column_color("RED").is_ok());
2879        assert!(validate_column_color("#ff0000").is_err());
2880    }
2881}
2882
2883#[cfg(test)]
2884mod column_description_tests {
2885    use super::*;
2886
2887    fn option(json: serde_json::Value) -> Result<GitHubProjectFieldOption, serde_json::Error> {
2888        serde_json::from_value(json)
2889    }
2890
2891    /// TEST11607: a column GitHub says has no description reads as having none.
2892    ///
2893    /// `null` is the ordinary answer for a column nobody described, and it was
2894    /// once refused — so a board whose columns had no descriptions could not be
2895    /// read at all, which is most boards.
2896    #[test]
2897    fn test11607_a_null_description_is_no_description() {
2898        let read = option(serde_json::json!({
2899            "id": "OPT_1", "name": "Todo", "color": "GRAY", "description": null
2900        }))
2901        .expect("a column with no description is an ordinary column");
2902        assert_eq!(read.description, "");
2903    }
2904
2905    /// TEST11608: a column whose description was never asked for is refused.
2906    ///
2907    /// The opposite direction, and the one that costs data. An absent key means
2908    /// this build did not ask GitHub for the field, so nothing here knows what
2909    /// it holds — and updating a column list writes the WHOLE configuration
2910    /// back, so reading absence as "" erases a description nobody touched.
2911    ///
2912    /// These two were the same case for a while, because the field carried a
2913    /// serde `default` as well as a null-reader. Fixing the first direction is
2914    /// what quietly enabled the second.
2915    #[test]
2916    fn test11608_an_absent_description_is_not_read_as_empty() {
2917        let refusal = option(serde_json::json!({
2918            "id": "OPT_1", "name": "Todo", "color": "GRAY"
2919        }))
2920        .expect_err("a column with no description FIELD was read as having none");
2921        assert!(
2922            refusal.to_string().contains("description"),
2923            "the refusal does not say which field: {refusal}"
2924        );
2925    }
2926
2927    /// TEST11609: a description that is there is kept exactly.
2928    ///
2929    /// The control. A reader that refused everything, or emptied everything,
2930    /// would satisfy one of the two tests above and lose the actual text.
2931    #[test]
2932    fn test11609_a_real_description_survives() {
2933        let read = option(serde_json::json!({
2934            "id": "OPT_1", "name": "Todo", "color": "GRAY",
2935            "description": "not started yet"
2936        }))
2937        .expect("a described column reads");
2938        assert_eq!(read.description, "not started yet");
2939    }
2940}