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