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