summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-11-04 11:30:10 +0100
committerDaniel Baumann <daniel@debian.org>2024-11-20 07:38:21 +0100
commit34f503aa3bfba930fd7978a0071786884d73749f (patch)
tree7e3c8e2506fdd93e29958d9f8cb36fbed4a5af7d /src
parentInitial commit. (diff)
downloadforgejo-cli-34f503aa3bfba930fd7978a0071786884d73749f.tar.xz
forgejo-cli-34f503aa3bfba930fd7978a0071786884d73749f.zip
Adding upstream version 0.1.1.upstream/0.1.1
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'src')
-rw-r--r--src/auth.rs236
-rw-r--r--src/issues.rs674
-rw-r--r--src/keys.rs129
-rw-r--r--src/main.rs800
-rw-r--r--src/prs.rs1564
-rw-r--r--src/release.rs614
-rw-r--r--src/repo.rs766
-rw-r--r--src/user.rs1019
-rw-r--r--src/wiki.rs158
9 files changed, 5960 insertions, 0 deletions
diff --git a/src/auth.rs b/src/auth.rs
new file mode 100644
index 0000000..072dc30
--- /dev/null
+++ b/src/auth.rs
@@ -0,0 +1,236 @@
+use clap::Subcommand;
+use eyre::OptionExt;
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum AuthCommand {
+ /// Log in to an instance.
+ ///
+ /// Opens an auth page in your browser
+ Login,
+ /// Deletes login info for an instance
+ Logout { host: String },
+ /// Add an application token for an instance
+ ///
+ /// Use this if `fj auth login` doesn't work
+ 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 all instances you're currently logged into
+ List,
+}
+
+impl AuthCommand {
+ pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> {
+ match self {
+ AuthCommand::Login => {
+ let repo_info = crate::repo::RepoInfo::get_current(host_name, None, None)?;
+ let host_url = repo_info.host_url();
+ let client_info = get_client_info_for(host_url);
+ if let Some((client_id, _)) = client_info {
+ oauth_login(keys, host_url, client_id).await?;
+ } else {
+ let host_domain = host_url.host_str().ok_or_eyre("invalid host")?;
+ let host_path = host_url.path();
+ let mut applications_url = host_url.clone();
+ applications_url
+ .path_segments_mut()
+ .map_err(|_| eyre::eyre!("invalid url"))?
+ .extend(["user", "settings", "applications"]);
+
+ println!("{host_domain}{host_path} doesn't support easy login");
+ println!();
+ println!("Please visit {applications_url}");
+ println!("to create a token, and use it to log in with `fj auth add-key`");
+ }
+ }
+ 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 => crate::readline("new key: ").await?.trim().to_string(),
+ };
+ if keys.hosts.get(&user).is_none() {
+ keys.hosts.insert(
+ host,
+ crate::keys::LoginInfo::Application {
+ name: user,
+ token: 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);
+ }
+ }
+ }
+ Ok(())
+ }
+}
+
+pub fn get_client_info_for(url: &url::Url) -> Option<(&'static str, &'static str)> {
+ let client_info = match (url.host_str()?, url.path()) {
+ ("codeberg.org", "/") => option_env!("CLIENT_INFO_CODEBERG"),
+ _ => None,
+ };
+ client_info.and_then(|info| info.split_once(":"))
+}
+
+async fn oauth_login(
+ keys: &mut crate::KeyInfo,
+ host: &url::Url,
+ client_id: &'static str,
+) -> eyre::Result<()> {
+ use base64ct::Encoding;
+ use rand::{distributions::Alphanumeric, prelude::*};
+
+ let mut rng = thread_rng();
+
+ let state = (0..32)
+ .map(|_| rng.sample(Alphanumeric) as char)
+ .collect::<String>();
+ let code_verifier = (0..43)
+ .map(|_| rng.sample(Alphanumeric) as char)
+ .collect::<String>();
+ let code_challenge =
+ base64ct::Base64Url::encode_string(sha256::digest(&code_verifier).as_bytes());
+
+ let mut auth_url = host.clone();
+ auth_url
+ .path_segments_mut()
+ .map_err(|_| eyre::eyre!("invalid url"))?
+ .extend(["login", "oauth", "authorize"]);
+ auth_url.query_pairs_mut().extend_pairs([
+ ("client_id", client_id),
+ ("redirect_uri", "http://127.0.0.1:26218/"),
+ ("response_type", "code"),
+ ("code_challenge_method", "S256"),
+ ("code_challenge", &code_challenge),
+ ("state", &state),
+ ]);
+ open::that(auth_url.as_str()).unwrap();
+
+ let (handle, mut rx) = auth_server();
+ let res = rx.recv().await.unwrap();
+ handle.abort();
+ let code = match res {
+ Ok(Some((code, returned_state))) => {
+ if returned_state == state {
+ code
+ } else {
+ eyre::bail!("returned with invalid state");
+ }
+ }
+ Ok(None) => {
+ println!("Login canceled");
+ return Ok(());
+ }
+ Err(e) => {
+ eyre::bail!("Failed to authenticate: {e}");
+ }
+ };
+
+ let api = forgejo_api::Forgejo::new(forgejo_api::Auth::None, host.clone())?;
+ let request = forgejo_api::structs::OAuthTokenRequest::Public {
+ client_id,
+ code_verifier: &code_verifier,
+ code: &code,
+ redirect_uri: url::Url::parse("http://127.0.0.1:26218/").unwrap(),
+ };
+ let response = api.oauth_get_access_token(request).await?;
+
+ let api = forgejo_api::Forgejo::new(
+ forgejo_api::Auth::OAuth2(&response.access_token),
+ host.clone(),
+ )?;
+ let current_user = api.user_get_current().await?;
+ let name = current_user
+ .login
+ .ok_or_eyre("user does not have login name")?;
+
+ // A minute less, in case any weirdness happens at the exact moment it
+ // expires. Better to refresh slightly too soon than slightly too late.
+ let expires_in = std::time::Duration::from_secs(response.expires_in.saturating_sub(60) as u64);
+ let expires_at = time::OffsetDateTime::now_utc() + expires_in;
+ let login_info = crate::keys::LoginInfo::OAuth {
+ name,
+ token: response.access_token,
+ refresh_token: response.refresh_token,
+ expires_at,
+ };
+ keys.hosts
+ .insert(host.host_str().unwrap().to_string(), login_info);
+
+ Ok(())
+}
+
+use tokio::{sync::mpsc::Receiver, task::JoinHandle};
+
+fn auth_server() -> (
+ JoinHandle<eyre::Result<()>>,
+ Receiver<Result<Option<(String, String)>, String>>,
+) {
+ let addr: std::net::SocketAddr = ([127, 0, 0, 1], 26218).into();
+ let (tx, rx) = tokio::sync::mpsc::channel(1);
+ let tx = std::sync::Arc::new(tx);
+ let handle = tokio::spawn(async move {
+ let listener = tokio::net::TcpListener::bind(addr).await?;
+ let server =
+ hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new());
+ let svc = hyper::service::service_fn(|req: hyper::Request<hyper::body::Incoming>| {
+ let tx = std::sync::Arc::clone(&tx);
+ async move {
+ let mut code = None;
+ let mut state = None;
+ let mut error_description = None;
+ if let Some(query) = req.uri().query() {
+ for item in query.split("&") {
+ let (key, value) = item.split_once("=").unwrap_or((item, ""));
+ match key {
+ "code" => code = Some(value),
+ "state" => state = Some(value),
+ "error_description" => error_description = Some(value),
+ _ => eprintln!("unknown key {key} {value}"),
+ }
+ }
+ }
+ let (response, message) = match (code, state, error_description) {
+ (_, _, Some(error)) => (Err(error.to_owned()), "Failed to authenticate"),
+ (Some(code), Some(state), None) => (
+ Ok(Some((code.to_owned(), state.to_owned()))),
+ "Authenticated! Close this tab and head back to your terminal",
+ ),
+ _ => (Ok(None), "Canceled"),
+ };
+ tx.send(response).await.unwrap();
+ Ok::<_, hyper::Error>(hyper::Response::new(message.to_owned()))
+ }
+ });
+ loop {
+ let (connection, _addr) = listener.accept().await.unwrap();
+ server
+ .serve_connection(hyper_util::rt::TokioIo::new(connection), svc)
+ .await
+ .unwrap();
+ }
+ });
+ (handle, rx)
+}
diff --git a/src/issues.rs b/src/issues.rs
new file mode 100644
index 0000000..a65e3d4
--- /dev/null
+++ b/src/issues.rs
@@ -0,0 +1,674 @@
+use std::str::FromStr;
+
+use clap::{Args, Subcommand};
+use eyre::{eyre, OptionExt};
+use forgejo_api::structs::{
+ Comment, CreateIssueCommentOption, CreateIssueOption, EditIssueOption, IssueGetCommentsQuery,
+};
+use forgejo_api::Forgejo;
+
+use crate::repo::{RepoArg, RepoInfo, RepoName};
+
+#[derive(Args, Clone, Debug)]
+pub struct IssueCommand {
+ /// The local git remote that points to the repo to operate on.
+ #[clap(long, short = 'R')]
+ remote: Option<String>,
+ #[clap(subcommand)]
+ command: IssueSubcommand,
+}
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum IssueSubcommand {
+ /// Create a new issue on a repo
+ Create {
+ title: String,
+ #[clap(long)]
+ body: Option<String>,
+ #[clap(long, short, id = "[HOST/]OWNER/REPO")]
+ repo: Option<RepoArg>,
+ },
+ /// Edit an issue
+ Edit {
+ #[clap(id = "[REPO#]ID")]
+ issue: IssueId,
+ #[clap(subcommand)]
+ command: EditCommand,
+ },
+ /// Add a comment on an issue
+ Comment {
+ #[clap(id = "[REPO#]ID")]
+ issue: IssueId,
+ body: Option<String>,
+ },
+ /// Close an issue
+ Close {
+ #[clap(id = "[REPO#]ID")]
+ issue: IssueId,
+ /// A comment to leave on the issue before closing it
+ #[clap(long, short)]
+ with_msg: Option<Option<String>>,
+ },
+ /// Search for an issue in a repo
+ Search {
+ #[clap(long, short, id = "[HOST/]OWNER/REPO")]
+ repo: Option<RepoArg>,
+ query: Option<String>,
+ #[clap(long, short)]
+ labels: Option<String>,
+ #[clap(long, short)]
+ creator: Option<String>,
+ #[clap(long, short)]
+ assignee: Option<String>,
+ #[clap(long, short)]
+ state: Option<State>,
+ },
+ /// View an issue's info
+ View {
+ #[clap(id = "[REPO#]ID")]
+ id: IssueId,
+ #[clap(subcommand)]
+ command: Option<ViewCommand>,
+ },
+ /// Open an issue in your browser
+ Browse {
+ #[clap(id = "[REPO#]ID")]
+ id: IssueId,
+ },
+}
+
+#[derive(Clone, Debug)]
+pub struct IssueId {
+ pub repo: Option<RepoArg>,
+ pub number: u64,
+}
+
+impl FromStr for IssueId {
+ type Err = IssueIdError;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ let (repo, number) = match s.rsplit_once("#") {
+ Some((repo, number)) => (Some(repo.parse::<RepoArg>()?), number),
+ None => (None, s),
+ };
+ Ok(Self {
+ repo,
+ number: number.parse()?,
+ })
+ }
+}
+
+#[derive(Debug, Clone)]
+pub enum IssueIdError {
+ Repo(crate::repo::RepoArgError),
+ Number(std::num::ParseIntError),
+}
+
+impl std::fmt::Display for IssueIdError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ IssueIdError::Repo(e) => e.fmt(f),
+ IssueIdError::Number(e) => e.fmt(f),
+ }
+ }
+}
+
+impl From<crate::repo::RepoArgError> for IssueIdError {
+ fn from(value: crate::repo::RepoArgError) -> Self {
+ Self::Repo(value)
+ }
+}
+
+impl From<std::num::ParseIntError> for IssueIdError {
+ fn from(value: std::num::ParseIntError) -> Self {
+ Self::Number(value)
+ }
+}
+
+impl std::error::Error for IssueIdError {}
+
+#[derive(clap::ValueEnum, Clone, Copy, Debug)]
+pub enum State {
+ Open,
+ Closed,
+}
+
+impl From<State> for forgejo_api::structs::IssueListIssuesQueryState {
+ fn from(value: State) -> Self {
+ match value {
+ State::Open => forgejo_api::structs::IssueListIssuesQueryState::Open,
+ State::Closed => forgejo_api::structs::IssueListIssuesQueryState::Closed,
+ }
+ }
+}
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum EditCommand {
+ /// Edit an issue's title
+ Title { new_title: Option<String> },
+ /// Edit an issue's text content
+ Body { new_body: Option<String> },
+ /// Edit a comment on an issue
+ Comment {
+ idx: usize,
+ new_body: Option<String>,
+ },
+}
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum ViewCommand {
+ /// View an issue's title and body. The default
+ Body,
+ /// View a specific
+ Comment { idx: usize },
+ /// List every comment
+ Comments,
+}
+
+impl IssueCommand {
+ pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> {
+ use IssueSubcommand::*;
+ let repo = RepoInfo::get_current(host_name, self.repo(), self.remote.as_deref())?;
+ let api = keys.get_api(repo.host_url()).await?;
+ let repo = repo.name().ok_or_else(|| self.no_repo_error())?;
+ match self.command {
+ Create {
+ repo: _,
+ title,
+ body,
+ } => create_issue(&repo, &api, title, body).await?,
+ View { id, command } => match command.unwrap_or(ViewCommand::Body) {
+ ViewCommand::Body => view_issue(&repo, &api, id.number).await?,
+ ViewCommand::Comment { idx } => view_comment(&repo, &api, id.number, idx).await?,
+ ViewCommand::Comments => view_comments(&repo, &api, id.number).await?,
+ },
+ Search {
+ repo: _,
+ query,
+ labels,
+ creator,
+ assignee,
+ state,
+ } => view_issues(&repo, &api, query, labels, creator, assignee, state).await?,
+ Edit { issue, command } => match command {
+ EditCommand::Title { new_title } => {
+ edit_title(&repo, &api, issue.number, new_title).await?
+ }
+ EditCommand::Body { new_body } => {
+ edit_body(&repo, &api, issue.number, new_body).await?
+ }
+ EditCommand::Comment { idx, new_body } => {
+ edit_comment(&repo, &api, issue.number, idx, new_body).await?
+ }
+ },
+ Close { issue, with_msg } => close_issue(&repo, &api, issue.number, with_msg).await?,
+ Browse { id } => browse_issue(&repo, &api, id.number).await?,
+ Comment { issue, body } => add_comment(&repo, &api, issue.number, body).await?,
+ }
+ Ok(())
+ }
+
+ fn repo(&self) -> Option<&RepoArg> {
+ use IssueSubcommand::*;
+ match &self.command {
+ Create { repo, .. } | Search { repo, .. } => repo.as_ref(),
+ View { id: issue, .. }
+ | Edit { issue, .. }
+ | Close { issue, .. }
+ | Comment { issue, .. }
+ | Browse { id: issue, .. } => issue.repo.as_ref(),
+ }
+ }
+
+ fn no_repo_error(&self) -> eyre::Error {
+ use IssueSubcommand::*;
+ match &self.command {
+ Create { .. } | Search { .. } => {
+ eyre::eyre!("can't figure what repo to access, try specifying with `--repo`")
+ }
+ View { id: issue, .. }
+ | Edit { issue, .. }
+ | Close { issue, .. }
+ | Comment { issue, .. }
+ | Browse { id: issue, .. } => eyre::eyre!(
+ "can't figure out what repo to access, try specifying with `{{owner}}/{{repo}}#{}`",
+ issue.number
+ ),
+ }
+ }
+}
+
+async fn create_issue(
+ repo: &RepoName,
+ api: &Forgejo,
+ title: String,
+ body: Option<String>,
+) -> eyre::Result<()> {
+ let body = match body {
+ Some(body) => body,
+ None => {
+ let mut body = String::new();
+ crate::editor(&mut body, Some("md")).await?;
+ body
+ }
+ };
+ let issue = api
+ .issue_create_issue(
+ repo.owner(),
+ repo.name(),
+ CreateIssueOption {
+ body: Some(body),
+ title,
+ assignee: None,
+ assignees: None,
+ closed: None,
+ due_date: None,
+ labels: None,
+ milestone: None,
+ r#ref: None,
+ },
+ )
+ .await?;
+ let number = issue
+ .number
+ .ok_or_else(|| eyre::eyre!("issue does not have number"))?;
+ let title = issue
+ .title
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("issue does not have title"))?;
+ eprintln!("created issue #{}: {}", number, title);
+ Ok(())
+}
+
+pub async fn view_issue(repo: &RepoName, api: &Forgejo, id: u64) -> eyre::Result<()> {
+ let crate::SpecialRender {
+ dash,
+
+ bright_red,
+ bright_green,
+ yellow,
+ dark_grey,
+ white,
+ reset,
+ ..
+ } = crate::special_render();
+
+ let issue = api.issue_get_issue(repo.owner(), repo.name(), id).await?;
+
+ // if it's a pull request, display it as one instead
+ if issue.pull_request.is_some() {
+ crate::prs::view_pr(repo, api, Some(id)).await?;
+ return Ok(());
+ }
+
+ let title = issue
+ .title
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("issue does not have title"))?;
+ let user = issue
+ .user
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("issue does not have creator"))?;
+ let username = user
+ .login
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("user does not have login"))?;
+ let state = issue
+ .state
+ .ok_or_else(|| eyre::eyre!("pr does not have state"))?;
+ let comments = issue.comments.unwrap_or_default();
+
+ println!("{yellow}{title} {dark_grey}#{id}{reset}");
+ print!("By {white}{username}{reset} {dash} ");
+
+ use forgejo_api::structs::StateType;
+ match state {
+ StateType::Open => println!("{bright_green}Open{reset}"),
+ StateType::Closed => println!("{bright_red}Closed{reset}"),
+ };
+
+ if let Some(body) = &issue.body {
+ if !body.is_empty() {
+ println!();
+ println!("{}", crate::markdown(body));
+ }
+ }
+ println!();
+
+ if comments == 1 {
+ println!("1 comment");
+ } else {
+ println!("{comments} comments");
+ }
+ Ok(())
+}
+async fn view_issues(
+ repo: &RepoName,
+ api: &Forgejo,
+ query_str: Option<String>,
+ labels: Option<String>,
+ creator: Option<String>,
+ assignee: Option<String>,
+ state: Option<State>,
+) -> eyre::Result<()> {
+ let labels = labels
+ .map(|s| s.split(',').map(|s| s.to_string()).collect::<Vec<_>>())
+ .unwrap_or_default();
+ let query = forgejo_api::structs::IssueListIssuesQuery {
+ q: query_str,
+ labels: Some(labels.join(",")),
+ created_by: creator,
+ assigned_by: assignee,
+ state: state.map(|s| s.into()),
+ r#type: None,
+ milestones: None,
+ since: None,
+ before: None,
+ mentioned_by: None,
+ page: None,
+ limit: None,
+ };
+ let issues = api
+ .issue_list_issues(repo.owner(), repo.name(), query)
+ .await?;
+ if issues.len() == 1 {
+ println!("1 issue");
+ } else {
+ println!("{} issues", issues.len());
+ }
+ for issue in issues {
+ let number = issue
+ .number
+ .ok_or_else(|| eyre::eyre!("issue does not have number"))?;
+ let title = issue
+ .title
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("issue does not have title"))?;
+ let user = issue
+ .user
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("issue does not have creator"))?;
+ let username = user
+ .login
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("user does not have login"))?;
+ println!("#{}: {} (by {})", number, title, username);
+ }
+ Ok(())
+}
+
+pub async fn view_comment(repo: &RepoName, api: &Forgejo, id: u64, idx: usize) -> eyre::Result<()> {
+ let query = IssueGetCommentsQuery {
+ since: None,
+ before: None,
+ };
+ let comments = api
+ .issue_get_comments(repo.owner(), repo.name(), id, query)
+ .await?;
+ let comment = comments
+ .get(idx)
+ .ok_or_else(|| eyre!("comment {idx} doesn't exist"))?;
+ print_comment(&comment)?;
+ Ok(())
+}
+
+pub async fn view_comments(repo: &RepoName, api: &Forgejo, id: u64) -> eyre::Result<()> {
+ let query = IssueGetCommentsQuery {
+ since: None,
+ before: None,
+ };
+ let comments = api
+ .issue_get_comments(repo.owner(), repo.name(), id, query)
+ .await?;
+ for comment in comments {
+ print_comment(&comment)?;
+ }
+ Ok(())
+}
+
+fn print_comment(comment: &Comment) -> eyre::Result<()> {
+ let body = comment
+ .body
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("comment does not have body"))?;
+ let user = comment
+ .user
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("comment does not have user"))?;
+ let username = user
+ .login
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("user does not have login"))?;
+ println!("{} said:", username);
+ println!("{}", crate::markdown(&body));
+ let assets = comment
+ .assets
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("comment does not have assets"))?;
+ if !assets.is_empty() {
+ println!("({} attachments)", assets.len());
+ }
+ Ok(())
+}
+
+pub async fn browse_issue(repo: &RepoName, api: &Forgejo, id: u64) -> eyre::Result<()> {
+ let issue = api.issue_get_issue(repo.owner(), repo.name(), id).await?;
+ let html_url = issue
+ .html_url
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("issue does not have html_url"))?;
+ open::that(html_url.as_str())?;
+ Ok(())
+}
+
+pub async fn add_comment(
+ repo: &RepoName,
+ api: &Forgejo,
+ issue: u64,
+ body: Option<String>,
+) -> eyre::Result<()> {
+ let body = match body {
+ Some(body) => body,
+ None => {
+ let mut body = String::new();
+ crate::editor(&mut body, Some("md")).await?;
+ body
+ }
+ };
+ api.issue_create_comment(
+ repo.owner(),
+ repo.name(),
+ issue,
+ forgejo_api::structs::CreateIssueCommentOption {
+ body,
+ updated_at: None,
+ },
+ )
+ .await?;
+ Ok(())
+}
+
+pub async fn edit_title(
+ repo: &RepoName,
+ api: &Forgejo,
+ issue: u64,
+ new_title: Option<String>,
+) -> eyre::Result<()> {
+ let new_title = match new_title {
+ Some(s) => s,
+ None => {
+ let issue_info = api
+ .issue_get_issue(repo.owner(), repo.name(), issue)
+ .await?;
+ let mut title = issue_info
+ .title
+ .ok_or_else(|| eyre::eyre!("issue does not have title"))?;
+ crate::editor(&mut title, Some("md")).await?;
+ title
+ }
+ };
+ let new_title = new_title.trim();
+ if new_title.is_empty() {
+ eyre::bail!("title cannot be empty");
+ }
+ if new_title.contains('\n') {
+ eyre::bail!("title cannot contain newlines");
+ }
+ api.issue_edit_issue(
+ repo.owner(),
+ repo.name(),
+ issue,
+ forgejo_api::structs::EditIssueOption {
+ title: Some(new_title.to_owned()),
+ assignee: None,
+ assignees: None,
+ body: None,
+ due_date: None,
+ milestone: None,
+ r#ref: None,
+ state: None,
+ unset_due_date: None,
+ updated_at: None,
+ },
+ )
+ .await?;
+ Ok(())
+}
+
+pub async fn edit_body(
+ repo: &RepoName,
+ api: &Forgejo,
+ issue: u64,
+ new_body: Option<String>,
+) -> eyre::Result<()> {
+ let new_body = match new_body {
+ Some(s) => s,
+ None => {
+ let issue_info = api
+ .issue_get_issue(repo.owner(), repo.name(), issue)
+ .await?;
+ let mut body = issue_info
+ .body
+ .ok_or_else(|| eyre::eyre!("issue does not have body"))?;
+ crate::editor(&mut body, Some("md")).await?;
+ body
+ }
+ };
+ api.issue_edit_issue(
+ repo.owner(),
+ repo.name(),
+ issue,
+ forgejo_api::structs::EditIssueOption {
+ body: Some(new_body),
+ assignee: None,
+ assignees: None,
+ due_date: None,
+ milestone: None,
+ r#ref: None,
+ state: None,
+ title: None,
+ unset_due_date: None,
+ updated_at: None,
+ },
+ )
+ .await?;
+ Ok(())
+}
+
+pub async fn edit_comment(
+ repo: &RepoName,
+ api: &Forgejo,
+ issue: u64,
+ idx: usize,
+ new_body: Option<String>,
+) -> eyre::Result<()> {
+ let comments = api
+ .issue_get_comments(
+ repo.owner(),
+ repo.name(),
+ issue,
+ IssueGetCommentsQuery {
+ since: None,
+ before: None,
+ },
+ )
+ .await?;
+ let comment = comments
+ .get(idx)
+ .ok_or_else(|| eyre!("comment not found"))?;
+ let new_body = match new_body {
+ Some(s) => s,
+ None => {
+ let mut body = comment
+ .body
+ .clone()
+ .ok_or_else(|| eyre::eyre!("issue does not have body"))?;
+ crate::editor(&mut body, Some("md")).await?;
+ body
+ }
+ };
+ let id = comment
+ .id
+ .ok_or_else(|| eyre::eyre!("comment does not have id"))? as u64;
+ api.issue_edit_comment(
+ repo.owner(),
+ repo.name(),
+ id,
+ forgejo_api::structs::EditIssueCommentOption {
+ body: new_body,
+ updated_at: None,
+ },
+ )
+ .await?;
+ Ok(())
+}
+
+pub async fn close_issue(
+ repo: &RepoName,
+ api: &Forgejo,
+ issue: u64,
+ message: Option<Option<String>>,
+) -> eyre::Result<()> {
+ if let Some(message) = message {
+ let body = match message {
+ Some(m) => m,
+ None => {
+ let mut s = String::new();
+ crate::editor(&mut s, Some("md")).await?;
+ s
+ }
+ };
+
+ let opt = CreateIssueCommentOption {
+ body,
+ updated_at: None,
+ };
+ api.issue_create_comment(repo.owner(), repo.name(), issue, opt)
+ .await?;
+ }
+
+ let edit = EditIssueOption {
+ state: Some("closed".into()),
+ assignee: None,
+ assignees: None,
+ body: None,
+ due_date: None,
+ milestone: None,
+ r#ref: None,
+ title: None,
+ unset_due_date: None,
+ updated_at: None,
+ };
+ let issue_data = api
+ .issue_edit_issue(repo.owner(), repo.name(), issue, edit)
+ .await?;
+
+ let issue_title = issue_data
+ .title
+ .as_deref()
+ .ok_or_eyre("issue does not have title")?;
+
+ println!("Closed issue {issue}: \"{issue_title}\"");
+
+ Ok(())
+}
diff --git a/src/keys.rs b/src/keys.rs
new file mode 100644
index 0000000..33ac02e
--- /dev/null
+++ b/src/keys.rs
@@ -0,0 +1,129 @@
+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_login(&mut self, url: &Url) -> eyre::Result<&mut 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_mut(&domain)
+ .ok_or_else(|| eyre!("not signed in to {domain}"))?;
+ Ok(login_info)
+ }
+
+ pub async fn get_api(&mut self, url: &Url) -> eyre::Result<forgejo_api::Forgejo> {
+ self.get_login(url)?.api_for(url).await.map_err(Into::into)
+ }
+}
+
+#[derive(serde::Serialize, serde::Deserialize, Clone)]
+#[serde(tag = "type")]
+pub enum LoginInfo {
+ Application {
+ name: String,
+ token: String,
+ },
+ OAuth {
+ name: String,
+ token: String,
+ refresh_token: String,
+ expires_at: time::OffsetDateTime,
+ },
+}
+
+impl LoginInfo {
+ pub fn username(&self) -> &str {
+ match self {
+ LoginInfo::Application { name, .. } => name,
+ LoginInfo::OAuth { name, .. } => name,
+ }
+ }
+
+ pub async fn api_for(&mut self, url: &Url) -> eyre::Result<forgejo_api::Forgejo> {
+ match self {
+ LoginInfo::Application { token, .. } => {
+ let api = forgejo_api::Forgejo::new(forgejo_api::Auth::Token(token), url.clone())?;
+ Ok(api)
+ }
+ LoginInfo::OAuth {
+ token,
+ refresh_token,
+ expires_at,
+ ..
+ } => {
+ if time::OffsetDateTime::now_utc() >= *expires_at {
+ let api = forgejo_api::Forgejo::new(forgejo_api::Auth::None, url.clone())?;
+ let (client_id, client_secret) = crate::auth::get_client_info_for(url)
+ .ok_or_else(|| {
+ eyre::eyre!("Can't refresh token; no client info for {url}. How did this happen?")
+ })?;
+ let response = api
+ .oauth_get_access_token(forgejo_api::structs::OAuthTokenRequest::Refresh {
+ refresh_token,
+ client_id,
+ client_secret,
+ })
+ .await?;
+ *token = response.access_token;
+ *refresh_token = response.refresh_token;
+ // A minute less, in case any weirdness happens at the exact moment it
+ // expires. Better to refresh slightly too soon than slightly too late.
+ let expires_in = std::time::Duration::from_secs(
+ response.expires_in.saturating_sub(60) as u64,
+ );
+ *expires_at = time::OffsetDateTime::now_utc() + expires_in;
+ }
+ let api = forgejo_api::Forgejo::new(forgejo_api::Auth::Token(token), url.clone())?;
+ Ok(api)
+ }
+ }
+ }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..c26081f
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,800 @@
+use std::io::IsTerminal;
+
+use clap::{Parser, Subcommand};
+use eyre::{eyre, Context, OptionExt};
+use tokio::io::AsyncWriteExt;
+
+mod keys;
+use keys::*;
+
+mod auth;
+mod issues;
+mod prs;
+mod release;
+mod repo;
+mod user;
+mod wiki;
+
+#[derive(Parser, Debug)]
+pub struct App {
+ #[clap(long, short = 'H')]
+ host: Option<String>,
+ #[clap(long)]
+ style: Option<Style>,
+ #[clap(subcommand)]
+ command: Command,
+}
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum Command {
+ #[clap(subcommand)]
+ Repo(repo::RepoCommand),
+ Issue(issues::IssueCommand),
+ Pr(prs::PrCommand),
+ Wiki(wiki::WikiCommand),
+ #[command(name = "whoami")]
+ WhoAmI {
+ #[clap(long, short)]
+ remote: Option<String>,
+ },
+ #[clap(subcommand)]
+ Auth(auth::AuthCommand),
+ Release(release::ReleaseCommand),
+ User(user::UserCommand),
+ Version {
+ /// Checks for updates
+ #[clap(long)]
+ #[cfg(feature = "update-check")]
+ check: bool,
+ },
+}
+
+#[tokio::main]
+async fn main() -> eyre::Result<()> {
+ let args = App::parse();
+
+ let _ = SPECIAL_RENDER.set(SpecialRender::new(args.style.unwrap_or_default()));
+
+ let mut keys = KeyInfo::load().await?;
+
+ let host_name = args.host.as_deref();
+ // let remote = repo::RepoInfo::get_current(host_name, remote_name)?;
+ match args.command {
+ Command::Repo(subcommand) => subcommand.run(&mut keys, host_name).await?,
+ Command::Issue(subcommand) => subcommand.run(&mut keys, host_name).await?,
+ Command::Pr(subcommand) => subcommand.run(&mut keys, host_name).await?,
+ Command::Wiki(subcommand) => subcommand.run(&mut keys, host_name).await?,
+ Command::WhoAmI { remote } => {
+ let url = repo::RepoInfo::get_current(host_name, None, remote.as_deref())
+ .wrap_err("could not find host, try specifying with --host")?
+ .host_url()
+ .clone();
+ let name = keys.get_login(&url)?.username();
+ let host = url
+ .host_str()
+ .ok_or_eyre("instance url does not have host")?;
+ if url.path() == "/" || url.path().is_empty() {
+ println!("currently signed in to {name}@{host}");
+ } else {
+ println!("currently signed in to {name}@{host}{}", url.path());
+ }
+ }
+ Command::Auth(subcommand) => subcommand.run(&mut keys, host_name).await?,
+ Command::Release(subcommand) => subcommand.run(&mut keys, host_name).await?,
+ Command::User(subcommand) => subcommand.run(&mut keys, host_name).await?,
+ Command::Version {
+ #[cfg(feature = "update-check")]
+ check,
+ } => {
+ println!("{}", env!("CARGO_PKG_VERSION"));
+ #[cfg(feature = "update-check")]
+ update_msg(check).await?;
+ }
+ }
+
+ keys.save().await?;
+ Ok(())
+}
+
+#[cfg(feature = "update-check")]
+async fn update_msg(check: bool) -> eyre::Result<()> {
+ use std::cmp::Ordering;
+
+ if check {
+ let url = url::Url::parse("https://codeberg.org/")?;
+ let api = forgejo_api::Forgejo::new(forgejo_api::Auth::None, url)?;
+
+ let latest = api
+ .repo_get_latest_release("Cyborus", "forgejo-cli")
+ .await?;
+ let latest_tag = latest
+ .tag_name
+ .ok_or_eyre("latest release does not have name")?;
+ let latest_ver = latest_tag
+ .strip_prefix("v")
+ .unwrap_or(&latest_tag)
+ .parse::<semver::Version>()?;
+
+ let current_ver = env!("CARGO_PKG_VERSION").parse::<semver::Version>()?;
+
+ match current_ver.cmp(&latest_ver) {
+ Ordering::Less => {
+ let latest_url = latest
+ .html_url
+ .ok_or_eyre("latest release does not have url")?;
+ println!("New version available: {latest_ver}");
+ println!("Get it at {}", latest_url);
+ }
+ Ordering::Equal => {
+ println!("Up to date!");
+ }
+ Ordering::Greater => {
+ println!("You are ahead of the latest published version");
+ }
+ }
+ } else {
+ println!("Check for a new version with `fj version --check`");
+ }
+ Ok(())
+}
+
+async fn readline(msg: &str) -> eyre::Result<String> {
+ use std::io::Write;
+ print!("{msg}");
+ std::io::stdout().flush()?;
+ tokio::task::spawn_blocking(|| {
+ let mut input = String::new();
+ std::io::stdin().read_line(&mut input)?;
+ Ok(input)
+ })
+ .await?
+}
+
+async fn editor(contents: &mut String, ext: Option<&str>) -> eyre::Result<()> {
+ let editor = std::path::PathBuf::from(
+ std::env::var_os("EDITOR").ok_or_else(|| eyre!("unable to locate editor"))?,
+ );
+
+ let (mut file, path) = tempfile(ext).await?;
+ file.write_all(contents.as_bytes()).await?;
+ drop(file);
+
+ // Closure acting as a try/catch block so that the temp file is deleted even
+ // on errors
+ let res = (|| async {
+ eprint!("waiting on editor\r");
+ let flags = get_editor_flags(&editor);
+ let status = tokio::process::Command::new(editor)
+ .args(flags)
+ .arg(&path)
+ .status()
+ .await?;
+ if !status.success() {
+ eyre::bail!("editor exited unsuccessfully");
+ }
+
+ *contents = tokio::fs::read_to_string(&path).await?;
+ eprint!(" \r");
+
+ Ok(())
+ })()
+ .await;
+
+ tokio::fs::remove_file(path).await?;
+ res?;
+ Ok(())
+}
+
+fn get_editor_flags(editor_path: &std::path::Path) -> &'static [&'static str] {
+ let editor_name = match editor_path.file_stem().and_then(|s| s.to_str()) {
+ Some(name) => name,
+ None => return &[],
+ };
+ if editor_name == "code" {
+ return &["--wait"];
+ }
+ &[]
+}
+
+async fn tempfile(ext: Option<&str>) -> tokio::io::Result<(tokio::fs::File, std::path::PathBuf)> {
+ let filename = uuid::Uuid::new_v4();
+ let mut path = std::env::temp_dir().join(filename.to_string());
+ if let Some(ext) = ext {
+ path.set_extension(ext);
+ }
+ let file = tokio::fs::OpenOptions::new()
+ .create(true)
+ .read(true)
+ .write(true)
+ .open(&path)
+ .await?;
+ Ok((file, path))
+}
+
+use std::sync::OnceLock;
+static SPECIAL_RENDER: OnceLock<SpecialRender> = OnceLock::new();
+
+fn special_render() -> &'static SpecialRender {
+ SPECIAL_RENDER
+ .get()
+ .expect("attempted to get special characters before that was initialized")
+}
+
+#[derive(clap::ValueEnum, Clone, Copy, Debug, Default)]
+enum Style {
+ /// Use special characters, and colors.
+ #[default]
+ Fancy,
+ /// No special characters and no colors. Always used in non-terminal contexts (i.e. pipes)
+ Minimal,
+}
+
+struct SpecialRender {
+ fancy: bool,
+
+ dash: char,
+ bullet: char,
+ body_prefix: char,
+ horiz_rule: char,
+
+ // Uncomment these as needed
+ // red: &'static str,
+ bright_red: &'static str,
+ // green: &'static str,
+ bright_green: &'static str,
+ // blue: &'static str,
+ bright_blue: &'static str,
+ // cyan: &'static str,
+ bright_cyan: &'static str,
+ yellow: &'static str,
+ // bright_yellow: &'static str,
+ // magenta: &'static str,
+ bright_magenta: &'static str,
+ black: &'static str,
+ dark_grey: &'static str,
+ light_grey: &'static str,
+ white: &'static str,
+ no_fg: &'static str,
+ reset: &'static str,
+
+ dark_grey_bg: &'static str,
+ // no_bg: &'static str,
+ hide_cursor: &'static str,
+ show_cursor: &'static str,
+ clear_line: &'static str,
+
+ italic: &'static str,
+ bold: &'static str,
+ strike: &'static str,
+ no_italic_bold: &'static str,
+ no_strike: &'static str,
+}
+
+impl SpecialRender {
+ fn new(display: Style) -> Self {
+ let is_tty = std::io::stdout().is_terminal();
+ match display {
+ _ if !is_tty => Self::minimal(),
+ Style::Fancy => Self::fancy(),
+ Style::Minimal => Self::minimal(),
+ }
+ }
+
+ fn fancy() -> Self {
+ Self {
+ fancy: true,
+
+ dash: '—',
+ bullet: '•',
+ body_prefix: '▌',
+ horiz_rule: '─',
+
+ // red: "\x1b[31m",
+ bright_red: "\x1b[91m",
+ // green: "\x1b[32m",
+ bright_green: "\x1b[92m",
+ // blue: "\x1b[34m",
+ bright_blue: "\x1b[94m",
+ // cyan: "\x1b[36m",
+ bright_cyan: "\x1b[96m",
+ yellow: "\x1b[33m",
+ // bright_yellow: "\x1b[93m",
+ // magenta: "\x1b[35m",
+ bright_magenta: "\x1b[95m",
+ black: "\x1b[30m",
+ dark_grey: "\x1b[90m",
+ light_grey: "\x1b[37m",
+ white: "\x1b[97m",
+ no_fg: "\x1b[39m",
+ reset: "\x1b[0m",
+
+ dark_grey_bg: "\x1b[100m",
+ // no_bg: "\x1b[49",
+ hide_cursor: "\x1b[?25l",
+ show_cursor: "\x1b[?25h",
+ clear_line: "\x1b[2K",
+
+ italic: "\x1b[3m",
+ bold: "\x1b[1m",
+ strike: "\x1b[9m",
+ no_italic_bold: "\x1b[23m",
+ no_strike: "\x1b[29m",
+ }
+ }
+
+ fn minimal() -> Self {
+ Self {
+ fancy: false,
+
+ dash: '-',
+ bullet: '-',
+ body_prefix: '>',
+ horiz_rule: '-',
+
+ // red: "",
+ bright_red: "",
+ // green: "",
+ bright_green: "",
+ // blue: "",
+ bright_blue: "",
+ // cyan: "",
+ bright_cyan: "",
+ yellow: "",
+ // bright_yellow: "",
+ // magenta: "",
+ bright_magenta: "",
+ black: "",
+ dark_grey: "",
+ light_grey: "",
+ white: "",
+ no_fg: "",
+ reset: "",
+
+ dark_grey_bg: "",
+ // no_bg: "",
+ hide_cursor: "",
+ show_cursor: "",
+ clear_line: "",
+
+ italic: "",
+ bold: "",
+ strike: "~~",
+ no_italic_bold: "",
+ no_strike: "~~",
+ }
+ }
+}
+
+fn markdown(text: &str) -> String {
+ let SpecialRender {
+ fancy,
+
+ bullet,
+ horiz_rule,
+ bright_blue,
+ dark_grey_bg,
+ body_prefix,
+ ..
+ } = *special_render();
+
+ if !fancy {
+ let mut out = String::new();
+ for line in text.lines() {
+ use std::fmt::Write;
+ let _ = writeln!(&mut out, "{body_prefix} {line}");
+ }
+ return out;
+ }
+
+ let arena = comrak::Arena::new();
+ let mut options = comrak::Options::default();
+ options.extension.strikethrough = true;
+ let root = comrak::parse_document(&arena, text, &options);
+
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
+ enum Side {
+ Start,
+ End,
+ }
+
+ let mut explore_stack = Vec::new();
+ let mut render_queue = Vec::new();
+
+ explore_stack.extend(root.reverse_children().map(|x| (x, Side::Start)));
+ while let Some((node, side)) = explore_stack.pop() {
+ if side == Side::Start {
+ explore_stack.push((node, Side::End));
+ explore_stack.extend(node.reverse_children().map(|x| (x, Side::Start)));
+ }
+ render_queue.push((node, side));
+ }
+
+ let mut list_numbers = Vec::new();
+
+ let (terminal_width, _) = crossterm::terminal::size().unwrap_or((80, 24));
+ let max_line_len = (terminal_width as usize - 2).min(80);
+
+ let mut links = Vec::new();
+
+ let mut ansi_printer = AnsiPrinter::new(max_line_len);
+ ansi_printer.pause_style();
+ ansi_printer.prefix();
+ ansi_printer.resume_style();
+ let mut iter = render_queue.into_iter().peekable();
+ while let Some((item, side)) = iter.next() {
+ use comrak::nodes::NodeValue;
+ use Side::*;
+ match (&item.data.borrow().value, side) {
+ (NodeValue::Paragraph, Start) => (),
+ (NodeValue::Paragraph, End) => {
+ if iter.peek().is_some_and(|(_, side)| *side == Start) {
+ ansi_printer.newline();
+ ansi_printer.newline();
+ }
+ }
+ (NodeValue::Text(s), Start) => ansi_printer.text(s),
+ (NodeValue::Link(_), Start) => {
+ ansi_printer.start_fg(bright_blue);
+ }
+ (NodeValue::Link(link), End) => {
+ use std::fmt::Write;
+ ansi_printer.stop_fg();
+ links.push(link.url.clone());
+ let _ = write!(&mut ansi_printer, "({})", links.len());
+ }
+ (NodeValue::Image(_), Start) => {
+ ansi_printer.start_fg(bright_blue);
+ }
+ (NodeValue::Image(link), End) => {
+ use std::fmt::Write;
+ ansi_printer.stop_fg();
+ links.push(link.url.clone());
+ let _ = write!(&mut ansi_printer, "({})", links.len());
+ }
+ (NodeValue::Code(code), Start) => {
+ ansi_printer.pause_style();
+ ansi_printer.start_bg(dark_grey_bg);
+ ansi_printer.text(&code.literal);
+ ansi_printer.resume_style();
+ }
+ (NodeValue::CodeBlock(code), Start) => {
+ if ansi_printer.cur_line_len != 0 {
+ ansi_printer.newline();
+ }
+ ansi_printer.pause_style();
+ ansi_printer.start_bg(dark_grey_bg);
+ ansi_printer.text(&code.literal);
+ ansi_printer.newline();
+ ansi_printer.resume_style();
+ ansi_printer.newline();
+ }
+ (NodeValue::BlockQuote, Start) => {
+ ansi_printer.blockquote_depth += 1;
+ ansi_printer.pause_style();
+ ansi_printer.prefix();
+ ansi_printer.resume_style();
+ }
+ (NodeValue::BlockQuote, End) => {
+ ansi_printer.blockquote_depth -= 1;
+ ansi_printer.newline();
+ }
+ (NodeValue::HtmlInline(html), Start) => {
+ ansi_printer.pause_style();
+ ansi_printer.text(html);
+ ansi_printer.resume_style();
+ }
+ (NodeValue::HtmlBlock(html), Start) => {
+ if ansi_printer.cur_line_len != 0 {
+ ansi_printer.newline();
+ }
+ ansi_printer.pause_style();
+ ansi_printer.text(&html.literal);
+ ansi_printer.newline();
+ ansi_printer.resume_style();
+ }
+
+ (NodeValue::Heading(heading), Start) => {
+ ansi_printer.reset();
+ ansi_printer.start_bold();
+ ansi_printer
+ .out
+ .extend(std::iter::repeat('#').take(heading.level as usize));
+ ansi_printer.out.push(' ');
+ ansi_printer.cur_line_len += heading.level as usize + 1;
+ }
+ (NodeValue::Heading(_), End) => {
+ ansi_printer.reset();
+ ansi_printer.newline();
+ ansi_printer.newline();
+ }
+
+ (NodeValue::List(list), Start) => {
+ if list.list_type == comrak::nodes::ListType::Ordered {
+ list_numbers.push(0);
+ }
+ }
+ (NodeValue::List(list), End) => {
+ if list.list_type == comrak::nodes::ListType::Ordered {
+ list_numbers.pop();
+ }
+ ansi_printer.newline();
+ }
+ (NodeValue::Item(list), Start) => {
+ if list.list_type == comrak::nodes::ListType::Ordered {
+ use std::fmt::Write;
+ let number: usize = if let Some(number) = list_numbers.last_mut() {
+ *number += 1;
+ *number
+ } else {
+ 0
+ };
+ let _ = write!(&mut ansi_printer, "{number}. ");
+ } else {
+ ansi_printer.out.push(bullet);
+ ansi_printer.out.push(' ');
+ ansi_printer.cur_line_len += 2;
+ }
+ }
+ (NodeValue::Item(_), End) => {
+ ansi_printer.newline();
+ }
+
+ (NodeValue::LineBreak, Start) => ansi_printer.newline(),
+ (NodeValue::SoftBreak, Start) => ansi_printer.newline(),
+ (NodeValue::ThematicBreak, Start) => {
+ if ansi_printer.cur_line_len != 0 {
+ ansi_printer.newline();
+ }
+ ansi_printer
+ .out
+ .extend(std::iter::repeat(horiz_rule).take(max_line_len));
+ ansi_printer.newline();
+ ansi_printer.newline();
+ }
+
+ (NodeValue::Emph, Start) => ansi_printer.start_italic(),
+ (NodeValue::Emph, End) => ansi_printer.stop_italic(),
+ (NodeValue::Strong, Start) => ansi_printer.start_bold(),
+ (NodeValue::Strong, End) => ansi_printer.stop_bold(),
+ (NodeValue::Strikethrough, Start) => ansi_printer.start_strike(),
+ (NodeValue::Strikethrough, End) => ansi_printer.stop_strike(),
+
+ (NodeValue::Escaped, Start) => (),
+ (_, End) => (),
+ (_, Start) => ansi_printer.text("?TODO?"),
+ }
+ }
+ if !links.is_empty() {
+ ansi_printer.out.push('\n');
+ for (i, url) in links.into_iter().enumerate() {
+ use std::fmt::Write;
+ let _ = writeln!(&mut ansi_printer.out, "({}. {url} )", i + 1);
+ }
+ }
+
+ ansi_printer.out
+}
+
+#[derive(Default)]
+struct RenderStyling {
+ bold: bool,
+ italic: bool,
+ strike: bool,
+
+ fg: Option<&'static str>,
+ bg: Option<&'static str>,
+}
+
+struct AnsiPrinter {
+ special_render: &'static SpecialRender,
+
+ out: String,
+
+ cur_line_len: usize,
+ max_line_len: usize,
+
+ blockquote_depth: usize,
+
+ style_frames: Vec<RenderStyling>,
+}
+
+impl AnsiPrinter {
+ fn new(max_line_len: usize) -> Self {
+ Self {
+ special_render: special_render(),
+
+ out: String::new(),
+
+ cur_line_len: 0,
+ max_line_len,
+
+ blockquote_depth: 0,
+
+ style_frames: vec![RenderStyling::default()],
+ }
+ }
+
+ fn text(&mut self, text: &str) {
+ let mut iter = text.lines().peekable();
+ while let Some(mut line) = iter.next() {
+ loop {
+ let this_len = line.chars().count();
+ if self.cur_line_len + this_len > self.max_line_len {
+ let mut split_at = self.max_line_len - self.cur_line_len;
+ loop {
+ if line.is_char_boundary(split_at) {
+ break;
+ }
+ split_at -= 1;
+ }
+ let split_at = line
+ .split_at(split_at)
+ .0
+ .char_indices()
+ .rev()
+ .find(|(_, c)| c.is_whitespace())
+ .map(|(i, _)| i)
+ .unwrap_or(split_at);
+ let (head, tail) = line.split_at(split_at);
+ self.out.push_str(head);
+ self.cur_line_len += split_at;
+ self.newline();
+ line = tail.trim_start();
+ } else {
+ self.out.push_str(line);
+ self.cur_line_len += this_len;
+ break;
+ }
+ }
+ if iter.peek().is_some() {
+ self.newline();
+ }
+ }
+ }
+
+ // Uncomment if needed
+ // fn current_fg(&self) -> Option<&'static str> {
+ // self.current_style().fg
+ // }
+
+ fn start_fg(&mut self, color: &'static str) {
+ self.current_style_mut().fg = Some(color);
+ self.out.push_str(color);
+ }
+
+ fn stop_fg(&mut self) {
+ self.current_style_mut().fg = None;
+ self.out.push_str(self.special_render.no_fg);
+ }
+
+ fn current_bg(&self) -> Option<&'static str> {
+ self.current_style().bg
+ }
+
+ fn start_bg(&mut self, color: &'static str) {
+ self.current_style_mut().bg = Some(color);
+ self.out.push_str(color);
+ }
+
+ // Uncomment if needed
+ // fn stop_bg(&mut self) {
+ // self.current_style_mut().bg = None;
+ // self.out.push_str(self.special_render.no_bg);
+ // }
+
+ fn is_bold(&self) -> bool {
+ self.current_style().bold
+ }
+
+ fn start_bold(&mut self) {
+ self.current_style_mut().bold = true;
+ self.out.push_str(self.special_render.bold);
+ }
+
+ fn stop_bold(&mut self) {
+ self.current_style_mut().bold = false;
+ self.out.push_str(self.special_render.reset);
+ if self.is_italic() {
+ self.out.push_str(self.special_render.italic);
+ }
+ if self.is_strike() {
+ self.out.push_str(self.special_render.strike);
+ }
+ }
+
+ fn is_italic(&self) -> bool {
+ self.current_style().italic
+ }
+
+ fn start_italic(&mut self) {
+ self.current_style_mut().italic = true;
+ self.out.push_str(self.special_render.italic);
+ }
+
+ fn stop_italic(&mut self) {
+ self.current_style_mut().italic = false;
+ self.out.push_str(self.special_render.no_italic_bold);
+ if self.is_bold() {
+ self.out.push_str(self.special_render.bold);
+ }
+ }
+
+ fn is_strike(&self) -> bool {
+ self.current_style().strike
+ }
+
+ fn start_strike(&mut self) {
+ self.current_style_mut().strike = true;
+ self.out.push_str(self.special_render.strike);
+ }
+
+ fn stop_strike(&mut self) {
+ self.current_style_mut().strike = false;
+ self.out.push_str(self.special_render.no_strike);
+ }
+
+ fn reset(&mut self) {
+ *self.current_style_mut() = RenderStyling::default();
+ self.out.push_str(self.special_render.reset);
+ }
+
+ fn pause_style(&mut self) {
+ self.out.push_str(self.special_render.reset);
+ self.style_frames.push(RenderStyling::default());
+ }
+
+ fn resume_style(&mut self) {
+ self.out.push_str(self.special_render.reset);
+ self.style_frames.pop();
+ if let Some(bg) = self.current_bg() {
+ self.out.push_str(bg);
+ }
+ if self.is_bold() {
+ self.out.push_str(self.special_render.bold);
+ }
+ if self.is_italic() {
+ self.out.push_str(self.special_render.italic);
+ }
+ if self.is_strike() {
+ self.out.push_str(self.special_render.strike);
+ }
+ }
+
+ fn newline(&mut self) {
+ if self.current_bg().is_some() {
+ self.out
+ .extend(std::iter::repeat(' ').take(self.max_line_len - self.cur_line_len));
+ }
+ self.pause_style();
+ self.out.push('\n');
+ self.prefix();
+ for _ in 0..self.blockquote_depth {
+ self.prefix();
+ }
+ self.resume_style();
+ self.cur_line_len = self.blockquote_depth * 2;
+ }
+
+ fn prefix(&mut self) {
+ self.out.push_str(self.special_render.dark_grey);
+ self.out.push(self.special_render.body_prefix);
+ self.out.push(' ');
+ }
+
+ fn current_style(&self) -> &RenderStyling {
+ self.style_frames.last().expect("Ran out of style frames")
+ }
+
+ fn current_style_mut(&mut self) -> &mut RenderStyling {
+ self.style_frames
+ .last_mut()
+ .expect("Ran out of style frames")
+ }
+}
+
+impl std::fmt::Write for AnsiPrinter {
+ fn write_str(&mut self, s: &str) -> std::fmt::Result {
+ self.text(s);
+ Ok(())
+ }
+}
diff --git a/src/prs.rs b/src/prs.rs
new file mode 100644
index 0000000..840ae12
--- /dev/null
+++ b/src/prs.rs
@@ -0,0 +1,1564 @@
+use std::str::FromStr;
+
+use clap::{Args, Subcommand};
+use eyre::{Context, OptionExt};
+use forgejo_api::{
+ structs::{
+ CreatePullRequestOption, MergePullRequestOption, RepoGetPullRequestCommitsQuery,
+ RepoGetPullRequestFilesQuery, StateType,
+ },
+ Forgejo,
+};
+
+use crate::{
+ issues::IssueId,
+ repo::{RepoArg, RepoInfo, RepoName},
+ SpecialRender,
+};
+
+#[derive(Args, Clone, Debug)]
+pub struct PrCommand {
+ /// The local git remote that points to the repo to operate on.
+ #[clap(long, short = 'R')]
+ remote: Option<String>,
+ #[clap(subcommand)]
+ command: PrSubcommand,
+}
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum PrSubcommand {
+ /// Search a repository's pull requests
+ Search {
+ query: Option<String>,
+ #[clap(long, short)]
+ labels: Option<String>,
+ #[clap(long, short)]
+ creator: Option<String>,
+ #[clap(long, short)]
+ assignee: Option<String>,
+ #[clap(long, short)]
+ state: Option<crate::issues::State>,
+ /// The repo to search in
+ #[clap(long, short)]
+ repo: Option<RepoArg>,
+ },
+ /// Create a new pull request
+ Create {
+ /// The branch to merge onto.
+ #[clap(long)]
+ base: Option<String>,
+ /// The branch to pull changes from.
+ #[clap(long)]
+ head: Option<String>,
+ /// What to name the new pull request.
+ ///
+ /// Prefix with "WIP: " to mark this PR as a draft.
+ title: String,
+ /// The text body of the pull request.
+ ///
+ /// Leaving this out will open your editor.
+ #[clap(long)]
+ body: Option<String>,
+ /// The repo to create this issue on
+ #[clap(long, short, id = "[HOST/]OWNER/REPO")]
+ repo: Option<RepoArg>,
+ },
+ /// View the contents of a pull request
+ View {
+ /// The pull request to view.
+ #[clap(id = "[REPO#]ID")]
+ id: Option<IssueId>,
+ #[clap(subcommand)]
+ command: Option<ViewCommand>,
+ },
+ /// View the mergability and CI status of a pull request
+ Status {
+ /// The pull request to view.
+ #[clap(id = "[REPO#]ID")]
+ id: Option<IssueId>,
+ },
+ /// Checkout a pull request in a new branch
+ Checkout {
+ /// The pull request to check out.
+ ///
+ /// Prefix with ^ to get a pull request from the parent repo.
+ #[clap(id = "ID")]
+ pr: PrNumber,
+ /// The name to give the newly created branch.
+ ///
+ /// Defaults to naming after the host url, repo owner, and PR number.
+ #[clap(long, id = "NAME")]
+ branch_name: Option<String>,
+ },
+ /// Add a comment on a pull request
+ Comment {
+ /// The pull request to comment on.
+ #[clap(id = "[REPO#]ID")]
+ pr: Option<IssueId>,
+ /// The text content of the comment.
+ ///
+ /// Not including this in the command will open your editor.
+ body: Option<String>,
+ },
+ /// Edit the contents of a pull request
+ Edit {
+ /// The pull request to edit.
+ #[clap(id = "[REPO#]ID")]
+ pr: Option<IssueId>,
+ #[clap(subcommand)]
+ command: EditCommand,
+ },
+ /// Close a pull request, without merging.
+ Close {
+ /// The pull request to close.
+ #[clap(id = "[REPO#]ID")]
+ pr: Option<IssueId>,
+ /// A comment to add before closing.
+ ///
+ /// Adding without an argument will open your editor
+ #[clap(long, short)]
+ with_msg: Option<Option<String>>,
+ },
+ /// Merge a pull request
+ Merge {
+ /// The pull request to merge.
+ #[clap(id = "[REPO#]ID")]
+ pr: Option<IssueId>,
+ /// The merge style to use.
+ #[clap(long, short = 'M')]
+ method: Option<MergeMethod>,
+ /// Option to delete the corresponding branch afterwards.
+ #[clap(long, short)]
+ delete: bool,
+ /// The title of the merge or squash commit to be created
+ #[clap(long, short)]
+ title: Option<String>,
+ /// The body of the merge or squash commit to be created
+ #[clap(long, short)]
+ message: Option<Option<String>>,
+ },
+ /// Open a pull request in your browser
+ Browse {
+ /// The pull request to open in your browser.
+ #[clap(id = "[REPO#]ID")]
+ id: Option<IssueId>,
+ },
+}
+
+#[derive(clap::ValueEnum, Clone, Copy, Debug)]
+pub enum MergeMethod {
+ Merge,
+ Rebase,
+ RebaseMerge,
+ Squash,
+ Manual,
+}
+
+#[derive(Clone, Copy, Debug)]
+pub enum PrNumber {
+ This(u64),
+ Parent(u64),
+}
+
+impl PrNumber {
+ fn number(self) -> u64 {
+ match self {
+ PrNumber::This(x) => x,
+ PrNumber::Parent(x) => x,
+ }
+ }
+}
+
+impl FromStr for PrNumber {
+ type Err = std::num::ParseIntError;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ if let Some(num) = s.strip_prefix("^") {
+ Ok(Self::Parent(num.parse()?))
+ } else {
+ Ok(Self::This(s.parse()?))
+ }
+ }
+}
+
+impl From<MergeMethod> for forgejo_api::structs::MergePullRequestOptionDo {
+ fn from(value: MergeMethod) -> Self {
+ use forgejo_api::structs::MergePullRequestOptionDo::*;
+ match value {
+ MergeMethod::Merge => Merge,
+ MergeMethod::Rebase => Rebase,
+ MergeMethod::RebaseMerge => RebaseMerge,
+ MergeMethod::Squash => Squash,
+ MergeMethod::Manual => ManuallyMerged,
+ }
+ }
+}
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum EditCommand {
+ /// Edit the title
+ Title {
+ /// New PR title.
+ ///
+ /// Leaving this out will open the current title in your editor.
+ new_title: Option<String>,
+ },
+ /// Edit the text body
+ Body {
+ /// New PR body.
+ ///
+ /// Leaving this out will open the current body in your editor.
+ new_body: Option<String>,
+ },
+ /// Edit a comment
+ Comment {
+ /// The index of the comment to edit, 0-indexed.
+ idx: usize,
+ /// New comment body.
+ ///
+ /// Leaving this out will open the current body in your editor.
+ new_body: Option<String>,
+ },
+ Labels {
+ /// The labels to add.
+ #[clap(long, short)]
+ add: Vec<String>,
+ /// The labels to remove.
+ #[clap(long, short)]
+ rm: Vec<String>,
+ },
+}
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum ViewCommand {
+ /// View the title and body of a pull request.
+ Body,
+ /// View a comment on a pull request.
+ Comment {
+ /// The index of the comment to view, 0-indexed.
+ idx: usize,
+ },
+ /// View all comments on a pull request.
+ Comments,
+ /// View the labels applied to a pull request.
+ Labels,
+ /// View the diff between the base and head branches of a pull request.
+ Diff {
+ /// Get the diff in patch format
+ #[clap(long, short)]
+ patch: bool,
+ /// View the diff in your text editor
+ #[clap(long, short)]
+ editor: bool,
+ },
+ /// View the files changed in a pull request.
+ Files,
+ /// View the commits in a pull request.
+ Commits {
+ /// View one commit per line
+ #[clap(long, short)]
+ oneline: bool,
+ },
+}
+
+impl PrCommand {
+ pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> {
+ use PrSubcommand::*;
+ let repo = RepoInfo::get_current(host_name, self.repo(), self.remote.as_deref())?;
+ let api = keys.get_api(repo.host_url()).await?;
+ let repo = repo.name().ok_or_else(|| self.no_repo_error())?;
+ match self.command {
+ Create {
+ title,
+ base,
+ head,
+ body,
+ repo: _,
+ } => create_pr(&repo, &api, title, base, head, body).await?,
+ Merge {
+ pr,
+ method,
+ delete,
+ title,
+ message,
+ } => {
+ merge_pr(
+ &repo,
+ &api,
+ pr.map(|id| id.number),
+ method,
+ delete,
+ title,
+ message,
+ )
+ .await?
+ }
+ View { id, command } => {
+ let id = id.map(|id| id.number);
+ match command.unwrap_or(ViewCommand::Body) {
+ ViewCommand::Body => view_pr(&repo, &api, id).await?,
+ ViewCommand::Comment { idx } => {
+ let (repo, id) = try_get_pr_number(&repo, &api, id).await?;
+ crate::issues::view_comment(&repo, &api, id, idx).await?
+ }
+ ViewCommand::Comments => {
+ let (repo, id) = try_get_pr_number(&repo, &api, id).await?;
+ crate::issues::view_comments(&repo, &api, id).await?
+ }
+ ViewCommand::Labels => view_pr_labels(&repo, &api, id).await?,
+ ViewCommand::Diff { patch, editor } => {
+ view_diff(&repo, &api, id, patch, editor).await?
+ }
+ ViewCommand::Files => view_pr_files(&repo, &api, id).await?,
+ ViewCommand::Commits { oneline } => {
+ view_pr_commits(&repo, &api, id, oneline).await?
+ }
+ }
+ }
+ Status { id } => view_pr_status(&repo, &api, id.map(|id| id.number)).await?,
+ Search {
+ query,
+ labels,
+ creator,
+ assignee,
+ state,
+ repo: _,
+ } => view_prs(&repo, &api, query, labels, creator, assignee, state).await?,
+ Edit { pr, command } => {
+ let pr = pr.map(|pr| pr.number);
+ match command {
+ EditCommand::Title { new_title } => {
+ let (repo, id) = try_get_pr_number(&repo, &api, pr).await?;
+ crate::issues::edit_title(&repo, &api, id, new_title).await?
+ }
+ EditCommand::Body { new_body } => {
+ let (repo, id) = try_get_pr_number(&repo, &api, pr).await?;
+ crate::issues::edit_body(&repo, &api, id, new_body).await?
+ }
+ EditCommand::Comment { idx, new_body } => {
+ let (repo, id) = try_get_pr_number(&repo, &api, pr).await?;
+ crate::issues::edit_comment(&repo, &api, id, idx, new_body).await?
+ }
+ EditCommand::Labels { add, rm } => {
+ edit_pr_labels(&repo, &api, pr, add, rm).await?
+ }
+ }
+ }
+ Close { pr, with_msg } => {
+ let (repo, pr) = try_get_pr_number(&repo, &api, pr.map(|pr| pr.number)).await?;
+ crate::issues::close_issue(&repo, &api, pr, with_msg).await?
+ }
+ Checkout { pr, branch_name } => checkout_pr(&repo, &api, pr, branch_name).await?,
+ Browse { id } => {
+ let (repo, id) = try_get_pr_number(&repo, &api, id.map(|pr| pr.number)).await?;
+ browse_pr(&repo, &api, id).await?
+ }
+ Comment { pr, body } => {
+ let (repo, pr) = try_get_pr_number(&repo, &api, pr.map(|pr| pr.number)).await?;
+ crate::issues::add_comment(&repo, &api, pr, body).await?
+ }
+ }
+ Ok(())
+ }
+
+ fn repo(&self) -> Option<&RepoArg> {
+ use PrSubcommand::*;
+ match &self.command {
+ Search { repo, .. } | Create { repo, .. } => repo.as_ref(),
+ Checkout { .. } => None,
+ View { id: pr, .. }
+ | Status { id: pr, .. }
+ | Comment { pr, .. }
+ | Edit { pr, .. }
+ | Close { pr, .. }
+ | Merge { pr, .. }
+ | Browse { id: pr } => pr.as_ref().and_then(|x| x.repo.as_ref()),
+ }
+ }
+
+ fn no_repo_error(&self) -> eyre::Error {
+ use PrSubcommand::*;
+ match &self.command {
+ Search { .. } | Create { .. } => {
+ eyre::eyre!("can't figure what repo to access, try specifying with `--repo`")
+ }
+ Checkout { .. } => {
+ if git2::Repository::open(".").is_ok() {
+ eyre::eyre!("can't figure out what repo to access, try setting a remote tracking branch")
+ } else {
+ eyre::eyre!("pr checkout only works if the current directory is a git repo")
+ }
+ }
+ View { id: pr, .. }
+ | Status { id: pr, .. }
+ | Comment { pr, .. }
+ | Edit { pr, .. }
+ | Close { pr, .. }
+ | Merge { pr, .. }
+ | Browse { id: pr, .. } => match pr {
+ Some(pr) => eyre::eyre!(
+ "can't figure out what repo to access, try specifying with `{{owner}}/{{repo}}#{}`",
+ pr.number
+ ),
+ None => eyre::eyre!(
+ "can't figure out what repo to access, try specifying with `{{owner}}/{{repo}}#{{pr}}`",
+ ),
+ },
+ }
+ }
+}
+
+pub async fn view_pr(repo: &RepoName, api: &Forgejo, id: Option<u64>) -> eyre::Result<()> {
+ let crate::SpecialRender {
+ dash,
+
+ bright_red,
+ bright_green,
+ bright_magenta,
+ yellow,
+ dark_grey,
+ light_grey,
+ white,
+ reset,
+ ..
+ } = crate::special_render();
+ let pr = try_get_pr(repo, api, id).await?;
+ let id = pr.number.ok_or_eyre("pr does not have number")? as u64;
+ let repo = repo_name_from_pr(&pr)?;
+
+ let mut additions = 0;
+ let mut deletions = 0;
+ let query = RepoGetPullRequestFilesQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ let (_, files) = api
+ .repo_get_pull_request_files(repo.owner(), repo.name(), id, query)
+ .await?;
+ for file in files {
+ additions += file.additions.unwrap_or_default();
+ deletions += file.deletions.unwrap_or_default();
+ }
+ let title = pr
+ .title
+ .as_deref()
+ .ok_or_else(|| eyre::eyre!("pr does not have title"))?;
+ let title_no_wip = title
+ .strip_prefix("WIP: ")
+ .or_else(|| title.strip_prefix("WIP:"));
+ let (title, is_draft) = match title_no_wip {
+ Some(title) => (title, true),
+ None => (title, false),
+ };
+ let state = pr
+ .state
+ .ok_or_else(|| eyre::eyre!("pr does not have state"))?;
+ let is_merged = pr.merged.unwrap_or_default();
+ let state = match state {
+ StateType::Open if is_draft => format!("{light_grey}Draft{reset}"),
+ StateType::Open => format!("{bright_green}Open{reset}"),
+ StateType::Closed if is_merged => format!("{bright_magenta}Merged{reset}"),
+ StateType::Closed => format!("{bright_red}Closed{reset}"),
+ };
+ let base = pr.base.as_ref().ok_or_eyre("pr does not have base")?;
+ let base_repo = base
+ .repo
+ .as_ref()
+ .ok_or_eyre("base does not have repo")?
+ .full_name
+ .as_deref()
+ .ok_or_eyre("base repo does not have name")?;
+ let base_name = base
+ .label
+ .as_deref()
+ .ok_or_eyre("base does not have label")?;
+ let head = pr.head.as_ref().ok_or_eyre("pr does not have head")?;
+ let head_repo = head
+ .repo
+ .as_ref()
+ .ok_or_eyre("head does not have repo")?
+ .full_name
+ .as_deref()
+ .ok_or_eyre("head repo does not have name")?;
+ let head_name = head
+ .label
+ .as_deref()
+ .ok_or_eyre("head does not have label")?;
+ let head_name = if base_repo != head_repo {
+ format!("{head_repo}:{head_name}")
+ } else {
+ head_name.to_owned()
+ };
+ let user = pr
+ .user
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("pr does not have creator"))?;
+ let username = user
+ .login
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("user does not have login"))?;
+ let comments = pr.comments.unwrap_or_default();
+ println!("{yellow}{title}{reset} {dark_grey}#{id}{reset}");
+ println!(
+ "By {white}{username}{reset} {dash} {state} {dash} {bright_green}+{additions} {bright_red}-{deletions}{reset}"
+ );
+ println!("From `{head_name}` into `{base_name}`");
+
+ if let Some(body) = &pr.body {
+ if !body.trim().is_empty() {
+ println!();
+ println!("{}", crate::markdown(body));
+ }
+ }
+ println!();
+ if comments == 1 {
+ println!("1 comment");
+ } else {
+ println!("{comments} comments");
+ }
+ Ok(())
+}
+
+async fn view_pr_labels(repo: &RepoName, api: &Forgejo, pr: Option<u64>) -> eyre::Result<()> {
+ let pr = try_get_pr(repo, api, pr).await?;
+ let labels = pr.labels.as_deref().unwrap_or_default();
+ let SpecialRender {
+ fancy,
+ black,
+ white,
+ reset,
+ ..
+ } = *crate::special_render();
+ if fancy {
+ let mut total_width = 0;
+ for label in labels {
+ let name = label.name.as_deref().unwrap_or("???").trim();
+ if total_width + name.len() > 40 {
+ println!();
+ total_width = 0;
+ }
+ let color_s = label.color.as_deref().unwrap_or("FFFFFF");
+ let (r, g, b) = parse_color(color_s)?;
+ let text_color = if luma(r, g, b) > 0.5 { black } else { white };
+ let rgb_bg = format!("\x1b[48;2;{r};{g};{b}m");
+ if label.exclusive.unwrap_or_default() {
+ let (r2, g2, b2) = darken(r, g, b);
+ let (category, name) = name
+ .split_once("/")
+ .ok_or_eyre("label is exclusive but does not have slash")?;
+ let rgb_bg_dark = format!("\x1b[48;2;{r2};{g2};{b2}m");
+ print!("{rgb_bg_dark}{text_color} {category} {rgb_bg} {name} {reset} ");
+ } else {
+ print!("{rgb_bg}{text_color} {name} {reset} ");
+ }
+ total_width += name.len();
+ }
+ println!();
+ } else {
+ for label in labels {
+ let name = label.name.as_deref().unwrap_or("???");
+ println!("{name}");
+ }
+ }
+ Ok(())
+}
+
+fn parse_color(color: &str) -> eyre::Result<(u8, u8, u8)> {
+ eyre::ensure!(color.len() == 6, "color string wrong length");
+ let mut iter = color.chars();
+ let mut next_digit = || {
+ iter.next()
+ .unwrap()
+ .to_digit(16)
+ .ok_or_eyre("invalid digit")
+ };
+ let r1 = next_digit()?;
+ let r2 = next_digit()?;
+ let g1 = next_digit()?;
+ let g2 = next_digit()?;
+ let b1 = next_digit()?;
+ let b2 = next_digit()?;
+ let r = ((r1 << 4) | (r2)) as u8;
+ let g = ((g1 << 4) | (g2)) as u8;
+ let b = ((b1 << 4) | (b2)) as u8;
+ Ok((r, g, b))
+}
+
+// Thanks, wikipedia.
+fn luma(r: u8, g: u8, b: u8) -> f32 {
+ ((0.299 * (r as f32)) + (0.578 * (g as f32)) + (0.114 * (b as f32))) / 255.0
+}
+
+fn darken(r: u8, g: u8, b: u8) -> (u8, u8, u8) {
+ (
+ ((r as f32) * 0.85) as u8,
+ ((g as f32) * 0.85) as u8,
+ ((b as f32) * 0.85) as u8,
+ )
+}
+
+async fn view_pr_status(repo: &RepoName, api: &Forgejo, id: Option<u64>) -> eyre::Result<()> {
+ let pr = try_get_pr(repo, api, id).await?;
+ let repo = repo_name_from_pr(&pr)?;
+
+ let SpecialRender {
+ bright_magenta,
+ bright_red,
+ bright_green,
+ yellow,
+ light_grey,
+ dash,
+ bullet,
+ reset,
+ ..
+ } = *crate::special_render();
+
+ if pr.merged.ok_or_eyre("pr merge status unknown")? {
+ let merged_by = pr.merged_by.ok_or_eyre("pr not merged by anyone")?;
+ let merged_by = merged_by
+ .login
+ .as_deref()
+ .ok_or_eyre("pr merger does not have login")?;
+ let merged_at = pr.merged_at.ok_or_eyre("pr does not have merge date")?;
+ let date_format = time::macros::format_description!(
+ "on [month repr:long] [day], [year], at [hour repr:12]:[minute] [period]"
+ );
+ let tz_format = time::macros::format_description!(
+ "[offset_hour padding:zero sign:mandatory]:[offset_minute]"
+ );
+ let (merged_at, show_tz) = if let Ok(local_offset) = time::UtcOffset::current_local_offset()
+ {
+ let merged_at = merged_at.to_offset(local_offset);
+ (merged_at, false)
+ } else {
+ (merged_at, true)
+ };
+ print!(
+ "{bright_magenta}Merged{reset} by {merged_by} {}",
+ merged_at.format(date_format)?
+ );
+ if show_tz {
+ print!("{}", merged_at.format(tz_format)?);
+ }
+ println!();
+ } else {
+ let pr_number = pr.number.ok_or_eyre("pr does not have number")? as u64;
+ let query = forgejo_api::structs::RepoGetPullRequestCommitsQuery {
+ page: None,
+ limit: Some(u32::MAX),
+ verification: Some(false),
+ files: Some(false),
+ };
+ let (_commit_headers, commits) = api
+ .repo_get_pull_request_commits(repo.owner(), repo.name(), pr_number, query)
+ .await?;
+ let latest_commit = commits
+ .iter()
+ .max_by_key(|x| x.created)
+ .ok_or_eyre("no commits in pr")?;
+ let sha = latest_commit
+ .sha
+ .as_deref()
+ .ok_or_eyre("commit does not have sha")?;
+ let query = forgejo_api::structs::RepoGetCombinedStatusByRefQuery {
+ page: None,
+ limit: Some(u32::MAX),
+ };
+ let combined_status = api
+ .repo_get_combined_status_by_ref(repo.owner(), repo.name(), sha, query)
+ .await?;
+
+ let state = pr.state.ok_or_eyre("pr does not have state")?;
+ let is_draft = pr.title.as_deref().is_some_and(|s| s.starts_with("WIP:"));
+ match state {
+ StateType::Open => {
+ if is_draft {
+ println!("{light_grey}Draft{reset} {dash} Can't merge draft PR")
+ } else {
+ print!("{bright_green}Open{reset} {dash} ");
+ let mergable = pr.mergeable.ok_or_eyre("pr does not have mergable")?;
+ if mergable {
+ println!("Can be merged");
+ } else {
+ println!("{bright_red}Merge conflicts{reset}");
+ }
+ }
+ }
+ StateType::Closed => println!("{bright_red}Closed{reset} {dash} Reopen to merge"),
+ }
+
+ let commit_statuses = combined_status
+ .statuses
+ .ok_or_eyre("combined status does not have status list")?;
+ for status in commit_statuses {
+ let state = status
+ .status
+ .as_deref()
+ .ok_or_eyre("status does not have status")?;
+ let context = status
+ .context
+ .as_deref()
+ .ok_or_eyre("status does not have context")?;
+ print!("{bullet} ");
+ match state {
+ "success" => print!("{bright_green}Success{reset}"),
+ "pending" => print!("{yellow}Pending{reset}"),
+ "failure" => print!("{bright_red}Failure{reset}"),
+ _ => eyre::bail!("invalid status"),
+ };
+ println!(" {dash} {context}");
+ }
+ }
+
+ Ok(())
+}
+
+async fn edit_pr_labels(
+ repo: &RepoName,
+ api: &Forgejo,
+ pr: Option<u64>,
+ add: Vec<String>,
+ rm: Vec<String>,
+) -> eyre::Result<()> {
+ let pr = try_get_pr(repo, api, pr).await?;
+ let pr_number = pr.number.ok_or_eyre("pr does not have number")? as u64;
+ let repo = repo_name_from_pr(&pr)?;
+
+ let query = forgejo_api::structs::IssueListLabelsQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ let mut labels = api
+ .issue_list_labels(repo.owner(), repo.name(), query)
+ .await?;
+ let query = forgejo_api::structs::OrgListLabelsQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ let org_labels = api
+ .org_list_labels(repo.owner(), query)
+ .await
+ .unwrap_or_default();
+ labels.extend(org_labels);
+
+ let mut unknown_labels = Vec::new();
+
+ let mut add_ids = Vec::with_capacity(add.len());
+ for label_name in &add {
+ let maybe_label = labels
+ .iter()
+ .find(|label| label.name.as_ref() == Some(&label_name));
+ if let Some(label) = maybe_label {
+ add_ids.push(serde_json::Value::Number(
+ label.id.ok_or_eyre("label does not have id")?.into(),
+ ));
+ } else {
+ unknown_labels.push(label_name);
+ }
+ }
+
+ let mut rm_ids = Vec::with_capacity(add.len());
+ for label_name in &rm {
+ let maybe_label = labels
+ .iter()
+ .find(|label| label.name.as_ref() == Some(&label_name));
+ if let Some(label) = maybe_label {
+ rm_ids.push(label.id.ok_or_eyre("label does not have id")?);
+ } else {
+ unknown_labels.push(label_name);
+ }
+ }
+
+ let opts = forgejo_api::structs::IssueLabelsOption {
+ labels: Some(add_ids),
+ updated_at: None,
+ };
+ api.issue_add_label(repo.owner(), repo.name(), pr_number, opts)
+ .await?;
+ let opts = forgejo_api::structs::DeleteLabelsOption { updated_at: None };
+ for id in rm_ids {
+ api.issue_remove_label(
+ repo.owner(),
+ repo.name(),
+ pr_number,
+ id as u64,
+ opts.clone(),
+ )
+ .await?;
+ }
+
+ if !unknown_labels.is_empty() {
+ if unknown_labels.len() == 1 {
+ println!("'{}' doesn't exist", &unknown_labels[0]);
+ } else {
+ let SpecialRender { bullet, .. } = *crate::special_render();
+ println!("The following labels don't exist:");
+ for unknown_label in unknown_labels {
+ println!("{bullet} {unknown_label}");
+ }
+ }
+ }
+
+ Ok(())
+}
+
+async fn create_pr(
+ repo: &RepoName,
+ api: &Forgejo,
+ title: String,
+ base: Option<String>,
+ head: Option<String>,
+ body: Option<String>,
+) -> eyre::Result<()> {
+ let mut repo_data = api.repo_get(repo.owner(), repo.name()).await?;
+
+ let head = match head {
+ Some(head) => head,
+ None => {
+ let local_repo = git2::Repository::open(".")?;
+ let head = local_repo.head()?;
+ eyre::ensure!(
+ head.is_branch(),
+ "HEAD is not on branch, can't guess head branch"
+ );
+
+ let branch_ref = head
+ .name()
+ .ok_or_eyre("current branch does not have utf8 name")?;
+ let upstream_remote = local_repo.branch_upstream_remote(branch_ref)?;
+ let remote_name = upstream_remote
+ .as_str()
+ .ok_or_eyre("remote does not have utf8 name")?;
+
+ let remote = local_repo.find_remote(remote_name)?;
+ let remote_url_s = remote.url().ok_or_eyre("remote does not have utf8 url")?;
+ let remote_url = url::Url::parse(remote_url_s)?;
+
+ let clone_url = repo_data
+ .clone_url
+ .as_ref()
+ .ok_or_eyre("repo does not have git url")?;
+ let html_url = repo_data
+ .html_url
+ .as_ref()
+ .ok_or_eyre("repo does not have html url")?;
+ let ssh_url = repo_data
+ .ssh_url
+ .as_ref()
+ .ok_or_eyre("repo does not have ssh url")?;
+ eyre::ensure!(
+ &remote_url == clone_url || &remote_url == html_url || &remote_url == ssh_url,
+ "branch does not track that repo"
+ );
+
+ let upstream_branch = local_repo.branch_upstream_name(branch_ref)?;
+ let upstream_branch = upstream_branch
+ .as_str()
+ .ok_or_eyre("remote branch does not have utf8 name")?;
+ upstream_branch
+ .rsplit_once("/")
+ .map(|(_, b)| b)
+ .unwrap_or(upstream_branch)
+ .to_owned()
+ }
+ };
+
+ let (base, base_is_parent) = match base {
+ Some(base) => match base.strip_prefix("^") {
+ Some(stripped) if stripped.is_empty() => (None, true),
+ Some(stripped) => (Some(stripped.to_owned()), true),
+ None => (Some(base), false),
+ },
+ None => (None, false),
+ };
+
+ let (repo_owner, repo_name, base_repo, head) = if base_is_parent {
+ let parent_repo = *repo_data
+ .parent
+ .take()
+ .ok_or_eyre("cannot create pull request upstream, there is no upstream")?;
+ let parent_owner = parent_repo
+ .owner
+ .as_ref()
+ .ok_or_eyre("parent has no owner")?
+ .login
+ .as_deref()
+ .ok_or_eyre("parent owner has no login")?
+ .to_owned();
+ let parent_name = parent_repo
+ .name
+ .as_deref()
+ .ok_or_eyre("parent has no name")?
+ .to_owned();
+
+ (
+ parent_owner,
+ parent_name,
+ parent_repo,
+ format!("{}:{}", repo.owner(), head),
+ )
+ } else {
+ (
+ repo.owner().to_owned(),
+ repo.name().to_owned(),
+ repo_data,
+ head,
+ )
+ };
+
+ let base = match base {
+ Some(base) => base,
+ None => base_repo
+ .default_branch
+ .as_deref()
+ .ok_or_eyre("repo does not have default branch")?
+ .to_owned(),
+ };
+
+ let body = match body {
+ Some(body) => body,
+ None => {
+ let mut body = String::new();
+ crate::editor(&mut body, Some("md")).await?;
+ body
+ }
+ };
+ let pr = api
+ .repo_create_pull_request(
+ &repo_owner,
+ &repo_name,
+ CreatePullRequestOption {
+ assignee: None,
+ assignees: None,
+ base: Some(base.to_owned()),
+ body: Some(body),
+ due_date: None,
+ head: Some(head),
+ labels: None,
+ milestone: None,
+ title: Some(title),
+ },
+ )
+ .await?;
+ let number = pr
+ .number
+ .ok_or_else(|| eyre::eyre!("pr does not have number"))?;
+ let title = pr
+ .title
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("pr does not have title"))?;
+ println!("created pull request #{}: {}", number, title);
+ Ok(())
+}
+
+async fn merge_pr(
+ repo: &RepoName,
+ api: &Forgejo,
+ pr: Option<u64>,
+ method: Option<MergeMethod>,
+ delete: bool,
+ title: Option<String>,
+ message: Option<Option<String>>,
+) -> eyre::Result<()> {
+ let repo_info = api.repo_get(repo.owner(), repo.name()).await?;
+
+ let pr_info = try_get_pr(repo, api, pr).await?;
+ let repo = repo_name_from_pr(&pr_info)?;
+ let pr_html_url = pr_info
+ .html_url
+ .as_ref()
+ .ok_or_eyre("pr does not have url")?;
+
+ let default_merge = repo_info
+ .default_merge_style
+ .map(|x| x.into())
+ .unwrap_or(forgejo_api::structs::MergePullRequestOptionDo::Merge);
+ let merge_style = method.map(|x| x.into()).unwrap_or(default_merge);
+
+ use forgejo_api::structs::MergePullRequestOptionDo::*;
+ if title.is_some() {
+ match merge_style {
+ Rebase => eyre::bail!("rebase does not support commit title"),
+ FastForwardOnly => eyre::bail!("ff-only does not support commit title"),
+ ManuallyMerged => eyre::bail!("manually merged does not support commit title"),
+ _ => (),
+ }
+ }
+ let default_message = || format!("Reviewed-on: {pr_html_url}");
+ let message = match message {
+ Some(Some(s)) => s,
+ Some(None) => {
+ let mut body = default_message();
+ crate::editor(&mut body, Some("md")).await?;
+ body
+ }
+ None => default_message(),
+ };
+
+ let request = MergePullRequestOption {
+ r#do: merge_style,
+ merge_commit_id: None,
+ merge_message_field: Some(message),
+ merge_title_field: title,
+ delete_branch_after_merge: Some(delete),
+ force_merge: None,
+ head_commit_id: None,
+ merge_when_checks_succeed: None,
+ };
+ let pr_number = pr_info.number.ok_or_eyre("pr does not have number")? as u64;
+ api.repo_merge_pull_request(repo.owner(), repo.name(), pr_number, request)
+ .await?;
+
+ let pr_title = pr_info
+ .title
+ .as_deref()
+ .ok_or_eyre("pr does not have title")?;
+ let pr_base = pr_info.base.as_ref().ok_or_eyre("pr does not have base")?;
+ let base_label = pr_base
+ .label
+ .as_ref()
+ .ok_or_eyre("base does not have label")?;
+ println!("Merged PR #{pr_number} \"{pr_title}\" into `{base_label}`");
+ Ok(())
+}
+
+async fn checkout_pr(
+ repo: &RepoName,
+ api: &Forgejo,
+ pr: PrNumber,
+ branch_name: Option<String>,
+) -> eyre::Result<()> {
+ let local_repo = git2::Repository::open(".").unwrap();
+
+ let mut options = git2::StatusOptions::new();
+ options.include_ignored(false);
+ let has_no_uncommited = local_repo.statuses(Some(&mut options)).unwrap().is_empty();
+ eyre::ensure!(
+ has_no_uncommited,
+ "Cannot checkout PR, working directory has uncommited changes"
+ );
+
+ let remote_repo = match pr {
+ PrNumber::Parent(_) => {
+ let mut this_repo = api.repo_get(repo.owner(), repo.name()).await?;
+ let name = this_repo.full_name.as_deref().unwrap_or("???/???");
+ *this_repo
+ .parent
+ .take()
+ .ok_or_else(|| eyre::eyre!("cannot get parent repo, {name} is not a fork"))?
+ }
+ PrNumber::This(_) => api.repo_get(repo.owner(), repo.name()).await?,
+ };
+
+ let (repo_owner, repo_name) = repo_name_from_repo(&remote_repo)?;
+
+ let pull_data = api
+ .repo_get_pull_request(repo_owner, repo_name, pr.number())
+ .await?;
+
+ let url = remote_repo
+ .clone_url
+ .as_ref()
+ .ok_or_eyre("repo has no clone url")?;
+ let mut remote = local_repo.remote_anonymous(url.as_str())?;
+ let branch_name = branch_name.unwrap_or_else(|| {
+ format!(
+ "pr-{}-{}-{}",
+ url.host_str().unwrap_or("unknown"),
+ repo_owner,
+ pr.number(),
+ )
+ });
+
+ auth_git2::GitAuthenticator::new().fetch(
+ &local_repo,
+ &mut remote,
+ &[&format!("pull/{}/head", pr.number())],
+ None,
+ )?;
+
+ let reference = local_repo.find_reference("FETCH_HEAD")?.resolve()?;
+ let commit = reference.peel_to_commit()?;
+
+ let mut branch_is_new = true;
+ let branch =
+ if let Ok(mut branch) = local_repo.find_branch(&branch_name, git2::BranchType::Local) {
+ branch_is_new = false;
+ branch
+ .get_mut()
+ .set_target(commit.id(), "update pr branch")?;
+ branch
+ } else {
+ local_repo.branch(&branch_name, &commit, false)?
+ };
+ let branch_ref = branch
+ .get()
+ .name()
+ .ok_or_eyre("branch does not have name")?;
+
+ local_repo.set_head(branch_ref)?;
+ local_repo
+ // for some reason, `.force()` is required to make it actually update
+ // file contents. thank you git2 examples for noticing this too, I would
+ // have pulled out so much hair figuring this out myself.
+ .checkout_head(Some(git2::build::CheckoutBuilder::default().force()))
+ .unwrap();
+
+ let pr_title = pull_data.title.as_deref().ok_or_eyre("pr has no title")?;
+ println!("Checked out PR #{}: {pr_title}", pr.number());
+ if branch_is_new {
+ println!("On new branch {branch_name}");
+ } else {
+ println!("Updated branch to latest commit");
+ }
+
+ Ok(())
+}
+
+async fn view_prs(
+ repo: &RepoName,
+ api: &Forgejo,
+ query_str: Option<String>,
+ labels: Option<String>,
+ creator: Option<String>,
+ assignee: Option<String>,
+ state: Option<crate::issues::State>,
+) -> eyre::Result<()> {
+ let labels = labels
+ .map(|s| s.split(',').map(|s| s.to_string()).collect::<Vec<_>>())
+ .unwrap_or_default();
+ let query = forgejo_api::structs::IssueListIssuesQuery {
+ q: query_str,
+ labels: Some(labels.join(",")),
+ created_by: creator,
+ assigned_by: assignee,
+ state: state.map(|s| s.into()),
+ r#type: Some(forgejo_api::structs::IssueListIssuesQueryType::Pulls),
+ milestones: None,
+ since: None,
+ before: None,
+ mentioned_by: None,
+ page: None,
+ limit: None,
+ };
+ let prs = api
+ .issue_list_issues(repo.owner(), repo.name(), query)
+ .await?;
+ if prs.len() == 1 {
+ println!("1 pull request");
+ } else {
+ println!("{} pull requests", prs.len());
+ }
+ for pr in prs {
+ let number = pr
+ .number
+ .ok_or_else(|| eyre::eyre!("pr does not have number"))?;
+ let title = pr
+ .title
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("pr does not have title"))?;
+ let user = pr
+ .user
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("pr does not have creator"))?;
+ let username = user
+ .login
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("user does not have login"))?;
+ println!("#{}: {} (by {})", number, title, username);
+ }
+ Ok(())
+}
+
+async fn view_diff(
+ repo: &RepoName,
+ api: &Forgejo,
+ pr: Option<u64>,
+ patch: bool,
+ editor: bool,
+) -> eyre::Result<()> {
+ let pr = try_get_pr(repo, api, pr).await?;
+ let pr_number = pr.number.ok_or_eyre("pr does not have number")? as u64;
+ let repo = repo_name_from_pr(&pr)?;
+ let diff_type = if patch { "patch" } else { "diff" };
+ let diff = api
+ .repo_download_pull_diff_or_patch(
+ repo.owner(),
+ repo.name(),
+ pr_number,
+ diff_type,
+ forgejo_api::structs::RepoDownloadPullDiffOrPatchQuery::default(),
+ )
+ .await?;
+ if editor {
+ let mut view = diff.clone();
+ crate::editor(&mut view, Some(diff_type)).await?;
+ if view != diff {
+ println!("changes made to the diff will not persist");
+ }
+ } else {
+ println!("{diff}");
+ }
+ Ok(())
+}
+
+async fn view_pr_files(repo: &RepoName, api: &Forgejo, pr: Option<u64>) -> eyre::Result<()> {
+ let pr = try_get_pr(repo, api, pr).await?;
+ let pr_number = pr.number.ok_or_eyre("pr does not have number")? as u64;
+ let repo = repo_name_from_pr(&pr)?;
+ let crate::SpecialRender {
+ bright_red,
+ bright_green,
+ reset,
+ ..
+ } = crate::special_render();
+
+ let query = RepoGetPullRequestFilesQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ let (_, files) = api
+ .repo_get_pull_request_files(repo.owner(), repo.name(), pr_number, query)
+ .await?;
+ let max_additions = files
+ .iter()
+ .map(|x| x.additions.unwrap_or_default())
+ .max()
+ .unwrap_or_default();
+ let max_deletions = files
+ .iter()
+ .map(|x| x.deletions.unwrap_or_default())
+ .max()
+ .unwrap_or_default();
+
+ let additions_width = max_additions.checked_ilog10().unwrap_or_default() as usize + 1;
+ let deletions_width = max_deletions.checked_ilog10().unwrap_or_default() as usize + 1;
+
+ for file in files {
+ let name = file.filename.as_deref().unwrap_or("???");
+ let additions = file.additions.unwrap_or_default();
+ let deletions = file.deletions.unwrap_or_default();
+ println!("{bright_green}+{additions:<additions_width$} {bright_red}-{deletions:<deletions_width$}{reset} {name}");
+ }
+ Ok(())
+}
+
+async fn view_pr_commits(
+ repo: &RepoName,
+ api: &Forgejo,
+ pr: Option<u64>,
+ oneline: bool,
+) -> eyre::Result<()> {
+ let pr = try_get_pr(repo, api, pr).await?;
+ let pr_number = pr.number.ok_or_eyre("pr does not have number")? as u64;
+ let repo = repo_name_from_pr(&pr)?;
+ let query = RepoGetPullRequestCommitsQuery {
+ limit: Some(u32::MAX),
+ files: Some(false),
+ ..Default::default()
+ };
+ let (_headers, commits) = api
+ .repo_get_pull_request_commits(repo.owner(), repo.name(), pr_number, query)
+ .await?;
+
+ let max_additions = commits
+ .iter()
+ .filter_map(|x| x.stats.as_ref())
+ .map(|x| x.additions.unwrap_or_default())
+ .max()
+ .unwrap_or_default();
+ let max_deletions = commits
+ .iter()
+ .filter_map(|x| x.stats.as_ref())
+ .map(|x| x.deletions.unwrap_or_default())
+ .max()
+ .unwrap_or_default();
+
+ let additions_width = max_additions.checked_ilog10().unwrap_or_default() as usize + 1;
+ let deletions_width = max_deletions.checked_ilog10().unwrap_or_default() as usize + 1;
+
+ let crate::SpecialRender {
+ bright_red,
+ bright_green,
+ yellow,
+ reset,
+ ..
+ } = crate::special_render();
+ for commit in commits {
+ let repo_commit = commit
+ .commit
+ .as_ref()
+ .ok_or_eyre("commit does not have commit?")?;
+
+ let message = repo_commit.message.as_deref().unwrap_or("[no msg]");
+ let name = message.lines().next().unwrap_or(&message);
+
+ let sha = commit
+ .sha
+ .as_deref()
+ .ok_or_eyre("commit does not have sha")?;
+ let short_sha = &sha[..7];
+
+ let stats = commit
+ .stats
+ .as_ref()
+ .ok_or_eyre("commit does not have stats")?;
+ let additions = stats.additions.unwrap_or_default();
+ let deletions = stats.deletions.unwrap_or_default();
+
+ if oneline {
+ println!("{yellow}{short_sha} {bright_green}+{additions:<additions_width$} {bright_red}-{deletions:<deletions_width$}{reset} {name}");
+ } else {
+ let author = repo_commit
+ .author
+ .as_ref()
+ .ok_or_eyre("commit has no author")?;
+ let author_name = author.name.as_deref().ok_or_eyre("author has no name")?;
+ let author_email = author.email.as_deref().ok_or_eyre("author has no email")?;
+ let date = commit
+ .created
+ .as_ref()
+ .ok_or_eyre("commit as no creation date")?;
+
+ println!("{yellow}commit {sha}{reset} ({bright_green}+{additions}{reset}, {bright_red}-{deletions}{reset})");
+ println!("Author: {author_name} <{author_email}>");
+ print!("Date: ");
+ let format = time::macros::format_description!("[weekday repr:short] [month repr:short] [day] [hour repr:24]:[minute]:[second] [year] [offset_hour sign:mandatory][offset_minute]");
+ date.format_into(&mut std::io::stdout().lock(), format)?;
+ println!();
+ println!();
+ for line in message.lines() {
+ println!(" {line}");
+ }
+ println!();
+ }
+ }
+ Ok(())
+}
+
+pub async fn browse_pr(repo: &RepoName, api: &Forgejo, id: u64) -> eyre::Result<()> {
+ let pr = api
+ .repo_get_pull_request(repo.owner(), repo.name(), id)
+ .await?;
+ let html_url = pr
+ .html_url
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("pr does not have html_url"))?;
+ open::that(html_url.as_str())?;
+ Ok(())
+}
+
+async fn try_get_pr_number(
+ repo: &RepoName,
+ api: &Forgejo,
+ number: Option<u64>,
+) -> eyre::Result<(RepoName, u64)> {
+ let pr = match number {
+ Some(number) => (repo.clone(), number),
+ None => {
+ let pr = guess_pr(repo, api)
+ .await
+ .wrap_err("could not guess pull request number, please specify")?;
+ let number = pr.number.ok_or_eyre("pr does not have number")? as u64;
+ let repo = repo_name_from_pr(&pr)?;
+ (repo, number)
+ }
+ };
+ Ok(pr)
+}
+
+async fn try_get_pr(
+ repo: &RepoName,
+ api: &Forgejo,
+ number: Option<u64>,
+) -> eyre::Result<forgejo_api::structs::PullRequest> {
+ let pr = match number {
+ Some(number) => {
+ api.repo_get_pull_request(repo.owner(), repo.name(), number)
+ .await?
+ }
+ None => guess_pr(repo, api)
+ .await
+ .wrap_err("could not guess pull request number, please specify")?,
+ };
+ Ok(pr)
+}
+
+async fn guess_pr(
+ repo: &RepoName,
+ api: &Forgejo,
+) -> eyre::Result<forgejo_api::structs::PullRequest> {
+ let local_repo = git2::Repository::open(".")?;
+ let head = local_repo.head()?;
+ eyre::ensure!(head.is_branch(), "head is not on branch");
+ let local_branch = git2::Branch::wrap(head);
+ let remote_branch = local_branch.upstream()?;
+ let remote_head_name = remote_branch
+ .get()
+ .name()
+ .ok_or_eyre("remote branch does not have valid name")?;
+ let remote_head_short = remote_head_name
+ .rsplit_once("/")
+ .map(|(_, b)| b)
+ .unwrap_or(remote_head_name);
+ let this_repo = api.repo_get(repo.owner(), repo.name()).await?;
+
+ // check for PRs on the main branch first
+ let base = this_repo
+ .default_branch
+ .as_deref()
+ .ok_or_eyre("repo does not have default branch")?;
+ if let Ok(pr) = api
+ .repo_get_pull_request_by_base_head(repo.owner(), repo.name(), base, remote_head_short)
+ .await
+ {
+ return Ok(pr);
+ }
+
+ let this_full_name = this_repo
+ .full_name
+ .as_deref()
+ .ok_or_eyre("repo does not have full name")?;
+ let parent_remote_head_name = format!("{this_full_name}:{remote_head_short}");
+
+ if let Some(parent) = this_repo.parent.as_deref() {
+ let (parent_owner, parent_name) = repo_name_from_repo(parent)?;
+ let parent_base = this_repo
+ .default_branch
+ .as_deref()
+ .ok_or_eyre("repo does not have default branch")?;
+ if let Ok(pr) = api
+ .repo_get_pull_request_by_base_head(
+ parent_owner,
+ parent_name,
+ parent_base,
+ &parent_remote_head_name,
+ )
+ .await
+ {
+ return Ok(pr);
+ }
+ }
+
+ // then iterate all branches
+ if let Some(pr) = find_pr_from_branch(repo.owner(), repo.name(), api, remote_head_short).await?
+ {
+ return Ok(pr);
+ }
+
+ if let Some(parent) = this_repo.parent.as_deref() {
+ let (parent_owner, parent_name) = repo_name_from_repo(parent)?;
+
+ if let Some(pr) =
+ find_pr_from_branch(parent_owner, parent_name, api, &parent_remote_head_name).await?
+ {
+ return Ok(pr);
+ }
+ }
+
+ eyre::bail!("could not find PR");
+}
+
+async fn find_pr_from_branch(
+ repo_owner: &str,
+ repo_name: &str,
+ api: &Forgejo,
+ head: &str,
+) -> eyre::Result<Option<forgejo_api::structs::PullRequest>> {
+ for page in 1.. {
+ let branch_query = forgejo_api::structs::RepoListBranchesQuery {
+ page: Some(page),
+ limit: Some(30),
+ };
+ let remote_branches = match api
+ .repo_list_branches(repo_owner, repo_name, branch_query)
+ .await
+ {
+ Ok(x) if !x.is_empty() => x,
+ _ => break,
+ };
+
+ let prs = futures::future::try_join_all(
+ remote_branches
+ .into_iter()
+ .map(|branch| check_branch_pair(repo_owner, repo_name, api, branch, head)),
+ )
+ .await?;
+ for pr in prs {
+ if pr.is_some() {
+ return Ok(pr);
+ }
+ }
+ }
+ Ok(None)
+}
+
+async fn check_branch_pair(
+ repo_owner: &str,
+ repo_name: &str,
+ api: &Forgejo,
+ base: forgejo_api::structs::Branch,
+ head: &str,
+) -> eyre::Result<Option<forgejo_api::structs::PullRequest>> {
+ let base_name = base
+ .name
+ .as_deref()
+ .ok_or_eyre("remote branch does not have name")?;
+ match api
+ .repo_get_pull_request_by_base_head(repo_owner, repo_name, base_name, head)
+ .await
+ {
+ Ok(pr) => Ok(Some(pr)),
+ Err(_) => Ok(None),
+ }
+}
+
+fn repo_name_from_repo(repo: &forgejo_api::structs::Repository) -> eyre::Result<(&str, &str)> {
+ let owner = repo
+ .owner
+ .as_ref()
+ .ok_or_eyre("repo does not have owner")?
+ .login
+ .as_deref()
+ .ok_or_eyre("repo owner does not have name")?;
+ let name = repo.name.as_deref().ok_or_eyre("repo does not have name")?;
+ Ok((owner, name))
+}
+
+fn repo_name_from_pr(pr: &forgejo_api::structs::PullRequest) -> eyre::Result<RepoName> {
+ let base_branch = pr.base.as_ref().ok_or_eyre("pr does not have base")?;
+ let repo = base_branch
+ .repo
+ .as_ref()
+ .ok_or_eyre("branch does not have repo")?;
+ let (owner, name) = repo_name_from_repo(repo)?;
+ let repo_name = RepoName::new(owner.to_owned(), name.to_owned());
+ Ok(repo_name)
+}
+
+//async fn guess_pr(
+// repo: &RepoName,
+// api: &Forgejo,
+//) -> eyre::Result<forgejo_api::structs::PullRequest> {
+// let local_repo = git2::Repository::open(".")?;
+// let head_id = local_repo.head()?.peel_to_commit()?.id();
+// let sha = oid_to_string(head_id);
+// let pr = api
+// .repo_get_commit_pull_request(repo.owner(), repo.name(), &sha)
+// .await?;
+// Ok(pr)
+//}
+//
+//fn oid_to_string(oid: git2::Oid) -> String {
+// let mut s = String::with_capacity(40);
+// for byte in oid.as_bytes() {
+// s.push(
+// char::from_digit((byte & 0xF) as u32, 16).expect("every nibble is a valid hex digit"),
+// );
+// s.push(
+// char::from_digit(((byte >> 4) & 0xF) as u32, 16)
+// .expect("every nibble is a valid hex digit"),
+// );
+// }
+// s
+//}
diff --git a/src/release.rs b/src/release.rs
new file mode 100644
index 0000000..fa9d7d7
--- /dev/null
+++ b/src/release.rs
@@ -0,0 +1,614 @@
+use clap::{Args, Subcommand};
+use eyre::{bail, eyre, OptionExt};
+use forgejo_api::{
+ structs::{RepoCreateReleaseAttachmentQuery, RepoListReleasesQuery},
+ Forgejo,
+};
+use tokio::io::AsyncWriteExt;
+
+use crate::{
+ keys::KeyInfo,
+ repo::{RepoArg, RepoInfo, RepoName},
+ SpecialRender,
+};
+
+#[derive(Args, Clone, Debug)]
+pub struct ReleaseCommand {
+ /// The local git remote that points to the repo to operate on.
+ #[clap(long, short = 'R')]
+ remote: Option<String>,
+ /// The name of the repository to operate on.
+ #[clap(long, short, id = "[HOST/]OWNER/REPO")]
+ repo: Option<RepoArg>,
+ #[clap(subcommand)]
+ command: ReleaseSubcommand,
+}
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum ReleaseSubcommand {
+ /// Create a new release
+ Create {
+ name: String,
+ #[clap(long, short = 'T')]
+ /// Create a new cooresponding tag for this release. Defaults to release's name.
+ create_tag: Option<Option<String>>,
+ #[clap(long, short = 't')]
+ /// Pre-existing tag to use
+ ///
+ /// If you need to create a new tag for this release, use `--create-tag`
+ tag: Option<String>,
+ #[clap(
+ long,
+ short,
+ help = "Include a file as an attachment",
+ long_help = "Include a file as an attachment
+
+`--attach=<FILE>` will set the attachment's name to the file name
+`--attach=<FILE>:<ASSET>` will use the provided name for the attachment"
+ )]
+ attach: Vec<String>,
+ #[clap(long, short)]
+ /// Text of the release body.
+ ///
+ /// Using this flag without an argument will open your editor.
+ body: Option<Option<String>>,
+ #[clap(long, short = 'B')]
+ branch: Option<String>,
+ #[clap(long, short)]
+ draft: bool,
+ #[clap(long, short)]
+ prerelease: bool,
+ },
+ /// Edit a release's info
+ Edit {
+ name: String,
+ #[clap(long, short = 'n')]
+ rename: Option<String>,
+ #[clap(long, short = 't')]
+ /// Corresponding tag for this release.
+ tag: Option<String>,
+ #[clap(long, short)]
+ /// Text of the release body.
+ ///
+ /// Using this flag without an argument will open your editor.
+ body: Option<Option<String>>,
+ #[clap(long, short)]
+ draft: Option<bool>,
+ #[clap(long, short)]
+ prerelease: Option<bool>,
+ },
+ /// Delete a release
+ Delete {
+ name: String,
+ #[clap(long, short = 't')]
+ by_tag: bool,
+ },
+ /// List all the releases on a repo
+ List {
+ #[clap(long, short = 'p')]
+ include_prerelease: bool,
+ #[clap(long, short = 'd')]
+ include_draft: bool,
+ },
+ /// View a release's info
+ View {
+ name: String,
+ #[clap(long, short = 't')]
+ by_tag: bool,
+ },
+ /// Open a release in your browser
+ Browse { name: Option<String> },
+ /// Commands on a release's attached files
+ #[clap(subcommand)]
+ Asset(AssetCommand),
+}
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum AssetCommand {
+ /// Create a new attachment on a release
+ Create {
+ release: String,
+ path: std::path::PathBuf,
+ name: Option<String>,
+ },
+ /// Remove an attachment from a release
+ Delete { release: String, asset: String },
+ /// Download an attached file
+ ///
+ /// Use `source.zip` or `source.tar.gz` to download the repo archive
+ Download {
+ release: String,
+ asset: String,
+ #[clap(long, short)]
+ output: Option<std::path::PathBuf>,
+ },
+}
+
+impl ReleaseCommand {
+ pub async fn run(self, keys: &mut KeyInfo, remote_name: Option<&str>) -> eyre::Result<()> {
+ let repo = RepoInfo::get_current(remote_name, self.repo.as_ref(), self.remote.as_deref())?;
+ let api = keys.get_api(&repo.host_url()).await?;
+ let repo = repo
+ .name()
+ .ok_or_eyre("couldn't get repo name, try specifying with --repo")?;
+ match self.command {
+ ReleaseSubcommand::Create {
+ name,
+ create_tag,
+ tag,
+ attach,
+ body,
+ branch,
+ draft,
+ prerelease,
+ } => {
+ create_release(
+ &repo, &api, name, create_tag, tag, attach, body, branch, draft, prerelease,
+ )
+ .await?
+ }
+ ReleaseSubcommand::Edit {
+ name,
+ rename,
+ tag,
+ body,
+ draft,
+ prerelease,
+ } => edit_release(&repo, &api, name, rename, tag, body, draft, prerelease).await?,
+ ReleaseSubcommand::Delete { name, by_tag } => {
+ delete_release(&repo, &api, name, by_tag).await?
+ }
+ ReleaseSubcommand::List {
+ include_prerelease,
+ include_draft,
+ } => list_releases(&repo, &api, include_prerelease, include_draft).await?,
+ ReleaseSubcommand::View { name, by_tag } => {
+ view_release(&repo, &api, name, by_tag).await?
+ }
+ ReleaseSubcommand::Browse { name } => browse_release(&repo, &api, name).await?,
+ ReleaseSubcommand::Asset(subcommand) => match subcommand {
+ AssetCommand::Create {
+ release,
+ path,
+ name,
+ } => create_asset(&repo, &api, release, path, name).await?,
+ AssetCommand::Delete { release, asset } => {
+ delete_asset(&repo, &api, release, asset).await?
+ }
+ AssetCommand::Download {
+ release,
+ asset,
+ output,
+ } => download_asset(&repo, &api, release, asset, output).await?,
+ },
+ }
+ Ok(())
+ }
+}
+
+async fn create_release(
+ repo: &RepoName,
+ api: &Forgejo,
+ name: String,
+ create_tag: Option<Option<String>>,
+ tag: Option<String>,
+ attachments: Vec<String>,
+ body: Option<Option<String>>,
+ branch: Option<String>,
+ draft: bool,
+ prerelease: bool,
+) -> eyre::Result<()> {
+ let tag_name = match (tag, create_tag) {
+ (None, None) => bail!("must select tag with `--tag` or `--create-tag`"),
+ (Some(tag), None) => tag,
+ (None, Some(tag)) => {
+ let tag = tag.unwrap_or_else(|| name.clone());
+ let opt = forgejo_api::structs::CreateTagOption {
+ message: None,
+ tag_name: tag.clone(),
+ target: branch,
+ };
+ api.repo_create_tag(repo.owner(), repo.name(), opt).await?;
+ tag
+ }
+ (Some(_), Some(_)) => {
+ bail!("`--tag` and `--create-tag` are mutually exclusive; please pick just one")
+ }
+ };
+
+ let body = match body {
+ Some(Some(body)) => Some(body),
+ Some(None) => {
+ let mut s = String::new();
+ crate::editor(&mut s, Some("md")).await?;
+ Some(s)
+ }
+ None => None,
+ };
+
+ let release_opt = forgejo_api::structs::CreateReleaseOption {
+ hide_archive_links: None,
+ body,
+ draft: Some(draft),
+ name: Some(name.clone()),
+ prerelease: Some(prerelease),
+ tag_name,
+ target_commitish: None,
+ };
+ let release = api
+ .repo_create_release(repo.owner(), repo.name(), release_opt)
+ .await?;
+
+ for attachment in attachments {
+ let (file, asset) = match attachment.split_once(':') {
+ Some((file, asset)) => (std::path::Path::new(file), asset),
+ None => {
+ let file = std::path::Path::new(&attachment);
+ let asset = file
+ .file_name()
+ .ok_or_else(|| eyre!("{attachment} does not have a file name"))?
+ .to_str()
+ .unwrap();
+ (file, asset)
+ }
+ };
+ let query = RepoCreateReleaseAttachmentQuery {
+ name: Some(asset.into()),
+ };
+ let id = release
+ .id
+ .ok_or_else(|| eyre::eyre!("release does not have id"))? as u64;
+ api.repo_create_release_attachment(
+ repo.owner(),
+ repo.name(),
+ id,
+ tokio::fs::read(file).await?,
+ query,
+ )
+ .await?;
+ }
+
+ println!("Created release {name}");
+
+ Ok(())
+}
+
+async fn edit_release(
+ repo: &RepoName,
+ api: &Forgejo,
+ name: String,
+ rename: Option<String>,
+ tag: Option<String>,
+ body: Option<Option<String>>,
+ draft: Option<bool>,
+ prerelease: Option<bool>,
+) -> eyre::Result<()> {
+ let release = find_release(repo, api, &name).await?;
+ let body = match body {
+ Some(Some(body)) => Some(body),
+ Some(None) => {
+ let mut s = release
+ .body
+ .clone()
+ .ok_or_else(|| eyre::eyre!("release does not have body"))?;
+ crate::editor(&mut s, Some("md")).await?;
+ Some(s)
+ }
+ None => None,
+ };
+ let release_edit = forgejo_api::structs::EditReleaseOption {
+ hide_archive_links: None,
+ name: rename,
+ tag_name: tag,
+ body,
+ draft,
+ prerelease,
+ target_commitish: None,
+ };
+ let id = release
+ .id
+ .ok_or_else(|| eyre::eyre!("release does not have id"))? as u64;
+ api.repo_edit_release(repo.owner(), repo.name(), id, release_edit)
+ .await?;
+ Ok(())
+}
+
+async fn list_releases(
+ repo: &RepoName,
+ api: &Forgejo,
+ prerelease: bool,
+ draft: bool,
+) -> eyre::Result<()> {
+ let query = forgejo_api::structs::RepoListReleasesQuery {
+ pre_release: Some(prerelease),
+ draft: Some(draft),
+ page: None,
+ limit: None,
+ };
+ let releases = api
+ .repo_list_releases(repo.owner(), repo.name(), query)
+ .await?;
+ for release in releases {
+ let name = release
+ .name
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("release does not have name"))?;
+ let draft = release
+ .draft
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("release does not have draft"))?;
+ let prerelease = release
+ .prerelease
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("release does not have prerelease"))?;
+ print!("{}", name);
+ match (draft, prerelease) {
+ (false, false) => (),
+ (true, false) => print!(" (draft)"),
+ (false, true) => print!(" (prerelease)"),
+ (true, true) => print!(" (draft, prerelease)"),
+ }
+ println!();
+ }
+ Ok(())
+}
+
+async fn view_release(
+ repo: &RepoName,
+ api: &Forgejo,
+ name: String,
+ by_tag: bool,
+) -> eyre::Result<()> {
+ let release = if by_tag {
+ api.repo_get_release_by_tag(repo.owner(), repo.name(), &name)
+ .await?
+ } else {
+ find_release(repo, api, &name).await?
+ };
+ let name = release
+ .name
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("release does not have name"))?;
+ let author = release
+ .author
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("release does not have author"))?;
+ let login = author
+ .login
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("autho does not have login"))?;
+ let created_at = release
+ .created_at
+ .ok_or_else(|| eyre::eyre!("release does not have created_at"))?;
+ println!("{}", name);
+ print!("By {} on ", login);
+ created_at.format_into(
+ &mut std::io::stdout(),
+ &time::format_description::well_known::Rfc2822,
+ )?;
+ println!();
+ let SpecialRender { bullet, .. } = crate::special_render();
+ let body = release
+ .body
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("release does not have body"))?;
+ if !body.is_empty() {
+ println!();
+ println!("{}", crate::markdown(&body));
+ println!();
+ }
+ let assets = release
+ .assets
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("release does not have assets"))?;
+ if !assets.is_empty() {
+ println!("{} assets", assets.len() + 2);
+ for asset in assets {
+ let name = asset
+ .name
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("asset does not have name"))?;
+ println!("{bullet} {}", name);
+ }
+ println!("{bullet} source.zip");
+ println!("{bullet} source.tar.gz");
+ }
+ Ok(())
+}
+
+async fn browse_release(repo: &RepoName, api: &Forgejo, name: Option<String>) -> eyre::Result<()> {
+ match name {
+ Some(name) => {
+ let release = find_release(repo, api, &name).await?;
+ let html_url = release
+ .html_url
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("release does not have html_url"))?;
+ open::that(html_url.as_str())?;
+ }
+ None => {
+ let repo_data = api.repo_get(repo.owner(), repo.name()).await?;
+ let mut html_url = repo_data
+ .html_url
+ .clone()
+ .ok_or_else(|| eyre::eyre!("repository does not have html_url"))?;
+ html_url.path_segments_mut().unwrap().push("releases");
+ open::that(html_url.as_str())?;
+ }
+ }
+ Ok(())
+}
+
+async fn create_asset(
+ repo: &RepoName,
+ api: &Forgejo,
+ release: String,
+ file: std::path::PathBuf,
+ asset: Option<String>,
+) -> eyre::Result<()> {
+ let (file, asset) = match asset {
+ Some(ref asset) => (&*file, &**asset),
+ None => {
+ let asset = file
+ .file_name()
+ .ok_or_else(|| eyre!("{} does not have a file name", file.display()))?
+ .to_str()
+ .unwrap();
+ (&*file, asset)
+ }
+ };
+ let id = find_release(repo, api, &release)
+ .await?
+ .id
+ .ok_or_else(|| eyre::eyre!("release does not have id"))? as u64;
+ let query = RepoCreateReleaseAttachmentQuery {
+ name: Some(asset.to_owned()),
+ };
+ api.repo_create_release_attachment(
+ repo.owner(),
+ repo.name(),
+ id,
+ tokio::fs::read(file).await?,
+ query,
+ )
+ .await?;
+
+ println!("Added attachment `{}` to {}", asset, release);
+
+ Ok(())
+}
+
+async fn delete_asset(
+ repo: &RepoName,
+ api: &Forgejo,
+ release_name: String,
+ asset_name: String,
+) -> eyre::Result<()> {
+ let release = find_release(repo, api, &release_name).await?;
+ let assets = release
+ .assets
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("release does not have assets"))?;
+ let asset = assets
+ .iter()
+ .find(|a| a.name.as_ref() == Some(&asset_name))
+ .ok_or_else(|| eyre!("asset not found"))?;
+ let release_id = release
+ .id
+ .ok_or_else(|| eyre::eyre!("release does not have id"))? as u64;
+ let asset_id = asset
+ .id
+ .ok_or_else(|| eyre::eyre!("asset does not have id"))? as u64;
+ api.repo_delete_release_attachment(repo.owner(), repo.name(), release_id, asset_id)
+ .await?;
+ println!("Removed attachment `{}` from {}", asset_name, release_name);
+ Ok(())
+}
+
+async fn download_asset(
+ repo: &RepoName,
+ api: &Forgejo,
+ release: String,
+ asset: String,
+ output: Option<std::path::PathBuf>,
+) -> eyre::Result<()> {
+ let release = find_release(repo, api, &release).await?;
+ let file = match &*asset {
+ "source.zip" => {
+ let tag_name = release
+ .tag_name
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("release does not have tag_name"))?;
+ api.repo_get_archive(repo.owner(), repo.name(), &format!("{}.zip", tag_name))
+ .await?
+ }
+ "source.tar.gz" => {
+ let tag_name = release
+ .tag_name
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("release does not have tag_name"))?;
+ api.repo_get_archive(repo.owner(), repo.name(), &format!("{}.tar.gz", tag_name))
+ .await?
+ }
+ name => {
+ let assets = release
+ .assets
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("release does not have assets"))?;
+ let asset = assets
+ .iter()
+ .find(|a| a.name.as_deref() == Some(name))
+ .ok_or_else(|| eyre!("asset not found"))?;
+ let release_id = release
+ .id
+ .ok_or_else(|| eyre::eyre!("release does not have id"))?
+ as u64;
+ let asset_id = asset
+ .id
+ .ok_or_else(|| eyre::eyre!("asset does not have id"))?
+ as u64;
+ api.download_release_attachment(repo.owner(), repo.name(), release_id, asset_id)
+ .await?
+ .to_vec()
+ }
+ };
+ let real_output = output
+ .as_deref()
+ .unwrap_or_else(|| std::path::Path::new(&asset));
+ tokio::fs::OpenOptions::new()
+ .create_new(true)
+ .write(true)
+ .open(real_output)
+ .await?
+ .write_all(file.as_ref())
+ .await?;
+
+ if output.is_some() {
+ println!("Downloaded {asset} into {}", real_output.display());
+ } else {
+ println!("Downloaded {asset}");
+ }
+
+ Ok(())
+}
+
+async fn find_release(
+ repo: &RepoName,
+ api: &Forgejo,
+ name: &str,
+) -> eyre::Result<forgejo_api::structs::Release> {
+ let query = RepoListReleasesQuery {
+ draft: None,
+ pre_release: None,
+ page: None,
+ limit: None,
+ };
+ let mut releases = api
+ .repo_list_releases(repo.owner(), repo.name(), query)
+ .await?;
+ let idx = releases
+ .iter()
+ .position(|r| r.name.as_deref() == Some(name))
+ .ok_or_else(|| eyre!("release not found"))?;
+ Ok(releases.swap_remove(idx))
+}
+
+async fn delete_release(
+ repo: &RepoName,
+ api: &Forgejo,
+ name: String,
+ by_tag: bool,
+) -> eyre::Result<()> {
+ if by_tag {
+ api.repo_delete_release_by_tag(repo.owner(), repo.name(), &name)
+ .await?;
+ } else {
+ let id = find_release(repo, api, &name)
+ .await?
+ .id
+ .ok_or_else(|| eyre::eyre!("release does not have id"))? as u64;
+ api.repo_delete_release(repo.owner(), repo.name(), id)
+ .await?;
+ }
+ Ok(())
+}
diff --git a/src/repo.rs b/src/repo.rs
new file mode 100644
index 0000000..dce5487
--- /dev/null
+++ b/src/repo.rs
@@ -0,0 +1,766 @@
+use std::{io::Write, path::PathBuf, str::FromStr};
+
+use clap::Subcommand;
+use eyre::{eyre, OptionExt};
+use forgejo_api::{structs::CreateRepoOption, Forgejo};
+use url::Url;
+
+use crate::SpecialRender;
+
+pub struct RepoInfo {
+ url: Url,
+ name: Option<RepoName>,
+}
+
+impl RepoInfo {
+ pub fn get_current(
+ host: Option<&str>,
+ repo: Option<&RepoArg>,
+ remote: Option<&str>,
+ ) -> eyre::Result<Self> {
+ // l = domain/owner/name
+ // s = owner/name
+ // x = is present
+ // i = found locally by git
+ //
+ // | repo | host | remote | ans-host | ans-repo |
+ // |------|------|--------|----------|----------|
+ // | l | x | x | repo | repo |
+ // | l | x | i | repo | repo |
+ // | l | x | | repo | repo |
+ // | l | | x | repo | repo |
+ // | l | | i | repo | repo |
+ // | l | | | repo | repo |
+ // | s | x | x | host | repo |
+ // | s | x | i | host | repo |
+ // | s | x | | host | repo |
+ // | s | | x | remote | repo |
+ // | s | | i | remote | repo |
+ // | s | | | err | repo |
+ // | | x | x | remote | remote |
+ // | | x | i | host | ?remote |
+ // | | x | | host | none |
+ // | | | x | remote | remote |
+ // | | | i | remote | remote |
+ // | | | | err | remote |
+
+ let mut repo_url: Option<Url> = None;
+ let mut repo_name: Option<RepoName> = None;
+
+ if let Some(repo) = repo {
+ if let Some(host) = &repo.host {
+ if let Ok(url) = Url::parse(host) {
+ repo_url = Some(url);
+ } else if let Ok(url) = Url::parse(&format!("https://{host}/")) {
+ repo_url = Some(url);
+ }
+ }
+ repo_name = Some(RepoName {
+ owner: repo.owner.clone(),
+ name: repo.name.clone(),
+ });
+ }
+
+ let repo_url = repo_url;
+ let repo_name = repo_name;
+
+ let host_url = host.and_then(|host| {
+ Url::parse(host)
+ .ok()
+ .or_else(|| Url::parse(&format!("https://{host}/")).ok())
+ });
+
+ let (remote_url, remote_repo_name) = {
+ let mut out = (None, None);
+ if let Ok(local_repo) = git2::Repository::open(".") {
+ let mut name = remote.map(|s| s.to_owned());
+
+ // if there's only one remote, use that
+ if name.is_none() {
+ let all_remotes = local_repo.remotes()?;
+ if all_remotes.len() == 1 {
+ if let Some(remote_name) = all_remotes.get(0) {
+ name = Some(remote_name.to_owned());
+ }
+ }
+ }
+
+ // if the current branch is tracking a remote branch, use that remote
+ if name.is_none() {
+ let head = local_repo.head()?;
+ let branch_name = head.name().ok_or_eyre("branch name not UTF-8")?;
+
+ if let Ok(remote_name) = local_repo.branch_upstream_remote(branch_name) {
+ let remote_name_s =
+ remote_name.as_str().ok_or_eyre("remote name invalid")?;
+
+ if let Some(host_url) = &host_url {
+ let remote = local_repo.find_remote(&remote_name_s)?;
+ let url_s = std::str::from_utf8(remote.url_bytes())?;
+ let url = Url::parse(url_s)?;
+
+ if url.host_str() == host_url.host_str() {
+ name = Some(remote_name_s.to_owned());
+ }
+ } else {
+ name = Some(remote_name_s.to_owned());
+ }
+ }
+ }
+
+ // if there's a remote whose host url matches the one
+ // specified with `--host`, use that
+ //
+ // This is different than using `--host` itself, since this
+ // will include the repo name, which `--host` can't do.
+ if name.is_none() {
+ if let Some(host_url) = &host_url {
+ let all_remotes = local_repo.remotes()?;
+ for remote_name in all_remotes.iter() {
+ let Some(remote_name) = remote_name else {
+ continue;
+ };
+ let remote = local_repo.find_remote(remote_name)?;
+
+ if let Some(url) = remote.url() {
+ let (url, _) = url_strip_repo_name(Url::parse(url)?)?;
+ if url.host_str() == host_url.host_str()
+ && url.path() == host_url.path()
+ {
+ name = Some(remote_name.to_owned());
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ if let Some(name) = name {
+ if let Ok(remote) = local_repo.find_remote(&name) {
+ let url_s = std::str::from_utf8(remote.url_bytes())?;
+ let url = Url::parse(url_s)?;
+ let (url, name) = url_strip_repo_name(url)?;
+
+ out = (Some(url), Some(name))
+ }
+ }
+ } else {
+ eyre::ensure!(remote.is_none(), "remote specified but no git repo found");
+ }
+ out
+ };
+
+ let (url, name) = if repo_url.is_some() {
+ (repo_url, repo_name)
+ } else if repo_name.is_some() {
+ (host_url.or(remote_url), repo_name)
+ } else {
+ if remote.is_some() {
+ (remote_url, remote_repo_name)
+ } else if host_url.is_none() || remote_url == host_url {
+ (remote_url, remote_repo_name)
+ } else {
+ (host_url, None)
+ }
+ };
+
+ let url = url.or_else(fallback_host);
+
+ let info = match (url, name) {
+ (Some(url), name) => RepoInfo { url, name },
+ (None, Some(_)) => eyre::bail!("cannot find repo, no host specified"),
+ (None, None) => eyre::bail!("no repo info specified"),
+ };
+
+ Ok(info)
+ }
+
+ pub fn name(&self) -> Option<&RepoName> {
+ self.name.as_ref()
+ }
+
+ pub fn host_url(&self) -> &Url {
+ &self.url
+ }
+}
+
+fn fallback_host() -> Option<Url> {
+ if let Some(envvar) = std::env::var_os("FJ_FALLBACK_HOST") {
+ let out = envvar.to_str().and_then(|x| x.parse::<Url>().ok());
+ if out.is_none() {
+ println!("warn: `FJ_FALLBACK_HOST` is not set to a valid url");
+ }
+ out
+ } else {
+ None
+ }
+}
+
+fn url_strip_repo_name(mut url: Url) -> eyre::Result<(Url, RepoName)> {
+ let mut iter = url
+ .path_segments()
+ .ok_or_eyre("repo url cannot be a base")?
+ .rev();
+
+ let name = iter.next().ok_or_eyre("repo url too short")?;
+ let name = name.strip_suffix(".git").unwrap_or(name).to_owned();
+
+ let owner = iter.next().ok_or_eyre("repo url too short")?.to_owned();
+
+ // Remove the username and repo name from the url
+ url.path_segments_mut()
+ .map_err(|_| eyre!("repo url cannot be a base"))?
+ .pop()
+ .pop();
+
+ Ok((url, RepoName { owner, name }))
+}
+
+#[derive(Clone, Debug)]
+pub struct RepoName {
+ owner: String,
+ name: String,
+}
+
+impl RepoName {
+ pub fn new(owner: String, name: String) -> Self {
+ Self { owner, name }
+ }
+ pub fn owner(&self) -> &str {
+ &self.owner
+ }
+
+ pub fn name(&self) -> &str {
+ &self.name
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct RepoArg {
+ host: Option<String>,
+ owner: String,
+ name: String,
+}
+
+impl std::fmt::Display for RepoArg {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match &self.host {
+ Some(host) => write!(f, "{host}/{}/{}", self.owner, self.name),
+ None => write!(f, "{}/{}", self.owner, self.name),
+ }
+ }
+}
+
+impl FromStr for RepoArg {
+ type Err = RepoArgError;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ let (head, name) = s.rsplit_once("/").ok_or(RepoArgError::NoOwner)?;
+ let name = name.strip_suffix(".git").unwrap_or(name);
+ let (host, owner) = match head.rsplit_once("/") {
+ Some((host, owner)) => (Some(host), owner),
+ None => (None, head),
+ };
+ Ok(Self {
+ host: host.map(|s| s.to_owned()),
+ owner: owner.to_owned(),
+ name: name.to_owned(),
+ })
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RepoArgError {
+ NoOwner,
+}
+
+impl std::error::Error for RepoArgError {}
+
+impl std::fmt::Display for RepoArgError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ RepoArgError::NoOwner => {
+ write!(f, "repo name should be in the format [HOST/]OWNER/NAME")
+ }
+ }
+ }
+}
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum RepoCommand {
+ /// Creates a new repository
+ Create {
+ repo: String,
+
+ // flags
+ #[clap(long, short)]
+ description: Option<String>,
+ #[clap(long, short = 'P')]
+ private: bool,
+ /// Creates a new remote with the given name for the new repo
+ #[clap(long, short)]
+ remote: Option<String>,
+ /// Pushes the current branch to the default branch on the new repo.
+ /// Implies `--remote=origin` (setting remote manually overrides this)
+ #[clap(long, short)]
+ push: bool,
+ },
+ /// Fork a repository onto your account
+ Fork {
+ #[clap(id = "[HOST/]OWNER/REPO")]
+ repo: RepoArg,
+ #[clap(long)]
+ name: Option<String>,
+ #[clap(long, short = 'R')]
+ remote: Option<String>,
+ },
+ /// View a repo's info
+ View {
+ #[clap(id = "[HOST/]OWNER/REPO")]
+ name: Option<RepoArg>,
+ #[clap(long, short = 'R')]
+ remote: Option<String>,
+ },
+ /// Clone a repo's code locally
+ Clone {
+ #[clap(id = "[HOST/]OWNER/REPO")]
+ repo: RepoArg,
+ path: Option<PathBuf>,
+ },
+ /// Add a star to a repo
+ Star {
+ #[clap(id = "[HOST/]OWNER/REPO")]
+ repo: RepoArg,
+ },
+ /// Take away a star from a repo
+ Unstar {
+ #[clap(id = "[HOST/]OWNER/REPO")]
+ repo: RepoArg,
+ },
+ /// Delete a repository
+ ///
+ /// This cannot be undone!
+ Delete {
+ #[clap(id = "[HOST/]OWNER/REPO")]
+ repo: RepoArg,
+ },
+ /// Open a repository's page in your browser
+ Browse {
+ #[clap(id = "[HOST/]OWNER/REPO")]
+ name: Option<RepoArg>,
+ #[clap(long, short = 'R')]
+ remote: Option<String>,
+ },
+}
+
+impl RepoCommand {
+ pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> {
+ match self {
+ RepoCommand::Create {
+ repo,
+
+ description,
+ private,
+ remote,
+ push,
+ } => {
+ let host = RepoInfo::get_current(host_name, None, None)?;
+ let api = keys.get_api(host.host_url()).await?;
+ create_repo(&api, repo, description, private, remote, push).await?;
+ }
+ RepoCommand::Fork { repo, name, remote } => {
+ fn strip(s: &str) -> &str {
+ let no_scheme = s
+ .strip_prefix("https://")
+ .or_else(|| s.strip_prefix("http://"))
+ .unwrap_or(s);
+ let no_trailing_slash = no_scheme.strip_suffix("/").unwrap_or(no_scheme);
+ no_trailing_slash
+ }
+ match (repo.host.as_deref(), host_name) {
+ (Some(a), Some(b)) => {
+ if strip(a) != strip(b) {
+ eyre::bail!("conflicting hosts {a} and {b}. please only specify one");
+ }
+ }
+ _ => (),
+ }
+
+ let repo_info = RepoInfo::get_current(host_name, Some(&repo), remote.as_deref())?;
+ let api = keys.get_api(&repo_info.host_url()).await?;
+ let repo = repo_info
+ .name()
+ .ok_or_eyre("couldn't get repo name, please specify")?;
+ fork_repo(&api, repo, name).await?
+ }
+ RepoCommand::View { name, remote } => {
+ let repo = RepoInfo::get_current(host_name, name.as_ref(), remote.as_deref())?;
+ let api = keys.get_api(&repo.host_url()).await?;
+ let repo = repo
+ .name()
+ .ok_or_eyre("couldn't get repo name, please specify")?;
+ view_repo(&api, &repo).await?
+ }
+ RepoCommand::Clone { repo, path } => {
+ let repo = RepoInfo::get_current(host_name, Some(&repo), None)?;
+ let api = keys.get_api(&repo.host_url()).await?;
+ let name = repo.name().unwrap();
+ cmd_clone_repo(&api, &name, path).await?;
+ }
+ RepoCommand::Star { repo } => {
+ let repo = RepoInfo::get_current(host_name, Some(&repo), None)?;
+ let api = keys.get_api(&repo.host_url()).await?;
+ let name = repo.name().unwrap();
+ api.user_current_put_star(name.owner(), name.name()).await?;
+ println!("Starred {}/{}!", name.owner(), name.name());
+ }
+ RepoCommand::Unstar { repo } => {
+ let repo = RepoInfo::get_current(host_name, Some(&repo), None)?;
+ let api = keys.get_api(&repo.host_url()).await?;
+ let name = repo.name().unwrap();
+ api.user_current_delete_star(name.owner(), name.name())
+ .await?;
+ println!("Removed star from {}/{}", name.owner(), name.name());
+ }
+ RepoCommand::Delete { repo } => {
+ let repo = RepoInfo::get_current(host_name, Some(&repo), None)?;
+ let api = keys.get_api(&repo.host_url()).await?;
+ let name = repo.name().unwrap();
+ delete_repo(&api, &name).await?;
+ }
+ RepoCommand::Browse { name, remote } => {
+ let repo = RepoInfo::get_current(host_name, name.as_ref(), remote.as_deref())?;
+ let mut url = repo.host_url().clone();
+ let repo = repo
+ .name()
+ .ok_or_eyre("couldn't get repo name, please specify")?;
+ url.path_segments_mut()
+ .map_err(|_| eyre!("url invalid"))?
+ .extend([repo.owner(), repo.name()]);
+
+ open::that(url.as_str())?;
+ }
+ };
+ Ok(())
+ }
+}
+
+pub async fn create_repo(
+ api: &Forgejo,
+ repo: String,
+ description: Option<String>,
+ private: bool,
+ remote: Option<String>,
+ push: bool,
+) -> eyre::Result<()> {
+ if remote.is_some() || push {
+ let repo = git2::Repository::open(".")?;
+
+ let upstream = remote.as_deref().unwrap_or("origin");
+ if repo.find_remote(upstream).is_ok() {
+ eyre::bail!("A remote named \"{upstream}\" already exists");
+ }
+ }
+ let repo_spec = CreateRepoOption {
+ auto_init: Some(false),
+ default_branch: Some("main".into()),
+ description,
+ gitignores: None,
+ issue_labels: None,
+ license: None,
+ name: repo,
+ object_format_name: None,
+ private: Some(private),
+ readme: Some(String::new()),
+ template: Some(false),
+ trust_model: Some(forgejo_api::structs::CreateRepoOptionTrustModel::Default),
+ };
+ let new_repo = api.create_current_user_repo(repo_spec).await?;
+ let html_url = new_repo
+ .html_url
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("new_repo does not have html_url"))?;
+ println!("created new repo at {}", html_url);
+
+ if remote.is_some() || push {
+ let repo = git2::Repository::open(".")?;
+
+ let upstream = remote.as_deref().unwrap_or("origin");
+ let clone_url = new_repo
+ .clone_url
+ .as_ref()
+ .ok_or_else(|| eyre::eyre!("new_repo does not have clone_url"))?;
+ let mut remote = repo.remote(upstream, clone_url.as_str())?;
+
+ if push {
+ let head = repo.head()?;
+ if !head.is_branch() {
+ eyre::bail!("HEAD is not on a branch; cannot push to remote");
+ }
+ let branch_shorthand = head
+ .shorthand()
+ .ok_or_else(|| eyre!("branch name invalid utf-8"))?
+ .to_owned();
+ let branch_name = std::str::from_utf8(head.name_bytes())?.to_owned();
+
+ let auth = auth_git2::GitAuthenticator::new();
+ auth.push(&repo, &mut remote, &[&branch_name])?;
+
+ remote.fetch(&[&branch_shorthand], None, None)?;
+
+ let mut current_branch = git2::Branch::wrap(head);
+ current_branch.set_upstream(Some(&format!("{upstream}/{branch_shorthand}")))?;
+ }
+ }
+
+ Ok(())
+}
+
+async fn fork_repo(api: &Forgejo, repo: &RepoName, name: Option<String>) -> eyre::Result<()> {
+ let opt = forgejo_api::structs::CreateForkOption {
+ name,
+ organization: None,
+ };
+ let new_fork = api.create_fork(repo.owner(), repo.name(), opt).await?;
+ let fork_full_name = new_fork
+ .full_name
+ .as_deref()
+ .ok_or_eyre("fork does not have name")?;
+ println!(
+ "Forked {}/{} into {}",
+ repo.owner(),
+ repo.name(),
+ fork_full_name
+ );
+
+ Ok(())
+}
+
+async fn view_repo(api: &Forgejo, repo: &RepoName) -> eyre::Result<()> {
+ let repo = api.repo_get(repo.owner(), repo.name()).await?;
+
+ let SpecialRender {
+ dash,
+ body_prefix,
+ dark_grey,
+ reset,
+ ..
+ } = crate::special_render();
+
+ println!("{}", repo.full_name.ok_or_eyre("no full name")?);
+
+ if let Some(parent) = &repo.parent {
+ println!(
+ "Fork of {}",
+ parent.full_name.as_ref().ok_or_eyre("no full name")?
+ );
+ }
+ if repo.mirror == Some(true) {
+ if let Some(original) = &repo.original_url {
+ println!("Mirror of {original}")
+ }
+ }
+ let desc = repo.description.as_deref().unwrap_or_default();
+ // Don't use body::markdown, this is plain text.
+ if !desc.is_empty() {
+ if desc.lines().count() > 1 {
+ println!();
+ }
+ for line in desc.lines() {
+ println!("{dark_grey}{body_prefix}{reset} {line}");
+ }
+ }
+ println!();
+
+ let lang = repo.language.as_deref().unwrap_or_default();
+ if !lang.is_empty() {
+ println!("Primary language is {lang}");
+ }
+
+ let stars = repo.stars_count.unwrap_or_default();
+ if stars == 1 {
+ print!("{stars} star {dash} ");
+ } else {
+ print!("{stars} stars {dash} ");
+ }
+
+ let watchers = repo.watchers_count.unwrap_or_default();
+ print!("{watchers} watching {dash} ");
+
+ let forks = repo.forks_count.unwrap_or_default();
+ if forks == 1 {
+ print!("{forks} fork");
+ } else {
+ print!("{forks} forks");
+ }
+ println!();
+
+ let mut first = true;
+ if repo.has_issues.unwrap_or_default() && repo.external_tracker.is_none() {
+ let issues = repo.open_issues_count.unwrap_or_default();
+ if issues == 1 {
+ print!("{issues} issue");
+ } else {
+ print!("{issues} issues");
+ }
+ first = false;
+ }
+ if repo.has_pull_requests.unwrap_or_default() {
+ if !first {
+ print!(" {dash} ");
+ }
+ let pulls = repo.open_pr_counter.unwrap_or_default();
+ if pulls == 1 {
+ print!("{pulls} PR");
+ } else {
+ print!("{pulls} PRs");
+ }
+ first = false;
+ }
+ if repo.has_releases.unwrap_or_default() {
+ if !first {
+ print!(" {dash} ");
+ }
+ let releases = repo.release_counter.unwrap_or_default();
+ if releases == 1 {
+ print!("{releases} release");
+ } else {
+ print!("{releases} releases");
+ }
+ first = false;
+ }
+ if !first {
+ println!();
+ }
+ if let Some(external_tracker) = &repo.external_tracker {
+ if let Some(tracker_url) = &external_tracker.external_tracker_url {
+ println!("Issue tracker is at {tracker_url}");
+ }
+ }
+
+ if let Some(html_url) = &repo.html_url {
+ println!();
+ println!("View online at {html_url}");
+ }
+
+ Ok(())
+}
+
+async fn cmd_clone_repo(
+ api: &Forgejo,
+ name: &RepoName,
+ path: Option<std::path::PathBuf>,
+) -> eyre::Result<()> {
+ let repo_data = api.repo_get(name.owner(), name.name()).await?;
+ let clone_url = repo_data
+ .clone_url
+ .as_ref()
+ .ok_or_eyre("repo does not have clone url")?;
+
+ let repo_name = repo_data
+ .name
+ .as_deref()
+ .ok_or_eyre("repo does not have name")?;
+ let repo_full_name = repo_data
+ .full_name
+ .as_deref()
+ .ok_or_eyre("repo does not have full name")?;
+
+ let path = path.unwrap_or_else(|| PathBuf::from(format!("./{repo_name}")));
+
+ let local_repo = clone_repo(&repo_full_name, &clone_url, &path)?;
+
+ if let Some(parent) = repo_data.parent.as_deref() {
+ let parent_clone_url = parent
+ .clone_url
+ .as_ref()
+ .ok_or_eyre("parent repo does not have clone url")?;
+ local_repo.remote("upstream", parent_clone_url.as_str())?;
+ }
+
+ Ok(())
+}
+
+pub fn clone_repo(
+ repo_name: &str,
+ url: &url::Url,
+ path: &std::path::Path,
+) -> eyre::Result<git2::Repository> {
+ let SpecialRender {
+ fancy,
+ hide_cursor,
+ show_cursor,
+ clear_line,
+ ..
+ } = *crate::special_render();
+
+ let auth = auth_git2::GitAuthenticator::new();
+ let git_config = git2::Config::open_default()?;
+
+ let mut options = git2::FetchOptions::new();
+ let mut callbacks = git2::RemoteCallbacks::new();
+ callbacks.credentials(auth.credentials(&git_config));
+
+ if fancy {
+ print!("{hide_cursor}");
+ print!(" Preparing...");
+ let _ = std::io::stdout().flush();
+
+ callbacks.transfer_progress(|progress| {
+ print!("{clear_line}\r");
+ if progress.received_objects() == progress.total_objects() {
+ if progress.indexed_deltas() == progress.total_deltas() {
+ print!("Finishing up...");
+ } else {
+ let percent = 100.0 * (progress.indexed_deltas() as f64)
+ / (progress.total_deltas() as f64);
+ print!(" Resolving... {percent:.01}%");
+ }
+ } else {
+ let bytes = progress.received_bytes();
+ let percent = 100.0 * (progress.received_objects() as f64)
+ / (progress.total_objects() as f64);
+ print!(" Downloading... {percent:.01}%");
+ match bytes {
+ 0..=1023 => print!(" ({}b)", bytes),
+ 1024..=1048575 => print!(" ({:.01}kb)", (bytes as f64) / 1024.0),
+ 1048576..=1073741823 => {
+ print!(" ({:.01}mb)", (bytes as f64) / 1048576.0)
+ }
+ 1073741824.. => {
+ print!(" ({:.01}gb)", (bytes as f64) / 1073741824.0)
+ }
+ }
+ }
+ let _ = std::io::stdout().flush();
+ true
+ });
+ options.remote_callbacks(callbacks);
+ }
+
+ let local_repo = git2::build::RepoBuilder::new()
+ .fetch_options(options)
+ .clone(url.as_str(), &path)?;
+ if fancy {
+ print!("{clear_line}{show_cursor}\r");
+ }
+ println!("Cloned {} into {}", repo_name, path.display());
+ Ok(local_repo)
+}
+
+async fn delete_repo(api: &Forgejo, name: &RepoName) -> eyre::Result<()> {
+ print!(
+ "Are you sure you want to delete {}/{}? (y/N) ",
+ name.owner(),
+ name.name()
+ );
+ let user_response = crate::readline("").await?;
+ let yes = matches!(user_response.trim(), "y" | "Y" | "yes" | "Yes");
+ if yes {
+ api.repo_delete(name.owner(), name.name()).await?;
+ println!("Deleted {}/{}", name.owner(), name.name());
+ } else {
+ println!("Did not delete");
+ }
+ Ok(())
+}
diff --git a/src/user.rs b/src/user.rs
new file mode 100644
index 0000000..d38d8b5
--- /dev/null
+++ b/src/user.rs
@@ -0,0 +1,1019 @@
+use clap::{Args, Subcommand};
+use eyre::OptionExt;
+use forgejo_api::Forgejo;
+
+use crate::{repo::RepoInfo, SpecialRender};
+
+#[derive(Args, Clone, Debug)]
+pub struct UserCommand {
+ /// The local git remote that points to the repo to operate on.
+ #[clap(long, short = 'R')]
+ remote: Option<String>,
+ #[clap(subcommand)]
+ command: UserSubcommand,
+}
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum UserSubcommand {
+ /// Search for a user by username
+ Search {
+ /// The name to search for
+ query: String,
+ #[clap(long, short)]
+ page: Option<usize>,
+ },
+ /// View a user's profile page
+ View {
+ /// The name of the user to view
+ ///
+ /// Omit to view your own page
+ user: Option<String>,
+ },
+ /// Open a user's profile page in your browser
+ Browse {
+ /// The name of the user to open in your browser
+ ///
+ /// Omit to view your own page
+ user: Option<String>,
+ },
+ /// Follow a user
+ Follow {
+ /// The name of the user to follow
+ user: String,
+ },
+ /// Unfollow a user
+ Unfollow {
+ /// The name of the user to follow
+ user: String,
+ },
+ /// List everyone a user's follows
+ Following {
+ /// The name of the user whose follows to list
+ ///
+ /// Omit to view your own follows
+ user: Option<String>,
+ },
+ /// List a user's followers
+ Followers {
+ /// The name of the user whose followers to list
+ ///
+ /// Omit to view your own followers
+ user: Option<String>,
+ },
+ /// Block a user
+ Block {
+ /// The name of the user to block
+ user: String,
+ },
+ /// Unblock a user
+ Unblock {
+ /// The name of the user to unblock
+ user: String,
+ },
+ /// List a user's repositories
+ Repos {
+ /// The name of the user whose repos to list
+ ///
+ /// Omit to view your own repos.
+ user: Option<String>,
+ /// List starred repos instead of owned repos
+ #[clap(long)]
+ starred: bool,
+ /// Method by which to sort the list
+ #[clap(long)]
+ sort: Option<RepoSortOrder>,
+ },
+ /// List the organizations a user is a member of
+ Orgs {
+ /// The name of the user to view org membership of
+ ///
+ /// Omit to view your own orgs.
+ user: Option<String>,
+ },
+ /// List a user's recent activity
+ Activity {
+ /// The name of the user to view the activity of
+ ///
+ /// Omit to view your own activity.
+ user: Option<String>,
+ },
+ /// Edit your user settings
+ #[clap(subcommand)]
+ Edit(EditCommand),
+}
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum EditCommand {
+ /// Set your bio
+ Bio {
+ /// The new description. Leave this out to open your editor.
+ content: Option<String>,
+ },
+ /// Set your full name
+ Name {
+ /// The new name.
+ #[clap(group = "arg")]
+ name: Option<String>,
+ /// Remove your name from your profile
+ #[clap(long, short, group = "arg")]
+ unset: bool,
+ },
+ /// Set your pronouns
+ Pronouns {
+ /// The new pronouns.
+ #[clap(group = "arg")]
+ pronouns: Option<String>,
+ /// Remove your pronouns from your profile
+ #[clap(long, short, group = "arg")]
+ unset: bool,
+ },
+ /// Set your activity visibility
+ Location {
+ /// The new location.
+ #[clap(group = "arg")]
+ location: Option<String>,
+ /// Remove your location from your profile
+ #[clap(long, short, group = "arg")]
+ unset: bool,
+ },
+ /// Set your activity visibility
+ Activity {
+ /// The visibility of your activity.
+ #[clap(long, short)]
+ visibility: VisbilitySetting,
+ },
+ /// Manage the email addresses associated with your account
+ Email {
+ /// Set the visibility of your email address.
+ #[clap(long, short)]
+ visibility: Option<VisbilitySetting>,
+ /// Add a new email address
+ #[clap(long, short)]
+ add: Vec<String>,
+ /// Remove an email address
+ #[clap(long, short)]
+ rm: Vec<String>,
+ },
+ /// Set your linked website
+ Website {
+ /// Your website URL.
+ #[clap(group = "arg")]
+ url: Option<String>,
+ /// Remove your website from your profile
+ #[clap(long, short, group = "arg")]
+ unset: bool,
+ },
+}
+
+#[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())?;
+ 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,
+ } => list_repos(&api, user.as_deref(), starred, sort).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?,
+ },
+ }
+ 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")?
+ }
+ };
+ // `User` doesn't have an `html_url` field, so we gotta construct the user
+ // page url ourselves
+ let mut url = host_url.clone();
+ url.path_segments_mut()
+ .map_err(|_| eyre::eyre!("invalid host url"))?
+ .push(&username);
+ open::that(url.as_str())?;
+
+ 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) => {
+ let query = forgejo_api::structs::UserListFollowingQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ api.user_list_following(user, query).await?
+ }
+ None => {
+ let query = forgejo_api::structs::UserCurrentListFollowingQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ api.user_current_list_following(query).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) => {
+ let query = forgejo_api::structs::UserListFollowersQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ api.user_list_followers(user, query).await?
+ }
+ None => {
+ let query = forgejo_api::structs::UserCurrentListFollowersQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ api.user_current_list_followers(query).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>,
+) -> eyre::Result<()> {
+ let mut repos = if starred {
+ match user {
+ Some(user) => {
+ let query = forgejo_api::structs::UserListStarredQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ api.user_list_starred(user, query).await?
+ }
+ None => {
+ let query = forgejo_api::structs::UserCurrentListStarredQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ api.user_current_list_starred(query).await?
+ }
+ }
+ } else {
+ match user {
+ Some(user) => {
+ let query = forgejo_api::structs::UserListReposQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ api.user_list_repos(user, query).await?
+ }
+ None => {
+ let query = forgejo_api::structs::UserCurrentListReposQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ api.user_current_list_repos(query).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, .. } = *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}");
+ }
+ if repos.len() == 1 {
+ println!("1 repo");
+ } else {
+ println!("{} repos", repos.len());
+ }
+ }
+
+ Ok(())
+}
+
+async fn list_orgs(api: &Forgejo, user: Option<&str>) -> eyre::Result<()> {
+ let mut orgs = match user {
+ Some(user) => {
+ let query = forgejo_api::structs::OrgListUserOrgsQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ api.org_list_user_orgs(user, query).await?
+ }
+ None => {
+ let query = forgejo_api::structs::OrgListCurrentUserOrgsQuery {
+ limit: Some(u32::MAX),
+ ..Default::default()
+ };
+ api.org_list_current_user_orgs(query).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?;
+
+ let SpecialRender {
+ bold,
+ yellow,
+ bright_cyan,
+ reset,
+ ..
+ } = *crate::special_render();
+
+ for activity in feed {
+ 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")?;
+
+ // do not add ? to these. they are here to make each branch smaller
+ 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
+ .rsplit_once("/")
+ .map(|(_, b)| b)
+ .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
+ .rsplit_once("/")
+ .map(|(_, b)| b)
+ .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
+ .rsplit_once("/")
+ .map(|(_, b)| b)
+ .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
+ .rsplit_once("/")
+ .map(|(_, b)| b)
+ .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,
+ 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(())
+}
diff --git a/src/wiki.rs b/src/wiki.rs
new file mode 100644
index 0000000..63d344f
--- /dev/null
+++ b/src/wiki.rs
@@ -0,0 +1,158 @@
+use std::path::PathBuf;
+
+use base64ct::Encoding;
+use clap::{Args, Subcommand};
+use eyre::{Context, OptionExt};
+use forgejo_api::Forgejo;
+
+use crate::{
+ repo::{RepoArg, RepoInfo, RepoName},
+ SpecialRender,
+};
+
+#[derive(Args, Clone, Debug)]
+pub struct WikiCommand {
+ /// The local git remote that points to the repo to operate on.
+ #[clap(long, short = 'R')]
+ remote: Option<String>,
+ #[clap(subcommand)]
+ command: WikiSubcommand,
+}
+
+#[derive(Subcommand, Clone, Debug)]
+pub enum WikiSubcommand {
+ Contents {
+ repo: Option<RepoArg>,
+ },
+ View {
+ #[clap(long, short)]
+ repo: Option<RepoArg>,
+ page: String,
+ },
+ Clone {
+ repo: Option<RepoArg>,
+ #[clap(long, short)]
+ path: Option<PathBuf>,
+ },
+ Browse {
+ #[clap(long, short)]
+ repo: Option<RepoArg>,
+ page: String,
+ },
+}
+
+impl WikiCommand {
+ pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> {
+ use WikiSubcommand::*;
+
+ let repo = RepoInfo::get_current(host_name, self.repo(), self.remote.as_deref())?;
+ let api = keys.get_api(repo.host_url()).await?;
+ let repo = repo
+ .name()
+ .ok_or_else(|| eyre::eyre!("couldn't guess repo"))?;
+
+ match self.command {
+ Contents { repo: _ } => wiki_contents(&repo, &api).await?,
+ View { repo: _, page } => view_wiki_page(&repo, &api, &*page).await?,
+ Clone { repo: _, path } => clone_wiki(&repo, &api, path).await?,
+ Browse { repo: _, page } => browse_wiki_page(&repo, &api, &*page).await?,
+ }
+ Ok(())
+ }
+
+ fn repo(&self) -> Option<&RepoArg> {
+ use WikiSubcommand::*;
+ match &self.command {
+ Contents { repo } | View { repo, .. } | Clone { repo, .. } | Browse { repo, .. } => {
+ repo.as_ref()
+ }
+ }
+ }
+}
+
+async fn wiki_contents(repo: &RepoName, api: &Forgejo) -> eyre::Result<()> {
+ let SpecialRender { bullet, .. } = *crate::special_render();
+
+ let query = forgejo_api::structs::RepoGetWikiPagesQuery {
+ page: None,
+ limit: None,
+ };
+ let pages = api
+ .repo_get_wiki_pages(repo.owner(), repo.name(), query)
+ .await?;
+ for page in pages {
+ let title = page
+ .title
+ .as_deref()
+ .ok_or_eyre("page does not have title")?;
+ println!("{bullet} {title}");
+ }
+
+ Ok(())
+}
+
+async fn view_wiki_page(repo: &RepoName, api: &Forgejo, page: &str) -> eyre::Result<()> {
+ let SpecialRender { bold, reset, .. } = *crate::special_render();
+
+ let page = api
+ .repo_get_wiki_page(repo.owner(), repo.name(), page)
+ .await?;
+
+ let title = page
+ .title
+ .as_deref()
+ .ok_or_eyre("page does not have title")?;
+ println!("{bold}{title}{reset}");
+ println!();
+
+ let contents_b64 = page
+ .content_base64
+ .as_deref()
+ .ok_or_eyre("page does not have content")?;
+ let contents = String::from_utf8(base64ct::Base64::decode_vec(contents_b64)?)
+ .wrap_err("page content is not utf-8")?;
+
+ println!("{}", crate::markdown(&contents));
+ Ok(())
+}
+
+async fn browse_wiki_page(repo: &RepoName, api: &Forgejo, page: &str) -> eyre::Result<()> {
+ let page = api
+ .repo_get_wiki_page(repo.owner(), repo.name(), page)
+ .await?;
+ let html_url = page
+ .html_url
+ .as_ref()
+ .ok_or_eyre("page does not have html url")?;
+ open::that(html_url.as_str())?;
+ Ok(())
+}
+
+async fn clone_wiki(repo: &RepoName, api: &Forgejo, path: Option<PathBuf>) -> eyre::Result<()> {
+ let repo_data = api.repo_get(repo.owner(), repo.name()).await?;
+ let clone_url = repo_data
+ .clone_url
+ .as_ref()
+ .ok_or_eyre("repo does not have clone url")?;
+ let git_stripped = clone_url
+ .as_str()
+ .strip_suffix(".git")
+ .unwrap_or(clone_url.as_str());
+ let clone_url = url::Url::parse(&format!("{}.wiki.git", git_stripped))?;
+
+ let repo_name = repo_data
+ .name
+ .as_deref()
+ .ok_or_eyre("repo does not have name")?;
+ let repo_full_name = repo_data
+ .full_name
+ .as_deref()
+ .ok_or_eyre("repo does not have full name")?;
+ let name = format!("{}'s wiki", repo_full_name);
+
+ let path = path.unwrap_or_else(|| PathBuf::from(format!("./{repo_name}-wiki")));
+
+ crate::repo::clone_repo(&name, &clone_url, &path)?;
+
+ Ok(())
+}