convert to forgejo-api repo
This commit is contained in:
parent
af89252c15
commit
285fddb2ea
6 changed files with 7 additions and 1997 deletions
175
src/keys.rs
175
src/keys.rs
|
@ -1,175 +0,0 @@
|
|||
use eyre::eyre;
|
||||
use std::{collections::BTreeMap, io::ErrorKind};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use url::Url;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Clone, Default)]
|
||||
pub struct KeyInfo {
|
||||
pub hosts: BTreeMap<String, LoginInfo>,
|
||||
}
|
||||
|
||||
impl KeyInfo {
|
||||
pub async fn load() -> eyre::Result<Self> {
|
||||
let path = directories::ProjectDirs::from("", "Cyborus", "forgejo-cli")
|
||||
.ok_or_else(|| eyre!("Could not find data directory"))?
|
||||
.data_dir()
|
||||
.join("keys.json");
|
||||
let json = tokio::fs::read(path).await;
|
||||
let this = match json {
|
||||
Ok(x) => serde_json::from_slice::<Self>(&x)?,
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => {
|
||||
eprintln!("keys file not found, creating");
|
||||
Self::default()
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
Ok(this)
|
||||
}
|
||||
|
||||
pub async fn save(&self) -> eyre::Result<()> {
|
||||
let json = serde_json::to_vec_pretty(self)?;
|
||||
let dirs = directories::ProjectDirs::from("", "Cyborus", "forgejo-cli")
|
||||
.ok_or_else(|| eyre!("Could not find data directory"))?;
|
||||
let path = dirs.data_dir();
|
||||
|
||||
tokio::fs::create_dir_all(path).await?;
|
||||
|
||||
tokio::fs::File::create(path.join("keys.json"))
|
||||
.await?
|
||||
.write_all(&json)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_current(&self) -> eyre::Result<(HostInfo<'_>, RepoInfo)> {
|
||||
let repo = git2::Repository::open(".")?;
|
||||
let remote_url = get_remote(&repo)?;
|
||||
let login_info = self.get_login(&remote_url)?;
|
||||
|
||||
let mut path = remote_url
|
||||
.path_segments()
|
||||
.ok_or_else(|| eyre!("bad path"))?
|
||||
.collect::<Vec<_>>();
|
||||
let repo_name = path
|
||||
.pop()
|
||||
.ok_or_else(|| eyre!("path does not have repo name"))?
|
||||
.to_string();
|
||||
let owner = path
|
||||
.pop()
|
||||
.ok_or_else(|| eyre!("path does not have owner name"))?
|
||||
.to_string();
|
||||
let base_path = path.join("/");
|
||||
|
||||
let mut url = remote_url;
|
||||
url.set_path(&base_path);
|
||||
let host_info = HostInfo { url, login_info };
|
||||
let repo_info = RepoInfo {
|
||||
owner,
|
||||
name: repo_name,
|
||||
};
|
||||
Ok((host_info, repo_info))
|
||||
}
|
||||
|
||||
pub fn get_login(&self, url: &Url) -> eyre::Result<&LoginInfo> {
|
||||
let host_str = url
|
||||
.host_str()
|
||||
.ok_or_else(|| eyre!("remote url does not have host"))?;
|
||||
let domain = if let Some(port) = url.port() {
|
||||
format!("{}:{}", host_str, port)
|
||||
} else {
|
||||
host_str.to_owned()
|
||||
};
|
||||
|
||||
let login_info = self
|
||||
.hosts
|
||||
.get(&domain)
|
||||
.ok_or_else(|| eyre!("not signed in to {domain}"))?;
|
||||
Ok(login_info)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct HostInfo<'a> {
|
||||
url: Url,
|
||||
login_info: &'a LoginInfo,
|
||||
}
|
||||
|
||||
impl<'a> HostInfo<'a> {
|
||||
pub fn api(&self) -> Result<forgejo_api::Forgejo, forgejo_api::ForgejoError> {
|
||||
self.login_info.api_for(self.url())
|
||||
}
|
||||
|
||||
pub fn url(&self) -> &Url {
|
||||
&self.url
|
||||
}
|
||||
|
||||
pub fn username(&self) -> &'a str {
|
||||
&self.login_info.name
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RepoInfo {
|
||||
owner: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl RepoInfo {
|
||||
pub fn owner(&self) -> &str {
|
||||
&self.owner
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
}
|
||||
|
||||
fn repo_from_url(url: &Url) -> eyre::Result<&str> {
|
||||
let mut iter = url
|
||||
.path_segments()
|
||||
.ok_or_else(|| eyre!("failed to get path from url"))?;
|
||||
soft_assert::soft_assert!(
|
||||
matches!(iter.next(), Some(_)),
|
||||
Err(eyre!("path should have 2 segments, has none"))
|
||||
);
|
||||
let repo = iter
|
||||
.next()
|
||||
.ok_or_else(|| eyre!("path should have 2 segments, has only 1"))?;
|
||||
let repo = repo.strip_suffix(".git").unwrap_or(repo);
|
||||
soft_assert::soft_assert!(
|
||||
matches!(iter.next(), None),
|
||||
Err(eyre!("path should have 2 segments, has more"))
|
||||
);
|
||||
Ok(repo)
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Clone, Default)]
|
||||
pub struct LoginInfo {
|
||||
name: String,
|
||||
key: String,
|
||||
}
|
||||
|
||||
impl LoginInfo {
|
||||
pub fn new(name: String, key: String) -> Self {
|
||||
Self { name, key }
|
||||
}
|
||||
|
||||
pub fn username(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub fn api_for(&self, url: &Url) -> Result<forgejo_api::Forgejo, forgejo_api::ForgejoError> {
|
||||
forgejo_api::Forgejo::new(&self.key, url.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn get_remote(repo: &git2::Repository) -> eyre::Result<Url> {
|
||||
let head = repo.head()?;
|
||||
let branch_name = head.name().ok_or_else(|| eyre!("branch name not UTF-8"))?;
|
||||
let remote_name = repo.branch_upstream_remote(branch_name)?;
|
||||
let remote_name = remote_name
|
||||
.as_str()
|
||||
.ok_or_else(|| eyre!("remote name not UTF-8"))?;
|
||||
let remote = repo.find_remote(remote_name)?;
|
||||
let url = Url::parse(std::str::from_utf8(remote.url_bytes())?)?;
|
||||
Ok(url)
|
||||
}
|
223
src/lib.rs
Normal file
223
src/lib.rs
Normal file
|
@ -0,0 +1,223 @@
|
|||
use reqwest::{Client, Request, StatusCode};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use soft_assert::*;
|
||||
use url::Url;
|
||||
|
||||
pub struct Forgejo {
|
||||
url: Url,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ForgejoError {
|
||||
#[error("url must have a host")]
|
||||
HostRequired,
|
||||
#[error("scheme must be http or https")]
|
||||
HttpRequired,
|
||||
#[error("{0}")] // for some reason, you can't use `source` and `transparent` together
|
||||
ReqwestError(#[source] reqwest::Error),
|
||||
#[error("API key should be ascii")]
|
||||
KeyNotAscii,
|
||||
#[error("the response from forgejo was not properly structured")]
|
||||
BadStructure,
|
||||
#[error("unexpected status code {} {}", .0.as_u16(), .0.canonical_reason().unwrap_or(""))]
|
||||
UnexpectedStatusCode(StatusCode),
|
||||
#[error("{} {}: {}", .0.as_u16(), .0.canonical_reason().unwrap_or(""), .1)]
|
||||
ApiError(StatusCode, String),
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for ForgejoError {
|
||||
fn from(e: reqwest::Error) -> Self {
|
||||
if e.is_decode() {
|
||||
ForgejoError::BadStructure
|
||||
} else {
|
||||
ForgejoError::ReqwestError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Forgejo {
|
||||
pub fn new(api_key: &str, url: Url) -> Result<Self, ForgejoError> {
|
||||
Self::with_user_agent(api_key, url, "forgejo-api-rs")
|
||||
}
|
||||
|
||||
pub fn with_user_agent(
|
||||
api_key: &str,
|
||||
url: Url,
|
||||
user_agent: &str,
|
||||
) -> Result<Self, ForgejoError> {
|
||||
soft_assert!(
|
||||
matches!(url.scheme(), "http" | "https"),
|
||||
Err(ForgejoError::HttpRequired)
|
||||
);
|
||||
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
let mut key_header: reqwest::header::HeaderValue = format!("token {api_key}")
|
||||
.try_into()
|
||||
.map_err(|_| ForgejoError::KeyNotAscii)?;
|
||||
// key_header.set_sensitive(true);
|
||||
headers.insert("Authorization", key_header);
|
||||
let client = Client::builder()
|
||||
.user_agent(user_agent)
|
||||
.default_headers(headers)
|
||||
.build()?;
|
||||
dbg!(&client);
|
||||
Ok(Self { url, client })
|
||||
}
|
||||
|
||||
pub async fn get_repo(&self, user: &str, repo: &str) -> Result<Option<Repo>, ForgejoError> {
|
||||
self.get_opt(&format!("repos/{user}/{repo}/")).await
|
||||
}
|
||||
|
||||
pub async fn create_repo(&self, repo: CreateRepoOption) -> Result<Repo, ForgejoError> {
|
||||
self.post("user/repos", &repo).await
|
||||
}
|
||||
|
||||
/// Returns user info about the authorized user.
|
||||
pub async fn myself(&self) -> Result<User, ForgejoError> {
|
||||
self.get("user").await
|
||||
}
|
||||
|
||||
pub async fn get_user(&self, user: &str) -> Result<Option<User>, ForgejoError> {
|
||||
self.get_opt(&format!("users/{user}/")).await
|
||||
}
|
||||
|
||||
pub async fn get_followers(&self, user: &str) -> Result<Option<Vec<User>>, ForgejoError> {
|
||||
self.get_opt(&format!("users/{user}/followers/")).await
|
||||
}
|
||||
|
||||
pub async fn get_following(&self, user: &str) -> Result<Option<Vec<User>>, ForgejoError> {
|
||||
self.get_opt(&format!("users/{user}/following/")).await
|
||||
}
|
||||
|
||||
async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ForgejoError> {
|
||||
let url = self.url.join("api/v1/").unwrap().join(path).unwrap();
|
||||
let request = self.client.get(url).build()?;
|
||||
self.execute(request).await
|
||||
}
|
||||
|
||||
async fn get_opt<T: DeserializeOwned>(&self, path: &str) -> Result<Option<T>, ForgejoError> {
|
||||
let url = self.url.join("api/v1/").unwrap().join(path).unwrap();
|
||||
let request = self.client.get(url).build()?;
|
||||
self.execute_opt(request).await
|
||||
}
|
||||
|
||||
async fn post<T: Serialize, U: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &T,
|
||||
) -> Result<U, ForgejoError> {
|
||||
let url = self.url.join("api/v1/").unwrap().join(path).unwrap();
|
||||
let request = self.client.post(url).json(body).build()?;
|
||||
self.execute(request).await
|
||||
}
|
||||
|
||||
async fn execute<T: DeserializeOwned>(&self, request: Request) -> Result<T, ForgejoError> {
|
||||
let response = self.client.execute(dbg!(request)).await?;
|
||||
match response.status() {
|
||||
status if status.is_success() => Ok(response.json::<T>().await?),
|
||||
status if status.is_client_error() => Err(ForgejoError::ApiError(
|
||||
status,
|
||||
response.json::<ErrorMessage>().await?.message,
|
||||
)),
|
||||
status => Err(ForgejoError::UnexpectedStatusCode(status)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Like `execute`, but returns `Ok(None)` on 404.
|
||||
async fn execute_opt<T: DeserializeOwned>(
|
||||
&self,
|
||||
request: Request,
|
||||
) -> Result<Option<T>, ForgejoError> {
|
||||
let response = self.client.execute(dbg!(request)).await?;
|
||||
match response.status() {
|
||||
status if status.is_success() => Ok(Some(response.json::<T>().await?)),
|
||||
StatusCode::NOT_FOUND => Ok(None),
|
||||
status if status.is_client_error() => Err(ForgejoError::ApiError(
|
||||
status,
|
||||
response.json::<ErrorMessage>().await?.message,
|
||||
)),
|
||||
status => Err(ForgejoError::UnexpectedStatusCode(status)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ErrorMessage {
|
||||
message: String,
|
||||
// intentionally ignored, no need for now
|
||||
// url: Url
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Debug, PartialEq)]
|
||||
pub struct Repo {
|
||||
pub clone_url: Url,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: time::OffsetDateTime,
|
||||
pub default_branch: String,
|
||||
pub description: String,
|
||||
pub fork: bool,
|
||||
pub forks_count: u64,
|
||||
pub full_name: String,
|
||||
|
||||
pub owner: User,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Debug, PartialEq)]
|
||||
pub struct User {
|
||||
pub active: bool,
|
||||
pub avatar_url: Url,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created: time::OffsetDateTime,
|
||||
pub description: String,
|
||||
pub email: String,
|
||||
pub followers_count: u64,
|
||||
pub following_count: u64,
|
||||
pub full_name: String,
|
||||
pub id: u64,
|
||||
pub is_admin: bool,
|
||||
pub language: String,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub last_login: time::OffsetDateTime,
|
||||
pub location: String,
|
||||
pub login: String,
|
||||
pub login_name: String,
|
||||
pub prohibit_login: bool,
|
||||
pub restricted: bool,
|
||||
pub starred_repos_count: u64,
|
||||
pub website: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Debug, PartialEq)]
|
||||
pub enum UserVisibility {
|
||||
#[serde(rename = "public")]
|
||||
Public,
|
||||
#[serde(rename = "limited")]
|
||||
Limited,
|
||||
#[serde(rename = "private")]
|
||||
Private,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Debug, PartialEq)]
|
||||
pub struct CreateRepoOption {
|
||||
pub auto_init: bool,
|
||||
pub default_branch: String,
|
||||
pub description: Option<String>,
|
||||
pub gitignores: String,
|
||||
pub issue_labels: String,
|
||||
pub license: String,
|
||||
pub name: String,
|
||||
pub private: bool,
|
||||
pub readme: String,
|
||||
pub template: bool,
|
||||
pub trust_model: TrustModel,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Debug, PartialEq)]
|
||||
pub enum TrustModel {
|
||||
Default,
|
||||
Collaborator,
|
||||
Committer,
|
||||
#[serde(rename = "collaboratorcommiter")]
|
||||
CollaboratorCommitter,
|
||||
}
|
205
src/main.rs
205
src/main.rs
|
@ -1,205 +0,0 @@
|
|||
use std::{collections::BTreeMap, io::ErrorKind};
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use eyre::{bail, eyre};
|
||||
use forgejo_api::{CreateRepoOption, Forgejo};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use url::Url;
|
||||
|
||||
mod keys;
|
||||
use keys::*;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
pub struct App {
|
||||
#[clap(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Clone, Debug)]
|
||||
pub enum Command {
|
||||
#[clap(subcommand)]
|
||||
Repo(RepoCommand),
|
||||
User {
|
||||
#[clap(long, short)]
|
||||
host: Option<String>,
|
||||
},
|
||||
#[clap(subcommand)]
|
||||
Auth(AuthCommand),
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Clone, Debug)]
|
||||
pub enum RepoCommand {
|
||||
Create {
|
||||
host: String,
|
||||
repo: String,
|
||||
|
||||
// flags
|
||||
#[clap(long, short)]
|
||||
description: Option<String>,
|
||||
#[clap(long, short)]
|
||||
private: bool,
|
||||
/// Sets the new repo to be the `origin` remote of the current local repo.
|
||||
#[clap(long, short)]
|
||||
set_upstream: Option<String>,
|
||||
/// Pushes the current branch to the default branch on the new repo.
|
||||
/// Implies `--set-upstream=origin` (setting upstream manual overrides this)
|
||||
#[clap(long, short)]
|
||||
push: bool,
|
||||
},
|
||||
Info,
|
||||
Browse,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Clone, Debug)]
|
||||
pub enum AuthCommand {
|
||||
Login,
|
||||
Logout {
|
||||
host: String,
|
||||
},
|
||||
AddKey {
|
||||
/// The domain name of the forgejo instance.
|
||||
host: String,
|
||||
/// The user that the key is associated with
|
||||
user: String,
|
||||
/// The key to add. If not present, the key will be read in from stdin.
|
||||
key: Option<String>,
|
||||
},
|
||||
List,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> eyre::Result<()> {
|
||||
let args = App::parse();
|
||||
let mut keys = KeyInfo::load().await?;
|
||||
|
||||
match args.command {
|
||||
Command::Repo(repo_subcommand) => match repo_subcommand {
|
||||
RepoCommand::Create {
|
||||
host,
|
||||
repo,
|
||||
|
||||
description,
|
||||
private,
|
||||
set_upstream,
|
||||
push,
|
||||
} => {
|
||||
let host = Url::parse(&host)?;
|
||||
let login = keys.get_login(&host)?;
|
||||
let api = login.api_for(&host)?;
|
||||
let repo_spec = CreateRepoOption {
|
||||
auto_init: false,
|
||||
default_branch: "main".into(),
|
||||
description,
|
||||
gitignores: String::new(),
|
||||
issue_labels: String::new(),
|
||||
license: String::new(),
|
||||
name: repo.clone(),
|
||||
private,
|
||||
readme: String::new(),
|
||||
template: false,
|
||||
trust_model: forgejo_api::TrustModel::Default,
|
||||
};
|
||||
let new_repo = api.create_repo(repo_spec).await?;
|
||||
eprintln!(
|
||||
"created new repo at {}",
|
||||
host.join(&format!("{}/{}", login.username(), repo))?
|
||||
);
|
||||
|
||||
let upstream = set_upstream.as_deref().unwrap_or("origin");
|
||||
|
||||
let repo = git2::Repository::open(".")?;
|
||||
let mut remote = if set_upstream.is_some() || push {
|
||||
repo.remote(upstream, new_repo.clone_url.as_str())?
|
||||
} else {
|
||||
repo.find_remote(upstream)?
|
||||
};
|
||||
|
||||
if push {
|
||||
remote.push::<&str>(&[], None)?;
|
||||
}
|
||||
}
|
||||
RepoCommand::Info => {
|
||||
let (host, repo) = keys.get_current()?;
|
||||
let api = host.api()?;
|
||||
let repo = api.get_repo(repo.owner(), repo.name()).await?;
|
||||
match repo {
|
||||
Some(repo) => {
|
||||
dbg!(repo);
|
||||
}
|
||||
None => eprintln!("repo not found"),
|
||||
}
|
||||
}
|
||||
RepoCommand::Browse => {
|
||||
let (host, repo) = keys.get_current()?;
|
||||
let mut url = host.url().clone();
|
||||
let new_path = format!(
|
||||
"{}/{}/{}",
|
||||
url.path().strip_suffix("/").unwrap_or(url.path()),
|
||||
repo.owner(),
|
||||
repo.name(),
|
||||
);
|
||||
url.set_path(&new_path);
|
||||
open::that(url.as_str())?;
|
||||
}
|
||||
},
|
||||
Command::User { host } => {
|
||||
let host = host.map(|host| Url::parse(&host)).transpose()?;
|
||||
let (url, name) = match host {
|
||||
Some(url) => (keys.get_login(&url)?.username(), url),
|
||||
None => {
|
||||
let (host, _) = keys.get_current()?;
|
||||
(host.username(), host.url().clone())
|
||||
}
|
||||
};
|
||||
eprintln!("currently signed in to {name}@{url}");
|
||||
}
|
||||
Command::Auth(auth_subcommand) => match auth_subcommand {
|
||||
AuthCommand::Login => {
|
||||
todo!();
|
||||
// let user = readline("username: ").await?;
|
||||
// let pass = readline("password: ").await?;
|
||||
}
|
||||
AuthCommand::Logout { host } => {
|
||||
let info_opt = keys.hosts.remove(&host);
|
||||
if let Some(info) = info_opt {
|
||||
eprintln!("signed out of {}@{}", &info.username(), host);
|
||||
} else {
|
||||
eprintln!("already not signed in to {host}");
|
||||
}
|
||||
}
|
||||
AuthCommand::AddKey { host, user, key } => {
|
||||
let key = match key {
|
||||
Some(key) => key,
|
||||
None => readline("new key: ").await?,
|
||||
};
|
||||
if keys.hosts.get(&user).is_none() {
|
||||
keys.hosts.insert(host, LoginInfo::new(user, key));
|
||||
} else {
|
||||
println!("key for {} already exists", host);
|
||||
}
|
||||
}
|
||||
AuthCommand::List => {
|
||||
if keys.hosts.is_empty() {
|
||||
println!("No logins.");
|
||||
}
|
||||
for (host_url, login_info) in &keys.hosts {
|
||||
println!("{}@{}", login_info.username(), host_url);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
keys.save().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn readline(msg: &str) -> eyre::Result<String> {
|
||||
print!("{msg}");
|
||||
tokio::io::stdout().flush().await?;
|
||||
tokio::task::spawn_blocking(|| {
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
Ok(input)
|
||||
})
|
||||
.await?
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue