git_assist/host/
github.rs

1use std::str::FromStr;
2
3use async_trait::async_trait;
4use git_url_parse::types::provider::GenericProvider;
5use inquire::{Password, PasswordDisplayMode, Select, Text};
6use jsonwebtoken::EncodingKey;
7use octocrab::{
8    auth::{AppAuth, Auth as GithubAuthentication},
9    models::AppId,
10    params::{pulls::Sort, Direction, State},
11    Octocrab, OctocrabBuilder,
12};
13use secrecy::{ExposeSecret, SecretString};
14
15use crate::host::{GitHost, GitPullRequest, GitRepositoryUrl, GITHUB_HOST};
16
17#[derive(Clone, Default, Debug)]
18pub struct GithubApi {
19    api: Octocrab,
20}
21
22impl GithubApi {
23    pub fn authenticated() -> anyhow::Result<Self> {
24        let mut builder = OctocrabBuilder::default();
25
26        builder = match pick_authentication()? {
27            GithubAuthentication::None => builder,
28            GithubAuthentication::Basic { username, password } => {
29                builder.basic_auth(username, password)
30            }
31            GithubAuthentication::PersonalToken(token) => {
32                builder.personal_token(token.expose_secret().to_owned())
33            }
34            GithubAuthentication::App(AppAuth { app_id, key }) => builder.app(app_id, key),
35            GithubAuthentication::OAuth(oauth) => builder.oauth(oauth),
36            GithubAuthentication::UserAccessToken(token) => {
37                builder.user_access_token(token.expose_secret().to_owned())
38            }
39        };
40
41        let api = builder.build()?;
42
43        Ok(Self { api })
44    }
45}
46
47#[derive(Clone, Debug)]
48pub struct GithubRepository(GitRepositoryUrl);
49
50impl TryFrom<GitRepositoryUrl> for GithubRepository {
51    type Error = anyhow::Error;
52
53    fn try_from(repository: GitRepositoryUrl) -> Result<Self, anyhow::Error> {
54        let url = &repository.url_string;
55        match repository.parsed_url.host() {
56            Some(GITHUB_HOST) => {}
57            Some(_) => {
58                anyhow::bail!("Not a Github url: {url:?}", url = url.to_string());
59            }
60            None => {
61                anyhow::bail!("Not host found in url: {url:?}", url = url.to_string());
62            }
63        }
64
65        Ok(Self(repository))
66    }
67}
68
69impl GithubRepository {
70    pub fn owner(&self) -> Result<String, anyhow::Error> {
71        let provider: GenericProvider = self.0.parsed_url.provider_info()?;
72        Ok(provider.owner().clone())
73    }
74
75    pub fn name(&self) -> Result<String, anyhow::Error> {
76        let provider: GenericProvider = self.0.parsed_url.provider_info()?;
77        Ok(provider.repo().clone())
78    }
79}
80
81#[async_trait]
82impl GitHost for GithubApi {
83    async fn merged_pull_requests(
84        &self,
85        repository: &GitRepositoryUrl,
86    ) -> Result<Vec<GitPullRequest>, anyhow::Error> {
87        let safe_repository = GithubRepository::try_from(repository.clone())?;
88
89        let pull_requests = self
90            .api
91            .all_pages(
92                self.api
93                    .pulls(safe_repository.owner()?, safe_repository.name()?)
94                    .list()
95                    .state(State::Closed)
96                    .sort(Sort::Created)
97                    .direction(Direction::Ascending)
98                    .per_page(100)
99                    .send()
100                    .await?,
101            )
102            .await?;
103
104        let pull_requests: Vec<GitPullRequest> = pull_requests
105            .into_iter()
106            .filter(|pull_request| pull_request.merged_at.is_some())
107            .map(|pull_request| {
108                let identifier = pull_request.number.to_string();
109                let title = pull_request.title;
110                let base_sha = pull_request.base.sha;
111                let Some(merge_sha) = pull_request.merge_commit_sha else {
112                    anyhow::bail!("Could not find merge commit sha");
113                };
114
115                Ok(GitPullRequest {
116                    identifier,
117                    title,
118                    base_sha,
119                    merge_sha,
120                })
121            })
122            .collect::<Result<_, _>>()?;
123
124        Ok(pull_requests)
125    }
126}
127
128fn pick_authentication() -> anyhow::Result<GithubAuthentication> {
129    enum AuthKind {
130        None,
131        Basic,
132        PersonalToken,
133        App,
134        OAuth,
135        UserAccessToken,
136    }
137
138    let auth_kinds = [
139        AuthKind::None,
140        AuthKind::Basic,
141        AuthKind::PersonalToken,
142        AuthKind::App,
143        AuthKind::OAuth,
144        AuthKind::UserAccessToken,
145    ];
146
147    let auth_labels: Vec<_> = auth_kinds
148        .iter()
149        .map(|kind| match kind {
150            AuthKind::None => "No authentication",
151            AuthKind::Basic => "Basic HTTP authentication (username:password)",
152            AuthKind::PersonalToken => "Authenticate using a Github personal access token",
153            AuthKind::App => "Authenticate as a Github App",
154            AuthKind::OAuth => "Authenticate as a Github OAuth App",
155            AuthKind::UserAccessToken => "Authenticate using a User Access Token",
156        })
157        .collect();
158
159    let authentication = Select::new("Authenticate?", auth_labels.clone()).prompt()?;
160
161    let index = auth_labels
162        .into_iter()
163        .position(|label| label == authentication)
164        .expect("Should be unreachable");
165
166    match auth_kinds[index] {
167        AuthKind::None => request_no_auth(),
168        AuthKind::Basic => request_basic_auth(),
169        AuthKind::PersonalToken => request_personal_token(),
170        AuthKind::App => request_app_auth(),
171        AuthKind::OAuth => request_oauth(),
172        AuthKind::UserAccessToken => request_user_access_token(),
173    }
174}
175
176fn request_no_auth() -> anyhow::Result<GithubAuthentication> {
177    Ok(GithubAuthentication::None)
178}
179
180fn request_basic_auth() -> anyhow::Result<GithubAuthentication> {
181    let username = Password::new("Username:")
182        .with_display_toggle_enabled()
183        .with_display_mode(PasswordDisplayMode::Hidden)
184        .prompt()?;
185    let password = Password::new("Password:")
186        .with_display_toggle_enabled()
187        .with_display_mode(PasswordDisplayMode::Hidden)
188        .prompt()?;
189
190    Ok(GithubAuthentication::Basic { username, password })
191}
192
193fn request_personal_token() -> anyhow::Result<GithubAuthentication> {
194    let personal_token = {
195        let string = Password::new("Personal token:")
196            .with_display_toggle_enabled()
197            .with_display_mode(PasswordDisplayMode::Hidden)
198            .prompt()
199            .map_err(anyhow::Error::from)?;
200
201        SecretString::from(string)
202    };
203
204    Ok(GithubAuthentication::PersonalToken(personal_token))
205}
206
207fn request_app_auth() -> anyhow::Result<GithubAuthentication> {
208    let app_id = {
209        let string = Password::new("App ID:")
210            .with_display_toggle_enabled()
211            .with_display_mode(PasswordDisplayMode::Hidden)
212            .prompt()?;
213
214        u64::from_str(&string).map(AppId::from)
215    }?;
216
217    let key = {
218        let string = Password::new("Encoding key:")
219            .with_display_toggle_enabled()
220            .with_display_mode(PasswordDisplayMode::Hidden)
221            .prompt()?;
222
223        EncodingKey::from_base64_secret(&string)
224    }?;
225
226    Ok(GithubAuthentication::App(AppAuth { app_id, key }))
227}
228
229fn request_oauth() -> anyhow::Result<GithubAuthentication> {
230    let access_token = {
231        let string = Password::new("Access token:")
232            .with_display_toggle_enabled()
233            .with_display_mode(PasswordDisplayMode::Hidden)
234            .prompt()
235            .map_err(anyhow::Error::from)?;
236
237        SecretString::from(string)
238    };
239
240    let token_type = Text::new("Token type:").prompt()?;
241
242    let scope = {
243        let string = Text::new("Scope (comma-separated list):").prompt()?;
244
245        string.split(',').map(|s| s.to_owned()).collect::<Vec<_>>()
246    };
247
248    Ok(GithubAuthentication::OAuth(octocrab::auth::OAuth {
249        access_token,
250        expires_in: None,
251        refresh_token_expires_in: None,
252        refresh_token: None,
253        scope,
254        token_type,
255    }))
256}
257
258// Note: Fine-grained personal access tokens may need to be
259// explicitly allowed for organizations and their repositories.
260fn request_user_access_token() -> anyhow::Result<GithubAuthentication> {
261    let personal_token = {
262        let string = Password::new("User access token:")
263            .with_display_toggle_enabled()
264            .with_display_mode(PasswordDisplayMode::Hidden)
265            .prompt()
266            .map_err(anyhow::Error::from)?;
267
268        SecretString::from(string)
269    };
270
271    Ok(GithubAuthentication::UserAccessToken(personal_token))
272}