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:
parent
de49c33921
commit
35d88bb370
17
Cargo.lock
generated
17
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
58
src/api/hook.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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
69
src/api/protect.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
130
src/cli/auth.rs
130
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<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
148
src/cli/hook.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
138
src/cli/protect.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -98,6 +98,8 @@ const KNOWN_SUBCOMMANDS: &[&str] = &[
|
|||
"config",
|
||||
"extension",
|
||||
"gist",
|
||||
"protect",
|
||||
"hook",
|
||||
"api",
|
||||
"completion",
|
||||
"help",
|
||||
|
|
|
|||
Loading…
Reference in a new issue