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