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:
commit
495276f654
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
/target
|
||||||
|
**/*.rs.bk
|
||||||
|
Cargo.lock.bak
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
2478
Cargo.lock
generated
Normal file
2478
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
47
Cargo.toml
Normal file
47
Cargo.toml
Normal 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
44
README.md
Normal 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
190
src/api/issue.rs
Normal 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
13
src/api/mod.rs
Normal 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
173
src/api/pull.rs
Normal 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
125
src/api/repo.rs
Normal 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
23
src/api/user.rs
Normal 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
40
src/auth/mod.rs
Normal 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
144
src/cli/api.rs
Normal 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
248
src/cli/auth.rs
Normal 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
274
src/cli/issue.rs
Normal 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
72
src/cli/mod.rs
Normal 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
317
src/cli/pr.rs
Normal 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
196
src/cli/repo.rs
Normal 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
13
src/client/error.rs
Normal 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
222
src/client/mod.rs
Normal 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
79
src/client/pagination.rs
Normal 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
111
src/config/hosts.rs
Normal 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
17
src/config/mod.rs
Normal 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
32
src/git/mod.rs
Normal 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
28
src/main.rs
Normal 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
106
src/output/mod.rs
Normal 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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue