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 body = self.graphql_answer(what, query, variables).await?;
1293 if let Some(said) = errors_said(&body, |_| false) {
1294 anyhow::bail!("{what}: {said}");
1295 }
1296 body.get("data")
1297 .cloned()
1298 .ok_or_else(|| anyhow::anyhow!("{what}: the answer carried no data"))
1299 }
1300
1301 async fn graphql_answer(
1303 &self,
1304 what: &str,
1305 query: &str,
1306 variables: serde_json::Value,
1307 ) -> Result<serde_json::Value> {
1308 let response = require_success(
1309 what,
1310 self.client
1311 .post(self.url("/graphql"))
1312 .bearer_auth(&self.token)
1313 .json(&serde_json::json!({ "query": query, "variables": variables }))
1314 .send()
1315 .await
1316 .with_context(|| format!("GitHub API request failed while {what}"))?,
1317 )?;
1318 response
1319 .json()
1320 .await
1321 .with_context(|| format!("failed to parse the answer to {what}"))
1322 }
1323
1324 pub async fn list_projects(&self, login: &str) -> Result<Vec<GitHubProject>> {
1344 const QUERY: &str = r#"
1345query($login: String!, $after: String) {
1346 repositoryOwner(login: $login) {
1347 __typename
1348 ... on Organization { projectsV2(first: 100, after: $after, orderBy: {field: UPDATED_AT, direction: DESC}) { ...Page } }
1349 ... on User { projectsV2(first: 100, after: $after, orderBy: {field: UPDATED_AT, direction: DESC}) { ...Page } }
1350 }
1351}
1352fragment Page on ProjectV2Connection {
1353 pageInfo { hasNextPage endCursor }
1354 nodes { ...P }
1355}
1356fragment P on ProjectV2 {
1357 id number title url shortDescription closed updatedAt
1358 fields(first: 50) {
1359 nodes {
1360 ... on ProjectV2FieldCommon { id name dataType }
1361 ... on ProjectV2SingleSelectField { id name dataType options { id name color description } }
1362 }
1363 }
1364}"#;
1365 let mut projects = Vec::new();
1366 let mut after: Option<String> = None;
1367 loop {
1368 let data = self
1369 .graphql(
1370 &format!("listing projects for {login}"),
1371 QUERY,
1372 serde_json::json!({ "login": login, "after": after }),
1373 )
1374 .await?;
1375 let page = projects_of_owner(&data, login)?;
1376 projects.extend(page.items);
1377 match page.next {
1378 Some(cursor) => after = Some(cursor),
1379 None => break,
1380 }
1381 }
1382 Ok(projects)
1383 }
1384
1385 pub async fn viewer(&self) -> Result<GitHubViewer> {
1395 const QUERY: &str = r#"
1396query($after: String) {
1397 viewer {
1398 login
1399 organizations(first: 100, after: $after) {
1400 pageInfo { hasNextPage endCursor }
1401 nodes { login }
1402 }
1403 }
1404}"#;
1405 let mut organizations = Vec::new();
1406 let mut after: Option<String> = None;
1407 let login = loop {
1408 let data = self
1409 .graphql(
1410 "reading who the token belongs to",
1411 QUERY,
1412 serde_json::json!({ "after": after }),
1413 )
1414 .await?;
1415 let page = viewer_page(&data)?;
1416 organizations.extend(page.organizations.items);
1417 match page.organizations.next {
1418 Some(cursor) => after = Some(cursor),
1419 None => break page.login,
1420 }
1421 };
1422 Ok(GitHubViewer { login, organizations })
1423 }
1424
1425 async fn project(&self, project_id: &str) -> Result<GitHubProject> {
1427 const QUERY: &str = r#"
1428query($id: ID!) {
1429 node(id: $id) {
1430 ... on ProjectV2 {
1431 id number title url shortDescription closed updatedAt
1432 owner {
1433 ... on Organization { login }
1434 ... on User { login }
1435 }
1436 fields(first: 50) {
1437 nodes {
1438 ... on ProjectV2FieldCommon { id name dataType }
1439 ... on ProjectV2SingleSelectField {
1440 id name dataType options { id name color description }
1441 }
1442 }
1443 }
1444 }
1445 }
1446}"#;
1447 let data = self
1448 .graphql(
1449 &format!("reading project {project_id}"),
1450 QUERY,
1451 serde_json::json!({ "id": project_id }),
1452 )
1453 .await?;
1454 let node = data
1455 .get("node")
1456 .filter(|node| !node.is_null())
1457 .ok_or_else(|| anyhow::anyhow!("project {project_id} no longer exists"))?;
1458 let owner = node
1459 .get("owner")
1460 .and_then(|owner| owner.get("login"))
1461 .and_then(|login| login.as_str())
1462 .ok_or_else(|| anyhow::anyhow!("project {project_id} has no readable owner"))?;
1463 parse_project(node, owner)
1464 }
1465
1466 pub async fn add_project_column(
1473 &self,
1474 project_id: &str,
1475 field_id: &str,
1476 name: &str,
1477 color: &str,
1478 description: &str,
1479 ) -> Result<GitHubProject> {
1480 validate_column_color(color)?;
1481 let name = name.trim();
1482 if name.is_empty() {
1483 anyhow::bail!("a project column needs a name");
1484 }
1485 let project = self.project(project_id).await?;
1486 let field = project
1487 .fields
1488 .iter()
1489 .find(|field| field.id == field_id)
1490 .ok_or_else(|| anyhow::anyhow!("project {project_id} has no field {field_id}"))?;
1491 if field.kind != "single_select" {
1492 anyhow::bail!(
1493 "field {field_id} is {}, not a single-select column field",
1494 field.kind
1495 );
1496 }
1497 if field
1498 .options
1499 .iter()
1500 .any(|option| option.name.eq_ignore_ascii_case(name))
1501 {
1502 anyhow::bail!("project {project_id} already has a column named '{name}'");
1503 }
1504
1505 let mut options: Vec<ProjectColumnOptionInput> = field
1506 .options
1507 .iter()
1508 .map(ProjectColumnOptionInput::from)
1509 .collect();
1510 options.push(ProjectColumnOptionInput {
1511 id: None,
1512 name: name.to_string(),
1513 color: color.to_string(),
1514 description: description.trim().to_string(),
1515 });
1516 self.update_project_columns(field_id, &options).await?;
1517 self.project(project_id).await
1518 }
1519
1520 pub async fn remove_project_column(
1526 &self,
1527 project_id: &str,
1528 field_id: &str,
1529 option_id: &str,
1530 ) -> Result<GitHubProject> {
1531 let project = self.project(project_id).await?;
1532 let field = project
1533 .fields
1534 .iter()
1535 .find(|field| field.id == field_id)
1536 .ok_or_else(|| anyhow::anyhow!("project {project_id} has no field {field_id}"))?;
1537 if field.kind != "single_select" {
1538 anyhow::bail!(
1539 "field {field_id} is {}, not a single-select column field",
1540 field.kind
1541 );
1542 }
1543 if !field.options.iter().any(|option| option.id == option_id) {
1544 anyhow::bail!("field {field_id} has no column {option_id}");
1545 }
1546 if field.options.len() == 1 {
1547 anyhow::bail!("the last project column cannot be removed; a single-select field requires an option");
1548 }
1549 let options: Vec<ProjectColumnOptionInput> = field
1550 .options
1551 .iter()
1552 .filter(|option| option.id != option_id)
1553 .map(ProjectColumnOptionInput::from)
1554 .collect();
1555 self.update_project_columns(field_id, &options).await?;
1556 self.project(project_id).await
1557 }
1558
1559 async fn update_project_columns(
1560 &self,
1561 field_id: &str,
1562 options: &[ProjectColumnOptionInput],
1563 ) -> Result<()> {
1564 const MUTATION: &str = r#"
1565mutation($field: ID!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {
1566 updateProjectV2Field(input: { fieldId: $field, singleSelectOptions: $options }) {
1567 projectV2Field { ... on ProjectV2SingleSelectField { id } }
1568 }
1569}"#;
1570 self.graphql(
1571 &format!("updating the columns of field {field_id}"),
1572 MUTATION,
1573 serde_json::json!({ "field": field_id, "options": options }),
1574 )
1575 .await?;
1576 Ok(())
1577 }
1578
1579 pub async fn project_items(&self, project_id: &str) -> Result<Vec<GitHubProjectItem>> {
1585 let query = format!(
1586 r#"
1587query($id: ID!, $after: String) {{
1588 node(id: $id) {{
1589 ... on ProjectV2 {{
1590 items(first: 50, after: $after) {{
1591 pageInfo {{ hasNextPage endCursor }}
1592 nodes {{ ...Card }}
1593 }}
1594 }}
1595 }}
1596}}
1597{ITEM_FRAGMENT}"#
1598 );
1599 let mut items = Vec::new();
1600 let mut after: Option<String> = None;
1601 loop {
1602 let data = self
1603 .graphql(
1604 &format!("reading the items of project {project_id}"),
1605 &query,
1606 serde_json::json!({ "id": project_id, "after": after }),
1607 )
1608 .await?;
1609 let page = data
1610 .get("node")
1611 .and_then(|node| node.get("items"))
1612 .ok_or_else(|| anyhow::anyhow!("project {project_id} has no items to read"))?;
1613
1614 for node in page
1615 .get("nodes")
1616 .and_then(|nodes| nodes.as_array())
1617 .unwrap_or(&Vec::new())
1618 {
1619 items.push(parse_project_item(node)?);
1620 }
1621
1622 let more = page
1623 .get("pageInfo")
1624 .and_then(|info| info.get("hasNextPage"))
1625 .and_then(|more| more.as_bool())
1626 .unwrap_or(false);
1627 if !more {
1628 break;
1629 }
1630 after = page
1631 .get("pageInfo")
1632 .and_then(|info| info.get("endCursor"))
1633 .and_then(|cursor| cursor.as_str())
1634 .map(str::to_string);
1635 if after.is_none() {
1636 anyhow::bail!("project {project_id} says there is another page and gives no cursor");
1639 }
1640 }
1641 Ok(items)
1642 }
1643
1644 pub async fn project_item(&self, item_id: &str) -> Result<GitHubProjectItem> {
1652 let query = format!(
1653 r#"
1654query($id: ID!) {{ node(id: $id) {{ ...Card }} }}
1655{ITEM_FRAGMENT}"#
1656 );
1657 let data = self
1658 .graphql(
1659 &format!("re-reading item {item_id}"),
1660 &query,
1661 serde_json::json!({ "id": item_id }),
1662 )
1663 .await?;
1664 let node = data
1665 .get("node")
1666 .filter(|node| !node.is_null())
1667 .ok_or_else(|| anyhow::anyhow!("item {item_id} is not on this board any more"))?;
1668 parse_project_item(node)
1669 }
1670
1671 pub async fn project_items_on(&self, project_id: &str, item_ids: &[String]) -> Result<Vec<GitHubProjectItem>> {
1681 let query = format!(
1682 r#"
1683query($ids: [ID!]!) {{ nodes(ids: $ids) {{ ... on ProjectV2Item {{ project {{ id }} ...Card }} }} }}
1684{ITEM_FRAGMENT}"#
1685 );
1686 let mut items = Vec::new();
1687 for ids in item_ids.chunks(100) {
1689 let what = format!("reading {} cards of project {project_id} by id", ids.len());
1690 let body = self
1691 .graphql_answer(&what, &query, serde_json::json!({ "ids": ids }))
1692 .await?;
1693 if let Some(said) = errors_said(&body, |error| {
1694 error.get("type").and_then(|kind| kind.as_str()) == Some("NOT_FOUND")
1695 && error
1696 .get("path")
1697 .and_then(|path| path.get(0))
1698 .and_then(|first| first.as_str())
1699 == Some("nodes")
1700 }) {
1701 anyhow::bail!("{what}: {said}");
1702 }
1703 let nodes = body
1704 .get("data")
1705 .and_then(|data| data.get("nodes"))
1706 .and_then(|nodes| nodes.as_array())
1707 .ok_or_else(|| anyhow::anyhow!("{what}: the answer carried no nodes"))?;
1708 for node in nodes {
1709 let on_board = node
1710 .get("project")
1711 .and_then(|project| project.get("id"))
1712 .and_then(|id| id.as_str())
1713 == Some(project_id);
1714 if on_board {
1715 items.push(parse_project_item(node)?);
1716 }
1717 }
1718 }
1719 Ok(items)
1720 }
1721
1722 pub async fn set_project_field(
1729 &self,
1730 project_id: &str,
1731 item_id: &str,
1732 field_id: &str,
1733 value: &ProjectFieldWrite,
1734 ) -> Result<GitHubProjectItem> {
1735 const MUTATION: &str = r#"
1736mutation($project: ID!, $item: ID!, $field: ID!, $value: ProjectV2FieldValue!) {
1737 updateProjectV2ItemFieldValue(input: {
1738 projectId: $project, itemId: $item, fieldId: $field, value: $value
1739 }) { projectV2Item { id } }
1740}"#;
1741 self.graphql(
1742 &format!("setting a field on item {item_id}"),
1743 MUTATION,
1744 serde_json::json!({
1745 "project": project_id,
1746 "item": item_id,
1747 "field": field_id,
1748 "value": value.as_input(),
1749 }),
1750 )
1751 .await?;
1752 self.project_item(item_id).await
1753 }
1754
1755 pub async fn clear_project_field(
1761 &self,
1762 project_id: &str,
1763 item_id: &str,
1764 field_id: &str,
1765 ) -> Result<GitHubProjectItem> {
1766 const MUTATION: &str = r#"
1767mutation($project: ID!, $item: ID!, $field: ID!) {
1768 clearProjectV2ItemFieldValue(input: {
1769 projectId: $project, itemId: $item, fieldId: $field
1770 }) { projectV2Item { id } }
1771}"#;
1772 self.graphql(
1773 &format!("clearing a field on item {item_id}"),
1774 MUTATION,
1775 serde_json::json!({ "project": project_id, "item": item_id, "field": field_id }),
1776 )
1777 .await?;
1778 self.project_item(item_id).await
1779 }
1780
1781 pub async fn move_project_item_after(
1787 &self,
1788 project_id: &str,
1789 item_id: &str,
1790 after: Option<&str>,
1791 ) -> Result<GitHubProjectItem> {
1792 const MUTATION: &str = r#"
1793mutation($project: ID!, $item: ID!, $after: ID) {
1794 updateProjectV2ItemPosition(input: {
1795 projectId: $project, itemId: $item, afterId: $after
1796 }) { items { totalCount } }
1797}"#;
1798 self.graphql(
1799 &format!("ordering item {item_id}"),
1800 MUTATION,
1801 serde_json::json!({ "project": project_id, "item": item_id, "after": after }),
1802 )
1803 .await?;
1804 self.project_item(item_id).await
1805 }
1806
1807 pub async fn add_project_draft(
1813 &self,
1814 project_id: &str,
1815 title: &str,
1816 body: &str,
1817 ) -> Result<GitHubProjectItem> {
1818 const MUTATION: &str = r#"
1819mutation($project: ID!, $title: String!, $body: String!) {
1820 addProjectV2DraftIssue(input: { projectId: $project, title: $title, body: $body }) {
1821 projectItem { id }
1822 }
1823}"#;
1824 let data = self
1825 .graphql(
1826 "adding a draft card",
1827 MUTATION,
1828 serde_json::json!({ "project": project_id, "title": title, "body": body }),
1829 )
1830 .await?;
1831 let id = data
1832 .get("addProjectV2DraftIssue")
1833 .and_then(|added| added.get("projectItem"))
1834 .and_then(|item| item.get("id"))
1835 .and_then(|id| id.as_str())
1836 .ok_or_else(|| {
1837 anyhow::anyhow!("the draft was created and GitHub did not say its id")
1838 })?;
1839 self.project_item(id).await
1840 }
1841
1842 pub async fn update_project_draft(
1847 &self,
1848 item_id: &str,
1849 draft_id: &str,
1850 title: &str,
1851 body: &str,
1852 ) -> Result<GitHubProjectItem> {
1853 const MUTATION: &str = r#"
1854mutation($draft: ID!, $title: String!, $body: String!) {
1855 updateProjectV2DraftIssue(input: { draftIssueId: $draft, title: $title, body: $body }) {
1856 draftIssue { id }
1857 }
1858}"#;
1859 self.graphql(
1860 &format!("editing draft {draft_id}"),
1861 MUTATION,
1862 serde_json::json!({ "draft": draft_id, "title": title, "body": body }),
1863 )
1864 .await?;
1865 self.project_item(item_id).await
1866 }
1867
1868 pub async fn add_project_item_by_url(
1874 &self,
1875 project_id: &str,
1876 url: &str,
1877 ) -> Result<GitHubProjectItem> {
1878 const RESOLVE: &str = r#"
1879query($url: URI!) {
1880 resource(url: $url) {
1881 __typename
1882 ... on Issue { id }
1883 ... on PullRequest { id }
1884 }
1885}"#;
1886 let found = self
1887 .graphql(
1888 &format!("looking up {url}"),
1889 RESOLVE,
1890 serde_json::json!({ "url": url }),
1891 )
1892 .await?;
1893 let resource = found
1894 .get("resource")
1895 .filter(|resource| !resource.is_null())
1896 .ok_or_else(|| {
1897 anyhow::anyhow!("{url} is not something on GitHub this token can see")
1898 })?;
1899 let kind = resource
1900 .get("__typename")
1901 .and_then(|kind| kind.as_str())
1902 .unwrap_or("that");
1903 let content = resource.get("id").and_then(|id| id.as_str()).ok_or_else(|| {
1904 anyhow::anyhow!("{url} is a {kind}, and only an issue or a pull request can go on a board")
1905 })?;
1906
1907 const MUTATION: &str = r#"
1908mutation($project: ID!, $content: ID!) {
1909 addProjectV2ItemById(input: { projectId: $project, contentId: $content }) {
1910 item { id }
1911 }
1912}"#;
1913 let data = self
1914 .graphql(
1915 &format!("adding {url} to the board"),
1916 MUTATION,
1917 serde_json::json!({ "project": project_id, "content": content }),
1918 )
1919 .await?;
1920 let id = data
1921 .get("addProjectV2ItemById")
1922 .and_then(|added| added.get("item"))
1923 .and_then(|item| item.get("id"))
1924 .and_then(|id| id.as_str())
1925 .ok_or_else(|| anyhow::anyhow!("the item was added and GitHub did not say its id"))?;
1926 self.project_item(id).await
1927 }
1928
1929 pub async fn archive_project_item(
1935 &self,
1936 project_id: &str,
1937 item_id: &str,
1938 archived: bool,
1939 ) -> Result<GitHubProjectItem> {
1940 const ARCHIVE: &str = r#"
1941mutation($project: ID!, $item: ID!) {
1942 archiveProjectV2Item(input: { projectId: $project, itemId: $item }) { item { id } }
1943}"#;
1944 const UNARCHIVE: &str = r#"
1945mutation($project: ID!, $item: ID!) {
1946 unarchiveProjectV2Item(input: { projectId: $project, itemId: $item }) { item { id } }
1947}"#;
1948 self.graphql(
1949 &format!("archiving item {item_id}"),
1950 if archived { ARCHIVE } else { UNARCHIVE },
1951 serde_json::json!({ "project": project_id, "item": item_id }),
1952 )
1953 .await?;
1954 self.project_item(item_id).await
1955 }
1956
1957 pub async fn delete_project_item(&self, project_id: &str, item_id: &str) -> Result<()> {
1963 const MUTATION: &str = r#"
1964mutation($project: ID!, $item: ID!) {
1965 deleteProjectV2Item(input: { projectId: $project, itemId: $item }) { deletedItemId }
1966}"#;
1967 self.graphql(
1968 &format!("removing item {item_id}"),
1969 MUTATION,
1970 serde_json::json!({ "project": project_id, "item": item_id }),
1971 )
1972 .await?;
1973 Ok(())
1974 }
1975}
1976
1977#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
1983#[serde(tag = "kind", rename_all = "snake_case")]
1984pub enum ProjectFieldWrite {
1985 SingleSelect {
1986 option_id: String,
1987 },
1988 Text {
1989 text: String,
1990 },
1991 Number {
1992 number: f64,
1993 },
1994 Date {
1996 date: String,
1997 },
1998 Iteration {
1999 iteration_id: String,
2000 },
2001}
2002
2003impl ProjectFieldWrite {
2004 fn as_input(&self) -> serde_json::Value {
2006 match self {
2007 Self::SingleSelect { option_id } => {
2008 serde_json::json!({ "singleSelectOptionId": option_id })
2009 }
2010 Self::Text { text } => serde_json::json!({ "text": text }),
2011 Self::Number { number } => serde_json::json!({ "number": number }),
2012 Self::Date { date } => serde_json::json!({ "date": date }),
2013 Self::Iteration { iteration_id } => {
2014 serde_json::json!({ "iterationId": iteration_id })
2015 }
2016 }
2017 }
2018}
2019
2020#[derive(Debug, Clone, PartialEq)]
2023struct Page<T> {
2024 items: Vec<T>,
2025 next: Option<String>,
2026}
2027
2028fn next_page(connection: &serde_json::Value, what: &str) -> Result<Option<String>> {
2034 let info = connection.get("pageInfo");
2035 let more = info
2036 .and_then(|info| info.get("hasNextPage"))
2037 .and_then(|more| more.as_bool())
2038 .unwrap_or(false);
2039 if !more {
2040 return Ok(None);
2041 }
2042 info.and_then(|info| info.get("endCursor"))
2043 .and_then(|cursor| cursor.as_str())
2044 .map(|cursor| Some(cursor.to_string()))
2045 .ok_or_else(|| anyhow::anyhow!("{what} says there is another page and gives no cursor"))
2046}
2047
2048fn projects_of_owner(data: &serde_json::Value, login: &str) -> Result<Page<GitHubProject>> {
2055 let nothing = Page { items: Vec::new(), next: None };
2056 let Some(owner) = data.get("repositoryOwner").filter(|owner| !owner.is_null()) else {
2060 return Ok(nothing);
2061 };
2062 let Some(connection) = owner.get("projectsV2") else {
2066 return Ok(nothing);
2067 };
2068 let Some(nodes) = connection.get("nodes").and_then(|nodes| nodes.as_array()) else {
2069 return Ok(nothing);
2070 };
2071 Ok(Page {
2072 items: nodes
2073 .iter()
2074 .map(|node| parse_project(node, login))
2075 .collect::<Result<_>>()?,
2076 next: next_page(connection, &format!("{login}'s board list"))?,
2077 })
2078}
2079
2080#[derive(Debug, Clone, PartialEq)]
2082struct ViewerPage {
2083 login: String,
2084 organizations: Page<String>,
2085}
2086
2087fn viewer_page(data: &serde_json::Value) -> Result<ViewerPage> {
2093 let viewer = data
2094 .get("viewer")
2095 .filter(|viewer| !viewer.is_null())
2096 .ok_or_else(|| anyhow::anyhow!("GitHub did not say who the token belongs to"))?;
2097 let login = string_at(viewer, "login", "the token's account")?;
2098 let connection = viewer
2099 .get("organizations")
2100 .ok_or_else(|| anyhow::anyhow!("GitHub did not list {login}'s organisations"))?;
2101 let organizations = connection
2102 .get("nodes")
2103 .and_then(|nodes| nodes.as_array())
2104 .ok_or_else(|| anyhow::anyhow!("{login}'s organisations are not a list"))?
2105 .iter()
2106 .map(|node| string_at(node, "login", "an organisation"))
2107 .collect::<Result<Vec<_>>>()?;
2108 Ok(ViewerPage {
2109 organizations: Page {
2110 items: organizations,
2111 next: next_page(connection, &format!("{login}'s organisation list"))?,
2112 },
2113 login,
2114 })
2115}
2116
2117fn validate_column_color(color: &str) -> Result<()> {
2118 const COLORS: &[&str] = &[
2119 "GRAY", "BLUE", "GREEN", "YELLOW", "ORANGE", "RED", "PINK", "PURPLE",
2120 ];
2121 if COLORS.contains(&color) {
2122 return Ok(());
2123 }
2124 anyhow::bail!(
2125 "'{color}' is not a GitHub project column color; expected {}",
2126 COLORS.join(", ")
2127 )
2128}
2129
2130fn parse_project(node: &serde_json::Value, login: &str) -> Result<GitHubProject> {
2131 let field_nodes = node
2132 .get("fields")
2133 .and_then(|fields| fields.get("nodes"))
2134 .and_then(|nodes| nodes.as_array())
2135 .ok_or_else(|| anyhow::anyhow!("a project has no field list"))?;
2136 let fields = field_nodes
2137 .iter()
2138 .map(|field| -> Result<GitHubProjectField> {
2139 let options = match field.get("options") {
2140 None => Vec::new(),
2141 Some(options) => options
2142 .as_array()
2143 .ok_or_else(|| anyhow::anyhow!("a project field's options are not a list"))?
2144 .iter()
2145 .map(|option| -> Result<GitHubProjectFieldOption> {
2146 Ok(GitHubProjectFieldOption {
2147 id: string_at(option, "id", "a project column")?,
2148 name: string_at(option, "name", "a project column")?,
2149 color: string_at(option, "color", "a project column")?,
2150 description: optional_string_at(option, "description", "a project column")?,
2151 })
2152 })
2153 .collect::<Result<Vec<_>>>()?,
2154 };
2155 Ok(GitHubProjectField {
2156 id: string_at(field, "id", "a project field")?,
2157 name: string_at(field, "name", "a project field")?,
2158 kind: string_at(field, "dataType", "a project field")?.to_ascii_lowercase(),
2159 options,
2160 })
2161 })
2162 .collect::<Result<Vec<_>>>()?;
2163
2164 Ok(GitHubProject {
2165 id: string_at(node, "id", "a project")?,
2166 number: node
2167 .get("number")
2168 .and_then(|number| number.as_i64())
2169 .ok_or_else(|| anyhow::anyhow!("a project has no number"))?,
2170 title: string_at(node, "title", "a project")?,
2171 url: string_at(node, "url", "a project")?,
2172 short_description: node
2173 .get("shortDescription")
2174 .and_then(|text| text.as_str())
2175 .map(str::to_string),
2176 closed: node
2177 .get("closed")
2178 .and_then(|closed| closed.as_bool())
2179 .unwrap_or(false),
2180 updated_at: string_at(node, "updatedAt", "a project")?,
2181 owner: login.to_string(),
2182 fields,
2183 })
2184}
2185
2186fn errors_said(body: &serde_json::Value, expected: impl Fn(&serde_json::Value) -> bool) -> Option<String> {
2189 let errors: Vec<&serde_json::Value> = body
2190 .get("errors")
2191 .and_then(|errors| errors.as_array())
2192 .into_iter()
2193 .flatten()
2194 .filter(|error| !expected(error))
2195 .collect();
2196 (!errors.is_empty()).then(|| {
2197 errors
2198 .iter()
2199 .filter_map(|error| error.get("message").and_then(|m| m.as_str()))
2200 .collect::<Vec<_>>()
2201 .join("; ")
2202 })
2203}
2204
2205fn parse_project_item(node: &serde_json::Value) -> Result<GitHubProjectItem> {
2206 let content = node.get("content");
2207 let mut values = BTreeMap::new();
2208 if let Some(nodes) = node
2209 .get("fieldValues")
2210 .and_then(|values| values.get("nodes"))
2211 .and_then(|nodes| nodes.as_array())
2212 {
2213 for value in nodes {
2214 let Some(field_id) = value
2217 .get("field")
2218 .and_then(|field| field.get("id"))
2219 .and_then(|id| id.as_str())
2220 else {
2221 continue;
2222 };
2223 let parsed = if let (Some(option), Some(name)) = (
2224 value.get("optionId").and_then(|id| id.as_str()),
2225 value.get("name").and_then(|name| name.as_str()),
2226 ) {
2227 GitHubProjectValue::SingleSelect {
2228 option_id: option.to_string(),
2229 name: name.to_string(),
2230 }
2231 } else if let Some(text) = value.get("text").and_then(|text| text.as_str()) {
2232 GitHubProjectValue::Text {
2233 text: text.to_string(),
2234 }
2235 } else if let Some(number) = value.get("number").and_then(|number| number.as_f64()) {
2236 GitHubProjectValue::Number { number }
2237 } else if let Some(date) = value.get("date").and_then(|date| date.as_str()) {
2238 GitHubProjectValue::Date {
2239 date: date.to_string(),
2240 }
2241 } else if let (Some(title), Some(start)) = (
2242 value.get("title").and_then(|title| title.as_str()),
2243 value.get("startDate").and_then(|date| date.as_str()),
2244 ) {
2245 GitHubProjectValue::Iteration {
2246 title: title.to_string(),
2247 start_date: start.to_string(),
2248 }
2249 } else {
2250 continue;
2251 };
2252 values.insert(field_id.to_string(), parsed);
2253 }
2254 }
2255
2256 Ok(GitHubProjectItem {
2257 id: string_at(node, "id", "a project item")?,
2258 kind: string_at(node, "type", "a project item")?,
2259 title: content
2260 .and_then(|content| content.get("title"))
2261 .and_then(|title| title.as_str())
2262 .unwrap_or("(untitled)")
2263 .to_string(),
2264 content_id: text(content, "id"),
2265 number: content
2266 .and_then(|content| content.get("number"))
2267 .and_then(|number| number.as_i64()),
2268 url: content
2269 .and_then(|content| content.get("url"))
2270 .and_then(|url| url.as_str())
2271 .map(str::to_string),
2272 repository: content
2273 .and_then(|content| content.get("repository"))
2274 .and_then(|repo| repo.get("nameWithOwner"))
2275 .and_then(|name| name.as_str())
2276 .map(str::to_string),
2277 state: content
2278 .and_then(|content| content.get("state"))
2279 .and_then(|state| state.as_str())
2280 .map(str::to_string),
2281 state_reason: text(content, "stateReason"),
2282 body: text(content, "body"),
2283 author: content
2287 .and_then(|content| content.get("author").or_else(|| content.get("creator")))
2288 .and_then(parse_user),
2289 assignees: people(content, "assignees"),
2290 labels: content
2291 .and_then(|content| content.get("labels"))
2292 .and_then(|labels| labels.get("nodes"))
2293 .and_then(|nodes| nodes.as_array())
2294 .map(|nodes| {
2295 nodes
2296 .iter()
2297 .filter_map(|label| {
2298 Some(GitHubLabel {
2299 name: label.get("name")?.as_str()?.to_string(),
2300 color: label
2301 .get("color")
2302 .and_then(|color| color.as_str())
2303 .unwrap_or_default()
2304 .to_string(),
2305 description: label
2306 .get("description")
2307 .and_then(|text| text.as_str())
2308 .map(str::to_string),
2309 })
2310 })
2311 .collect()
2312 })
2313 .unwrap_or_default(),
2314 milestone: content
2315 .and_then(|content| content.get("milestone"))
2316 .and_then(|milestone| milestone.get("title"))
2317 .and_then(|title| title.as_str())
2318 .map(str::to_string),
2319 comment_count: content
2320 .and_then(|content| content.get("comments"))
2321 .and_then(|comments| comments.get("totalCount"))
2322 .and_then(|count| count.as_i64())
2323 .unwrap_or(0),
2324 created_at: text(content, "createdAt"),
2325 closed_at: text(content, "closedAt"),
2326 is_draft: flag(content, "isDraft"),
2327 merged: flag(content, "merged"),
2328 review_decision: text(content, "reviewDecision"),
2329 archived: node
2330 .get("isArchived")
2331 .and_then(|archived| archived.as_bool())
2332 .unwrap_or(false),
2333 field_values: values,
2334 updated_at: string_at(node, "updatedAt", "a project item")?,
2335 })
2336}
2337
2338fn text(content: Option<&serde_json::Value>, key: &str) -> Option<String> {
2344 content
2345 .and_then(|content| content.get(key))
2346 .and_then(|value| value.as_str())
2347 .map(str::to_string)
2348}
2349
2350fn flag(content: Option<&serde_json::Value>, key: &str) -> bool {
2351 content
2352 .and_then(|content| content.get(key))
2353 .and_then(|value| value.as_bool())
2354 .unwrap_or(false)
2355}
2356
2357fn parse_user(node: &serde_json::Value) -> Option<GitHubUser> {
2358 Some(GitHubUser {
2359 login: node.get("login")?.as_str()?.to_string(),
2360 avatar_url: node
2361 .get("avatarUrl")
2362 .and_then(|url| url.as_str())
2363 .map(str::to_string),
2364 })
2365}
2366
2367fn people(content: Option<&serde_json::Value>, key: &str) -> Vec<GitHubUser> {
2368 content
2369 .and_then(|content| content.get(key))
2370 .and_then(|people| people.get("nodes"))
2371 .and_then(|nodes| nodes.as_array())
2372 .map(|nodes| nodes.iter().filter_map(parse_user).collect())
2373 .unwrap_or_default()
2374}
2375
2376fn string_at(node: &serde_json::Value, key: &str, what: &str) -> Result<String> {
2377 node.get(key)
2378 .and_then(|value| value.as_str())
2379 .map(str::to_string)
2380 .ok_or_else(|| anyhow::anyhow!("{what} has no {key}"))
2381}
2382
2383fn optional_string_at(node: &serde_json::Value, key: &str, what: &str) -> Result<String> {
2397 match node.get(key) {
2398 Some(serde_json::Value::Null) => Ok(String::new()),
2399 Some(serde_json::Value::String(text)) => Ok(text.clone()),
2400 Some(other) => Err(anyhow::anyhow!(
2401 "{what} has a {key} that is not text: {other}"
2402 )),
2403 None => Err(anyhow::anyhow!(
2404 "{what} came back without a {key}, so this cannot say what GitHub \
2405 holds for it — and a refresh writes the whole column back, which \
2406 would erase it. Refresh Projects to read them again."
2407 )),
2408 }
2409}
2410
2411fn header_value(response: &reqwest::Response, name: reqwest::header::HeaderName) -> Option<String> {
2413 response
2414 .headers()
2415 .get(name)
2416 .and_then(|value| value.to_str().ok())
2417 .map(str::to_string)
2418}
2419
2420impl GitHubClient {
2421 pub async fn list_issues(
2423 &self,
2424 owner: &str,
2425 repo: &str,
2426 state: &str,
2427 ) -> Result<Vec<GitHubIssue>> {
2428 let url = self.url(&format!(
2429 "/repos/{}/{}/issues?state={}&per_page=100&sort=updated&direction=desc",
2430 owner, repo, state
2431 ));
2432 let response = require_success(
2433 &format!("GitHub API error listing issues for {}/{}", owner, repo),
2434 self.client
2435 .get(&url)
2436 .bearer_auth(&self.token)
2437 .send()
2438 .await
2439 .with_context(|| format!("failed to list issues for {}/{}", owner, repo))?,
2440 )?;
2441 let resp: Vec<GitHubIssue> = response
2442 .json()
2443 .await
2444 .context("failed to parse issues response")?;
2445
2446 Ok(resp
2448 .into_iter()
2449 .filter(|i| !i.html_url.contains("/pull/"))
2450 .collect())
2451 }
2452
2453 pub async fn get_issue(&self, owner: &str, repo: &str, number: i64) -> Result<GitHubIssue> {
2455 let url = self.url(&format!("/repos/{}/{}/issues/{}", owner, repo, number));
2456 let issue: GitHubIssue = require_success(
2457 &format!("failed to get issue {}/{}#{}", owner, repo, number),
2458 self.client
2459 .get(&url)
2460 .bearer_auth(&self.token)
2461 .send()
2462 .await?,
2463 )?
2464 .json()
2465 .await?;
2466 Ok(issue)
2467 }
2468
2469 pub async fn create_issue(
2471 &self,
2472 owner: &str,
2473 repo: &str,
2474 req: &CreateIssueRequest,
2475 ) -> Result<GitHubIssue> {
2476 let url = self.url(&format!("/repos/{}/{}/issues", owner, repo));
2477 let issue: GitHubIssue = require_success(
2478 &format!("failed to create issue in {}/{}", owner, repo),
2479 self.client
2480 .post(&url)
2481 .bearer_auth(&self.token)
2482 .json(req)
2483 .send()
2484 .await?,
2485 )?
2486 .json()
2487 .await?;
2488 Ok(issue)
2489 }
2490
2491 pub async fn update_issue(
2493 &self,
2494 owner: &str,
2495 repo: &str,
2496 number: i64,
2497 req: &UpdateIssueRequest,
2498 ) -> Result<GitHubIssue> {
2499 let url = self.url(&format!("/repos/{}/{}/issues/{}", owner, repo, number));
2500 let issue: GitHubIssue = require_success(
2501 &format!("failed to update issue {}/{}#{}", owner, repo, number),
2502 self.client
2503 .patch(&url)
2504 .bearer_auth(&self.token)
2505 .json(req)
2506 .send()
2507 .await?,
2508 )?
2509 .json()
2510 .await?;
2511 Ok(issue)
2512 }
2513
2514 pub async fn list_comments(
2516 &self,
2517 owner: &str,
2518 repo: &str,
2519 number: i64,
2520 ) -> Result<Vec<GitHubComment>> {
2521 let url = self.url(&format!(
2522 "/repos/{}/{}/issues/{}/comments?per_page=100",
2523 owner, repo, number
2524 ));
2525 let comments: Vec<GitHubComment> = require_success(
2526 &format!("failed to list comments for {}/{}#{}", owner, repo, number),
2527 self.client
2528 .get(&url)
2529 .bearer_auth(&self.token)
2530 .send()
2531 .await?,
2532 )?
2533 .json()
2534 .await?;
2535 Ok(comments)
2536 }
2537
2538 pub async fn add_comment(
2540 &self,
2541 owner: &str,
2542 repo: &str,
2543 number: i64,
2544 body: &str,
2545 ) -> Result<GitHubComment> {
2546 let url = self.url(&format!("/repos/{}/{}/issues/{}/comments", owner, repo, number));
2547 let comment: GitHubComment = require_success(
2548 &format!("failed to add comment to {}/{}#{}", owner, repo, number),
2549 self.client
2550 .post(&url)
2551 .bearer_auth(&self.token)
2552 .json(&CreateCommentRequest {
2553 body: body.to_string(),
2554 })
2555 .send()
2556 .await?,
2557 )?
2558 .json()
2559 .await?;
2560 Ok(comment)
2561 }
2562
2563 pub async fn list_gists(&self) -> Result<Vec<GitHubGist>> {
2565 let response = require_success(
2566 "failed to list gists",
2567 self.client
2568 .get(self.url("/gists?per_page=100"))
2569 .bearer_auth(&self.token)
2570 .send()
2571 .await?,
2572 )?;
2573 let gists: Vec<GitHubGist> = response
2574 .json()
2575 .await
2576 .context("failed to parse gists response")?;
2577 Ok(gists)
2578 }
2579
2580 pub async fn get_gist(&self, gist_id: &str) -> Result<GitHubGist> {
2582 let url = self.url(&format!("/gists/{}", gist_id));
2583 let gist: GitHubGist = require_success(
2584 &format!("failed to get gist {}", gist_id),
2585 self.client
2586 .get(&url)
2587 .bearer_auth(&self.token)
2588 .send()
2589 .await?,
2590 )?
2591 .json()
2592 .await
2593 .context("failed to parse gist response")?;
2594 Ok(gist)
2595 }
2596
2597 pub async fn create_gist(&self, req: &CreateGistRequest) -> Result<GitHubGist> {
2599 let gist: GitHubGist = require_success(
2600 "failed to create gist",
2601 self.client
2602 .post(&self.url("/gists"))
2603 .bearer_auth(&self.token)
2604 .json(req)
2605 .send()
2606 .await?,
2607 )?
2608 .json()
2609 .await
2610 .context("failed to parse create gist response")?;
2611 Ok(gist)
2612 }
2613
2614 pub async fn update_gist(&self, gist_id: &str, req: &UpdateGistRequest) -> Result<GitHubGist> {
2616 let url = self.url(&format!("/gists/{}", gist_id));
2617 let gist: GitHubGist = require_success(
2618 &format!("failed to update gist {}", gist_id),
2619 self.client
2620 .patch(&url)
2621 .bearer_auth(&self.token)
2622 .json(req)
2623 .send()
2624 .await?,
2625 )?
2626 .json()
2627 .await
2628 .context("failed to parse update gist response")?;
2629 Ok(gist)
2630 }
2631}
2632
2633pub fn parse_github_url(url: &str) -> Option<(String, String)> {
2638 let url = url.trim();
2639
2640 if let Some(rest) = url
2642 .strip_prefix("https://github.com/")
2643 .or_else(|| url.strip_prefix("http://github.com/"))
2644 {
2645 return parse_owner_repo(rest);
2646 }
2647
2648 if let Some(rest) = url
2650 .strip_prefix("git@github.com:")
2651 .or_else(|| url.strip_prefix("ssh://git@github.com/"))
2652 {
2653 return parse_owner_repo(rest);
2654 }
2655
2656 None
2657}
2658
2659fn parse_owner_repo(path: &str) -> Option<(String, String)> {
2660 let path = path.trim_end_matches(".git").trim_end_matches('/');
2661 let parts: Vec<&str> = path.splitn(3, '/').collect();
2662 if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
2663 Some((parts[0].to_string(), parts[1].to_string()))
2664 } else {
2665 None
2666 }
2667}
2668
2669#[cfg(test)]
2670mod tests {
2671 use super::*;
2672
2673 #[test]
2674 fn test_parse_github_url_https() {
2675 assert_eq!(
2676 parse_github_url("https://github.com/wesm/agentsview"),
2677 Some(("wesm".to_string(), "agentsview".to_string()))
2678 );
2679 }
2680
2681 #[test]
2682 fn test_parse_github_url_https_with_git_suffix() {
2683 assert_eq!(
2684 parse_github_url("https://github.com/wesm/agentsview.git"),
2685 Some(("wesm".to_string(), "agentsview".to_string()))
2686 );
2687 }
2688
2689 #[test]
2690 fn test_parse_github_url_ssh() {
2691 assert_eq!(
2692 parse_github_url("git@github.com:wesm/agentsview.git"),
2693 Some(("wesm".to_string(), "agentsview".to_string()))
2694 );
2695 }
2696
2697 #[test]
2700 fn test_parse_github_url_ssh_url_form() {
2701 assert_eq!(
2702 parse_github_url("ssh://git@github.com/wesm/agentsview.git"),
2703 Some(("wesm".to_string(), "agentsview".to_string()))
2704 );
2705 }
2706
2707 #[test]
2708 fn test_parse_github_url_non_github() {
2709 assert_eq!(parse_github_url("https://gitlab.com/user/repo"), None);
2710 }
2711
2712 #[test]
2713 fn test_parse_github_url_invalid() {
2714 assert_eq!(parse_github_url("not a url"), None);
2715 }
2716
2717 fn owner_with_a_board(kind: &str) -> serde_json::Value {
2719 serde_json::json!({
2720 "repositoryOwner": {
2721 "__typename": kind,
2722 "projectsV2": { "nodes": [{
2723 "id": "PVT_1",
2724 "number": 7,
2725 "title": "Roadmap",
2726 "url": "https://github.com/orgs/machinefabric/projects/7",
2727 "shortDescription": null,
2728 "closed": false,
2729 "updatedAt": "2026-08-19T09:00:00Z",
2730 "fields": { "nodes": [] }
2731 }]}
2732 }
2733 })
2734 }
2735
2736 #[test]
2746 fn test10074_a_board_is_read_for_either_kind_of_owner() {
2747 for kind in ["Organization", "User"] {
2748 let boards = projects_of_owner(&owner_with_a_board(kind), "machinefabric")
2749 .expect("the answer parses")
2750 .items;
2751 assert_eq!(boards.len(), 1, "no board came back for a {kind} owner");
2752 assert_eq!(boards[0].number, 7);
2753 assert_eq!(boards[0].title, "Roadmap");
2754 assert_eq!(boards[0].owner, "machinefabric");
2755 }
2756 }
2757
2758 #[test]
2767 fn test10075_an_owner_who_does_not_resolve_has_no_boards() {
2768 let nobody = serde_json::json!({ "repositoryOwner": serde_json::Value::Null });
2769 assert!(projects_of_owner(&nobody, "gone")
2770 .expect("a null owner is an answer")
2771 .items
2772 .is_empty());
2773
2774 let unknown = serde_json::json!({
2777 "repositoryOwner": { "__typename": "Mannequin" }
2778 });
2779 assert!(projects_of_owner(&unknown, "someone")
2780 .expect("an unknown owner kind is an answer")
2781 .items
2782 .is_empty());
2783 }
2784
2785 #[test]
2792 fn test20001_a_board_list_continues_or_is_refused() {
2793 let mut answer = owner_with_a_board("Organization");
2794 answer["repositoryOwner"]["projectsV2"]["pageInfo"] =
2795 serde_json::json!({ "hasNextPage": true, "endCursor": "Y3Vyc29y" });
2796 let page = projects_of_owner(&answer, "machinefabric").expect("the answer parses");
2797 assert_eq!(page.items.len(), 1);
2798 assert_eq!(page.next.as_deref(), Some("Y3Vyc29y"), "the next page was not carried");
2799
2800 answer["repositoryOwner"]["projectsV2"]["pageInfo"] =
2801 serde_json::json!({ "hasNextPage": false, "endCursor": "Y3Vyc29y" });
2802 assert_eq!(
2803 projects_of_owner(&answer, "machinefabric").expect("parses").next,
2804 None,
2805 "the last page named a next one"
2806 );
2807
2808 answer["repositoryOwner"]["projectsV2"]["pageInfo"] =
2809 serde_json::json!({ "hasNextPage": true, "endCursor": null });
2810 let said = format!(
2811 "{:#}",
2812 projects_of_owner(&answer, "machinefabric").expect_err("a contradiction is refused")
2813 );
2814 assert!(said.contains("machinefabric's board list"), "{said}");
2815 }
2816
2817 #[test]
2824 fn test20002_the_viewer_is_a_login_and_its_organisations() {
2825 let answer = serde_json::json!({ "viewer": {
2826 "login": "bahram",
2827 "organizations": {
2828 "pageInfo": { "hasNextPage": true, "endCursor": "T1JH" },
2829 "nodes": [{ "login": "machinefabric" }, { "login": "floom" }]
2830 }
2831 }});
2832 let page = viewer_page(&answer).expect("the answer parses");
2833 assert_eq!(page.login, "bahram");
2834 assert_eq!(page.organizations.items, vec!["machinefabric".to_string(), "floom".to_string()]);
2835 assert_eq!(page.organizations.next.as_deref(), Some("T1JH"));
2836
2837 let nobody = serde_json::json!({ "viewer": serde_json::Value::Null });
2838 assert!(viewer_page(&nobody).is_err(), "a token with no account was read as one");
2839 let unlisted = serde_json::json!({ "viewer": { "login": "bahram" } });
2840 assert!(
2841 viewer_page(&unlisted).is_err(),
2842 "an answer without the organisation list was read as no organisations"
2843 );
2844 }
2845
2846 #[test]
2853 fn test10196_a_column_update_preserves_the_complete_existing_options() {
2854 let option = GitHubProjectFieldOption {
2855 id: "OPT_TODO".to_string(),
2856 name: "Todo".to_string(),
2857 color: "GRAY".to_string(),
2858 description: "not started".to_string(),
2859 };
2860 let input = serde_json::to_value(ProjectColumnOptionInput::from(&option))
2861 .expect("the GraphQL input serializes");
2862 assert_eq!(input["id"], "OPT_TODO");
2863 assert_eq!(input["name"], "Todo");
2864 assert_eq!(input["color"], "GRAY");
2865 assert_eq!(input["description"], "not started");
2866
2867 let new_option = ProjectColumnOptionInput {
2868 id: None,
2869 name: "Blocked".to_string(),
2870 color: "RED".to_string(),
2871 description: String::new(),
2872 };
2873 let new_input = serde_json::to_value(new_option).expect("new option serializes");
2874 assert!(
2875 new_input.get("id").is_none(),
2876 "GitHub assigns a new option id"
2877 );
2878 assert!(validate_column_color("RED").is_ok());
2879 assert!(validate_column_color("#ff0000").is_err());
2880 }
2881}
2882
2883#[cfg(test)]
2884mod column_description_tests {
2885 use super::*;
2886
2887 fn option(json: serde_json::Value) -> Result<GitHubProjectFieldOption, serde_json::Error> {
2888 serde_json::from_value(json)
2889 }
2890
2891 #[test]
2897 fn test11607_a_null_description_is_no_description() {
2898 let read = option(serde_json::json!({
2899 "id": "OPT_1", "name": "Todo", "color": "GRAY", "description": null
2900 }))
2901 .expect("a column with no description is an ordinary column");
2902 assert_eq!(read.description, "");
2903 }
2904
2905 #[test]
2916 fn test11608_an_absent_description_is_not_read_as_empty() {
2917 let refusal = option(serde_json::json!({
2918 "id": "OPT_1", "name": "Todo", "color": "GRAY"
2919 }))
2920 .expect_err("a column with no description FIELD was read as having none");
2921 assert!(
2922 refusal.to_string().contains("description"),
2923 "the refusal does not say which field: {refusal}"
2924 );
2925 }
2926
2927 #[test]
2932 fn test11609_a_real_description_survives() {
2933 let read = option(serde_json::json!({
2934 "id": "OPT_1", "name": "Todo", "color": "GRAY",
2935 "description": "not started yet"
2936 }))
2937 .expect("a described column reads");
2938 assert_eq!(read.description, "not started yet");
2939 }
2940}