1use 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#[derive(Clone)]
28pub struct GitHubClient {
29 client: reqwest::Client,
30 token: String,
31 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#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct GitHubPull {
157 pub number: i64,
158 pub title: String,
159 pub body: Option<String>,
160 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 #[serde(rename = "ref")]
182 pub branch: String,
183 pub sha: String,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192pub struct GitHubChecks {
193 pub sha: String,
195 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 pub status: String,
207 pub conclusion: Option<String>,
210 pub url: Option<String>,
211}
212
213impl GitHubChecks {
214 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 "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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct GitHubNotification {
327 pub id: String,
328 pub unread: bool,
329 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 #[serde(rename = "type")]
341 pub kind: String,
342 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
358pub struct GitHubRateLimit {
359 pub limit: i64,
360 pub remaining: i64,
361 pub reset: i64,
363 pub used: i64,
364}
365
366#[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 #[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#[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#[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
419const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
430
431const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
434
435impl GitHubClient {
436 pub fn new(token: &str) -> Self {
437 Self::against("https://api.github.com", token)
438 }
439
440 pub fn against(base: &str, token: &str) -> Self {
442 Self::bounded(base, token, REQUEST_TIMEOUT)
443 }
444
445 pub fn against_within(base: &str, token: &str, request: std::time::Duration) -> Self {
449 Self::bounded(base, token, request)
450 }
451
452 fn bounded(base: &str, token: &str, request: std::time::Duration) -> Self {
453 let client = reqwest::Client::builder()
454 .user_agent(concat!("wsb-github/", env!("CARGO_PKG_VERSION")))
455 .no_proxy()
456 .connect_timeout(CONNECT_TIMEOUT.min(request))
457 .timeout(request)
458 .build()
459 .expect("failed to build reqwest client");
460
461 Self {
462 client,
463 token: token.to_string(),
464 base: base.trim_end_matches('/').to_string(),
465 }
466 }
467
468 fn url(&self, path: &str) -> String {
470 format!("{}{}", self.base, path)
471 }
472
473 pub async fn validate_token(&self) -> Result<String> {
475 let response = require_success(
476 "GitHub token validation",
477 self.client
478 .get(self.url("/user"))
479 .bearer_auth(&self.token)
480 .send()
481 .await
482 .context("GitHub API request failed")?,
483 )?;
484 let resp: GitHubUser = response
485 .json()
486 .await
487 .context("failed to parse GitHub user response")?;
488 Ok(resp.login)
489 }
490
491 pub async fn list_rulesets(&self, owner: &str, repo: &str) -> Result<Vec<RepositoryRuleset>> {
497 let response = require_success(
498 "listing rulesets",
499 self.client
500 .get(self.url(&format!("/repos/{owner}/{repo}/rulesets")))
501 .bearer_auth(&self.token)
502 .send()
503 .await
504 .context("GitHub API request failed")?,
505 )?;
506 response
507 .json()
508 .await
509 .context("failed to parse the ruleset list")
510 }
511
512 pub async fn put_ruleset(
518 &self,
519 owner: &str,
520 repo: &str,
521 name: &str,
522 body: &serde_json::Value,
523 ) -> Result<RepositoryRuleset> {
524 let existing = self
525 .list_rulesets(owner, repo)
526 .await?
527 .into_iter()
528 .find(|ruleset| ruleset.name == name);
529
530 let response = match &existing {
531 Some(ruleset) => {
532 self.client
533 .put(self.url(&format!(
534 "/repos/{owner}/{repo}/rulesets/{}",
535 ruleset.id
536 )))
537 .bearer_auth(&self.token)
538 .json(body)
539 .send()
540 .await
541 }
542 None => {
543 self.client
544 .post(self.url(&format!("/repos/{owner}/{repo}/rulesets")))
545 .bearer_auth(&self.token)
546 .json(body)
547 .send()
548 .await
549 }
550 }
551 .context("GitHub API request failed")?;
552
553 let response = require_success(
554 if existing.is_some() {
555 "updating a ruleset"
556 } else {
557 "creating a ruleset"
558 },
559 response,
560 )?;
561 response
562 .json()
563 .await
564 .context("failed to parse the ruleset GitHub wrote")
565 }
566}
567
568#[derive(Debug, Clone, PartialEq, Eq)]
575pub enum Fetched<T> {
576 Fresh { value: T, etag: Option<String> },
578 Unchanged,
582}
583
584impl<T> Fetched<T> {
585 pub fn value(self) -> Option<T> {
586 match self {
587 Self::Fresh { value, .. } => Some(value),
588 Self::Unchanged => None,
589 }
590 }
591}
592
593#[derive(Debug, Clone, Default)]
595pub struct Conditions {
596 pub etag: Option<String>,
598 pub since: Option<String>,
600}
601
602impl GitHubClient {
603 pub async fn list_issues_changed(
615 &self,
616 owner: &str,
617 repo: &str,
618 conditions: &Conditions,
619 ) -> Result<Fetched<Vec<GitHubIssue>>> {
620 let mut url = self.url(&format!(
621 "/repos/{owner}/{repo}/issues?state=all&per_page=100&sort=updated&direction=desc"
622 ));
623 if let Some(since) = &conditions.since {
624 url.push_str(&format!("&since={since}"));
625 }
626
627 let mut request = self.client.get(&url).bearer_auth(&self.token);
628 if let Some(etag) = &conditions.etag {
629 request = request.header(reqwest::header::IF_NONE_MATCH, etag);
630 }
631 let response = request
632 .send()
633 .await
634 .with_context(|| format!("failed to list issues for {owner}/{repo}"))?;
635
636 if response.status() == reqwest::StatusCode::NOT_MODIFIED {
637 return Ok(Fetched::Unchanged);
638 }
639 let etag = header_value(&response, reqwest::header::ETAG);
640 let response = require_success(
641 &format!("GitHub API error listing issues for {owner}/{repo}"),
642 response,
643 )?;
644 let issues: Vec<GitHubIssue> = response
645 .json()
646 .await
647 .context("failed to parse issues response")?;
648
649 Ok(Fetched::Fresh {
650 value: issues
653 .into_iter()
654 .filter(|issue| !issue.html_url.contains("/pull/"))
655 .collect(),
656 etag,
657 })
658 }
659
660 pub async fn list_gists_changed(
666 &self,
667 conditions: &Conditions,
668 ) -> Result<Fetched<Vec<GitHubGist>>> {
669 let mut request = self
670 .client
671 .get(self.url("/gists?per_page=100"))
672 .bearer_auth(&self.token);
673 if let Some(etag) = &conditions.etag {
674 request = request.header(reqwest::header::IF_NONE_MATCH, etag);
675 }
676 let response = request.send().await.context("failed to list gists")?;
677
678 if response.status() == reqwest::StatusCode::NOT_MODIFIED {
679 return Ok(Fetched::Unchanged);
680 }
681 let etag = header_value(&response, reqwest::header::ETAG);
682 let response = require_success("failed to list gists", response)?;
683 let gists: Vec<GitHubGist> = response
684 .json()
685 .await
686 .context("failed to parse gists response")?;
687 Ok(Fetched::Fresh { value: gists, etag })
688 }
689}
690
691impl GitHubClient {
692 async fn conditional<T: serde::de::DeserializeOwned>(
699 &self,
700 what: &str,
701 path: &str,
702 conditions: &Conditions,
703 ) -> Result<Fetched<T>> {
704 let mut request = self.client.get(self.url(path)).bearer_auth(&self.token);
705 if let Some(etag) = &conditions.etag {
706 request = request.header(reqwest::header::IF_NONE_MATCH, etag);
707 }
708 let response = request
709 .send()
710 .await
711 .with_context(|| format!("failed {what}"))?;
712
713 if response.status() == reqwest::StatusCode::NOT_MODIFIED {
714 return Ok(Fetched::Unchanged);
715 }
716 let etag = header_value(&response, reqwest::header::ETAG);
717 let response = require_success(what, response)?;
718 let value: T = response
719 .json()
720 .await
721 .with_context(|| format!("failed to parse the answer to {what}"))?;
722 Ok(Fetched::Fresh { value, etag })
723 }
724
725 pub async fn list_pulls_changed(
732 &self,
733 owner: &str,
734 repo: &str,
735 conditions: &Conditions,
736 ) -> Result<Fetched<Vec<GitHubPull>>> {
737 self.conditional(
738 &format!("listing pull requests for {owner}/{repo}"),
739 &format!("/repos/{owner}/{repo}/pulls?state=all&per_page=100&sort=updated&direction=desc"),
740 conditions,
741 )
742 .await
743 }
744
745 pub async fn checks_for(&self, owner: &str, repo: &str, commit: &str) -> Result<GitHubChecks> {
751 #[derive(Deserialize)]
752 struct CheckRuns {
753 check_runs: Vec<RawCheckRun>,
754 }
755 #[derive(Deserialize)]
756 struct RawCheckRun {
757 name: String,
758 status: String,
759 conclusion: Option<String>,
760 html_url: Option<String>,
761 }
762 #[derive(Deserialize)]
763 struct CombinedStatus {
764 statuses: Vec<RawStatus>,
765 }
766 #[derive(Deserialize)]
767 struct RawStatus {
768 context: String,
769 state: String,
770 target_url: Option<String>,
771 }
772
773 let runs: CheckRuns = require_success(
774 &format!("listing check runs for {owner}/{repo}@{commit}"),
775 self.client
776 .get(self.url(&format!(
777 "/repos/{owner}/{repo}/commits/{commit}/check-runs?per_page=100"
778 )))
779 .bearer_auth(&self.token)
780 .send()
781 .await
782 .context("GitHub API request failed")?,
783 )?
784 .json()
785 .await
786 .context("failed to parse the check runs")?;
787
788 let combined: CombinedStatus = require_success(
789 &format!("listing commit statuses for {owner}/{repo}@{commit}"),
790 self.client
791 .get(self.url(&format!(
792 "/repos/{owner}/{repo}/commits/{commit}/status"
793 )))
794 .bearer_auth(&self.token)
795 .send()
796 .await
797 .context("GitHub API request failed")?,
798 )?
799 .json()
800 .await
801 .context("failed to parse the commit statuses")?;
802
803 let mut all: Vec<GitHubCheckRun> = runs
804 .check_runs
805 .into_iter()
806 .map(|run| GitHubCheckRun {
807 name: run.name,
808 status: run.status,
809 conclusion: run.conclusion,
810 url: run.html_url,
811 })
812 .collect();
813 all.extend(combined.statuses.into_iter().map(|status| GitHubCheckRun {
814 name: status.context,
815 status: "completed".to_string(),
818 conclusion: Some(match status.state.as_str() {
819 "success" => "success".to_string(),
820 "failure" | "error" => "failure".to_string(),
821 other => other.to_string(),
822 }),
823 url: status.target_url,
824 }));
825
826 Ok(GitHubChecks {
827 sha: commit.to_string(),
828 state: GitHubChecks::rollup(&all),
829 runs: all,
830 })
831 }
832
833 pub async fn list_releases_changed(
834 &self,
835 owner: &str,
836 repo: &str,
837 conditions: &Conditions,
838 ) -> Result<Fetched<Vec<GitHubRelease>>> {
839 self.conditional(
840 &format!("listing releases for {owner}/{repo}"),
841 &format!("/repos/{owner}/{repo}/releases?per_page=100"),
842 conditions,
843 )
844 .await
845 }
846
847 pub async fn list_tags_changed(
848 &self,
849 owner: &str,
850 repo: &str,
851 conditions: &Conditions,
852 ) -> Result<Fetched<Vec<GitHubTag>>> {
853 self.conditional(
854 &format!("listing tags for {owner}/{repo}"),
855 &format!("/repos/{owner}/{repo}/tags?per_page=100"),
856 conditions,
857 )
858 .await
859 }
860
861 pub async fn repository_changed(
862 &self,
863 owner: &str,
864 repo: &str,
865 conditions: &Conditions,
866 ) -> Result<Fetched<GitHubRepository>> {
867 self.conditional(
868 &format!("reading {owner}/{repo}"),
869 &format!("/repos/{owner}/{repo}"),
870 conditions,
871 )
872 .await
873 }
874
875 pub async fn list_milestones_changed(
876 &self,
877 owner: &str,
878 repo: &str,
879 conditions: &Conditions,
880 ) -> Result<Fetched<Vec<GitHubMilestone>>> {
881 self.conditional(
882 &format!("listing milestones for {owner}/{repo}"),
883 &format!("/repos/{owner}/{repo}/milestones?state=all&per_page=100"),
884 conditions,
885 )
886 .await
887 }
888
889 pub async fn list_labels_changed(
890 &self,
891 owner: &str,
892 repo: &str,
893 conditions: &Conditions,
894 ) -> Result<Fetched<Vec<GitHubLabel>>> {
895 self.conditional(
896 &format!("listing labels for {owner}/{repo}"),
897 &format!("/repos/{owner}/{repo}/labels?per_page=100"),
898 conditions,
899 )
900 .await
901 }
902
903 pub async fn list_comments_changed(
905 &self,
906 owner: &str,
907 repo: &str,
908 number: i64,
909 conditions: &Conditions,
910 ) -> Result<Fetched<Vec<GitHubComment>>> {
911 self.conditional(
912 &format!("listing comments on {owner}/{repo}#{number}"),
913 &format!("/repos/{owner}/{repo}/issues/{number}/comments?per_page=100"),
914 conditions,
915 )
916 .await
917 }
918
919 pub async fn list_notifications_changed(
921 &self,
922 conditions: &Conditions,
923 ) -> Result<Fetched<Vec<GitHubNotification>>> {
924 self.conditional(
925 "listing notifications",
926 "/notifications?all=false&per_page=50",
927 conditions,
928 )
929 .await
930 }
931
932 pub async fn rate_limit(&self) -> Result<GitHubRateLimit> {
938 #[derive(Deserialize)]
939 struct Envelope {
940 resources: Resources,
941 }
942 #[derive(Deserialize)]
943 struct Resources {
944 core: GitHubRateLimit,
945 }
946 let envelope: Envelope = require_success(
947 "reading the rate limit",
948 self.client
949 .get(self.url("/rate_limit"))
950 .bearer_auth(&self.token)
951 .send()
952 .await
953 .context("GitHub API request failed")?,
954 )?
955 .json()
956 .await
957 .context("failed to parse the rate limit")?;
958 Ok(envelope.resources.core)
959 }
960
961 pub async fn traffic(&self, owner: &str, repo: &str) -> Result<GitHubTraffic> {
967 let views: TrafficSeries = require_success(
968 &format!("reading traffic for {owner}/{repo}"),
969 self.client
970 .get(self.url(&format!("/repos/{owner}/{repo}/traffic/views")))
971 .bearer_auth(&self.token)
972 .send()
973 .await
974 .context("GitHub API request failed")?,
975 )?
976 .json()
977 .await
978 .context("failed to parse the view traffic")?;
979
980 let clones: TrafficSeries = require_success(
981 &format!("reading clone traffic for {owner}/{repo}"),
982 self.client
983 .get(self.url(&format!("/repos/{owner}/{repo}/traffic/clones")))
984 .bearer_auth(&self.token)
985 .send()
986 .await
987 .context("GitHub API request failed")?,
988 )?
989 .json()
990 .await
991 .context("failed to parse the clone traffic")?;
992
993 Ok(GitHubTraffic {
994 views: views.count,
995 unique_views: views.uniques,
996 clones: clones.count,
997 unique_clones: clones.uniques,
998 view_days: views.views,
999 clone_days: clones.clones,
1000 })
1001 }
1002}
1003
1004#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1020pub struct GitHubViewer {
1021 pub login: String,
1022 pub organizations: Vec<String>,
1024}
1025
1026#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1028pub struct GitHubProject {
1029 pub id: String,
1031 pub number: i64,
1032 pub title: String,
1033 pub url: String,
1034 pub short_description: Option<String>,
1035 pub closed: bool,
1036 pub updated_at: String,
1037 pub owner: String,
1041 pub fields: Vec<GitHubProjectField>,
1043}
1044
1045impl GitHubProject {
1046 pub fn column_field(&self) -> Option<&GitHubProjectField> {
1054 self.fields
1055 .iter()
1056 .find(|field| field.kind == "single_select" && field.name == "Status")
1057 .or_else(|| {
1058 self.fields
1059 .iter()
1060 .find(|field| field.kind == "single_select")
1061 })
1062 }
1063}
1064
1065#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1066pub struct GitHubProjectField {
1067 pub id: String,
1068 pub name: String,
1069 pub kind: String,
1071 #[serde(default)]
1073 pub options: Vec<GitHubProjectFieldOption>,
1074}
1075
1076#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1077pub struct GitHubProjectFieldOption {
1078 pub id: String,
1079 pub name: String,
1080 pub color: String,
1082 #[serde(deserialize_with = "text_or_nothing")]
1096 pub description: String,
1097}
1098
1099fn text_or_nothing<'de, D>(deserializer: D) -> Result<String, D::Error>
1107where
1108 D: serde::Deserializer<'de>,
1109{
1110 Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_default())
1111}
1112
1113#[derive(Debug, Clone, Serialize)]
1114struct ProjectColumnOptionInput {
1115 #[serde(skip_serializing_if = "Option::is_none")]
1116 id: Option<String>,
1117 name: String,
1118 color: String,
1119 description: String,
1120}
1121
1122impl From<&GitHubProjectFieldOption> for ProjectColumnOptionInput {
1123 fn from(option: &GitHubProjectFieldOption) -> Self {
1124 Self {
1125 id: Some(option.id.clone()),
1126 name: option.name.clone(),
1127 color: option.color.clone(),
1128 description: option.description.clone(),
1129 }
1130 }
1131}
1132
1133#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1135pub struct GitHubProjectItem {
1136 pub id: String,
1137 pub kind: String,
1139 pub title: String,
1140 pub content_id: Option<String>,
1145 pub number: Option<i64>,
1148 pub url: Option<String>,
1149 pub repository: Option<String>,
1151 pub state: Option<String>,
1153 pub state_reason: Option<String>,
1157 pub body: Option<String>,
1160 pub author: Option<GitHubUser>,
1161 #[serde(default)]
1162 pub assignees: Vec<GitHubUser>,
1163 #[serde(default)]
1164 pub labels: Vec<GitHubLabel>,
1165 pub milestone: Option<String>,
1166 #[serde(default)]
1170 pub comment_count: i64,
1171 pub created_at: Option<String>,
1172 pub closed_at: Option<String>,
1173 #[serde(default)]
1177 pub is_draft: bool,
1178 #[serde(default)]
1179 pub merged: bool,
1180 pub review_decision: Option<String>,
1183 #[serde(default)]
1186 pub archived: bool,
1187 #[serde(default)]
1191 pub field_values: BTreeMap<String, GitHubProjectValue>,
1192 pub updated_at: String,
1193}
1194
1195impl GitHubProjectItem {
1196 pub fn column(&self, field_id: &str) -> Option<&str> {
1198 match self.field_values.get(field_id)? {
1199 GitHubProjectValue::SingleSelect { option_id, .. } => Some(option_id),
1200 _ => None,
1201 }
1202 }
1203}
1204
1205#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1210#[serde(tag = "kind", rename_all = "snake_case")]
1211pub enum GitHubProjectValue {
1212 SingleSelect {
1216 option_id: String,
1217 name: String,
1218 },
1219 Text {
1220 text: String,
1221 },
1222 Number {
1223 number: f64,
1224 },
1225 Date {
1226 date: String,
1227 },
1228 Iteration {
1229 title: String,
1230 start_date: String,
1231 },
1232}
1233
1234const ITEM_FRAGMENT: &str = r#"
1241fragment Card on ProjectV2Item {
1242 id type updatedAt isArchived
1243 fieldValues(first: 30) {
1244 nodes {
1245 ... on ProjectV2ItemFieldSingleSelectValue { name optionId field { ... on ProjectV2FieldCommon { id } } }
1246 ... on ProjectV2ItemFieldTextValue { text field { ... on ProjectV2FieldCommon { id } } }
1247 ... on ProjectV2ItemFieldNumberValue { number field { ... on ProjectV2FieldCommon { id } } }
1248 ... on ProjectV2ItemFieldDateValue { date field { ... on ProjectV2FieldCommon { id } } }
1249 ... on ProjectV2ItemFieldIterationValue { title startDate field { ... on ProjectV2FieldCommon { id } } }
1250 }
1251 }
1252 content {
1253 ... on DraftIssue {
1254 id title body createdAt updatedAt
1255 creator { login avatarUrl }
1256 assignees(first: 10) { nodes { login avatarUrl } }
1257 }
1258 ... on Issue {
1259 id title body number url state stateReason createdAt closedAt
1260 repository { nameWithOwner }
1261 author { login avatarUrl }
1262 assignees(first: 10) { nodes { login avatarUrl } }
1263 labels(first: 20) { nodes { name color description } }
1264 milestone { title }
1265 comments { totalCount }
1266 }
1267 ... on PullRequest {
1268 id title body number url state createdAt closedAt isDraft merged reviewDecision
1269 repository { nameWithOwner }
1270 author { login avatarUrl }
1271 assignees(first: 10) { nodes { login avatarUrl } }
1272 labels(first: 20) { nodes { name color description } }
1273 milestone { title }
1274 comments { totalCount }
1275 }
1276 }
1277}"#;
1278
1279impl GitHubClient {
1280 async fn graphql(
1287 &self,
1288 what: &str,
1289 query: &str,
1290 variables: serde_json::Value,
1291 ) -> Result<serde_json::Value> {
1292 let response = require_success(
1293 what,
1294 self.client
1295 .post(self.url("/graphql"))
1296 .bearer_auth(&self.token)
1297 .json(&serde_json::json!({ "query": query, "variables": variables }))
1298 .send()
1299 .await
1300 .with_context(|| format!("GitHub API request failed while {what}"))?,
1301 )?;
1302 let body: serde_json::Value = response
1303 .json()
1304 .await
1305 .with_context(|| format!("failed to parse the answer to {what}"))?;
1306
1307 if let Some(errors) = body.get("errors").and_then(|errors| errors.as_array()) {
1308 if !errors.is_empty() {
1309 let said = errors
1310 .iter()
1311 .filter_map(|error| error.get("message").and_then(|m| m.as_str()))
1312 .collect::<Vec<_>>()
1313 .join("; ");
1314 anyhow::bail!("{what}: {said}");
1315 }
1316 }
1317 body.get("data")
1318 .cloned()
1319 .ok_or_else(|| anyhow::anyhow!("{what}: the answer carried no data"))
1320 }
1321
1322 pub async fn list_projects(&self, login: &str) -> Result<Vec<GitHubProject>> {
1342 const QUERY: &str = r#"
1343query($login: String!, $after: String) {
1344 repositoryOwner(login: $login) {
1345 __typename
1346 ... on Organization { projectsV2(first: 100, after: $after, orderBy: {field: UPDATED_AT, direction: DESC}) { ...Page } }
1347 ... on User { projectsV2(first: 100, after: $after, orderBy: {field: UPDATED_AT, direction: DESC}) { ...Page } }
1348 }
1349}
1350fragment Page on ProjectV2Connection {
1351 pageInfo { hasNextPage endCursor }
1352 nodes { ...P }
1353}
1354fragment P on ProjectV2 {
1355 id number title url shortDescription closed updatedAt
1356 fields(first: 50) {
1357 nodes {
1358 ... on ProjectV2FieldCommon { id name dataType }
1359 ... on ProjectV2SingleSelectField { id name dataType options { id name color description } }
1360 }
1361 }
1362}"#;
1363 let mut projects = Vec::new();
1364 let mut after: Option<String> = None;
1365 loop {
1366 let data = self
1367 .graphql(
1368 &format!("listing projects for {login}"),
1369 QUERY,
1370 serde_json::json!({ "login": login, "after": after }),
1371 )
1372 .await?;
1373 let page = projects_of_owner(&data, login)?;
1374 projects.extend(page.items);
1375 match page.next {
1376 Some(cursor) => after = Some(cursor),
1377 None => break,
1378 }
1379 }
1380 Ok(projects)
1381 }
1382
1383 pub async fn viewer(&self) -> Result<GitHubViewer> {
1393 const QUERY: &str = r#"
1394query($after: String) {
1395 viewer {
1396 login
1397 organizations(first: 100, after: $after) {
1398 pageInfo { hasNextPage endCursor }
1399 nodes { login }
1400 }
1401 }
1402}"#;
1403 let mut organizations = Vec::new();
1404 let mut after: Option<String> = None;
1405 let login = loop {
1406 let data = self
1407 .graphql(
1408 "reading who the token belongs to",
1409 QUERY,
1410 serde_json::json!({ "after": after }),
1411 )
1412 .await?;
1413 let page = viewer_page(&data)?;
1414 organizations.extend(page.organizations.items);
1415 match page.organizations.next {
1416 Some(cursor) => after = Some(cursor),
1417 None => break page.login,
1418 }
1419 };
1420 Ok(GitHubViewer { login, organizations })
1421 }
1422
1423 async fn project(&self, project_id: &str) -> Result<GitHubProject> {
1425 const QUERY: &str = r#"
1426query($id: ID!) {
1427 node(id: $id) {
1428 ... on ProjectV2 {
1429 id number title url shortDescription closed updatedAt
1430 owner {
1431 ... on Organization { login }
1432 ... on User { login }
1433 }
1434 fields(first: 50) {
1435 nodes {
1436 ... on ProjectV2FieldCommon { id name dataType }
1437 ... on ProjectV2SingleSelectField {
1438 id name dataType options { id name color description }
1439 }
1440 }
1441 }
1442 }
1443 }
1444}"#;
1445 let data = self
1446 .graphql(
1447 &format!("reading project {project_id}"),
1448 QUERY,
1449 serde_json::json!({ "id": project_id }),
1450 )
1451 .await?;
1452 let node = data
1453 .get("node")
1454 .filter(|node| !node.is_null())
1455 .ok_or_else(|| anyhow::anyhow!("project {project_id} no longer exists"))?;
1456 let owner = node
1457 .get("owner")
1458 .and_then(|owner| owner.get("login"))
1459 .and_then(|login| login.as_str())
1460 .ok_or_else(|| anyhow::anyhow!("project {project_id} has no readable owner"))?;
1461 parse_project(node, owner)
1462 }
1463
1464 pub async fn add_project_column(
1471 &self,
1472 project_id: &str,
1473 field_id: &str,
1474 name: &str,
1475 color: &str,
1476 description: &str,
1477 ) -> Result<GitHubProject> {
1478 validate_column_color(color)?;
1479 let name = name.trim();
1480 if name.is_empty() {
1481 anyhow::bail!("a project column needs a name");
1482 }
1483 let project = self.project(project_id).await?;
1484 let field = project
1485 .fields
1486 .iter()
1487 .find(|field| field.id == field_id)
1488 .ok_or_else(|| anyhow::anyhow!("project {project_id} has no field {field_id}"))?;
1489 if field.kind != "single_select" {
1490 anyhow::bail!(
1491 "field {field_id} is {}, not a single-select column field",
1492 field.kind
1493 );
1494 }
1495 if field
1496 .options
1497 .iter()
1498 .any(|option| option.name.eq_ignore_ascii_case(name))
1499 {
1500 anyhow::bail!("project {project_id} already has a column named '{name}'");
1501 }
1502
1503 let mut options: Vec<ProjectColumnOptionInput> = field
1504 .options
1505 .iter()
1506 .map(ProjectColumnOptionInput::from)
1507 .collect();
1508 options.push(ProjectColumnOptionInput {
1509 id: None,
1510 name: name.to_string(),
1511 color: color.to_string(),
1512 description: description.trim().to_string(),
1513 });
1514 self.update_project_columns(field_id, &options).await?;
1515 self.project(project_id).await
1516 }
1517
1518 pub async fn remove_project_column(
1524 &self,
1525 project_id: &str,
1526 field_id: &str,
1527 option_id: &str,
1528 ) -> Result<GitHubProject> {
1529 let project = self.project(project_id).await?;
1530 let field = project
1531 .fields
1532 .iter()
1533 .find(|field| field.id == field_id)
1534 .ok_or_else(|| anyhow::anyhow!("project {project_id} has no field {field_id}"))?;
1535 if field.kind != "single_select" {
1536 anyhow::bail!(
1537 "field {field_id} is {}, not a single-select column field",
1538 field.kind
1539 );
1540 }
1541 if !field.options.iter().any(|option| option.id == option_id) {
1542 anyhow::bail!("field {field_id} has no column {option_id}");
1543 }
1544 if field.options.len() == 1 {
1545 anyhow::bail!("the last project column cannot be removed; a single-select field requires an option");
1546 }
1547 let options: Vec<ProjectColumnOptionInput> = field
1548 .options
1549 .iter()
1550 .filter(|option| option.id != option_id)
1551 .map(ProjectColumnOptionInput::from)
1552 .collect();
1553 self.update_project_columns(field_id, &options).await?;
1554 self.project(project_id).await
1555 }
1556
1557 async fn update_project_columns(
1558 &self,
1559 field_id: &str,
1560 options: &[ProjectColumnOptionInput],
1561 ) -> Result<()> {
1562 const MUTATION: &str = r#"
1563mutation($field: ID!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {
1564 updateProjectV2Field(input: { fieldId: $field, singleSelectOptions: $options }) {
1565 projectV2Field { ... on ProjectV2SingleSelectField { id } }
1566 }
1567}"#;
1568 self.graphql(
1569 &format!("updating the columns of field {field_id}"),
1570 MUTATION,
1571 serde_json::json!({ "field": field_id, "options": options }),
1572 )
1573 .await?;
1574 Ok(())
1575 }
1576
1577 pub async fn project_items(&self, project_id: &str) -> Result<Vec<GitHubProjectItem>> {
1583 let query = format!(
1584 r#"
1585query($id: ID!, $after: String) {{
1586 node(id: $id) {{
1587 ... on ProjectV2 {{
1588 items(first: 50, after: $after) {{
1589 pageInfo {{ hasNextPage endCursor }}
1590 nodes {{ ...Card }}
1591 }}
1592 }}
1593 }}
1594}}
1595{ITEM_FRAGMENT}"#
1596 );
1597 let mut items = Vec::new();
1598 let mut after: Option<String> = None;
1599 loop {
1600 let data = self
1601 .graphql(
1602 &format!("reading the items of project {project_id}"),
1603 &query,
1604 serde_json::json!({ "id": project_id, "after": after }),
1605 )
1606 .await?;
1607 let page = data
1608 .get("node")
1609 .and_then(|node| node.get("items"))
1610 .ok_or_else(|| anyhow::anyhow!("project {project_id} has no items to read"))?;
1611
1612 for node in page
1613 .get("nodes")
1614 .and_then(|nodes| nodes.as_array())
1615 .unwrap_or(&Vec::new())
1616 {
1617 items.push(parse_project_item(node)?);
1618 }
1619
1620 let more = page
1621 .get("pageInfo")
1622 .and_then(|info| info.get("hasNextPage"))
1623 .and_then(|more| more.as_bool())
1624 .unwrap_or(false);
1625 if !more {
1626 break;
1627 }
1628 after = page
1629 .get("pageInfo")
1630 .and_then(|info| info.get("endCursor"))
1631 .and_then(|cursor| cursor.as_str())
1632 .map(str::to_string);
1633 if after.is_none() {
1634 anyhow::bail!("project {project_id} says there is another page and gives no cursor");
1637 }
1638 }
1639 Ok(items)
1640 }
1641
1642 pub async fn project_item(&self, item_id: &str) -> Result<GitHubProjectItem> {
1650 let query = format!(
1651 r#"
1652query($id: ID!) {{ node(id: $id) {{ ...Card }} }}
1653{ITEM_FRAGMENT}"#
1654 );
1655 let data = self
1656 .graphql(
1657 &format!("re-reading item {item_id}"),
1658 &query,
1659 serde_json::json!({ "id": item_id }),
1660 )
1661 .await?;
1662 let node = data
1663 .get("node")
1664 .filter(|node| !node.is_null())
1665 .ok_or_else(|| anyhow::anyhow!("item {item_id} is not on this board any more"))?;
1666 parse_project_item(node)
1667 }
1668
1669 pub async fn set_project_field(
1676 &self,
1677 project_id: &str,
1678 item_id: &str,
1679 field_id: &str,
1680 value: &ProjectFieldWrite,
1681 ) -> Result<GitHubProjectItem> {
1682 const MUTATION: &str = r#"
1683mutation($project: ID!, $item: ID!, $field: ID!, $value: ProjectV2FieldValue!) {
1684 updateProjectV2ItemFieldValue(input: {
1685 projectId: $project, itemId: $item, fieldId: $field, value: $value
1686 }) { projectV2Item { id } }
1687}"#;
1688 self.graphql(
1689 &format!("setting a field on item {item_id}"),
1690 MUTATION,
1691 serde_json::json!({
1692 "project": project_id,
1693 "item": item_id,
1694 "field": field_id,
1695 "value": value.as_input(),
1696 }),
1697 )
1698 .await?;
1699 self.project_item(item_id).await
1700 }
1701
1702 pub async fn clear_project_field(
1708 &self,
1709 project_id: &str,
1710 item_id: &str,
1711 field_id: &str,
1712 ) -> Result<GitHubProjectItem> {
1713 const MUTATION: &str = r#"
1714mutation($project: ID!, $item: ID!, $field: ID!) {
1715 clearProjectV2ItemFieldValue(input: {
1716 projectId: $project, itemId: $item, fieldId: $field
1717 }) { projectV2Item { id } }
1718}"#;
1719 self.graphql(
1720 &format!("clearing a field on item {item_id}"),
1721 MUTATION,
1722 serde_json::json!({ "project": project_id, "item": item_id, "field": field_id }),
1723 )
1724 .await?;
1725 self.project_item(item_id).await
1726 }
1727
1728 pub async fn move_project_item_after(
1734 &self,
1735 project_id: &str,
1736 item_id: &str,
1737 after: Option<&str>,
1738 ) -> Result<GitHubProjectItem> {
1739 const MUTATION: &str = r#"
1740mutation($project: ID!, $item: ID!, $after: ID) {
1741 updateProjectV2ItemPosition(input: {
1742 projectId: $project, itemId: $item, afterId: $after
1743 }) { items { totalCount } }
1744}"#;
1745 self.graphql(
1746 &format!("ordering item {item_id}"),
1747 MUTATION,
1748 serde_json::json!({ "project": project_id, "item": item_id, "after": after }),
1749 )
1750 .await?;
1751 self.project_item(item_id).await
1752 }
1753
1754 pub async fn add_project_draft(
1760 &self,
1761 project_id: &str,
1762 title: &str,
1763 body: &str,
1764 ) -> Result<GitHubProjectItem> {
1765 const MUTATION: &str = r#"
1766mutation($project: ID!, $title: String!, $body: String!) {
1767 addProjectV2DraftIssue(input: { projectId: $project, title: $title, body: $body }) {
1768 projectItem { id }
1769 }
1770}"#;
1771 let data = self
1772 .graphql(
1773 "adding a draft card",
1774 MUTATION,
1775 serde_json::json!({ "project": project_id, "title": title, "body": body }),
1776 )
1777 .await?;
1778 let id = data
1779 .get("addProjectV2DraftIssue")
1780 .and_then(|added| added.get("projectItem"))
1781 .and_then(|item| item.get("id"))
1782 .and_then(|id| id.as_str())
1783 .ok_or_else(|| {
1784 anyhow::anyhow!("the draft was created and GitHub did not say its id")
1785 })?;
1786 self.project_item(id).await
1787 }
1788
1789 pub async fn update_project_draft(
1794 &self,
1795 item_id: &str,
1796 draft_id: &str,
1797 title: &str,
1798 body: &str,
1799 ) -> Result<GitHubProjectItem> {
1800 const MUTATION: &str = r#"
1801mutation($draft: ID!, $title: String!, $body: String!) {
1802 updateProjectV2DraftIssue(input: { draftIssueId: $draft, title: $title, body: $body }) {
1803 draftIssue { id }
1804 }
1805}"#;
1806 self.graphql(
1807 &format!("editing draft {draft_id}"),
1808 MUTATION,
1809 serde_json::json!({ "draft": draft_id, "title": title, "body": body }),
1810 )
1811 .await?;
1812 self.project_item(item_id).await
1813 }
1814
1815 pub async fn add_project_item_by_url(
1821 &self,
1822 project_id: &str,
1823 url: &str,
1824 ) -> Result<GitHubProjectItem> {
1825 const RESOLVE: &str = r#"
1826query($url: URI!) {
1827 resource(url: $url) {
1828 __typename
1829 ... on Issue { id }
1830 ... on PullRequest { id }
1831 }
1832}"#;
1833 let found = self
1834 .graphql(
1835 &format!("looking up {url}"),
1836 RESOLVE,
1837 serde_json::json!({ "url": url }),
1838 )
1839 .await?;
1840 let resource = found
1841 .get("resource")
1842 .filter(|resource| !resource.is_null())
1843 .ok_or_else(|| {
1844 anyhow::anyhow!("{url} is not something on GitHub this token can see")
1845 })?;
1846 let kind = resource
1847 .get("__typename")
1848 .and_then(|kind| kind.as_str())
1849 .unwrap_or("that");
1850 let content = resource.get("id").and_then(|id| id.as_str()).ok_or_else(|| {
1851 anyhow::anyhow!("{url} is a {kind}, and only an issue or a pull request can go on a board")
1852 })?;
1853
1854 const MUTATION: &str = r#"
1855mutation($project: ID!, $content: ID!) {
1856 addProjectV2ItemById(input: { projectId: $project, contentId: $content }) {
1857 item { id }
1858 }
1859}"#;
1860 let data = self
1861 .graphql(
1862 &format!("adding {url} to the board"),
1863 MUTATION,
1864 serde_json::json!({ "project": project_id, "content": content }),
1865 )
1866 .await?;
1867 let id = data
1868 .get("addProjectV2ItemById")
1869 .and_then(|added| added.get("item"))
1870 .and_then(|item| item.get("id"))
1871 .and_then(|id| id.as_str())
1872 .ok_or_else(|| anyhow::anyhow!("the item was added and GitHub did not say its id"))?;
1873 self.project_item(id).await
1874 }
1875
1876 pub async fn archive_project_item(
1882 &self,
1883 project_id: &str,
1884 item_id: &str,
1885 archived: bool,
1886 ) -> Result<GitHubProjectItem> {
1887 const ARCHIVE: &str = r#"
1888mutation($project: ID!, $item: ID!) {
1889 archiveProjectV2Item(input: { projectId: $project, itemId: $item }) { item { id } }
1890}"#;
1891 const UNARCHIVE: &str = r#"
1892mutation($project: ID!, $item: ID!) {
1893 unarchiveProjectV2Item(input: { projectId: $project, itemId: $item }) { item { id } }
1894}"#;
1895 self.graphql(
1896 &format!("archiving item {item_id}"),
1897 if archived { ARCHIVE } else { UNARCHIVE },
1898 serde_json::json!({ "project": project_id, "item": item_id }),
1899 )
1900 .await?;
1901 self.project_item(item_id).await
1902 }
1903
1904 pub async fn delete_project_item(&self, project_id: &str, item_id: &str) -> Result<()> {
1910 const MUTATION: &str = r#"
1911mutation($project: ID!, $item: ID!) {
1912 deleteProjectV2Item(input: { projectId: $project, itemId: $item }) { deletedItemId }
1913}"#;
1914 self.graphql(
1915 &format!("removing item {item_id}"),
1916 MUTATION,
1917 serde_json::json!({ "project": project_id, "item": item_id }),
1918 )
1919 .await?;
1920 Ok(())
1921 }
1922}
1923
1924#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
1930#[serde(tag = "kind", rename_all = "snake_case")]
1931pub enum ProjectFieldWrite {
1932 SingleSelect {
1933 option_id: String,
1934 },
1935 Text {
1936 text: String,
1937 },
1938 Number {
1939 number: f64,
1940 },
1941 Date {
1943 date: String,
1944 },
1945 Iteration {
1946 iteration_id: String,
1947 },
1948}
1949
1950impl ProjectFieldWrite {
1951 fn as_input(&self) -> serde_json::Value {
1953 match self {
1954 Self::SingleSelect { option_id } => {
1955 serde_json::json!({ "singleSelectOptionId": option_id })
1956 }
1957 Self::Text { text } => serde_json::json!({ "text": text }),
1958 Self::Number { number } => serde_json::json!({ "number": number }),
1959 Self::Date { date } => serde_json::json!({ "date": date }),
1960 Self::Iteration { iteration_id } => {
1961 serde_json::json!({ "iterationId": iteration_id })
1962 }
1963 }
1964 }
1965}
1966
1967#[derive(Debug, Clone, PartialEq)]
1970struct Page<T> {
1971 items: Vec<T>,
1972 next: Option<String>,
1973}
1974
1975fn next_page(connection: &serde_json::Value, what: &str) -> Result<Option<String>> {
1981 let info = connection.get("pageInfo");
1982 let more = info
1983 .and_then(|info| info.get("hasNextPage"))
1984 .and_then(|more| more.as_bool())
1985 .unwrap_or(false);
1986 if !more {
1987 return Ok(None);
1988 }
1989 info.and_then(|info| info.get("endCursor"))
1990 .and_then(|cursor| cursor.as_str())
1991 .map(|cursor| Some(cursor.to_string()))
1992 .ok_or_else(|| anyhow::anyhow!("{what} says there is another page and gives no cursor"))
1993}
1994
1995fn projects_of_owner(data: &serde_json::Value, login: &str) -> Result<Page<GitHubProject>> {
2002 let nothing = Page { items: Vec::new(), next: None };
2003 let Some(owner) = data.get("repositoryOwner").filter(|owner| !owner.is_null()) else {
2007 return Ok(nothing);
2008 };
2009 let Some(connection) = owner.get("projectsV2") else {
2013 return Ok(nothing);
2014 };
2015 let Some(nodes) = connection.get("nodes").and_then(|nodes| nodes.as_array()) else {
2016 return Ok(nothing);
2017 };
2018 Ok(Page {
2019 items: nodes
2020 .iter()
2021 .map(|node| parse_project(node, login))
2022 .collect::<Result<_>>()?,
2023 next: next_page(connection, &format!("{login}'s board list"))?,
2024 })
2025}
2026
2027#[derive(Debug, Clone, PartialEq)]
2029struct ViewerPage {
2030 login: String,
2031 organizations: Page<String>,
2032}
2033
2034fn viewer_page(data: &serde_json::Value) -> Result<ViewerPage> {
2040 let viewer = data
2041 .get("viewer")
2042 .filter(|viewer| !viewer.is_null())
2043 .ok_or_else(|| anyhow::anyhow!("GitHub did not say who the token belongs to"))?;
2044 let login = string_at(viewer, "login", "the token's account")?;
2045 let connection = viewer
2046 .get("organizations")
2047 .ok_or_else(|| anyhow::anyhow!("GitHub did not list {login}'s organisations"))?;
2048 let organizations = connection
2049 .get("nodes")
2050 .and_then(|nodes| nodes.as_array())
2051 .ok_or_else(|| anyhow::anyhow!("{login}'s organisations are not a list"))?
2052 .iter()
2053 .map(|node| string_at(node, "login", "an organisation"))
2054 .collect::<Result<Vec<_>>>()?;
2055 Ok(ViewerPage {
2056 organizations: Page {
2057 items: organizations,
2058 next: next_page(connection, &format!("{login}'s organisation list"))?,
2059 },
2060 login,
2061 })
2062}
2063
2064fn validate_column_color(color: &str) -> Result<()> {
2065 const COLORS: &[&str] = &[
2066 "GRAY", "BLUE", "GREEN", "YELLOW", "ORANGE", "RED", "PINK", "PURPLE",
2067 ];
2068 if COLORS.contains(&color) {
2069 return Ok(());
2070 }
2071 anyhow::bail!(
2072 "'{color}' is not a GitHub project column color; expected {}",
2073 COLORS.join(", ")
2074 )
2075}
2076
2077fn parse_project(node: &serde_json::Value, login: &str) -> Result<GitHubProject> {
2078 let field_nodes = node
2079 .get("fields")
2080 .and_then(|fields| fields.get("nodes"))
2081 .and_then(|nodes| nodes.as_array())
2082 .ok_or_else(|| anyhow::anyhow!("a project has no field list"))?;
2083 let fields = field_nodes
2084 .iter()
2085 .map(|field| -> Result<GitHubProjectField> {
2086 let options = match field.get("options") {
2087 None => Vec::new(),
2088 Some(options) => options
2089 .as_array()
2090 .ok_or_else(|| anyhow::anyhow!("a project field's options are not a list"))?
2091 .iter()
2092 .map(|option| -> Result<GitHubProjectFieldOption> {
2093 Ok(GitHubProjectFieldOption {
2094 id: string_at(option, "id", "a project column")?,
2095 name: string_at(option, "name", "a project column")?,
2096 color: string_at(option, "color", "a project column")?,
2097 description: optional_string_at(option, "description", "a project column")?,
2098 })
2099 })
2100 .collect::<Result<Vec<_>>>()?,
2101 };
2102 Ok(GitHubProjectField {
2103 id: string_at(field, "id", "a project field")?,
2104 name: string_at(field, "name", "a project field")?,
2105 kind: string_at(field, "dataType", "a project field")?.to_ascii_lowercase(),
2106 options,
2107 })
2108 })
2109 .collect::<Result<Vec<_>>>()?;
2110
2111 Ok(GitHubProject {
2112 id: string_at(node, "id", "a project")?,
2113 number: node
2114 .get("number")
2115 .and_then(|number| number.as_i64())
2116 .ok_or_else(|| anyhow::anyhow!("a project has no number"))?,
2117 title: string_at(node, "title", "a project")?,
2118 url: string_at(node, "url", "a project")?,
2119 short_description: node
2120 .get("shortDescription")
2121 .and_then(|text| text.as_str())
2122 .map(str::to_string),
2123 closed: node
2124 .get("closed")
2125 .and_then(|closed| closed.as_bool())
2126 .unwrap_or(false),
2127 updated_at: string_at(node, "updatedAt", "a project")?,
2128 owner: login.to_string(),
2129 fields,
2130 })
2131}
2132
2133fn parse_project_item(node: &serde_json::Value) -> Result<GitHubProjectItem> {
2134 let content = node.get("content");
2135 let mut values = BTreeMap::new();
2136 if let Some(nodes) = node
2137 .get("fieldValues")
2138 .and_then(|values| values.get("nodes"))
2139 .and_then(|nodes| nodes.as_array())
2140 {
2141 for value in nodes {
2142 let Some(field_id) = value
2145 .get("field")
2146 .and_then(|field| field.get("id"))
2147 .and_then(|id| id.as_str())
2148 else {
2149 continue;
2150 };
2151 let parsed = if let (Some(option), Some(name)) = (
2152 value.get("optionId").and_then(|id| id.as_str()),
2153 value.get("name").and_then(|name| name.as_str()),
2154 ) {
2155 GitHubProjectValue::SingleSelect {
2156 option_id: option.to_string(),
2157 name: name.to_string(),
2158 }
2159 } else if let Some(text) = value.get("text").and_then(|text| text.as_str()) {
2160 GitHubProjectValue::Text {
2161 text: text.to_string(),
2162 }
2163 } else if let Some(number) = value.get("number").and_then(|number| number.as_f64()) {
2164 GitHubProjectValue::Number { number }
2165 } else if let Some(date) = value.get("date").and_then(|date| date.as_str()) {
2166 GitHubProjectValue::Date {
2167 date: date.to_string(),
2168 }
2169 } else if let (Some(title), Some(start)) = (
2170 value.get("title").and_then(|title| title.as_str()),
2171 value.get("startDate").and_then(|date| date.as_str()),
2172 ) {
2173 GitHubProjectValue::Iteration {
2174 title: title.to_string(),
2175 start_date: start.to_string(),
2176 }
2177 } else {
2178 continue;
2179 };
2180 values.insert(field_id.to_string(), parsed);
2181 }
2182 }
2183
2184 Ok(GitHubProjectItem {
2185 id: string_at(node, "id", "a project item")?,
2186 kind: string_at(node, "type", "a project item")?,
2187 title: content
2188 .and_then(|content| content.get("title"))
2189 .and_then(|title| title.as_str())
2190 .unwrap_or("(untitled)")
2191 .to_string(),
2192 content_id: text(content, "id"),
2193 number: content
2194 .and_then(|content| content.get("number"))
2195 .and_then(|number| number.as_i64()),
2196 url: content
2197 .and_then(|content| content.get("url"))
2198 .and_then(|url| url.as_str())
2199 .map(str::to_string),
2200 repository: content
2201 .and_then(|content| content.get("repository"))
2202 .and_then(|repo| repo.get("nameWithOwner"))
2203 .and_then(|name| name.as_str())
2204 .map(str::to_string),
2205 state: content
2206 .and_then(|content| content.get("state"))
2207 .and_then(|state| state.as_str())
2208 .map(str::to_string),
2209 state_reason: text(content, "stateReason"),
2210 body: text(content, "body"),
2211 author: content
2215 .and_then(|content| content.get("author").or_else(|| content.get("creator")))
2216 .and_then(parse_user),
2217 assignees: people(content, "assignees"),
2218 labels: content
2219 .and_then(|content| content.get("labels"))
2220 .and_then(|labels| labels.get("nodes"))
2221 .and_then(|nodes| nodes.as_array())
2222 .map(|nodes| {
2223 nodes
2224 .iter()
2225 .filter_map(|label| {
2226 Some(GitHubLabel {
2227 name: label.get("name")?.as_str()?.to_string(),
2228 color: label
2229 .get("color")
2230 .and_then(|color| color.as_str())
2231 .unwrap_or_default()
2232 .to_string(),
2233 description: label
2234 .get("description")
2235 .and_then(|text| text.as_str())
2236 .map(str::to_string),
2237 })
2238 })
2239 .collect()
2240 })
2241 .unwrap_or_default(),
2242 milestone: content
2243 .and_then(|content| content.get("milestone"))
2244 .and_then(|milestone| milestone.get("title"))
2245 .and_then(|title| title.as_str())
2246 .map(str::to_string),
2247 comment_count: content
2248 .and_then(|content| content.get("comments"))
2249 .and_then(|comments| comments.get("totalCount"))
2250 .and_then(|count| count.as_i64())
2251 .unwrap_or(0),
2252 created_at: text(content, "createdAt"),
2253 closed_at: text(content, "closedAt"),
2254 is_draft: flag(content, "isDraft"),
2255 merged: flag(content, "merged"),
2256 review_decision: text(content, "reviewDecision"),
2257 archived: node
2258 .get("isArchived")
2259 .and_then(|archived| archived.as_bool())
2260 .unwrap_or(false),
2261 field_values: values,
2262 updated_at: string_at(node, "updatedAt", "a project item")?,
2263 })
2264}
2265
2266fn text(content: Option<&serde_json::Value>, key: &str) -> Option<String> {
2272 content
2273 .and_then(|content| content.get(key))
2274 .and_then(|value| value.as_str())
2275 .map(str::to_string)
2276}
2277
2278fn flag(content: Option<&serde_json::Value>, key: &str) -> bool {
2279 content
2280 .and_then(|content| content.get(key))
2281 .and_then(|value| value.as_bool())
2282 .unwrap_or(false)
2283}
2284
2285fn parse_user(node: &serde_json::Value) -> Option<GitHubUser> {
2286 Some(GitHubUser {
2287 login: node.get("login")?.as_str()?.to_string(),
2288 avatar_url: node
2289 .get("avatarUrl")
2290 .and_then(|url| url.as_str())
2291 .map(str::to_string),
2292 })
2293}
2294
2295fn people(content: Option<&serde_json::Value>, key: &str) -> Vec<GitHubUser> {
2296 content
2297 .and_then(|content| content.get(key))
2298 .and_then(|people| people.get("nodes"))
2299 .and_then(|nodes| nodes.as_array())
2300 .map(|nodes| nodes.iter().filter_map(parse_user).collect())
2301 .unwrap_or_default()
2302}
2303
2304fn string_at(node: &serde_json::Value, key: &str, what: &str) -> Result<String> {
2305 node.get(key)
2306 .and_then(|value| value.as_str())
2307 .map(str::to_string)
2308 .ok_or_else(|| anyhow::anyhow!("{what} has no {key}"))
2309}
2310
2311fn optional_string_at(node: &serde_json::Value, key: &str, what: &str) -> Result<String> {
2325 match node.get(key) {
2326 Some(serde_json::Value::Null) => Ok(String::new()),
2327 Some(serde_json::Value::String(text)) => Ok(text.clone()),
2328 Some(other) => Err(anyhow::anyhow!(
2329 "{what} has a {key} that is not text: {other}"
2330 )),
2331 None => Err(anyhow::anyhow!(
2332 "{what} came back without a {key}, so this cannot say what GitHub \
2333 holds for it — and a refresh writes the whole column back, which \
2334 would erase it. Refresh Projects to read them again."
2335 )),
2336 }
2337}
2338
2339fn header_value(response: &reqwest::Response, name: reqwest::header::HeaderName) -> Option<String> {
2341 response
2342 .headers()
2343 .get(name)
2344 .and_then(|value| value.to_str().ok())
2345 .map(str::to_string)
2346}
2347
2348impl GitHubClient {
2349 pub async fn list_issues(
2351 &self,
2352 owner: &str,
2353 repo: &str,
2354 state: &str,
2355 ) -> Result<Vec<GitHubIssue>> {
2356 let url = self.url(&format!(
2357 "/repos/{}/{}/issues?state={}&per_page=100&sort=updated&direction=desc",
2358 owner, repo, state
2359 ));
2360 let response = require_success(
2361 &format!("GitHub API error listing issues for {}/{}", owner, repo),
2362 self.client
2363 .get(&url)
2364 .bearer_auth(&self.token)
2365 .send()
2366 .await
2367 .with_context(|| format!("failed to list issues for {}/{}", owner, repo))?,
2368 )?;
2369 let resp: Vec<GitHubIssue> = response
2370 .json()
2371 .await
2372 .context("failed to parse issues response")?;
2373
2374 Ok(resp
2376 .into_iter()
2377 .filter(|i| !i.html_url.contains("/pull/"))
2378 .collect())
2379 }
2380
2381 pub async fn get_issue(&self, owner: &str, repo: &str, number: i64) -> Result<GitHubIssue> {
2383 let url = self.url(&format!("/repos/{}/{}/issues/{}", owner, repo, number));
2384 let issue: GitHubIssue = require_success(
2385 &format!("failed to get issue {}/{}#{}", owner, repo, number),
2386 self.client
2387 .get(&url)
2388 .bearer_auth(&self.token)
2389 .send()
2390 .await?,
2391 )?
2392 .json()
2393 .await?;
2394 Ok(issue)
2395 }
2396
2397 pub async fn create_issue(
2399 &self,
2400 owner: &str,
2401 repo: &str,
2402 req: &CreateIssueRequest,
2403 ) -> Result<GitHubIssue> {
2404 let url = self.url(&format!("/repos/{}/{}/issues", owner, repo));
2405 let issue: GitHubIssue = require_success(
2406 &format!("failed to create issue in {}/{}", owner, repo),
2407 self.client
2408 .post(&url)
2409 .bearer_auth(&self.token)
2410 .json(req)
2411 .send()
2412 .await?,
2413 )?
2414 .json()
2415 .await?;
2416 Ok(issue)
2417 }
2418
2419 pub async fn update_issue(
2421 &self,
2422 owner: &str,
2423 repo: &str,
2424 number: i64,
2425 req: &UpdateIssueRequest,
2426 ) -> Result<GitHubIssue> {
2427 let url = self.url(&format!("/repos/{}/{}/issues/{}", owner, repo, number));
2428 let issue: GitHubIssue = require_success(
2429 &format!("failed to update issue {}/{}#{}", owner, repo, number),
2430 self.client
2431 .patch(&url)
2432 .bearer_auth(&self.token)
2433 .json(req)
2434 .send()
2435 .await?,
2436 )?
2437 .json()
2438 .await?;
2439 Ok(issue)
2440 }
2441
2442 pub async fn list_comments(
2444 &self,
2445 owner: &str,
2446 repo: &str,
2447 number: i64,
2448 ) -> Result<Vec<GitHubComment>> {
2449 let url = self.url(&format!(
2450 "/repos/{}/{}/issues/{}/comments?per_page=100",
2451 owner, repo, number
2452 ));
2453 let comments: Vec<GitHubComment> = require_success(
2454 &format!("failed to list comments for {}/{}#{}", owner, repo, number),
2455 self.client
2456 .get(&url)
2457 .bearer_auth(&self.token)
2458 .send()
2459 .await?,
2460 )?
2461 .json()
2462 .await?;
2463 Ok(comments)
2464 }
2465
2466 pub async fn add_comment(
2468 &self,
2469 owner: &str,
2470 repo: &str,
2471 number: i64,
2472 body: &str,
2473 ) -> Result<GitHubComment> {
2474 let url = self.url(&format!("/repos/{}/{}/issues/{}/comments", owner, repo, number));
2475 let comment: GitHubComment = require_success(
2476 &format!("failed to add comment to {}/{}#{}", owner, repo, number),
2477 self.client
2478 .post(&url)
2479 .bearer_auth(&self.token)
2480 .json(&CreateCommentRequest {
2481 body: body.to_string(),
2482 })
2483 .send()
2484 .await?,
2485 )?
2486 .json()
2487 .await?;
2488 Ok(comment)
2489 }
2490
2491 pub async fn list_gists(&self) -> Result<Vec<GitHubGist>> {
2493 let response = require_success(
2494 "failed to list gists",
2495 self.client
2496 .get(self.url("/gists?per_page=100"))
2497 .bearer_auth(&self.token)
2498 .send()
2499 .await?,
2500 )?;
2501 let gists: Vec<GitHubGist> = response
2502 .json()
2503 .await
2504 .context("failed to parse gists response")?;
2505 Ok(gists)
2506 }
2507
2508 pub async fn get_gist(&self, gist_id: &str) -> Result<GitHubGist> {
2510 let url = self.url(&format!("/gists/{}", gist_id));
2511 let gist: GitHubGist = require_success(
2512 &format!("failed to get gist {}", gist_id),
2513 self.client
2514 .get(&url)
2515 .bearer_auth(&self.token)
2516 .send()
2517 .await?,
2518 )?
2519 .json()
2520 .await
2521 .context("failed to parse gist response")?;
2522 Ok(gist)
2523 }
2524
2525 pub async fn create_gist(&self, req: &CreateGistRequest) -> Result<GitHubGist> {
2527 let gist: GitHubGist = require_success(
2528 "failed to create gist",
2529 self.client
2530 .post(&self.url("/gists"))
2531 .bearer_auth(&self.token)
2532 .json(req)
2533 .send()
2534 .await?,
2535 )?
2536 .json()
2537 .await
2538 .context("failed to parse create gist response")?;
2539 Ok(gist)
2540 }
2541
2542 pub async fn update_gist(&self, gist_id: &str, req: &UpdateGistRequest) -> Result<GitHubGist> {
2544 let url = self.url(&format!("/gists/{}", gist_id));
2545 let gist: GitHubGist = require_success(
2546 &format!("failed to update gist {}", gist_id),
2547 self.client
2548 .patch(&url)
2549 .bearer_auth(&self.token)
2550 .json(req)
2551 .send()
2552 .await?,
2553 )?
2554 .json()
2555 .await
2556 .context("failed to parse update gist response")?;
2557 Ok(gist)
2558 }
2559}
2560
2561pub fn parse_github_url(url: &str) -> Option<(String, String)> {
2566 let url = url.trim();
2567
2568 if let Some(rest) = url
2570 .strip_prefix("https://github.com/")
2571 .or_else(|| url.strip_prefix("http://github.com/"))
2572 {
2573 return parse_owner_repo(rest);
2574 }
2575
2576 if let Some(rest) = url
2578 .strip_prefix("git@github.com:")
2579 .or_else(|| url.strip_prefix("ssh://git@github.com/"))
2580 {
2581 return parse_owner_repo(rest);
2582 }
2583
2584 None
2585}
2586
2587fn parse_owner_repo(path: &str) -> Option<(String, String)> {
2588 let path = path.trim_end_matches(".git").trim_end_matches('/');
2589 let parts: Vec<&str> = path.splitn(3, '/').collect();
2590 if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
2591 Some((parts[0].to_string(), parts[1].to_string()))
2592 } else {
2593 None
2594 }
2595}
2596
2597#[cfg(test)]
2598mod tests {
2599 use super::*;
2600
2601 #[test]
2602 fn test_parse_github_url_https() {
2603 assert_eq!(
2604 parse_github_url("https://github.com/wesm/agentsview"),
2605 Some(("wesm".to_string(), "agentsview".to_string()))
2606 );
2607 }
2608
2609 #[test]
2610 fn test_parse_github_url_https_with_git_suffix() {
2611 assert_eq!(
2612 parse_github_url("https://github.com/wesm/agentsview.git"),
2613 Some(("wesm".to_string(), "agentsview".to_string()))
2614 );
2615 }
2616
2617 #[test]
2618 fn test_parse_github_url_ssh() {
2619 assert_eq!(
2620 parse_github_url("git@github.com:wesm/agentsview.git"),
2621 Some(("wesm".to_string(), "agentsview".to_string()))
2622 );
2623 }
2624
2625 #[test]
2628 fn test_parse_github_url_ssh_url_form() {
2629 assert_eq!(
2630 parse_github_url("ssh://git@github.com/wesm/agentsview.git"),
2631 Some(("wesm".to_string(), "agentsview".to_string()))
2632 );
2633 }
2634
2635 #[test]
2636 fn test_parse_github_url_non_github() {
2637 assert_eq!(parse_github_url("https://gitlab.com/user/repo"), None);
2638 }
2639
2640 #[test]
2641 fn test_parse_github_url_invalid() {
2642 assert_eq!(parse_github_url("not a url"), None);
2643 }
2644
2645 fn owner_with_a_board(kind: &str) -> serde_json::Value {
2647 serde_json::json!({
2648 "repositoryOwner": {
2649 "__typename": kind,
2650 "projectsV2": { "nodes": [{
2651 "id": "PVT_1",
2652 "number": 7,
2653 "title": "Roadmap",
2654 "url": "https://github.com/orgs/machinefabric/projects/7",
2655 "shortDescription": null,
2656 "closed": false,
2657 "updatedAt": "2026-08-19T09:00:00Z",
2658 "fields": { "nodes": [] }
2659 }]}
2660 }
2661 })
2662 }
2663
2664 #[test]
2674 fn test10074_a_board_is_read_for_either_kind_of_owner() {
2675 for kind in ["Organization", "User"] {
2676 let boards = projects_of_owner(&owner_with_a_board(kind), "machinefabric")
2677 .expect("the answer parses")
2678 .items;
2679 assert_eq!(boards.len(), 1, "no board came back for a {kind} owner");
2680 assert_eq!(boards[0].number, 7);
2681 assert_eq!(boards[0].title, "Roadmap");
2682 assert_eq!(boards[0].owner, "machinefabric");
2683 }
2684 }
2685
2686 #[test]
2695 fn test10075_an_owner_who_does_not_resolve_has_no_boards() {
2696 let nobody = serde_json::json!({ "repositoryOwner": serde_json::Value::Null });
2697 assert!(projects_of_owner(&nobody, "gone")
2698 .expect("a null owner is an answer")
2699 .items
2700 .is_empty());
2701
2702 let unknown = serde_json::json!({
2705 "repositoryOwner": { "__typename": "Mannequin" }
2706 });
2707 assert!(projects_of_owner(&unknown, "someone")
2708 .expect("an unknown owner kind is an answer")
2709 .items
2710 .is_empty());
2711 }
2712
2713 #[test]
2720 fn test20001_a_board_list_continues_or_is_refused() {
2721 let mut answer = owner_with_a_board("Organization");
2722 answer["repositoryOwner"]["projectsV2"]["pageInfo"] =
2723 serde_json::json!({ "hasNextPage": true, "endCursor": "Y3Vyc29y" });
2724 let page = projects_of_owner(&answer, "machinefabric").expect("the answer parses");
2725 assert_eq!(page.items.len(), 1);
2726 assert_eq!(page.next.as_deref(), Some("Y3Vyc29y"), "the next page was not carried");
2727
2728 answer["repositoryOwner"]["projectsV2"]["pageInfo"] =
2729 serde_json::json!({ "hasNextPage": false, "endCursor": "Y3Vyc29y" });
2730 assert_eq!(
2731 projects_of_owner(&answer, "machinefabric").expect("parses").next,
2732 None,
2733 "the last page named a next one"
2734 );
2735
2736 answer["repositoryOwner"]["projectsV2"]["pageInfo"] =
2737 serde_json::json!({ "hasNextPage": true, "endCursor": null });
2738 let said = format!(
2739 "{:#}",
2740 projects_of_owner(&answer, "machinefabric").expect_err("a contradiction is refused")
2741 );
2742 assert!(said.contains("machinefabric's board list"), "{said}");
2743 }
2744
2745 #[test]
2752 fn test20002_the_viewer_is_a_login_and_its_organisations() {
2753 let answer = serde_json::json!({ "viewer": {
2754 "login": "bahram",
2755 "organizations": {
2756 "pageInfo": { "hasNextPage": true, "endCursor": "T1JH" },
2757 "nodes": [{ "login": "machinefabric" }, { "login": "floom" }]
2758 }
2759 }});
2760 let page = viewer_page(&answer).expect("the answer parses");
2761 assert_eq!(page.login, "bahram");
2762 assert_eq!(page.organizations.items, vec!["machinefabric".to_string(), "floom".to_string()]);
2763 assert_eq!(page.organizations.next.as_deref(), Some("T1JH"));
2764
2765 let nobody = serde_json::json!({ "viewer": serde_json::Value::Null });
2766 assert!(viewer_page(&nobody).is_err(), "a token with no account was read as one");
2767 let unlisted = serde_json::json!({ "viewer": { "login": "bahram" } });
2768 assert!(
2769 viewer_page(&unlisted).is_err(),
2770 "an answer without the organisation list was read as no organisations"
2771 );
2772 }
2773
2774 #[test]
2781 fn test10196_a_column_update_preserves_the_complete_existing_options() {
2782 let option = GitHubProjectFieldOption {
2783 id: "OPT_TODO".to_string(),
2784 name: "Todo".to_string(),
2785 color: "GRAY".to_string(),
2786 description: "not started".to_string(),
2787 };
2788 let input = serde_json::to_value(ProjectColumnOptionInput::from(&option))
2789 .expect("the GraphQL input serializes");
2790 assert_eq!(input["id"], "OPT_TODO");
2791 assert_eq!(input["name"], "Todo");
2792 assert_eq!(input["color"], "GRAY");
2793 assert_eq!(input["description"], "not started");
2794
2795 let new_option = ProjectColumnOptionInput {
2796 id: None,
2797 name: "Blocked".to_string(),
2798 color: "RED".to_string(),
2799 description: String::new(),
2800 };
2801 let new_input = serde_json::to_value(new_option).expect("new option serializes");
2802 assert!(
2803 new_input.get("id").is_none(),
2804 "GitHub assigns a new option id"
2805 );
2806 assert!(validate_column_color("RED").is_ok());
2807 assert!(validate_column_color("#ff0000").is_err());
2808 }
2809}
2810
2811#[cfg(test)]
2812mod column_description_tests {
2813 use super::*;
2814
2815 fn option(json: serde_json::Value) -> Result<GitHubProjectFieldOption, serde_json::Error> {
2816 serde_json::from_value(json)
2817 }
2818
2819 #[test]
2825 fn test11607_a_null_description_is_no_description() {
2826 let read = option(serde_json::json!({
2827 "id": "OPT_1", "name": "Todo", "color": "GRAY", "description": null
2828 }))
2829 .expect("a column with no description is an ordinary column");
2830 assert_eq!(read.description, "");
2831 }
2832
2833 #[test]
2844 fn test11608_an_absent_description_is_not_read_as_empty() {
2845 let refusal = option(serde_json::json!({
2846 "id": "OPT_1", "name": "Todo", "color": "GRAY"
2847 }))
2848 .expect_err("a column with no description FIELD was read as having none");
2849 assert!(
2850 refusal.to_string().contains("description"),
2851 "the refusal does not say which field: {refusal}"
2852 );
2853 }
2854
2855 #[test]
2860 fn test11609_a_real_description_survives() {
2861 let read = option(serde_json::json!({
2862 "id": "OPT_1", "name": "Todo", "color": "GRAY",
2863 "description": "not started yet"
2864 }))
2865 .expect("a described column reads");
2866 assert_eq!(read.description, "not started yet");
2867 }
2868}