git_assist/host/
github.rs

1use std::str::FromStr;
2
3use async_trait::async_trait;
4
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.as_deref() {
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) -> &str {
71        self.0.parsed_url.owner.as_deref().unwrap()
72    }
73
74    pub fn name(&self) -> &str {
75        &self.0.parsed_url.name
76    }
77}
78
79#[async_trait]
80impl GitHost for GithubApi {
81    async fn merged_pull_requests(
82        &self,
83        repository: &GitRepositoryUrl,
84    ) -> Result<Vec<GitPullRequest>, anyhow::Error> {
85        let safe_repository = GithubRepository::try_from(repository.clone())?;
86
87        let pull_requests = self
88            .api
89            .all_pages(
90                self.api
91                    .pulls(safe_repository.owner(), safe_repository.name())
92                    .list()
93                    .state(State::Closed)
94                    .sort(Sort::Created)
95                    .direction(Direction::Ascending)
96                    .per_page(100)
97                    .send()
98                    .await?,
99            )
100            .await?;
101
102        let pull_requests: Vec<GitPullRequest> = pull_requests
103            .into_iter()
104            .filter(|pull_request| pull_request.merged_at.is_some())
105            .map(|pull_request| {
106                let identifier = pull_request.number.to_string();
107                let title = pull_request.title;
108                let base_sha = pull_request.base.sha;
109                let Some(merge_sha) = pull_request.merge_commit_sha else {
110                    anyhow::bail!("Could not find merge commit sha");
111                };
112
113                Ok(GitPullRequest {
114                    identifier,
115                    title,
116                    base_sha,
117                    merge_sha,
118                })
119            })
120            .collect::<Result<_, _>>()?;
121
122        Ok(pull_requests)
123    }
124}
125
126fn pick_authentication() -> anyhow::Result<GithubAuthentication> {
127    enum AuthKind {
128        None,
129        Basic,
130        PersonalToken,
131        App,
132        OAuth,
133        UserAccessToken,
134    }
135
136    let auth_kinds = [
137        AuthKind::None,
138        AuthKind::Basic,
139        AuthKind::PersonalToken,
140        AuthKind::App,
141        AuthKind::OAuth,
142        AuthKind::UserAccessToken,
143    ];
144
145    let auth_labels: Vec<_> = auth_kinds
146        .iter()
147        .map(|kind| match kind {
148            AuthKind::None => "No authentication",
149            AuthKind::Basic => "Basic HTTP authentication (username:password)",
150            AuthKind::PersonalToken => "Authenticate using a Github personal access token",
151            AuthKind::App => "Authenticate as a Github App",
152            AuthKind::OAuth => "Authenticate as a Github OAuth App",
153            AuthKind::UserAccessToken => "Authenticate using a User Access Token",
154        })
155        .collect();
156
157    let authentication = Select::new("Authenticate?", auth_labels.clone()).prompt()?;
158
159    let index = auth_labels
160        .into_iter()
161        .position(|label| label == authentication)
162        .expect("Should be unreachable");
163
164    match auth_kinds[index] {
165        AuthKind::None => request_no_auth(),
166        AuthKind::Basic => request_basic_auth(),
167        AuthKind::PersonalToken => request_personal_token(),
168        AuthKind::App => request_app_auth(),
169        AuthKind::OAuth => request_oauth(),
170        AuthKind::UserAccessToken => request_user_access_token(),
171    }
172}
173
174fn request_no_auth() -> anyhow::Result<GithubAuthentication> {
175    Ok(GithubAuthentication::None)
176}
177
178fn request_basic_auth() -> anyhow::Result<GithubAuthentication> {
179    let username = Password::new("Username:")
180        .with_display_toggle_enabled()
181        .with_display_mode(PasswordDisplayMode::Hidden)
182        .prompt()?;
183    let password = Password::new("Password:")
184        .with_display_toggle_enabled()
185        .with_display_mode(PasswordDisplayMode::Hidden)
186        .prompt()?;
187
188    Ok(GithubAuthentication::Basic { username, password })
189}
190
191fn request_personal_token() -> anyhow::Result<GithubAuthentication> {
192    let personal_token = {
193        let string = Password::new("Personal token:")
194            .with_display_toggle_enabled()
195            .with_display_mode(PasswordDisplayMode::Hidden)
196            .prompt()
197            .map_err(anyhow::Error::from)?;
198
199        SecretString::from(string)
200    };
201
202    Ok(GithubAuthentication::PersonalToken(personal_token))
203}
204
205fn request_app_auth() -> anyhow::Result<GithubAuthentication> {
206    let app_id = {
207        let string = Password::new("App ID:")
208            .with_display_toggle_enabled()
209            .with_display_mode(PasswordDisplayMode::Hidden)
210            .prompt()?;
211
212        u64::from_str(&string).map(AppId::from)
213    }?;
214
215    let key = {
216        let string = Password::new("Encoding key:")
217            .with_display_toggle_enabled()
218            .with_display_mode(PasswordDisplayMode::Hidden)
219            .prompt()?;
220
221        EncodingKey::from_base64_secret(&string)
222    }?;
223
224    Ok(GithubAuthentication::App(AppAuth { app_id, key }))
225}
226
227fn request_oauth() -> anyhow::Result<GithubAuthentication> {
228    let access_token = {
229        let string = Password::new("Access token:")
230            .with_display_toggle_enabled()
231            .with_display_mode(PasswordDisplayMode::Hidden)
232            .prompt()
233            .map_err(anyhow::Error::from)?;
234
235        SecretString::from(string)
236    };
237
238    let token_type = Text::new("Token type:").prompt()?;
239
240    let scope = {
241        let string = Text::new("Scope (comma-separated list):").prompt()?;
242
243        string.split(',').map(|s| s.to_owned()).collect::<Vec<_>>()
244    };
245
246    Ok(GithubAuthentication::OAuth(octocrab::auth::OAuth {
247        access_token,
248        expires_in: None,
249        refresh_token_expires_in: None,
250        refresh_token: None,
251        scope,
252        token_type,
253    }))
254}
255
256// Note: Fine-grained personal access tokens may need to be
257// explicitly allowed for organizations and their repositories.
258fn request_user_access_token() -> anyhow::Result<GithubAuthentication> {
259    let personal_token = {
260        let string = Password::new("User access token:")
261            .with_display_toggle_enabled()
262            .with_display_mode(PasswordDisplayMode::Hidden)
263            .prompt()
264            .map_err(anyhow::Error::from)?;
265
266        SecretString::from(string)
267    };
268
269    Ok(GithubAuthentication::UserAccessToken(personal_token))
270}