synd_term/client/github/
mod.rs

1use graphql_client::GraphQLQuery;
2use octocrab::Octocrab;
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6use crate::{
7    config,
8    types::github::{
9        IssueContext, IssueId, Notification, NotificationContext, NotificationId,
10        PullRequestContext, PullRequestId, RepositoryKey, ThreadId,
11    },
12};
13
14#[derive(Debug, Error)]
15pub enum GithubError {
16    #[error("invalid credential. please make sure a valid PAT is set")]
17    BadCredential,
18    // https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#about-secondary-rate-limits
19    #[error("secondary rate limits exceeded")]
20    SecondaryRateLimit,
21    #[error("github api error: {0}")]
22    Api(Box<octocrab::Error>),
23}
24
25impl From<octocrab::Error> for GithubError {
26    fn from(err: octocrab::Error) -> Self {
27        match &err {
28            octocrab::Error::GitHub { source, .. } => match source.status_code.as_u16() {
29                401 => GithubError::BadCredential,
30                403 if source.message.contains("secondary rate limit") => {
31                    GithubError::SecondaryRateLimit
32                }
33                _ => GithubError::Api(Box::new(err)),
34            },
35            _ => GithubError::Api(Box::new(err)),
36        }
37    }
38}
39
40#[derive(Clone)]
41pub struct GithubClient {
42    client: Octocrab,
43}
44
45impl GithubClient {
46    pub fn new(pat: impl Into<String>) -> Result<Self, GithubError> {
47        let pat = pat.into();
48        if pat.is_empty() {
49            return Err(GithubError::BadCredential);
50        }
51        let timeout = Some(config::github::CLIENT_TIMEOUT);
52        let octo = Octocrab::builder()
53            .personal_token(pat)
54            .set_connect_timeout(timeout)
55            .set_read_timeout(timeout)
56            .set_write_timeout(timeout)
57            .build()
58            .unwrap();
59        Ok(Self::with(octo))
60    }
61
62    #[must_use]
63    pub fn with(client: Octocrab) -> Self {
64        Self { client }
65    }
66
67    pub(crate) async fn mark_thread_as_done(&self, id: NotificationId) -> Result<(), GithubError> {
68        self.client
69            .activity()
70            .notifications()
71            .mark_as_read(id)
72            .await
73            .map_err(GithubError::from)
74    }
75
76    pub(crate) async fn unsubscribe_thread(&self, id: ThreadId) -> Result<(), GithubError> {
77        // The reasons for not using the `set_thread_subscription` method of `NotificationHandler` are twofold:
78        // 1. Since the API require the PUT method, but it is implemented using GET, it results in a "Not found" error.
79        // 2. During the deserialization of the `ThreadSubscription` response type, an empty string is assigned to the reason, causing an error when deserializing the `Reason` enum.
80        // https://github.com/XAMPPRocky/octocrab/pull/661
81
82        #[derive(serde::Serialize)]
83        struct Inner {
84            ignored: bool,
85        }
86        #[derive(serde::Deserialize)]
87        struct Response {}
88
89        let thread = id;
90        let ignored = true;
91
92        let route = format!("/notifications/threads/{thread}/subscription");
93        let body = Inner { ignored };
94
95        self.client
96            .put::<Response, _, _>(route, Some(&body))
97            .await?;
98        Ok(())
99    }
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
103pub(crate) enum FetchNotificationInclude {
104    /// Fetch only unread notifications
105    OnlyUnread,
106    All,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
110pub(crate) enum FetchNotificationParticipating {
111    /// Fetch only participating notifications
112    OnlyParticipating,
113    All,
114}
115
116#[derive(Debug, Clone)]
117pub(crate) struct FetchNotificationsParams {
118    pub(crate) page: u8,
119    pub(crate) include: FetchNotificationInclude,
120    pub(crate) participating: FetchNotificationParticipating,
121}
122
123impl GithubClient {
124    #[tracing::instrument(skip(self))]
125    pub(crate) async fn fetch_notifications(
126        &self,
127        FetchNotificationsParams {
128            page,
129            include,
130            participating,
131        }: FetchNotificationsParams,
132    ) -> Result<Vec<Notification>, GithubError> {
133        let mut page = self
134            .client
135            .activity()
136            .notifications()
137            .list()
138            .participating(participating == FetchNotificationParticipating::OnlyParticipating)
139            .all(include == FetchNotificationInclude::All)
140            .page(page) // 1 Origin
141            .per_page(config::github::NOTIFICATION_PER_PAGE)
142            .send()
143            .await?;
144        let notifications: Vec<_> = page
145            .take_items()
146            .into_iter()
147            .map(Notification::from)
148            .collect();
149
150        tracing::debug!(
151            "Fetch {} github notifications: {page:?}",
152            notifications.len()
153        );
154
155        Ok(notifications)
156    }
157}
158
159#[derive(GraphQLQuery)]
160#[graphql(
161    schema_path = "src/client/github/schema.json",
162    query_path = "src/client/github/issue_query.gql",
163    variables_derives = "Clone, Debug",
164    response_derives = "Clone, Debug"
165)]
166pub(crate) struct IssueQuery;
167
168impl GithubClient {
169    pub(crate) async fn fetch_issue(
170        &self,
171        NotificationContext {
172            id,
173            repository_key: RepositoryKey { name, owner },
174            ..
175        }: NotificationContext<IssueId>,
176    ) -> Result<IssueContext, GithubError> {
177        let response: octocrab::Result<graphql_client::Response<issue_query::ResponseData>> = self
178            .client
179            .graphql(&IssueQuery::build_query(issue_query::Variables {
180                repository_owner: owner,
181                repository_name: name,
182                issue_number: id.into_inner(),
183            }))
184            .await;
185
186        match response {
187            Ok(response) => match (response.data, response.errors) {
188                (_, Some(errors)) => {
189                    tracing::error!("{errors:?}");
190                    todo!()
191                }
192                (Some(data), _) => Ok(IssueContext::from(data)),
193                _ => unreachable!(),
194            },
195            Err(error) => Err(GithubError::from(error)),
196        }
197    }
198}
199
200#[derive(GraphQLQuery)]
201#[graphql(
202    schema_path = "src/client/github/schema.json",
203    query_path = "src/client/github/pull_request_query.gql",
204    variables_derives = "Clone, Debug",
205    response_derives = "Clone, Debug"
206)]
207pub(crate) struct PullRequestQuery;
208
209impl GithubClient {
210    pub(crate) async fn fetch_pull_request(
211        &self,
212        NotificationContext {
213            id,
214            repository_key: RepositoryKey { name, owner },
215            ..
216        }: NotificationContext<PullRequestId>,
217    ) -> Result<PullRequestContext, GithubError> {
218        let response: octocrab::Result<graphql_client::Response<pull_request_query::ResponseData>> =
219            self.client
220                .graphql(&PullRequestQuery::build_query(
221                    pull_request_query::Variables {
222                        repository_owner: owner,
223                        repository_name: name,
224                        pull_request_number: id.into_inner(),
225                    },
226                ))
227                .await;
228
229        match response {
230            Ok(response) => match (response.data, response.errors) {
231                (_, Some(errors)) => {
232                    tracing::error!("{errors:?}");
233                    todo!()
234                }
235                (Some(data), _) => Ok(PullRequestContext::from(data)),
236                _ => unreachable!(),
237            },
238            Err(error) => Err(GithubError::from(error)),
239        }
240    }
241}