batch: release, label, workflow, search, browse, status, org, keys, alias, config, extension, gist

* New top-level groups, each with full CRUD where the API supports it:
  - release: list/view/create/edit/delete/upload/download
  - label: list/create/edit/delete
  - run: workflow runs (list/view/rerun/cancel)
  - secret + variable: Actions secrets/vars (list/set/delete)
  - search: cross-cutting (repos/issues/prs/users)
  - browse: open repo/path on the web
  - status: notifications inbox + mark-all-read
  - org: list/view/teams
  - ssh-key, gpg-key: list/add/delete on your account
  - alias: user-defined shortcuts (e.g. `fj alias set co "pr checkout"`)
  - config: local prefs (editor, pager, browser, etc.)
  - extension: discover and run `fj-<name>` plugin binaries on PATH
  - gist: thin wrapper over `gist-*` repos
* main.rs now expands aliases before clap and dispatches to plugins for
  unknown subcommands (matching gh).
* New API modules: release, label, notification, search, org, workflow,
  with the corresponding strongly-typed wrappers.
* Release asset upload uses reqwest multipart (feature flag added).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stephen Way 2026-05-13 08:29:31 -07:00
parent 191d941c78
commit de49c33921
No known key found for this signature in database
27 changed files with 2723 additions and 38 deletions

23
Cargo.lock generated
View file

@ -950,6 +950,22 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]] [[package]]
name = "minimal-lexical" name = "minimal-lexical"
version = "0.2.1" version = "0.2.1"
@ -1240,6 +1256,7 @@ dependencies = [
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"mime_guess",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn", "quinn",
@ -1855,6 +1872,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"

View file

@ -19,7 +19,7 @@ thiserror = "2"
clap = { version = "4.5", features = ["derive", "env", "wrap_help"] } clap = { version = "4.5", features = ["derive", "env", "wrap_help"] }
clap_complete = "4.5" clap_complete = "4.5"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "process", "io-util", "io-std", "signal"] } 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"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream", "gzip", "brotli", "multipart"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
toml = "0.8" toml = "0.8"

97
src/api/label.rs Normal file
View file

@ -0,0 +1,97 @@
use anyhow::Result;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::client::Client;
pub use super::issue::Label;
#[derive(Debug, Clone, Serialize)]
pub struct CreateLabel<'a> {
pub name: &'a str,
pub color: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<&'a str>,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct EditLabel<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<&'a str>,
}
pub async fn list(client: &Client, owner: &str, name: &str) -> Result<Vec<Label>> {
let path = format!("/api/v1/repos/{owner}/{name}/labels");
client.json(Method::GET, &path, &[], None::<&()>).await
}
pub async fn create(
client: &Client,
owner: &str,
name: &str,
body: &CreateLabel<'_>,
) -> Result<Label> {
let path = format!("/api/v1/repos/{owner}/{name}/labels");
client.json(Method::POST, &path, &[], Some(body)).await
}
pub async fn edit(
client: &Client,
owner: &str,
name: &str,
id: u64,
body: &EditLabel<'_>,
) -> Result<Label> {
let path = format!("/api/v1/repos/{owner}/{name}/labels/{id}");
client.json(Method::PATCH, &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}/labels/{id}");
let res = client.request(Method::DELETE, &path, &[], None).await?;
res.error_for_status()?;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(dead_code)]
pub struct AddLabelsBody {
pub labels: Vec<u64>,
}
#[allow(dead_code)]
pub async fn add_to_issue(
client: &Client,
owner: &str,
name: &str,
number: u64,
label_ids: Vec<u64>,
) -> Result<Vec<Label>> {
let path = format!("/api/v1/repos/{owner}/{name}/issues/{number}/labels");
client
.json(
Method::POST,
&path,
&[],
Some(&AddLabelsBody { labels: label_ids }),
)
.await
}
#[allow(dead_code)]
pub async fn remove_from_issue(
client: &Client,
owner: &str,
name: &str,
number: u64,
label_id: u64,
) -> Result<()> {
let path = format!("/api/v1/repos/{owner}/{name}/issues/{number}/labels/{label_id}");
let res = client.request(Method::DELETE, &path, &[], None).await?;
res.error_for_status()?;
Ok(())
}

View file

@ -1,7 +1,13 @@
pub mod issue; pub mod issue;
pub mod label;
pub mod notification;
pub mod org;
pub mod pull; pub mod pull;
pub mod release;
pub mod repo; pub mod repo;
pub mod search;
pub mod user; pub mod user;
pub mod workflow;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};

52
src/api/notification.rs Normal file
View file

@ -0,0 +1,52 @@
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 Notification {
pub id: u64,
pub unread: bool,
pub pinned: bool,
pub updated_at: DateTime<Utc>,
pub url: String,
pub html_url: String,
pub subject: Subject,
pub repository: Option<NotificationRepo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Subject {
pub title: String,
#[serde(rename = "type")]
pub type_: String,
pub state: String,
#[serde(default)]
pub url: String,
#[serde(default)]
pub latest_comment_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationRepo {
pub full_name: String,
pub html_url: String,
}
pub async fn list(client: &Client, all: bool, limit: u32) -> Result<Vec<Notification>> {
let path = "/api/v1/notifications";
let mut q = vec![("limit".into(), limit.clamp(1, 50).to_string())];
if all {
q.push(("all".into(), "true".into()));
}
client.json(Method::GET, path, &q, None::<&()>).await
}
pub async fn mark_all_read(client: &Client) -> Result<()> {
let path = "/api/v1/notifications";
let res = client.request(Method::PUT, path, &[], None).await?;
res.error_for_status()?;
Ok(())
}

44
src/api/org.rs Normal file
View file

@ -0,0 +1,44 @@
use anyhow::Result;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::client::Client;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Organization {
pub id: u64,
pub username: String,
#[serde(default)]
pub full_name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub visibility: String,
pub avatar_url: String,
}
pub async fn list_for_user(client: &Client) -> Result<Vec<Organization>> {
client
.json(Method::GET, "/api/v1/user/orgs", &[], None::<&()>)
.await
}
pub async fn get(client: &Client, org: &str) -> Result<Organization> {
let path = format!("/api/v1/orgs/{org}");
client.json(Method::GET, &path, &[], None::<&()>).await
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Team {
pub id: u64,
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub permission: String,
}
pub async fn list_teams(client: &Client, org: &str) -> Result<Vec<Team>> {
let path = format!("/api/v1/orgs/{org}/teams");
client.json(Method::GET, &path, &[], None::<&()>).await
}

163
src/api/release.rs Normal file
View file

@ -0,0 +1,163 @@
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 Release {
pub id: u64,
pub tag_name: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub body: String,
pub target_commitish: String,
pub draft: bool,
pub prerelease: bool,
pub html_url: String,
pub created_at: DateTime<Utc>,
pub published_at: Option<DateTime<Utc>>,
#[serde(default)]
pub assets: Vec<Asset>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Asset {
pub id: u64,
pub name: String,
pub size: u64,
pub browser_download_url: String,
pub created_at: DateTime<Utc>,
}
pub async fn list(client: &Client, owner: &str, name: &str, limit: u32) -> Result<Vec<Release>> {
let path = format!("/api/v1/repos/{owner}/{name}/releases");
let query = vec![
("limit".into(), limit.clamp(1, 50).to_string()),
("page".into(), "1".into()),
];
client.json(Method::GET, &path, &query, None::<&()>).await
}
pub async fn get_by_tag(client: &Client, owner: &str, name: &str, tag: &str) -> Result<Release> {
let path = format!("/api/v1/repos/{owner}/{name}/releases/tags/{tag}");
client.json(Method::GET, &path, &[], None::<&()>).await
}
#[derive(Debug, Clone, Serialize)]
pub struct CreateRelease<'a> {
pub tag_name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_commitish: Option<&'a str>,
#[serde(default)]
pub draft: bool,
#[serde(default)]
pub prerelease: bool,
}
pub async fn create(
client: &Client,
owner: &str,
name: &str,
body: &CreateRelease<'_>,
) -> Result<Release> {
let path = format!("/api/v1/repos/{owner}/{name}/releases");
client.json(Method::POST, &path, &[], Some(body)).await
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct EditRelease<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
pub tag_name: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prerelease: Option<bool>,
}
pub async fn edit(
client: &Client,
owner: &str,
name: &str,
id: u64,
body: &EditRelease<'_>,
) -> Result<Release> {
let path = format!("/api/v1/repos/{owner}/{name}/releases/{id}");
client.json(Method::PATCH, &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}/releases/{id}");
let res = client.request(Method::DELETE, &path, &[], None).await?;
res.error_for_status()?;
Ok(())
}
/// Upload an asset (binary) to a release.
pub async fn upload_asset(
client: &Client,
owner: &str,
name: &str,
release_id: u64,
asset_name: &str,
bytes: Vec<u8>,
) -> Result<Asset> {
let path = format!(
"/api/v1/repos/{owner}/{name}/releases/{release_id}/assets?name={}",
urlencode(asset_name)
);
// Build a multipart form with the file payload.
let url = client.url(&path)?;
let part = reqwest::multipart::Part::bytes(bytes)
.file_name(asset_name.to_string())
.mime_str("application/octet-stream")?;
let form = reqwest::multipart::Form::new().part("attachment", part);
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::AUTHORIZATION,
reqwest::header::HeaderValue::from_str(&format!("token {}", client.token()))?,
);
let res = client
.http()
.post(url)
.headers(headers)
.multipart(form)
.send()
.await?;
let res = res.error_for_status()?;
Ok(res.json().await?)
}
#[allow(dead_code)]
pub async fn delete_asset(client: &Client, owner: &str, name: &str, asset_id: u64) -> Result<()> {
let path = format!("/api/v1/repos/{owner}/{name}/releases/assets/{asset_id}");
let res = client.request(Method::DELETE, &path, &[], None).await?;
res.error_for_status()?;
Ok(())
}
fn urlencode(s: &str) -> String {
// The asset name appears in a query parameter; URL-encode special chars
// conservatively. `url` crate provides a Serializer for full forms.
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '~') {
out.push(c);
} else {
for b in c.to_string().bytes() {
out.push_str(&format!("%{b:02X}"));
}
}
}
out
}

