git_assist/host/
github.rs1use 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
258fn 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}