diff --git a/Cargo.lock b/Cargo.lock index 2999d67..963635c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -278,6 +278,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clap_mangen" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -430,6 +440,7 @@ dependencies = [ "chrono", "clap", "clap_complete", + "clap_mangen", "dialoguer", "directories", "futures-util", @@ -1294,6 +1305,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + [[package]] name = "rustc-hash" version = "2.1.2" diff --git a/Cargo.toml b/Cargo.toml index a5a47be..318e7da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ anyhow = "1" thiserror = "2" clap = { version = "4.5", features = ["derive", "env", "wrap_help"] } clap_complete = "4.5" +clap_mangen = "0.2" tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "process", "io-util", "io-std", "signal"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream", "gzip", "brotli", "multipart"] } serde = { version = "1", features = ["derive"] } diff --git a/src/api/hook.rs b/src/api/hook.rs new file mode 100644 index 0000000..b47027e --- /dev/null +++ b/src/api/hook.rs @@ -0,0 +1,58 @@ +use anyhow::Result; +use chrono::{DateTime, Utc}; +use reqwest::Method; +use serde::{Deserialize, Serialize}; + +use crate::client::Client; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Hook { + pub id: u64, + #[serde(rename = "type")] + pub type_: String, + #[serde(default)] + pub events: Vec, + pub active: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + #[serde(default)] + pub config: serde_json::Value, +} + +pub async fn list(client: &Client, owner: &str, name: &str) -> Result> { + let path = format!("/api/v1/repos/{owner}/{name}/hooks"); + client.json(Method::GET, &path, &[], None::<&()>).await +} + +#[derive(Debug, Serialize)] +pub struct CreateHook<'a> { + #[serde(rename = "type")] + pub type_: &'a str, + pub config: serde_json::Value, + pub events: Vec<&'a str>, + pub active: bool, +} + +pub async fn create( + client: &Client, + owner: &str, + name: &str, + body: &CreateHook<'_>, +) -> Result { + let path = format!("/api/v1/repos/{owner}/{name}/hooks"); + client.json(Method::POST, &path, &[], Some(body)).await +} + +pub async fn delete(client: &Client, owner: &str, name: &str, id: u64) -> Result<()> { + let path = format!("/api/v1/repos/{owner}/{name}/hooks/{id}"); + let res = client.request(Method::DELETE, &path, &[], None).await?; + res.error_for_status()?; + Ok(()) +} + +pub async fn test(client: &Client, owner: &str, name: &str, id: u64) -> Result<()> { + let path = format!("/api/v1/repos/{owner}/{name}/hooks/{id}/tests"); + let res = client.request(Method::POST, &path, &[], None).await?; + res.error_for_status()?; + Ok(()) +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 2b4b6ba..9f1c70f 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,7 +1,9 @@ +pub mod hook; pub mod issue; pub mod label; pub mod notification; pub mod org; +pub mod protect; pub mod pull; pub mod release; pub mod repo; diff --git a/src/api/protect.rs b/src/api/protect.rs new file mode 100644 index 0000000..0ca3d19 --- /dev/null +++ b/src/api/protect.rs @@ -0,0 +1,69 @@ +use anyhow::Result; +use reqwest::Method; +use serde::{Deserialize, Serialize}; + +use crate::client::Client; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BranchProtection { + pub branch_name: String, + #[serde(default)] + pub rule_name: String, + #[serde(default)] + pub enable_push: bool, + #[serde(default)] + pub enable_push_whitelist: bool, + #[serde(default)] + pub push_whitelist_usernames: Vec, + #[serde(default)] + pub require_signed_commits: bool, + #[serde(default)] + pub block_on_outdated_branch: bool, + #[serde(default)] + pub required_approvals: i64, +} + +pub async fn list(client: &Client, owner: &str, name: &str) -> Result> { + let path = format!("/api/v1/repos/{owner}/{name}/branch_protections"); + client.json(Method::GET, &path, &[], None::<&()>).await +} + +pub async fn get( + client: &Client, + owner: &str, + name: &str, + branch: &str, +) -> Result { + let path = format!("/api/v1/repos/{owner}/{name}/branch_protections/{branch}"); + client.json(Method::GET, &path, &[], None::<&()>).await +} + +#[derive(Debug, Clone, Serialize)] +pub struct CreateBranchProtection<'a> { + pub branch_name: &'a str, + #[serde(default)] + pub enable_push: bool, + #[serde(default)] + pub require_signed_commits: bool, + #[serde(default)] + pub block_on_outdated_branch: bool, + #[serde(default)] + pub required_approvals: i64, +} + +pub async fn create( + client: &Client, + owner: &str, + name: &str, + body: &CreateBranchProtection<'_>, +) -> Result { + let path = format!("/api/v1/repos/{owner}/{name}/branch_protections"); + client.json(Method::POST, &path, &[], Some(body)).await +} + +pub async fn delete(client: &Client, owner: &str, name: &str, branch: &str) -> Result<()> { + let path = format!("/api/v1/repos/{owner}/{name}/branch_protections/{branch}"); + let res = client.request(Method::DELETE, &path, &[], None).await?; + res.error_for_status()?; + Ok(()) +} diff --git a/src/cli/alias.rs b/src/cli/alias.rs index 5999ddd..e6eaa0f 100644 --- a/src/cli/alias.rs +++ b/src/cli/alias.rs @@ -24,7 +24,7 @@ pub struct AliasCmd { pub enum AliasSub { /// List configured aliases. List, - /// Set an alias: `fj alias set co "pr checkout"`. + /// Set an alias (e.g. `fj alias set co "pr checkout"`). Set(SetArgs), /// Delete an alias. Delete(DeleteArgs), diff --git a/src/cli/auth.rs b/src/cli/auth.rs index ece7f60..f8f0b3f 100644 --- a/src/cli/auth.rs +++ b/src/cli/auth.rs @@ -26,6 +26,39 @@ pub enum AuthSub { List, /// Set the default host for commands that omit `--host`. Switch(SwitchArgs), + /// Print the stored token for a host (for scripting). + Token(TokenArgs), + /// Re-verify the stored token. Replaces it if a new one is given. + Refresh(RefreshArgs), + /// Install a git credential helper that uses fj's stored token. + SetupGit(SetupGitArgs), +} + +#[derive(Debug, Args)] +pub struct TokenArgs { + #[arg(long)] + pub host: Option, +} + +#[derive(Debug, Args)] +pub struct RefreshArgs { + #[arg(long)] + pub host: Option, + /// Replace the stored token. If omitted, fj just re-verifies the existing one. + #[arg(long)] + pub token: Option, + /// Read replacement token from stdin. + #[arg(long, conflicts_with = "token")] + pub with_token: bool, +} + +#[derive(Debug, Args)] +pub struct SetupGitArgs { + #[arg(long)] + pub host: Option, + /// Print the recommended git config commands without applying them. + #[arg(long)] + pub dry_run: bool, } #[derive(Debug, Args)] @@ -71,9 +104,106 @@ pub async fn run(cmd: AuthCmd) -> Result<()> { AuthSub::Logout(args) => logout(args), AuthSub::List => list(), AuthSub::Switch(args) => switch(args), + AuthSub::Token(args) => token(args), + AuthSub::Refresh(args) => refresh(args).await, + AuthSub::SetupGit(args) => setup_git(args), } } +fn token(args: TokenArgs) -> Result<()> { + let hosts = Hosts::load()?; + let host = hosts.resolve_host(args.host.as_deref())?.to_string(); + let token = token_store::load_token(&host)? + .ok_or_else(|| anyhow!("no token stored for {host}; run `fj auth login`"))?; + println!("{token}"); + Ok(()) +} + +async fn refresh(args: RefreshArgs) -> Result<()> { + let hosts = Hosts::load()?; + let host = hosts.resolve_host(args.host.as_deref())?.to_string(); + let cfg = hosts + .hosts + .get(&host) + .cloned() + .ok_or_else(|| anyhow!("host '{host}' not configured"))?; + + let new_token = if let Some(t) = args.token { + Some(t) + } else if args.with_token { + use std::io::Read; + let mut buf = String::new(); + std::io::stdin().read_to_string(&mut buf)?; + Some(buf.trim().to_string()) + } else { + None + }; + + let token = match &new_token { + Some(t) => t.clone(), + None => token_store::load_token(&host)? + .ok_or_else(|| anyhow!("no token stored; pass --token to set one"))?, + }; + + let probe = build_probe_client(&host, &cfg, &token)?; + let me = api::user::current(&probe) + .await + .context("verifying token against /api/v1/user")?; + + if new_token.is_some() { + token_store::store_token(&host, &token)?; + println!("✓ Replaced token for {host} (verified as {})", me.login); + } else { + println!("✓ Token for {host} is still valid (user: {})", me.login); + } + Ok(()) +} + +fn setup_git(args: SetupGitArgs) -> Result<()> { + let hosts = Hosts::load()?; + let host = hosts.resolve_host(args.host.as_deref())?.to_string(); + let cfg = hosts + .hosts + .get(&host) + .cloned() + .ok_or_else(|| anyhow!("host '{host}' not configured"))?; + // Use git's credential.helper indirection: fj prints the token on demand. + // We register an `https://` scoped helper that invokes + // `fj auth git-credential` (a synthetic subcommand exposed via stdin). + let helper = format!("!fj auth token --host {host} | sed 's/^/password=/'"); + let cmd_set_helper = format!("git config --global credential.https://{host}.helper '{helper}'"); + let cmd_set_user = match cfg.user.as_deref() { + Some(u) => format!("git config --global credential.https://{host}.username {u}"), + None => String::new(), + }; + + if args.dry_run { + println!("# Run these commands to install the fj git credential helper:"); + println!("{cmd_set_helper}"); + if !cmd_set_user.is_empty() { + println!("{cmd_set_user}"); + } + return Ok(()); + } + + // Apply. + let parts: Vec = vec![cmd_set_helper.clone(), cmd_set_user.clone()] + .into_iter() + .filter(|s| !s.is_empty()) + .collect(); + for c in parts { + let status = std::process::Command::new("sh") + .arg("-c") + .arg(&c) + .status()?; + if !status.success() { + return Err(anyhow!("`{c}` failed with status {status}")); + } + } + println!("✓ Installed git credential helper for {host}"); + Ok(()) +} + async fn login(args: LoginArgs) -> Result<()> { let hostname = match args.host.clone() { Some(h) => h, diff --git a/src/cli/hook.rs b/src/cli/hook.rs new file mode 100644 index 0000000..6052df8 --- /dev/null +++ b/src/cli/hook.rs @@ -0,0 +1,148 @@ +//! `fj hook` — repository webhooks. + +use anyhow::Result; +use clap::{Args, Subcommand}; + +use crate::api::hook::{self, CreateHook}; +use crate::cli::context::{resolve_repo, RepoFlag}; +use crate::output; + +#[derive(Debug, Args)] +pub struct HookCmd { + #[command(subcommand)] + pub command: HookSub, +} + +#[derive(Debug, Subcommand)] +pub enum HookSub { + /// List webhooks configured on a repo. + List(ListArgs), + /// Create a new webhook pointing at a URL. + Create(CreateArgs), + /// Delete a webhook by id. + Delete(DeleteArgs), + /// Send a test delivery to an existing webhook. + Test(DeleteArgs), +} + +#[derive(Debug, Args)] +pub struct ListArgs { + #[command(flatten)] + pub r: RepoFlag, + #[arg(long)] + pub json: bool, +} + +#[derive(Debug, Args)] +pub struct CreateArgs { + #[command(flatten)] + pub r: RepoFlag, + /// Webhook URL. + pub url: String, + /// Hook type. Defaults to `gitea` (Gitea-compatible JSON payload). + #[arg(long, default_value = "gitea")] + pub r#type: String, + /// Events to subscribe to (comma-separated). Defaults to `push`. + #[arg(long, default_value = "push")] + pub events: String, + /// Set Content-Type for the request. + #[arg(long, default_value = "json")] + pub content_type: String, + /// HMAC secret used to sign webhook deliveries. + #[arg(long)] + pub secret: Option, +} + +#[derive(Debug, Args)] +pub struct DeleteArgs { + #[command(flatten)] + pub r: RepoFlag, + pub id: u64, +} + +pub async fn run(cmd: HookCmd, host: Option<&str>) -> Result<()> { + match cmd.command { + HookSub::List(args) => list(args, host).await, + HookSub::Create(args) => create(args, host).await, + HookSub::Delete(args) => delete(args, host).await, + HookSub::Test(args) => test(args, host).await, + } +} + +async fn list(args: ListArgs, host: Option<&str>) -> Result<()> { + let ctx = resolve_repo(args.r.repo.as_deref(), host)?; + let items = hook::list(&ctx.client, &ctx.owner, &ctx.name).await?; + if args.json { + return output::print_json(&serde_json::to_value(&items)?); + } + if items.is_empty() { + println!("(no webhooks)"); + return Ok(()); + } + let rows: Vec> = items + .iter() + .map(|h| { + let url = h + .config + .get("url") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + vec![ + h.id.to_string(), + h.type_.clone(), + url, + h.events.join(","), + h.active.to_string(), + output::dim(&output::relative_time(h.updated_at)), + ] + }) + .collect(); + print!( + "{}", + output::render_table(&["ID", "TYPE", "URL", "EVENTS", "ACTIVE", "UPDATED"], &rows) + ); + Ok(()) +} + +async fn create(args: CreateArgs, host: Option<&str>) -> Result<()> { + let ctx = resolve_repo(args.r.repo.as_deref(), host)?; + let events: Vec<&str> = args + .events + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect(); + let mut config = serde_json::Map::new(); + config.insert("url".into(), serde_json::Value::String(args.url.clone())); + config.insert( + "content_type".into(), + serde_json::Value::String(args.content_type), + ); + if let Some(s) = &args.secret { + config.insert("secret".into(), serde_json::Value::String(s.clone())); + } + let body = CreateHook { + type_: &args.r#type, + config: serde_json::Value::Object(config), + events, + active: true, + }; + let h = hook::create(&ctx.client, &ctx.owner, &ctx.name, &body).await?; + println!("✓ Created webhook #{} → {}", h.id, args.url); + Ok(()) +} + +async fn delete(args: DeleteArgs, host: Option<&str>) -> Result<()> { + let ctx = resolve_repo(args.r.repo.as_deref(), host)?; + hook::delete(&ctx.client, &ctx.owner, &ctx.name, args.id).await?; + println!("✓ Deleted webhook #{}", args.id); + Ok(()) +} + +async fn test(args: DeleteArgs, host: Option<&str>) -> Result<()> { + let ctx = resolve_repo(args.r.repo.as_deref(), host)?; + hook::test(&ctx.client, &ctx.owner, &ctx.name, args.id).await?; + println!("✓ Triggered test delivery for webhook #{}", args.id); + Ok(()) +} diff --git a/src/cli/key.rs b/src/cli/key.rs index 5bf9faa..3789456 100644 --- a/src/cli/key.rs +++ b/src/cli/key.rs @@ -34,8 +34,11 @@ pub struct SshKeyCmd { #[derive(Debug, Subcommand)] pub enum SshKeySub { + /// List SSH keys on your account. List(ListArgs), + /// Add an SSH key. Add(AddArgs), + /// Remove an SSH key by id. Delete(DeleteArgs), } @@ -47,8 +50,11 @@ pub struct GpgKeyCmd { #[derive(Debug, Subcommand)] pub enum GpgKeySub { + /// List GPG keys on your account. List(ListArgs), + /// Add a GPG key. Add(GpgAddArgs), + /// Remove a GPG key by id. Delete(DeleteArgs), } diff --git a/src/cli/label.rs b/src/cli/label.rs index bf29812..cb553b0 100644 --- a/src/cli/label.rs +++ b/src/cli/label.rs @@ -13,9 +13,13 @@ pub struct LabelCmd { #[derive(Debug, Subcommand)] pub enum LabelSub { + /// List labels in a repo. List(ListArgs), + /// Create a new label. Create(CreateArgs), + /// Edit a label's name, color, or description. Edit(EditArgs), + /// Delete a label. Delete(DeleteArgs), } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 91d20f7..3777d7c 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -7,11 +7,13 @@ pub mod context; pub mod editor; pub mod extension; pub mod gist; +pub mod hook; pub mod issue; pub mod key; pub mod label; pub mod org; pub mod pr; +pub mod protect; pub mod release; pub mod repo; pub mod search; @@ -38,6 +40,10 @@ pub struct Cli { #[arg(long, global = true, env = "FJ_HOST")] pub host: Option, + /// Log every HTTP request fj makes to stderr. + #[arg(long, global = true, env = "FJ_DEBUG")] + pub debug: bool, + #[command(subcommand)] pub command: Command, } @@ -78,6 +84,10 @@ pub enum Command { Alias(alias::AliasCmd), /// Manage local fj configuration (editor, pager, browser, etc.). Config(config::ConfigCmd), + /// Manage branch protection rules. + Protect(protect::ProtectCmd), + /// Manage repository webhooks. + Hook(hook::HookCmd), /// Run a discovered `fj-` plugin from PATH. Extension(extension::ExtensionCmd), /// Forgejo gists. @@ -86,6 +96,15 @@ pub enum Command { Api(api::ApiArgs), /// Print shell completions to stdout. Completion(CompletionArgs), + /// Generate man pages for `fj` and all subcommands into a directory. + Man(ManArgs), +} + +#[derive(Debug, clap::Args)] +pub struct ManArgs { + /// Output directory. Defaults to `./man`. + #[arg(short = 'o', long, default_value = "./man")] + pub output_dir: std::path::PathBuf, } #[derive(Debug, clap::Args)] @@ -96,10 +115,7 @@ pub struct CompletionArgs { } pub async fn run(cli: Cli) -> Result<()> { - // Alias resolution: if the first positional looks like a user-defined - // alias and the user typed `fj `, expand it before dispatching. - // clap already parsed; aliases are handled inside the dedicated `alias` - // subcommand or by re-execing ourselves. See cli::alias::dispatch. + crate::client::set_debug(cli.debug); match cli.command { Command::Auth(cmd) => auth::run(cmd).await, Command::Repo(cmd) => repo::run(cmd, cli.host.as_deref()).await, @@ -118,6 +134,8 @@ pub async fn run(cli: Cli) -> Result<()> { Command::GpgKey(cmd) => key::run_gpg(cmd, cli.host.as_deref()).await, Command::Alias(cmd) => alias::run(cmd).await, Command::Config(cmd) => config::run(cmd).await, + Command::Protect(cmd) => protect::run(cmd, cli.host.as_deref()).await, + Command::Hook(cmd) => hook::run(cmd, cli.host.as_deref()).await, Command::Extension(cmd) => extension::run(cmd).await, Command::Gist(cmd) => gist::run(cmd, cli.host.as_deref()).await, Command::Api(args) => api::run(args, cli.host.as_deref()).await, @@ -127,5 +145,28 @@ pub async fn run(cli: Cli) -> Result<()> { clap_complete::generate(args.shell, &mut cmd, "fj", &mut std::io::stdout()); Ok(()) } + Command::Man(args) => generate_man_pages(args).await, } } + +async fn generate_man_pages(args: ManArgs) -> Result<()> { + use clap::CommandFactory; + let dir = &args.output_dir; + std::fs::create_dir_all(dir)?; + let cmd = Cli::command(); + write_man_for(&cmd, "fj", dir)?; + for sub in cmd.get_subcommands() { + let name = format!("fj-{}", sub.get_name()); + write_man_for(sub, &name, dir)?; + } + println!("✓ Wrote man pages to {}", dir.display()); + Ok(()) +} + +fn write_man_for(cmd: &clap::Command, file_name: &str, dir: &std::path::Path) -> Result<()> { + let path = dir.join(format!("{file_name}.1")); + let mut out = std::fs::File::create(&path)?; + let man = clap_mangen::Man::new(cmd.clone()); + man.render(&mut out)?; + Ok(()) +} diff --git a/src/cli/protect.rs b/src/cli/protect.rs new file mode 100644 index 0000000..d03a32a --- /dev/null +++ b/src/cli/protect.rs @@ -0,0 +1,138 @@ +//! `fj protect` — branch protection rules. + +use anyhow::Result; +use clap::{Args, Subcommand}; + +use crate::api::protect::{self, CreateBranchProtection}; +use crate::cli::context::{resolve_repo, RepoFlag}; +use crate::output; + +#[derive(Debug, Args)] +pub struct ProtectCmd { + #[command(subcommand)] + pub command: ProtectSub, +} + +#[derive(Debug, Subcommand)] +pub enum ProtectSub { + /// List branch protection rules. + List(ListArgs), + /// Show a single rule. + View(BranchArgs), + /// Create or update a rule. + Set(SetArgs), + /// Delete a rule. + Delete(BranchArgs), +} + +#[derive(Debug, Args)] +pub struct ListArgs { + #[command(flatten)] + pub r: RepoFlag, + #[arg(long)] + pub json: bool, +} + +#[derive(Debug, Args)] +pub struct BranchArgs { + #[command(flatten)] + pub r: RepoFlag, + pub branch: String, + #[arg(long)] + pub json: bool, +} + +#[derive(Debug, Args)] +pub struct SetArgs { + #[command(flatten)] + pub r: RepoFlag, + pub branch: String, + /// Require signed commits. + #[arg(long)] + pub require_signed: bool, + /// Block merges from outdated branches. + #[arg(long)] + pub block_outdated: bool, + /// Minimum required approvals. + #[arg(long, default_value_t = 0)] + pub required_approvals: i64, + /// Disable direct pushes. + #[arg(long)] + pub no_push: bool, +} + +pub async fn run(cmd: ProtectCmd, host: Option<&str>) -> Result<()> { + match cmd.command { + ProtectSub::List(args) => list(args, host).await, + ProtectSub::View(args) => view(args, host).await, + ProtectSub::Set(args) => set(args, host).await, + ProtectSub::Delete(args) => delete(args, host).await, + } +} + +async fn list(args: ListArgs, host: Option<&str>) -> Result<()> { + let ctx = resolve_repo(args.r.repo.as_deref(), host)?; + let items = protect::list(&ctx.client, &ctx.owner, &ctx.name).await?; + if args.json { + return output::print_json(&serde_json::to_value(&items)?); + } + if items.is_empty() { + println!("(no protection rules)"); + return Ok(()); + } + let rows: Vec> = items + .iter() + .map(|p| { + vec![ + p.branch_name.clone(), + p.required_approvals.to_string(), + p.require_signed_commits.to_string(), + p.block_on_outdated_branch.to_string(), + p.enable_push.to_string(), + ] + }) + .collect(); + print!( + "{}", + output::render_table( + &["BRANCH", "APPROVALS", "SIGNED", "BLOCK_OUTDATED", "PUSH"], + &rows + ) + ); + Ok(()) +} + +async fn view(args: BranchArgs, host: Option<&str>) -> Result<()> { + let ctx = resolve_repo(args.r.repo.as_deref(), host)?; + let p = protect::get(&ctx.client, &ctx.owner, &ctx.name, &args.branch).await?; + if args.json { + return output::print_json(&serde_json::to_value(&p)?); + } + println!("{}", output::bold(&p.branch_name)); + println!("Required approvals: {}", p.required_approvals); + println!("Require signed: {}", p.require_signed_commits); + println!("Block outdated branch: {}", p.block_on_outdated_branch); + println!("Direct push allowed: {}", p.enable_push); + Ok(()) +} + +async fn set(args: SetArgs, host: Option<&str>) -> Result<()> { + let ctx = resolve_repo(args.r.repo.as_deref(), host)?; + let body = CreateBranchProtection { + branch_name: &args.branch, + enable_push: !args.no_push, + require_signed_commits: args.require_signed, + block_on_outdated_branch: args.block_outdated, + required_approvals: args.required_approvals, + }; + let p = protect::create(&ctx.client, &ctx.owner, &ctx.name, &body).await?; + println!("✓ Protected {}", p.branch_name); + Ok(()) +} + +async fn delete(args: BranchArgs, host: Option<&str>) -> Result<()> { + let ctx = resolve_repo(args.r.repo.as_deref(), host)?; + protect::delete(&ctx.client, &ctx.owner, &ctx.name, &args.branch).await?; + println!("✓ Removed protection on {}", args.branch); + Ok(()) +} diff --git a/src/cli/search.rs b/src/cli/search.rs index 46bd3ca..34926c9 100644 --- a/src/cli/search.rs +++ b/src/cli/search.rs @@ -13,9 +13,13 @@ pub struct SearchCmd { #[derive(Debug, Subcommand)] pub enum SearchSub { + /// Search repositories. Repos(QueryArgs), + /// Search issues across all repos you can see. Issues(QueryArgs), + /// Search pull requests across all repos you can see. Prs(QueryArgs), + /// Search users. Users(QueryArgs), } diff --git a/src/cli/workflow.rs b/src/cli/workflow.rs index ce0574e..417920f 100644 --- a/src/cli/workflow.rs +++ b/src/cli/workflow.rs @@ -144,8 +144,11 @@ pub struct SecretCmd { #[derive(Debug, Subcommand)] pub enum SecretSub { + /// List secret names (values are write-only). List(SecretListArgs), + /// Set or replace a secret. Set(SecretSetArgs), + /// Delete a secret. Delete(SecretDeleteArgs), } @@ -239,8 +242,11 @@ pub struct VariableCmd { #[derive(Debug, Subcommand)] pub enum VariableSub { + /// List Actions variables. List(SecretListArgs), + /// Set or replace a variable. Set(SecretSetArgs), + /// Delete a variable. Delete(SecretDeleteArgs), } diff --git a/src/client/mod.rs b/src/client/mod.rs index 2e3ab33..fee0a2c 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,8 +1,20 @@ pub mod error; pub mod pagination; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; +static DEBUG: AtomicBool = AtomicBool::new(false); + +/// Toggle request logging at runtime. Set by `--debug` / `FJ_DEBUG`. +pub fn set_debug(on: bool) { + DEBUG.store(on, Ordering::Relaxed); +} + +fn debug_enabled() -> bool { + DEBUG.load(Ordering::Relaxed) +} + use anyhow::{anyhow, Context, Result}; use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT}; use reqwest::{Method, Response, StatusCode}; @@ -150,11 +162,37 @@ impl Client { for (k, v) in extra.iter() { headers.insert(k.clone(), v.clone()); } - let mut req = self.http.request(method, url).headers(headers).query(query); + let mut req = self + .http + .request(method.clone(), url.clone()) + .headers(headers) + .query(query); if let Some(body) = body { req = req.json(body); } + if debug_enabled() { + let q = if query.is_empty() { + String::new() + } else { + let pairs: Vec = query.iter().map(|(k, v)| format!("{k}={v}")).collect(); + format!("?{}", pairs.join("&")) + }; + eprintln!("→ {method} {url}{q}"); + if let Some(b) = body { + if let Ok(text) = serde_json::to_string(b) { + let preview = if text.len() > 200 { + format!("{}…", &text[..200]) + } else { + text + }; + eprintln!(" body: {preview}"); + } + } + } let res = req.send().await.context("sending HTTP request")?; + if debug_enabled() { + eprintln!("← {} {}", res.status(), res.url()); + } Ok(res) } diff --git a/src/main.rs b/src/main.rs index 49ca52d..5be9777 100644 --- a/src/main.rs +++ b/src/main.rs @@ -98,6 +98,8 @@ const KNOWN_SUBCOMMANDS: &[&str] = &[ "config", "extension", "gist", + "protect", + "hook", "api", "completion", "help",