expand: auth token/refresh/setup-git, protect, hook, --debug, man pages

* `fj auth token` prints the stored token for scripting.
* `fj auth refresh` re-verifies (or replaces) the stored token.
* `fj auth setup-git` installs a git credential helper that delegates
  password lookup to `fj auth token`.
* New top-level `fj protect` group: list / view / set / delete branch
  protection rules.
* New top-level `fj hook` group: webhook CRUD + test delivery.
* Global `--debug` (`FJ_DEBUG`) flag dumps every HTTP request fj makes
  to stderr (method, URL, query, body preview, status).
* `fj man -o ./man` generates `clap_mangen` pages for fj and every
  subcommand. Useful for downstream packaging.
* Fixed: several subcommand groups (hook, label, search, key, workflow
  secrets/variables) were showing the flattened RepoFlag's doc string
  in their --help summary line. Added explicit doc comments per variant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stephen Way 2026-05-13 08:34:01 -07:00
parent de49c33921
commit 35d88bb370
No known key found for this signature in database
16 changed files with 670 additions and 6 deletions

17
Cargo.lock generated
View file

@ -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"

View file

@ -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"] }

58
src/api/hook.rs Normal file
View file

@ -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<String>,
pub active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(default)]
pub config: serde_json::Value,
}
pub async fn list(client: &Client, owner: &str, name: &str) -> Result<Vec<Hook>> {
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<Hook> {
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(())
}

View file

@ -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;

69
src/api/protect.rs Normal file
View file

@ -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<String>,
#[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<Vec<BranchProtection>> {
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<BranchProtection> {
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<BranchProtection> {
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(())
}

View file

@ -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),

View file

@ -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<String>,
}
#[derive(Debug, Args)]
pub struct RefreshArgs {
#[arg(long)]
pub host: Option<String>,
/// Replace the stored token. If omitted, fj just re-verifies the existing one.
#[arg(long)]
pub token: Option<String>,
/// 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<String>,
/// 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://<host>` 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<String> = 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,

148
src/cli/hook.rs Normal file
View file

@ -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<String>,
}
#[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<Vec<String>> = 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(())
}

View file

@ -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),
}

View file

@ -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),
}

View file

@ -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<String>,
/// 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-<name>` 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 <alias>`, 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(())
}

138
src/cli/protect.rs Normal file
View file

@ -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<Vec<String>> = 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(())
}

View file

@ -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),
}

View file

@ -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),
}

View file

@ -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<String> = 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)
}

View file

@ -98,6 +98,8 @@ const KNOWN_SUBCOMMANDS: &[&str] = &[
"config",
"extension",
"gist",
"protect",
"hook",
"api",
"completion",
"help",