use clap::{Args, Subcommand};
use eyre::{Context, ContextCompat, OptionExt};
use forgejo_api::Forgejo;
use crate::{repo::RepoInfo, SpecialRender};
#[derive(Args, Clone, Debug)]
pub struct UserCommand {
#[clap(long, short = 'R')]
remote: Option<String>,
#[clap(subcommand)]
command: UserSubcommand,
}
#[derive(Subcommand, Clone, Debug)]
pub enum UserSubcommand {
Search {
query: String,
#[clap(long, short)]
page: Option<usize>,
},
View {
user: Option<String>,
},
Browse {
user: Option<String>,
},
Follow {
user: String,
},
Unfollow {
user: String,
},
Following {
user: Option<String>,
},
Followers {
user: Option<String>,
},
Block {
user: String,
},
Unblock {
user: String,
},
Repos {
user: Option<String>,
#[clap(long)]
starred: bool,
#[clap(long)]
sort: Option<RepoSortOrder>,
#[clap(long, default_value_t = 1)]
page: u32,
},
Orgs {
user: Option<String>,
},
Activity {
user: Option<String>,
},
#[clap(subcommand)]
Edit(EditCommand),
#[clap(subcommand)]
Key(KeyCommand),
#[clap(subcommand)]
Gpg(GpgCommand),
}
#[derive(Subcommand, Clone, Debug)]
pub enum EditCommand {
Bio {
content: Option<String>,
},
Name {
#[clap(group = "arg")]
name: Option<String>,
#[clap(long, short, group = "arg")]
unset: bool,
},
Pronouns {
#[clap(group = "arg")]
pronouns: Option<String>,
#[clap(long, short, group = "arg")]
unset: bool,
},
Location {
#[clap(group = "arg")]
location: Option<String>,
#[clap(long, short, group = "arg")]
unset: bool,
},
Activity {
#[clap(long, short)]
visibility: VisbilitySetting,
},
Email {
#[clap(long, short)]
visibility: Option<VisbilitySetting>,
#[clap(long, short)]
add: Vec<String>,
#[clap(long, short)]
rm: Vec<String>,
},
Website {
#[clap(group = "arg")]
url: Option<String>,
#[clap(long, short, group = "arg")]
unset: bool,
},
}
#[derive(Subcommand, Clone, Debug)]
pub enum KeyCommand {
List {
#[clap(short, long)]
verbose: bool,
},
View {
id: i64,
},
Delete {
id: i64,
},
Upload {
keyfile: Option<String>,
#[clap(short, long)]
title: Option<String>,
#[clap(short, long)]
force: bool,
#[clap(short, long)]
read_only: bool,
},
}
#[derive(Subcommand, Clone, Debug)]
pub enum GpgCommand {
List {
#[clap(short, long)]
verbose: bool,
},
View {
id: i64,
},
Delete {
id: i64,
#[clap(short, long)]
force: bool,
},
Upload {
key: String,
#[clap(short, long)]
no_verify: bool,
},
Verify {
id: i64,
},
}
#[derive(clap::ValueEnum, Clone, Debug, PartialEq, Eq)]
pub enum VisbilitySetting {
Hidden,
Public,
}
impl UserCommand {
pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> {
let repo = RepoInfo::get_current(host_name, None, self.remote.as_deref(), &keys)?;
let api = keys.get_api(repo.host_url()).await?;
match self.command {
UserSubcommand::Search { query, page } => user_search(&api, &query, page).await?,
UserSubcommand::View { user } => view_user(&api, user.as_deref()).await?,
UserSubcommand::Browse { user } => {
browse_user(&api, repo.host_url(), user.as_deref()).await?
}
UserSubcommand::Follow { user } => follow_user(&api, &user).await?,
UserSubcommand::Unfollow { user } => unfollow_user(&api, &user).await?,
UserSubcommand::Following { user } => list_following(&api, user.as_deref()).await?,
UserSubcommand::Followers { user } => list_followers(&api, user.as_deref()).await?,
UserSubcommand::Block { user } => block_user(&api, &user).await?,
UserSubcommand::Unblock { user } => unblock_user(&api, &user).await?,
UserSubcommand::Repos {
user,
starred,
sort,
page,
} => list_repos(&api, user.as_deref(), starred, sort, page).await?,
UserSubcommand::Orgs { user } => list_orgs(&api, user.as_deref()).await?,
UserSubcommand::Activity { user } => list_activity(&api, user.as_deref()).await?,
UserSubcommand::Edit(cmd) => match cmd {
EditCommand::Bio { content } => edit_bio(&api, content).await?,
EditCommand::Name { name, unset } => edit_name(&api, name, unset).await?,
EditCommand::Pronouns { pronouns, unset } => {
edit_pronouns(&api, pronouns, unset).await?
}
EditCommand::Location { location, unset } => {
edit_location(&api, location, unset).await?
}
EditCommand::Activity { visibility } => edit_activity(&api, visibility).await?,
EditCommand::Email {
visibility,
add,
rm,
} => edit_email(&api, visibility, add, rm).await?,
EditCommand::Website { url, unset } => edit_website(&api, url, unset).await?,
},
UserSubcommand::Key(cmd) => match cmd {
KeyCommand::List { verbose } => list_keys(&api, verbose).await?,
KeyCommand::View { id } => view_key(&api, id).await?,
KeyCommand::Delete { id } => delete_key(&api, id).await?,
KeyCommand::Upload {
keyfile,
title,
force,
read_only,
} => upload_key(&api, keyfile, title, force, read_only).await?,
},
UserSubcommand::Gpg(cmd) => match cmd {
GpgCommand::List { verbose } => list_gpg(&api, verbose).await?,
GpgCommand::View { id } => view_gpg(&api, id).await?,
GpgCommand::Delete { id, force } => delete_gpg(&api, id, force).await?,
GpgCommand::Upload { key, no_verify } => upload_gpg(&api, key, no_verify).await?,
GpgCommand::Verify { id } => verify_gpg(&api, id).await?,
},
}
Ok(())
}
}
async fn user_search(api: &Forgejo, query: &str, page: Option<usize>) -> eyre::Result<()> {
let page = page.unwrap_or(1);
if page == 0 {
println!("There is no page 0");
}
let query = forgejo_api::structs::UserSearchQuery {
q: Some(query.to_owned()),
..Default::default()
};
let result = api.user_search(query).await?;
let users = result.data.ok_or_eyre("search did not return data")?;
let ok = result.ok.ok_or_eyre("search did not return ok")?;
if !ok {
println!("Search failed");
return Ok(());
}
if users.is_empty() {
println!("No users matched that query");
} else {
let SpecialRender {
bullet,
dash,
bold,
reset,
..
} = *crate::special_render();
let page_start = (page - 1) * 20;
let pages_total = users.len().div_ceil(20);
if page_start >= users.len() {
if pages_total == 1 {
println!("There is only 1 page");
} else {
println!("There are only {pages_total} pages");
}
} else {
for user in users.iter().skip(page_start).take(20) {
let username = user
.login
.as_deref()
.ok_or_eyre("user does not have name")?;
println!("{bullet} {bold}{username}{reset}");
}
println!(
"Showing {bold}{}{dash}{}{reset} of {bold}{}{reset} results ({page}/{pages_total})",
page_start + 1,
(page_start + 20).min(users.len()),
users.len()
);
if users.len() > 20 {
println!("View more with the --page flag");
}
}
}
Ok(())
}
async fn view_user(api: &Forgejo, user: Option<&str>) -> eyre::Result<()> {
let SpecialRender {
bold,
dash,
bright_cyan,
light_grey,
reset,
..
} = *crate::special_render();
let user_data = match user {
Some(user) => api.user_get(user).await?,
None => api.user_get_current().await?,
};
let username = user_data
.login
.as_deref()
.ok_or_eyre("user has no username")?;
print!("{bright_cyan}{bold}{username}{reset}");
if let Some(pronouns) = user_data.pronouns.as_deref() {
if !pronouns.is_empty() {
print!("{light_grey} {dash} {bold}{pronouns}{reset}");
}
}
println!();
let followers = user_data.followers_count.unwrap_or_default();
let following = user_data.following_count.unwrap_or_default();
println!("{bold}{followers}{reset} followers {dash} {bold}{following}{reset} following");
let mut first = true;
if let Some(website) = user_data.website.as_deref() {
if !website.is_empty() {
print!("{bold}{website}{reset}");
first = false;
}
}
if let Some(email) = user_data.email.as_deref() {
if !email.is_empty() && !email.contains("noreply") {
if !first {
print!(" {dash} ");
}
print!("{bold}{email}{reset}");
}
}
if !first {
println!();
}
if let Some(desc) = user_data.description.as_deref() {
if !desc.is_empty() {
println!();
println!("{}", crate::markdown(desc));
println!();
}
}
let joined = user_data
.created
.ok_or_eyre("user does not have join date")?;
let date_format = time::macros::format_description!("[month repr:short] [day], [year]");
println!("Joined on {bold}{}{reset}", joined.format(&date_format)?);
Ok(())
}
async fn browse_user(api: &Forgejo, host_url: &url::Url, user: Option<&str>) -> eyre::Result<()> {
let username = match user {
Some(user) => user.to_owned(),
None => {
let myself = api.user_get_current().await?;
myself
.login
.ok_or_eyre("authenticated user does not have login")?
}
};
let mut url = host_url.clone();
url.path_segments_mut()
.map_err(|_| eyre::eyre!("invalid host url"))?
.push(&username);
open::that_detached(url.as_str()).wrap_err("Failed to open URL")?;
Ok(())
}
async fn follow_user(api: &Forgejo, user: &str) -> eyre::Result<()> {
api.user_current_put_follow(user).await?;
println!("Followed {user}");
Ok(())
}
async fn unfollow_user(api: &Forgejo, user: &str) -> eyre::Result<()> {
api.user_current_delete_follow(user).await?;
println!("Unfollowed {user}");
Ok(())
}
async fn list_following(api: &Forgejo, user: Option<&str>) -> eyre::Result<()> {
let following = match user {
Some(user) => api.user_list_following(user).all().await?,
None => api.user_current_list_following().all().await?,
};
if following.is_empty() {
match user {
Some(name) => println!("{name} isn't following anyone"),
None => println!("You aren't following anyone"),
}
} else {
match user {
Some(name) => println!("{name} is following:"),
None => println!("You are following:"),
}
let SpecialRender { bullet, .. } = *crate::special_render();
for followed in following {
let username = followed
.login
.as_deref()
.ok_or_eyre("user does not have username")?;
println!("{bullet} {username}");
}
}
Ok(())
}
async fn list_followers(api: &Forgejo, user: Option<&str>) -> eyre::Result<()> {
let (_, followers) = match user {
Some(user) => api.user_list_followers(user).await?,
None => api.user_current_list_followers().await?,
};
if followers.is_empty() {
match user {
Some(name) => println!("{name} has no followers"),
None => println!("You have no followers :("),
}
} else {
match user {
Some(name) => println!("{name} is followed by:"),
None => println!("You are followed by:"),
}
let SpecialRender { bullet, .. } = *crate::special_render();
for follower in followers {
let username = follower
.login
.as_deref()
.ok_or_eyre("user does not have username")?;
println!("{bullet} {username}");
}
}
Ok(())
}
async fn block_user(api: &Forgejo, user: &str) -> eyre::Result<()> {
api.user_block_user(user).await?;
println!("Blocked {user}");
Ok(())
}
async fn unblock_user(api: &Forgejo, user: &str) -> eyre::Result<()> {
api.user_unblock_user(user).await?;
println!("Unblocked {user}");
Ok(())
}
#[derive(clap::ValueEnum, Clone, Debug, Default)]
pub enum RepoSortOrder {
#[default]
Name,
Modified,
Created,
Stars,
Forks,
}
async fn list_repos(
api: &Forgejo,
user: Option<&str>,
starred: bool,
sort: Option<RepoSortOrder>,
page: u32,
) -> eyre::Result<()> {
let (headers, mut repos) = if starred {
match user {
Some(user) => api.user_list_starred(user).page(page).page_size(50).await?,
None => {
api.user_current_list_starred()
.page(page)
.page_size(50)
.await?
}
}
} else {
match user {
Some(user) => api.user_list_repos(user).page(page).page_size(50).await?,
None => {
let query = forgejo_api::structs::UserCurrentListReposQuery {
..Default::default()
};
api.user_current_list_repos(query)
.page(page)
.page_size(50)
.await?
}
}
};
if repos.is_empty() {
if starred {
match user {
Some(user) => println!("{user} has not starred any repos"),
None => println!("You have not starred any repos"),
}
} else {
match user {
Some(user) => println!("{user} does not own any repos"),
None => println!("You do not own any repos"),
}
};
} else {
let sort_fn: fn(
&forgejo_api::structs::Repository,
&forgejo_api::structs::Repository,
) -> std::cmp::Ordering = match sort.unwrap_or_default() {
RepoSortOrder::Name => |a, b| a.full_name.cmp(&b.full_name),
RepoSortOrder::Modified => |a, b| b.updated_at.cmp(&a.updated_at),
RepoSortOrder::Created => |a, b| b.created_at.cmp(&a.created_at),
RepoSortOrder::Stars => |a, b| b.stars_count.cmp(&a.stars_count),
RepoSortOrder::Forks => |a, b| b.forks_count.cmp(&a.forks_count),
};
repos.sort_unstable_by(sort_fn);
let SpecialRender {
bullet,
bold,
dash,
reset,
..
} = *crate::special_render();
for repo in &repos {
let name = repo
.full_name
.as_deref()
.ok_or_eyre("repo does not have name")?;
println!("{bullet} {name}");
}
let page_start = (page - 1) * 50;
let total_items = match headers.x_total_count {
Some(t) => t as usize,
None => repos.len(),
};
let pages_total = total_items.div_ceil(50);
if repos.len() == 1 {
println!("1 repo");
} else {
println!(
"Showing {bold}{}{dash}{}{reset} of {bold}{}{reset} results ({page}/{pages_total})",
page_start + 1,
page_start + repos.len() as u32,
total_items,
);
}
}
Ok(())
}
async fn list_orgs(api: &Forgejo, user: Option<&str>) -> eyre::Result<()> {
let mut orgs = match user {
Some(user) => api.org_list_user_orgs(user).await?,
None => api.org_list_current_user_orgs().await?,
};
if orgs.is_empty() {
match user {
Some(user) => println!("{user} is not a member of any organizations"),
None => println!("You are not a member of any organizations"),
}
} else {
orgs.sort_unstable_by(|a, b| a.name.cmp(&b.name));
let SpecialRender { bullet, dash, .. } = *crate::special_render();
for org in &orgs {
let name = org.name.as_deref().ok_or_eyre("org does not have name")?;
let full_name = org
.full_name
.as_deref()
.ok_or_eyre("org does not have name")?;
if !full_name.is_empty() {
println!("{bullet} {name} {dash} \"{full_name}\"");
} else {
println!("{bullet} {name}");
}
}
if orgs.len() == 1 {
println!("1 organization");
} else {
println!("{} organizations", orgs.len());
}
}
Ok(())
}
async fn list_activity(api: &Forgejo, user: Option<&str>) -> eyre::Result<()> {
let user = match user {
Some(s) => s.to_owned(),
None => {
let myself = api.user_get_current().await?;
myself.login.ok_or_eyre("current user does not have name")?
}
};
let query = forgejo_api::structs::UserListActivityFeedsQuery {
only_performed_by: Some(true),
..Default::default()
};
let (_, feed) = api.user_list_activity_feeds(&user, query).await?;
for activity in feed {
print_activity(&activity)?;
}
Ok(())
}
pub fn print_activity(activity: &forgejo_api::structs::Activity) -> eyre::Result<()> {
let SpecialRender {
bold,
yellow,
bright_cyan,
reset,
..
} = *crate::special_render();
let actor = activity
.act_user
.as_ref()
.ok_or_eyre("activity does not have actor")?;
let actor_name = actor
.login
.as_deref()
.ok_or_eyre("actor does not have name")?;
let op_type = activity
.op_type
.as_ref()
.ok_or_eyre("activity does not have op type")?;
let repo = activity
.repo
.as_ref()
.ok_or_eyre("activity does not have repo");
let content = activity
.content
.as_deref()
.ok_or_eyre("activity does not have content");
let ref_name = activity
.ref_name
.as_deref()
.ok_or_eyre("repo does not have full name");
fn issue_name<'a, 'b>(
repo: &'a forgejo_api::structs::Repository,
content: &'b str,
) -> eyre::Result<(&'a str, &'b str)> {
let full_name = repo
.full_name
.as_deref()
.ok_or_eyre("repo does not have full name")?;
let (issue_id, _issue_name) = content.split_once("|").unwrap_or((content, ""));
Ok((full_name, issue_id))
}
print!("");
use forgejo_api::structs::ActivityOpType;
match op_type {
ActivityOpType::CreateRepo => {
let repo = repo?;
let full_name = repo
.full_name
.as_deref()
.ok_or_eyre("repo does not have full name")?;
if let Some(parent) = &repo.parent {
let parent_full_name = parent
.full_name
.as_deref()
.ok_or_eyre("parent repo does not have full name")?;
println!("{bold}{actor_name}{reset} forked repository {bold}{yellow}{parent_full_name}{reset} to {bold}{yellow}{full_name}{reset}");
} else if repo.mirror.is_some_and(|b| b) {
println!(
"{bold}{actor_name}{reset} created mirror {bold}{yellow}{full_name}{reset}"
);
} else {
println!(
"{bold}{actor_name}{reset} created repository {bold}{yellow}{full_name}{reset}"
);
}
}
ActivityOpType::RenameRepo => {
let repo = repo?;
let content = content?;
let full_name = repo
.full_name
.as_deref()
.ok_or_eyre("repo does not have full name")?;
println!("{bold}{actor_name}{reset} renamed repository from {bold}{yellow}\"{content}\"{reset} to {bold}{yellow}{full_name}{reset}");
}
ActivityOpType::StarRepo => {
let repo = repo?;
let full_name = repo
.full_name
.as_deref()
.ok_or_eyre("repo does not have full name")?;
println!(
"{bold}{actor_name}{reset} starred repository {bold}{yellow}{full_name}{reset}"
);
}
ActivityOpType::WatchRepo => {
let repo = repo?;
let full_name = repo
.full_name
.as_deref()
.ok_or_eyre("repo does not have full name")?;
println!(
"{bold}{actor_name}{reset} watched repository {bold}{yellow}{full_name}{reset}"
);
}
ActivityOpType::CommitRepo => {
let repo = repo?;
let full_name = repo
.full_name
.as_deref()
.ok_or_eyre("repo does not have full name")?;
let ref_name = ref_name?;
let branch = ref_name.strip_prefix("refs/heads/").unwrap_or(ref_name);
if !content?.is_empty() {
println!("{bold}{actor_name}{reset} pushed to {bold}{bright_cyan}{branch}{reset} on {bold}{yellow}{full_name}{reset}");
}
}
ActivityOpType::CreateIssue => {
let (name, id) = issue_name(repo?, content?)?;
println!("{bold}{actor_name}{reset} opened issue {bold}{yellow}{name}#{id}{reset}");
}
ActivityOpType::CreatePullRequest => {
let (name, id) = issue_name(repo?, content?)?;
println!(
"{bold}{actor_name}{reset} created pull request {bold}{yellow}{name}#{id}{reset}"
);
}
ActivityOpType::TransferRepo => {
let repo = repo?;
let full_name = repo
.full_name
.as_deref()
.ok_or_eyre("repo does not have full name")?;
let content = content?;
println!("{bold}{actor_name}{reset} transfered repository {bold}{yellow}{content}{reset} to {bold}{yellow}{full_name}{reset}");
}
ActivityOpType::PushTag => {
let repo = repo?;
let full_name = repo
.full_name
.as_deref()
.ok_or_eyre("repo does not have full name")?;
let ref_name = ref_name?;
let tag = ref_name.strip_prefix("refs/heads/").unwrap_or(ref_name);
println!("{bold}{actor_name}{reset} pushed tag {bold}{bright_cyan}{tag}{reset} to {bold}{yellow}{full_name}{reset}");
}
ActivityOpType::CommentIssue => {
let (name, id) = issue_name(repo?, content?)?;
println!(
"{bold}{actor_name}{reset} commented on issue {bold}{yellow}{name}#{id}{reset}"
);
}
ActivityOpType::MergePullRequest | ActivityOpType::AutoMergePullRequest => {
let (name, id) = issue_name(repo?, content?)?;
println!(
"{bold}{actor_name}{reset} merged pull request {bold}{yellow}{name}#{id}{reset}"
);
}
ActivityOpType::CloseIssue => {
let (name, id) = issue_name(repo?, content?)?;
println!("{bold}{actor_name}{reset} closed issue {bold}{yellow}{name}#{id}{reset}");
}
ActivityOpType::ReopenIssue => {
let (name, id) = issue_name(repo?, content?)?;
println!("{bold}{actor_name}{reset} reopened issue {bold}{yellow}{name}#{id}{reset}");
}
ActivityOpType::ClosePullRequest => {
let (name, id) = issue_name(repo?, content?)?;
println!(
"{bold}{actor_name}{reset} closed pull request {bold}{yellow}{name}#{id}{reset}"
);
}
ActivityOpType::ReopenPullRequest => {
let (name, id) = issue_name(repo?, content?)?;
println!(
"{bold}{actor_name}{reset} reopened pull request {bold}{yellow}{name}#{id}{reset}"
);
}
ActivityOpType::DeleteTag => {
let repo = repo?;
let full_name = repo
.full_name
.as_deref()
.ok_or_eyre("repo does not have full name")?;
let ref_name = ref_name?;
let tag = ref_name.strip_prefix("refs/heads/").unwrap_or(ref_name);
println!("{bold}{actor_name}{reset} deleted tag {bold}{bright_cyan}{tag}{reset} from {bold}{yellow}{full_name}{reset}");
}
ActivityOpType::DeleteBranch => {
let repo = repo?;
let full_name = repo
.full_name
.as_deref()
.ok_or_eyre("repo does not have full name")?;
let ref_name = ref_name?;
let branch = ref_name.strip_prefix("refs/heads/").unwrap_or(ref_name);
println!("{bold}{actor_name}{reset} deleted branch {bold}{bright_cyan}{branch}{reset} from {bold}{yellow}{full_name}{reset}");
}
ActivityOpType::MirrorSyncPush => {}
ActivityOpType::MirrorSyncCreate => {}
ActivityOpType::MirrorSyncDelete => {}
ActivityOpType::ApprovePullRequest => {
let (name, id) = issue_name(repo?, content?)?;
println!("{bold}{actor_name}{reset} approved {bold}{yellow}{name}#{id}{reset}");
}
ActivityOpType::RejectPullRequest => {
let (name, id) = issue_name(repo?, content?)?;
println!(
"{bold}{actor_name}{reset} suggested changes for {bold}{yellow}{name}#{id}{reset}"
);
}
ActivityOpType::CommentPull => {
let (name, id) = issue_name(repo?, content?)?;
println!("{bold}{actor_name}{reset} commented on pull request {bold}{yellow}{name}#{id}{reset}");
}
ActivityOpType::PublishRelease => {
let repo = repo?;
let full_name = repo
.full_name
.as_deref()
.ok_or_eyre("repo does not have full name")?;
let content = content?;
println!("{bold}{actor_name}{reset} created release {bold}{bright_cyan}\"{content}\"{reset} to {bold}{yellow}{full_name}{reset}");
}
ActivityOpType::PullReviewDismissed => {}
ActivityOpType::PullRequestReadyForReview => {}
}
Ok(())
}
fn default_settings_opt() -> forgejo_api::structs::UserSettingsOptions {
forgejo_api::structs::UserSettingsOptions {
description: None,
diff_view_style: None,
enable_repo_unit_hints: None,
full_name: None,
hide_activity: None,
hide_email: None,
language: None,
location: None,
pronouns: None,
hide_pronouns: None,
theme: None,
website: None,
}
}
async fn edit_bio(api: &Forgejo, new_bio: Option<String>) -> eyre::Result<()> {
let new_bio = match new_bio {
Some(s) => s,
None => {
let mut bio = api
.user_get_current()
.await?
.description
.unwrap_or_default();
crate::editor(&mut bio, Some("md")).await?;
bio
}
};
let opt = forgejo_api::structs::UserSettingsOptions {
description: Some(new_bio),
..default_settings_opt()
};
api.update_user_settings(opt).await?;
Ok(())
}
async fn edit_name(api: &Forgejo, new_name: Option<String>, unset: bool) -> eyre::Result<()> {
match (new_name, unset) {
(Some(_), true) => unreachable!(),
(Some(name), false) if !name.is_empty() => {
let opt = forgejo_api::structs::UserSettingsOptions {
full_name: Some(name),
..default_settings_opt()
};
api.update_user_settings(opt).await?;
}
(None, true) => {
let opt = forgejo_api::structs::UserSettingsOptions {
full_name: Some(String::new()),
..default_settings_opt()
};
api.update_user_settings(opt).await?;
}
_ => println!("Use --unset to remove your name from your profile"),
}
Ok(())
}
async fn edit_pronouns(
api: &Forgejo,
new_pronouns: Option<String>,
unset: bool,
) -> eyre::Result<()> {
match (new_pronouns, unset) {
(Some(_), true) => unreachable!(),
(Some(pronouns), false) if !pronouns.is_empty() => {
let opt = forgejo_api::structs::UserSettingsOptions {
pronouns: Some(pronouns),
..default_settings_opt()
};
api.update_user_settings(opt).await?;
}
(None, true) => {
let opt = forgejo_api::structs::UserSettingsOptions {
pronouns: Some(String::new()),
..default_settings_opt()
};
api.update_user_settings(opt).await?;
}
_ => println!("Use --unset to remove your pronouns from your profile"),
}
Ok(())
}
async fn edit_location(
api: &Forgejo,
new_location: Option<String>,
unset: bool,
) -> eyre::Result<()> {
match (new_location, unset) {
(Some(_), true) => unreachable!(),
(Some(location), false) if !location.is_empty() => {
let opt = forgejo_api::structs::UserSettingsOptions {
location: Some(location),
..default_settings_opt()
};
api.update_user_settings(opt).await?;
}
(None, true) => {
let opt = forgejo_api::structs::UserSettingsOptions {
location: Some(String::new()),
..default_settings_opt()
};
api.update_user_settings(opt).await?;
}
_ => println!("Use --unset to remove your location from your profile"),
}
Ok(())
}
async fn edit_activity(api: &Forgejo, visibility: VisbilitySetting) -> eyre::Result<()> {
let opt = forgejo_api::structs::UserSettingsOptions {
hide_activity: Some(visibility == VisbilitySetting::Hidden),
..default_settings_opt()
};
api.update_user_settings(opt).await?;
Ok(())
}
async fn edit_email(
api: &Forgejo,
visibility: Option<VisbilitySetting>,
add: Vec<String>,
rm: Vec<String>,
) -> eyre::Result<()> {
if let Some(vis) = visibility {
let opt = forgejo_api::structs::UserSettingsOptions {
hide_activity: Some(vis == VisbilitySetting::Hidden),
..default_settings_opt()
};
api.update_user_settings(opt).await?;
}
if !add.is_empty() {
let opt = forgejo_api::structs::CreateEmailOption { emails: Some(add) };
api.user_add_email(opt).await?;
}
if !rm.is_empty() {
let opt = forgejo_api::structs::DeleteEmailOption { emails: Some(rm) };
api.user_delete_email(opt).await?;
}
Ok(())
}
async fn edit_website(api: &Forgejo, new_url: Option<String>, unset: bool) -> eyre::Result<()> {
match (new_url, unset) {
(Some(_), true) => unreachable!(),
(Some(url), false) if !url.is_empty() => {
let opt = forgejo_api::structs::UserSettingsOptions {
website: Some(url),
..default_settings_opt()
};
api.update_user_settings(opt).await?;
}
(None, true) => {
let opt = forgejo_api::structs::UserSettingsOptions {
website: Some(String::new()),
..default_settings_opt()
};
api.update_user_settings(opt).await?;
}
_ => println!("Use --unset to remove your name from your profile"),
}
Ok(())
}
async fn list_keys(api: &Forgejo, verbose: bool) -> eyre::Result<()> {
let SpecialRender {
bold,
bright_cyan,
bright_magenta,
reset,
..
} = *crate::special_render();
let keys = api.user_current_list_keys(Default::default()).all().await?;
println!("total keys: {}", keys.len());
let id_length = keys
.iter()
.map(|k| std::cmp::max(k.id.unwrap_or(0), 1).ilog10() + 1)
.max()
.unwrap_or(0) as usize;
let title_length = keys
.iter()
.map(|k| k.title.as_ref().map(String::len).unwrap_or(0))
.max()
.unwrap_or(0);
for key in keys {
let id = key.id.unwrap_or(0);
if verbose {
println!("\n{bold}Key {bright_magenta}{id}{reset}:");
print_key(&key, 4);
} else {
let title = crate::DisplayOptional(key.title, "?");
let fingerprint = crate::DisplayOptional(key.fingerprint, "?");
println!(
"{bold}{id: >id_length$} {bright_cyan}{title: <title_length$}{reset} {fingerprint}"
);
}
}
Ok(())
}
async fn view_key(api: &Forgejo, id: i64) -> eyre::Result<()> {
let key = api.user_current_get_key(id).await?;
print_key(&key, 0);
Ok(())
}
fn print_key(key: &forgejo_api::structs::PublicKey, indent: usize) {
let SpecialRender {
bold,
bright_red,
bright_cyan,
reset,
..
} = *crate::special_render();
let indent = " ".repeat(indent);
let unknown_value = format!("{bright_red}?{reset}");
println!(
"{indent}{bold}Title: {reset}{bright_cyan}{}{reset}",
crate::DisplayOptional(key.title.as_ref(), &unknown_value),
);
println!(
"{indent}{bold}Created At: {reset}{bright_cyan}{}{reset}",
crate::DisplayOptional(key.created_at, &unknown_value),
);
println!(
"{indent}{bold}Type: {reset}{bright_cyan}{}{reset}",
crate::DisplayOptional(key.key_type.as_ref(), &unknown_value),
);
println!(
"{indent}{bold}Fingerprint: {reset}{bright_cyan}{}{reset}",
crate::DisplayOptional(key.fingerprint.as_ref(), &unknown_value),
);
if let Some(key) = &key.key {
println!("\n{indent}{key}");
}
}
async fn delete_key(api: &Forgejo, id: i64) -> eyre::Result<()> {
api.user_current_delete_key(id).await?;
println!("successfully deleted key with ID {id}");
Ok(())
}
async fn upload_key(
api: &Forgejo,
file: Option<String>,
title: Option<String>,
force: bool,
read_only: bool,
) -> eyre::Result<()> {
use tokio::io::AsyncReadExt;
let is_stdin = matches!(file.as_deref(), Some("-"));
let file =
if let Some(file) = file {
std::path::PathBuf::from(file)
} else {
let ssh_dir = directories::UserDirs::new().ok_or_eyre(
"Couldn't locate home directory. Please provide an explicit path for the key file.",
)?.home_dir().join(".ssh");
let mut dirstream = tokio::fs::read_dir(ssh_dir).await?;
loop {
let Some(entry) = dirstream.next_entry().await? else {
eyre::bail!("No keys found.");
};
if !entry.file_type().await?.is_file() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
if !name.starts_with("id_") || !name.ends_with(".pub") {
continue;
}
let path = entry.path();
println!("Guessed key file: {}", path.display());
eyre::ensure!(
crate::prompt_bool("Does this look good?", false).await?,
"User didn't confirm guessed key file.",
);
break path;
}
};
eyre::ensure!(
force || is_stdin || file.extension().map(|e| e == "pub").unwrap_or_default(),
concat!(
"'{}' doesn't end in '.pub'. Are you sure this isn't a private key?",
" If you want to proceed anyways, add --force."
),
file.display(),
);
let SpecialRender {
bright_cyan, reset, ..
} = *crate::special_render();
let content = if is_stdin {
let mut key_content = String::new();
tokio::io::stdin().read_to_string(&mut key_content).await?;
key_content
} else {
tokio::fs::read_to_string(&file).await?
};
let trimmed = content.trim();
eyre::ensure!(
force || (trimmed.starts_with("ssh-") && !trimmed.contains('\n')),
concat!(
"'{}' looks like a private key or invalid data!",
" If you want to proceed anyways, add --force."
),
file.display(),
);
let title = if let Some(title) = title {
title
} else {
let Some(guess) = trimmed.split(' ').last() else {
eyre::bail!(
"Couldn't guess key title, please provide one explicitly and check your key file."
);
};
println!("Guessed title: {bright_cyan}{guess}{reset}");
eyre::ensure!(
crate::prompt_bool("Does this look good?", false).await?,
"User didn't confirm guessed title.",
);
guess.to_string()
};
let body = forgejo_api::structs::CreateKeyOption {
key: content,
read_only: Some(read_only),
title,
};
let key = api.user_current_post_key(body).await?;
println!("Key created successfully!\n");
print_key(&key, 0);
Ok(())
}
async fn list_gpg(api: &Forgejo, verbose: bool) -> eyre::Result<()> {
let SpecialRender {
bold,
bright_cyan,
bright_magenta,
reset,
..
} = *crate::special_render();
let keys = api.user_current_list_gpg_keys().all().await?;
let id_length = keys
.iter()
.map(|k| std::cmp::max(k.id.unwrap_or(0), 1).ilog10() + 1)
.max()
.unwrap_or(0) as usize;
let keyid_length = keys
.iter()
.map(|k| k.key_id.as_ref().map(String::len).unwrap_or(0))
.max()
.unwrap_or(0);
println!("total keys: {}", keys.len());
for key in keys {
let id = key.id.unwrap_or(0);
if verbose {
println!("\n{bold}Key {bright_magenta}{id}{reset}:");
print_gpg(&key, 4);
} else {
let keyid = crate::DisplayOptional(key.key_id, "?");
println!("{bold}{id: >id_length$} {bright_cyan}{keyid: <keyid_length$}{reset}");
}
}
Ok(())
}
async fn view_gpg(api: &Forgejo, id: i64) -> eyre::Result<()> {
let key = api.user_current_get_gpg_key(id).await?;
print_gpg(&key, 0);
Ok(())
}
fn print_gpg(key: &forgejo_api::structs::GPGKey, indent_depth: usize) {
let SpecialRender {
bold,
bright_cyan,
bright_red,
bright_magenta,
reset,
..
} = *crate::special_render();
let indent = " ".repeat(indent_depth);
let unknown_value = format!("{bright_red}?{reset}");
println!(
"{indent}{bold}Key ID: {reset}{bright_cyan}{}{reset}",
crate::DisplayOptional(key.key_id.as_ref(), &unknown_value)
);
println!(
"{indent}{bold}Can Sign: {reset}{}",
crate::DisplayBool(key.can_sign.unwrap_or(false))
);
println!(
"{indent}{bold}Can Encrypt Comms: {reset}{}",
crate::DisplayBool(key.can_encrypt_comms.unwrap_or(false))
);
println!(
"{indent}{bold}Can Encrypt Storage: {reset}{}",
crate::DisplayBool(key.can_encrypt_storage.unwrap_or(false))
);
println!(
"{indent}{bold}Can Certify: {reset}{}",
crate::DisplayBool(key.can_certify.unwrap_or(false))
);
println!(
"{indent}{bold}Verified: {reset}{}",
crate::DisplayBool(key.verified.unwrap_or(false))
);
for email in key.emails.as_ref().map(Vec::as_slice).unwrap_or_default() {
if let forgejo_api::structs::GPGKeyEmail {
email: Some(email),
verified,
} = email
{
let verified = verified.unwrap_or(false);
println!(
"{indent}{bright_cyan}{email}{reset} {}",
if verified { "verified" } else { "not verified" }
);
}
}
if let Some(key) = key.public_key.as_ref() {
println!("\n{indent}{key}");
}
for subkey in key.subkeys.as_ref().map(Vec::as_slice).unwrap_or(&[]) {
println!(
"\n{indent}{bold}Subkey {bright_magenta}{}{reset}:",
crate::DisplayOptional(key.id, "?")
);
print_gpg(subkey, indent_depth + 4);
}
}
async fn delete_gpg(api: &Forgejo, id: i64, force: bool) -> eyre::Result<()> {
let prompt =
"Deleting a GPG key will cause all commits signed by that key to become unverified! Continue?";
eyre::ensure!(
force || crate::prompt_bool(prompt, false).await?,
"User aborted process.",
);
api.user_current_delete_gpg_key(id).await?;
println!("Key with ID {id} deleted successfully.");
Ok(())
}
async fn upload_gpg(api: &Forgejo, key_name: String, no_verify: bool) -> eyre::Result<()> {
println!("Exporting key...");
let key_output = tokio::process::Command::new("gpg")
.arg("--export")
.arg("--armor")
.arg(&key_name)
.stderr(std::process::Stdio::inherit())
.output()
.await?;
eyre::ensure!(
key_output.status.success(),
"Failed to export key. GPG status: {}",
key_output.status,
);
eyre::ensure!(!key_output.stdout.is_empty(), "No such key found!");
let key = String::from_utf8(key_output.stdout).context("Couldn't convert key to string.")?;
let signature = if no_verify {
None
} else {
Some(gpg_verify_token(api, &key_name).await?)
};
let form = forgejo_api::structs::CreateGPGKeyOption {
armored_public_key: key,
armored_signature: signature,
};
let key = api.user_current_post_gpg_key(form).await?;
println!("Key successfully added!\n");
print_gpg(&key, 0);
Ok(())
}
async fn gpg_verify_token(api: &Forgejo, key_name: &str) -> eyre::Result<String> {
use tokio::io::AsyncWriteExt;
println!("Fetching verification token...");
let token = api.get_verification_token().await?;
println!("Signing verification token with key '{key_name}'...");
let mut child = tokio::process::Command::new("gpg")
.arg("--armor")
.arg("--default-key")
.arg(key_name)
.arg("--detach-sig")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()?;
let mut stdin = child.stdin.take().context("Failed to open GPG stdin")?;
let writer = tokio::spawn(async move { stdin.write_all(token.as_bytes()).await });
let output = child.wait_with_output().await?;
writer.await??;
eyre::ensure!(
output.status.success(),
"Failed to export key. GPG status: {}",
output.status,
);
Ok(String::from_utf8(output.stdout)?)
}
async fn verify_gpg(api: &Forgejo, id: i64) -> eyre::Result<()> {
let key = api.user_current_get_gpg_key(id).await?;
let Some(key_id) = &key.key_id else {
eyre::bail!("API didn't return a key ID!");
};
println!("Verifying this key:");
print_gpg(&key, 0);
let token = gpg_verify_token(api, key_id).await?;
let option = forgejo_api::structs::VerifyGPGKeyOption {
armored_signature: Some(token),
key_id: key_id.clone(),
};
api.user_verify_gpg_key(option).await?;
println!("Verification successful!");
Ok(())
}