1use std::process::Command;
17
18use anyhow::{Context, Result};
19use keyring::Entry;
20use octocrab::Octocrab;
21use reqwest::header::ACCEPT;
22use secrecy::{ExposeSecret, SecretString};
23use tracing::{debug, info, instrument};
24
25use super::{KEYRING_SERVICE, KEYRING_USER};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum TokenSource {
30 Environment,
32 GhCli,
34 Keyring,
36}
37
38impl std::fmt::Display for TokenSource {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 TokenSource::Environment => write!(f, "environment variable"),
42 TokenSource::GhCli => write!(f, "GitHub CLI"),
43 TokenSource::Keyring => write!(f, "system keyring"),
44 }
45 }
46}
47
48const OAUTH_SCOPES: &[&str] = &["repo", "read:user"];
50
51fn keyring_entry() -> Result<Entry> {
53 Entry::new(KEYRING_SERVICE, KEYRING_USER).context("Failed to create keyring entry")
54}
55
56#[instrument]
60#[allow(clippy::let_and_return)] pub fn is_authenticated() -> bool {
62 let result = resolve_token().is_some();
63 result
64}
65
66#[instrument]
71#[allow(clippy::let_and_return)] pub fn has_keyring_token() -> bool {
73 let result = match keyring_entry() {
74 Ok(entry) => entry.get_password().is_ok(),
75 Err(_) => false,
76 };
77 result
78}
79
80#[instrument]
84pub fn get_stored_token() -> Option<SecretString> {
85 let entry = keyring_entry().ok()?;
86 let password = entry.get_password().ok()?;
87 debug!("Retrieved token from keyring");
88 Some(SecretString::from(password))
89}
90
91#[instrument]
99fn get_token_from_gh_cli() -> Option<SecretString> {
100 debug!("Attempting to get token from gh CLI");
101
102 let output = Command::new("gh").args(["auth", "token"]).output();
104
105 match output {
106 Ok(output) if output.status.success() => {
107 let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
108 if token.is_empty() {
109 debug!("gh auth token returned empty output");
110 None
111 } else {
112 debug!("Successfully retrieved token from gh CLI");
113 Some(SecretString::from(token))
114 }
115 }
116 Ok(output) => {
117 let stderr = String::from_utf8_lossy(&output.stderr);
118 debug!(
119 status = ?output.status,
120 stderr = %stderr.trim(),
121 "gh auth token failed"
122 );
123 None
124 }
125 Err(e) => {
126 debug!(error = %e, "Failed to execute gh command");
127 None
128 }
129 }
130}
131
132#[instrument]
142pub fn resolve_token() -> Option<(SecretString, TokenSource)> {
143 if let Ok(token) = std::env::var("GH_TOKEN")
145 && !token.is_empty()
146 {
147 debug!("Using token from GH_TOKEN environment variable");
148 return Some((SecretString::from(token), TokenSource::Environment));
149 }
150
151 if let Ok(token) = std::env::var("GITHUB_TOKEN")
153 && !token.is_empty()
154 {
155 debug!("Using token from GITHUB_TOKEN environment variable");
156 return Some((SecretString::from(token), TokenSource::Environment));
157 }
158
159 if let Some(token) = get_token_from_gh_cli() {
161 debug!("Using token from GitHub CLI");
162 return Some((token, TokenSource::GhCli));
163 }
164
165 if let Some(token) = get_stored_token() {
167 debug!("Using token from system keyring");
168 return Some((token, TokenSource::Keyring));
169 }
170
171 debug!("No token found in any source");
172 None
173}
174
175#[instrument(skip(token))]
177pub fn store_token(token: &SecretString) -> Result<()> {
178 let entry = keyring_entry()?;
179 entry
180 .set_password(token.expose_secret())
181 .context("Failed to store token in keyring")?;
182 info!("Token stored in system keyring");
183 Ok(())
184}
185
186#[instrument]
188pub fn delete_token() -> Result<()> {
189 let entry = keyring_entry()?;
190 entry
191 .delete_credential()
192 .context("Failed to delete token from keyring")?;
193 info!("Token deleted from keyring");
194 Ok(())
195}
196
197#[instrument]
207pub async fn authenticate(client_id: &SecretString) -> Result<()> {
208 debug!("Starting OAuth device flow");
209
210 let crab = Octocrab::builder()
212 .base_uri("https://github.com")
213 .context("Failed to set base URI")?
214 .add_header(ACCEPT, "application/json".to_string())
215 .build()
216 .context("Failed to build OAuth client")?;
217
218 let codes = crab
220 .authenticate_as_device(client_id, OAUTH_SCOPES)
221 .await
222 .context("Failed to request device code")?;
223
224 println!();
226 println!("To authenticate, visit:");
227 println!();
228 println!(" {}", codes.verification_uri);
229 println!();
230 println!("And enter the code:");
231 println!();
232 println!(" {}", codes.user_code);
233 println!();
234 println!("Waiting for authorization...");
235
236 let auth = codes
238 .poll_until_available(&crab, client_id)
239 .await
240 .context("Authorization failed or timed out")?;
241
242 let token = SecretString::from(auth.access_token.expose_secret().to_owned());
244 store_token(&token)?;
245
246 info!("Authentication successful");
247 Ok(())
248}
249
250#[instrument]
257pub fn create_client() -> Result<Octocrab> {
258 let (token, source) =
259 resolve_token().context("Not authenticated - run `aptu auth login` first")?;
260
261 info!(source = %source, "Creating GitHub client");
262
263 let client = Octocrab::builder()
264 .personal_token(token.expose_secret().to_string())
265 .build()
266 .context("Failed to build GitHub client")?;
267
268 debug!("Created authenticated GitHub client");
269 Ok(client)
270}
271
272#[instrument(skip(token))]
285pub fn create_client_with_token(token: &SecretString) -> Result<Octocrab> {
286 info!("Creating GitHub client with provided token");
287
288 let client = Octocrab::builder()
289 .personal_token(token.expose_secret().to_string())
290 .build()
291 .context("Failed to build GitHub client")?;
292
293 debug!("Created authenticated GitHub client");
294 Ok(client)
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn test_keyring_entry_creation() {
303 let result = keyring_entry();
305 assert!(result.is_ok());
306 }
307
308 #[test]
309 fn test_token_source_display() {
310 assert_eq!(TokenSource::Environment.to_string(), "environment variable");
311 assert_eq!(TokenSource::GhCli.to_string(), "GitHub CLI");
312 assert_eq!(TokenSource::Keyring.to_string(), "system keyring");
313 }
314
315 #[test]
316 fn test_token_source_equality() {
317 assert_eq!(TokenSource::Environment, TokenSource::Environment);
318 assert_ne!(TokenSource::Environment, TokenSource::GhCli);
319 assert_ne!(TokenSource::GhCli, TokenSource::Keyring);
320 }
321
322 #[test]
323 fn test_gh_cli_not_installed_returns_none() {
324 let result = get_token_from_gh_cli();
329 let _ = result;
332 }
333}