initial: fj, a CLI for Forgejo

Multi-host auth (tokens in OS keychain), repo/issue/pr CRUD, and a
gh-style `api` escape hatch with -f/-F/-X/-q. Targets Forgejo 7.x via
the Gitea-compatible /api/v1 surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stephen Way 2026-05-13 07:56:28 -07:00
commit 495276f654
No known key found for this signature in database
24 changed files with 5000 additions and 0 deletions

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
/target
**/*.rs.bk
Cargo.lock.bak
.DS_Store
.idea/
.vscode/
*.swp
*.swo

2478
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

47
Cargo.toml Normal file
View file

@ -0,0 +1,47 @@
[package]
name = "fj"
version = "0.1.0"
edition = "2021"
rust-version = "1.80"
description = "Command-line tool for Forgejo, in the spirit of gh"
authors = ["Stephen Way <stephen@rasterstate.com>"]
license = "MIT"
repository = "https://rasterhub.com/rasterstate/fj"
readme = "README.md"
[[bin]]
name = "fj"
path = "src/main.rs"
[dependencies]
anyhow = "1"
thiserror = "2"
clap = { version = "4.5", features = ["derive", "env", "wrap_help"] }
clap_complete = "4.5"
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"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
directories = "5"
keyring = { version = "3", features = ["apple-native", "linux-native", "windows-native"] }
url = "2"
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"] }
tabled = { version = "0.16", features = ["ansi"] }
owo-colors = { version = "4", features = ["supports-colors"] }
supports-color = "3"
indicatif = "0.17"
dialoguer = { version = "0.11", default-features = false, features = ["password"] }
is-terminal = "0.4"
textwrap = "0.16"
futures-util = "0.3"
[profile.release]
lto = "thin"
codegen-units = 1
strip = "symbols"
opt-level = 3
[profile.dist]
inherits = "release"
lto = "fat"

44
README.md Normal file
View file

@ -0,0 +1,44 @@
# fj
A command-line tool for [Forgejo](https://forgejo.org) instances, in the spirit of GitHub's `gh`.
Multi-host from day one. Tokens are stored in your OS keychain.
## Install
```sh
cargo install --path .
```
## Quick start
```sh
fj auth login # add a host and token
fj auth status # show signed-in hosts
fj repo list # repos you own on the default host
fj repo view owner/name # repo overview
fj issue list -R owner/name # issues
fj pr list -R owner/name --state open # pull requests
fj api /repos/search?q=foo # raw API escape hatch
```
Use `--host <hostname>` on any command to target a specific host.
## Commands
| Group | Commands |
| ------- | ------------------------------------------------- |
| `auth` | `login`, `status`, `logout`, `list`, `switch` |
| `repo` | `list`, `view`, `clone`, `create` |
| `issue` | `list`, `view`, `create`, `close`, `reopen`, `comment` |
| `pr` | `list`, `view`, `create`, `checkout`, `merge`, `close` |
| `api` | raw HTTP against `/api/v1` with optional fields and jq-style field selection |
## Config
- Hosts and the current host live in `$XDG_CONFIG_HOME/fj/hosts.toml` (`~/Library/Application Support/fj/hosts.toml` on macOS).
- Tokens live in the OS keychain under service `fj` keyed by hostname.
## License
MIT

190
src/api/issue.rs Normal file
View file

@ -0,0 +1,190 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::client::{Client, Page};
use super::user::User;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Issue {
pub id: u64,
pub number: u64,
pub title: String,
#[serde(default)]
pub body: String,
pub state: String,
pub user: User,
#[serde(default)]
pub labels: Vec<Label>,
#[serde(default)]
pub assignees: Vec<User>,
pub html_url: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(default)]
pub comments: u64,
/// Present only when the issue is actually a pull request.
#[serde(default)]
pub pull_request: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Label {
pub id: u64,
pub name: String,
pub color: String,
#[serde(default)]
pub description: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum State {
Open,
Closed,
All,
}
impl State {
pub fn as_str(&self) -> &'static str {
match self {
State::Open => "open",
State::Closed => "closed",
State::All => "all",
}
}
}
#[derive(Debug, Clone)]
pub struct ListOptions<'a> {
pub state: State,
pub limit: u32,
pub page: u32,
pub labels: Option<&'a str>,
pub assignee: Option<&'a str>,
pub query: Option<&'a str>,
}
impl Default for ListOptions<'_> {
fn default() -> Self {
Self {
state: State::Open,
limit: 30,
page: 1,
labels: None,
assignee: None,
query: None,
}
}
}
pub async fn list(
client: &Client,
owner: &str,
name: &str,
opts: ListOptions<'_>,
) -> Result<Page<Issue>> {
let path = format!("/api/v1/repos/{owner}/{name}/issues");
let mut query: Vec<(String, String)> = vec![
("state".into(), opts.state.as_str().into()),
("limit".into(), opts.limit.clamp(1, 50).to_string()),
("page".into(), opts.page.max(1).to_string()),
("type".into(), "issues".into()),
];
if let Some(l) = opts.labels {
query.push(("labels".into(), l.into()));
}
if let Some(a) = opts.assignee {
query.push(("assigned_by".into(), a.into()));
}
if let Some(q) = opts.query {
query.push(("q".into(), q.into()));
}
client.get_page::<Issue>(&path, &query).await
}
pub async fn get(client: &Client, owner: &str, name: &str, number: u64) -> Result<Issue> {
let path = format!("/api/v1/repos/{owner}/{name}/issues/{number}");
client.json(Method::GET, &path, &[], None::<&()>).await
}
#[derive(Debug, Clone, Serialize)]
pub struct CreateIssue<'a> {
pub title: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assignees: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<u64>>,
}
pub async fn create(
client: &Client,
owner: &str,
name: &str,
body: &CreateIssue<'_>,
) -> Result<Issue> {
let path = format!("/api/v1/repos/{owner}/{name}/issues");
client.json(Method::POST, &path, &[], Some(body)).await
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct EditIssue<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<&'a str>,
}
pub async fn edit(
client: &Client,
owner: &str,
name: &str,
number: u64,
body: &EditIssue<'_>,
) -> Result<Issue> {
let path = format!("/api/v1/repos/{owner}/{name}/issues/{number}");
client.json(Method::PATCH, &path, &[], Some(body)).await
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Comment {
pub id: u64,
pub body: String,
pub user: User,
pub html_url: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize)]
struct CreateComment<'a> {
body: &'a str,
}
pub async fn comment(
client: &Client,
owner: &str,
name: &str,
number: u64,
body: &str,
) -> Result<Comment> {
let path = format!("/api/v1/repos/{owner}/{name}/issues/{number}/comments");
let payload = CreateComment { body };
client
.json(Method::POST, &path, &[], Some(&payload))
.await
}
pub async fn list_comments(
client: &Client,
owner: &str,
name: &str,
number: u64,
) -> Result<Vec<Comment>> {
let path = format!("/api/v1/repos/{owner}/{name}/issues/{number}/comments");
client.json(Method::GET, &path, &[], None::<&()>).await
}

13
src/api/mod.rs Normal file
View file

@ -0,0 +1,13 @@
pub mod issue;
pub mod pull;
pub mod repo;
pub mod user;
use anyhow::{anyhow, Result};
/// Split an `owner/name` slug. Returns a helpful error if the form is wrong.
pub fn split_repo(repo: &str) -> Result<(&str, &str)> {
repo.split_once('/')
.filter(|(o, n)| !o.is_empty() && !n.is_empty())
.ok_or_else(|| anyhow!("expected '<owner>/<name>', got '{repo}'"))
}

173
src/api/pull.rs Normal file
View file

@ -0,0 +1,173 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::client::{Client, Page};
use super::issue::{Label, State};
use super::user::User;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pull {
pub id: u64,
pub number: u64,
pub title: String,
#[serde(default)]
pub body: String,
pub state: String,
pub user: User,
#[serde(default)]
pub labels: Vec<Label>,
pub html_url: String,
pub head: Branch,
pub base: Branch,
#[serde(default)]
pub draft: bool,
#[serde(default)]
pub mergeable: bool,
#[serde(default)]
pub merged: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(default)]
pub comments: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Branch {
#[serde(default)]
pub label: String,
#[serde(default, rename = "ref")]
pub ref_: String,
#[serde(default)]
pub sha: String,
}
#[derive(Debug, Clone)]
pub struct ListOptions {
pub state: State,
pub limit: u32,
pub page: u32,
}
impl Default for ListOptions {
fn default() -> Self {
Self {
state: State::Open,
limit: 30,
page: 1,
}
}
}
pub async fn list(
client: &Client,
owner: &str,
name: &str,
opts: ListOptions,
) -> Result<Page<Pull>> {
let path = format!("/api/v1/repos/{owner}/{name}/pulls");
let query: Vec<(String, String)> = vec![
("state".into(), opts.state.as_str().into()),
("limit".into(), opts.limit.clamp(1, 50).to_string()),
("page".into(), opts.page.max(1).to_string()),
];
client.get_page::<Pull>(&path, &query).await
}
pub async fn get(client: &Client, owner: &str, name: &str, number: u64) -> Result<Pull> {
let path = format!("/api/v1/repos/{owner}/{name}/pulls/{number}");
client.json(Method::GET, &path, &[], None::<&()>).await
}
#[derive(Debug, Clone, Serialize)]
pub struct CreatePull<'a> {
pub title: &'a str,
pub head: &'a str,
pub base: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<&'a str>,
}
pub async fn create(
client: &Client,
owner: &str,
name: &str,
body: &CreatePull<'_>,
) -> Result<Pull> {
let path = format!("/api/v1/repos/{owner}/{name}/pulls");
client.json(Method::POST, &path, &[], Some(body)).await
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct EditPull<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<&'a str>,
}
pub async fn edit(
client: &Client,
owner: &str,
name: &str,
number: u64,
body: &EditPull<'_>,
) -> Result<Pull> {
let path = format!("/api/v1/repos/{owner}/{name}/pulls/{number}");
client.json(Method::PATCH, &path, &[], Some(body)).await
}
#[derive(Debug, Clone, Copy)]
pub enum MergeStyle {
Merge,
Rebase,
RebaseMerge,
Squash,
}
impl MergeStyle {
fn as_str(self) -> &'static str {
match self {
MergeStyle::Merge => "merge",
MergeStyle::Rebase => "rebase",
MergeStyle::RebaseMerge => "rebase-merge",
MergeStyle::Squash => "squash",
}
}
}
#[derive(Debug, Clone, Serialize)]
struct MergeBody<'a> {
#[serde(rename = "Do")]
do_: &'a str,
#[serde(skip_serializing_if = "Option::is_none", rename = "MergeTitleField")]
title: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none", rename = "MergeMessageField")]
message: Option<&'a str>,
}
pub async fn merge(
client: &Client,
owner: &str,
name: &str,
number: u64,
style: MergeStyle,
title: Option<&str>,
message: Option<&str>,
) -> Result<()> {
let path = format!("/api/v1/repos/{owner}/{name}/pulls/{number}/merge");
let body = MergeBody {
do_: style.as_str(),
title,
message,
};
let res = client
.request(Method::POST, &path, &[], Some(&serde_json::to_value(&body)?))
.await?;
res.error_for_status()?;
Ok(())
}

125
src/api/repo.rs Normal file
View file

@ -0,0 +1,125 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::client::{Client, Page};
use super::user::User;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Repo {
pub id: u64,
pub name: String,
pub full_name: String,
pub owner: User,
#[serde(default)]
pub description: String,
#[serde(default)]
pub private: bool,
#[serde(default)]
pub fork: bool,
#[serde(default)]
pub archived: bool,
pub html_url: String,
pub clone_url: String,
pub ssh_url: String,
#[serde(default)]
pub default_branch: String,
#[serde(default)]
pub stars_count: u64,
#[serde(default)]
pub forks_count: u64,
#[serde(default)]
pub open_issues_count: u64,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct ListOptions<'a> {
pub limit: u32,
pub page: u32,
pub query: Option<&'a str>,
}
pub async fn list_for_user(client: &Client, opts: ListOptions<'_>) -> Result<Page<Repo>> {
let limit = opts.limit.clamp(1, 50);
let page = opts.page.max(1);
let query: Vec<(String, String)> = vec![
("limit".into(), limit.to_string()),
("page".into(), page.to_string()),
];
client.get_page::<Repo>("/api/v1/user/repos", &query).await
}
pub async fn search(client: &Client, opts: ListOptions<'_>) -> Result<Page<SearchHit>> {
let limit = opts.limit.clamp(1, 50);
let page = opts.page.max(1);
let mut query: Vec<(String, String)> = vec![
("limit".into(), limit.to_string()),
("page".into(), page.to_string()),
];
if let Some(q) = opts.query {
query.push(("q".into(), q.into()));
}
let res = client
.request(
Method::GET,
"/api/v1/repos/search",
&query,
None,
)
.await?;
let headers = res.headers().clone();
let body: SearchResponse = res.error_for_status()?.json().await?;
Ok(Page::from_headers(body.data, &headers).also_total(body.ok))
}
#[derive(Debug, Deserialize)]
struct SearchResponse {
#[serde(default)]
data: Vec<SearchHit>,
#[serde(default)]
ok: bool,
}
impl<T> Page<T> {
fn also_total(self, _ok: bool) -> Self {
self
}
}
pub type SearchHit = Repo;
pub async fn get(client: &Client, owner: &str, name: &str) -> Result<Repo> {
let path = format!("/api/v1/repos/{owner}/{name}");
client.json(Method::GET, &path, &[], None::<&()>).await
}
#[derive(Debug, Clone, Serialize)]
pub struct CreateRepo<'a> {
pub name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<&'a str>,
#[serde(default)]
pub private: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_branch: Option<&'a str>,
#[serde(default)]
pub auto_init: bool,
}
pub async fn create_for_current_user(client: &Client, body: &CreateRepo<'_>) -> Result<Repo> {
client
.json(Method::POST, "/api/v1/user/repos", &[], Some(body))
.await
}
pub async fn create_for_org(
client: &Client,
org: &str,
body: &CreateRepo<'_>,
) -> Result<Repo> {
let path = format!("/api/v1/orgs/{org}/repos");
client.json(Method::POST, &path, &[], Some(body)).await
}

23
src/api/user.rs Normal file
View file

@ -0,0 +1,23 @@
use anyhow::Result;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::client::Client;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: u64,
pub login: String,
#[serde(default)]
pub full_name: String,
#[serde(default)]
pub email: String,
#[serde(default)]
pub avatar_url: String,
#[serde(default)]
pub is_admin: bool,
}
pub async fn current(client: &Client) -> Result<User> {
client.json(Method::GET, "/api/v1/user", &[], None::<&()>).await
}

40
src/auth/mod.rs Normal file
View file

@ -0,0 +1,40 @@
//! Token storage. Tokens live in the OS keychain under service `fj`, keyed by
//! hostname. We deliberately do not write tokens to disk.
use anyhow::{Context, Result};
use keyring::Entry;
const SERVICE: &str = "fj";
fn entry(host: &str) -> Result<Entry> {
Entry::new(SERVICE, host).with_context(|| format!("opening keychain entry for {host}"))
}
/// Save a token for `host`. Replaces any existing entry.
pub fn store_token(host: &str, token: &str) -> Result<()> {
let entry = entry(host)?;
entry
.set_password(token)
.with_context(|| format!("writing token for {host} to keychain"))?;
Ok(())
}
/// Look up the stored token for `host`. Returns `Ok(None)` when not present
/// rather than an error so callers can prompt for login.
pub fn load_token(host: &str) -> Result<Option<String>> {
let entry = entry(host)?;
match entry.get_password() {
Ok(token) => Ok(Some(token)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(e).with_context(|| format!("reading token for {host} from keychain")),
}
}
pub fn delete_token(host: &str) -> Result<()> {
let entry = entry(host)?;
match entry.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(e).with_context(|| format!("removing token for {host} from keychain")),
}
}

144
src/cli/api.rs Normal file
View file

@ -0,0 +1,144 @@
use anyhow::{anyhow, Context, Result};
use clap::Args;
use reqwest::Method;
use serde_json::Value;
use crate::client::Client;
use crate::output;
#[derive(Debug, Args)]
pub struct ApiArgs {
/// API path, e.g. `/repos/owner/name` or `repos/owner/name`. Absolute URLs
/// are also accepted.
pub endpoint: String,
/// HTTP method. Defaults to GET, or POST when fields are supplied.
#[arg(short = 'X', long = "method")]
pub method: Option<String>,
/// Add a query parameter (`-f key=value`). For GET; for non-GET methods these
/// are sent as a JSON body field instead. Repeatable.
#[arg(short = 'f', long = "field", value_name = "KEY=VALUE")]
pub fields: Vec<String>,
/// Like `-f` but interprets the value as raw JSON (numbers, bools, arrays, objects).
#[arg(short = 'F', long = "raw-field", value_name = "KEY=VALUE")]
pub raw_fields: Vec<String>,
/// Output a specific JSON path (very small jq-ish subset, e.g. `.items.0.title`).
#[arg(short = 'q', long = "jq")]
pub jq: Option<String>,
/// Send a literal JSON request body. Conflicts with `-f` / `-F`.
#[arg(long, conflicts_with_all = ["fields", "raw_fields"])]
pub input: Option<String>,
}
pub async fn run(args: ApiArgs, host: Option<&str>) -> Result<()> {
let client = Client::connect(host)?;
let method = pick_method(&args)?;
let (query, body) = build_query_or_body(&args, &method)?;
let res = client
.request(method.clone(), &args.endpoint, &query, body.as_ref())
.await?;
let status = res.status();
let text = res.text().await.context("reading response body")?;
let parsed: Value = if text.is_empty() {
Value::Null
} else {
serde_json::from_str(&text).unwrap_or_else(|_| Value::String(text.clone()))
};
if !status.is_success() {
eprintln!("HTTP {status}");
output::print_json(&parsed)?;
return Err(anyhow!("request failed"));
}
let projected = match &args.jq {
Some(path) => extract_path(&parsed, path)?,
None => parsed,
};
output::print_json(&projected)
}
fn pick_method(args: &ApiArgs) -> Result<Method> {
if let Some(m) = &args.method {
return Method::from_bytes(m.to_uppercase().as_bytes())
.with_context(|| format!("invalid HTTP method '{m}'"));
}
// Only `--input` (a literal JSON body) implies POST. `-f` / `-F` on a GET
// are sent as query params, which is what users hit search endpoints for.
// To send a write request, pass `-X POST` (or PATCH/PUT/DELETE) explicitly.
if args.input.is_some() {
return Ok(Method::POST);
}
Ok(Method::GET)
}
fn build_query_or_body(
args: &ApiArgs,
method: &Method,
) -> Result<(Vec<(String, String)>, Option<Value>)> {
if let Some(input) = &args.input {
let body: Value =
serde_json::from_str(input).context("--input must be valid JSON")?;
return Ok((Vec::new(), Some(body)));
}
if method == Method::GET || method == Method::DELETE {
let mut q = Vec::new();
for f in &args.fields {
let (k, v) = split_kv(f)?;
q.push((k.to_string(), v.to_string()));
}
for f in &args.raw_fields {
let (k, v) = split_kv(f)?;
q.push((k.to_string(), v.to_string()));
}
return Ok((q, None));
}
let mut obj = serde_json::Map::new();
for f in &args.fields {
let (k, v) = split_kv(f)?;
obj.insert(k.to_string(), Value::String(v.to_string()));
}
for f in &args.raw_fields {
let (k, v) = split_kv(f)?;
let parsed: Value = serde_json::from_str(v)
.with_context(|| format!("--raw-field {k}: value '{v}' is not valid JSON"))?;
obj.insert(k.to_string(), parsed);
}
Ok((Vec::new(), Some(Value::Object(obj))))
}
fn split_kv(s: &str) -> Result<(&str, &str)> {
s.split_once('=')
.ok_or_else(|| anyhow!("expected KEY=VALUE, got '{s}'"))
}
/// Very small jq-ish projector: dot-separated keys + numeric indices.
/// e.g. `.data.0.name`, `.message`.
fn extract_path(value: &Value, path: &str) -> Result<Value> {
let path = path.trim_start_matches('.');
if path.is_empty() {
return Ok(value.clone());
}
let mut current = value;
for segment in path.split('.') {
if let Ok(idx) = segment.parse::<usize>() {
current = current
.get(idx)
.ok_or_else(|| anyhow!("index {idx} out of range at '{segment}'"))?;
} else {
current = current
.get(segment)
.ok_or_else(|| anyhow!("no field '{segment}'"))?;
}
}
Ok(current.clone())
}

248
src/cli/auth.rs Normal file
View file

@ -0,0 +1,248 @@
use anyhow::{anyhow, Context, Result};
use clap::{Args, Subcommand};
use dialoguer::Password;
use crate::api;
use crate::auth as token_store;
use crate::client::Client;
use crate::config::hosts::{Host, Hosts};
use crate::output;
#[derive(Debug, Args)]
pub struct AuthCmd {
#[command(subcommand)]
pub command: AuthSub,
}
#[derive(Debug, Subcommand)]
pub enum AuthSub {
/// Authenticate with a Forgejo host. Stores the token in your OS keychain.
Login(LoginArgs),
/// Show which hosts you're signed in to.
Status(StatusArgs),
/// Forget the token for a host.
Logout(LogoutArgs),
/// List configured hosts.
List,
/// Set the default host for commands that omit `--host`.
Switch(SwitchArgs),
}
#[derive(Debug, Args)]
pub struct LoginArgs {
/// Hostname to log in to (e.g. `rasterhub.com`).
#[arg(long)]
pub host: Option<String>,
/// Pass the token as a flag instead of through stdin / interactive prompt.
#[arg(long, env = "FJ_TOKEN", hide_env_values = true)]
pub token: Option<String>,
/// Read the token from stdin (useful for piping `cat token.txt | fj auth login --with-token`).
#[arg(long, conflicts_with = "token")]
pub with_token: bool,
/// Git protocol to use when cloning from this host.
#[arg(long, value_parser = ["https", "ssh"], default_value = "https")]
pub git_protocol: String,
}
#[derive(Debug, Args)]
pub struct StatusArgs {
/// Show the token (still doesn't read it from disk; pulled from keychain).
#[arg(long)]
pub show_token: bool,
}
#[derive(Debug, Args)]
pub struct LogoutArgs {
/// Hostname to remove. Required when more than one host is configured.
#[arg(long)]
pub host: Option<String>,
}
#[derive(Debug, Args)]
pub struct SwitchArgs {
/// Hostname to make the new default.
pub host: String,
}
pub async fn run(cmd: AuthCmd) -> Result<()> {
match cmd.command {
AuthSub::Login(args) => login(args).await,
AuthSub::Status(args) => status(args).await,
AuthSub::Logout(args) => logout(args),
AuthSub::List => list(),
AuthSub::Switch(args) => switch(args),
}
}
async fn login(args: LoginArgs) -> Result<()> {
let hostname = match args.host.clone() {
Some(h) => h,
None => prompt_hostname()?,
};
let hostname = hostname.trim().to_string();
if hostname.is_empty() {
return Err(anyhow!("hostname is required"));
}
let token = read_token(&args)?;
if token.trim().is_empty() {
return Err(anyhow!("token is required"));
}
let git_protocol = args.git_protocol.clone();
// Verify before persisting: never store a token we can't actually use.
let provisional = Host {
user: None,
git_protocol: git_protocol.clone(),
api_base_path: None,
};
let probe_client = build_probe_client(&hostname, &provisional, &token)?;
let me = api::user::current(&probe_client)
.await
.context("verifying token against /api/v1/user")?;
// Persist host + token.
let mut hosts = Hosts::load()?;
let host = Host {
user: Some(me.login.clone()),
git_protocol,
api_base_path: None,
};
hosts.upsert(&hostname, host);
hosts.save()?;
token_store::store_token(&hostname, &token)?;
println!(
"{} Logged in to {} as {}",
output::bold(""),
output::bold(&hostname),
output::bold(&me.login)
);
Ok(())
}
fn build_probe_client(host: &str, cfg: &Host, token: &str) -> Result<Client> {
let resolved = crate::client::ResolvedHost {
name: host.to_string(),
host: cfg.clone(),
token: token.to_string(),
};
Client::new(resolved)
}
fn read_token(args: &LoginArgs) -> Result<String> {
if let Some(t) = &args.token {
return Ok(t.clone());
}
if args.with_token {
use std::io::Read;
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
return Ok(buf.trim().to_string());
}
let t = Password::new()
.with_prompt("Paste a Forgejo access token")
.interact()
.context("reading token")?;
Ok(t)
}
fn prompt_hostname() -> Result<String> {
eprint!("Hostname (e.g. rasterhub.com): ");
use std::io::{stderr, stdin, Write};
stderr().flush().ok();
let mut buf = String::new();
stdin().read_line(&mut buf).context("reading hostname")?;
Ok(buf)
}
async fn status(args: StatusArgs) -> Result<()> {
let hosts = Hosts::load()?;
if hosts.hosts.is_empty() {
println!("You are not signed in to any Forgejo hosts. Run `fj auth login`.");
return Ok(());
}
for (name, host) in &hosts.hosts {
let current_marker = if hosts.current.as_deref() == Some(name) {
output::bold(" (current)")
} else {
String::new()
};
println!("{}{current_marker}", output::bold(name));
let token_present = token_store::load_token(name)?.is_some();
match host.user.as_deref() {
Some(u) => println!(" ✓ Logged in as {u}"),
None => println!(" ✓ Host configured"),
}
println!(
" ✓ Token: {}",
if token_present { "present in keychain" } else { "missing" }
);
if args.show_token {
if let Some(t) = token_store::load_token(name)? {
println!(" Token value: {t}");
}
}
println!(" Git protocol: {}", host.git_protocol);
}
Ok(())
}
fn logout(args: LogoutArgs) -> Result<()> {
let mut hosts = Hosts::load()?;
let target = match args.host {
Some(h) => h,
None => {
if hosts.hosts.len() != 1 {
return Err(anyhow!(
"multiple hosts configured; pass `--host <hostname>`"
));
}
hosts.hosts.keys().next().cloned().unwrap()
}
};
if hosts.remove(&target).is_none() {
return Err(anyhow!("no entry for host '{target}'"));
}
hosts.save()?;
token_store::delete_token(&target)?;
println!("✓ Logged out of {target}");
Ok(())
}
fn list() -> Result<()> {
let hosts = Hosts::load()?;
if hosts.hosts.is_empty() {
println!("(no hosts configured)");
return Ok(());
}
let rows: Vec<Vec<String>> = hosts
.hosts
.iter()
.map(|(name, h)| {
let current = if hosts.current.as_deref() == Some(name) {
"*"
} else {
""
};
vec![
current.into(),
name.clone(),
h.user.clone().unwrap_or_default(),
h.git_protocol.clone(),
]
})
.collect();
let table = output::render_table(&["", "HOST", "USER", "PROTOCOL"], &rows);
print!("{table}");
Ok(())
}
fn switch(args: SwitchArgs) -> Result<()> {
let mut hosts = Hosts::load()?;
hosts.set_current(&args.host)?;
hosts.save()?;
println!("✓ Default host is now {}", args.host);
Ok(())
}

274
src/cli/issue.rs Normal file
View file

@ -0,0 +1,274 @@
use std::io::Read;
use anyhow::{anyhow, Result};
use clap::{Args, Subcommand, ValueEnum};
use crate::api;
use crate::api::issue::{CreateIssue, EditIssue, ListOptions, State};
use crate::client::Client;
use crate::output;
#[derive(Debug, Args)]
pub struct IssueCmd {
#[command(subcommand)]
pub command: IssueSub,
}
#[derive(Debug, Subcommand)]
pub enum IssueSub {
/// List issues in a repo.
List(ListArgs),
/// View an issue by number.
View(ViewArgs),
/// Create an issue.
Create(CreateArgs),
/// Close an issue.
Close(NumberOnly),
/// Reopen a closed issue.
Reopen(NumberOnly),
/// Add a comment.
Comment(CommentArgs),
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum StateFilter {
Open,
Closed,
All,
}
impl From<StateFilter> for State {
fn from(value: StateFilter) -> Self {
match value {
StateFilter::Open => State::Open,
StateFilter::Closed => State::Closed,
StateFilter::All => State::All,
}
}
}
#[derive(Debug, Args)]
pub struct ListArgs {
/// Repository slug `owner/name`.
#[arg(short = 'R', long = "repo")]
pub repo: String,
#[arg(short = 's', long, value_enum, default_value_t = StateFilter::Open)]
pub state: StateFilter,
#[arg(short = 'L', long, default_value_t = 30)]
pub limit: u32,
#[arg(long, default_value_t = 1)]
pub page: u32,
#[arg(short = 'l', long)]
pub label: Option<String>,
#[arg(short = 'a', long)]
pub assignee: Option<String>,
#[arg(short = 'S', long = "search")]
pub query: Option<String>,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct ViewArgs {
#[arg(short = 'R', long = "repo")]
pub repo: String,
pub number: u64,
#[arg(long, default_value_t = false)]
pub comments: bool,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct CreateArgs {
#[arg(short = 'R', long = "repo")]
pub repo: String,
#[arg(short = 't', long)]
pub title: String,
/// Body. Use `-` to read from stdin.
#[arg(short = 'b', long)]
pub body: Option<String>,
}
#[derive(Debug, Args)]
pub struct NumberOnly {
#[arg(short = 'R', long = "repo")]
pub repo: String,
pub number: u64,
}
#[derive(Debug, Args)]
pub struct CommentArgs {
#[arg(short = 'R', long = "repo")]
pub repo: String,
pub number: u64,
/// Comment body. Use `-` to read from stdin.
#[arg(short = 'b', long)]
pub body: String,
}
pub async fn run(cmd: IssueCmd, host: Option<&str>) -> Result<()> {
let client = Client::connect(host)?;
match cmd.command {
IssueSub::List(args) => list(&client, args).await,
IssueSub::View(args) => view(&client, args).await,
IssueSub::Create(args) => create(&client, args).await,
IssueSub::Close(args) => set_state(&client, args, "closed").await,
IssueSub::Reopen(args) => set_state(&client, args, "open").await,
IssueSub::Comment(args) => comment(&client, args).await,
}
}
async fn list(client: &Client, args: ListArgs) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?;
let opts = ListOptions {
state: args.state.into(),
limit: args.limit,
page: args.page,
labels: args.label.as_deref(),
assignee: args.assignee.as_deref(),
query: args.query.as_deref(),
};
let page = api::issue::list(client, owner, name, opts).await?;
if args.json {
return output::print_json(&serde_json::to_value(&page.items)?);
}
if page.items.is_empty() {
println!("(no issues)");
return Ok(());
}
let rows: Vec<Vec<String>> = page
.items
.iter()
.map(|i| {
vec![
format!("#{}", i.number),
output::state_pill(&i.state, false),
truncate(&i.title, 70),
output::dim(&output::relative_time(i.updated_at)),
]
})
.collect();
print!(
"{}",
output::render_table(&["", "STATE", "TITLE", "UPDATED"], &rows)
);
Ok(())
}
async fn view(client: &Client, args: ViewArgs) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?;
let issue = api::issue::get(client, owner, name, args.number).await?;
if args.json {
let mut v = serde_json::to_value(&issue)?;
if args.comments {
let comments = api::issue::list_comments(client, owner, name, args.number).await?;
v["comments_list"] = serde_json::to_value(comments)?;
}
return output::print_json(&v);
}
println!(
"{} {} {}",
output::bold(&format!("#{} {}", issue.number, issue.title)),
output::state_pill(&issue.state, false),
output::dim(&output::relative_time(issue.updated_at)),
);
println!("{}", output::dim(&issue.html_url));
println!();
if !issue.body.is_empty() {
println!("{}", issue.body);
println!();
}
if args.comments {
let comments = api::issue::list_comments(client, owner, name, args.number).await?;
if comments.is_empty() {
println!("{}", output::dim("(no comments)"));
} else {
for c in comments {
println!(
"── {} {}",
output::bold(&c.user.login),
output::dim(&output::relative_time(c.created_at)),
);
println!("{}", c.body);
println!();
}
}
}
Ok(())
}
async fn create(client: &Client, args: CreateArgs) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?;
let body = read_body(args.body.as_deref())?;
let payload = CreateIssue {
title: &args.title,
body: body.as_deref(),
assignees: None,
labels: None,
};
let issue = api::issue::create(client, owner, name, &payload).await?;
println!("✓ Created issue #{}: {}", issue.number, issue.title);
println!("{}", issue.html_url);
Ok(())
}
async fn set_state(client: &Client, args: NumberOnly, state: &str) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?;
let issue = api::issue::edit(
client,
owner,
name,
args.number,
&EditIssue {
state: Some(state),
..Default::default()
},
)
.await?;
println!(
"✓ Issue #{} is now {}",
issue.number,
output::state_pill(&issue.state, false)
);
Ok(())
}
async fn comment(client: &Client, args: CommentArgs) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?;
let body = if args.body == "-" {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
buf
} else {
args.body
};
if body.trim().is_empty() {
return Err(anyhow!("comment body is empty"));
}
let c = api::issue::comment(client, owner, name, args.number, &body).await?;
println!("✓ Commented on #{} ({})", args.number, c.html_url);
Ok(())
}
fn read_body(arg: Option<&str>) -> Result<Option<String>> {
match arg {
None => Ok(None),
Some("-") => {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
Ok(Some(buf))
}
Some(s) => Ok(Some(s.to_string())),
}
}
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/mod.rs Normal file
View file

@ -0,0 +1,72 @@
pub mod api;
pub mod auth;
pub mod issue;
pub mod pr;
pub mod repo;
use anyhow::Result;
use clap::{Parser, Subcommand};
/// `fj` — command-line tool for Forgejo.
#[derive(Debug, Parser)]
#[command(
name = "fj",
version,
about = "Command-line tool for Forgejo",
long_about = None,
propagate_version = true,
arg_required_else_help = true,
)]
pub struct Cli {
/// Override the host for this command (otherwise the current host from
/// `fj auth login` is used).
#[arg(long, global = true, env = "FJ_HOST")]
pub host: Option<String>,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
/// Manage authentication against Forgejo hosts.
Auth(auth::AuthCmd),
/// Work with repositories.
Repo(repo::RepoCmd),
/// Work with issues.
Issue(issue::IssueCmd),
/// Work with pull requests.
Pr(pr::PrCmd),
/// Make a raw API request (like `gh api`).
Api(api::ApiArgs),
/// Print shell completions to stdout.
Completion(CompletionArgs),
}
#[derive(Debug, clap::Args)]
pub struct CompletionArgs {
/// Shell to generate completions for.
#[arg(value_enum)]
pub shell: clap_complete::Shell,
}
pub async fn run(cli: Cli) -> Result<()> {
match cli.command {
Command::Auth(cmd) => auth::run(cmd).await,
Command::Repo(cmd) => repo::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::Api(args) => api::run(args, cli.host.as_deref()).await,
Command::Completion(args) => {
use clap::CommandFactory;
let mut cmd = Cli::command();
clap_complete::generate(
args.shell,
&mut cmd,
"fj",
&mut std::io::stdout(),
);
Ok(())
}
}
}

317
src/cli/pr.rs Normal file
View file

@ -0,0 +1,317 @@
use std::io::Read;
use anyhow::Result;
use clap::{Args, Subcommand, ValueEnum};
use crate::api;
use crate::api::issue::State;
use crate::api::pull::{CreatePull, EditPull, ListOptions, MergeStyle};
use crate::client::Client;
use crate::git;
use crate::output;
#[derive(Debug, Args)]
pub struct PrCmd {
#[command(subcommand)]
pub command: PrSub,
}
#[derive(Debug, Subcommand)]
pub enum PrSub {
/// List pull requests.
List(ListArgs),
/// View a pull request.
View(ViewArgs),
/// Create a pull request.
Create(CreateArgs),
/// Check out a pull request locally.
Checkout(CheckoutArgs),
/// Merge a pull request.
Merge(MergeArgs),
/// Close a pull request without merging.
Close(NumberOnly),
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum StateFilter {
Open,
Closed,
All,
}
impl From<StateFilter> for State {
fn from(value: StateFilter) -> Self {
match value {
StateFilter::Open => State::Open,
StateFilter::Closed => State::Closed,
StateFilter::All => State::All,
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum MergeStyleArg {
Merge,
Rebase,
RebaseMerge,
Squash,
}
impl From<MergeStyleArg> for MergeStyle {
fn from(value: MergeStyleArg) -> Self {
match value {
MergeStyleArg::Merge => MergeStyle::Merge,
MergeStyleArg::Rebase => MergeStyle::Rebase,
MergeStyleArg::RebaseMerge => MergeStyle::RebaseMerge,
MergeStyleArg::Squash => MergeStyle::Squash,
}
}
}
#[derive(Debug, Args)]
pub struct ListArgs {
#[arg(short = 'R', long = "repo")]
pub repo: String,
#[arg(short = 's', long, value_enum, default_value_t = StateFilter::Open)]
pub state: StateFilter,
#[arg(short = 'L', long, default_value_t = 30)]
pub limit: u32,
#[arg(long, default_value_t = 1)]
pub page: u32,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct ViewArgs {
#[arg(short = 'R', long = "repo")]
pub repo: String,
pub number: u64,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct CreateArgs {
#[arg(short = 'R', long = "repo")]
pub repo: String,
#[arg(short = 't', long)]
pub title: String,
#[arg(short = 'B', long, default_value = "main")]
pub base: String,
/// Head branch. Either a branch name on the same repo, or `owner:branch`.
#[arg(short = 'H', long)]
pub head: String,
/// Body. Use `-` to read from stdin.
#[arg(short = 'b', long)]
pub body: Option<String>,
}
#[derive(Debug, Args)]
pub struct CheckoutArgs {
#[arg(short = 'R', long = "repo")]
pub repo: String,
pub number: u64,
/// Override the local branch name.
#[arg(short = 'b', long)]
pub branch: Option<String>,
/// Git remote to fetch from.
#[arg(long, default_value = "origin")]
pub remote: String,
}
#[derive(Debug, Args)]
pub struct MergeArgs {
#[arg(short = 'R', long = "repo")]
pub repo: String,
pub number: u64,
#[arg(long, value_enum, default_value_t = MergeStyleArg::Merge)]
pub style: MergeStyleArg,
#[arg(long)]
pub title: Option<String>,
#[arg(long)]
pub message: Option<String>,
}
#[derive(Debug, Args)]
pub struct NumberOnly {
#[arg(short = 'R', long = "repo")]
pub repo: String,
pub number: u64,
}
pub async fn run(cmd: PrCmd, host: Option<&str>) -> Result<()> {
let client = Client::connect(host)?;
match cmd.command {
PrSub::List(args) => list(&client, args).await,
PrSub::View(args) => view(&client, args).await,
PrSub::Create(args) => create(&client, args).await,
PrSub::Checkout(args) => checkout(&client, args).await,
PrSub::Merge(args) => merge(&client, args).await,
PrSub::Close(args) => close(&client, args).await,
}
}
async fn list(client: &Client, args: ListArgs) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?;
let page = api::pull::list(
client,
owner,
name,
ListOptions {
state: args.state.into(),
limit: args.limit,
page: args.page,
},
)
.await?;
if args.json {
return output::print_json(&serde_json::to_value(&page.items)?);
}
if page.items.is_empty() {
println!("(no pull requests)");
return Ok(());
}
let rows: Vec<Vec<String>> = page
.items
.iter()
.map(|p| {
vec![
format!("#{}", p.number),
output::state_pill(&p.state, p.merged),
truncate(&p.title, 60),
output::dim(&format!("{}{}", branch_label(&p.head), branch_label(&p.base))),
output::dim(&output::relative_time(p.updated_at)),
]
})
.collect();
print!(
"{}",
output::render_table(&["", "STATE", "TITLE", "BRANCHES", "UPDATED"], &rows)
);
Ok(())
}
async fn view(client: &Client, args: ViewArgs) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?;
let pr = api::pull::get(client, owner, name, args.number).await?;
if args.json {
return output::print_json(&serde_json::to_value(&pr)?);
}
println!(
"{} {} {}",
output::bold(&format!("#{} {}", pr.number, pr.title)),
output::state_pill(&pr.state, pr.merged),
output::dim(&output::relative_time(pr.updated_at)),
);
println!("{}", output::dim(&pr.html_url));
println!();
println!(
"Branches: {} → {}",
branch_label(&pr.head),
branch_label(&pr.base)
);
if pr.draft {
println!("Draft: yes");
}
if !pr.body.is_empty() {
println!();
println!("{}", pr.body);
}
Ok(())
}
async fn create(client: &Client, args: CreateArgs) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?;
let body = match args.body.as_deref() {
Some("-") => {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
Some(buf)
}
other => other.map(|s| s.to_string()),
};
let pr = api::pull::create(
client,
owner,
name,
&CreatePull {
title: &args.title,
head: &args.head,
base: &args.base,
body: body.as_deref(),
},
)
.await?;
println!("✓ Created PR #{}: {}", pr.number, pr.title);
println!("{}", pr.html_url);
Ok(())
}
async fn checkout(client: &Client, args: CheckoutArgs) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?;
let pr = api::pull::get(client, owner, name, args.number).await?;
let branch = args.branch.unwrap_or_else(|| format!("pr-{}", pr.number));
git::fetch_pr(&args.remote, pr.number, &branch)?;
git::checkout(&branch)?;
println!("✓ Checked out #{} into {branch}", pr.number);
Ok(())
}
async fn merge(client: &Client, args: MergeArgs) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?;
api::pull::merge(
client,
owner,
name,
args.number,
args.style.into(),
args.title.as_deref(),
args.message.as_deref(),
)
.await?;
println!("✓ Merged PR #{}", args.number);
Ok(())
}
async fn close(client: &Client, args: NumberOnly) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?;
api::pull::edit(
client,
owner,
name,
args.number,
&EditPull {
state: Some("closed"),
..Default::default()
},
)
.await?;
println!("✓ Closed PR #{}", args.number);
Ok(())
}
/// Render a branch name for display. Forgejo populates `head.ref` with
/// `refs/pull/<n>/head` (the synthetic PR ref) when the source branch is gone
/// or the PR comes from a fork; in that case the real name is in `label`.
/// For same-repo PRs `ref` is `refs/heads/<branch>` and `label` is the bare
/// branch name. Prefer `label` whenever it's non-empty.
fn branch_label(b: &crate::api::pull::Branch) -> String {
if !b.label.is_empty() {
return b.label.clone();
}
b.ref_
.strip_prefix("refs/heads/")
.unwrap_or(&b.ref_)
.to_string()
}
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
}

196
src/cli/repo.rs Normal file
View file

@ -0,0 +1,196 @@
use anyhow::Result;
use clap::{Args, Subcommand};
use crate::api;
use crate::client::Client;
use crate::config::hosts::Hosts;
use crate::git;
use crate::output;
#[derive(Debug, Args)]
pub struct RepoCmd {
#[command(subcommand)]
pub command: RepoSub,
}
#[derive(Debug, Subcommand)]
pub enum RepoSub {
/// List repositories you have access to on the current host.
List(ListArgs),
/// View a repository.
View(ViewArgs),
/// Clone a repository over git.
Clone(CloneArgs),
/// Create a new repository.
Create(CreateArgs),
}
#[derive(Debug, Args)]
pub struct ListArgs {
#[arg(short = 'L', long, default_value_t = 30)]
pub limit: u32,
#[arg(long, default_value_t = 1)]
pub page: u32,
/// Search query (uses `/repos/search`).
#[arg(short, long)]
pub search: Option<String>,
/// Emit raw JSON instead of a table.
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct ViewArgs {
/// `owner/name` slug.
pub repo: String,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct CloneArgs {
/// `owner/name` slug.
pub repo: String,
/// Optional destination directory.
pub dir: Option<String>,
}
#[derive(Debug, Args)]
pub struct CreateArgs {
/// `[owner/]name`. Without an owner, the authenticated user is used.
pub repo: String,
#[arg(short, long)]
pub description: Option<String>,
#[arg(long, default_value_t = false)]
pub private: bool,
#[arg(long, default_value_t = false)]
pub init: bool,
}
pub async fn run(cmd: RepoCmd, host: Option<&str>) -> Result<()> {
let client = Client::connect(host)?;
match cmd.command {
RepoSub::List(args) => list(&client, args).await,
RepoSub::View(args) => view(&client, args).await,
RepoSub::Clone(args) => clone(&client, host, args).await,
RepoSub::Create(args) => create(&client, args).await,
}
}
async fn list(client: &Client, args: ListArgs) -> Result<()> {
let opts = api::repo::ListOptions {
limit: args.limit,
page: args.page,
query: args.search.as_deref(),
};
let page = if args.search.is_some() {
api::repo::search(client, opts).await?
} else {
api::repo::list_for_user(client, opts).await?
};
if args.json {
let v = serde_json::to_value(&page.items)?;
return output::print_json(&v);
}
if page.items.is_empty() {
println!("(no repositories)");
return Ok(());
}
let rows: Vec<Vec<String>> = page
.items
.iter()
.map(|r| {
let visibility = if r.private { "private" } else { "public" };
vec![
r.full_name.clone(),
truncate(&r.description, 60),
visibility.into(),
output::dim(&output::relative_time(r.updated_at)),
]
})
.collect();
print!(
"{}",
output::render_table(&["NAME", "DESCRIPTION", "VISIBILITY", "UPDATED"], &rows)
);
Ok(())
}
async fn view(client: &Client, args: ViewArgs) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?;
let repo = api::repo::get(client, owner, name).await?;
if args.json {
return output::print_json(&serde_json::to_value(&repo)?);
}
let header = output::bold(&repo.full_name);
let vis = if repo.private { "private" } else { "public" };
println!("{header} {}", output::dim(vis));
if !repo.description.is_empty() {
println!("{}", repo.description);
}
println!();
println!("Default branch: {}", repo.default_branch);
println!("Stars: {}", repo.stars_count);
println!("Forks: {}", repo.forks_count);
println!("Open issues: {}", repo.open_issues_count);
println!("Updated: {}", output::relative_time(repo.updated_at));
println!();
println!("URL: {}", repo.html_url);
println!("Clone URL: {}", repo.clone_url);
println!("SSH URL: {}", repo.ssh_url);
Ok(())
}
async fn clone(client: &Client, host_flag: Option<&str>, args: CloneArgs) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?;
let repo = api::repo::get(client, owner, name).await?;
let hosts = Hosts::load()?;
let hostname = hosts.resolve_host(host_flag)?;
let proto = hosts
.hosts
.get(hostname)
.map(|h| h.git_protocol.clone())
.unwrap_or_else(|| "https".into());
let url = if proto == "ssh" {
repo.ssh_url
} else {
repo.clone_url
};
git::clone(&url, args.dir.as_deref())
}
async fn create(client: &Client, args: CreateArgs) -> Result<()> {
let (owner, name) = match args.repo.split_once('/') {
Some((o, n)) => (Some(o.to_string()), n.to_string()),
None => (None, args.repo.clone()),
};
let body = api::repo::CreateRepo {
name: &name,
description: args.description.as_deref(),
private: args.private,
default_branch: None,
auto_init: args.init,
};
let repo = match owner {
Some(o) => {
// Try as org first; if 404, fall through to user-namespaced.
match api::repo::create_for_org(client, &o, &body).await {
Ok(r) => r,
Err(_) => api::repo::create_for_current_user(client, &body).await?,
}
}
None => api::repo::create_for_current_user(client, &body).await?,
};
println!("✓ Created {}", repo.full_name);
println!("{}", repo.html_url);
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
}

13
src/client/error.rs Normal file
View file

@ -0,0 +1,13 @@
use reqwest::StatusCode;
use thiserror::Error;
/// A non-2xx response from the Forgejo API. We surface the parsed `message`
/// when one is available and keep the raw body for `--debug` style output.
#[derive(Debug, Error)]
#[error("HTTP {status} from {url}: {message}")]
pub struct ApiError {
pub status: StatusCode,
pub url: String,
pub message: String,
pub body: String,
}

222
src/client/mod.rs Normal file
View file

@ -0,0 +1,222 @@
pub mod error;
pub mod pagination;
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT};
use reqwest::{Method, Response, StatusCode};
use serde::de::DeserializeOwned;
use serde::Serialize;
use url::Url;
use crate::auth;
use crate::config::hosts::{api_base_path, Host, Hosts};
pub use error::ApiError;
pub use pagination::Page;
/// Convenience handle for talking to a single Forgejo host.
#[derive(Clone)]
pub struct Client {
http: reqwest::Client,
#[allow(dead_code)]
host: String,
base: Url,
token: String,
}
pub struct ResolvedHost {
pub name: String,
pub host: Host,
pub token: String,
}
/// Look up the host config and token for the host the user is targeting.
pub fn resolve(host_flag: Option<&str>) -> Result<ResolvedHost> {
let hosts = Hosts::load()?;
let name = hosts.resolve_host(host_flag)?.to_string();
let host_cfg = hosts
.hosts
.get(&name)
.cloned()
.ok_or_else(|| anyhow!("host '{name}' not configured"))?;
let token = auth::load_token(&name)?.ok_or_else(|| {
anyhow!("no token stored for host '{name}'. Run `fj auth login --host {name}`.")
})?;
Ok(ResolvedHost {
name,
host: host_cfg,
token,
})
}
impl Client {
pub fn new(resolved: ResolvedHost) -> Result<Self> {
let base = build_base_url(&resolved.name, &resolved.host)?;
let http = reqwest::Client::builder()
.user_agent(default_user_agent())
.connect_timeout(Duration::from_secs(15))
.timeout(Duration::from_secs(60))
.build()
.context("building HTTP client")?;
Ok(Self {
http,
host: resolved.name,
base,
token: resolved.token,
})
}
/// Construct a client from an explicit host flag, loading config + token.
pub fn connect(host_flag: Option<&str>) -> Result<Self> {
Self::new(resolve(host_flag)?)
}
#[allow(dead_code)]
pub fn host(&self) -> &str {
&self.host
}
#[allow(dead_code)]
pub fn base(&self) -> &Url {
&self.base
}
fn auth_headers(&self) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("token {}", self.token))
.expect("token contains invalid header chars"),
);
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
headers.insert(
USER_AGENT,
HeaderValue::from_static(default_user_agent_static()),
);
headers
}
/// Build an absolute URL for an API path. Accepts any of: a full URL
/// (`https://…`), an absolute path that already includes the API base
/// (`/api/v1/user`), or a path relative to the API base (`user`, `/user`).
/// All forms anchor at `<host>/api/v1/` unless an explicit URL is given.
pub fn url(&self, path: &str) -> Result<Url> {
if path.starts_with("http://") || path.starts_with("https://") {
return Ok(Url::parse(path)?);
}
let trimmed = path
.strip_prefix("/api/v1/")
.or_else(|| path.strip_prefix("api/v1/"))
.or_else(|| path.strip_prefix('/'))
.unwrap_or(path);
Ok(self.base.join(trimmed)?)
}
pub async fn request(
&self,
method: Method,
path: &str,
query: &[(String, String)],
body: Option<&serde_json::Value>,
) -> Result<Response> {
let url = self.url(path)?;
let mut req = self
.http
.request(method, url)
.headers(self.auth_headers())
.query(query);
if let Some(body) = body {
req = req.json(body);
}
let res = req.send().await.context("sending HTTP request")?;
Ok(res)
}
/// Issue a request and decode a JSON body, mapping non-2xx to a typed
/// `ApiError`.
pub async fn json<T, B>(
&self,
method: Method,
path: &str,
query: &[(String, String)],
body: Option<&B>,
) -> Result<T>
where
T: DeserializeOwned,
B: Serialize + ?Sized,
{
let body_value = match body {
Some(b) => Some(serde_json::to_value(b).context("serializing request body")?),
None => None,
};
let res = self
.request(method, path, query, body_value.as_ref())
.await?;
ensure_success(res).await?.json().await.context("decoding JSON response")
}
/// GET that returns a single page along with pagination metadata.
pub async fn get_page<T: DeserializeOwned>(
&self,
path: &str,
query: &[(String, String)],
) -> Result<Page<T>> {
let res = self.request(Method::GET, path, query, None).await?;
let res = ensure_success(res).await?;
let headers = res.headers().clone();
let items: Vec<T> = res.json().await.context("decoding JSON list response")?;
Ok(Page::from_headers(items, &headers))
}
}
fn build_base_url(hostname: &str, host: &Host) -> Result<Url> {
let scheme = "https";
let mut url = Url::parse(&format!("{scheme}://{hostname}"))
.with_context(|| format!("constructing URL for {hostname}"))?;
let path = api_base_path(host).trim_end_matches('/');
url.set_path(&format!("{path}/"));
Ok(url)
}
async fn ensure_success(res: Response) -> Result<Response> {
let status = res.status();
if status.is_success() {
return Ok(res);
}
let url = res.url().clone();
let text = res.text().await.unwrap_or_default();
let message = parse_error_message(&text).unwrap_or_else(|| text.clone());
let err = ApiError {
status,
url: url.to_string(),
message,
body: text,
};
if status == StatusCode::UNAUTHORIZED {
Err(anyhow::Error::new(err).context("authentication failed (HTTP 401). Token may be invalid or revoked"))
} else {
Err(anyhow::Error::new(err))
}
}
fn parse_error_message(text: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(text).ok()?;
v.get("message")
.and_then(|m| m.as_str())
.map(|s| s.to_string())
.or_else(|| {
v.get("error")
.and_then(|m| m.as_str())
.map(|s| s.to_string())
})
}
fn default_user_agent() -> String {
default_user_agent_static().to_string()
}
const fn default_user_agent_static() -> &'static str {
concat!("fj/", env!("CARGO_PKG_VERSION"))
}

79
src/client/pagination.rs Normal file
View file

@ -0,0 +1,79 @@
use reqwest::header::HeaderMap;
/// A single page of items along with parsed pagination headers. Forgejo
/// follows Gitea's convention: `Link` header in RFC 5988 style plus
/// `X-Total-Count`.
pub struct Page<T> {
pub items: Vec<T>,
#[allow(dead_code)]
pub next: Option<String>,
#[allow(dead_code)]
pub prev: Option<String>,
#[allow(dead_code)]
pub last: Option<String>,
#[allow(dead_code)]
pub first: Option<String>,
#[allow(dead_code)]
pub total: Option<u64>,
}
impl<T> Page<T> {
pub fn from_headers(items: Vec<T>, headers: &HeaderMap) -> Self {
let mut next = None;
let mut prev = None;
let mut last = None;
let mut first = None;
if let Some(link) = headers.get(reqwest::header::LINK) {
if let Ok(link_str) = link.to_str() {
for (url, rel) in parse_link_header(link_str) {
match rel.as_str() {
"next" => next = Some(url),
"prev" => prev = Some(url),
"last" => last = Some(url),
"first" => first = Some(url),
_ => {}
}
}
}
}
let total = headers
.get("x-total-count")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok());
Self {
items,
next,
prev,
last,
first,
total,
}
}
}
fn parse_link_header(value: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
for part in value.split(',') {
let part = part.trim();
// `<url>; rel="name"`
let Some((url_part, params)) = part.split_once(';') else {
continue;
};
let url = url_part.trim().trim_start_matches('<').trim_end_matches('>');
let rel = params
.split(';')
.map(str::trim)
.find_map(|p| {
let (k, v) = p.split_once('=')?;
if k.trim().eq_ignore_ascii_case("rel") {
Some(v.trim().trim_matches('"').to_string())
} else {
None
}
});
if let Some(rel) = rel {
out.push((url.to_string(), rel));
}
}
out
}

111
src/config/hosts.rs Normal file
View file

@ -0,0 +1,111 @@
use std::collections::BTreeMap;
use std::fs;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use super::hosts_path;
/// A single Forgejo host entry. The auth token is intentionally NOT serialized
/// here: tokens live in the OS keychain via the `auth` module.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Host {
/// Username (login) of the authenticated user on this host.
pub user: Option<String>,
/// Protocol used for git operations (`https` or `ssh`).
#[serde(default = "default_git_protocol")]
pub git_protocol: String,
/// Optional API base path. Defaults to `/api/v1` when absent.
pub api_base_path: Option<String>,
}
fn default_git_protocol() -> String {
"https".into()
}
/// Top-level shape of `hosts.toml`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Hosts {
/// Hostname currently selected as the default for commands that don't pass `--host`.
pub current: Option<String>,
/// Map of hostname -> host config.
#[serde(default)]
pub hosts: BTreeMap<String, Host>,
}
impl Hosts {
pub fn load() -> Result<Self> {
let path = hosts_path()?;
if !path.exists() {
return Ok(Self::default());
}
let text = fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
let parsed: Self = toml::from_str(&text)
.with_context(|| format!("parsing {}", path.display()))?;
Ok(parsed)
}
pub fn save(&self) -> Result<()> {
let path = hosts_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
let text = toml::to_string_pretty(self).context("serializing hosts.toml")?;
fs::write(&path, text).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
/// Resolve which hostname to use given an explicit override (e.g. from `--host`).
pub fn resolve_host<'a>(&'a self, explicit: Option<&'a str>) -> Result<&'a str> {
if let Some(h) = explicit {
if !self.hosts.contains_key(h) {
return Err(anyhow!(
"no auth entry for host '{h}'. Run `fj auth login --host {h}` first."
));
}
return Ok(h);
}
if let Some(current) = self.current.as_deref() {
if self.hosts.contains_key(current) {
return Ok(current);
}
}
// Fall back to the only host configured, if there is exactly one.
if self.hosts.len() == 1 {
return Ok(self.hosts.keys().next().unwrap());
}
Err(anyhow!(
"no host selected. Run `fj auth login` first, or pass `--host <hostname>`."
))
}
pub fn upsert(&mut self, hostname: &str, host: Host) {
self.hosts.insert(hostname.to_string(), host);
if self.current.is_none() {
self.current = Some(hostname.to_string());
}
}
pub fn remove(&mut self, hostname: &str) -> Option<Host> {
let removed = self.hosts.remove(hostname);
if self.current.as_deref() == Some(hostname) {
self.current = self.hosts.keys().next().cloned();
}
removed
}
pub fn set_current(&mut self, hostname: &str) -> Result<()> {
if !self.hosts.contains_key(hostname) {
return Err(anyhow!("host '{hostname}' is not configured"));
}
self.current = Some(hostname.to_string());
Ok(())
}
}
/// Returns the `/api/v1` base path for a host, honoring per-host overrides.
pub fn api_base_path(host: &Host) -> &str {
host.api_base_path.as_deref().unwrap_or("/api/v1")
}

17
src/config/mod.rs Normal file
View file

@ -0,0 +1,17 @@
pub mod hosts;
use std::path::PathBuf;
use anyhow::{Context, Result};
use directories::ProjectDirs;
/// Returns the directory where fj stores its configuration files.
pub fn config_dir() -> Result<PathBuf> {
let dirs = ProjectDirs::from("com", "rasterstate", "fj")
.context("could not determine a platform config directory for fj")?;
Ok(dirs.config_dir().to_path_buf())
}
pub fn hosts_path() -> Result<PathBuf> {
Ok(config_dir()?.join("hosts.toml"))
}

32
src/git/mod.rs Normal file
View file

@ -0,0 +1,32 @@
use std::process::Command;
use anyhow::{bail, Context, Result};
/// Spawn `git` synchronously and surface the exit status as an error.
pub fn run(args: &[&str]) -> Result<()> {
let status = Command::new("git")
.args(args)
.status()
.with_context(|| "spawning git (is it installed and on PATH?)")?;
if !status.success() {
bail!("git {} exited with status {}", args.join(" "), status);
}
Ok(())
}
pub fn clone(url: &str, dest: Option<&str>) -> Result<()> {
let mut args = vec!["clone", url];
if let Some(d) = dest {
args.push(d);
}
run(&args)
}
pub fn fetch_pr(remote: &str, number: u64, local_branch: &str) -> Result<()> {
let refspec = format!("pull/{number}/head:{local_branch}");
run(&["fetch", remote, &refspec])
}
pub fn checkout(branch: &str) -> Result<()> {
run(&["checkout", branch])
}

28
src/main.rs Normal file
View file

@ -0,0 +1,28 @@
mod api;
mod auth;
mod cli;
mod client;
mod config;
mod git;
mod output;
use std::process::ExitCode;
use clap::Parser;
#[tokio::main]
async fn main() -> ExitCode {
let cli = cli::Cli::parse();
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:"));
let mut source = err.source();
while let Some(cause) = source {
eprintln!(" caused by: {cause}");
source = cause.source();
}
return ExitCode::from(1);
}
ExitCode::SUCCESS
}

106
src/output/mod.rs Normal file
View file

@ -0,0 +1,106 @@
use std::io::IsTerminal;
use anyhow::Result;
use owo_colors::{OwoColorize, Style};
use tabled::settings::{object::Columns, Alignment, Modify, Padding, Style as TStyle};
use tabled::{builder::Builder, Table};
#[allow(dead_code)]
pub fn stdout_is_tty() -> bool {
std::io::stdout().is_terminal()
}
pub fn supports_color() -> bool {
supports_color::on(supports_color::Stream::Stdout).is_some()
}
/// Pretty-print a list as a borderless gh-style table. Header row is
/// rendered bold when color is supported.
pub fn render_table(headers: &[&str], rows: &[Vec<String>]) -> String {
let mut builder = Builder::default();
let header_row: Vec<String> = if supports_color() {
headers
.iter()
.map(|h| h.style(Style::new().bold()).to_string())
.collect()
} else {
headers.iter().map(|h| h.to_string()).collect()
};
builder.push_record(header_row);
for row in rows {
builder.push_record(row.clone());
}
let mut table: Table = builder.build();
table
.with(TStyle::empty())
.with(Padding::new(0, 2, 0, 0))
.with(Modify::new(Columns::new(0..)).with(Alignment::left()));
table.to_string()
}
/// Print JSON pretty-formatted to stdout.
pub fn print_json(value: &serde_json::Value) -> Result<()> {
let text = serde_json::to_string_pretty(value)?;
println!("{text}");
Ok(())
}
/// gh-style state pill: green for open, magenta for merged, red for closed.
/// Always returns the capitalized label; color is applied only when the
/// terminal supports it. `merged=true` overrides `state` (which is `closed`
/// for merged PRs on the Forgejo API).
pub fn state_pill(state: &str, merged: bool) -> String {
let (label, style) = match (state, merged) {
(_, true) => ("Merged", Style::new().magenta().bold()),
("open", _) => ("Open", Style::new().green().bold()),
("closed", _) => ("Closed", Style::new().red().bold()),
(other, _) => return other.to_string(),
};
if supports_color() {
label.style(style).to_string()
} else {
label.to_string()
}
}
pub fn dim(s: &str) -> String {
if supports_color() {
s.dimmed().to_string()
} else {
s.to_string()
}
}
pub fn bold(s: &str) -> String {
if supports_color() {
s.bold().to_string()
} else {
s.to_string()
}
}
/// Approximate "2h ago" / "3d ago" rendering. Forgejo timestamps are UTC.
pub fn relative_time(t: chrono::DateTime<chrono::Utc>) -> String {
let now = chrono::Utc::now();
let delta = now.signed_duration_since(t);
let secs = delta.num_seconds();
let abs = secs.abs();
let label = if abs < 60 {
format!("{abs}s")
} else if abs < 3600 {
format!("{}m", abs / 60)
} else if abs < 86_400 {
format!("{}h", abs / 3600)
} else if abs < 86_400 * 30 {
format!("{}d", abs / 86_400)
} else if abs < 86_400 * 365 {
format!("{}mo", abs / (86_400 * 30))
} else {
format!("{}y", abs / (86_400 * 365))
};
if secs >= 0 {
format!("{label} ago")
} else {
format!("in {label}")
}
}