generated from pure_sagacity/rust-starter
added credential management to keyring.
cursor.
This commit is contained in:
+1
-1
@@ -13,5 +13,5 @@ octocrab = "0.54.0"
|
||||
dialoguer = "0.12"
|
||||
keyring = "4.1.4"
|
||||
colored = "3.1.1"
|
||||
toml = "1.1.2+spec-1.1.0"
|
||||
toml = "1.1.2"
|
||||
dirs = "6.0.0"
|
||||
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use colored::*;
|
||||
|
||||
use crate::{
|
||||
config::{parse_config, Provider},
|
||||
credentials,
|
||||
providers::Provider as ProviderTrait,
|
||||
tui::user_select_repo,
|
||||
CONFIG_PATH,
|
||||
};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "clonee", about = "Clone repositories from GitHub or Gitea")]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Command>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Command {
|
||||
/// Manage provider API tokens
|
||||
Auth {
|
||||
#[command(subcommand)]
|
||||
command: AuthCommand,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum AuthCommand {
|
||||
/// Store an API token in the system keychain
|
||||
Set {
|
||||
/// Friendly name of the provider from config.toml
|
||||
friendly_name: String,
|
||||
},
|
||||
/// Remove a stored API token from the system keychain
|
||||
Remove {
|
||||
/// Friendly name of the provider from config.toml
|
||||
friendly_name: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn run(cli: Cli) {
|
||||
match cli.command {
|
||||
Some(Command::Auth { command }) => run_auth(command).await,
|
||||
None => run_interactive().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_auth(command: AuthCommand) {
|
||||
let config_file = format!("{CONFIG_PATH}/config.toml");
|
||||
let config = match parse_config(config_file.clone()).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("{}: {}", "Failed to parse config".red(), e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
match command {
|
||||
AuthCommand::Set { friendly_name } => {
|
||||
let provider = match find_provider(&config.provider, &friendly_name) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
eprintln!(
|
||||
"{}: no provider named '{}'",
|
||||
"Error".red(),
|
||||
friendly_name
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let token = match credentials::prompt_for_token(&friendly_name) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
eprintln!("{}: {}", "Failed to read token".red(), e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
if token.is_empty() {
|
||||
eprintln!("{}", "Token cannot be empty.".red());
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
if let Err(e) =
|
||||
credentials::store_token(provider.provider_type(), &friendly_name, &token)
|
||||
{
|
||||
eprintln!("{}: {}", "Failed to store token".red(), e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
println!(
|
||||
"Stored token for '{}' in the system keychain.",
|
||||
friendly_name
|
||||
);
|
||||
}
|
||||
AuthCommand::Remove { friendly_name } => {
|
||||
let provider = match find_provider(&config.provider, &friendly_name) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
eprintln!(
|
||||
"{}: no provider named '{}'",
|
||||
"Error".red(),
|
||||
friendly_name
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) =
|
||||
credentials::delete_token(provider.provider_type(), &friendly_name)
|
||||
{
|
||||
eprintln!("{}: {}", "Failed to remove token".red(), e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
println!(
|
||||
"Removed token for '{}' from the system keychain.",
|
||||
friendly_name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_provider<'a>(providers: &'a [Provider], friendly_name: &str) -> Option<&'a Provider> {
|
||||
providers
|
||||
.iter()
|
||||
.find(|p| p.friendly_name() == friendly_name)
|
||||
}
|
||||
|
||||
fn print_repositories_error(err: &dyn std::error::Error, friendly_name: &str) {
|
||||
eprintln!("{}", "Failed to fetch repositories.".red());
|
||||
|
||||
let details = err.to_string();
|
||||
let is_auth_error = details.contains("401")
|
||||
|| details.contains("403")
|
||||
|| details.contains("Unauthorized")
|
||||
|| details.contains("Forbidden");
|
||||
|
||||
if is_auth_error {
|
||||
eprintln!(
|
||||
"{}",
|
||||
"Your stored token may be invalid or expired.".yellow()
|
||||
);
|
||||
eprintln!(
|
||||
"Remove it with: clonee auth remove \"{friendly_name}\""
|
||||
);
|
||||
eprintln!("Or set a new one with: clonee auth set \"{friendly_name}\"");
|
||||
}
|
||||
|
||||
eprintln!("{}", details.dimmed());
|
||||
}
|
||||
|
||||
pub async fn run_interactive() {
|
||||
let config_file = format!("{CONFIG_PATH}/config.toml");
|
||||
let config = match parse_config(config_file.clone()).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let top = "Failed to parse config.".red();
|
||||
let bottom = "Maybe there's an error in the config?".dimmed();
|
||||
|
||||
println!("{}\n{}", top, bottom);
|
||||
eprintln!("Error at {}: {}", config_file, e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let selected_provider = crate::ask_option("Choose a provider:", config.provider);
|
||||
let friendly_name = selected_provider.friendly_name().to_string();
|
||||
|
||||
let provider = match selected_provider.into_provider_client().await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("{}: {}", "Failed to initialize provider".red(), e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let repos = match provider.repositories().await {
|
||||
Ok(repos) => repos,
|
||||
Err(e) => {
|
||||
print_repositories_error(&*e, &friendly_name);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let selection = match user_select_repo(repos).await {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
println!("{}", "No repository was selected!".red());
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
match crate::ask_yes_no("Clone the repo?", Some(crate::DefaultAnswer::Yes)) {
|
||||
true => {
|
||||
let folder: Option<String> =
|
||||
if crate::ask_yes_no("Use a custom folder?", Some(crate::DefaultAnswer::No)) {
|
||||
let custom = crate::get_input("Folder name: ");
|
||||
Some(custom)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match provider.clone(selection, folder, false).await {
|
||||
Ok(_) => println!("Repo cloned!"),
|
||||
Err(_) => {
|
||||
println!("{}", "Error cloning repo.".red())
|
||||
}
|
||||
}
|
||||
}
|
||||
false => {
|
||||
println!("Exiting...");
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
+144
-19
@@ -1,16 +1,15 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::credentials;
|
||||
use crate::providers::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::fs;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Config {
|
||||
pub provider: Vec<Provider>,
|
||||
}
|
||||
|
||||
// Added #[serde(tag = "type")] so Serde knows to look for a "type" field
|
||||
// instead of nesting the entire structure inside the variant name.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum Provider {
|
||||
@@ -18,6 +17,23 @@ pub enum Provider {
|
||||
Github {
|
||||
friendly_name: String,
|
||||
username: String,
|
||||
},
|
||||
#[serde(rename = "gitea")]
|
||||
Gitea {
|
||||
friendly_name: String,
|
||||
base_url: String,
|
||||
username: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(tag = "type")]
|
||||
enum LegacyProvider {
|
||||
#[serde(rename = "github")]
|
||||
Github {
|
||||
friendly_name: String,
|
||||
username: String,
|
||||
#[serde(default)]
|
||||
token: Option<String>,
|
||||
},
|
||||
#[serde(rename = "gitea")]
|
||||
@@ -25,10 +41,16 @@ pub enum Provider {
|
||||
friendly_name: String,
|
||||
base_url: String,
|
||||
username: String,
|
||||
#[serde(default)]
|
||||
token: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LegacyConfig {
|
||||
provider: Vec<LegacyProvider>,
|
||||
}
|
||||
|
||||
pub enum ProviderClient {
|
||||
Github(crate::providers::github::Github),
|
||||
Gitea(crate::providers::gitea::Gitea),
|
||||
@@ -63,21 +85,45 @@ impl Provider {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_provider_trait(self) -> ProviderClient {
|
||||
pub fn provider_type(&self) -> &str {
|
||||
match self {
|
||||
Provider::Github {
|
||||
username, token, ..
|
||||
} => {
|
||||
todo!("Return ProviderClient::Github(...)")
|
||||
}
|
||||
Provider::Github { .. } => "github",
|
||||
Provider::Gitea { .. } => "gitea",
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn resolve_token(&self) -> Result<Option<String>> {
|
||||
let provider_type = self.provider_type();
|
||||
let friendly_name = self.friendly_name();
|
||||
|
||||
if let Some(token) = credentials::get_token(provider_type, friendly_name)? {
|
||||
return Ok(Some(token));
|
||||
}
|
||||
|
||||
if crate::ask_yes_no(
|
||||
"Would you like to add a token? (to view private repos)",
|
||||
Some(crate::DefaultAnswer::No),
|
||||
) {
|
||||
credentials::prompt_and_store_token(provider_type, friendly_name)
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn into_provider_client(self) -> Result<ProviderClient> {
|
||||
let token = self.resolve_token().await?;
|
||||
|
||||
match self {
|
||||
Provider::Github { username, .. } => Ok(ProviderClient::Github(
|
||||
crate::providers::github::Github::new(username, token),
|
||||
)),
|
||||
Provider::Gitea {
|
||||
base_url,
|
||||
username,
|
||||
token,
|
||||
..
|
||||
} => ProviderClient::Gitea(crate::providers::gitea::Gitea::new(
|
||||
} => Ok(ProviderClient::Gitea(crate::providers::gitea::Gitea::new(
|
||||
base_url, username, token,
|
||||
)),
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,18 +134,97 @@ impl std::fmt::Display for Provider {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn parse_config(file_path: String) -> Result<Config> {
|
||||
impl From<LegacyProvider> for Provider {
|
||||
fn from(legacy: LegacyProvider) -> Self {
|
||||
match legacy {
|
||||
LegacyProvider::Github {
|
||||
friendly_name,
|
||||
username,
|
||||
..
|
||||
} => Provider::Github {
|
||||
friendly_name,
|
||||
username,
|
||||
},
|
||||
LegacyProvider::Gitea {
|
||||
friendly_name,
|
||||
base_url,
|
||||
username,
|
||||
..
|
||||
} => Provider::Gitea {
|
||||
friendly_name,
|
||||
base_url,
|
||||
username,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_path(file_path: &str) -> PathBuf {
|
||||
let mut path = PathBuf::from(file_path);
|
||||
|
||||
if path.starts_with("~") {
|
||||
if let Some(home_dir) = dirs::home_dir() {
|
||||
// Strip the "~" and join the rest of the path to the home directory
|
||||
path = home_dir.join(path.strip_prefix("~").unwrap());
|
||||
if path.starts_with("~")
|
||||
&& let Some(home_dir) = dirs::home_dir()
|
||||
{
|
||||
path = home_dir.join(path.strip_prefix("~").unwrap());
|
||||
}
|
||||
|
||||
path
|
||||
}
|
||||
|
||||
async fn migrate_legacy_tokens(path: &PathBuf, legacy: LegacyConfig) -> Result<Config> {
|
||||
let mut migrated = false;
|
||||
|
||||
for provider in &legacy.provider {
|
||||
let (provider_type, friendly_name, token) = match provider {
|
||||
LegacyProvider::Github {
|
||||
friendly_name,
|
||||
token,
|
||||
..
|
||||
} => ("github", friendly_name.as_str(), token),
|
||||
LegacyProvider::Gitea {
|
||||
friendly_name,
|
||||
token,
|
||||
..
|
||||
} => ("gitea", friendly_name.as_str(), token),
|
||||
};
|
||||
|
||||
if let Some(token) = token
|
||||
&& !token.is_empty()
|
||||
{
|
||||
credentials::store_token(provider_type, friendly_name, token)?;
|
||||
println!("Migrated token for '{friendly_name}' to system keychain.");
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
|
||||
let config = fs::read_to_string(&path).await?;
|
||||
let config: Config = toml::from_str(&config)?;
|
||||
let config = Config {
|
||||
provider: legacy.provider.into_iter().map(Provider::from).collect(),
|
||||
};
|
||||
|
||||
if migrated {
|
||||
let contents = toml::to_string_pretty(&config)?;
|
||||
fs::write(path, contents).await?;
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub async fn parse_config(file_path: String) -> Result<Config> {
|
||||
let path = expand_path(&file_path);
|
||||
let contents = fs::read_to_string(&path).await?;
|
||||
let legacy: LegacyConfig = toml::from_str(&contents)?;
|
||||
|
||||
let has_plaintext_tokens = legacy.provider.iter().any(|provider| match provider {
|
||||
LegacyProvider::Github { token, .. } | LegacyProvider::Gitea { token, .. } => {
|
||||
token.as_ref().is_some_and(|t| !t.is_empty())
|
||||
}
|
||||
});
|
||||
|
||||
if has_plaintext_tokens {
|
||||
return migrate_legacy_tokens(&path, legacy).await;
|
||||
}
|
||||
|
||||
Ok(Config {
|
||||
provider: legacy.provider.into_iter().map(Provider::from).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
use dialoguer::{theme::ColorfulTheme, Password};
|
||||
use keyring::{Error, Entry};
|
||||
|
||||
const SERVICE: &str = "clonee";
|
||||
|
||||
pub fn account_key(provider_type: &str, friendly_name: &str) -> String {
|
||||
format!("{provider_type}:{friendly_name}")
|
||||
}
|
||||
|
||||
fn entry(provider_type: &str, friendly_name: &str) -> keyring::Result<Entry> {
|
||||
Entry::new(SERVICE, &account_key(provider_type, friendly_name))
|
||||
}
|
||||
|
||||
pub fn store_token(
|
||||
provider_type: &str,
|
||||
friendly_name: &str,
|
||||
token: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
entry(provider_type, friendly_name)?.set_password(token)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_token(
|
||||
provider_type: &str,
|
||||
friendly_name: &str,
|
||||
) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
||||
match entry(provider_type, friendly_name)?.get_password() {
|
||||
Ok(token) if token.trim().is_empty() => Ok(None),
|
||||
Ok(token) => Ok(Some(token)),
|
||||
Err(Error::NoEntry) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_token(
|
||||
provider_type: &str,
|
||||
friendly_name: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match entry(provider_type, friendly_name)?.delete_credential() {
|
||||
Ok(()) => Ok(()),
|
||||
Err(Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prompt_for_token(friendly_name: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let token = Password::with_theme(&ColorfulTheme::default())
|
||||
.with_prompt(format!("Enter API token for {friendly_name}"))
|
||||
.interact()?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub fn prompt_and_store_token(
|
||||
provider_type: &str,
|
||||
friendly_name: &str,
|
||||
) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
||||
let token = prompt_for_token(friendly_name)?;
|
||||
if token.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
store_token(provider_type, friendly_name, &token)?;
|
||||
Ok(Some(token))
|
||||
}
|
||||
+11
-58
@@ -1,21 +1,22 @@
|
||||
use crate::{config::parse_config, providers::Provider, tui::user_select_repo};
|
||||
use colored::*;
|
||||
use clap::Parser;
|
||||
use dialoguer::{Select, theme::ColorfulTheme};
|
||||
use std::io::Write;
|
||||
use std::{io, process};
|
||||
use std::io;
|
||||
|
||||
mod cli;
|
||||
mod config;
|
||||
mod credentials;
|
||||
mod providers;
|
||||
mod tui;
|
||||
|
||||
const CONFIG_PATH: &str = "~/.config/clonee";
|
||||
pub(crate) const CONFIG_PATH: &str = "~/.config/clonee";
|
||||
|
||||
enum DefaultAnswer {
|
||||
pub(crate) enum DefaultAnswer {
|
||||
Yes,
|
||||
No,
|
||||
}
|
||||
|
||||
fn ask_option<T>(message: &str, options: Vec<T>) -> T
|
||||
pub(crate) fn ask_option<T>(message: &str, options: Vec<T>) -> T
|
||||
where
|
||||
T: std::fmt::Display + Clone,
|
||||
{
|
||||
@@ -29,14 +30,13 @@ where
|
||||
options[selection].clone()
|
||||
}
|
||||
|
||||
fn ask_yes_no(message: &str, default: Option<DefaultAnswer>) -> bool {
|
||||
pub(crate) fn ask_yes_no(message: &str, default: Option<DefaultAnswer>) -> bool {
|
||||
let options: Vec<&str> = match default {
|
||||
Some(DefaultAnswer::Yes) => vec!["Yes", "No"],
|
||||
Some(DefaultAnswer::No) => vec!["No", "Yes"],
|
||||
None => vec!["Yes", "No"],
|
||||
};
|
||||
|
||||
// &str implements both Display and Copy!
|
||||
match ask_option(message, options) {
|
||||
"Yes" => true,
|
||||
"No" => false,
|
||||
@@ -44,7 +44,7 @@ fn ask_yes_no(message: &str, default: Option<DefaultAnswer>) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_input(message: &str) -> String {
|
||||
pub(crate) fn get_input(message: &str) -> String {
|
||||
print!("{}", message);
|
||||
io::stdout().flush().unwrap();
|
||||
|
||||
@@ -56,53 +56,6 @@ fn get_input(message: &str) -> String {
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let config_file = format!("{}/config.toml", CONFIG_PATH);
|
||||
let config = match parse_config(config_file.clone()).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let top = "Failed to parse config.".red();
|
||||
let bottom = "Maybe there's an error in the config?".dimmed();
|
||||
|
||||
println!("{}\n{}", top, bottom);
|
||||
eprintln!("Error at {}: {}", config_file, e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let selected_provider = ask_option("Choose a provider:", config.provider);
|
||||
|
||||
let provider: crate::config::ProviderClient = selected_provider.into_provider_trait();
|
||||
|
||||
let repos = provider.repositories().await.expect("Failed to get repos.");
|
||||
|
||||
let selection = match user_select_repo(repos).await {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
println!("{}", "No repository was selected!".red());
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
match ask_yes_no("Clone the repo?", Some(DefaultAnswer::Yes)) {
|
||||
true => {
|
||||
let folder: Option<String> =
|
||||
if ask_yes_no("Use a custom folder?", Some(DefaultAnswer::No)) {
|
||||
let custom = get_input("Folder name: ");
|
||||
Some(custom)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match provider.clone(selection, folder, false).await {
|
||||
Ok(_) => println!("Repo cloned!"),
|
||||
Err(_) => {
|
||||
println!("{}", "Error cloning repo.".red())
|
||||
}
|
||||
}
|
||||
}
|
||||
false => {
|
||||
println!("Exiting...");
|
||||
process::exit(0);
|
||||
}
|
||||
}
|
||||
let cli = cli::Cli::parse();
|
||||
cli::run(cli).await;
|
||||
}
|
||||
|
||||
+19
-16
@@ -3,26 +3,24 @@ use serde::Deserialize;
|
||||
|
||||
use crate::providers::Provider;
|
||||
|
||||
// TODO: add email and password authentication for viewing private repos.
|
||||
pub struct Github {
|
||||
pub username: String,
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
impl Github {
|
||||
fn new(username: String) -> Self {
|
||||
Self { username }
|
||||
pub fn new(username: String, token: Option<String>) -> Self {
|
||||
Self { username, token }
|
||||
}
|
||||
}
|
||||
|
||||
// 1. This struct matches GitHub's exact API shape.
|
||||
// It remains completely hidden inside your GitHub provider module.
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct GithubRepoDto {
|
||||
id: u64,
|
||||
name: String,
|
||||
owner: GithubOwnerDto,
|
||||
ssh_url: Option<String>,
|
||||
clone_url: String, // Or html_url
|
||||
clone_url: String,
|
||||
stargazers_count: u64,
|
||||
description: Option<String>,
|
||||
}
|
||||
@@ -32,7 +30,6 @@ struct GithubOwnerDto {
|
||||
login: String,
|
||||
}
|
||||
|
||||
// 2. Map the GitHub DTO to your general Repository struct
|
||||
impl From<GithubRepoDto> for Repository {
|
||||
fn from(gh: GithubRepoDto) -> Self {
|
||||
Repository {
|
||||
@@ -53,18 +50,25 @@ impl Provider for Github {
|
||||
let mut page = 1;
|
||||
let mut repos: Vec<Repository> = Vec::new();
|
||||
|
||||
loop {
|
||||
let url = format!(
|
||||
"https://api.github.com/users/{}/repos?per_page=100&page={}",
|
||||
&self.username, page
|
||||
);
|
||||
let base_url = match self.token.is_some() {
|
||||
true => "https://api.github.com/user/repos".to_string(),
|
||||
false => format!("https://api.github.com/users/{}/repos", self.username),
|
||||
};
|
||||
|
||||
let response = client
|
||||
loop {
|
||||
let url = format!("{}?per_page=100&page={}", base_url, page);
|
||||
|
||||
let mut request = client
|
||||
.get(&url)
|
||||
.header("User-Agent", "my-app")
|
||||
.send()
|
||||
.await?;
|
||||
.header("Accept", "application/vnd.github+json"); // Best practice for GitHub API
|
||||
|
||||
// Apply token authentication if it's available
|
||||
if let Some(ref token) = self.token {
|
||||
request = request.bearer_auth(token);
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
let status = response.status();
|
||||
|
||||
if !status.is_success() {
|
||||
@@ -79,7 +83,6 @@ impl Provider for Github {
|
||||
break;
|
||||
}
|
||||
|
||||
// Convert the GitHub DTOs seamlessly into your clean domain structs
|
||||
let domain_repos = gh_page.into_iter().map(Repository::from);
|
||||
repos.extend(domain_repos);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user