Skip to main content

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}");
59            }
60            None => {
61                anyhow::bail!("No host found in url: {url}");
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    // Check for GITHUB_TOKEN environment variable first
130    if let Ok(token) = std::env::var("GITHUB_TOKEN") {
131        if !token.is_empty() {
132            println!("Using GITHUB_TOKEN from environment");
133            return Ok(GithubAuthentication::PersonalToken(SecretString::from(
134                token,
135            )));
136        }
137    }
138
139    enum AuthKind {
140        PersonalToken,
141        None,
142        UserAccessToken,
143        Basic,
144        App,
145        OAuth,
146    }
147
148    let auth_kinds = [
149        AuthKind::PersonalToken,
150        AuthKind::None,
151        AuthKind::UserAccessToken,
152        AuthKind::Basic,
153        AuthKind::App,
154        AuthKind::OAuth,
155    ];
156
157    let auth_labels: Vec<_> = auth_kinds
158        .iter()
159        .map(|kind| match kind {
160            AuthKind::None => "No authentication (public API only, rate limited)",
161            AuthKind::Basic => "Basic authentication - username:password (deprecated by GitHub)",
162            AuthKind::PersonalToken => {
163                "Personal access token (recommended - set GITHUB_TOKEN env var to skip this prompt)"
164            }
165            AuthKind::App => "GitHub App authentication (for app developers)",
166            AuthKind::OAuth => "OAuth token (for OAuth apps)",
167            AuthKind::UserAccessToken => "User access token (fine-grained PAT)",
168        })
169        .collect();
170
171    let authentication =
172        Select::new("Choose GitHub authentication method:", auth_labels.clone()).prompt()?;
173
174    let index = auth_labels
175        .into_iter()
176        .position(|label| label == authentication)
177        .ok_or_else(|| anyhow::anyhow!("Selected authentication method not found in list"))?;
178
179    match auth_kinds[index] {
180        AuthKind::None => request_no_auth(),
181        AuthKind::Basic => request_basic_auth(),
182        AuthKind::PersonalToken => request_personal_token(),
183        AuthKind::App => request_app_auth(),
184        AuthKind::OAuth => request_oauth(),
185        AuthKind::UserAccessToken => request_user_access_token(),
186    }
187}
188
189fn request_no_auth() -> anyhow::Result<GithubAuthentication> {
190    Ok(GithubAuthentication::None)
191}
192
193fn request_basic_auth() -> anyhow::Result<GithubAuthentication> {
194    let username = Password::new("Username:")
195        .with_display_toggle_enabled()
196        .with_display_mode(PasswordDisplayMode::Hidden)
197        .prompt()?;
198    let password = Password::new("Password:")
199        .with_display_toggle_enabled()
200        .with_display_mode(PasswordDisplayMode::Hidden)
201        .prompt()?;
202
203    Ok(GithubAuthentication::Basic { username, password })
204}
205
206fn request_personal_token() -> anyhow::Result<GithubAuthentication> {
207    let personal_token = {
208        let string = Password::new("Personal token:")
209            .with_display_toggle_enabled()
210            .with_display_mode(PasswordDisplayMode::Hidden)
211            .prompt()
212            .map_err(anyhow::Error::from)?;
213
214        SecretString::from(string)
215    };
216
217    Ok(GithubAuthentication::PersonalToken(personal_token))
218}
219
220fn request_app_auth() -> anyhow::Result<GithubAuthentication> {
221    let app_id = {
222        let string = Password::new("App ID:")
223            .with_display_toggle_enabled()
224            .with_display_mode(PasswordDisplayMode::Hidden)
225            .prompt()?;
226
227        u64::from_str(&string).map(AppId::from)
228    }?;
229
230    let key = {
231        let string = Password::new("Encoding key:")
232            .with_display_toggle_enabled()
233            .with_display_mode(PasswordDisplayMode::Hidden)
234            .prompt()?;
235
236        EncodingKey::from_base64_secret(&string)
237    }?;
238
239    Ok(GithubAuthentication::App(AppAuth { app_id, key }))
240}
241
242fn request_oauth() -> anyhow::Result<GithubAuthentication> {
243    let access_token = {
244        let string = Password::new("Access token:")
245            .with_display_toggle_enabled()
246            .with_display_mode(PasswordDisplayMode::Hidden)
247            .prompt()
248            .map_err(anyhow::Error::from)?;
249
250        SecretString::from(string)
251    };
252
253    let token_type = Text::new("Token type:").prompt()?;
254
255    let scope = {
256        let string = Text::new("Scope (comma-separated list):").prompt()?;
257
258        string.split(',').map(|s| s.to_owned()).collect::<Vec<_>>()
259    };
260
261    Ok(GithubAuthentication::OAuth(octocrab::auth::OAuth {
262        access_token,
263        expires_in: None,
264        refresh_token_expires_in: None,
265        refresh_token: None,
266        scope,
267        token_type,
268    }))
269}
270
271// Note: Fine-grained personal access tokens may need to be
272// explicitly allowed for organizations and their repositories.
273fn request_user_access_token() -> anyhow::Result<GithubAuthentication> {
274    let personal_token = {
275        let string = Password::new("User access token:")
276            .with_display_toggle_enabled()
277            .with_display_mode(PasswordDisplayMode::Hidden)
278            .prompt()
279            .map_err(anyhow::Error::from)?;
280
281        SecretString::from(string)
282    };
283
284    Ok(GithubAuthentication::UserAccessToken(personal_token))
285}