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}");
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 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
271fn 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}