batch: release, label, workflow, search, browse, status, org, keys, alias, config, extension, gist
* New top-level groups, each with full CRUD where the API supports it: - release: list/view/create/edit/delete/upload/download - label: list/create/edit/delete - run: workflow runs (list/view/rerun/cancel) - secret + variable: Actions secrets/vars (list/set/delete) - search: cross-cutting (repos/issues/prs/users) - browse: open repo/path on the web - status: notifications inbox + mark-all-read - org: list/view/teams - ssh-key, gpg-key: list/add/delete on your account - alias: user-defined shortcuts (e.g. `fj alias set co "pr checkout"`) - config: local prefs (editor, pager, browser, etc.) - extension: discover and run `fj-<name>` plugin binaries on PATH - gist: thin wrapper over `gist-*` repos * main.rs now expands aliases before clap and dispatches to plugins for unknown subcommands (matching gh). * New API modules: release, label, notification, search, org, workflow, with the corresponding strongly-typed wrappers. * Release asset upload uses reqwest multipart (feature flag added). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
191d941c78
commit
de49c33921
23
Cargo.lock
generated
23
Cargo.lock
generated
|
|
@ -950,6 +950,22 @@ version = "2.8.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
|
|
@ -1240,6 +1256,7 @@ dependencies = [
|
|||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
|
|
@ -1855,6 +1872,12 @@ version = "0.2.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ 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"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream", "gzip", "brotli", "multipart"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
|
|
|
|||
97
src/api/label.rs
Normal file
97
src/api/label.rs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
use anyhow::Result;
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::client::Client;
|
||||
|
||||
pub use super::issue::Label;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CreateLabel<'a> {
|
||||
pub name: &'a str,
|
||||
pub color: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Default)]
|
||||
pub struct EditLabel<'a> {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub async fn list(client: &Client, owner: &str, name: &str) -> Result<Vec<Label>> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/labels");
|
||||
client.json(Method::GET, &path, &[], None::<&()>).await
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
client: &Client,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
body: &CreateLabel<'_>,
|
||||
) -> Result<Label> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/labels");
|
||||
client.json(Method::POST, &path, &[], Some(body)).await
|
||||
}
|
||||
|
||||
pub async fn edit(
|
||||
client: &Client,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
id: u64,
|
||||
body: &EditLabel<'_>,
|
||||
) -> Result<Label> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/labels/{id}");
|
||||
client.json(Method::PATCH, &path, &[], Some(body)).await
|
||||
}
|
||||
|
||||
pub async fn delete(client: &Client, owner: &str, name: &str, id: u64) -> Result<()> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/labels/{id}");
|
||||
let res = client.request(Method::DELETE, &path, &[], None).await?;
|
||||
res.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct AddLabelsBody {
|
||||
pub labels: Vec<u64>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn add_to_issue(
|
||||
client: &Client,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
number: u64,
|
||||
label_ids: Vec<u64>,
|
||||
) -> Result<Vec<Label>> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/issues/{number}/labels");
|
||||
client
|
||||
.json(
|
||||
Method::POST,
|
||||
&path,
|
||||
&[],
|
||||
Some(&AddLabelsBody { labels: label_ids }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn remove_from_issue(
|
||||
client: &Client,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
number: u64,
|
||||
label_id: u64,
|
||||
) -> Result<()> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/issues/{number}/labels/{label_id}");
|
||||
let res = client.request(Method::DELETE, &path, &[], None).await?;
|
||||
res.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,7 +1,13 @@
|
|||
pub mod issue;
|
||||
pub mod label;
|
||||
pub mod notification;
|
||||
pub mod org;
|
||||
pub mod pull;
|
||||
pub mod release;
|
||||
pub mod repo;
|
||||
pub mod search;
|
||||
pub mod user;
|
||||
pub mod workflow;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
|
|
|
|||
52
src/api/notification.rs
Normal file
52
src/api/notification.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::client::Client;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Notification {
|
||||
pub id: u64,
|
||||
pub unread: bool,
|
||||
pub pinned: bool,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub url: String,
|
||||
pub html_url: String,
|
||||
pub subject: Subject,
|
||||
pub repository: Option<NotificationRepo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Subject {
|
||||
pub title: String,
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
pub state: String,
|
||||
#[serde(default)]
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub latest_comment_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotificationRepo {
|
||||
pub full_name: String,
|
||||
pub html_url: String,
|
||||
}
|
||||
|
||||
pub async fn list(client: &Client, all: bool, limit: u32) -> Result<Vec<Notification>> {
|
||||
let path = "/api/v1/notifications";
|
||||
let mut q = vec![("limit".into(), limit.clamp(1, 50).to_string())];
|
||||
if all {
|
||||
q.push(("all".into(), "true".into()));
|
||||
}
|
||||
client.json(Method::GET, path, &q, None::<&()>).await
|
||||
}
|
||||
|
||||
pub async fn mark_all_read(client: &Client) -> Result<()> {
|
||||
let path = "/api/v1/notifications";
|
||||
let res = client.request(Method::PUT, path, &[], None).await?;
|
||||
res.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
44
src/api/org.rs
Normal file
44
src/api/org.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
use anyhow::Result;
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::client::Client;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Organization {
|
||||
pub id: u64,
|
||||
pub username: String,
|
||||
#[serde(default)]
|
||||
pub full_name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub visibility: String,
|
||||
pub avatar_url: String,
|
||||
}
|
||||
|
||||
pub async fn list_for_user(client: &Client) -> Result<Vec<Organization>> {
|
||||
client
|
||||
.json(Method::GET, "/api/v1/user/orgs", &[], None::<&()>)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get(client: &Client, org: &str) -> Result<Organization> {
|
||||
let path = format!("/api/v1/orgs/{org}");
|
||||
client.json(Method::GET, &path, &[], None::<&()>).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Team {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub permission: String,
|
||||
}
|
||||
|
||||
pub async fn list_teams(client: &Client, org: &str) -> Result<Vec<Team>> {
|
||||
let path = format!("/api/v1/orgs/{org}/teams");
|
||||
client.json(Method::GET, &path, &[], None::<&()>).await
|
||||
}
|
||||
163
src/api/release.rs
Normal file
163
src/api/release.rs
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::client::Client;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Release {
|
||||
pub id: u64,
|
||||
pub tag_name: String,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub body: String,
|
||||
pub target_commitish: String,
|
||||
pub draft: bool,
|
||||
pub prerelease: bool,
|
||||
pub html_url: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub published_at: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub assets: Vec<Asset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Asset {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub size: u64,
|
||||
pub browser_download_url: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub async fn list(client: &Client, owner: &str, name: &str, limit: u32) -> Result<Vec<Release>> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/releases");
|
||||
let query = vec![
|
||||
("limit".into(), limit.clamp(1, 50).to_string()),
|
||||
("page".into(), "1".into()),
|
||||
];
|
||||
client.json(Method::GET, &path, &query, None::<&()>).await
|
||||
}
|
||||
|
||||
pub async fn get_by_tag(client: &Client, owner: &str, name: &str, tag: &str) -> Result<Release> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/releases/tags/{tag}");
|
||||
client.json(Method::GET, &path, &[], None::<&()>).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CreateRelease<'a> {
|
||||
pub tag_name: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub target_commitish: Option<&'a str>,
|
||||
#[serde(default)]
|
||||
pub draft: bool,
|
||||
#[serde(default)]
|
||||
pub prerelease: bool,
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
client: &Client,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
body: &CreateRelease<'_>,
|
||||
) -> Result<Release> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/releases");
|
||||
client.json(Method::POST, &path, &[], Some(body)).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Default)]
|
||||
pub struct EditRelease<'a> {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tag_name: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prerelease: Option<bool>,
|
||||
}
|
||||
|
||||
pub async fn edit(
|
||||
client: &Client,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
id: u64,
|
||||
body: &EditRelease<'_>,
|
||||
) -> Result<Release> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/releases/{id}");
|
||||
client.json(Method::PATCH, &path, &[], Some(body)).await
|
||||
}
|
||||
|
||||
pub async fn delete(client: &Client, owner: &str, name: &str, id: u64) -> Result<()> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/releases/{id}");
|
||||
let res = client.request(Method::DELETE, &path, &[], None).await?;
|
||||
res.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Upload an asset (binary) to a release.
|
||||
pub async fn upload_asset(
|
||||
client: &Client,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
release_id: u64,
|
||||
asset_name: &str,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<Asset> {
|
||||
let path = format!(
|
||||
"/api/v1/repos/{owner}/{name}/releases/{release_id}/assets?name={}",
|
||||
urlencode(asset_name)
|
||||
);
|
||||
// Build a multipart form with the file payload.
|
||||
let url = client.url(&path)?;
|
||||
let part = reqwest::multipart::Part::bytes(bytes)
|
||||
.file_name(asset_name.to_string())
|
||||
.mime_str("application/octet-stream")?;
|
||||
let form = reqwest::multipart::Form::new().part("attachment", part);
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
reqwest::header::HeaderValue::from_str(&format!("token {}", client.token()))?,
|
||||
);
|
||||
let res = client
|
||||
.http()
|
||||
.post(url)
|
||||
.headers(headers)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await?;
|
||||
let res = res.error_for_status()?;
|
||||
Ok(res.json().await?)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn delete_asset(client: &Client, owner: &str, name: &str, asset_id: u64) -> Result<()> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/releases/assets/{asset_id}");
|
||||
let res = client.request(Method::DELETE, &path, &[], None).await?;
|
||||
res.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn urlencode(s: &str) -> String {
|
||||
// The asset name appears in a query parameter; URL-encode special chars
|
||||
// conservatively. `url` crate provides a Serializer for full forms.
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '~') {
|
||||
out.push(c);
|
||||
} else {
|
||||
for b in c.to_string().bytes() {
|
||||
out.push_str(&format!("%{b:02X}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
@ -74,7 +74,6 @@ pub async fn search(client: &Client, opts: ListOptions<'_>) -> Result<Page<Searc
|
|||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SearchResponse {
|
||||
#[serde(default)]
|
||||
data: Vec<SearchHit>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
|
|
@ -126,12 +125,7 @@ pub struct EditRepo<'a> {
|
|||
pub archived: Option<bool>,
|
||||
}
|
||||
|
||||
pub async fn edit(
|
||||
client: &Client,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
body: &EditRepo<'_>,
|
||||
) -> Result<Repo> {
|
||||
pub async fn edit(client: &Client, owner: &str, name: &str, body: &EditRepo<'_>) -> Result<Repo> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}");
|
||||
client.json(Method::PATCH, &path, &[], Some(body)).await
|
||||
}
|
||||
|
|
@ -230,11 +224,7 @@ pub struct BranchCommit {
|
|||
pub message: String,
|
||||
}
|
||||
|
||||
pub async fn list_branches(
|
||||
client: &Client,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
) -> Result<Vec<Branch>> {
|
||||
pub async fn list_branches(client: &Client, owner: &str, name: &str) -> Result<Vec<Branch>> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/branches");
|
||||
client.json(Method::GET, &path, &[], None::<&()>).await
|
||||
}
|
||||
|
|
@ -245,22 +235,13 @@ pub struct Topic {
|
|||
pub topics: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn list_topics(
|
||||
client: &Client,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
) -> Result<Vec<String>> {
|
||||
pub async fn list_topics(client: &Client, owner: &str, name: &str) -> Result<Vec<String>> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/topics");
|
||||
let t: Topic = client.json(Method::GET, &path, &[], None::<&()>).await?;
|
||||
Ok(t.topics)
|
||||
}
|
||||
|
||||
pub async fn set_topics(
|
||||
client: &Client,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
topics: &[String],
|
||||
) -> Result<()> {
|
||||
pub async fn set_topics(client: &Client, owner: &str, name: &str, topics: &[String]) -> Result<()> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/topics");
|
||||
let res = client
|
||||
.request(
|
||||
|
|
|
|||
45
src/api/search.rs
Normal file
45
src/api/search.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
use anyhow::Result;
|
||||
use reqwest::Method;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::client::Client;
|
||||
|
||||
use super::issue::Issue;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Envelope<T> {
|
||||
data: Vec<T>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
pub async fn issues(client: &Client, query: &str, type_: &str, limit: u32) -> Result<Vec<Issue>> {
|
||||
let path = "/api/v1/repos/issues/search";
|
||||
let q = vec![
|
||||
("q".into(), query.into()),
|
||||
("type".into(), type_.into()),
|
||||
("limit".into(), limit.clamp(1, 50).to_string()),
|
||||
];
|
||||
client.json(Method::GET, path, &q, None::<&()>).await
|
||||
}
|
||||
|
||||
pub async fn repos(client: &Client, query: &str, limit: u32) -> Result<Vec<super::repo::Repo>> {
|
||||
let path = "/api/v1/repos/search";
|
||||
let q = vec![
|
||||
("q".into(), query.into()),
|
||||
("limit".into(), limit.clamp(1, 50).to_string()),
|
||||
];
|
||||
let body: Envelope<super::repo::Repo> = client.json(Method::GET, path, &q, None::<&()>).await?;
|
||||
Ok(body.data)
|
||||
}
|
||||
|
||||
pub async fn users(client: &Client, query: &str, limit: u32) -> Result<Vec<super::user::User>> {
|
||||
let path = "/api/v1/users/search";
|
||||
let q = vec![
|
||||
("q".into(), query.into()),
|
||||
("limit".into(), limit.clamp(1, 50).to_string()),
|
||||
];
|
||||
let body: Envelope<super::user::User> = client.json(Method::GET, path, &q, None::<&()>).await?;
|
||||
Ok(body.data)
|
||||
}
|
||||
172
src/api/workflow.rs
Normal file
172
src/api/workflow.rs
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
//! Forgejo Actions workflow + run + secret APIs.
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::client::Client;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkflowRun {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub head_branch: String,
|
||||
#[serde(default)]
|
||||
pub head_sha: String,
|
||||
pub status: String,
|
||||
pub conclusion: Option<String>,
|
||||
pub workflow_id: u64,
|
||||
pub html_url: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub run_number: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RunList {
|
||||
#[serde(default)]
|
||||
workflow_runs: Vec<WorkflowRun>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
total_count: u64,
|
||||
}
|
||||
|
||||
pub async fn list_runs(
|
||||
client: &Client,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
limit: u32,
|
||||
) -> Result<Vec<WorkflowRun>> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/actions/runs");
|
||||
let q = vec![("limit".into(), limit.clamp(1, 50).to_string())];
|
||||
let list: RunList = client.json(Method::GET, &path, &q, None::<&()>).await?;
|
||||
Ok(list.workflow_runs)
|
||||
}
|
||||
|
||||
pub async fn get_run(client: &Client, owner: &str, name: &str, run_id: u64) -> Result<WorkflowRun> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/actions/runs/{run_id}");
|
||||
client.json(Method::GET, &path, &[], None::<&()>).await
|
||||
}
|
||||
|
||||
pub async fn rerun(client: &Client, owner: &str, name: &str, run_id: u64) -> Result<()> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/actions/runs/{run_id}/rerun");
|
||||
let res = client.request(Method::POST, &path, &[], None).await?;
|
||||
res.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cancel(client: &Client, owner: &str, name: &str, run_id: u64) -> Result<()> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/actions/runs/{run_id}/cancel");
|
||||
let res = client.request(Method::POST, &path, &[], None).await?;
|
||||
res.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActionSecret {
|
||||
pub name: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub async fn list_secrets(client: &Client, owner: &str, name: &str) -> Result<Vec<ActionSecret>> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/actions/secrets");
|
||||
#[derive(Deserialize)]
|
||||
struct Envelope {
|
||||
#[serde(default)]
|
||||
secrets: Vec<ActionSecret>,
|
||||
}
|
||||
let env: Envelope = client.json(Method::GET, &path, &[], None::<&()>).await?;
|
||||
Ok(env.secrets)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SecretBody<'a> {
|
||||
data: &'a str,
|
||||
}
|
||||
|
||||
pub async fn set_secret(
|
||||
client: &Client,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
secret_name: &str,
|
||||
value: &str,
|
||||
) -> Result<()> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/actions/secrets/{secret_name}");
|
||||
let res = client
|
||||
.request(
|
||||
Method::PUT,
|
||||
&path,
|
||||
&[],
|
||||
Some(&serde_json::to_value(SecretBody { data: value })?),
|
||||
)
|
||||
.await?;
|
||||
res.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_secret(
|
||||
client: &Client,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
secret_name: &str,
|
||||
) -> Result<()> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/actions/secrets/{secret_name}");
|
||||
let res = client.request(Method::DELETE, &path, &[], None).await?;
|
||||
res.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActionVariable {
|
||||
pub name: String,
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
pub async fn list_variables(
|
||||
client: &Client,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
) -> Result<Vec<ActionVariable>> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/actions/variables");
|
||||
#[derive(Deserialize)]
|
||||
struct Envelope {
|
||||
#[serde(default)]
|
||||
variables: Vec<ActionVariable>,
|
||||
}
|
||||
let env: Envelope = client.json(Method::GET, &path, &[], None::<&()>).await?;
|
||||
Ok(env.variables)
|
||||
}
|
||||
|
||||
pub async fn set_variable(
|
||||
client: &Client,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
var_name: &str,
|
||||
value: &str,
|
||||
) -> Result<()> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/actions/variables/{var_name}");
|
||||
let res = client
|
||||
.request(
|
||||
Method::PUT,
|
||||
&path,
|
||||
&[],
|
||||
Some(&serde_json::json!({ "value": value })),
|
||||
)
|
||||
.await?;
|
||||
res.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_variable(
|
||||
client: &Client,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
var_name: &str,
|
||||
) -> Result<()> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/actions/variables/{var_name}");
|
||||
let res = client.request(Method::DELETE, &path, &[], None).await?;
|
||||
res.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
145
src/cli/alias.rs
Normal file
145
src/cli/alias.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
//! `fj alias` — user-defined command shortcuts stored in $XDG_CONFIG_HOME/fj/aliases.toml.
|
||||
//!
|
||||
//! `fj alias set co "pr checkout"` then `fj co 42` (note: top-level shadowing
|
||||
//! of unknown subcommands is implemented in main.rs by checking the alias map
|
||||
//! before clap parsing).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Args, Subcommand};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config;
|
||||
use crate::output;
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct AliasCmd {
|
||||
#[command(subcommand)]
|
||||
pub command: AliasSub,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum AliasSub {
|
||||
/// List configured aliases.
|
||||
List,
|
||||
/// Set an alias: `fj alias set co "pr checkout"`.
|
||||
Set(SetArgs),
|
||||
/// Delete an alias.
|
||||
Delete(DeleteArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct SetArgs {
|
||||
pub name: String,
|
||||
/// Expansion: the rest of the line after the alias name.
|
||||
pub expansion: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct DeleteArgs {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct AliasFile {
|
||||
#[serde(default)]
|
||||
pub aliases: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
pub fn path() -> Result<std::path::PathBuf> {
|
||||
Ok(config::config_dir()?.join("aliases.toml"))
|
||||
}
|
||||
|
||||
pub fn load() -> Result<AliasFile> {
|
||||
let p = path()?;
|
||||
if !p.exists() {
|
||||
return Ok(AliasFile::default());
|
||||
}
|
||||
let text = fs::read_to_string(&p).with_context(|| format!("reading {}", p.display()))?;
|
||||
toml::from_str(&text).with_context(|| format!("parsing {}", p.display()))
|
||||
}
|
||||
|
||||
pub fn save(a: &AliasFile) -> Result<()> {
|
||||
let p = path()?;
|
||||
if let Some(parent) = p.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(&p, toml::to_string_pretty(a)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run(cmd: AliasCmd) -> Result<()> {
|
||||
let mut file = load()?;
|
||||
match cmd.command {
|
||||
AliasSub::List => list(&file),
|
||||
AliasSub::Set(args) => {
|
||||
file.aliases
|
||||
.insert(args.name.clone(), args.expansion.clone());
|
||||
save(&file)?;
|
||||
println!("✓ {} → {}", args.name, args.expansion);
|
||||
Ok(())
|
||||
}
|
||||
AliasSub::Delete(args) => {
|
||||
if file.aliases.remove(&args.name).is_none() {
|
||||
return Err(anyhow::anyhow!("no alias '{}'", args.name));
|
||||
}
|
||||
save(&file)?;
|
||||
println!("✓ deleted {}", args.name);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn list(file: &AliasFile) -> Result<()> {
|
||||
if file.aliases.is_empty() {
|
||||
println!("(no aliases)");
|
||||
return Ok(());
|
||||
}
|
||||
let rows: Vec<Vec<String>> = file
|
||||
.aliases
|
||||
.iter()
|
||||
.map(|(k, v)| vec![k.clone(), v.clone()])
|
||||
.collect();
|
||||
print!("{}", output::render_table(&["ALIAS", "EXPANSION"], &rows));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Expand `argv` if `argv[1]` is a configured alias. Returns a new argv with
|
||||
/// the alias replaced by its tokenized expansion. Falls back to `argv` if no
|
||||
/// alias matches.
|
||||
pub fn expand_argv(argv: Vec<String>) -> Vec<String> {
|
||||
if argv.len() < 2 {
|
||||
return argv;
|
||||
}
|
||||
let Ok(file) = load() else {
|
||||
return argv;
|
||||
};
|
||||
let Some(expansion) = file.aliases.get(&argv[1]) else {
|
||||
return argv;
|
||||
};
|
||||
let mut out = Vec::with_capacity(argv.len() + 4);
|
||||
out.push(argv[0].clone());
|
||||
for tok in expansion.split_whitespace() {
|
||||
out.push(tok.to_string());
|
||||
}
|
||||
out.extend(argv.into_iter().skip(2));
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn expand_no_alias_passthrough() {
|
||||
let argv = vec!["fj".into(), "pr".into(), "list".into()];
|
||||
assert_eq!(expand_argv(argv.clone()), argv);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_empty_argv() {
|
||||
assert_eq!(expand_argv(vec!["fj".into()]), vec!["fj".to_string()]);
|
||||
}
|
||||
}
|
||||
|
|
@ -234,7 +234,10 @@ async fn collect_paginated(
|
|||
// client (which adds auth + headers).
|
||||
let parsed = Url::parse(&next_url).context("parsing next-page URL")?;
|
||||
current_endpoint = parsed.path().to_string();
|
||||
current_query = parsed.query_pairs().map(|(k, v)| (k.into_owned(), v.into_owned())).collect();
|
||||
current_query = parsed
|
||||
.query_pairs()
|
||||
.map(|(k, v)| (k.into_owned(), v.into_owned()))
|
||||
.collect();
|
||||
}
|
||||
Ok(Value::Array(all_items))
|
||||
}
|
||||
|
|
@ -279,11 +282,7 @@ fn apply_stage(value: &Value, stage: &str) -> Result<Value> {
|
|||
let Some(arr) = target.as_array() else {
|
||||
return Err(anyhow!("not an array; can't index with '{raw_str}'"));
|
||||
};
|
||||
let real = if idx < 0 {
|
||||
arr.len() as i64 + idx
|
||||
} else {
|
||||
idx
|
||||
};
|
||||
let real = if idx < 0 { arr.len() as i64 + idx } else { idx };
|
||||
let v = arr
|
||||
.get(real as usize)
|
||||
.ok_or_else(|| anyhow!("index {idx} out of range"))?
|
||||
|
|
|
|||
36
src/cli/browse.rs
Normal file
36
src/cli/browse.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
//! `fj browse [path...]` — open the current repo (or a path within it) on the web.
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Args;
|
||||
|
||||
use crate::api;
|
||||
use crate::cli::context::{resolve_repo, RepoFlag};
|
||||
|
||||
use super::web;
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct BrowseArgs {
|
||||
#[command(flatten)]
|
||||
pub r: RepoFlag,
|
||||
/// Optional sub-path (e.g. `src/main.rs`, `issues`, `pulls/3`).
|
||||
pub path: Option<String>,
|
||||
/// Branch name for source-tree paths.
|
||||
#[arg(long)]
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn run(args: BrowseArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
let repo = api::repo::get(&ctx.client, &ctx.owner, &ctx.name).await?;
|
||||
let url = match args.path.as_deref() {
|
||||
None | Some("") => repo.html_url.clone(),
|
||||
Some("issues") | Some("pulls") | Some("releases") | Some("wiki") | Some("settings") => {
|
||||
format!("{}/{}", repo.html_url, args.path.unwrap())
|
||||
}
|
||||
Some(rest) => {
|
||||
let branch = args.branch.as_deref().unwrap_or(&repo.default_branch);
|
||||
format!("{}/src/branch/{}/{}", repo.html_url, branch, rest)
|
||||
}
|
||||
};
|
||||
web::open(&url)
|
||||
}
|
||||
134
src/cli/config.rs
Normal file
134
src/cli/config.rs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
//! `fj config` — local preferences (editor, pager, browser, default merge style).
|
||||
//!
|
||||
//! Stored in `$XDG_CONFIG_HOME/fj/config.toml`. These keys are advisory: the
|
||||
//! relevant subcommands look them up when their corresponding env var is unset.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use clap::{Args, Subcommand};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config;
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct ConfigFile {
|
||||
#[serde(default)]
|
||||
pub values: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
const KNOWN_KEYS: &[&str] = &[
|
||||
"editor",
|
||||
"pager",
|
||||
"browser",
|
||||
"git_protocol",
|
||||
"default_merge",
|
||||
"no_color",
|
||||
];
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct ConfigCmd {
|
||||
#[command(subcommand)]
|
||||
pub command: ConfigSub,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum ConfigSub {
|
||||
/// Print a key's current value.
|
||||
Get(GetArgs),
|
||||
/// Set a key.
|
||||
Set(SetArgs),
|
||||
/// Show all configured keys.
|
||||
List,
|
||||
/// Print the path of the config file.
|
||||
Path,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct GetArgs {
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct SetArgs {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
pub fn config_path() -> Result<std::path::PathBuf> {
|
||||
Ok(config::config_dir()?.join("config.toml"))
|
||||
}
|
||||
|
||||
pub fn load() -> Result<ConfigFile> {
|
||||
let p = config_path()?;
|
||||
if !p.exists() {
|
||||
return Ok(ConfigFile::default());
|
||||
}
|
||||
let text = fs::read_to_string(&p).with_context(|| format!("reading {}", p.display()))?;
|
||||
toml::from_str(&text).with_context(|| format!("parsing {}", p.display()))
|
||||
}
|
||||
|
||||
pub fn save(c: &ConfigFile) -> Result<()> {
|
||||
let p = config_path()?;
|
||||
if let Some(parent) = p.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(&p, toml::to_string_pretty(c)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a config value, optionally falling back to an env var.
|
||||
#[allow(dead_code)]
|
||||
pub fn lookup(key: &str, env_fallback: Option<&str>) -> Option<String> {
|
||||
if let Some(env) = env_fallback {
|
||||
if let Ok(v) = std::env::var(env) {
|
||||
if !v.is_empty() {
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
load().ok().and_then(|c| c.values.get(key).cloned())
|
||||
}
|
||||
|
||||
pub async fn run(cmd: ConfigCmd) -> Result<()> {
|
||||
match cmd.command {
|
||||
ConfigSub::Get(args) => {
|
||||
let file = load()?;
|
||||
match file.values.get(&args.key) {
|
||||
Some(v) => println!("{v}"),
|
||||
None => return Err(anyhow!("key '{}' is unset", args.key)),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
ConfigSub::Set(args) => {
|
||||
if !KNOWN_KEYS.contains(&args.key.as_str()) {
|
||||
eprintln!(
|
||||
"note: '{}' is not a well-known key (known: {})",
|
||||
args.key,
|
||||
KNOWN_KEYS.join(", ")
|
||||
);
|
||||
}
|
||||
let mut file = load()?;
|
||||
file.values.insert(args.key.clone(), args.value.clone());
|
||||
save(&file)?;
|
||||
println!("✓ {} = {}", args.key, args.value);
|
||||
Ok(())
|
||||
}
|
||||
ConfigSub::List => {
|
||||
let file = load()?;
|
||||
if file.values.is_empty() {
|
||||
println!("(empty)");
|
||||
return Ok(());
|
||||
}
|
||||
for (k, v) in &file.values {
|
||||
println!("{k} = {v}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
ConfigSub::Path => {
|
||||
println!("{}", config_path()?.display());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
82
src/cli/extension.rs
Normal file
82
src/cli/extension.rs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
//! `fj extension` — discover and run user-installed plugins.
|
||||
//!
|
||||
//! Convention matches gh: any executable on $PATH named `fj-<name>` is treated
|
||||
//! as a plugin. `fj extension list` enumerates them. The shell does the rest
|
||||
//! of the work since you can already invoke `fj-foo` directly; this command
|
||||
//! just makes them discoverable from inside `fj`.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Args, Subcommand};
|
||||
|
||||
use crate::output;
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct ExtensionCmd {
|
||||
#[command(subcommand)]
|
||||
pub command: ExtensionSub,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum ExtensionSub {
|
||||
/// List discovered `fj-*` extensions on PATH.
|
||||
List,
|
||||
/// Invoke a discovered extension. Equivalent to running `fj-<name> ...`.
|
||||
Run(RunArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct RunArgs {
|
||||
pub name: String,
|
||||
/// Arguments forwarded verbatim to the plugin binary.
|
||||
pub args: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn run(cmd: ExtensionCmd) -> Result<()> {
|
||||
match cmd.command {
|
||||
ExtensionSub::List => list().await,
|
||||
ExtensionSub::Run(args) => invoke(args).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn list() -> Result<()> {
|
||||
let mut found: BTreeSet<(String, PathBuf)> = BTreeSet::new();
|
||||
let path = env::var_os("PATH").unwrap_or_default();
|
||||
for dir in env::split_paths(&path) {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if let Some(plugin) = name.strip_prefix("fj-") {
|
||||
if !plugin.is_empty() && !plugin.starts_with('.') {
|
||||
found.insert((plugin.to_string(), entry.path()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if found.is_empty() {
|
||||
println!("(no fj-* extensions found on PATH)");
|
||||
return Ok(());
|
||||
}
|
||||
let rows: Vec<Vec<String>> = found
|
||||
.iter()
|
||||
.map(|(n, p)| vec![n.clone(), output::dim(&p.display().to_string())])
|
||||
.collect();
|
||||
print!("{}", output::render_table(&["NAME", "PATH"], &rows));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn invoke(args: RunArgs) -> Result<()> {
|
||||
let prog = format!("fj-{}", args.name);
|
||||
let status = std::process::Command::new(&prog)
|
||||
.args(&args.args)
|
||||
.status()?;
|
||||
if !status.success() {
|
||||
std::process::exit(status.code().unwrap_or(1));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
106
src/cli/gist.rs
Normal file
106
src/cli/gist.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
//! `fj gist` — Forgejo Gists (snippets).
|
||||
//!
|
||||
//! Forgejo doesn't ship a Gist surface separate from regular repos; we expose
|
||||
//! a thin wrapper that lists/views the current user's "fj-gist-*" repos and
|
||||
//! creates new ones. Power users should fall through to `fj api`.
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Args, Subcommand};
|
||||
|
||||
use crate::api;
|
||||
use crate::client::Client;
|
||||
use crate::output;
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct GistCmd {
|
||||
#[command(subcommand)]
|
||||
pub command: GistSub,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum GistSub {
|
||||
/// List your gist-style repos (any repo prefixed `gist-`).
|
||||
List(ListArgs),
|
||||
/// Create a new gist (private repo with a single file).
|
||||
Create(CreateArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct ListArgs {
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct CreateArgs {
|
||||
/// Gist name (used as the repo name, prefixed with `gist-`).
|
||||
pub name: String,
|
||||
/// Optional description.
|
||||
#[arg(short = 'd', long)]
|
||||
pub description: Option<String>,
|
||||
/// Make it private (the default).
|
||||
#[arg(long, default_value_t = true)]
|
||||
pub private: bool,
|
||||
}
|
||||
|
||||
pub async fn run(cmd: GistCmd, host: Option<&str>) -> Result<()> {
|
||||
let client = Client::connect(host)?;
|
||||
match cmd.command {
|
||||
GistSub::List(args) => list(&client, args).await,
|
||||
GistSub::Create(args) => create(&client, args).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(client: &Client, args: ListArgs) -> Result<()> {
|
||||
let page = api::repo::list_for_user(
|
||||
client,
|
||||
api::repo::ListOptions {
|
||||
limit: 50,
|
||||
page: 1,
|
||||
query: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let gists: Vec<_> = page
|
||||
.items
|
||||
.into_iter()
|
||||
.filter(|r| r.name.starts_with("gist-"))
|
||||
.collect();
|
||||
if args.json {
|
||||
return output::print_json(&serde_json::to_value(&gists)?);
|
||||
}
|
||||
if gists.is_empty() {
|
||||
println!("(no gists)");
|
||||
return Ok(());
|
||||
}
|
||||
let rows: Vec<Vec<String>> = gists
|
||||
.iter()
|
||||
.map(|g| {
|
||||
vec![
|
||||
g.full_name.clone(),
|
||||
g.description.clone(),
|
||||
output::dim(&output::relative_time(g.updated_at)),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
print!(
|
||||
"{}",
|
||||
output::render_table(&["NAME", "DESCRIPTION", "UPDATED"], &rows)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create(client: &Client, args: CreateArgs) -> Result<()> {
|
||||
let name = format!("gist-{}", args.name);
|
||||
let body = api::repo::CreateRepo {
|
||||
name: &name,
|
||||
description: args.description.as_deref(),
|
||||
private: args.private,
|
||||
default_branch: None,
|
||||
auto_init: true,
|
||||
};
|
||||
let repo = api::repo::create_for_current_user(client, &body).await?;
|
||||
println!("✓ Created gist {}", repo.full_name);
|
||||
println!("{}", repo.html_url);
|
||||
Ok(())
|
||||
}
|
||||
262
src/cli/key.rs
Normal file
262
src/cli/key.rs
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
use anyhow::Result;
|
||||
use clap::{Args, Subcommand};
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::output;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PublicKey {
|
||||
pub id: u64,
|
||||
pub key: String,
|
||||
pub title: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
#[serde(default)]
|
||||
pub read_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GpgKey {
|
||||
pub id: u64,
|
||||
pub key_id: String,
|
||||
pub public_key: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
#[serde(default)]
|
||||
pub primary_key_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct SshKeyCmd {
|
||||
#[command(subcommand)]
|
||||
pub command: SshKeySub,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum SshKeySub {
|
||||
List(ListArgs),
|
||||
Add(AddArgs),
|
||||
Delete(DeleteArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct GpgKeyCmd {
|
||||
#[command(subcommand)]
|
||||
pub command: GpgKeySub,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum GpgKeySub {
|
||||
List(ListArgs),
|
||||
Add(GpgAddArgs),
|
||||
Delete(DeleteArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct ListArgs {
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct AddArgs {
|
||||
pub title: String,
|
||||
/// SSH public key. Pass `--from-file ~/.ssh/id_ed25519.pub` instead for files.
|
||||
#[arg(long)]
|
||||
pub key: Option<String>,
|
||||
#[arg(long = "from-file")]
|
||||
pub from_file: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct GpgAddArgs {
|
||||
/// Armored public GPG key. Use `--from-file -` for stdin.
|
||||
#[arg(long)]
|
||||
pub key: Option<String>,
|
||||
#[arg(long = "from-file")]
|
||||
pub from_file: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct DeleteArgs {
|
||||
pub id: u64,
|
||||
#[arg(short = 'y', long)]
|
||||
pub yes: bool,
|
||||
}
|
||||
|
||||
pub async fn run_ssh(cmd: SshKeyCmd, host: Option<&str>) -> Result<()> {
|
||||
let client = Client::connect(host)?;
|
||||
match cmd.command {
|
||||
SshKeySub::List(args) => list_ssh(&client, args).await,
|
||||
SshKeySub::Add(args) => add_ssh(&client, args).await,
|
||||
SshKeySub::Delete(args) => delete_ssh(&client, args).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_ssh(client: &Client, args: ListArgs) -> Result<()> {
|
||||
let keys: Vec<PublicKey> = client
|
||||
.json(Method::GET, "/api/v1/user/keys", &[], None::<&()>)
|
||||
.await?;
|
||||
if args.json {
|
||||
return output::print_json(&serde_json::to_value(&keys)?);
|
||||
}
|
||||
if keys.is_empty() {
|
||||
println!("(no SSH keys)");
|
||||
return Ok(());
|
||||
}
|
||||
let rows: Vec<Vec<String>> = keys
|
||||
.iter()
|
||||
.map(|k| {
|
||||
vec![
|
||||
k.id.to_string(),
|
||||
k.title.clone(),
|
||||
output::dim(&fingerprint_preview(&k.key)),
|
||||
output::dim(&output::relative_time(k.created_at)),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
print!(
|
||||
"{}",
|
||||
output::render_table(&["ID", "TITLE", "KEY", "CREATED"], &rows)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_ssh(client: &Client, args: AddArgs) -> Result<()> {
|
||||
let key = read_key(args.key.as_deref(), args.from_file.as_deref())?;
|
||||
#[derive(Serialize)]
|
||||
struct Body<'a> {
|
||||
title: &'a str,
|
||||
key: &'a str,
|
||||
}
|
||||
let body = Body {
|
||||
title: &args.title,
|
||||
key: &key,
|
||||
};
|
||||
let k: PublicKey = client
|
||||
.json(Method::POST, "/api/v1/user/keys", &[], Some(&body))
|
||||
.await?;
|
||||
println!("✓ Added SSH key '{}' (id {})", k.title, k.id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_ssh(client: &Client, args: DeleteArgs) -> Result<()> {
|
||||
if !args.yes {
|
||||
let ans = crate::cli::editor::prompt_line(&format!(
|
||||
"Delete SSH key {}? Type '{}' to confirm",
|
||||
args.id, args.id
|
||||
))?;
|
||||
if ans != args.id.to_string() {
|
||||
return Err(anyhow::anyhow!("aborted"));
|
||||
}
|
||||
}
|
||||
let path = format!("/api/v1/user/keys/{}", args.id);
|
||||
client
|
||||
.request(Method::DELETE, &path, &[], None)
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
println!("✓ Deleted SSH key {}", args.id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run_gpg(cmd: GpgKeyCmd, host: Option<&str>) -> Result<()> {
|
||||
let client = Client::connect(host)?;
|
||||
match cmd.command {
|
||||
GpgKeySub::List(args) => list_gpg(&client, args).await,
|
||||
GpgKeySub::Add(args) => add_gpg(&client, args).await,
|
||||
GpgKeySub::Delete(args) => delete_gpg(&client, args).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_gpg(client: &Client, args: ListArgs) -> Result<()> {
|
||||
let keys: Vec<GpgKey> = client
|
||||
.json(Method::GET, "/api/v1/user/gpg_keys", &[], None::<&()>)
|
||||
.await?;
|
||||
if args.json {
|
||||
return output::print_json(&serde_json::to_value(&keys)?);
|
||||
}
|
||||
if keys.is_empty() {
|
||||
println!("(no GPG keys)");
|
||||
return Ok(());
|
||||
}
|
||||
let rows: Vec<Vec<String>> = keys
|
||||
.iter()
|
||||
.map(|k| {
|
||||
vec![
|
||||
k.id.to_string(),
|
||||
k.key_id.clone(),
|
||||
output::dim(&output::relative_time(k.created_at)),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
print!(
|
||||
"{}",
|
||||
output::render_table(&["ID", "KEY ID", "CREATED"], &rows)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_gpg(client: &Client, args: GpgAddArgs) -> Result<()> {
|
||||
let key = read_key(args.key.as_deref(), args.from_file.as_deref())?;
|
||||
let k: GpgKey = client
|
||||
.json(
|
||||
Method::POST,
|
||||
"/api/v1/user/gpg_keys",
|
||||
&[],
|
||||
Some(&serde_json::json!({ "armored_public_key": key })),
|
||||
)
|
||||
.await?;
|
||||
println!("✓ Added GPG key {} (id {})", k.key_id, k.id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_gpg(client: &Client, args: DeleteArgs) -> Result<()> {
|
||||
if !args.yes {
|
||||
let ans = crate::cli::editor::prompt_line(&format!(
|
||||
"Delete GPG key {}? Type '{}' to confirm",
|
||||
args.id, args.id
|
||||
))?;
|
||||
if ans != args.id.to_string() {
|
||||
return Err(anyhow::anyhow!("aborted"));
|
||||
}
|
||||
}
|
||||
let path = format!("/api/v1/user/gpg_keys/{}", args.id);
|
||||
client
|
||||
.request(Method::DELETE, &path, &[], None)
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
println!("✓ Deleted GPG key {}", args.id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_key(value: Option<&str>, from_file: Option<&str>) -> Result<String> {
|
||||
if let Some(v) = value {
|
||||
return Ok(v.trim().to_string());
|
||||
}
|
||||
if let Some(path) = from_file {
|
||||
if path == "-" {
|
||||
use std::io::Read;
|
||||
let mut buf = String::new();
|
||||
std::io::stdin().read_to_string(&mut buf)?;
|
||||
return Ok(buf.trim().to_string());
|
||||
}
|
||||
return Ok(std::fs::read_to_string(path)?.trim().to_string());
|
||||
}
|
||||
Err(anyhow::anyhow!("pass --key or --from-file"))
|
||||
}
|
||||
|
||||
/// Compact preview of an SSH public key: `ssh-ed25519 …abc12`
|
||||
fn fingerprint_preview(key: &str) -> String {
|
||||
let mut parts = key.split_whitespace();
|
||||
let kind = parts.next().unwrap_or("");
|
||||
let body = parts.next().unwrap_or("");
|
||||
let tail = body
|
||||
.chars()
|
||||
.rev()
|
||||
.take(8)
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect::<String>();
|
||||
format!("{kind} …{tail}")
|
||||
}
|
||||
175
src/cli/label.rs
Normal file
175
src/cli/label.rs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
use anyhow::Result;
|
||||
use clap::{Args, Subcommand};
|
||||
|
||||
use crate::api::label::{self, CreateLabel, EditLabel};
|
||||
use crate::cli::context::{resolve_repo, RepoFlag};
|
||||
use crate::output;
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct LabelCmd {
|
||||
#[command(subcommand)]
|
||||
pub command: LabelSub,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum LabelSub {
|
||||
List(ListArgs),
|
||||
Create(CreateArgs),
|
||||
Edit(EditArgs),
|
||||
Delete(DeleteArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct ListArgs {
|
||||
#[command(flatten)]
|
||||
pub r: RepoFlag,
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct CreateArgs {
|
||||
#[command(flatten)]
|
||||
pub r: RepoFlag,
|
||||
pub name: String,
|
||||
/// Color as 6-char hex (with or without `#`).
|
||||
#[arg(long, default_value = "ededed")]
|
||||
pub color: String,
|
||||
#[arg(long)]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct EditArgs {
|
||||
#[command(flatten)]
|
||||
pub r: RepoFlag,
|
||||
/// Label name to edit.
|
||||
pub name: String,
|
||||
/// New name.
|
||||
#[arg(long)]
|
||||
pub rename: Option<String>,
|
||||
#[arg(long)]
|
||||
pub color: Option<String>,
|
||||
#[arg(long)]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct DeleteArgs {
|
||||
#[command(flatten)]
|
||||
pub r: RepoFlag,
|
||||
pub name: String,
|
||||
#[arg(short = 'y', long)]
|
||||
pub yes: bool,
|
||||
}
|
||||
|
||||
pub async fn run(cmd: LabelCmd, host: Option<&str>) -> Result<()> {
|
||||
match cmd.command {
|
||||
LabelSub::List(args) => list(args, host).await,
|
||||
LabelSub::Create(args) => create(args, host).await,
|
||||
LabelSub::Edit(args) => edit(args, host).await,
|
||||
LabelSub::Delete(args) => delete(args, host).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(args: ListArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
let items = label::list(&ctx.client, &ctx.owner, &ctx.name).await?;
|
||||
if args.json {
|
||||
return output::print_json(&serde_json::to_value(&items)?);
|
||||
}
|
||||
if items.is_empty() {
|
||||
println!("(no labels)");
|
||||
return Ok(());
|
||||
}
|
||||
let rows: Vec<Vec<String>> = items
|
||||
.iter()
|
||||
.map(|l| {
|
||||
vec![
|
||||
l.name.clone(),
|
||||
format!("#{}", l.color),
|
||||
truncate(&l.description, 60),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
print!(
|
||||
"{}",
|
||||
output::render_table(&["NAME", "COLOR", "DESCRIPTION"], &rows)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create(args: CreateArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
let color = args.color.trim_start_matches('#').to_string();
|
||||
let l = label::create(
|
||||
&ctx.client,
|
||||
&ctx.owner,
|
||||
&ctx.name,
|
||||
&CreateLabel {
|
||||
name: &args.name,
|
||||
color: &color,
|
||||
description: args.description.as_deref(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
println!("✓ Created label '{}' (#{})", l.name, l.color);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn edit(args: EditArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
let existing = label::list(&ctx.client, &ctx.owner, &ctx.name)
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|l| l.name == args.name)
|
||||
.ok_or_else(|| anyhow::anyhow!("no label named '{}'", args.name))?;
|
||||
let color = args
|
||||
.color
|
||||
.as_ref()
|
||||
.map(|c| c.trim_start_matches('#').to_string());
|
||||
let l = label::edit(
|
||||
&ctx.client,
|
||||
&ctx.owner,
|
||||
&ctx.name,
|
||||
existing.id,
|
||||
&EditLabel {
|
||||
name: args.rename.as_deref(),
|
||||
color: color.as_deref(),
|
||||
description: args.description.as_deref(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
println!("✓ Updated label '{}'", l.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(args: DeleteArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
let existing = label::list(&ctx.client, &ctx.owner, &ctx.name)
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|l| l.name == args.name)
|
||||
.ok_or_else(|| anyhow::anyhow!("no label named '{}'", args.name))?;
|
||||
if !args.yes {
|
||||
let answer = crate::cli::editor::prompt_line(&format!(
|
||||
"Delete label '{}'? Type the name to confirm",
|
||||
args.name
|
||||
))?;
|
||||
if answer != args.name {
|
||||
return Err(anyhow::anyhow!("aborted"));
|
||||
}
|
||||
}
|
||||
label::delete(&ctx.client, &ctx.owner, &ctx.name, existing.id).await?;
|
||||
println!("✓ Deleted label '{}'", args.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
|
||||
out.push('…');
|
||||
out
|
||||
}
|
||||
|
|
@ -1,11 +1,23 @@
|
|||
pub mod alias;
|
||||
pub mod api;
|
||||
pub mod auth;
|
||||
pub mod browse;
|
||||
pub mod config;
|
||||
pub mod context;
|
||||
pub mod editor;
|
||||
pub mod extension;
|
||||
pub mod gist;
|
||||
pub mod issue;
|
||||
pub mod key;
|
||||
pub mod label;
|
||||
pub mod org;
|
||||
pub mod pr;
|
||||
pub mod release;
|
||||
pub mod repo;
|
||||
pub mod search;
|
||||
pub mod status;
|
||||
pub mod web;
|
||||
pub mod workflow;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
|
|
@ -40,6 +52,36 @@ pub enum Command {
|
|||
Issue(issue::IssueCmd),
|
||||
/// Work with pull requests.
|
||||
Pr(pr::PrCmd),
|
||||
/// Work with releases.
|
||||
Release(release::ReleaseCmd),
|
||||
/// Work with labels.
|
||||
Label(label::LabelCmd),
|
||||
/// Manage workflow runs (Forgejo Actions).
|
||||
Run(workflow::RunCmd),
|
||||
/// Manage Actions secrets.
|
||||
Secret(workflow::SecretCmd),
|
||||
/// Manage Actions variables.
|
||||
Variable(workflow::VariableCmd),
|
||||
/// Cross-cutting search (repos, issues, prs, users).
|
||||
Search(search::SearchCmd),
|
||||
/// Open the current repo (or a path within it) in your browser.
|
||||
Browse(browse::BrowseArgs),
|
||||
/// Notifications inbox.
|
||||
Status(status::StatusArgs),
|
||||
/// Organization helpers.
|
||||
Org(org::OrgCmd),
|
||||
/// Manage SSH keys on your account.
|
||||
SshKey(key::SshKeyCmd),
|
||||
/// Manage GPG keys on your account.
|
||||
GpgKey(key::GpgKeyCmd),
|
||||
/// User-defined command aliases.
|
||||
Alias(alias::AliasCmd),
|
||||
/// Manage local fj configuration (editor, pager, browser, etc.).
|
||||
Config(config::ConfigCmd),
|
||||
/// Run a discovered `fj-<name>` plugin from PATH.
|
||||
Extension(extension::ExtensionCmd),
|
||||
/// Forgejo gists.
|
||||
Gist(gist::GistCmd),
|
||||
/// Make a raw API request (like `gh api`).
|
||||
Api(api::ApiArgs),
|
||||
/// Print shell completions to stdout.
|
||||
|
|
@ -54,11 +96,30 @@ pub struct CompletionArgs {
|
|||
}
|
||||
|
||||
pub async fn run(cli: Cli) -> Result<()> {
|
||||
// Alias resolution: if the first positional looks like a user-defined
|
||||
// alias and the user typed `fj <alias>`, expand it before dispatching.
|
||||
// clap already parsed; aliases are handled inside the dedicated `alias`
|
||||
// subcommand or by re-execing ourselves. See cli::alias::dispatch.
|
||||
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::Release(cmd) => release::run(cmd, cli.host.as_deref()).await,
|
||||
Command::Label(cmd) => label::run(cmd, cli.host.as_deref()).await,
|
||||
Command::Run(cmd) => workflow::run(cmd, cli.host.as_deref()).await,
|
||||
Command::Secret(cmd) => workflow::run_secret(cmd, cli.host.as_deref()).await,
|
||||
Command::Variable(cmd) => workflow::run_variable(cmd, cli.host.as_deref()).await,
|
||||
Command::Search(cmd) => search::run(cmd, cli.host.as_deref()).await,
|
||||
Command::Browse(args) => browse::run(args, cli.host.as_deref()).await,
|
||||
Command::Status(args) => status::run(args, cli.host.as_deref()).await,
|
||||
Command::Org(cmd) => org::run(cmd, cli.host.as_deref()).await,
|
||||
Command::SshKey(cmd) => key::run_ssh(cmd, cli.host.as_deref()).await,
|
||||
Command::GpgKey(cmd) => key::run_gpg(cmd, cli.host.as_deref()).await,
|
||||
Command::Alias(cmd) => alias::run(cmd).await,
|
||||
Command::Config(cmd) => config::run(cmd).await,
|
||||
Command::Extension(cmd) => extension::run(cmd).await,
|
||||
Command::Gist(cmd) => gist::run(cmd, cli.host.as_deref()).await,
|
||||
Command::Api(args) => api::run(args, cli.host.as_deref()).await,
|
||||
Command::Completion(args) => {
|
||||
use clap::CommandFactory;
|
||||
|
|
|
|||
115
src/cli/org.rs
Normal file
115
src/cli/org.rs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
use anyhow::Result;
|
||||
use clap::{Args, Subcommand};
|
||||
|
||||
use crate::api;
|
||||
use crate::client::Client;
|
||||
use crate::output;
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct OrgCmd {
|
||||
#[command(subcommand)]
|
||||
pub command: OrgSub,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum OrgSub {
|
||||
/// List orgs you belong to.
|
||||
List(ListArgs),
|
||||
/// Show details about a specific org.
|
||||
View(ViewArgs),
|
||||
/// List teams in an org.
|
||||
Teams(TeamsArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct ListArgs {
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct ViewArgs {
|
||||
pub org: String,
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct TeamsArgs {
|
||||
pub org: String,
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
pub async fn run(cmd: OrgCmd, host: Option<&str>) -> Result<()> {
|
||||
let client = Client::connect(host)?;
|
||||
match cmd.command {
|
||||
OrgSub::List(args) => list(&client, args).await,
|
||||
OrgSub::View(args) => view(&client, args).await,
|
||||
OrgSub::Teams(args) => teams(&client, args).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(client: &Client, args: ListArgs) -> Result<()> {
|
||||
let items = api::org::list_for_user(client).await?;
|
||||
if args.json {
|
||||
return output::print_json(&serde_json::to_value(&items)?);
|
||||
}
|
||||
if items.is_empty() {
|
||||
println!("(no orgs)");
|
||||
return Ok(());
|
||||
}
|
||||
let rows: Vec<Vec<String>> = items
|
||||
.iter()
|
||||
.map(|o| {
|
||||
vec![
|
||||
o.username.clone(),
|
||||
o.full_name.clone(),
|
||||
o.visibility.clone(),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
print!(
|
||||
"{}",
|
||||
output::render_table(&["LOGIN", "NAME", "VISIBILITY"], &rows)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn view(client: &Client, args: ViewArgs) -> Result<()> {
|
||||
let o = api::org::get(client, &args.org).await?;
|
||||
if args.json {
|
||||
return output::print_json(&serde_json::to_value(&o)?);
|
||||
}
|
||||
println!("{}", output::bold(&o.username));
|
||||
if !o.full_name.is_empty() {
|
||||
println!("{}", o.full_name);
|
||||
}
|
||||
if !o.description.is_empty() {
|
||||
println!();
|
||||
println!("{}", o.description);
|
||||
}
|
||||
println!();
|
||||
println!("Visibility: {}", o.visibility);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn teams(client: &Client, args: TeamsArgs) -> Result<()> {
|
||||
let teams = api::org::list_teams(client, &args.org).await?;
|
||||
if args.json {
|
||||
return output::print_json(&serde_json::to_value(&teams)?);
|
||||
}
|
||||
if teams.is_empty() {
|
||||
println!("(no teams)");
|
||||
return Ok(());
|
||||
}
|
||||
let rows: Vec<Vec<String>> = teams
|
||||
.iter()
|
||||
.map(|t| vec![t.name.clone(), t.permission.clone(), t.description.clone()])
|
||||
.collect();
|
||||
print!(
|
||||
"{}",
|
||||
output::render_table(&["TEAM", "PERMISSION", "DESCRIPTION"], &rows)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
390
src/cli/release.rs
Normal file
390
src/cli/release.rs
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
use anyhow::Result;
|
||||
use clap::{Args, Subcommand};
|
||||
|
||||
use crate::api;
|
||||
use crate::api::release::{CreateRelease, EditRelease};
|
||||
use crate::cli::context::{resolve_repo, RepoFlag};
|
||||
use crate::output;
|
||||
|
||||
use super::editor;
|
||||
use super::web;
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct ReleaseCmd {
|
||||
#[command(subcommand)]
|
||||
pub command: ReleaseSub,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum ReleaseSub {
|
||||
/// List releases on a repository.
|
||||
List(ListArgs),
|
||||
/// View a release by tag.
|
||||
View(ViewArgs),
|
||||
/// Create a release.
|
||||
Create(CreateArgs),
|
||||
/// Edit a release.
|
||||
Edit(EditArgs),
|
||||
/// Delete a release.
|
||||
Delete(DeleteArgs),
|
||||
/// Upload an asset file to a release.
|
||||
Upload(UploadArgs),
|
||||
/// Download a release asset by name.
|
||||
Download(DownloadArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct ListArgs {
|
||||
#[command(flatten)]
|
||||
pub r: RepoFlag,
|
||||
#[arg(short = 'L', long, default_value_t = 30)]
|
||||
pub limit: u32,
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
#[arg(long)]
|
||||
pub web: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct ViewArgs {
|
||||
#[command(flatten)]
|
||||
pub r: RepoFlag,
|
||||
pub tag: String,
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
#[arg(long)]
|
||||
pub web: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct CreateArgs {
|
||||
#[command(flatten)]
|
||||
pub r: RepoFlag,
|
||||
pub tag: String,
|
||||
#[arg(short = 't', long)]
|
||||
pub title: Option<String>,
|
||||
#[arg(short = 'b', long)]
|
||||
pub body: Option<String>,
|
||||
/// Target commitish (branch or SHA). Defaults to default branch.
|
||||
#[arg(long)]
|
||||
pub target: Option<String>,
|
||||
#[arg(long)]
|
||||
pub draft: bool,
|
||||
#[arg(long)]
|
||||
pub prerelease: bool,
|
||||
/// Files to upload as assets (repeatable).
|
||||
#[arg(long = "asset", value_name = "FILE")]
|
||||
pub assets: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct EditArgs {
|
||||
#[command(flatten)]
|
||||
pub r: RepoFlag,
|
||||
pub tag: String,
|
||||
#[arg(short = 't', long)]
|
||||
pub title: Option<String>,
|
||||
#[arg(short = 'b', long)]
|
||||
pub body: Option<String>,
|
||||
#[arg(long)]
|
||||
pub draft: Option<bool>,
|
||||
#[arg(long)]
|
||||
pub prerelease: Option<bool>,
|
||||
#[arg(long)]
|
||||
pub body_editor: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct DeleteArgs {
|
||||
#[command(flatten)]
|
||||
pub r: RepoFlag,
|
||||
pub tag: String,
|
||||
#[arg(short = 'y', long)]
|
||||
pub yes: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct UploadArgs {
|
||||
#[command(flatten)]
|
||||
pub r: RepoFlag,
|
||||
pub tag: String,
|
||||
/// File paths to upload.
|
||||
pub files: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct DownloadArgs {
|
||||
#[command(flatten)]
|
||||
pub r: RepoFlag,
|
||||
pub tag: String,
|
||||
/// Asset name. If omitted, all assets are downloaded.
|
||||
#[arg(short = 'n', long)]
|
||||
pub name: Option<String>,
|
||||
/// Output directory (default: cwd).
|
||||
#[arg(short = 'o', long)]
|
||||
pub output_dir: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn run(cmd: ReleaseCmd, host: Option<&str>) -> Result<()> {
|
||||
match cmd.command {
|
||||
ReleaseSub::List(args) => list(args, host).await,
|
||||
ReleaseSub::View(args) => view(args, host).await,
|
||||
ReleaseSub::Create(args) => create(args, host).await,
|
||||
ReleaseSub::Edit(args) => edit(args, host).await,
|
||||
ReleaseSub::Delete(args) => delete(args, host).await,
|
||||
ReleaseSub::Upload(args) => upload(args, host).await,
|
||||
ReleaseSub::Download(args) => download(args, host).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(args: ListArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
if args.web {
|
||||
return web::open(&format!(
|
||||
"https://{}/{}/{}/releases",
|
||||
ctx.host, ctx.owner, ctx.name
|
||||
));
|
||||
}
|
||||
let items = api::release::list(&ctx.client, &ctx.owner, &ctx.name, args.limit).await?;
|
||||
if args.json {
|
||||
return output::print_json(&serde_json::to_value(&items)?);
|
||||
}
|
||||
if items.is_empty() {
|
||||
println!("(no releases)");
|
||||
return Ok(());
|
||||
}
|
||||
let rows: Vec<Vec<String>> = items
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let when = r
|
||||
.published_at
|
||||
.map(output::relative_time)
|
||||
.unwrap_or_else(|| "draft".into());
|
||||
let kind = if r.prerelease {
|
||||
"pre-release"
|
||||
} else if r.draft {
|
||||
"draft"
|
||||
} else {
|
||||
"release"
|
||||
};
|
||||
vec![
|
||||
r.tag_name.clone(),
|
||||
truncate(&r.name, 60),
|
||||
output::dim(kind),
|
||||
output::dim(&when),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
print!(
|
||||
"{}",
|
||||
output::render_table(&["TAG", "TITLE", "KIND", "PUBLISHED"], &rows)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn view(args: ViewArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
let r = api::release::get_by_tag(&ctx.client, &ctx.owner, &ctx.name, &args.tag).await?;
|
||||
if args.web {
|
||||
return web::open(&r.html_url);
|
||||
}
|
||||
if args.json {
|
||||
return output::print_json(&serde_json::to_value(&r)?);
|
||||
}
|
||||
println!(
|
||||
"{} {}",
|
||||
output::bold(&format!("{} {}", r.tag_name, r.name)),
|
||||
output::dim(&r.html_url),
|
||||
);
|
||||
if r.prerelease {
|
||||
println!("(prerelease)");
|
||||
}
|
||||
if r.draft {
|
||||
println!("(draft)");
|
||||
}
|
||||
println!();
|
||||
println!("Target: {}", r.target_commitish);
|
||||
let when = r
|
||||
.published_at
|
||||
.map(output::relative_time)
|
||||
.unwrap_or_else(|| "—".into());
|
||||
println!("Published: {when}");
|
||||
println!();
|
||||
if !r.body.is_empty() {
|
||||
println!("{}", r.body);
|
||||
println!();
|
||||
}
|
||||
if !r.assets.is_empty() {
|
||||
println!("{}", output::bold("Assets:"));
|
||||
for a in &r.assets {
|
||||
println!(
|
||||
" • {} {} {}",
|
||||
a.name,
|
||||
output::dim(&human_size(a.size)),
|
||||
output::dim(&a.browser_download_url)
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create(args: CreateArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
let body = editor::resolve_body(args.body.as_deref(), "RELEASE_BODY.md", "")?;
|
||||
let payload = CreateRelease {
|
||||
tag_name: &args.tag,
|
||||
name: args.title.as_deref(),
|
||||
body: body.as_deref(),
|
||||
target_commitish: args.target.as_deref(),
|
||||
draft: args.draft,
|
||||
prerelease: args.prerelease,
|
||||
};
|
||||
let release = api::release::create(&ctx.client, &ctx.owner, &ctx.name, &payload).await?;
|
||||
println!("✓ Created release {}", release.tag_name);
|
||||
println!("{}", release.html_url);
|
||||
|
||||
for path in &args.assets {
|
||||
upload_one(&ctx, release.id, path).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn edit(args: EditArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
let existing = api::release::get_by_tag(&ctx.client, &ctx.owner, &ctx.name, &args.tag).await?;
|
||||
let body = if args.body_editor {
|
||||
Some(editor::edit_text("RELEASE_BODY.md", &existing.body)?)
|
||||
} else {
|
||||
args.body
|
||||
};
|
||||
let payload = EditRelease {
|
||||
tag_name: None,
|
||||
name: args.title.as_deref(),
|
||||
body: body.as_deref(),
|
||||
draft: args.draft,
|
||||
prerelease: args.prerelease,
|
||||
};
|
||||
let r = api::release::edit(&ctx.client, &ctx.owner, &ctx.name, existing.id, &payload).await?;
|
||||
println!("✓ Updated release {}", r.tag_name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(args: DeleteArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
let existing = api::release::get_by_tag(&ctx.client, &ctx.owner, &ctx.name, &args.tag).await?;
|
||||
if !args.yes {
|
||||
let answer =
|
||||
editor::prompt_line(&format!("Delete release {}? Type tag to confirm", args.tag))?;
|
||||
if answer != args.tag {
|
||||
return Err(anyhow::anyhow!("aborted"));
|
||||
}
|
||||
}
|
||||
api::release::delete(&ctx.client, &ctx.owner, &ctx.name, existing.id).await?;
|
||||
println!("✓ Deleted release {}", args.tag);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload(args: UploadArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
let release = api::release::get_by_tag(&ctx.client, &ctx.owner, &ctx.name, &args.tag).await?;
|
||||
for path in &args.files {
|
||||
upload_one(&ctx, release.id, path).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_one(
|
||||
ctx: &crate::cli::context::RepoContext,
|
||||
release_id: u64,
|
||||
path: &str,
|
||||
) -> Result<()> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
let name = std::path::Path::new(path)
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(path)
|
||||
.to_string();
|
||||
let asset =
|
||||
api::release::upload_asset(&ctx.client, &ctx.owner, &ctx.name, release_id, &name, bytes)
|
||||
.await?;
|
||||
println!("✓ Uploaded {} ({})", asset.name, human_size(asset.size));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download(args: DownloadArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
let release = api::release::get_by_tag(&ctx.client, &ctx.owner, &ctx.name, &args.tag).await?;
|
||||
let out = args.output_dir.unwrap_or_else(|| ".".into());
|
||||
std::fs::create_dir_all(&out)?;
|
||||
let mut downloaded = 0usize;
|
||||
for a in &release.assets {
|
||||
if let Some(filter) = &args.name {
|
||||
if &a.name != filter {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let dest = std::path::Path::new(&out).join(&a.name);
|
||||
let bytes = ctx
|
||||
.client
|
||||
.http()
|
||||
.get(&a.browser_download_url)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.bytes()
|
||||
.await?;
|
||||
std::fs::write(&dest, &bytes)?;
|
||||
println!("✓ Downloaded {} → {}", a.name, dest.display());
|
||||
downloaded += 1;
|
||||
}
|
||||
if downloaded == 0 {
|
||||
println!("(no assets matched)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn human_size(bytes: u64) -> String {
|
||||
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
|
||||
let mut size = bytes as f64;
|
||||
let mut unit = 0;
|
||||
while size >= 1024.0 && unit < UNITS.len() - 1 {
|
||||
size /= 1024.0;
|
||||
unit += 1;
|
||||
}
|
||||
if unit == 0 {
|
||||
format!("{}B", bytes)
|
||||
} else {
|
||||
format!("{:.1}{}", size, UNITS[unit])
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
|
||||
out.push('…');
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::human_size;
|
||||
|
||||
#[test]
|
||||
fn formats_bytes() {
|
||||
assert_eq!(human_size(0), "0B");
|
||||
assert_eq!(human_size(512), "512B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_kb() {
|
||||
assert_eq!(human_size(2048), "2.0KB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_mb_gb() {
|
||||
assert!(human_size(5 * 1024 * 1024).ends_with("MB"));
|
||||
assert!(human_size(2 * 1024_u64.pow(3)).ends_with("GB"));
|
||||
}
|
||||
}
|
||||
|
|
@ -348,7 +348,11 @@ async fn fork(args: ForkArgs, host: Option<&str>) -> Result<()> {
|
|||
.get(hostname)
|
||||
.map(|h| h.git_protocol.clone())
|
||||
.unwrap_or_else(|| "https".into());
|
||||
let url = if proto == "ssh" { r.ssh_url } else { r.clone_url };
|
||||
let url = if proto == "ssh" {
|
||||
r.ssh_url
|
||||
} else {
|
||||
r.clone_url
|
||||
};
|
||||
git::clone(&url, None)?;
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -365,7 +369,10 @@ async fn sync(args: SyncArgs, host: Option<&str>) -> Result<()> {
|
|||
}
|
||||
};
|
||||
api::repo::sync_with_upstream(&ctx.client, &ctx.owner, &ctx.name, &branch).await?;
|
||||
println!("✓ Synced {}/{} branch {} with upstream", ctx.owner, ctx.name, branch);
|
||||
println!(
|
||||
"✓ Synced {}/{} branch {} with upstream",
|
||||
ctx.owner, ctx.name, branch
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -420,9 +427,7 @@ async fn delete(args: DeleteArgs, host: Option<&str>) -> Result<()> {
|
|||
let (owner, name) = api::split_repo(&args.repo)?;
|
||||
let client = Client::connect(host)?;
|
||||
if !args.yes {
|
||||
let prompt = format!(
|
||||
"Delete {owner}/{name}? Type the slug to confirm"
|
||||
);
|
||||
let prompt = format!("Delete {owner}/{name}? Type the slug to confirm");
|
||||
let answer = editor::prompt_line(&prompt)?;
|
||||
if answer != args.repo {
|
||||
return Err(anyhow!("aborted: slug did not match"));
|
||||
|
|
|
|||
123
src/cli/search.rs
Normal file
123
src/cli/search.rs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
use anyhow::Result;
|
||||
use clap::{Args, Subcommand};
|
||||
|
||||
use crate::api;
|
||||
use crate::client::Client;
|
||||
use crate::output;
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct SearchCmd {
|
||||
#[command(subcommand)]
|
||||
pub command: SearchSub,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum SearchSub {
|
||||
Repos(QueryArgs),
|
||||
Issues(QueryArgs),
|
||||
Prs(QueryArgs),
|
||||
Users(QueryArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct QueryArgs {
|
||||
pub query: String,
|
||||
#[arg(short = 'L', long, default_value_t = 20)]
|
||||
pub limit: u32,
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
pub async fn run(cmd: SearchCmd, host: Option<&str>) -> Result<()> {
|
||||
let client = Client::connect(host)?;
|
||||
match cmd.command {
|
||||
SearchSub::Repos(args) => search_repos(&client, args).await,
|
||||
SearchSub::Issues(args) => search_issues(&client, args, "issues").await,
|
||||
SearchSub::Prs(args) => search_issues(&client, args, "pulls").await,
|
||||
SearchSub::Users(args) => search_users(&client, args).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn search_repos(client: &Client, args: QueryArgs) -> Result<()> {
|
||||
let hits = api::search::repos(client, &args.query, args.limit).await?;
|
||||
if args.json {
|
||||
return output::print_json(&serde_json::to_value(&hits)?);
|
||||
}
|
||||
if hits.is_empty() {
|
||||
println!("(no matches)");
|
||||
return Ok(());
|
||||
}
|
||||
let rows: Vec<Vec<String>> = hits
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let vis = if r.private { "private" } else { "public" };
|
||||
vec![
|
||||
r.full_name.clone(),
|
||||
truncate(&r.description, 60),
|
||||
vis.into(),
|
||||
output::dim(&output::relative_time(r.updated_at)),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
print!(
|
||||
"{}",
|
||||
output::render_table(&["NAME", "DESCRIPTION", "VIS", "UPDATED"], &rows)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn search_issues(client: &Client, args: QueryArgs, type_: &str) -> Result<()> {
|
||||
let hits = api::search::issues(client, &args.query, type_, args.limit).await?;
|
||||
if args.json {
|
||||
return output::print_json(&serde_json::to_value(&hits)?);
|
||||
}
|
||||
if hits.is_empty() {
|
||||
println!("(no matches)");
|
||||
return Ok(());
|
||||
}
|
||||
let rows: Vec<Vec<String>> = hits
|
||||
.iter()
|
||||
.map(|i| {
|
||||
vec![
|
||||
format!("#{}", i.number),
|
||||
output::state_pill(&i.state, false),
|
||||
truncate(&i.title, 60),
|
||||
output::dim(&i.html_url),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
print!(
|
||||
"{}",
|
||||
output::render_table(&["", "STATE", "TITLE", "URL"], &rows)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn search_users(client: &Client, args: QueryArgs) -> Result<()> {
|
||||
let hits = api::search::users(client, &args.query, args.limit).await?;
|
||||
if args.json {
|
||||
return output::print_json(&serde_json::to_value(&hits)?);
|
||||
}
|
||||
if hits.is_empty() {
|
||||
println!("(no matches)");
|
||||
return Ok(());
|
||||
}
|
||||
let rows: Vec<Vec<String>> = hits
|
||||
.iter()
|
||||
.map(|u| vec![u.login.clone(), u.full_name.clone(), u.email.clone()])
|
||||
.collect();
|
||||
print!(
|
||||
"{}",
|
||||
output::render_table(&["LOGIN", "NAME", "EMAIL"], &rows)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
|
||||
out.push('…');
|
||||
out
|
||||
}
|
||||
72
src/cli/status.rs
Normal file
72
src/cli/status.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
//! `fj status` — notifications inbox.
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Args;
|
||||
|
||||
use crate::api;
|
||||
use crate::client::Client;
|
||||
use crate::output;
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct StatusArgs {
|
||||
/// Show all (including already-read) notifications.
|
||||
#[arg(short = 'a', long)]
|
||||
pub all: bool,
|
||||
#[arg(short = 'L', long, default_value_t = 20)]
|
||||
pub limit: u32,
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
/// Mark all notifications as read.
|
||||
#[arg(long)]
|
||||
pub mark_read: bool,
|
||||
}
|
||||
|
||||
pub async fn run(args: StatusArgs, host: Option<&str>) -> Result<()> {
|
||||
let client = Client::connect(host)?;
|
||||
if args.mark_read {
|
||||
api::notification::mark_all_read(&client).await?;
|
||||
println!("✓ Marked all notifications as read");
|
||||
return Ok(());
|
||||
}
|
||||
let items = api::notification::list(&client, args.all, args.limit).await?;
|
||||
if args.json {
|
||||
return output::print_json(&serde_json::to_value(&items)?);
|
||||
}
|
||||
if items.is_empty() {
|
||||
println!("(no notifications)");
|
||||
return Ok(());
|
||||
}
|
||||
let rows: Vec<Vec<String>> = items
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let mark = if n.unread { "•" } else { " " };
|
||||
let repo = n
|
||||
.repository
|
||||
.as_ref()
|
||||
.map(|r| r.full_name.clone())
|
||||
.unwrap_or_default();
|
||||
vec![
|
||||
mark.into(),
|
||||
output::state_pill(&n.subject.state, false),
|
||||
n.subject.type_.clone(),
|
||||
repo,
|
||||
truncate(&n.subject.title, 60),
|
||||
output::dim(&output::relative_time(n.updated_at)),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
print!(
|
||||
"{}",
|
||||
output::render_table(&["", "STATE", "TYPE", "REPO", "TITLE", "UPDATED"], &rows)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
|
||||
out.push('…');
|
||||
out
|
||||
}
|
||||
310
src/cli/workflow.rs
Normal file
310
src/cli/workflow.rs
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
//! `fj run` and `fj secret` / `fj variable` — Forgejo Actions.
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Args, Subcommand};
|
||||
|
||||
use crate::api;
|
||||
use crate::cli::context::{resolve_repo, RepoFlag};
|
||||
use crate::output;
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct RunCmd {
|
||||
#[command(subcommand)]
|
||||
pub command: RunSub,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum RunSub {
|
||||
/// List workflow runs.
|
||||
List(ListArgs),
|
||||
/// View a workflow run.
|
||||
View(ViewArgs),
|
||||
/// Rerun a workflow run.
|
||||
Rerun(IdArgs),
|
||||
/// Cancel an in-progress workflow run.
|
||||
Cancel(IdArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct ListArgs {
|
||||
#[command(flatten)]
|
||||
pub r: RepoFlag,
|
||||
#[arg(short = 'L', long, default_value_t = 20)]
|
||||
pub limit: u32,
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct ViewArgs {
|
||||
#[command(flatten)]
|
||||
pub r: RepoFlag,
|
||||
pub run_id: u64,
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct IdArgs {
|
||||
#[command(flatten)]
|
||||
pub r: RepoFlag,
|
||||
pub run_id: u64,
|
||||
}
|
||||
|
||||
pub async fn run(cmd: RunCmd, host: Option<&str>) -> Result<()> {
|
||||
match cmd.command {
|
||||
RunSub::List(args) => list(args, host).await,
|
||||
RunSub::View(args) => view(args, host).await,
|
||||
RunSub::Rerun(args) => rerun(args, host).await,
|
||||
RunSub::Cancel(args) => cancel(args, host).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(args: ListArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
let runs = api::workflow::list_runs(&ctx.client, &ctx.owner, &ctx.name, args.limit).await?;
|
||||
if args.json {
|
||||
return output::print_json(&serde_json::to_value(&runs)?);
|
||||
}
|
||||
if runs.is_empty() {
|
||||
println!("(no workflow runs)");
|
||||
return Ok(());
|
||||
}
|
||||
let rows: Vec<Vec<String>> = runs
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let conclusion = r.conclusion.clone().unwrap_or_default();
|
||||
vec![
|
||||
r.run_number.to_string(),
|
||||
truncate(&r.name, 40),
|
||||
r.status.clone(),
|
||||
conclusion,
|
||||
truncate(&r.head_branch, 30),
|
||||
output::dim(&output::relative_time(r.updated_at)),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
print!(
|
||||
"{}",
|
||||
output::render_table(
|
||||
&["#", "WORKFLOW", "STATUS", "CONCLUSION", "BRANCH", "UPDATED"],
|
||||
&rows
|
||||
)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn view(args: ViewArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
let r = api::workflow::get_run(&ctx.client, &ctx.owner, &ctx.name, args.run_id).await?;
|
||||
if args.json {
|
||||
return output::print_json(&serde_json::to_value(&r)?);
|
||||
}
|
||||
println!("Run #{}: {}", r.run_number, r.name);
|
||||
println!("Status: {}", r.status);
|
||||
if let Some(c) = &r.conclusion {
|
||||
println!("Conclusion: {c}");
|
||||
}
|
||||
println!("Branch: {}", r.head_branch);
|
||||
println!("SHA: {}", r.head_sha);
|
||||
println!("URL: {}", r.html_url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn rerun(args: IdArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
api::workflow::rerun(&ctx.client, &ctx.owner, &ctx.name, args.run_id).await?;
|
||||
println!("✓ Re-ran workflow run #{}", args.run_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cancel(args: IdArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
api::workflow::cancel(&ctx.client, &ctx.owner, &ctx.name, args.run_id).await?;
|
||||
println!("✓ Cancelled workflow run #{}", args.run_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
|
||||
out.push('…');
|
||||
out
|
||||
}
|
||||
|
||||
// ── Secrets ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct SecretCmd {
|
||||
#[command(subcommand)]
|
||||
pub command: SecretSub,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum SecretSub {
|
||||
List(SecretListArgs),
|
||||
Set(SecretSetArgs),
|
||||
Delete(SecretDeleteArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct SecretListArgs {
|
||||
#[command(flatten)]
|
||||
pub r: RepoFlag,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct SecretSetArgs {
|
||||
#[command(flatten)]
|
||||
pub r: RepoFlag,
|
||||
pub name: String,
|
||||
/// Value (raw string). For multi-line secrets, prefer `--from-file -` and pipe stdin.
|
||||
#[arg(short = 'v', long)]
|
||||
pub value: Option<String>,
|
||||
/// Read value from this file. Use `-` for stdin.
|
||||
#[arg(short = 'f', long = "from-file")]
|
||||
pub from_file: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct SecretDeleteArgs {
|
||||
#[command(flatten)]
|
||||
pub r: RepoFlag,
|
||||
pub name: String,
|
||||
#[arg(short = 'y', long)]
|
||||
pub yes: bool,
|
||||
}
|
||||
|
||||
pub async fn run_secret(cmd: SecretCmd, host: Option<&str>) -> Result<()> {
|
||||
match cmd.command {
|
||||
SecretSub::List(args) => secret_list(args, host).await,
|
||||
SecretSub::Set(args) => secret_set(args, host).await,
|
||||
SecretSub::Delete(args) => secret_delete(args, host).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn secret_list(args: SecretListArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
let s = api::workflow::list_secrets(&ctx.client, &ctx.owner, &ctx.name).await?;
|
||||
if s.is_empty() {
|
||||
println!("(no secrets)");
|
||||
return Ok(());
|
||||
}
|
||||
let rows: Vec<Vec<String>> = s
|
||||
.iter()
|
||||
.map(|x| {
|
||||
vec![
|
||||
x.name.clone(),
|
||||
output::dim(&output::relative_time(x.created_at)),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
print!("{}", output::render_table(&["NAME", "CREATED"], &rows));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn secret_set(args: SecretSetArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
let value = read_value(args.value.as_deref(), args.from_file.as_deref())?;
|
||||
api::workflow::set_secret(&ctx.client, &ctx.owner, &ctx.name, &args.name, &value).await?;
|
||||
println!("✓ Set secret {}", args.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn secret_delete(args: SecretDeleteArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
if !args.yes {
|
||||
let ans = crate::cli::editor::prompt_line(&format!(
|
||||
"Delete secret '{}'? Type name to confirm",
|
||||
args.name
|
||||
))?;
|
||||
if ans != args.name {
|
||||
return Err(anyhow::anyhow!("aborted"));
|
||||
}
|
||||
}
|
||||
api::workflow::delete_secret(&ctx.client, &ctx.owner, &ctx.name, &args.name).await?;
|
||||
println!("✓ Deleted secret {}", args.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Variables ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct VariableCmd {
|
||||
#[command(subcommand)]
|
||||
pub command: VariableSub,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum VariableSub {
|
||||
List(SecretListArgs),
|
||||
Set(SecretSetArgs),
|
||||
Delete(SecretDeleteArgs),
|
||||
}
|
||||
|
||||
pub async fn run_variable(cmd: VariableCmd, host: Option<&str>) -> Result<()> {
|
||||
match cmd.command {
|
||||
VariableSub::List(args) => var_list(args, host).await,
|
||||
VariableSub::Set(args) => var_set(args, host).await,
|
||||
VariableSub::Delete(args) => var_delete(args, host).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn var_list(args: SecretListArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
let v = api::workflow::list_variables(&ctx.client, &ctx.owner, &ctx.name).await?;
|
||||
if v.is_empty() {
|
||||
println!("(no variables)");
|
||||
return Ok(());
|
||||
}
|
||||
let rows: Vec<Vec<String>> = v
|
||||
.iter()
|
||||
.map(|x| vec![x.name.clone(), x.data.clone()])
|
||||
.collect();
|
||||
print!("{}", output::render_table(&["NAME", "VALUE"], &rows));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn var_set(args: SecretSetArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
let value = read_value(args.value.as_deref(), args.from_file.as_deref())?;
|
||||
api::workflow::set_variable(&ctx.client, &ctx.owner, &ctx.name, &args.name, &value).await?;
|
||||
println!("✓ Set variable {}", args.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn var_delete(args: SecretDeleteArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
if !args.yes {
|
||||
let ans = crate::cli::editor::prompt_line(&format!(
|
||||
"Delete variable '{}'? Type name to confirm",
|
||||
args.name
|
||||
))?;
|
||||
if ans != args.name {
|
||||
return Err(anyhow::anyhow!("aborted"));
|
||||
}
|
||||
}
|
||||
api::workflow::delete_variable(&ctx.client, &ctx.owner, &ctx.name, &args.name).await?;
|
||||
println!("✓ Deleted variable {}", args.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_value(value: Option<&str>, from_file: Option<&str>) -> Result<String> {
|
||||
if let Some(v) = value {
|
||||
return Ok(v.to_string());
|
||||
}
|
||||
if let Some(path) = from_file {
|
||||
if path == "-" {
|
||||
use std::io::Read;
|
||||
let mut buf = String::new();
|
||||
std::io::stdin().read_to_string(&mut buf)?;
|
||||
return Ok(buf.trim_end().to_string());
|
||||
}
|
||||
return Ok(std::fs::read_to_string(path)?.trim_end().to_string());
|
||||
}
|
||||
Err(anyhow::anyhow!(
|
||||
"pass --value or --from-file (use `-` for stdin)"
|
||||
))
|
||||
}
|
||||
|
|
@ -83,6 +83,17 @@ impl Client {
|
|||
&self.base
|
||||
}
|
||||
|
||||
/// Underlying `reqwest::Client` for code paths that need to bypass our
|
||||
/// JSON-shaped helpers (e.g. multipart uploads for release assets).
|
||||
pub fn http(&self) -> &reqwest::Client {
|
||||
&self.http
|
||||
}
|
||||
|
||||
/// Raw token. Use only when constructing custom request headers.
|
||||
pub fn token(&self) -> &str {
|
||||
&self.token
|
||||
}
|
||||
|
||||
fn auth_headers(&self) -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
|
|
|
|||
82
src/main.rs
82
src/main.rs
|
|
@ -6,16 +6,39 @@ mod config;
|
|||
mod git;
|
||||
mod output;
|
||||
|
||||
use std::process::ExitCode;
|
||||
use std::process::{Command, ExitCode};
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
let cli = cli::Cli::parse();
|
||||
// Two pre-parse transformations, in order:
|
||||
// 1. expand a user-defined alias for `fj <name>`
|
||||
// 2. dispatch to a `fj-<name>` plugin binary on PATH if clap can't
|
||||
// recognize the subcommand (matches gh's convention)
|
||||
let argv: Vec<String> = std::env::args().collect();
|
||||
let argv = cli::alias::expand_argv(argv);
|
||||
|
||||
if let Some(plugin) = detect_plugin(&argv) {
|
||||
let prog = format!("fj-{plugin}");
|
||||
let extra: Vec<&String> = argv.iter().skip(2).collect();
|
||||
let status = match Command::new(&prog).args(&extra).status() {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
// Plugin not found — fall through to clap, which will error
|
||||
// nicely.
|
||||
return run_cli(argv).await;
|
||||
}
|
||||
};
|
||||
return ExitCode::from(status.code().unwrap_or(1) as u8);
|
||||
}
|
||||
|
||||
run_cli(argv).await
|
||||
}
|
||||
|
||||
async fn run_cli(argv: Vec<String>) -> ExitCode {
|
||||
let cli = cli::Cli::parse_from(argv);
|
||||
if let Err(err) = cli::run(cli).await {
|
||||
// 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 {
|
||||
|
|
@ -26,3 +49,56 @@ async fn main() -> ExitCode {
|
|||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
/// Returns `Some(name)` if `argv[1]` is unrecognized by clap AND a `fj-<name>`
|
||||
/// binary exists on PATH.
|
||||
fn detect_plugin(argv: &[String]) -> Option<String> {
|
||||
if argv.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let candidate = &argv[1];
|
||||
// Skip if it's clap-internal or obviously a flag.
|
||||
if candidate.starts_with('-') {
|
||||
return None;
|
||||
}
|
||||
if KNOWN_SUBCOMMANDS.iter().any(|s| s == candidate) {
|
||||
return None;
|
||||
}
|
||||
// Look up `fj-<name>` on PATH.
|
||||
let bin = format!("fj-{candidate}");
|
||||
let path = std::env::var_os("PATH")?;
|
||||
for dir in std::env::split_paths(&path) {
|
||||
let full = dir.join(&bin);
|
||||
if full.is_file() {
|
||||
return Some(candidate.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Keep this list in sync with `cli::Command`. It's an allowlist used to avoid
|
||||
/// hijacking real subcommands as plugin candidates.
|
||||
const KNOWN_SUBCOMMANDS: &[&str] = &[
|
||||
"auth",
|
||||
"repo",
|
||||
"issue",
|
||||
"pr",
|
||||
"release",
|
||||
"label",
|
||||
"run",
|
||||
"secret",
|
||||
"variable",
|
||||
"search",
|
||||
"browse",
|
||||
"status",
|
||||
"org",
|
||||
"ssh-key",
|
||||
"gpg-key",
|
||||
"alias",
|
||||
"config",
|
||||
"extension",
|
||||
"gist",
|
||||
"api",
|
||||
"completion",
|
||||
"help",
|
||||
];
|
||||
|
|
|
|||
Loading…
Reference in a new issue