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
419impl GitHubClient {
420 pub fn new(token: &str) -> Self {
421 Self::against("https://api.github.com", token)
422 }
423
424 pub fn against(base: &str, token: &str) -> Self {
426 let client = reqwest::Client::builder()
427 .user_agent(concat!("wsb-github/", env!("CARGO_PKG_VERSION")))
428 .no_proxy()
429 .build()
430 .expect("failed to build reqwest client");
431
432 Self {
433 client,
434 token: token.to_string(),
435 base: base.trim_end_matches('/').to_string(),
436 }
437 }
438
439 fn url(&self, path: &str) -> String {
441 format!("{}{}", self.base, path)
442 }
443
444 pub async fn validate_token(&self) -> Result<String> {
446 let response = require_success(
447 "GitHub token validation",
448 self.client
449 .get(self.url("/user"))
450 .bearer_auth(&self.token)
451 .send()
452 .await
453 .context("GitHub API request failed")?,
454 )?;
455 let resp: GitHubUser = response
456 .json()
457 .await
458 .context("failed to parse GitHub user response")?;
459 Ok(resp.login)
460 }
461
462 pub async fn list_rulesets(&self, owner: &str, repo: &str) -> Result<Vec<RepositoryRuleset>> {
468 let response = require_success(
469 "listing rulesets",
470 self.client
471 .get(self.url(&format!("/repos/{owner}/{repo}/rulesets")))
472 .bearer_auth(&self.token)
473 .send()
474 .await
475 .context("GitHub API request failed")?,
476 )?;
477 response
478 .json()
479 .await
480 .context("failed to parse the ruleset list")
481 }
482
483 pub async fn put_ruleset(
489 &self,
490 owner: &str,
491 repo: &str,
492 name: &str,
493 body: &serde_json::Value,
494 ) -> Result<RepositoryRuleset> {
495 let existing = self
496 .list_rulesets(owner, repo)
497 .await?
498 .into_iter()
499 .find(|ruleset| ruleset.name == name);
500
501 let response = match &existing {
502 Some(ruleset) => {
503 self.client
504 .put(self.url(&format!(
505 "/repos/{owner}/{repo}/rulesets/{}",
506 ruleset.id
507 )))
508 .bearer_auth(&self.token)
509 .json(body)
510 .send()
511 .await
512 }
513 None => {
514 self.client
515 .post(self.url(&format!("/repos/{owner}/{repo}/rulesets")))
516 .bearer_auth(&self.token)
517 .json(body)
518 .send()
519 .await
520 }
521 }
522 .context("GitHub API request failed")?;
523
524 let response = require_success(
525 if existing.is_some() {
526 "updating a ruleset"
527 } else {
528 "creating a ruleset"
529 },
530 response,
531 )?;
532 response
533 .json()
534 .await
535 .context("failed to parse the ruleset GitHub wrote")
536 }
537}
538
539#[derive(Debug, Clone, PartialEq, Eq)]
546pub enum Fetched<T> {
547 Fresh { value: T, etag: Option<String> },
549 Unchanged,
553}
554
555impl<T> Fetched<T> {
556 pub fn value(self) -> Option<T> {
557 match self {
558 Self::Fresh { value, .. } => Some(value),
559 Self::Unchanged => None,
560 }
561 }
562}
563
564#[derive(Debug, Clone, Default)]
566pub struct Conditions {
567 pub etag: Option<String>,
569 pub since: Option<String>,
571}
572
573impl GitHubClient {
574 pub async fn list_issues_changed(
586 &self,
587 owner: &str,
588 repo: &str,
589 conditions: &Conditions,
590 ) -> Result<Fetched<Vec<GitHubIssue>>> {
591 let mut url = self.url(&format!(
592 "/repos/{owner}/{repo}/issues?state=all&per_page=100&sort=updated&direction=desc"
593 ));
594 if let Some(since) = &conditions.since {
595 url.push_str(&format!("&since={since}"));
596 }
597
598 let mut request = self.client.get(&url).bearer_auth(&self.token);
599 if let Some(etag) = &conditions.etag {
600 request = request.header(reqwest::header::IF_NONE_MATCH, etag);
601 }
602 let response = request
603 .send()
604 .await
605 .with_context(|| format!("failed to list issues for {owner}/{repo}"))?;
606
607 if response.status() == reqwest::StatusCode::NOT_MODIFIED {
608 return Ok(Fetched::Unchanged);
609 }
610 let etag = header_value(&response, reqwest::header::ETAG);
611 let response = require_success(
612 &format!("GitHub API error listing issues for {owner}/{repo}"),
613 response,
614 )?;
615 let issues: Vec<GitHubIssue> = response
616 .json()
617 .await
618 .context("failed to parse issues response")?;
619
620 Ok(Fetched::Fresh {
621 value: issues
624 .into_iter()
625 .filter(|issue| !issue.html_url.contains("/pull/"))
626 .collect(),
627 etag,
628 })
629 }
630
631 pub async fn list_gists_changed(
637 &self,
638 conditions: &Conditions,
639 ) -> Result<Fetched<Vec<GitHubGist>>> {
640 let mut request = self
641 .client
642 .get(self.url("/gists?per_page=100"))
643 .bearer_auth(&self.token);
644 if let Some(etag) = &conditions.etag {
645 request = request.header(reqwest::header::IF_NONE_MATCH, etag);
646 }
647 let response = request.send().await.context("failed to list gists")?;
648
649 if response.status() == reqwest::StatusCode::NOT_MODIFIED {
650 return Ok(Fetched::Unchanged);
651 }
652 let etag = header_value(&response, reqwest::header::ETAG);
653 let response = require_success("failed to list gists", response)?;
654 let gists: Vec<GitHubGist> = response
655 .json()
656 .await
657 .context("failed to parse gists response")?;
658 Ok(Fetched::Fresh { value: gists, etag })
659 }
660}
661
662impl GitHubClient {
663 async fn conditional<T: serde::de::DeserializeOwned>(
670 &self,
671 what: &str,
672 path: &str,
673 conditions: &Conditions,
674 ) -> Result<Fetched<T>> {
675 let mut request = self.client.get(self.url(path)).bearer_auth(&self.token);
676 if let Some(etag) = &conditions.etag {
677 request = request.header(reqwest::header::IF_NONE_MATCH, etag);
678 }
679 let response = request
680 .send()
681 .await
682 .with_context(|| format!("failed {what}"))?;
683
684 if response.status() == reqwest::StatusCode::NOT_MODIFIED {
685 return Ok(Fetched::Unchanged);
686 }
687 let etag = header_value(&response, reqwest::header::ETAG);
688 let response = require_success(what, response)?;
689 let value: T = response
690 .json()
691 .await
692 .with_context(|| format!("failed to parse the answer to {what}"))?;
693 Ok(Fetched::Fresh { value, etag })
694 }
695
696 pub async fn list_pulls_changed(
703 &self,
704 owner: &str,
705 repo: &str,
706 conditions: &Conditions,
707 ) -> Result<Fetched<Vec<GitHubPull>>> {
708 self.conditional(
709 &format!("listing pull requests for {owner}/{repo}"),
710 &format!("/repos/{owner}/{repo}/pulls?state=all&per_page=100&sort=updated&direction=desc"),
711 conditions,
712 )
713 .await
714 }
715
716 pub async fn checks_for(&self, owner: &str, repo: &str, commit: &str) -> Result<GitHubChecks> {
722 #[derive(Deserialize)]
723 struct CheckRuns {
724 check_runs: Vec<RawCheckRun>,
725 }
726 #[derive(Deserialize)]
727 struct RawCheckRun {
728 name: String,
729 status: String,
730 conclusion: Option<String>,
731 html_url: Option<String>,
732 }
733 #[derive(Deserialize)]
734 struct CombinedStatus {
735 statuses: Vec<RawStatus>,
736 }
737 #[derive(Deserialize)]
738 struct RawStatus {
739 context: String,
740 state: String,
741 target_url: Option<String>,
742 }
743
744 let runs: CheckRuns = require_success(
745 &format!("listing check runs for {owner}/{repo}@{commit}"),
746 self.client
747 .get(self.url(&format!(
748 "/repos/{owner}/{repo}/commits/{commit}/check-runs?per_page=100"
749 )))
750 .bearer_auth(&self.token)
751 .send()
752 .await
753 .context("GitHub API request failed")?,
754 )?
755 .json()
756 .await
757 .context("failed to parse the check runs")?;
758
759 let combined: CombinedStatus = require_success(
760 &format!("listing commit statuses for {owner}/{repo}@{commit}"),
761 self.client
762 .get(self.url(&format!(
763 "/repos/{owner}/{repo}/commits/{commit}/status"
764 )))
765 .bearer_auth(&self.token)
766 .send()
767 .await
768 .context("GitHub API request failed")?,
769 )?
770 .json()
771 .await
772 .context("failed to parse the commit statuses")?;
773
774 let mut all: Vec<GitHubCheckRun> = runs
775 .check_runs
776 .into_iter()
777 .map(|run| GitHubCheckRun {
778 name: run.name,
779 status: run.status,
780 conclusion: run.conclusion,
781 url: run.html_url,
782 })
783 .collect();
784 all.extend(combined.statuses.into_iter().map(|status| GitHubCheckRun {
785 name: status.context,
786 status: "completed".to_string(),
789 conclusion: Some(match status.state.as_str() {
790 "success" => "success".to_string(),
791 "failure" | "error" => "failure".to_string(),
792 other => other.to_string(),
793 }),
794 url: status.target_url,
795 }));
796
797 Ok(GitHubChecks {
798 sha: commit.to_string(),
799 state: GitHubChecks::rollup(&all),
800 runs: all,
801 })
802 }
803
804 pub async fn list_releases_changed(
805 &self,
806 owner: &str,
807 repo: &str,
808 conditions: &Conditions,
809 ) -> Result<Fetched<Vec<GitHubRelease>>> {
810 self.conditional(
811 &format!("listing releases for {owner}/{repo}"),
812 &format!("/repos/{owner}/{repo}/releases?per_page=100"),
813 conditions,
814 )
815 .await
816 }
817
818 pub async fn list_tags_changed(
819 &self,
820 owner: &str,
821 repo: &str,
822 conditions: &Conditions,
823 ) -> Result<Fetched<Vec<GitHubTag>>> {
824 self.conditional(
825 &format!("listing tags for {owner}/{repo}"),
826 &format!("/repos/{owner}/{repo}/tags?per_page=100"),
827 conditions,
828 )
829 .await
830 }
831
832 pub async fn repository_changed(
833 &self,
834 owner: &str,
835 repo: &str,
836 conditions: &Conditions,
837 ) -> Result<Fetched<GitHubRepository>> {
838 self.conditional(
839 &format!("reading {owner}/{repo}"),
840 &format!("/repos/{owner}/{repo}"),
841 conditions,
842 )
843 .await
844 }
845
846 pub async fn list_milestones_changed(
847 &self,
848 owner: &str,
849 repo: &str,
850 conditions: &Conditions,
851 ) -> Result<Fetched<Vec<GitHubMilestone>>> {
852 self.conditional(
853 &format!("listing milestones for {owner}/{repo}"),
854 &format!("/repos/{owner}/{repo}/milestones?state=all&per_page=100"),
855 conditions,
856 )
857 .await
858 }
859
860 pub async fn list_labels_changed(
861 &self,
862 owner: &str,
863 repo: &str,
864 conditions: &Conditions,
865 ) -> Result<Fetched<Vec<GitHubLabel>>> {
866 self.conditional(
867 &format!("listing labels for {owner}/{repo}"),
868 &format!("/repos/{owner}/{repo}/labels?per_page=100"),
869 conditions,
870 )
871 .await
872 }
873
874 pub async fn list_comments_changed(
876 &self,
877 owner: &str,
878 repo: &str,
879 number: i64,
880 conditions: &Conditions,
881 ) -> Result<Fetched<Vec<GitHubComment>>> {
882 self.conditional(
883 &format!("listing comments on {owner}/{repo}#{number}"),
884 &format!("/repos/{owner}/{repo}/issues/{number}/comments?per_page=100"),
885 conditions,
886 )
887 .await
888 }
889
890 pub async fn list_notifications_changed(
892 &self,
893 conditions: &Conditions,
894 ) -> Result<Fetched<Vec<GitHubNotification>>> {
895 self.conditional(
896 "listing notifications",
897 "/notifications?all=false&per_page=50",
898 conditions,
899 )
900 .await
901 }
902
903 pub async fn rate_limit(&self) -> Result<GitHubRateLimit> {
909 #[derive(Deserialize)]
910 struct Envelope {
911 resources: Resources,
912 }
913 #[derive(Deserialize)]
914 struct Resources {
915 core: GitHubRateLimit,
916 }
917 let envelope: Envelope = require_success(
918 "reading the rate limit",
919 self.client
920 .get(self.url("/rate_limit"))
921 .bearer_auth(&self.token)
922 .send()
923 .await
924 .context("GitHub API request failed")?,
925 )?
926 .json()
927 .await
928 .context("failed to parse the rate limit")?;
929 Ok(envelope.resources.core)
930 }
931
932 pub async fn traffic(&self, owner: &str, repo: &str) -> Result<GitHubTraffic> {
938 let views: TrafficSeries = require_success(
939 &format!("reading traffic for {owner}/{repo}"),
940 self.client
941 .get(self.url(&format!("/repos/{owner}/{repo}/traffic/views")))
942 .bearer_auth(&self.token)
943 .send()
944 .await
945 .context("GitHub API request failed")?,
946 )?
947 .json()
948 .await
949 .context("failed to parse the view traffic")?;
950
951 let clones: TrafficSeries = require_success(
952 &format!("reading clone traffic for {owner}/{repo}"),
953 self.client
954 .get(self.url(&format!("/repos/{owner}/{repo}/traffic/clones")))
955 .bearer_auth(&self.token)
956 .send()
957 .await
958 .context("GitHub API request failed")?,
959 )?
960 .json()
961 .await
962 .context("failed to parse the clone traffic")?;
963
964 Ok(GitHubTraffic {
965 views: views.count,
966 unique_views: views.uniques,
967 clones: clones.count,
968 unique_clones: clones.uniques,
969 view_days: views.views,
970 clone_days: clones.clones,
971 })
972 }
973}
974
975#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
991pub struct GitHubProject {
992 pub id: String,
994 pub number: i64,
995 pub title: String,
996 pub url: String,
997 pub short_description: Option<String>,
998 pub closed: bool,
999 pub updated_at: String,
1000 pub owner: String,
1004 pub fields: Vec<GitHubProjectField>,
1006}
1007
1008impl GitHubProject {
1009 pub fn column_field(&self) -> Option<&GitHubProjectField> {
1017 self.fields
1018 .iter()
1019 .find(|field| field.kind == "single_select" && field.name == "Status")
1020 .or_else(|| {
1021 self.fields
1022 .iter()
1023 .find(|field| field.kind == "single_select")
1024 })
1025 }
1026}
1027
1028#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1029pub struct GitHubProjectField {
1030 pub id: String,
1031 pub name: String,
1032 pub kind: String,
1034 #[serde(default)]
1036 pub options: Vec<GitHubProjectFieldOption>,
1037}
1038
1039#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1040pub struct GitHubProjectFieldOption {
1041 pub id: String,
1042 pub name: String,
1043 pub color: String,
1045 #[serde(deserialize_with = "text_or_nothing")]
1059 pub description: String,
1060}
1061
1062fn text_or_nothing<'de, D>(deserializer: D) -> Result<String, D::Error>
1070where
1071 D: serde::Deserializer<'de>,
1072{
1073 Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_default())
1074}
1075
1076#[derive(Debug, Clone, Serialize)]
1077struct ProjectColumnOptionInput {
1078 #[serde(skip_serializing_if = "Option::is_none")]
1079 id: Option<String>,
1080 name: String,
1081 color: String,
1082 description: String,
1083}
1084
1085impl From<&GitHubProjectFieldOption> for ProjectColumnOptionInput {
1086 fn from(option: &GitHubProjectFieldOption) -> Self {
1087 Self {
1088 id: Some(option.id.clone()),
1089 name: option.name.clone(),
1090 color: option.color.clone(),
1091 description: option.description.clone(),
1092 }
1093 }
1094}
1095
1096#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1098pub struct GitHubProjectItem {
1099 pub id: String,
1100 pub kind: String,
1102 pub title: String,
1103 pub content_id: Option<String>,
1108 pub number: Option<i64>,
1111 pub url: Option<String>,
1112 pub repository: Option<String>,
1114 pub state: Option<String>,
1116 pub state_reason: Option<String>,
1120 pub body: Option<String>,
1123 pub author: Option<GitHubUser>,
1124 #[serde(default)]
1125 pub assignees: Vec<GitHubUser>,
1126 #[serde(default)]
1127 pub labels: Vec<GitHubLabel>,
1128 pub milestone: Option<String>,
1129 #[serde(default)]
1133 pub comment_count: i64,
1134 pub created_at: Option<String>,
1135 pub closed_at: Option<String>,
1136 #[serde(default)]
1140 pub is_draft: bool,
1141 #[serde(default)]
1142 pub merged: bool,
1143 pub review_decision: Option<String>,
1146 #[serde(default)]
1149 pub archived: bool,
1150 #[serde(default)]
1154 pub field_values: BTreeMap<String, GitHubProjectValue>,
1155 pub updated_at: String,
1156}
1157
1158impl GitHubProjectItem {
1159 pub fn column(&self, field_id: &str) -> Option<&str> {
1161 match self.field_values.get(field_id)? {
1162 GitHubProjectValue::SingleSelect { option_id, .. } => Some(option_id),
1163 _ => None,
1164 }
1165 }
1166}
1167
1168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1173#[serde(tag = "kind", rename_all = "snake_case")]
1174pub enum GitHubProjectValue {
1175 SingleSelect {
1179 option_id: String,
1180 name: String,
1181 },
1182 Text {
1183 text: String,
1184 },
1185 Number {
1186 number: f64,
1187 },
1188 Date {
1189 date: String,
1190 },
1191 Iteration {
1192 title: String,
1193 start_date: String,
1194 },
1195}
1196
1197const ITEM_FRAGMENT: &str = r#"
1204fragment Card on ProjectV2Item {
1205 id type updatedAt isArchived
1206 fieldValues(first: 30) {
1207 nodes {
1208 ... on ProjectV2ItemFieldSingleSelectValue { name optionId field { ... on ProjectV2FieldCommon { id } } }
1209 ... on ProjectV2ItemFieldTextValue { text field { ... on ProjectV2FieldCommon { id } } }
1210 ... on ProjectV2ItemFieldNumberValue { number field { ... on ProjectV2FieldCommon { id } } }
1211 ... on ProjectV2ItemFieldDateValue { date field { ... on ProjectV2FieldCommon { id } } }
1212 ... on ProjectV2ItemFieldIterationValue { title startDate field { ... on ProjectV2FieldCommon { id } } }
1213 }
1214 }
1215 content {
1216 ... on DraftIssue {
1217 id title body createdAt updatedAt
1218 creator { login avatarUrl }
1219 assignees(first: 10) { nodes { login avatarUrl } }
1220 }
1221 ... on Issue {
1222 id title body number url state stateReason createdAt closedAt
1223 repository { nameWithOwner }
1224 author { login avatarUrl }
1225 assignees(first: 10) { nodes { login avatarUrl } }
1226 labels(first: 20) { nodes { name color description } }
1227 milestone { title }
1228 comments { totalCount }
1229 }
1230 ... on PullRequest {
1231 id title body number url state createdAt closedAt isDraft merged reviewDecision
1232 repository { nameWithOwner }
1233 author { login avatarUrl }
1234 assignees(first: 10) { nodes { login avatarUrl } }
1235 labels(first: 20) { nodes { name color description } }
1236 milestone { title }
1237 comments { totalCount }
1238 }
1239 }
1240}"#;
1241
1242impl GitHubClient {
1243 async fn graphql(
1250 &self,
1251 what: &str,
1252 query: &str,
1253 variables: serde_json::Value,
1254 ) -> Result<serde_json::Value> {
1255 let response = require_success(
1256 what,
1257 self.client
1258 .post(self.url("/graphql"))
1259 .bearer_auth(&self.token)
1260 .json(&serde_json::json!({ "query": query, "variables": variables }))
1261 .send()
1262 .await
1263 .with_context(|| format!("GitHub API request failed while {what}"))?,
1264 )?;
1265 let body: serde_json::Value = response
1266 .json()
1267 .await
1268 .with_context(|| format!("failed to parse the answer to {what}"))?;
1269
1270 if let Some(errors) = body.get("errors").and_then(|errors| errors.as_array()) {
1271 if !errors.is_empty() {
1272 let said = errors
1273 .iter()
1274 .filter_map(|error| error.get("message").and_then(|m| m.as_str()))
1275 .collect::<Vec<_>>()
1276 .join("; ");
1277 anyhow::bail!("{what}: {said}");
1278 }
1279 }
1280 body.get("data")
1281 .cloned()
1282 .ok_or_else(|| anyhow::anyhow!("{what}: the answer carried no data"))
1283 }
1284
1285 pub async fn list_projects(&self, login: &str) -> Result<Vec<GitHubProject>> {
1301 const QUERY: &str = r#"
1302query($login: String!) {
1303 repositoryOwner(login: $login) {
1304 __typename
1305 ... on Organization { projectsV2(first: 20, orderBy: {field: UPDATED_AT, direction: DESC}) { nodes { ...P } } }
1306 ... on User { projectsV2(first: 20, orderBy: {field: UPDATED_AT, direction: DESC}) { nodes { ...P } } }
1307 }
1308}
1309fragment P on ProjectV2 {
1310 id number title url shortDescription closed updatedAt
1311 fields(first: 50) {
1312 nodes {
1313 ... on ProjectV2FieldCommon { id name dataType }
1314 ... on ProjectV2SingleSelectField { id name dataType options { id name color description } }
1315 }
1316 }
1317}"#;
1318 let data = self
1319 .graphql(
1320 &format!("listing projects for {login}"),
1321 QUERY,
1322 serde_json::json!({ "login": login }),
1323 )
1324 .await?;
1325
1326 projects_of_owner(&data, login)
1327 }
1328
1329 async fn project(&self, project_id: &str) -> Result<GitHubProject> {
1331 const QUERY: &str = r#"
1332query($id: ID!) {
1333 node(id: $id) {
1334 ... on ProjectV2 {
1335 id number title url shortDescription closed updatedAt
1336 owner {
1337 ... on Organization { login }
1338 ... on User { login }
1339 }
1340 fields(first: 50) {
1341 nodes {
1342 ... on ProjectV2FieldCommon { id name dataType }
1343 ... on ProjectV2SingleSelectField {
1344 id name dataType options { id name color description }
1345 }
1346 }
1347 }
1348 }
1349 }
1350}"#;
1351 let data = self
1352 .graphql(
1353 &format!("reading project {project_id}"),
1354 QUERY,
1355 serde_json::json!({ "id": project_id }),
1356 )
1357 .await?;
1358 let node = data
1359 .get("node")
1360 .filter(|node| !node.is_null())
1361 .ok_or_else(|| anyhow::anyhow!("project {project_id} no longer exists"))?;
1362 let owner = node
1363 .get("owner")
1364 .and_then(|owner| owner.get("login"))
1365 .and_then(|login| login.as_str())
1366 .ok_or_else(|| anyhow::anyhow!("project {project_id} has no readable owner"))?;
1367 parse_project(node, owner)
1368 }
1369
1370 pub async fn add_project_column(
1377 &self,
1378 project_id: &str,
1379 field_id: &str,
1380 name: &str,
1381 color: &str,
1382 description: &str,
1383 ) -> Result<GitHubProject> {
1384 validate_column_color(color)?;
1385 let name = name.trim();
1386 if name.is_empty() {
1387 anyhow::bail!("a project column needs a name");
1388 }
1389 let project = self.project(project_id).await?;
1390 let field = project
1391 .fields
1392 .iter()
1393 .find(|field| field.id == field_id)
1394 .ok_or_else(|| anyhow::anyhow!("project {project_id} has no field {field_id}"))?;
1395 if field.kind != "single_select" {
1396 anyhow::bail!(
1397 "field {field_id} is {}, not a single-select column field",
1398 field.kind
1399 );
1400 }
1401 if field
1402 .options
1403 .iter()
1404 .any(|option| option.name.eq_ignore_ascii_case(name))
1405 {
1406 anyhow::bail!("project {project_id} already has a column named '{name}'");
1407 }
1408
1409 let mut options: Vec<ProjectColumnOptionInput> = field
1410 .options
1411 .iter()
1412 .map(ProjectColumnOptionInput::from)
1413 .collect();
1414 options.push(ProjectColumnOptionInput {
1415 id: None,
1416 name: name.to_string(),
1417 color: color.to_string(),
1418 description: description.trim().to_string(),
1419 });
1420 self.update_project_columns(field_id, &options).await?;
1421 self.project(project_id).await
1422 }
1423
1424 pub async fn remove_project_column(
1430 &self,
1431 project_id: &str,
1432 field_id: &str,
1433 option_id: &str,
1434 ) -> Result<GitHubProject> {
1435 let project = self.project(project_id).await?;
1436 let field = project
1437 .fields
1438 .iter()
1439 .find(|field| field.id == field_id)
1440 .ok_or_else(|| anyhow::anyhow!("project {project_id} has no field {field_id}"))?;
1441 if field.kind != "single_select" {
1442 anyhow::bail!(
1443 "field {field_id} is {}, not a single-select column field",
1444 field.kind
1445 );
1446 }
1447 if !field.options.iter().any(|option| option.id == option_id) {
1448 anyhow::bail!("field {field_id} has no column {option_id}");
1449 }
1450 if field.options.len() == 1 {
1451 anyhow::bail!("the last project column cannot be removed; a single-select field requires an option");
1452 }
1453 let options: Vec<ProjectColumnOptionInput> = field
1454 .options
1455 .iter()
1456 .filter(|option| option.id != option_id)
1457 .map(ProjectColumnOptionInput::from)
1458 .collect();
1459 self.update_project_columns(field_id, &options).await?;
1460 self.project(project_id).await
1461 }
1462
1463 async fn update_project_columns(
1464 &self,
1465 field_id: &str,
1466 options: &[ProjectColumnOptionInput],
1467 ) -> Result<()> {
1468 const MUTATION: &str = r#"
1469mutation($field: ID!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {
1470 updateProjectV2Field(input: { fieldId: $field, singleSelectOptions: $options }) {
1471 projectV2Field { ... on ProjectV2SingleSelectField { id } }
1472 }
1473}"#;
1474 self.graphql(
1475 &format!("updating the columns of field {field_id}"),
1476 MUTATION,
1477 serde_json::json!({ "field": field_id, "options": options }),
1478 )
1479 .await?;
1480 Ok(())
1481 }
1482
1483 pub async fn project_items(&self, project_id: &str) -> Result<Vec<GitHubProjectItem>> {
1489 let query = format!(
1490 r#"
1491query($id: ID!, $after: String) {{
1492 node(id: $id) {{
1493 ... on ProjectV2 {{
1494 items(first: 50, after: $after) {{
1495 pageInfo {{ hasNextPage endCursor }}
1496 nodes {{ ...Card }}
1497 }}
1498 }}
1499 }}
1500}}
1501{ITEM_FRAGMENT}"#
1502 );
1503 let mut items = Vec::new();
1504 let mut after: Option<String> = None;
1505 loop {
1506 let data = self
1507 .graphql(
1508 &format!("reading the items of project {project_id}"),
1509 &query,
1510 serde_json::json!({ "id": project_id, "after": after }),
1511 )
1512 .await?;
1513 let page = data
1514 .get("node")
1515 .and_then(|node| node.get("items"))
1516 .ok_or_else(|| anyhow::anyhow!("project {project_id} has no items to read"))?;
1517
1518 for node in page
1519 .get("nodes")
1520 .and_then(|nodes| nodes.as_array())
1521 .unwrap_or(&Vec::new())
1522 {
1523 items.push(parse_project_item(node)?);
1524 }
1525
1526 let more = page
1527 .get("pageInfo")
1528 .and_then(|info| info.get("hasNextPage"))
1529 .and_then(|more| more.as_bool())
1530 .unwrap_or(false);
1531 if !more {
1532 break;
1533 }
1534 after = page
1535 .get("pageInfo")
1536 .and_then(|info| info.get("endCursor"))
1537 .and_then(|cursor| cursor.as_str())
1538 .map(str::to_string);
1539 if after.is_none() {
1540 anyhow::bail!("project {project_id} says there is another page and gives no cursor");
1543 }
1544 }
1545 Ok(items)
1546 }
1547
1548 pub async fn project_item(&self, item_id: &str) -> Result<GitHubProjectItem> {
1556 let query = format!(
1557 r#"
1558query($id: ID!) {{ node(id: $id) {{ ...Card }} }}
1559{ITEM_FRAGMENT}"#
1560 );
1561 let data = self
1562 .graphql(
1563 &format!("re-reading item {item_id}"),
1564 &query,
1565 serde_json::json!({ "id": item_id }),
1566 )
1567 .await?;
1568 let node = data
1569 .get("node")
1570 .filter(|node| !node.is_null())
1571 .ok_or_else(|| anyhow::anyhow!("item {item_id} is not on this board any more"))?;
1572 parse_project_item(node)
1573 }
1574
1575 pub async fn set_project_field(
1582 &self,
1583 project_id: &str,
1584 item_id: &str,
1585 field_id: &str,
1586 value: &ProjectFieldWrite,
1587 ) -> Result<GitHubProjectItem> {
1588 const MUTATION: &str = r#"
1589mutation($project: ID!, $item: ID!, $field: ID!, $value: ProjectV2FieldValue!) {
1590 updateProjectV2ItemFieldValue(input: {
1591 projectId: $project, itemId: $item, fieldId: $field, value: $value
1592 }) { projectV2Item { id } }
1593}"#;
1594 self.graphql(
1595 &format!("setting a field on item {item_id}"),
1596 MUTATION,
1597 serde_json::json!({
1598 "project": project_id,
1599 "item": item_id,
1600 "field": field_id,
1601 "value": value.as_input(),
1602 }),
1603 )
1604 .await?;
1605 self.project_item(item_id).await
1606 }
1607
1608 pub async fn clear_project_field(
1614 &self,
1615 project_id: &str,
1616 item_id: &str,
1617 field_id: &str,
1618 ) -> Result<GitHubProjectItem> {
1619 const MUTATION: &str = r#"
1620mutation($project: ID!, $item: ID!, $field: ID!) {
1621 clearProjectV2ItemFieldValue(input: {
1622 projectId: $project, itemId: $item, fieldId: $field
1623 }) { projectV2Item { id } }
1624}"#;
1625 self.graphql(
1626 &format!("clearing a field on item {item_id}"),
1627 MUTATION,
1628 serde_json::json!({ "project": project_id, "item": item_id, "field": field_id }),
1629 )
1630 .await?;
1631 self.project_item(item_id).await
1632 }
1633
1634 pub async fn move_project_item_after(
1640 &self,
1641 project_id: &str,
1642 item_id: &str,
1643 after: Option<&str>,
1644 ) -> Result<GitHubProjectItem> {
1645 const MUTATION: &str = r#"
1646mutation($project: ID!, $item: ID!, $after: ID) {
1647 updateProjectV2ItemPosition(input: {
1648 projectId: $project, itemId: $item, afterId: $after
1649 }) { items { totalCount } }
1650}"#;
1651 self.graphql(
1652 &format!("ordering item {item_id}"),
1653 MUTATION,
1654 serde_json::json!({ "project": project_id, "item": item_id, "after": after }),
1655 )
1656 .await?;
1657 self.project_item(item_id).await
1658 }
1659
1660 pub async fn add_project_draft(
1666 &self,
1667 project_id: &str,
1668 title: &str,
1669 body: &str,
1670 ) -> Result<GitHubProjectItem> {
1671 const MUTATION: &str = r#"
1672mutation($project: ID!, $title: String!, $body: String!) {
1673 addProjectV2DraftIssue(input: { projectId: $project, title: $title, body: $body }) {
1674 projectItem { id }
1675 }
1676}"#;
1677 let data = self
1678 .graphql(
1679 "adding a draft card",
1680 MUTATION,
1681 serde_json::json!({ "project": project_id, "title": title, "body": body }),
1682 )
1683 .await?;
1684 let id = data
1685 .get("addProjectV2DraftIssue")
1686 .and_then(|added| added.get("projectItem"))
1687 .and_then(|item| item.get("id"))
1688 .and_then(|id| id.as_str())
1689 .ok_or_else(|| {
1690 anyhow::anyhow!("the draft was created and GitHub did not say its id")
1691 })?;
1692 self.project_item(id).await
1693 }
1694
1695 pub async fn update_project_draft(
1700 &self,
1701 item_id: &str,
1702 draft_id: &str,
1703 title: &str,
1704 body: &str,
1705 ) -> Result<GitHubProjectItem> {
1706 const MUTATION: &str = r#"
1707mutation($draft: ID!, $title: String!, $body: String!) {
1708 updateProjectV2DraftIssue(input: { draftIssueId: $draft, title: $title, body: $body }) {
1709 draftIssue { id }
1710 }
1711}"#;
1712 self.graphql(
1713 &format!("editing draft {draft_id}"),
1714 MUTATION,
1715 serde_json::json!({ "draft": draft_id, "title": title, "body": body }),
1716 )
1717 .await?;
1718 self.project_item(item_id).await
1719 }
1720
1721 pub async fn add_project_item_by_url(
1727 &self,
1728 project_id: &str,
1729 url: &str,
1730 ) -> Result<GitHubProjectItem> {
1731 const RESOLVE: &str = r#"
1732query($url: URI!) {
1733 resource(url: $url) {
1734 __typename
1735 ... on Issue { id }
1736 ... on PullRequest { id }
1737 }
1738}"#;
1739 let found = self
1740 .graphql(
1741 &format!("looking up {url}"),
1742 RESOLVE,
1743 serde_json::json!({ "url": url }),
1744 )
1745 .await?;
1746 let resource = found
1747 .get("resource")
1748 .filter(|resource| !resource.is_null())
1749 .ok_or_else(|| {
1750 anyhow::anyhow!("{url} is not something on GitHub this token can see")
1751 })?;
1752 let kind = resource
1753 .get("__typename")
1754 .and_then(|kind| kind.as_str())
1755 .unwrap_or("that");
1756 let content = resource.get("id").and_then(|id| id.as_str()).ok_or_else(|| {
1757 anyhow::anyhow!("{url} is a {kind}, and only an issue or a pull request can go on a board")
1758 })?;
1759
1760 const MUTATION: &str = r#"
1761mutation($project: ID!, $content: ID!) {
1762 addProjectV2ItemById(input: { projectId: $project, contentId: $content }) {
1763 item { id }
1764 }
1765}"#;
1766 let data = self
1767 .graphql(
1768 &format!("adding {url} to the board"),
1769 MUTATION,
1770 serde_json::json!({ "project": project_id, "content": content }),
1771 )
1772 .await?;
1773 let id = data
1774 .get("addProjectV2ItemById")
1775 .and_then(|added| added.get("item"))
1776 .and_then(|item| item.get("id"))
1777 .and_then(|id| id.as_str())
1778 .ok_or_else(|| anyhow::anyhow!("the item was added and GitHub did not say its id"))?;
1779 self.project_item(id).await
1780 }
1781
1782 pub async fn archive_project_item(
1788 &self,
1789 project_id: &str,
1790 item_id: &str,
1791 archived: bool,
1792 ) -> Result<GitHubProjectItem> {
1793 const ARCHIVE: &str = r#"
1794mutation($project: ID!, $item: ID!) {
1795 archiveProjectV2Item(input: { projectId: $project, itemId: $item }) { item { id } }
1796}"#;
1797 const UNARCHIVE: &str = r#"
1798mutation($project: ID!, $item: ID!) {
1799 unarchiveProjectV2Item(input: { projectId: $project, itemId: $item }) { item { id } }
1800}"#;
1801 self.graphql(
1802 &format!("archiving item {item_id}"),
1803 if archived { ARCHIVE } else { UNARCHIVE },
1804 serde_json::json!({ "project": project_id, "item": item_id }),
1805 )
1806 .await?;
1807 self.project_item(item_id).await
1808 }
1809
1810 pub async fn delete_project_item(&self, project_id: &str, item_id: &str) -> Result<()> {
1816 const MUTATION: &str = r#"
1817mutation($project: ID!, $item: ID!) {
1818 deleteProjectV2Item(input: { projectId: $project, itemId: $item }) { deletedItemId }
1819}"#;
1820 self.graphql(
1821 &format!("removing item {item_id}"),
1822 MUTATION,
1823 serde_json::json!({ "project": project_id, "item": item_id }),
1824 )
1825 .await?;
1826 Ok(())
1827 }
1828}
1829
1830#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
1836#[serde(tag = "kind", rename_all = "snake_case")]
1837pub enum ProjectFieldWrite {
1838 SingleSelect {
1839 option_id: String,
1840 },
1841 Text {
1842 text: String,
1843 },
1844 Number {
1845 number: f64,
1846 },
1847 Date {
1849 date: String,
1850 },
1851 Iteration {
1852 iteration_id: String,
1853 },
1854}
1855
1856impl ProjectFieldWrite {
1857 fn as_input(&self) -> serde_json::Value {
1859 match self {
1860 Self::SingleSelect { option_id } => {
1861 serde_json::json!({ "singleSelectOptionId": option_id })
1862 }
1863 Self::Text { text } => serde_json::json!({ "text": text }),
1864 Self::Number { number } => serde_json::json!({ "number": number }),
1865 Self::Date { date } => serde_json::json!({ "date": date }),
1866 Self::Iteration { iteration_id } => {
1867 serde_json::json!({ "iterationId": iteration_id })
1868 }
1869 }
1870 }
1871}
1872
1873fn projects_of_owner(data: &serde_json::Value, login: &str) -> Result<Vec<GitHubProject>> {
1880 let Some(owner) = data.get("repositoryOwner").filter(|owner| !owner.is_null()) else {
1884 return Ok(Vec::new());
1885 };
1886 let Some(nodes) = owner
1890 .get("projectsV2")
1891 .and_then(|projects| projects.get("nodes"))
1892 .and_then(|nodes| nodes.as_array())
1893 else {
1894 return Ok(Vec::new());
1895 };
1896 nodes
1897 .iter()
1898 .map(|node| parse_project(node, login))
1899 .collect()
1900}
1901
1902fn validate_column_color(color: &str) -> Result<()> {
1903 const COLORS: &[&str] = &[
1904 "GRAY", "BLUE", "GREEN", "YELLOW", "ORANGE", "RED", "PINK", "PURPLE",
1905 ];
1906 if COLORS.contains(&color) {
1907 return Ok(());
1908 }
1909 anyhow::bail!(
1910 "'{color}' is not a GitHub project column color; expected {}",
1911 COLORS.join(", ")
1912 )
1913}
1914
1915fn parse_project(node: &serde_json::Value, login: &str) -> Result<GitHubProject> {
1916 let field_nodes = node
1917 .get("fields")
1918 .and_then(|fields| fields.get("nodes"))
1919 .and_then(|nodes| nodes.as_array())
1920 .ok_or_else(|| anyhow::anyhow!("a project has no field list"))?;
1921 let fields = field_nodes
1922 .iter()
1923 .map(|field| -> Result<GitHubProjectField> {
1924 let options = match field.get("options") {
1925 None => Vec::new(),
1926 Some(options) => options
1927 .as_array()
1928 .ok_or_else(|| anyhow::anyhow!("a project field's options are not a list"))?
1929 .iter()
1930 .map(|option| -> Result<GitHubProjectFieldOption> {
1931 Ok(GitHubProjectFieldOption {
1932 id: string_at(option, "id", "a project column")?,
1933 name: string_at(option, "name", "a project column")?,
1934 color: string_at(option, "color", "a project column")?,
1935 description: optional_string_at(option, "description", "a project column")?,
1936 })
1937 })
1938 .collect::<Result<Vec<_>>>()?,
1939 };
1940 Ok(GitHubProjectField {
1941 id: string_at(field, "id", "a project field")?,
1942 name: string_at(field, "name", "a project field")?,
1943 kind: string_at(field, "dataType", "a project field")?.to_ascii_lowercase(),
1944 options,
1945 })
1946 })
1947 .collect::<Result<Vec<_>>>()?;
1948
1949 Ok(GitHubProject {
1950 id: string_at(node, "id", "a project")?,
1951 number: node
1952 .get("number")
1953 .and_then(|number| number.as_i64())
1954 .ok_or_else(|| anyhow::anyhow!("a project has no number"))?,
1955 title: string_at(node, "title", "a project")?,
1956 url: string_at(node, "url", "a project")?,
1957 short_description: node
1958 .get("shortDescription")
1959 .and_then(|text| text.as_str())
1960 .map(str::to_string),
1961 closed: node
1962 .get("closed")
1963 .and_then(|closed| closed.as_bool())
1964 .unwrap_or(false),
1965 updated_at: string_at(node, "updatedAt", "a project")?,
1966 owner: login.to_string(),
1967 fields,
1968 })
1969}
1970
1971fn parse_project_item(node: &serde_json::Value) -> Result<GitHubProjectItem> {
1972 let content = node.get("content");
1973 let mut values = BTreeMap::new();
1974 if let Some(nodes) = node
1975 .get("fieldValues")
1976 .and_then(|values| values.get("nodes"))
1977 .and_then(|nodes| nodes.as_array())
1978 {
1979 for value in nodes {
1980 let Some(field_id) = value
1983 .get("field")
1984 .and_then(|field| field.get("id"))
1985 .and_then(|id| id.as_str())
1986 else {
1987 continue;
1988 };
1989 let parsed = if let (Some(option), Some(name)) = (
1990 value.get("optionId").and_then(|id| id.as_str()),
1991 value.get("name").and_then(|name| name.as_str()),
1992 ) {
1993 GitHubProjectValue::SingleSelect {
1994 option_id: option.to_string(),
1995 name: name.to_string(),
1996 }
1997 } else if let Some(text) = value.get("text").and_then(|text| text.as_str()) {
1998 GitHubProjectValue::Text {
1999 text: text.to_string(),
2000 }
2001 } else if let Some(number) = value.get("number").and_then(|number| number.as_f64()) {
2002 GitHubProjectValue::Number { number }
2003 } else if let Some(date) = value.get("date").and_then(|date| date.as_str()) {
2004 GitHubProjectValue::Date {
2005 date: date.to_string(),
2006 }
2007 } else if let (Some(title), Some(start)) = (
2008 value.get("title").and_then(|title| title.as_str()),
2009 value.get("startDate").and_then(|date| date.as_str()),
2010 ) {
2011 GitHubProjectValue::Iteration {
2012 title: title.to_string(),
2013 start_date: start.to_string(),
2014 }
2015 } else {
2016 continue;
2017 };
2018 values.insert(field_id.to_string(), parsed);
2019 }
2020 }
2021
2022 Ok(GitHubProjectItem {
2023 id: string_at(node, "id", "a project item")?,
2024 kind: string_at(node, "type", "a project item")?,
2025 title: content
2026 .and_then(|content| content.get("title"))
2027 .and_then(|title| title.as_str())
2028 .unwrap_or("(untitled)")
2029 .to_string(),
2030 content_id: text(content, "id"),
2031 number: content
2032 .and_then(|content| content.get("number"))
2033 .and_then(|number| number.as_i64()),
2034 url: content
2035 .and_then(|content| content.get("url"))
2036 .and_then(|url| url.as_str())
2037 .map(str::to_string),
2038 repository: content
2039 .and_then(|content| content.get("repository"))
2040 .and_then(|repo| repo.get("nameWithOwner"))
2041 .and_then(|name| name.as_str())
2042 .map(str::to_string),
2043 state: content
2044 .and_then(|content| content.get("state"))
2045 .and_then(|state| state.as_str())
2046 .map(str::to_string),
2047 state_reason: text(content, "stateReason"),
2048 body: text(content, "body"),
2049 author: content
2053 .and_then(|content| content.get("author").or_else(|| content.get("creator")))
2054 .and_then(parse_user),
2055 assignees: people(content, "assignees"),
2056 labels: content
2057 .and_then(|content| content.get("labels"))
2058 .and_then(|labels| labels.get("nodes"))
2059 .and_then(|nodes| nodes.as_array())
2060 .map(|nodes| {
2061 nodes
2062 .iter()
2063 .filter_map(|label| {
2064 Some(GitHubLabel {
2065 name: label.get("name")?.as_str()?.to_string(),
2066 color: label
2067 .get("color")
2068 .and_then(|color| color.as_str())
2069 .unwrap_or_default()
2070 .to_string(),
2071 description: label
2072 .get("description")
2073 .and_then(|text| text.as_str())
2074 .map(str::to_string),
2075 })
2076 })
2077 .collect()
2078 })
2079 .unwrap_or_default(),
2080 milestone: content
2081 .and_then(|content| content.get("milestone"))
2082 .and_then(|milestone| milestone.get("title"))
2083 .and_then(|title| title.as_str())
2084 .map(str::to_string),
2085 comment_count: content
2086 .and_then(|content| content.get("comments"))
2087 .and_then(|comments| comments.get("totalCount"))
2088 .and_then(|count| count.as_i64())
2089 .unwrap_or(0),
2090 created_at: text(content, "createdAt"),
2091 closed_at: text(content, "closedAt"),
2092 is_draft: flag(content, "isDraft"),
2093 merged: flag(content, "merged"),
2094 review_decision: text(content, "reviewDecision"),
2095 archived: node
2096 .get("isArchived")
2097 .and_then(|archived| archived.as_bool())
2098 .unwrap_or(false),
2099 field_values: values,
2100 updated_at: string_at(node, "updatedAt", "a project item")?,
2101 })
2102}
2103
2104fn text(content: Option<&serde_json::Value>, key: &str) -> Option<String> {
2110 content
2111 .and_then(|content| content.get(key))
2112 .and_then(|value| value.as_str())
2113 .map(str::to_string)
2114}
2115
2116fn flag(content: Option<&serde_json::Value>, key: &str) -> bool {
2117 content
2118 .and_then(|content| content.get(key))
2119 .and_then(|value| value.as_bool())
2120 .unwrap_or(false)
2121}
2122
2123fn parse_user(node: &serde_json::Value) -> Option<GitHubUser> {
2124 Some(GitHubUser {
2125 login: node.get("login")?.as_str()?.to_string(),
2126 avatar_url: node
2127 .get("avatarUrl")
2128 .and_then(|url| url.as_str())
2129 .map(str::to_string),
2130 })
2131}
2132
2133fn people(content: Option<&serde_json::Value>, key: &str) -> Vec<GitHubUser> {
2134 content
2135 .and_then(|content| content.get(key))
2136 .and_then(|people| people.get("nodes"))
2137 .and_then(|nodes| nodes.as_array())
2138 .map(|nodes| nodes.iter().filter_map(parse_user).collect())
2139 .unwrap_or_default()
2140}
2141
2142fn string_at(node: &serde_json::Value, key: &str, what: &str) -> Result<String> {
2143 node.get(key)
2144 .and_then(|value| value.as_str())
2145 .map(str::to_string)
2146 .ok_or_else(|| anyhow::anyhow!("{what} has no {key}"))
2147}
2148
2149fn optional_string_at(node: &serde_json::Value, key: &str, what: &str) -> Result<String> {
2163 match node.get(key) {
2164 Some(serde_json::Value::Null) => Ok(String::new()),
2165 Some(serde_json::Value::String(text)) => Ok(text.clone()),
2166 Some(other) => Err(anyhow::anyhow!(
2167 "{what} has a {key} that is not text: {other}"
2168 )),
2169 None => Err(anyhow::anyhow!(
2170 "{what} came back without a {key}, so this cannot say what GitHub \
2171 holds for it — and a refresh writes the whole column back, which \
2172 would erase it. Refresh Projects to read them again."
2173 )),
2174 }
2175}
2176
2177fn header_value(response: &reqwest::Response, name: reqwest::header::HeaderName) -> Option<String> {
2179 response
2180 .headers()
2181 .get(name)
2182 .and_then(|value| value.to_str().ok())
2183 .map(str::to_string)
2184}
2185
2186impl GitHubClient {
2187 pub async fn list_issues(
2189 &self,
2190 owner: &str,
2191 repo: &str,
2192 state: &str,
2193 ) -> Result<Vec<GitHubIssue>> {
2194 let url = self.url(&format!(
2195 "/repos/{}/{}/issues?state={}&per_page=100&sort=updated&direction=desc",
2196 owner, repo, state
2197 ));
2198 let response = require_success(
2199 &format!("GitHub API error listing issues for {}/{}", owner, repo),
2200 self.client
2201 .get(&url)
2202 .bearer_auth(&self.token)
2203 .send()
2204 .await
2205 .with_context(|| format!("failed to list issues for {}/{}", owner, repo))?,
2206 )?;
2207 let resp: Vec<GitHubIssue> = response
2208 .json()
2209 .await
2210 .context("failed to parse issues response")?;
2211
2212 Ok(resp
2214 .into_iter()
2215 .filter(|i| !i.html_url.contains("/pull/"))
2216 .collect())
2217 }
2218
2219 pub async fn get_issue(&self, owner: &str, repo: &str, number: i64) -> Result<GitHubIssue> {
2221 let url = self.url(&format!("/repos/{}/{}/issues/{}", owner, repo, number));
2222 let issue: GitHubIssue = require_success(
2223 &format!("failed to get issue {}/{}#{}", owner, repo, number),
2224 self.client
2225 .get(&url)
2226 .bearer_auth(&self.token)
2227 .send()
2228 .await?,
2229 )?
2230 .json()
2231 .await?;
2232 Ok(issue)
2233 }
2234
2235 pub async fn create_issue(
2237 &self,
2238 owner: &str,
2239 repo: &str,
2240 req: &CreateIssueRequest,
2241 ) -> Result<GitHubIssue> {
2242 let url = self.url(&format!("/repos/{}/{}/issues", owner, repo));
2243 let issue: GitHubIssue = require_success(
2244 &format!("failed to create issue in {}/{}", owner, repo),
2245 self.client
2246 .post(&url)
2247 .bearer_auth(&self.token)
2248 .json(req)
2249 .send()
2250 .await?,
2251 )?
2252 .json()
2253 .await?;
2254 Ok(issue)
2255 }
2256
2257 pub async fn update_issue(
2259 &self,
2260 owner: &str,
2261 repo: &str,
2262 number: i64,
2263 req: &UpdateIssueRequest,
2264 ) -> Result<GitHubIssue> {
2265 let url = self.url(&format!("/repos/{}/{}/issues/{}", owner, repo, number));
2266 let issue: GitHubIssue = require_success(
2267 &format!("failed to update issue {}/{}#{}", owner, repo, number),
2268 self.client
2269 .patch(&url)
2270 .bearer_auth(&self.token)
2271 .json(req)
2272 .send()
2273 .await?,
2274 )?
2275 .json()
2276 .await?;
2277 Ok(issue)
2278 }
2279
2280 pub async fn list_comments(
2282 &self,
2283 owner: &str,
2284 repo: &str,
2285 number: i64,
2286 ) -> Result<Vec<GitHubComment>> {
2287 let url = self.url(&format!(
2288 "/repos/{}/{}/issues/{}/comments?per_page=100",
2289 owner, repo, number
2290 ));
2291 let comments: Vec<GitHubComment> = require_success(
2292 &format!("failed to list comments for {}/{}#{}", owner, repo, number),
2293 self.client
2294 .get(&url)
2295 .bearer_auth(&self.token)
2296 .send()
2297 .await?,
2298 )?
2299 .json()
2300 .await?;
2301 Ok(comments)
2302 }
2303
2304 pub async fn add_comment(
2306 &self,
2307 owner: &str,
2308 repo: &str,
2309 number: i64,
2310 body: &str,
2311 ) -> Result<GitHubComment> {
2312 let url = self.url(&format!("/repos/{}/{}/issues/{}/comments", owner, repo, number));
2313 let comment: GitHubComment = require_success(
2314 &format!("failed to add comment to {}/{}#{}", owner, repo, number),
2315 self.client
2316 .post(&url)
2317 .bearer_auth(&self.token)
2318 .json(&CreateCommentRequest {
2319 body: body.to_string(),
2320 })
2321 .send()
2322 .await?,
2323 )?
2324 .json()
2325 .await?;
2326 Ok(comment)
2327 }
2328
2329 pub async fn list_gists(&self) -> Result<Vec<GitHubGist>> {
2331 let response = require_success(
2332 "failed to list gists",
2333 self.client
2334 .get(self.url("/gists?per_page=100"))
2335 .bearer_auth(&self.token)
2336 .send()
2337 .await?,
2338 )?;
2339 let gists: Vec<GitHubGist> = response
2340 .json()
2341 .await
2342 .context("failed to parse gists response")?;
2343 Ok(gists)
2344 }
2345
2346 pub async fn get_gist(&self, gist_id: &str) -> Result<GitHubGist> {
2348 let url = self.url(&format!("/gists/{}", gist_id));
2349 let gist: GitHubGist = require_success(
2350 &format!("failed to get gist {}", gist_id),
2351 self.client
2352 .get(&url)
2353 .bearer_auth(&self.token)
2354 .send()
2355 .await?,
2356 )?
2357 .json()
2358 .await
2359 .context("failed to parse gist response")?;
2360 Ok(gist)
2361 }
2362
2363 pub async fn create_gist(&self, req: &CreateGistRequest) -> Result<GitHubGist> {
2365 let gist: GitHubGist = require_success(
2366 "failed to create gist",
2367 self.client
2368 .post(&self.url("/gists"))
2369 .bearer_auth(&self.token)
2370 .json(req)
2371 .send()
2372 .await?,
2373 )?
2374 .json()
2375 .await
2376 .context("failed to parse create gist response")?;
2377 Ok(gist)
2378 }
2379
2380 pub async fn update_gist(&self, gist_id: &str, req: &UpdateGistRequest) -> Result<GitHubGist> {
2382 let url = self.url(&format!("/gists/{}", gist_id));
2383 let gist: GitHubGist = require_success(
2384 &format!("failed to update gist {}", gist_id),
2385 self.client
2386 .patch(&url)
2387 .bearer_auth(&self.token)
2388 .json(req)
2389 .send()
2390 .await?,
2391 )?
2392 .json()
2393 .await
2394 .context("failed to parse update gist response")?;
2395 Ok(gist)
2396 }
2397}
2398
2399pub fn parse_github_url(url: &str) -> Option<(String, String)> {
2404 let url = url.trim();
2405
2406 if let Some(rest) = url
2408 .strip_prefix("https://github.com/")
2409 .or_else(|| url.strip_prefix("http://github.com/"))
2410 {
2411 return parse_owner_repo(rest);
2412 }
2413
2414 if let Some(rest) = url
2416 .strip_prefix("git@github.com:")
2417 .or_else(|| url.strip_prefix("ssh://git@github.com/"))
2418 {
2419 return parse_owner_repo(rest);
2420 }
2421
2422 None
2423}
2424
2425fn parse_owner_repo(path: &str) -> Option<(String, String)> {
2426 let path = path.trim_end_matches(".git").trim_end_matches('/');
2427 let parts: Vec<&str> = path.splitn(3, '/').collect();
2428 if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
2429 Some((parts[0].to_string(), parts[1].to_string()))
2430 } else {
2431 None
2432 }
2433}
2434
2435#[cfg(test)]
2436mod tests {
2437 use super::*;
2438
2439 #[test]
2440 fn test_parse_github_url_https() {
2441 assert_eq!(
2442 parse_github_url("https://github.com/wesm/agentsview"),
2443 Some(("wesm".to_string(), "agentsview".to_string()))
2444 );
2445 }
2446
2447 #[test]
2448 fn test_parse_github_url_https_with_git_suffix() {
2449 assert_eq!(
2450 parse_github_url("https://github.com/wesm/agentsview.git"),
2451 Some(("wesm".to_string(), "agentsview".to_string()))
2452 );
2453 }
2454
2455 #[test]
2456 fn test_parse_github_url_ssh() {
2457 assert_eq!(
2458 parse_github_url("git@github.com:wesm/agentsview.git"),
2459 Some(("wesm".to_string(), "agentsview".to_string()))
2460 );
2461 }
2462
2463 #[test]
2466 fn test_parse_github_url_ssh_url_form() {
2467 assert_eq!(
2468 parse_github_url("ssh://git@github.com/wesm/agentsview.git"),
2469 Some(("wesm".to_string(), "agentsview".to_string()))
2470 );
2471 }
2472
2473 #[test]
2474 fn test_parse_github_url_non_github() {
2475 assert_eq!(parse_github_url("https://gitlab.com/user/repo"), None);
2476 }
2477
2478 #[test]
2479 fn test_parse_github_url_invalid() {
2480 assert_eq!(parse_github_url("not a url"), None);
2481 }
2482
2483 fn owner_with_a_board(kind: &str) -> serde_json::Value {
2485 serde_json::json!({
2486 "repositoryOwner": {
2487 "__typename": kind,
2488 "projectsV2": { "nodes": [{
2489 "id": "PVT_1",
2490 "number": 7,
2491 "title": "Roadmap",
2492 "url": "https://github.com/orgs/machinefabric/projects/7",
2493 "shortDescription": null,
2494 "closed": false,
2495 "updatedAt": "2026-08-19T09:00:00Z",
2496 "fields": { "nodes": [] }
2497 }]}
2498 }
2499 })
2500 }
2501
2502 #[test]
2512 fn test10074_a_board_is_read_for_either_kind_of_owner() {
2513 for kind in ["Organization", "User"] {
2514 let boards = projects_of_owner(&owner_with_a_board(kind), "machinefabric")
2515 .expect("the answer parses");
2516 assert_eq!(boards.len(), 1, "no board came back for a {kind} owner");
2517 assert_eq!(boards[0].number, 7);
2518 assert_eq!(boards[0].title, "Roadmap");
2519 assert_eq!(boards[0].owner, "machinefabric");
2520 }
2521 }
2522
2523 #[test]
2532 fn test10075_an_owner_who_does_not_resolve_has_no_boards() {
2533 let nobody = serde_json::json!({ "repositoryOwner": serde_json::Value::Null });
2534 assert!(projects_of_owner(&nobody, "gone")
2535 .expect("a null owner is an answer")
2536 .is_empty());
2537
2538 let unknown = serde_json::json!({
2541 "repositoryOwner": { "__typename": "Mannequin" }
2542 });
2543 assert!(projects_of_owner(&unknown, "someone")
2544 .expect("an unknown owner kind is an answer")
2545 .is_empty());
2546 }
2547
2548 #[test]
2555 fn test10196_a_column_update_preserves_the_complete_existing_options() {
2556 let option = GitHubProjectFieldOption {
2557 id: "OPT_TODO".to_string(),
2558 name: "Todo".to_string(),
2559 color: "GRAY".to_string(),
2560 description: "not started".to_string(),
2561 };
2562 let input = serde_json::to_value(ProjectColumnOptionInput::from(&option))
2563 .expect("the GraphQL input serializes");
2564 assert_eq!(input["id"], "OPT_TODO");
2565 assert_eq!(input["name"], "Todo");
2566 assert_eq!(input["color"], "GRAY");
2567 assert_eq!(input["description"], "not started");
2568
2569 let new_option = ProjectColumnOptionInput {
2570 id: None,
2571 name: "Blocked".to_string(),
2572 color: "RED".to_string(),
2573 description: String::new(),
2574 };
2575 let new_input = serde_json::to_value(new_option).expect("new option serializes");
2576 assert!(
2577 new_input.get("id").is_none(),
2578 "GitHub assigns a new option id"
2579 );
2580 assert!(validate_column_color("RED").is_ok());
2581 assert!(validate_column_color("#ff0000").is_err());
2582 }
2583}
2584
2585#[cfg(test)]
2586mod column_description_tests {
2587 use super::*;
2588
2589 fn option(json: serde_json::Value) -> Result<GitHubProjectFieldOption, serde_json::Error> {
2590 serde_json::from_value(json)
2591 }
2592
2593 #[test]
2599 fn test11607_a_null_description_is_no_description() {
2600 let read = option(serde_json::json!({
2601 "id": "OPT_1", "name": "Todo", "color": "GRAY", "description": null
2602 }))
2603 .expect("a column with no description is an ordinary column");
2604 assert_eq!(read.description, "");
2605 }
2606
2607 #[test]
2618 fn test11608_an_absent_description_is_not_read_as_empty() {
2619 let refusal = option(serde_json::json!({
2620 "id": "OPT_1", "name": "Todo", "color": "GRAY"
2621 }))
2622 .expect_err("a column with no description FIELD was read as having none");
2623 assert!(
2624 refusal.to_string().contains("description"),
2625 "the refusal does not say which field: {refusal}"
2626 );
2627 }
2628
2629 #[test]
2634 fn test11609_a_real_description_survives() {
2635 let read = option(serde_json::json!({
2636 "id": "OPT_1", "name": "Todo", "color": "GRAY",
2637 "description": "not started yet"
2638 }))
2639 .expect("a described column reads");
2640 assert_eq!(read.description, "not started yet");
2641 }
2642}