View file

@ -74,7 +74,6 @@ pub async fn search(client: &Client, opts: ListOptions<'_>) -> Result<Page<Searc
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct SearchResponse { struct SearchResponse {
#[serde(default)]
data: Vec<SearchHit>, data: Vec<SearchHit>,
#[serde(default)] #[serde(default)]
#[allow(dead_code)] #[allow(dead_code)]
@ -126,12 +125,7 @@ pub struct EditRepo<'a> {
pub archived: Option<bool>, pub archived: Option<bool>,
} }
pub async fn edit( pub async fn edit(client: &Client, owner: &str, name: &str, body: &EditRepo<'_>) -> Result<Repo> {
client: &Client,
owner: &str,
name: &str,
body: &EditRepo<'_>,
) -> Result<Repo> {
let path = format!("/api/v1/repos/{owner}/{name}"); let path = format!("/api/v1/repos/{owner}/{name}");
client.json(Method::PATCH, &path, &[], Some(body)).await client.json(Method::PATCH, &path, &[], Some(body)).await
} }
@ -230,11 +224,7 @@ pub struct BranchCommit {
pub message: String, pub message: String,
} }
pub async fn list_branches( pub async fn list_branches(client: &Client, owner: &str, name: &str) -> Result<Vec<Branch>> {
client: &Client,
owner: &str,
name: &str,
) -> Result<Vec<Branch>> {
let path = format!("/api/v1/repos/{owner}/{name}/branches"); let path = format!("/api/v1/repos/{owner}/{name}/branches");
client.json(Method::GET, &path, &[], None::<&()>).await client.json(Method::GET, &path, &[], None::<&()>).await
} }
@ -245,22 +235,13 @@ pub struct Topic {
pub topics: Vec<String>, pub topics: Vec<String>,
} }
pub async fn list_topics( pub async fn list_topics(client: &Client, owner: &str, name: &str) -> Result<Vec<String>> {
client: &Client,
owner: &str,
name: &str,
) -> Result<Vec<String>> {
let path = format!("/api/v1/repos/{owner}/{name}/topics"); let path = format!("/api/v1/repos/{owner}/{name}/topics");
let t: Topic = client.json(Method::GET, &path, &[], None::<&()>).await?; let t: Topic = client.json(Method::GET, &path, &[], None::<&()>).await?;
Ok(t.topics) Ok(t.topics)
} }
pub async fn set_topics( pub async fn set_topics(client: &Client, owner: &str, name: &str, topics: &[String]) -> Result<()> {
client: &Client,
owner: &str,
name: &str,
topics: &[String],
) -> Result<()> {
let path = format!("/api/v1/repos/{owner}/{name}/topics"); let path = format!("/api/v1/repos/{owner}/{name}/topics");
let res = client let res = client
.request( .request(

45
src/api/search.rs Normal file
View file

@ -0,0 +1,45 @@
use anyhow::Result;
use reqwest::Method;
use serde::Deserialize;
use crate::client::Client;
use super::issue::Issue;
#[derive(Debug, Deserialize)]
struct Envelope<T> {
data: Vec<T>,
#[serde(default)]
#[allow(dead_code)]
ok: bool,
}
pub async fn issues(client: &Client, query: &str, type_: &str, limit: u32) -> Result<Vec<Issue>> {
let path = "/api/v1/repos/issues/search";
let q = vec![
("q".into(), query.into()),
("type".into(), type_.into()),
("limit".into(), limit.clamp(1, 50).to_string()),
];
client.json(Method::GET, path, &q, None::<&()>).await
}
pub async fn repos(client: &Client, query: &str, limit: u32) -> Result<Vec<super::repo::Repo>> {
let path = "/api/v1/repos/search";
let q = vec![
("q".into(), query.into()),
("limit".into(), limit.clamp(1, 50).to_string()),
];
let body: Envelope<super::repo::Repo> = client.json(Method::GET, path, &q, None::<&()>).await?;
Ok(body.data)
}
pub async fn users(client: &Client, query: &str, limit: u32) -> Result<Vec<super::user::User>> {
let path = "/api/v1/users/search";
let q = vec![
("q".into(), query.into()),
("limit".into(), limit.clamp(1, 50).to_string()),
];
let body: Envelope<super::user::User> = client.json(Method::GET, path, &q, None::<&()>).await?;
Ok(body.data)
}

172
src/api/workflow.rs Normal file
View file

@ -0,0 +1,172 @@
//! Forgejo Actions workflow + run + secret APIs.
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 WorkflowRun {
pub id: u64,
pub name: String,
#[serde(default)]
pub head_branch: String,
#[serde(default)]
pub head_sha: String,
pub status: String,
pub conclusion: Option<String>,
pub workflow_id: u64,
pub html_url: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub run_number: u64,
}
#[derive(Debug, Deserialize)]
struct RunList {
#[serde(default)]
workflow_runs: Vec<WorkflowRun>,
#[serde(default)]
#[allow(dead_code)]
total_count: u64,
}
pub async fn list_runs(
client: &Client,
owner: &str,
name: &str,
limit: u32,
) -> Result<Vec<WorkflowRun>> {
let path = format!("/api/v1/repos/{owner}/{name}/actions/runs");
let q = vec![("limit".into(), limit.clamp(1, 50).to_string())];
let list: RunList = client.json(Method::GET, &path, &q, None::<&()>).await?;
Ok(list.workflow_runs)
}
pub async fn get_run(client: &Client, owner: &str, name: &str, run_id: u64) -> Result<WorkflowRun> {
let path = format!("/api/v1/repos/{owner}/{name}/actions/runs/{run_id}");
client.json(Method::GET, &path, &[], None::<&()>).await
}
pub async fn rerun(client: &Client, owner: &str, name: &str, run_id: u64) -> Result<()> {
let path = format!("/api/v1/repos/{owner}/{name}/actions/runs/{run_id}/rerun");
let res = client.request(Method::POST, &path, &[], None).await?;
res.error_for_status()?;
Ok(())
}
pub async fn cancel(client: &Client, owner: &str, name: &str, run_id: u64) -> Result<()> {
let path = format!("/api/v1/repos/{owner}/{name}/actions/runs/{run_id}/cancel");
let res = client.request(Method::POST, &path, &[], None).await?;
res.error_for_status()?;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionSecret {
pub name: String,
pub created_at: DateTime<Utc>,
}
pub async fn list_secrets(client: &Client, owner: &str, name: &str) -> Result<Vec<ActionSecret>> {
let path = format!("/api/v1/repos/{owner}/{name}/actions/secrets");
#[derive(Deserialize)]
struct Envelope {
#[serde(default)]
secrets: Vec<ActionSecret>,
}
let env: Envelope = client.json(Method::GET, &path, &[], None::<&()>).await?;
Ok(env.secrets)
}
#[derive(Debug, Serialize)]
struct SecretBody<'a> {
data: &'a str,
}
pub async fn set_secret(
client: &Client,
owner: &str,
name: &str,
secret_name: &str,
value: &str,
) -> Result<()> {
let path = format!("/api/v1/repos/{owner}/{name}/actions/secrets/{secret_name}");
let res = client
.request(
Method::PUT,
&path,
&[],
Some(&serde_json::to_value(SecretBody { data: value })?),
)
.await?;
res.error_for_status()?;
Ok(())
}
pub async fn delete_secret(
client: &Client,
owner: &str,
name: &str,
secret_name: &str,
) -> Result<()> {
let path = format!("/api/v1/repos/{owner}/{name}/actions/secrets/{secret_name}");
let res = client.request(Method::DELETE, &path, &[], None).await?;
res.error_for_status()?;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionVariable {
pub name: String,
pub data: String,
}
pub async fn list_variables(
client: &Client,
owner: &str,
name: &str,
) -> Result<Vec<ActionVariable>> {
let path = format!("/api/v1/repos/{owner}/{name}/actions/variables");
#[derive(Deserialize)]
struct Envelope {
#[serde(default)]
variables: Vec<ActionVariable>,
}
let env: Envelope = client.json(Method::GET, &path, &[], None::<&()>).await?;
Ok(env.variables)
}
pub async fn set_variable(
client: &Client,
owner: &str,
name: &str,
var_name: &str,
value: &str,
) -> Result<()> {
let path = format!("/api/v1/repos/{owner}/{name}/actions/variables/{var_name}");
let res = client
.request(
Method::PUT,
&path,
&[],
Some(&serde_json::json!({ "value": value })),
)
.await?;
res.error_for_status()?;
Ok(())
}
pub async fn delete_variable(
client: &Client,
owner: &str,
name: &str,
var_name: &str,
) -> Result<()> {
let path = format!("/api/v1/repos/{owner}/{name}/actions/variables/{var_name}");
let res = client.request(Method::DELETE, &path, &[], None).await?;
res.error_for_status()?;
Ok(())
}

145
src/cli/alias.rs Normal file
View file

@ -0,0 +1,145 @@
//! `fj alias` — user-defined command shortcuts stored in $XDG_CONFIG_HOME/fj/aliases.toml.
//!
//! `fj alias set co "pr checkout"` then `fj co 42` (note: top-level shadowing
//! of unknown subcommands is implemented in main.rs by checking the alias map
//! before clap parsing).
use std::collections::BTreeMap;
use std::fs;
use anyhow::{Context, Result};
use clap::{Args, Subcommand};
use serde::{Deserialize, Serialize};
use crate::config;
use crate::output;
#[derive(Debug, Args)]
pub struct AliasCmd {
#[command(subcommand)]
pub command: AliasSub,
}
#[derive(Debug, Subcommand)]
pub enum AliasSub {
/// List configured aliases.
List,
/// Set an alias: `fj alias set co "pr checkout"`.
Set(SetArgs),
/// Delete an alias.
Delete(DeleteArgs),
}
#[derive(Debug, Args)]
pub struct SetArgs {
pub name: String,
/// Expansion: the rest of the line after the alias name.
pub expansion: String,
}
#[derive(Debug, Args)]
pub struct DeleteArgs {
pub name: String,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct AliasFile {
#[serde(default)]
pub aliases: BTreeMap<String, String>,
}
pub fn path() -> Result<std::path::PathBuf> {
Ok(config::config_dir()?.join("aliases.toml"))
}
pub fn load() -> Result<AliasFile> {
let p = path()?;
if !p.exists() {
return Ok(AliasFile::default());
}
let text = fs::read_to_string(&p).with_context(|| format!("reading {}", p.display()))?;
toml::from_str(&text).with_context(|| format!("parsing {}", p.display()))
}
pub fn save(a: &AliasFile) -> Result<()> {
let p = path()?;
if let Some(parent) = p.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&p, toml::to_string_pretty(a)?)?;
Ok(())
}
pub async fn run(cmd: AliasCmd) -> Result<()> {
let mut file = load()?;
match cmd.command {
AliasSub::List => list(&file),
AliasSub::Set(args) => {
file.aliases
.insert(args.name.clone(), args.expansion.clone());
save(&file)?;
println!("{}{}", args.name, args.expansion);
Ok(())
}
AliasSub::Delete(args) => {
if file.aliases.remove(&args.name).is_none() {
return Err(anyhow::anyhow!("no alias '{}'", args.name));
}
save(&file)?;
println!("✓ deleted {}", args.name);
Ok(())
}
}
}
fn list(file: &AliasFile) -> Result<()> {
if file.aliases.is_empty() {
println!("(no aliases)");
return Ok(());
}
let rows: Vec<Vec<String>> = file
.aliases
.iter()
.map(|(k, v)| vec![k.clone(), v.clone()])
.collect();
print!("{}", output::render_table(&["ALIAS", "EXPANSION"], &rows));
Ok(())
}
/// Expand `argv` if `argv[1]` is a configured alias. Returns a new argv with
/// the alias replaced by its tokenized expansion. Falls back to `argv` if no
/// alias matches.
pub fn expand_argv(argv: Vec<String>) -> Vec<String> {
if argv.len() < 2 {
return argv;
}
let Ok(file) = load() else {
return argv;
};
let Some(expansion) = file.aliases.get(&argv[1]) else {
return argv;
};
let mut out = Vec::with_capacity(argv.len() + 4);
out.push(argv[0].clone());
for tok in expansion.split_whitespace() {
out.push(tok.to_string());
}
out.extend(argv.into_iter().skip(2));
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expand_no_alias_passthrough() {
let argv = vec!["fj".into(), "pr".into(), "list".into()];
assert_eq!(expand_argv(argv.clone()), argv);
}
#[test]
fn expand_empty_argv() {
assert_eq!(expand_argv(vec!["fj".into()]), vec!["fj".to_string()]);
}
}

View file

@ -234,7 +234,10 @@ async fn collect_paginated(
// client (which adds auth + headers). // client (which adds auth + headers).
let parsed = Url::parse(&next_url).context("parsing next-page URL")?; let parsed = Url::parse(&next_url).context("parsing next-page URL")?;
current_endpoint = parsed.path().to_string(); current_endpoint = parsed.path().to_string();
current_query = parsed.query_pairs().map(|(k, v)| (k.into_owned(), v.into_owned())).collect(); current_query = parsed
.query_pairs()
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
} }
Ok(Value::Array(all_items)) Ok(Value::Array(all_items))
} }
@ -279,11 +282,7 @@ fn apply_stage(value: &Value, stage: &str) -> Result<Value> {
let Some(arr) = target.as_array() else { let Some(arr) = target.as_array() else {
return Err(anyhow!("not an array; can't index with '{raw_str}'")); return Err(anyhow!("not an array; can't index with '{raw_str}'"));
}; };
let real = if idx < 0 { let real = if idx < 0 { arr.len() as i64 + idx } else { idx };
arr.len() as i64 + idx
} else {
idx
};
let v = arr let v = arr
.get(real as usize) .get(real as usize)
.ok_or_else(|| anyhow!("index {idx} out of range"))? .ok_or_else(|| anyhow!("index {idx} out of range"))?

36
src/cli/browse.rs Normal file
View file

@ -0,0 +1,36 @@
//! `fj browse [path...]` — open the current repo (or a path within it) on the web.
use anyhow::Result;
use clap::Args;
use crate::api;
use crate::cli::context::{resolve_repo, RepoFlag};
use super::web;
#[derive(Debug, Args)]
pub struct BrowseArgs {
#[command(flatten)]
pub r: RepoFlag,
/// Optional sub-path (e.g. `src/main.rs`, `issues`, `pulls/3`).
pub path: Option<String>,
/// Branch name for source-tree paths.
#[arg(long)]
pub branch: Option<String>,
}
pub async fn run(args: BrowseArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let repo = api::repo::get(&ctx.client, &ctx.owner, &ctx.name).await?;
let url = match args.path.as_deref() {
None | Some("") => repo.html_url.clone(),
Some("issues") | Some("pulls") | Some("releases") | Some("wiki") | Some("settings") => {
format!("{}/{}", repo.html_url, args.path.unwrap())
}
Some(rest) => {
let branch = args.branch.as_deref().unwrap_or(&repo.default_branch);
format!("{}/src/branch/{}/{}", repo.html_url, branch, rest)
}
};
web::open(&url)
}

134
src/cli/config.rs Normal file
View file

@ -0,0 +1,134 @@
//! `fj config` — local preferences (editor, pager, browser, default merge style).
//!
//! Stored in `$XDG_CONFIG_HOME/fj/config.toml`. These keys are advisory: the
//! relevant subcommands look them up when their corresponding env var is unset.
use std::collections::BTreeMap;
use std::fs;
use anyhow::{anyhow, Context, Result};
use clap::{Args, Subcommand};
use serde::{Deserialize, Serialize};
use crate::config;
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct ConfigFile {
#[serde(default)]
pub values: BTreeMap<String, String>,
}
const KNOWN_KEYS: &[&str] = &[
"editor",
"pager",
"browser",
"git_protocol",
"default_merge",
"no_color",
];
#[derive(Debug, Args)]
pub struct ConfigCmd {
#[command(subcommand)]
pub command: ConfigSub,
}
#[derive(Debug, Subcommand)]
pub enum ConfigSub {
/// Print a key's current value.
Get(GetArgs),
/// Set a key.
Set(SetArgs),
/// Show all configured keys.
List,
/// Print the path of the config file.
Path,
}
#[derive(Debug, Args)]
pub struct GetArgs {
pub key: String,
}
#[derive(Debug, Args)]
pub struct SetArgs {
pub key: String,
pub value: String,
}
pub fn config_path() -> Result<std::path::PathBuf> {
Ok(config::config_dir()?.join("config.toml"))
}
pub fn load() -> Result<ConfigFile> {
let p = config_path()?;
if !p.exists() {
return Ok(ConfigFile::default());
}
let text = fs::read_to_string(&p).with_context(|| format!("reading {}", p.display()))?;
toml::from_str(&text).with_context(|| format!("parsing {}", p.display()))
}
pub fn save(c: &ConfigFile) -> Result<()> {
let p = config_path()?;
if let Some(parent) = p.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&p, toml::to_string_pretty(c)?)?;
Ok(())
}
/// Read a config value, optionally falling back to an env var.
#[allow(dead_code)]
pub fn lookup(key: &str, env_fallback: Option<&str>) -> Option<String> {
if let Some(env) = env_fallback {
if let Ok(v) = std::env::var(env) {
if !v.is_empty() {
return Some(v);
}
}
}
load().ok().and_then(|c| c.values.get(key).cloned())
}
pub async fn run(cmd: ConfigCmd) -> Result<()> {
match cmd.command {
ConfigSub::Get(args) => {
let file = load()?;
match file.values.get(&args.key) {
Some(v) => println!("{v}"),
None => return Err(anyhow!("key '{}' is unset", args.key)),
}
Ok(())
}
ConfigSub::Set(args) => {
if !KNOWN_KEYS.contains(&args.key.as_str()) {
eprintln!(
"note: '{}' is not a well-known key (known: {})",
args.key,
KNOWN_KEYS.join(", ")
);
}
let mut file = load()?;
file.values.insert(args.key.clone(), args.value.clone());
save(&file)?;
println!("{} = {}", args.key, args.value);
Ok(())
}
ConfigSub::List => {
let file = load()?;
if file.values.is_empty() {
println!("(empty)");
return Ok(());
}
for (k, v) in &file.values {
println!("{k} = {v}");
}
Ok(())
}
ConfigSub::Path => {
println!("{}", config_path()?.display());
Ok(())
}
}
}

82
src/cli/extension.rs Normal file
View file

@ -0,0 +1,82 @@
//! `fj extension` — discover and run user-installed plugins.
//!
//! Convention matches gh: any executable on $PATH named `fj-<name>` is treated
//! as a plugin. `fj extension list` enumerates them. The shell does the rest
//! of the work since you can already invoke `fj-foo` directly; this command
//! just makes them discoverable from inside `fj`.
use std::collections::BTreeSet;
use std::env;
use std::path::PathBuf;
use anyhow::Result;
use clap::{Args, Subcommand};
use crate::output;
#[derive(Debug, Args)]
pub struct ExtensionCmd {
#[command(subcommand)]
pub command: ExtensionSub,
}
#[derive(Debug, Subcommand)]
pub enum ExtensionSub {
/// List discovered `fj-*` extensions on PATH.
List,
/// Invoke a discovered extension. Equivalent to running `fj-<name> ...`.
Run(RunArgs),
}
#[derive(Debug, Args)]
pub struct RunArgs {
pub name: String,
/// Arguments forwarded verbatim to the plugin binary.
pub args: Vec<String>,
}
pub async fn run(cmd: ExtensionCmd) -> Result<()> {
match cmd.command {
ExtensionSub::List => list().await,
ExtensionSub::Run(args) => invoke(args).await,
}
}
async fn list() -> Result<()> {
let mut found: BTreeSet<(String, PathBuf)> = BTreeSet::new();
let path = env::var_os("PATH").unwrap_or_default();
for dir in env::split_paths(&path) {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if let Some(plugin) = name.strip_prefix("fj-") {
if !plugin.is_empty() && !plugin.starts_with('.') {
found.insert((plugin.to_string(), entry.path()));
}
}
}
}
if found.is_empty() {
println!("(no fj-* extensions found on PATH)");
return Ok(());
}
let rows: Vec<Vec<String>> = found
.iter()
.map(|(n, p)| vec![n.clone(), output::dim(&p.display().to_string())])
.collect();
print!("{}", output::render_table(&["NAME", "PATH"], &rows));
Ok(())
}
async fn invoke(args: RunArgs) -> Result<()> {
let prog = format!("fj-{}", args.name);
let status = std::process::Command::new(&prog)
.args(&args.args)
.status()?;
if !status.success() {
std::process::exit(status.code().unwrap_or(1));
}
Ok(())
}

106
src/cli/gist.rs Normal file
View file

@ -0,0 +1,106 @@
//! `fj gist` — Forgejo Gists (snippets).
//!
//! Forgejo doesn't ship a Gist surface separate from regular repos; we expose
//! a thin wrapper that lists/views the current user's "fj-gist-*" repos and
//! creates new ones. Power users should fall through to `fj api`.
use anyhow::Result;
use clap::{Args, Subcommand};
use crate::api;
use crate::client::Client;
use crate::output;
#[derive(Debug, Args)]
pub struct GistCmd {
#[command(subcommand)]
pub command: GistSub,
}
#[derive(Debug, Subcommand)]
pub enum GistSub {
/// List your gist-style repos (any repo prefixed `gist-`).
List(ListArgs),
/// Create a new gist (private repo with a single file).
Create(CreateArgs),
}
#[derive(Debug, Args)]
pub struct ListArgs {
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct CreateArgs {
/// Gist name (used as the repo name, prefixed with `gist-`).
pub name: String,
/// Optional description.
#[arg(short = 'd', long)]
pub description: Option<String>,
/// Make it private (the default).
#[arg(long, default_value_t = true)]
pub private: bool,
}
pub async fn run(cmd: GistCmd, host: Option<&str>) -> Result<()> {
let client = Client::connect(host)?;
match cmd.command {
GistSub::List(args) => list(&client, args).await,
GistSub::Create(args) => create(&client, args).await,
}
}
async fn list(client: &Client, args: ListArgs) -> Result<()> {
let page = api::repo::list_for_user(
client,
api::repo::ListOptions {
limit: 50,
page: 1,
query: None,
},
)
.await?;
let gists: Vec<_> = page
.items
.into_iter()
.filter(|r| r.name.starts_with("gist-"))
.collect();
if args.json {
return output::print_json(&serde_json::to_value(&gists)?);
}
if gists.is_empty() {
println!("(no gists)");
return Ok(());
}
let rows: Vec<Vec<String>> = gists
.iter()
.map(|g| {
vec![
g.full_name.clone(),
g.description.clone(),
output::dim(&output::relative_time(g.updated_at)),
]
})
.collect();
print!(
"{}",
output::render_table(&["NAME", "DESCRIPTION", "UPDATED"], &rows)
);
Ok(())
}
async fn create(client: &Client, args: CreateArgs) -> Result<()> {
let name = format!("gist-{}", args.name);
let body = api::repo::CreateRepo {
name: &name,
description: args.description.as_deref(),
private: args.private,
default_branch: None,
auto_init: true,
};
let repo = api::repo::create_for_current_user(client, &body).await?;
println!("✓ Created gist {}", repo.full_name);
println!("{}", repo.html_url);
Ok(())
}

262
src/cli/key.rs Normal file
View file

@ -0,0 +1,262 @@
use anyhow::Result;
use clap::{Args, Subcommand};
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::client::Client;
use crate::output;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublicKey {
pub id: u64,
pub key: String,
pub title: String,
pub created_at: chrono::DateTime<chrono::Utc>,
#[serde(default)]
pub read_only: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GpgKey {
pub id: u64,
pub key_id: String,
pub public_key: String,
pub created_at: chrono::DateTime<chrono::Utc>,
#[serde(default)]
pub primary_key_id: String,
}
#[derive(Debug, Args)]
pub struct SshKeyCmd {
#[command(subcommand)]
pub command: SshKeySub,
}
#[derive(Debug, Subcommand)]
pub enum SshKeySub {
List(ListArgs),
Add(AddArgs),
Delete(DeleteArgs),
}
#[derive(Debug, Args)]
pub struct GpgKeyCmd {
#[command(subcommand)]
pub command: GpgKeySub,
}
#[derive(Debug, Subcommand)]
pub enum GpgKeySub {
List(ListArgs),
Add(GpgAddArgs),
Delete(DeleteArgs),
}
#[derive(Debug, Args)]
pub struct ListArgs {
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct AddArgs {
pub title: String,
/// SSH public key. Pass `--from-file ~/.ssh/id_ed25519.pub` instead for files.
#[arg(long)]
pub key: Option<String>,
#[arg(long = "from-file")]
pub from_file: Option<String>,
}
#[derive(Debug, Args)]
pub struct GpgAddArgs {
/// Armored public GPG key. Use `--from-file -` for stdin.
#[arg(long)]
pub key: Option<String>,
#[arg(long = "from-file")]
pub from_file: Option<String>,
}
#[derive(Debug, Args)]
pub struct DeleteArgs {
pub id: u64,
#[arg(short = 'y', long)]
pub yes: bool,
}
pub async fn run_ssh(cmd: SshKeyCmd, host: Option<&str>) -> Result<()> {
let client = Client::connect(host)?;
match cmd.command {
SshKeySub::List(args) => list_ssh(&client, args).await,
SshKeySub::Add(args) => add_ssh(&client, args).await,
SshKeySub::Delete(args) => delete_ssh(&client, args).await,
}
}
async fn list_ssh(client: &Client, args: ListArgs) -> Result<()> {
let keys: Vec<PublicKey> = client
.json(Method::GET, "/api/v1/user/keys", &[], None::<&()>)
.await?;
if args.json {
return output::print_json(&serde_json::to_value(&keys)?);
}
if keys.is_empty() {
println!("(no SSH keys)");
return Ok(());
}
let rows: Vec<Vec<String>> = keys
.iter()
.map(|k| {
vec![
k.id.to_string(),
k.title.clone(),
output::dim(&fingerprint_preview(&k.key)),
output::dim(&output::relative_time(k.created_at)),
]
})
.collect();
print!(
"{}",
output::render_table(&["ID", "TITLE", "KEY", "CREATED"], &rows)
);
Ok(())
}
async fn add_ssh(client: &Client, args: AddArgs) -> Result<()> {
let key = read_key(args.key.as_deref(), args.from_file.as_deref())?;
#[derive(Serialize)]
struct Body<'a> {
title: &'a str,
key: &'a str,
}
let body = Body {
title: &args.title,
key: &key,
};
let k: PublicKey = client
.json(Method::POST, "/api/v1/user/keys", &[], Some(&body))
.await?;
println!("✓ Added SSH key '{}' (id {})", k.title, k.id);
Ok(())
}
async fn delete_ssh(client: &Client, args: DeleteArgs) -> Result<()> {
if !args.yes {
let ans = crate::cli::editor::prompt_line(&format!(
"Delete SSH key {}? Type '{}' to confirm",
args.id, args.id
))?;
if ans != args.id.to_string() {
return Err(anyhow::anyhow!("aborted"));
}
}
let path = format!("/api/v1/user/keys/{}", args.id);
client
.request(Method::DELETE, &path, &[], None)
.await?
.error_for_status()?;
println!("✓ Deleted SSH key {}", args.id);
Ok(())
}
pub async fn run_gpg(cmd: GpgKeyCmd, host: Option<&str>) -> Result<()> {
let client = Client::connect(host)?;
match cmd.command {
GpgKeySub::List(args) => list_gpg(&client, args).await,
GpgKeySub::Add(args) => add_gpg(&client, args).await,
GpgKeySub::Delete(args) => delete_gpg(&client, args).await,
}
}
async fn list_gpg(client: &Client, args: ListArgs) -> Result<()> {
let keys: Vec<GpgKey> = client
.json(Method::GET, "/api/v1/user/gpg_keys", &[], None::<&()>)
.await?;
if args.json {
return output::print_json(&serde_json::to_value(&keys)?);
}
if keys.is_empty() {
println!("(no GPG keys)");
return Ok(());
}
let rows: Vec<Vec<String>> = keys
.iter()
.map(|k| {
vec![
k.id.to_string(),
k.key_id.clone(),
output::dim(&output::relative_time(k.created_at)),
]
})
.collect();
print!(
"{}",
output::render_table(&["ID", "KEY ID", "CREATED"], &rows)
);
Ok(())
}
async fn add_gpg(client: &Client, args: GpgAddArgs) -> Result<()> {
let key = read_key(args.key.as_deref(), args.from_file.as_deref())?;
let k: GpgKey = client
.json(
Method::POST,
"/api/v1/user/gpg_keys",
&[],
Some(&serde_json::json!({ "armored_public_key": key })),
)
.await?;
println!("✓ Added GPG key {} (id {})", k.key_id, k.id);
Ok(())
}
async fn delete_gpg(client: &Client, args: DeleteArgs) -> Result<()> {
if !args.yes {
let ans = crate::cli::editor::prompt_line(&format!(
"Delete GPG key {}? Type '{}' to confirm",
args.id, args.id
))?;
if ans != args.id.to_string() {
return Err(anyhow::anyhow!("aborted"));
}
}
let path = format!("/api/v1/user/gpg_keys/{}", args.id);
client
.request(Method::DELETE, &path, &[], None)
.await?
.error_for_status()?;
println!("✓ Deleted GPG key {}", args.id);
Ok(())
}
fn read_key(value: Option<&str>, from_file: Option<&str>) -> Result<String> {
if let Some(v) = value {
return Ok(v.trim().to_string());
}
if let Some(path) = from_file {
if path == "-" {
use std::io::Read;
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
return Ok(buf.trim().to_string());
}
return Ok(std::fs::read_to_string(path)?.trim().to_string());
}
Err(anyhow::anyhow!("pass --key or --from-file"))
}
/// Compact preview of an SSH public key: `ssh-ed25519 …abc12`
fn fingerprint_preview(key: &str) -> String {
let mut parts = key.split_whitespace();
let kind = parts.next().unwrap_or("");
let body = parts.next().unwrap_or("");
let tail = body
.chars()
.rev()
.take(8)
.collect::<String>()
.chars()
.rev()
.collect::<String>();
format!("{kind}{tail}")
}

175
src/cli/label.rs Normal file
View file

@ -0,0 +1,175 @@
use anyhow::Result;
use clap::{Args, Subcommand};
use crate::api::label::{self, CreateLabel, EditLabel};
use crate::cli::context::{resolve_repo, RepoFlag};
use crate::output;
#[derive(Debug, Args)]
pub struct LabelCmd {
#[command(subcommand)]
pub command: LabelSub,
}
#[derive(Debug, Subcommand)]
pub enum LabelSub {
List(ListArgs),
Create(CreateArgs),
Edit(EditArgs),
Delete(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,
pub name: String,
/// Color as 6-char hex (with or without `#`).
#[arg(long, default_value = "ededed")]
pub color: String,
#[arg(long)]
pub description: Option<String>,
}
#[derive(Debug, Args)]
pub struct EditArgs {
#[command(flatten)]
pub r: RepoFlag,
/// Label name to edit.
pub name: String,
/// New name.
#[arg(long)]
pub rename: Option<String>,
#[arg(long)]
pub color: Option<String>,
#[arg(long)]
pub description: Option<String>,
}
#[derive(Debug, Args)]
pub struct DeleteArgs {
#[command(flatten)]
pub r: RepoFlag,
pub name: String,
#[arg(short = 'y', long)]
pub yes: bool,
}
pub async fn run(cmd: LabelCmd, host: Option<&str>) -> Result<()> {
match cmd.command {
LabelSub::List(args) => list(args, host).await,
LabelSub::Create(args) => create(args, host).await,
LabelSub::Edit(args) => edit(args, host).await,
LabelSub::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 = label::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 labels)");
return Ok(());
}
let rows: Vec<Vec<String>> = items
.iter()
.map(|l| {
vec![
l.name.clone(),
format!("#{}", l.color),
truncate(&l.description, 60),
]
})
.collect();
print!(
"{}",
output::render_table(&["NAME", "COLOR", "DESCRIPTION"], &rows)
);
Ok(())
}
async fn create(args: CreateArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let color = args.color.trim_start_matches('#').to_string();
let l = label::create(
&ctx.client,
&ctx.owner,
&ctx.name,
&CreateLabel {
name: &args.name,
color: &color,
description: args.description.as_deref(),
},
)
.await?;
println!("✓ Created label '{}' (#{})", l.name, l.color);
Ok(())
}
async fn edit(args: EditArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let existing = label::list(&ctx.client, &ctx.owner, &ctx.name)
.await?
.into_iter()
.find(|l| l.name == args.name)
.ok_or_else(|| anyhow::anyhow!("no label named '{}'", args.name))?;
let color = args
.color
.as_ref()
.map(|c| c.trim_start_matches('#').to_string());
let l = label::edit(
&ctx.client,
&ctx.owner,
&ctx.name,
existing.id,
&EditLabel {
name: args.rename.as_deref(),
color: color.as_deref(),
description: args.description.as_deref(),
},
)
.await?;
println!("✓ Updated label '{}'", l.name);
Ok(())
}
async fn delete(args: DeleteArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let existing = label::list(&ctx.client, &ctx.owner, &ctx.name)
.await?
.into_iter()
.find(|l| l.name == args.name)
.ok_or_else(|| anyhow::anyhow!("no label named '{}'", args.name))?;
if !args.yes {
let answer = crate::cli::editor::prompt_line(&format!(
"Delete label '{}'? Type the name to confirm",
args.name
))?;
if answer != args.name {
return Err(anyhow::anyhow!("aborted"));
}
}
label::delete(&ctx.client, &ctx.owner, &ctx.name, existing.id).await?;
println!("✓ Deleted label '{}'", args.name);
Ok(())
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}

View file

@ -1,11 +1,23 @@
pub mod alias;
pub mod api; pub mod api;
pub mod auth; pub mod auth;
pub mod browse;
pub mod config;
pub mod context; pub mod context;
pub mod editor; pub mod editor;
pub mod extension;
pub mod gist;
pub mod issue; pub mod issue;
pub mod key;
pub mod label;
pub mod org;
pub mod pr; pub mod pr;
pub mod release;
pub mod repo; pub mod repo;
pub mod search;
pub mod status;
pub mod web; pub mod web;
pub mod workflow;
use anyhow::Result; use anyhow::Result;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
@ -40,6 +52,36 @@ pub enum Command {
Issue(issue::IssueCmd), Issue(issue::IssueCmd),
/// Work with pull requests. /// Work with pull requests.
Pr(pr::PrCmd), Pr(pr::PrCmd),
/// Work with releases.
Release(release::ReleaseCmd),
/// Work with labels.
Label(label::LabelCmd),
/// Manage workflow runs (Forgejo Actions).
Run(workflow::RunCmd),
/// Manage Actions secrets.
Secret(workflow::SecretCmd),
/// Manage Actions variables.
Variable(workflow::VariableCmd),
/// Cross-cutting search (repos, issues, prs, users).
Search(search::SearchCmd),
/// Open the current repo (or a path within it) in your browser.
Browse(browse::BrowseArgs),
/// Notifications inbox.
Status(status::StatusArgs),
/// Organization helpers.
Org(org::OrgCmd),
/// Manage SSH keys on your account.
SshKey(key::SshKeyCmd),
/// Manage GPG keys on your account.
GpgKey(key::GpgKeyCmd),
/// User-defined command aliases.
Alias(alias::AliasCmd),
/// Manage local fj configuration (editor, pager, browser, etc.).
Config(config::ConfigCmd),
/// Run a discovered `fj-<name>` plugin from PATH.
Extension(extension::ExtensionCmd),
/// Forgejo gists.
Gist(gist::GistCmd),
/// Make a raw API request (like `gh api`). /// Make a raw API request (like `gh api`).
Api(api::ApiArgs), Api(api::ApiArgs),
/// Print shell completions to stdout. /// Print shell completions to stdout.
@ -54,11 +96,30 @@ pub struct CompletionArgs {
} }
pub async fn run(cli: Cli) -> Result<()> { 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.
match cli.command { match cli.command {
Command::Auth(cmd) => auth::run(cmd).await, Command::Auth(cmd) => auth::run(cmd).await,
Command::Repo(cmd) => repo::run(cmd, cli.host.as_deref()).await, Command::Repo(cmd) => repo::run(cmd, cli.host.as_deref()).await,
Command::Issue(cmd) => issue::run(cmd, cli.host.as_deref()).await, Command::Issue(cmd) => issue::run(cmd, cli.host.as_deref()).await,
Command::Pr(cmd) => pr::run(cmd, cli.host.as_deref()).await, Command::Pr(cmd) => pr::run(cmd, cli.host.as_deref()).await,
Command::Release(cmd) => release::run(cmd, cli.host.as_deref()).await,
Command::Label(cmd) => label::run(cmd, cli.host.as_deref()).await,
Command::Run(cmd) => workflow::run(cmd, cli.host.as_deref()).await,
Command::Secret(cmd) => workflow::run_secret(cmd, cli.host.as_deref()).await,
Command::Variable(cmd) => workflow::run_variable(cmd, cli.host.as_deref()).await,
Command::Search(cmd) => search::run(cmd, cli.host.as_deref()).await,
Command::Browse(args) => browse::run(args, cli.host.as_deref()).await,
Command::Status(args) => status::run(args, cli.host.as_deref()).await,
Command::Org(cmd) => org::run(cmd, cli.host.as_deref()).await,
Command::SshKey(cmd) => key::run_ssh(cmd, cli.host.as_deref()).await,
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::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, Command::Api(args) => api::run(args, cli.host.as_deref()).await,
Command::Completion(args) => { Command::Completion(args) => {
use clap::CommandFactory; use clap::CommandFactory;

115
src/cli/org.rs Normal file
View file

@ -0,0 +1,115 @@
use anyhow::Result;
use clap::{Args, Subcommand};
use crate::api;
use crate::client::Client;
use crate::output;
#[derive(Debug, Args)]
pub struct OrgCmd {
#[command(subcommand)]
pub command: OrgSub,
}
#[derive(Debug, Subcommand)]
pub enum OrgSub {
/// List orgs you belong to.
List(ListArgs),
/// Show details about a specific org.
View(ViewArgs),
/// List teams in an org.
Teams(TeamsArgs),
}
#[derive(Debug, Args)]
pub struct ListArgs {
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct ViewArgs {
pub org: String,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct TeamsArgs {
pub org: String,
#[arg(long)]
pub json: bool,
}
pub async fn run(cmd: OrgCmd, host: Option<&str>) -> Result<()> {
let client = Client::connect(host)?;
match cmd.command {
OrgSub::List(args) => list(&client, args).await,
OrgSub::View(args) => view(&client, args).await,
OrgSub::Teams(args) => teams(&client, args).await,
}
}
async fn list(client: &Client, args: ListArgs) -> Result<()> {
let items = api::org::list_for_user(client).await?;
if args.json {
return output::print_json(&serde_json::to_value(&items)?);
}
if items.is_empty() {
println!("(no orgs)");
return Ok(());
}
let rows: Vec<Vec<String>> = items
.iter()
.map(|o| {
vec![
o.username.clone(),
o.full_name.clone(),
o.visibility.clone(),
]
})
.collect();
print!(
"{}",
output::render_table(&["LOGIN", "NAME", "VISIBILITY"], &rows)
);
Ok(())
}
async fn view(client: &Client, args: ViewArgs) -> Result<()> {
let o = api::org::get(client, &args.org).await?;
if args.json {
return output::print_json(&serde_json::to_value(&o)?);
}
println!("{}", output::bold(&o.username));
if !o.full_name.is_empty() {
println!("{}", o.full_name);
}
if !o.description.is_empty() {
println!();
println!("{}", o.description);
}
println!();
println!("Visibility: {}", o.visibility);
Ok(())
}
async fn teams(client: &Client, args: TeamsArgs) -> Result<()> {
let teams = api::org::list_teams(client, &args.org).await?;
if args.json {
return output::print_json(&serde_json::to_value(&teams)?);
}
if teams.is_empty() {
println!("(no teams)");
return Ok(());
}
let rows: Vec<Vec<String>> = teams
.iter()
.map(|t| vec![t.name.clone(), t.permission.clone(), t.description.clone()])
.collect();
print!(
"{}",
output::render_table(&["TEAM", "PERMISSION", "DESCRIPTION"], &rows)
);
Ok(())
}

390
src/cli/release.rs Normal file
View file

@ -0,0 +1,390 @@
use anyhow::Result;
use clap::{Args, Subcommand};
use crate::api;
use crate::api::release::{CreateRelease, EditRelease};
use crate::cli::context::{resolve_repo, RepoFlag};
use crate::output;
use super::editor;
use super::web;
#[derive(Debug, Args)]
pub struct ReleaseCmd {
#[command(subcommand)]
pub command: ReleaseSub,
}
#[derive(Debug, Subcommand)]
pub enum ReleaseSub {
/// List releases on a repository.
List(ListArgs),
/// View a release by tag.
View(ViewArgs),
/// Create a release.
Create(CreateArgs),
/// Edit a release.
Edit(EditArgs),
/// Delete a release.
Delete(DeleteArgs),
/// Upload an asset file to a release.
Upload(UploadArgs),
/// Download a release asset by name.
Download(DownloadArgs),
}
#[derive(Debug, Args)]
pub struct ListArgs {
#[command(flatten)]
pub r: RepoFlag,
#[arg(short = 'L', long, default_value_t = 30)]
pub limit: u32,
#[arg(long)]
pub json: bool,
#[arg(long)]
pub web: bool,
}
#[derive(Debug, Args)]
pub struct ViewArgs {
#[command(flatten)]
pub r: RepoFlag,
pub tag: String,
#[arg(long)]
pub json: bool,
#[arg(long)]
pub web: bool,
}
#[derive(Debug, Args)]
pub struct CreateArgs {
#[command(flatten)]
pub r: RepoFlag,
pub tag: String,
#[arg(short = 't', long)]
pub title: Option<String>,
#[arg(short = 'b', long)]
pub body: Option<String>,
/// Target commitish (branch or SHA). Defaults to default branch.
#[arg(long)]
pub target: Option<String>,
#[arg(long)]
pub draft: bool,
#[arg(long)]
pub prerelease: bool,
/// Files to upload as assets (repeatable).
#[arg(long = "asset", value_name = "FILE")]
pub assets: Vec<String>,
}
#[derive(Debug, Args)]
pub struct EditArgs {
#[command(flatten)]
pub r: RepoFlag,
pub tag: String,
#[arg(short = 't', long)]
pub title: Option<String>,
#[arg(short = 'b', long)]
pub body: Option<String>,
#[arg(long)]
pub draft: Option<bool>,
#[arg(long)]
pub prerelease: Option<bool>,
#[arg(long)]
pub body_editor: bool,
}
#[derive(Debug, Args)]
pub struct DeleteArgs {
#[command(flatten)]
pub r: RepoFlag,
pub tag: String,
#[arg(short = 'y', long)]
pub yes: bool,
}
#[derive(Debug, Args)]
pub struct UploadArgs {
#[command(flatten)]
pub r: RepoFlag,
pub tag: String,
/// File paths to upload.
pub files: Vec<String>,
}
#[derive(Debug, Args)]
pub struct DownloadArgs {
#[command(flatten)]
pub r: RepoFlag,
pub tag: String,
/// Asset name. If omitted, all assets are downloaded.
#[arg(short = 'n', long)]
pub name: Option<String>,
/// Output directory (default: cwd).
#[arg(short = 'o', long)]
pub output_dir: Option<String>,
}
pub async fn run(cmd: ReleaseCmd, host: Option<&str>) -> Result<()> {
match cmd.command {
ReleaseSub::List(args) => list(args, host).await,
ReleaseSub::View(args) => view(args, host).await,
ReleaseSub::Create(args) => create(args, host).await,
ReleaseSub::Edit(args) => edit(args, host).await,
ReleaseSub::Delete(args) => delete(args, host).await,
ReleaseSub::Upload(args) => upload(args, host).await,
ReleaseSub::Download(args) => download(args, host).await,
}
}
async fn list(args: ListArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
if args.web {
return web::open(&format!(
"https://{}/{}/{}/releases",
ctx.host, ctx.owner, ctx.name
));
}
let items = api::release::list(&ctx.client, &ctx.owner, &ctx.name, args.limit).await?;
if args.json {
return output::print_json(&serde_json::to_value(&items)?);
}
if items.is_empty() {
println!("(no releases)");
return Ok(());
}
let rows: Vec<Vec<String>> = items
.iter()
.map(|r| {
let when = r
.published_at
.map(output::relative_time)
.unwrap_or_else(|| "draft".into());
let kind = if r.prerelease {
"pre-release"
} else if r.draft {
"draft"
} else {
"release"
};
vec![
r.tag_name.clone(),
truncate(&r.name, 60),
output::dim(kind),
output::dim(&when),
]
})
.collect();
print!(
"{}",
output::render_table(&["TAG", "TITLE", "KIND", "PUBLISHED"], &rows)
);
Ok(())
}
async fn view(args: ViewArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let r = api::release::get_by_tag(&ctx.client, &ctx.owner, &ctx.name, &args.tag).await?;
if args.web {
return web::open(&r.html_url);
}
if args.json {
return output::print_json(&serde_json::to_value(&r)?);
}
println!(
"{} {}",
output::bold(&format!("{} {}", r.tag_name, r.name)),
output::dim(&r.html_url),
);
if r.prerelease {
println!("(prerelease)");
}
if r.draft {
println!("(draft)");
}
println!();
println!("Target: {}", r.target_commitish);
let when = r
.published_at
.map(output::relative_time)
.unwrap_or_else(|| "".into());
println!("Published: {when}");
println!();
if !r.body.is_empty() {
println!("{}", r.body);
println!();
}
if !r.assets.is_empty() {
println!("{}", output::bold("Assets:"));
for a in &r.assets {
println!(
" • {} {} {}",
a.name,
output::dim(&human_size(a.size)),
output::dim(&a.browser_download_url)
);
}
}
Ok(())
}
async fn create(args: CreateArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let body = editor::resolve_body(args.body.as_deref(), "RELEASE_BODY.md", "")?;
let payload = CreateRelease {
tag_name: &args.tag,
name: args.title.as_deref(),
body: body.as_deref(),
target_commitish: args.target.as_deref(),
draft: args.draft,
prerelease: args.prerelease,
};
let release = api::release::create(&ctx.client, &ctx.owner, &ctx.name, &payload).await?;
println!("✓ Created release {}", release.tag_name);
println!("{}", release.html_url);
for path in &args.assets {
upload_one(&ctx, release.id, path).await?;
}
Ok(())
}
async fn edit(args: EditArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let existing = api::release::get_by_tag(&ctx.client, &ctx.owner, &ctx.name, &args.tag).await?;
let body = if args.body_editor {
Some(editor::edit_text("RELEASE_BODY.md", &existing.body)?)
} else {
args.body
};
let payload = EditRelease {
tag_name: None,
name: args.title.as_deref(),
body: body.as_deref(),
draft: args.draft,
prerelease: args.prerelease,
};
let r = api::release::edit(&ctx.client, &ctx.owner, &ctx.name, existing.id, &payload).await?;
println!("✓ Updated release {}", r.tag_name);
Ok(())
}
async fn delete(args: DeleteArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let existing = api::release::get_by_tag(&ctx.client, &ctx.owner, &ctx.name, &args.tag).await?;
if !args.yes {
let answer =
editor::prompt_line(&format!("Delete release {}? Type tag to confirm", args.tag))?;
if answer != args.tag {
return Err(anyhow::anyhow!("aborted"));
}
}
api::release::delete(&ctx.client, &ctx.owner, &ctx.name, existing.id).await?;
println!("✓ Deleted release {}", args.tag);
Ok(())
}
async fn upload(args: UploadArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let release = api::release::get_by_tag(&ctx.client, &ctx.owner, &ctx.name, &args.tag).await?;
for path in &args.files {
upload_one(&ctx, release.id, path).await?;
}
Ok(())
}
async fn upload_one(
ctx: &crate::cli::context::RepoContext,
release_id: u64,
path: &str,
) -> Result<()> {
let bytes = std::fs::read(path)?;
let name = std::path::Path::new(path)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(path)
.to_string();
let asset =
api::release::upload_asset(&ctx.client, &ctx.owner, &ctx.name, release_id, &name, bytes)
.await?;
println!("✓ Uploaded {} ({})", asset.name, human_size(asset.size));
Ok(())
}
async fn download(args: DownloadArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let release = api::release::get_by_tag(&ctx.client, &ctx.owner, &ctx.name, &args.tag).await?;
let out = args.output_dir.unwrap_or_else(|| ".".into());
std::fs::create_dir_all(&out)?;
let mut downloaded = 0usize;
for a in &release.assets {
if let Some(filter) = &args.name {
if &a.name != filter {
continue;
}
}
let dest = std::path::Path::new(&out).join(&a.name);
let bytes = ctx
.client
.http()
.get(&a.browser_download_url)
.send()
.await?
.error_for_status()?
.bytes()
.await?;
std::fs::write(&dest, &bytes)?;
println!("✓ Downloaded {}{}", a.name, dest.display());
downloaded += 1;
}
if downloaded == 0 {
println!("(no assets matched)");
}
Ok(())
}
fn human_size(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
let mut size = bytes as f64;
let mut unit = 0;
while size >= 1024.0 && unit < UNITS.len() - 1 {
size /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{}B", bytes)
} else {
format!("{:.1}{}", size, UNITS[unit])
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
#[cfg(test)]
mod tests {
use super::human_size;
#[test]
fn formats_bytes() {
assert_eq!(human_size(0), "0B");
assert_eq!(human_size(512), "512B");
}
#[test]
fn formats_kb() {
assert_eq!(human_size(2048), "2.0KB");
}
#[test]
fn formats_mb_gb() {
assert!(human_size(5 * 1024 * 1024).ends_with("MB"));
assert!(human_size(2 * 1024_u64.pow(3)).ends_with("GB"));
}
}

View file

@ -348,7 +348,11 @@ async fn fork(args: ForkArgs, host: Option<&str>) -> Result<()> {
.get(hostname) .get(hostname)
.map(|h| h.git_protocol.clone()) .map(|h| h.git_protocol.clone())
.unwrap_or_else(|| "https".into()); .unwrap_or_else(|| "https".into());
let url = if proto == "ssh" { r.ssh_url } else { r.clone_url }; let url = if proto == "ssh" {
r.ssh_url
} else {
r.clone_url
};
git::clone(&url, None)?; git::clone(&url, None)?;
} }
Ok(()) Ok(())
@ -365,7 +369,10 @@ async fn sync(args: SyncArgs, host: Option<&str>) -> Result<()> {
} }
}; };
api::repo::sync_with_upstream(&ctx.client, &ctx.owner, &ctx.name, &branch).await?; api::repo::sync_with_upstream(&ctx.client, &ctx.owner, &ctx.name, &branch).await?;
println!("✓ Synced {}/{} branch {} with upstream", ctx.owner, ctx.name, branch); println!(
"✓ Synced {}/{} branch {} with upstream",
ctx.owner, ctx.name, branch
);
Ok(()) Ok(())
} }
@ -420,9 +427,7 @@ async fn delete(args: DeleteArgs, host: Option<&str>) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?; let (owner, name) = api::split_repo(&args.repo)?;
let client = Client::connect(host)?; let client = Client::connect(host)?;
if !args.yes { if !args.yes {
let prompt = format!( let prompt = format!("Delete {owner}/{name}? Type the slug to confirm");
"Delete {owner}/{name}? Type the slug to confirm"
);
let answer = editor::prompt_line(&prompt)?; let answer = editor::prompt_line(&prompt)?;
if answer != args.repo { if answer != args.repo {
return Err(anyhow!("aborted: slug did not match")); return Err(anyhow!("aborted: slug did not match"));

123
src/cli/search.rs Normal file
View file

@ -0,0 +1,123 @@
use anyhow::Result;
use clap::{Args, Subcommand};
use crate::api;
use crate::client::Client;
use crate::output;
#[derive(Debug, Args)]
pub struct SearchCmd {
#[command(subcommand)]
pub command: SearchSub,
}
#[derive(Debug, Subcommand)]
pub enum SearchSub {
Repos(QueryArgs),
Issues(QueryArgs),
Prs(QueryArgs),
Users(QueryArgs),
}
#[derive(Debug, Args)]
pub struct QueryArgs {
pub query: String,
#[arg(short = 'L', long, default_value_t = 20)]
pub limit: u32,
#[arg(long)]
pub json: bool,
}
pub async fn run(cmd: SearchCmd, host: Option<&str>) -> Result<()> {
let client = Client::connect(host)?;
match cmd.command {
SearchSub::Repos(args) => search_repos(&client, args).await,
SearchSub::Issues(args) => search_issues(&client, args, "issues").await,
SearchSub::Prs(args) => search_issues(&client, args, "pulls").await,
SearchSub::Users(args) => search_users(&client, args).await,
}
}
async fn search_repos(client: &Client, args: QueryArgs) -> Result<()> {
let hits = api::search::repos(client, &args.query, args.limit).await?;
if args.json {
return output::print_json(&serde_json::to_value(&hits)?);
}
if hits.is_empty() {
println!("(no matches)");
return Ok(());
}
let rows: Vec<Vec<String>> = hits
.iter()
.map(|r| {
let vis = if r.private { "private" } else { "public" };
vec![
r.full_name.clone(),
truncate(&r.description, 60),
vis.into(),
output::dim(&output::relative_time(r.updated_at)),
]
})
.collect();
print!(
"{}",
output::render_table(&["NAME", "DESCRIPTION", "VIS", "UPDATED"], &rows)
);
Ok(())
}
async fn search_issues(client: &Client, args: QueryArgs, type_: &str) -> Result<()> {
let hits = api::search::issues(client, &args.query, type_, args.limit).await?;
if args.json {
return output::print_json(&serde_json::to_value(&hits)?);
}
if hits.is_empty() {
println!("(no matches)");
return Ok(());
}
let rows: Vec<Vec<String>> = hits
.iter()
.map(|i| {
vec![
format!("#{}", i.number),
output::state_pill(&i.state, false),
truncate(&i.title, 60),
output::dim(&i.html_url),
]
})
.collect();
print!(
"{}",
output::render_table(&["", "STATE", "TITLE", "URL"], &rows)
);
Ok(())
}
async fn search_users(client: &Client, args: QueryArgs) -> Result<()> {
let hits = api::search::users(client, &args.query, args.limit).await?;
if args.json {
return output::print_json(&serde_json::to_value(&hits)?);
}
if hits.is_empty() {
println!("(no matches)");
return Ok(());
}
let rows: Vec<Vec<String>> = hits
.iter()
.map(|u| vec![u.login.clone(), u.full_name.clone(), u.email.clone()])
.collect();
print!(
"{}",
output::render_table(&["LOGIN", "NAME", "EMAIL"], &rows)
);
Ok(())
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}

72
src/cli/status.rs Normal file
View file

@ -0,0 +1,72 @@
//! `fj status` — notifications inbox.
use anyhow::Result;
use clap::Args;
use crate::api;
use crate::client::Client;
use crate::output;
#[derive(Debug, Args)]
pub struct StatusArgs {
/// Show all (including already-read) notifications.
#[arg(short = 'a', long)]
pub all: bool,
#[arg(short = 'L', long, default_value_t = 20)]
pub limit: u32,
#[arg(long)]
pub json: bool,
/// Mark all notifications as read.
#[arg(long)]
pub mark_read: bool,
}
pub async fn run(args: StatusArgs, host: Option<&str>) -> Result<()> {
let client = Client::connect(host)?;
if args.mark_read {
api::notification::mark_all_read(&client).await?;
println!("✓ Marked all notifications as read");
return Ok(());
}
let items = api::notification::list(&client, args.all, args.limit).await?;
if args.json {
return output::print_json(&serde_json::to_value(&items)?);
}
if items.is_empty() {
println!("(no notifications)");
return Ok(());
}
let rows: Vec<Vec<String>> = items
.iter()
.map(|n| {
let mark = if n.unread { "" } else { " " };
let repo = n
.repository
.as_ref()
.map(|r| r.full_name.clone())
.unwrap_or_default();
vec![
mark.into(),
output::state_pill(&n.subject.state, false),
n.subject.type_.clone(),
repo,
truncate(&n.subject.title, 60),
output::dim(&output::relative_time(n.updated_at)),
]
})
.collect();
print!(
"{}",
output::render_table(&["", "STATE", "TYPE", "REPO", "TITLE", "UPDATED"], &rows)
);
Ok(())
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}

310
src/cli/workflow.rs Normal file
View file

@ -0,0 +1,310 @@
//! `fj run` and `fj secret` / `fj variable` — Forgejo Actions.
use anyhow::Result;
use clap::{Args, Subcommand};
use crate::api;
use crate::cli::context::{resolve_repo, RepoFlag};
use crate::output;
#[derive(Debug, Args)]
pub struct RunCmd {
#[command(subcommand)]
pub command: RunSub,
}
#[derive(Debug, Subcommand)]
pub enum RunSub {
/// List workflow runs.
List(ListArgs),
/// View a workflow run.
View(ViewArgs),
/// Rerun a workflow run.
Rerun(IdArgs),
/// Cancel an in-progress workflow run.
Cancel(IdArgs),
}
#[derive(Debug, Args)]
pub struct ListArgs {
#[command(flatten)]
pub r: RepoFlag,
#[arg(short = 'L', long, default_value_t = 20)]
pub limit: u32,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct ViewArgs {
#[command(flatten)]
pub r: RepoFlag,
pub run_id: u64,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct IdArgs {
#[command(flatten)]
pub r: RepoFlag,
pub run_id: u64,
}
pub async fn run(cmd: RunCmd, host: Option<&str>) -> Result<()> {
match cmd.command {
RunSub::List(args) => list(args, host).await,
RunSub::View(args) => view(args, host).await,
RunSub::Rerun(args) => rerun(args, host).await,
RunSub::Cancel(args) => cancel(args, host).await,
}
}
async fn list(args: ListArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let runs = api::workflow::list_runs(&ctx.client, &ctx.owner, &ctx.name, args.limit).await?;
if args.json {
return output::print_json(&serde_json::to_value(&runs)?);
}
if runs.is_empty() {
println!("(no workflow runs)");
return Ok(());
}
let rows: Vec<Vec<String>> = runs
.iter()
.map(|r| {
let conclusion = r.conclusion.clone().unwrap_or_default();
vec![
r.run_number.to_string(),
truncate(&r.name, 40),
r.status.clone(),
conclusion,
truncate(&r.head_branch, 30),
output::dim(&output::relative_time(r.updated_at)),
]
})
.collect();
print!(
"{}",
output::render_table(
&["#", "WORKFLOW", "STATUS", "CONCLUSION", "BRANCH", "UPDATED"],
&rows
)
);
Ok(())
}
async fn view(args: ViewArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let r = api::workflow::get_run(&ctx.client, &ctx.owner, &ctx.name, args.run_id).await?;
if args.json {
return output::print_json(&serde_json::to_value(&r)?);
}
println!("Run #{}: {}", r.run_number, r.name);
println!("Status: {}", r.status);
if let Some(c) = &r.conclusion {
println!("Conclusion: {c}");
}
println!("Branch: {}", r.head_branch);
println!("SHA: {}", r.head_sha);
println!("URL: {}", r.html_url);
Ok(())
}
async fn rerun(args: IdArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
api::workflow::rerun(&ctx.client, &ctx.owner, &ctx.name, args.run_id).await?;
println!("✓ Re-ran workflow run #{}", args.run_id);
Ok(())
}
async fn cancel(args: IdArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
api::workflow::cancel(&ctx.client, &ctx.owner, &ctx.name, args.run_id).await?;
println!("✓ Cancelled workflow run #{}", args.run_id);
Ok(())
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
// ── Secrets ─────────────────────────────────────────────────────────────────
#[derive(Debug, Args)]
pub struct SecretCmd {
#[command(subcommand)]
pub command: SecretSub,
}
#[derive(Debug, Subcommand)]
pub enum SecretSub {
List(SecretListArgs),
Set(SecretSetArgs),
Delete(SecretDeleteArgs),
}
#[derive(Debug, Args)]
pub struct SecretListArgs {
#[command(flatten)]
pub r: RepoFlag,
}
#[derive(Debug, Args)]
pub struct SecretSetArgs {
#[command(flatten)]
pub r: RepoFlag,
pub name: String,
/// Value (raw string). For multi-line secrets, prefer `--from-file -` and pipe stdin.
#[arg(short = 'v', long)]
pub value: Option<String>,
/// Read value from this file. Use `-` for stdin.
#[arg(short = 'f', long = "from-file")]
pub from_file: Option<String>,
}
#[derive(Debug, Args)]
pub struct SecretDeleteArgs {
#[command(flatten)]
pub r: RepoFlag,
pub name: String,
#[arg(short = 'y', long)]
pub yes: bool,
}
pub async fn run_secret(cmd: SecretCmd, host: Option<&str>) -> Result<()> {
match cmd.command {
SecretSub::List(args) => secret_list(args, host).await,
SecretSub::Set(args) => secret_set(args, host).await,
SecretSub::Delete(args) => secret_delete(args, host).await,
}
}
async fn secret_list(args: SecretListArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let s = api::workflow::list_secrets(&ctx.client, &ctx.owner, &ctx.name).await?;
if s.is_empty() {
println!("(no secrets)");
return Ok(());
}
let rows: Vec<Vec<String>> = s
.iter()
.map(|x| {
vec![
x.name.clone(),
output::dim(&output::relative_time(x.created_at)),
]
})
.collect();
print!("{}", output::render_table(&["NAME", "CREATED"], &rows));
Ok(())
}
async fn secret_set(args: SecretSetArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let value = read_value(args.value.as_deref(), args.from_file.as_deref())?;
api::workflow::set_secret(&ctx.client, &ctx.owner, &ctx.name, &args.name, &value).await?;
println!("✓ Set secret {}", args.name);
Ok(())
}
async fn secret_delete(args: SecretDeleteArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
if !args.yes {
let ans = crate::cli::editor::prompt_line(&format!(
"Delete secret '{}'? Type name to confirm",
args.name
))?;
if ans != args.name {
return Err(anyhow::anyhow!("aborted"));
}
}
api::workflow::delete_secret(&ctx.client, &ctx.owner, &ctx.name, &args.name).await?;
println!("✓ Deleted secret {}", args.name);
Ok(())
}
// ── Variables ──────────────────────────────────────────────────────────────
#[derive(Debug, Args)]
pub struct VariableCmd {
#[command(subcommand)]
pub command: VariableSub,
}
#[derive(Debug, Subcommand)]
pub enum VariableSub {
List(SecretListArgs),
Set(SecretSetArgs),
Delete(SecretDeleteArgs),
}
pub async fn run_variable(cmd: VariableCmd, host: Option<&str>) -> Result<()> {
match cmd.command {
VariableSub::List(args) => var_list(args, host).await,
VariableSub::Set(args) => var_set(args, host).await,
VariableSub::Delete(args) => var_delete(args, host).await,
}
}
async fn var_list(args: SecretListArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let v = api::workflow::list_variables(&ctx.client, &ctx.owner, &ctx.name).await?;
if v.is_empty() {
println!("(no variables)");
return Ok(());
}
let rows: Vec<Vec<String>> = v
.iter()
.map(|x| vec![x.name.clone(), x.data.clone()])
.collect();
print!("{}", output::render_table(&["NAME", "VALUE"], &rows));
Ok(())
}
async fn var_set(args: SecretSetArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let value = read_value(args.value.as_deref(), args.from_file.as_deref())?;
api::workflow::set_variable(&ctx.client, &ctx.owner, &ctx.name, &args.name, &value).await?;
println!("✓ Set variable {}", args.name);
Ok(())
}
async fn var_delete(args: SecretDeleteArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
if !args.yes {
let ans = crate::cli::editor::prompt_line(&format!(
"Delete variable '{}'? Type name to confirm",
args.name
))?;
if ans != args.name {
return Err(anyhow::anyhow!("aborted"));
}
}
api::workflow::delete_variable(&ctx.client, &ctx.owner, &ctx.name, &args.name).await?;
println!("✓ Deleted variable {}", args.name);
Ok(())
}
fn read_value(value: Option<&str>, from_file: Option<&str>) -> Result<String> {
if let Some(v) = value {
return Ok(v.to_string());
}
if let Some(path) = from_file {
if path == "-" {
use std::io::Read;
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
return Ok(buf.trim_end().to_string());
}
return Ok(std::fs::read_to_string(path)?.trim_end().to_string());
}
Err(anyhow::anyhow!(
"pass --value or --from-file (use `-` for stdin)"
))
}

View file

@ -83,6 +83,17 @@ impl Client {
&self.base &self.base
} }
/// Underlying `reqwest::Client` for code paths that need to bypass our
/// JSON-shaped helpers (e.g. multipart uploads for release assets).
pub fn http(&self) -> &reqwest::Client {
&self.http
}
/// Raw token. Use only when constructing custom request headers.
pub fn token(&self) -> &str {
&self.token
}
fn auth_headers(&self) -> HeaderMap { fn auth_headers(&self) -> HeaderMap {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert( headers.insert(

View file

@ -6,16 +6,39 @@ mod config;
mod git; mod git;
mod output; mod output;
use std::process::ExitCode; use std::process::{Command, ExitCode};
use clap::Parser; use clap::Parser;
#[tokio::main] #[tokio::main]
async fn main() -> ExitCode { async fn main() -> ExitCode {
let cli = cli::Cli::parse(); // Two pre-parse transformations, in order:
// 1. expand a user-defined alias for `fj <name>`
// 2. dispatch to a `fj-<name>` plugin binary on PATH if clap can't
// recognize the subcommand (matches gh's convention)
let argv: Vec<String> = std::env::args().collect();
let argv = cli::alias::expand_argv(argv);
if let Some(plugin) = detect_plugin(&argv) {
let prog = format!("fj-{plugin}");
let extra: Vec<&String> = argv.iter().skip(2).collect();
let status = match Command::new(&prog).args(&extra).status() {
Ok(s) => s,
Err(_) => {
// Plugin not found — fall through to clap, which will error
// nicely.
return run_cli(argv).await;
}
};
return ExitCode::from(status.code().unwrap_or(1) as u8);
}
run_cli(argv).await
}
async fn run_cli(argv: Vec<String>) -> ExitCode {
let cli = cli::Cli::parse_from(argv);
if let Err(err) = cli::run(cli).await { if let Err(err) = cli::run(cli).await {
// Print the full chain of causes, matching the gh-style error output.
eprintln!("{} {err}", owo_colors::OwoColorize::red(&"error:")); eprintln!("{} {err}", owo_colors::OwoColorize::red(&"error:"));
let mut source = err.source(); let mut source = err.source();
while let Some(cause) = source { while let Some(cause) = source {
@ -26,3 +49,56 @@ async fn main() -> ExitCode {
} }
ExitCode::SUCCESS ExitCode::SUCCESS
} }
/// Returns `Some(name)` if `argv[1]` is unrecognized by clap AND a `fj-<name>`
/// binary exists on PATH.
fn detect_plugin(argv: &[String]) -> Option<String> {
if argv.len() < 2 {
return None;
}
let candidate = &argv[1];
// Skip if it's clap-internal or obviously a flag.
if candidate.starts_with('-') {
return None;
}
if KNOWN_SUBCOMMANDS.iter().any(|s| s == candidate) {
return None;
}
// Look up `fj-<name>` on PATH.
let bin = format!("fj-{candidate}");
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let full = dir.join(&bin);
if full.is_file() {
return Some(candidate.to_string());
}
}
None
}
/// Keep this list in sync with `cli::Command`. It's an allowlist used to avoid
/// hijacking real subcommands as plugin candidates.
const KNOWN_SUBCOMMANDS: &[&str] = &[
"auth",
"repo",
"issue",
"pr",
"release",
"label",
"run",
"secret",
"variable",
"search",
"browse",
"status",
"org",
"ssh-key",
"gpg-key",
"alias",
"config",
"extension",
"gist",
"api",
"completion",
"help",
];