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