expand: repo auto-detect, --web, editor, PR diff/checks/ready/review/status, repo lifecycle, api headers/paginate

* CI: pre-push hook (fmt, clippy -D warnings, test, release build) plus
  opt-in FJ_E2E=1 smoke. Install via scripts/install-hooks.sh.
* Repo auto-detection from git remote: -R/--repo becomes optional on all
  repo-scoped subcommands. Detection prefers `upstream` then `origin`,
  honors --host, and parses https/ssh/scp-style URLs.
* `--web` flag wired to every list/view command (open in default browser).
* `$EDITOR` integration for issue/pr create + comment + edit (omit
  `--body` to launch your editor; `-` keeps stdin).
* PR: new `diff`, `commits`, `files`, `checks`, `ready`, `review`,
  `edit`, `status`, `reopen` subcommands. `view --comments` now shows
  reviews too.
* Issue: `edit` and `develop` (creates a branch for the issue).
* Repo: `fork`, `sync`, `edit`, `rename`, `archive`, `unarchive`,
  `delete` (with slug-confirmation), `branches`, `topics`.
* `fj api` gains `-H` headers, `--paginate` (follows Link rel=next),
  `--include` (response headers), `--silent`. The jq-ish projector now
  supports `[N]`/`[-N]` brackets and `|` pipes.
* MSRV bumped to 1.82 (uses `is_none_or`).
* 46 unit tests covering pure logic: hosts CRUD, remote URL parsing,
  link header parser, jq projection, branch label fallback, slugify.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stephen Way 2026-05-13 08:22:40 -07:00
parent 495276f654
commit 191d941c78
No known key found for this signature in database
23 changed files with 2492 additions and 245 deletions

View file

@ -2,7 +2,7 @@
name = "fj" name = "fj"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
rust-version = "1.80" rust-version = "1.82"
description = "Command-line tool for Forgejo, in the spirit of gh" description = "Command-line tool for Forgejo, in the spirit of gh"
authors = ["Stephen Way <stephen@rasterstate.com>"] authors = ["Stephen Way <stephen@rasterstate.com>"]
license = "MIT" license = "MIT"

37
hooks/pre-push Executable file
View file

@ -0,0 +1,37 @@
#!/usr/bin/env bash
# fj pre-push hook — local CI gate.
# Runs fmt-check, clippy, tests, and a release build. With FJ_E2E=1 also
# runs the live-API smoke suite against the currently signed-in host.
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
# Allow skip in genuine emergencies. Don't use this unless you know why.
if [[ "${FJ_SKIP_PREPUSH:-0}" = "1" ]]; then
echo "fj pre-push: skipped (FJ_SKIP_PREPUSH=1)"
exit 0
fi
step() {
printf '\n\033[1;34m== %s ==\033[0m\n' "$*"
}
step "cargo fmt --check"
cargo fmt --all -- --check
step "cargo clippy (deny warnings)"
cargo clippy --all-targets --all-features -- -D warnings
step "cargo test"
cargo test --all --locked
step "cargo build --release"
cargo build --release --locked
if [[ "${FJ_E2E:-0}" = "1" ]]; then
step "E2E smoke (live API)"
./scripts/e2e-smoke.sh
fi
printf '\n\033[1;32m✓ pre-push checks passed\033[0m\n'

44
scripts/e2e-smoke.sh Executable file
View file

@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Live-API smoke: hit a curated set of read-only endpoints via the just-built
# binary. Requires the user to be logged in (`fj auth login`). Designed for
# the pre-push hook when FJ_E2E=1.
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
FJ="${FJ_BIN:-./target/release/fj}"
TEST_REPO="${FJ_E2E_REPO:-stephen/fj-cli-test}"
run() {
printf ' • %s\n' "$*"
"$@" >/dev/null
}
if [[ ! -x "$FJ" ]]; then
echo "missing $FJ; run cargo build --release first" >&2
exit 1
fi
echo "== auth =="
run "$FJ" auth status
run "$FJ" auth list
echo "== api =="
run "$FJ" api /version
run "$FJ" api /user
echo "== repo =="
run "$FJ" repo list -L 5
run "$FJ" repo view "$TEST_REPO"
run "$FJ" repo view "$TEST_REPO" --json
echo "== issue =="
run "$FJ" issue list -R "$TEST_REPO" --state all
run "$FJ" issue list -R "$TEST_REPO" --json
echo "== pr =="
run "$FJ" pr list -R "$TEST_REPO" --state all
run "$FJ" pr list -R "$TEST_REPO" --json
echo "✓ e2e smoke passed against $TEST_REPO"

19
scripts/install-hooks.sh Executable file
View file

@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Install the repo's hooks into .git/hooks via symlink so updates in tree
# are picked up automatically.
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
hooks_dir=".git/hooks"
mkdir -p "$hooks_dir"
for src in hooks/*; do
name="$(basename "$src")"
dest="$hooks_dir/$name"
rm -f "$dest"
ln -s "../../$src" "$dest"
chmod +x "$src"
echo "✓ linked $name"
done

View file

@ -174,9 +174,7 @@ pub async fn comment(
) -> Result<Comment> { ) -> Result<Comment> {
let path = format!("/api/v1/repos/{owner}/{name}/issues/{number}/comments"); let path = format!("/api/v1/repos/{owner}/{name}/issues/{number}/comments");
let payload = CreateComment { body }; let payload = CreateComment { body };
client client.json(Method::POST, &path, &[], Some(&payload)).await
.json(Method::POST, &path, &[], Some(&payload))
.await
} }
pub async fn list_comments( pub async fn list_comments(

View file

@ -11,3 +11,30 @@ pub fn split_repo(repo: &str) -> Result<(&str, &str)> {
.filter(|(o, n)| !o.is_empty() && !n.is_empty()) .filter(|(o, n)| !o.is_empty() && !n.is_empty())
.ok_or_else(|| anyhow!("expected '<owner>/<name>', got '{repo}'")) .ok_or_else(|| anyhow!("expected '<owner>/<name>', got '{repo}'"))
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_normal_slug() {
let (o, n) = split_repo("rasterstate/fj").unwrap();
assert_eq!(o, "rasterstate");
assert_eq!(n, "fj");
}
#[test]
fn rejects_missing_slash() {
assert!(split_repo("fj").is_err());
}
#[test]
fn rejects_empty_owner() {
assert!(split_repo("/fj").is_err());
}
#[test]
fn rejects_empty_name() {
assert!(split_repo("rasterstate/").is_err());
}
}

View file

@ -44,6 +44,57 @@ pub struct Branch {
pub sha: String, pub sha: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PullFile {
pub filename: String,
pub status: String,
#[serde(default)]
pub additions: u64,
#[serde(default)]
pub deletions: u64,
#[serde(default)]
pub changes: u64,
#[serde(default)]
pub previous_filename: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Commit {
pub sha: String,
#[serde(default)]
pub commit: CommitMeta,
#[serde(default)]
pub html_url: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CommitMeta {
#[serde(default)]
pub message: String,
#[serde(default)]
pub author: Option<CommitAuthor>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommitAuthor {
pub name: String,
#[serde(default)]
pub email: String,
#[serde(default)]
pub date: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Review {
pub id: u64,
pub state: String,
#[serde(default)]
pub body: String,
pub user: User,
pub submitted_at: Option<DateTime<Utc>>,
pub html_url: String,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ListOptions { pub struct ListOptions {
pub state: State, pub state: State,
@ -88,6 +139,8 @@ pub struct CreatePull<'a> {
pub base: &'a str, pub base: &'a str,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<&'a str>, pub body: Option<&'a str>,
#[serde(default)]
pub draft: bool,
} }
pub async fn create( pub async fn create(
@ -121,6 +174,97 @@ pub async fn edit(
client.json(Method::PATCH, &path, &[], Some(body)).await client.json(Method::PATCH, &path, &[], Some(body)).await
} }
/// Convert a draft PR to ready-for-review.
pub async fn ready(client: &Client, owner: &str, name: &str, number: u64) -> Result<()> {
let path = format!("/api/v1/repos/{owner}/{name}/pulls/{number}");
// Forgejo accepts `allow_maintainer_edit` and the toggle for `draft` via
// PATCH; the more reliable form is the dedicated endpoint when present,
// but PATCH with `{ "draft": false }` works on 7.x.
#[derive(serde::Serialize)]
struct ReadyBody {
draft: bool,
}
let body = ReadyBody { draft: false };
client
.json::<Pull, _>(Method::PATCH, &path, &[], Some(&body))
.await
.map(|_| ())
}
pub async fn files(client: &Client, owner: &str, name: &str, number: u64) -> Result<Vec<PullFile>> {
let path = format!("/api/v1/repos/{owner}/{name}/pulls/{number}/files");
client.json(Method::GET, &path, &[], None::<&()>).await
}
pub async fn commits(client: &Client, owner: &str, name: &str, number: u64) -> Result<Vec<Commit>> {
let path = format!("/api/v1/repos/{owner}/{name}/pulls/{number}/commits");
client.json(Method::GET, &path, &[], None::<&()>).await
}
/// Raw unified diff for a PR.
pub async fn diff_text(client: &Client, owner: &str, name: &str, number: u64) -> Result<String> {
let path = format!("/api/v1/repos/{owner}/{name}/pulls/{number}.diff");
let res = client.request(Method::GET, &path, &[], None).await?;
let res = res.error_for_status()?;
Ok(res.text().await?)
}
#[derive(Debug, Clone, Copy)]
pub enum ReviewEvent {
Approve,
RequestChanges,
Comment,
}
impl ReviewEvent {
pub fn as_str(self) -> &'static str {
match self {
ReviewEvent::Approve => "APPROVED",
ReviewEvent::RequestChanges => "REQUEST_CHANGES",
ReviewEvent::Comment => "COMMENT",
}
}
}
#[derive(Debug, Clone, Serialize)]
struct CreateReviewBody<'a> {
event: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
body: Option<&'a str>,
}
pub async fn submit_review(
client: &Client,
owner: &str,
name: &str,
number: u64,
event: ReviewEvent,
body: Option<&str>,
) -> Result<Review> {
let path = format!("/api/v1/repos/{owner}/{name}/pulls/{number}/reviews");
client
.json(
Method::POST,
&path,
&[],
Some(&CreateReviewBody {
event: event.as_str(),
body,
}),
)
.await
}
pub async fn list_reviews(
client: &Client,
owner: &str,
name: &str,
number: u64,
) -> Result<Vec<Review>> {
let path = format!("/api/v1/repos/{owner}/{name}/pulls/{number}/reviews");
client.json(Method::GET, &path, &[], None::<&()>).await
}
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub enum MergeStyle { pub enum MergeStyle {
Merge, Merge,
@ -166,8 +310,48 @@ pub async fn merge(
message, message,
}; };
let res = client let res = client
.request(Method::POST, &path, &[], Some(&serde_json::to_value(&body)?)) .request(
Method::POST,
&path,
&[],
Some(&serde_json::to_value(&body)?),
)
.await?; .await?;
res.error_for_status()?; res.error_for_status()?;
Ok(()) Ok(())
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CombinedStatus {
#[serde(default)]
pub state: String,
pub sha: String,
#[serde(default)]
pub statuses: Vec<CommitStatus>,
#[serde(default)]
pub total_count: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommitStatus {
#[serde(default)]
pub status: String,
#[serde(default)]
pub context: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub target_url: String,
#[serde(default)]
pub updated_at: Option<DateTime<Utc>>,
}
pub async fn combined_status(
client: &Client,
owner: &str,
name: &str,
sha: &str,
) -> Result<CombinedStatus> {
let path = format!("/api/v1/repos/{owner}/{name}/commits/{sha}/status");
client.json(Method::GET, &path, &[], None::<&()>).await
}

View file

@ -21,6 +21,8 @@ pub struct Repo {
pub fork: bool, pub fork: bool,
#[serde(default)] #[serde(default)]
pub archived: bool, pub archived: bool,
#[serde(default)]
pub mirror: bool,
pub html_url: String, pub html_url: String,
pub clone_url: String, pub clone_url: String,
pub ssh_url: String, pub ssh_url: String,
@ -63,16 +65,11 @@ pub async fn search(client: &Client, opts: ListOptions<'_>) -> Result<Page<Searc
query.push(("q".into(), q.into())); query.push(("q".into(), q.into()));
} }
let res = client let res = client
.request( .request(Method::GET, "/api/v1/repos/search", &query, None)
Method::GET,
"/api/v1/repos/search",
&query,
None,
)
.await?; .await?;
let headers = res.headers().clone(); let headers = res.headers().clone();
let body: SearchResponse = res.error_for_status()?.json().await?; let body: SearchResponse = res.error_for_status()?.json().await?;
Ok(Page::from_headers(body.data, &headers).also_total(body.ok)) Ok(Page::from_headers(body.data, &headers))
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -80,15 +77,10 @@ struct SearchResponse {
#[serde(default)] #[serde(default)]
data: Vec<SearchHit>, data: Vec<SearchHit>,
#[serde(default)] #[serde(default)]
#[allow(dead_code)]
ok: bool, ok: bool,
} }
impl<T> Page<T> {
fn also_total(self, _ok: bool) -> Self {
self
}
}
pub type SearchHit = Repo; pub type SearchHit = Repo;
pub async fn get(client: &Client, owner: &str, name: &str) -> Result<Repo> { pub async fn get(client: &Client, owner: &str, name: &str) -> Result<Repo> {
@ -115,11 +107,169 @@ pub async fn create_for_current_user(client: &Client, body: &CreateRepo<'_>) ->
.await .await
} }
pub async fn create_for_org( pub async fn create_for_org(client: &Client, org: &str, body: &CreateRepo<'_>) -> Result<Repo> {
client: &Client,
org: &str,
body: &CreateRepo<'_>,
) -> Result<Repo> {
let path = format!("/api/v1/orgs/{org}/repos"); let path = format!("/api/v1/orgs/{org}/repos");
client.json(Method::POST, &path, &[], Some(body)).await client.json(Method::POST, &path, &[], Some(body)).await
} }
#[derive(Debug, Clone, Serialize, Default)]
pub struct EditRepo<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub website: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub private: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_branch: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub archived: Option<bool>,
}
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
}
pub async fn delete(client: &Client, owner: &str, name: &str) -> Result<()> {
let path = format!("/api/v1/repos/{owner}/{name}");
let res = client.request(Method::DELETE, &path, &[], None).await?;
res.error_for_status()?;
Ok(())
}
#[derive(Debug, Clone, Serialize)]
struct ForkBody<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
organization: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<&'a str>,
}
pub async fn fork(
client: &Client,
owner: &str,
name: &str,
organization: Option<&str>,
new_name: Option<&str>,
) -> Result<Repo> {
let path = format!("/api/v1/repos/{owner}/{name}/forks");
client
.json(
Method::POST,
&path,
&[],
Some(&ForkBody {
organization,
name: new_name,
}),
)
.await
}
pub async fn rename(client: &Client, owner: &str, name: &str, new_name: &str) -> Result<()> {
// Forgejo: PATCH /repos/{owner}/{name} with `name` field.
let path = format!("/api/v1/repos/{owner}/{name}");
#[derive(Serialize)]
struct Body<'a> {
name: &'a str,
}
let res = client
.request(
Method::PATCH,
&path,
&[],
Some(&serde_json::to_value(Body { name: new_name })?),
)
.await?;
res.error_for_status()?;
Ok(())
}
#[derive(Debug, Clone, Serialize)]
pub struct MergeUpstream<'a> {
pub branch: &'a str,
}
pub async fn sync_with_upstream(
client: &Client,
owner: &str,
name: &str,
branch: &str,
) -> Result<()> {
let path = format!("/api/v1/repos/{owner}/{name}/merge-upstream");
let res = client
.request(
Method::POST,
&path,
&[],
Some(&serde_json::to_value(MergeUpstream { branch })?),
)
.await?;
res.error_for_status()?;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Branch {
pub name: String,
#[serde(default)]
pub protected: bool,
pub commit: BranchCommit,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BranchCommit {
pub id: String,
#[serde(default)]
pub message: String,
}
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
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Topic {
#[serde(default)]
pub topics: 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<()> {
let path = format!("/api/v1/repos/{owner}/{name}/topics");
let res = client
.request(
Method::PUT,
&path,
&[],
Some(&serde_json::json!({ "topics": topics })),
)
.await?;
res.error_for_status()?;
Ok(())
}

View file

@ -19,5 +19,7 @@ pub struct User {
} }
pub async fn current(client: &Client) -> Result<User> { pub async fn current(client: &Client) -> Result<User> {
client.json(Method::GET, "/api/v1/user", &[], None::<&()>).await client
.json(Method::GET, "/api/v1/user", &[], None::<&()>)
.await
} }

View file

@ -1,9 +1,11 @@
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use clap::Args; use clap::Args;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use reqwest::Method; use reqwest::Method;
use serde_json::Value; use serde_json::Value;
use url::Url;
use crate::client::Client; use crate::client::{pagination::parse_link_header, Client};
use crate::output; use crate::output;
#[derive(Debug, Args)] #[derive(Debug, Args)]
@ -12,44 +14,92 @@ pub struct ApiArgs {
/// are also accepted. /// are also accepted.
pub endpoint: String, pub endpoint: String,
/// HTTP method. Defaults to GET, or POST when fields are supplied. /// HTTP method. Defaults to GET, or POST when `--input` is supplied.
#[arg(short = 'X', long = "method")] #[arg(short = 'X', long = "method")]
pub method: Option<String>, pub method: Option<String>,
/// Add a query parameter (`-f key=value`). For GET; for non-GET methods these /// Add a query parameter for GET/DELETE (`-f key=value`); for non-GET
/// are sent as a JSON body field instead. Repeatable. /// methods these are sent as JSON body fields. Repeatable.
#[arg(short = 'f', long = "field", value_name = "KEY=VALUE")] #[arg(short = 'f', long = "field", value_name = "KEY=VALUE")]
pub fields: Vec<String>, pub fields: Vec<String>,
/// Like `-f` but interprets the value as raw JSON (numbers, bools, arrays, objects). /// Like `-f` but interprets the value as raw JSON.
#[arg(short = 'F', long = "raw-field", value_name = "KEY=VALUE")] #[arg(short = 'F', long = "raw-field", value_name = "KEY=VALUE")]
pub raw_fields: Vec<String>, pub raw_fields: Vec<String>,
/// Output a specific JSON path (very small jq-ish subset, e.g. `.items.0.title`). /// Add a custom request header (`-H name=value`). Repeatable.
#[arg(short = 'H', long = "header", value_name = "NAME=VALUE")]
pub headers: Vec<String>,
/// Output a JSON path projection. Supports dot paths, [N] indices,
/// negative indices like [-1], and pipes (`. | .field`).
#[arg(short = 'q', long = "jq")] #[arg(short = 'q', long = "jq")]
pub jq: Option<String>, pub jq: Option<String>,
/// Send a literal JSON request body. Conflicts with `-f` / `-F`. /// Send a literal JSON request body. Conflicts with `-f` / `-F`.
#[arg(long, conflicts_with_all = ["fields", "raw_fields"])] #[arg(long, conflicts_with_all = ["fields", "raw_fields"])]
pub input: Option<String>, pub input: Option<String>,
/// Follow `Link: rel=next` and concatenate all pages into a single array.
/// Only valid for GET requests whose body is an array.
#[arg(long)]
pub paginate: bool,
/// Print response headers before the body.
#[arg(short = 'i', long)]
pub include: bool,
/// Suppress response body output (still surfaces errors).
#[arg(long)]
pub silent: bool,
} }
pub async fn run(args: ApiArgs, host: Option<&str>) -> Result<()> { pub async fn run(args: ApiArgs, host: Option<&str>) -> Result<()> {
let client = Client::connect(host)?; let client = Client::connect(host)?;
let method = pick_method(&args)?; let method = pick_method(&args)?;
let (query, body) = build_query_or_body(&args, &method)?; let (query, body) = build_query_or_body(&args, &method)?;
let extra_headers = parse_headers(&args.headers)?;
if args.paginate {
if method != Method::GET {
return Err(anyhow!("--paginate only works with GET"));
}
let pages = collect_paginated(&client, &args.endpoint, &query, &extra_headers).await?;
let projected = match &args.jq {
Some(path) => extract_path(&pages, path)?,
None => pages,
};
if !args.silent {
output::print_json(&projected)?;
}
return Ok(());
}
let res = client let res = client
.request(method.clone(), &args.endpoint, &query, body.as_ref()) .request_with_headers(
method.clone(),
&args.endpoint,
&query,
body.as_ref(),
&extra_headers,
)
.await?; .await?;
let status = res.status(); let status = res.status();
let resp_headers = res.headers().clone();
let text = res.text().await.context("reading response body")?; let text = res.text().await.context("reading response body")?;
if args.include {
eprintln!("HTTP {status}");
for (k, v) in resp_headers.iter() {
eprintln!("{}: {}", k.as_str(), v.to_str().unwrap_or("(binary)"));
}
eprintln!();
}
let parsed: Value = if text.is_empty() { let parsed: Value = if text.is_empty() {
Value::Null Value::Null
} else { } else {
serde_json::from_str(&text).unwrap_or_else(|_| Value::String(text.clone())) serde_json::from_str(&text).unwrap_or(Value::String(text.clone()))
}; };
if !status.is_success() { if !status.is_success() {
@ -62,7 +112,10 @@ pub async fn run(args: ApiArgs, host: Option<&str>) -> Result<()> {
Some(path) => extract_path(&parsed, path)?, Some(path) => extract_path(&parsed, path)?,
None => parsed, None => parsed,
}; };
output::print_json(&projected) if !args.silent {
output::print_json(&projected)?;
}
Ok(())
} }
fn pick_method(args: &ApiArgs) -> Result<Method> { fn pick_method(args: &ApiArgs) -> Result<Method> {
@ -70,22 +123,17 @@ fn pick_method(args: &ApiArgs) -> Result<Method> {
return Method::from_bytes(m.to_uppercase().as_bytes()) return Method::from_bytes(m.to_uppercase().as_bytes())
.with_context(|| format!("invalid HTTP method '{m}'")); .with_context(|| format!("invalid HTTP method '{m}'"));
} }
// Only `--input` (a literal JSON body) implies POST. `-f` / `-F` on a GET
// are sent as query params, which is what users hit search endpoints for.
// To send a write request, pass `-X POST` (or PATCH/PUT/DELETE) explicitly.
if args.input.is_some() { if args.input.is_some() {
return Ok(Method::POST); return Ok(Method::POST);
} }
Ok(Method::GET) Ok(Method::GET)
} }
fn build_query_or_body( type QueryAndBody = (Vec<(String, String)>, Option<Value>);
args: &ApiArgs,
method: &Method, fn build_query_or_body(args: &ApiArgs, method: &Method) -> Result<QueryAndBody> {
) -> Result<(Vec<(String, String)>, Option<Value>)> {
if let Some(input) = &args.input { if let Some(input) = &args.input {
let body: Value = let body: Value = serde_json::from_str(input).context("--input must be valid JSON")?;
serde_json::from_str(input).context("--input must be valid JSON")?;
return Ok((Vec::new(), Some(body))); return Ok((Vec::new(), Some(body)));
} }
@ -116,29 +164,269 @@ fn build_query_or_body(
Ok((Vec::new(), Some(Value::Object(obj)))) Ok((Vec::new(), Some(Value::Object(obj))))
} }
fn parse_headers(items: &[String]) -> Result<HeaderMap> {
let mut map = HeaderMap::new();
for item in items {
let (k, v) = item
.split_once(':')
.or_else(|| item.split_once('='))
.ok_or_else(|| anyhow!("expected NAME: VALUE, got '{item}'"))?;
let name = HeaderName::from_bytes(k.trim().as_bytes())
.with_context(|| format!("invalid header name '{k}'"))?;
let value = HeaderValue::from_str(v.trim())
.with_context(|| format!("invalid header value for '{k}'"))?;
map.insert(name, value);
}
Ok(map)
}
async fn collect_paginated(
client: &Client,
endpoint: &str,
query: &[(String, String)],
extra_headers: &HeaderMap,
) -> Result<Value> {
let mut all_items = Vec::<Value>::new();
let mut current_endpoint: String = endpoint.to_string();
let mut current_query: Vec<(String, String)> = query.to_vec();
loop {
let res = client
.request_with_headers(
Method::GET,
&current_endpoint,
&current_query,
None,
extra_headers,
)
.await?;
let headers = res.headers().clone();
let status = res.status();
let text = res.text().await?;
if !status.is_success() {
return Err(anyhow!("HTTP {status}: {text}"));
}
let v: Value = serde_json::from_str(&text)?;
match v {
Value::Array(items) => all_items.extend(items),
other => {
if all_items.is_empty() {
return Ok(other);
}
return Err(anyhow!(
"expected an array body for --paginate, got: {other}"
));
}
}
let next = headers
.get(reqwest::header::LINK)
.and_then(|l| l.to_str().ok())
.map(parse_link_header)
.and_then(|links| {
links
.into_iter()
.find_map(|(url, rel)| (rel == "next").then_some(url))
});
let Some(next_url) = next else { break };
// Forgejo's `next` is a full URL with the page query param updated.
// Re-parse to extract the path + query so it threads through our
// 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();
}
Ok(Value::Array(all_items))
}
fn split_kv(s: &str) -> Result<(&str, &str)> { fn split_kv(s: &str) -> Result<(&str, &str)> {
s.split_once('=') s.split_once('=')
.ok_or_else(|| anyhow!("expected KEY=VALUE, got '{s}'")) .ok_or_else(|| anyhow!("expected KEY=VALUE, got '{s}'"))
} }
/// Very small jq-ish projector: dot-separated keys + numeric indices. /// jq-ish projector. Supports:
/// e.g. `.data.0.name`, `.message`. /// - dot fields (`.a.b`)
fn extract_path(value: &Value, path: &str) -> Result<Value> { /// - numeric indices (`.0`, `.data.1`)
let path = path.trim_start_matches('.'); /// - bracket indices (`.data[0]`, negative supported: `.data[-1]`)
if path.is_empty() { /// - pipes (`.foo | .bar`)
fn extract_path(value: &Value, expression: &str) -> Result<Value> {
let mut current = value.clone();
for stage in expression.split('|') {
current = apply_stage(&current, stage.trim())?;
}
Ok(current)
}
fn apply_stage(value: &Value, stage: &str) -> Result<Value> {
let stage = stage.trim_start_matches('.');
if stage.is_empty() {
return Ok(value.clone()); return Ok(value.clone());
} }
let mut current = value; let mut current = value;
for segment in path.split('.') { let mut owned: Option<Value> = None;
if let Ok(idx) = segment.parse::<usize>() {
current = current for raw in split_segments(stage) {
.get(idx) let target = owned.as_ref().unwrap_or(current);
.ok_or_else(|| anyhow!("index {idx} out of range at '{segment}'"))?; let raw_str = raw.as_str();
if let Some(rest) = raw_str.strip_prefix('[') {
let inner = rest
.strip_suffix(']')
.ok_or_else(|| anyhow!("unterminated bracket in '{raw_str}'"))?;
let idx: i64 = inner
.parse()
.with_context(|| format!("invalid bracket index '{inner}'"))?;
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 { } else {
current = current idx
.get(segment) };
.ok_or_else(|| anyhow!("no field '{segment}'"))?; let v = arr
.get(real as usize)
.ok_or_else(|| anyhow!("index {idx} out of range"))?
.clone();
owned = Some(v);
current = owned.as_ref().unwrap();
} else if let Ok(idx) = raw_str.parse::<usize>() {
let v = target
.get(idx)
.ok_or_else(|| anyhow!("index {idx} out of range"))?
.clone();
owned = Some(v);
current = owned.as_ref().unwrap();
} else {
let v = target
.get(raw_str)
.ok_or_else(|| anyhow!("no field '{raw_str}'"))?
.clone();
owned = Some(v);
current = owned.as_ref().unwrap();
} }
} }
Ok(current.clone()) Ok(owned.unwrap_or_else(|| value.clone()))
}
/// Split a stage like `data.0[3].name` into ["data", "0", "[3]", "name"].
fn split_segments(stage: &str) -> Vec<String> {
let mut out = Vec::new();
let mut buf = String::new();
let mut chars = stage.chars().peekable();
while let Some(c) = chars.next() {
match c {
'.' => {
if !buf.is_empty() {
out.push(std::mem::take(&mut buf));
}
}
'[' => {
if !buf.is_empty() {
out.push(std::mem::take(&mut buf));
}
let mut br = String::from('[');
for c2 in chars.by_ref() {
br.push(c2);
if c2 == ']' {
break;
}
}
out.push(br);
}
_ => buf.push(c),
}
}
if !buf.is_empty() {
out.push(buf);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn extract_root() {
let v = json!({"a": 1});
assert_eq!(extract_path(&v, "").unwrap(), v);
assert_eq!(extract_path(&v, ".").unwrap(), v);
}
#[test]
fn extract_field() {
let v = json!({"a": {"b": 42}});
assert_eq!(extract_path(&v, ".a.b").unwrap(), json!(42));
}
#[test]
fn extract_array_index_dotted() {
let v = json!({"data": [{"name": "first"}, {"name": "second"}]});
assert_eq!(extract_path(&v, ".data.1.name").unwrap(), json!("second"));
}
#[test]
fn extract_array_index_bracket() {
let v = json!({"data": [10, 20, 30]});
assert_eq!(extract_path(&v, ".data[1]").unwrap(), json!(20));
}
#[test]
fn extract_negative_index() {
let v = json!([1, 2, 3, 4]);
assert_eq!(extract_path(&v, "[-1]").unwrap(), json!(4));
assert_eq!(extract_path(&v, "[-2]").unwrap(), json!(3));
}
#[test]
fn extract_pipe() {
let v = json!({"data": [{"x": 7}]});
assert_eq!(extract_path(&v, ".data[0] | .x").unwrap(), json!(7));
}
#[test]
fn extract_missing_field_errors() {
let v = json!({"a": 1});
assert!(extract_path(&v, ".missing").is_err());
}
#[test]
fn extract_out_of_range_errors() {
let v = json!([1, 2]);
assert!(extract_path(&v, ".5").is_err());
assert!(extract_path(&v, "[5]").is_err());
}
#[test]
fn split_kv_works() {
let (k, v) = split_kv("a=b").unwrap();
assert_eq!(k, "a");
assert_eq!(v, "b");
}
#[test]
fn split_kv_keeps_equals_in_value() {
let (k, v) = split_kv("filter=a=b").unwrap();
assert_eq!(k, "filter");
assert_eq!(v, "a=b");
}
#[test]
fn parse_headers_basic() {
let h = parse_headers(&["X-Custom: hello".into()]).unwrap();
assert_eq!(h.get("x-custom").unwrap(), "hello");
}
#[test]
fn parse_headers_equals_form() {
let h = parse_headers(&["X-Custom=world".into()]).unwrap();
assert_eq!(h.get("x-custom").unwrap(), "world");
}
#[test]
fn parse_headers_rejects_unfielded() {
assert!(parse_headers(&["nope".into()]).is_err());
}
} }

View file

@ -177,7 +177,11 @@ async fn status(args: StatusArgs) -> Result<()> {
} }
println!( println!(
" ✓ Token: {}", " ✓ Token: {}",
if token_present { "present in keychain" } else { "missing" } if token_present {
"present in keychain"
} else {
"missing"
}
); );
if args.show_token { if args.show_token {
if let Some(t) = token_store::load_token(name)? { if let Some(t) = token_store::load_token(name)? {

87
src/cli/context.rs Normal file
View file

@ -0,0 +1,87 @@
//! Shared helpers for resolving the target repo + host across subcommands.
//!
//! The contract: each subcommand accepts `-R <owner>/<name>` plus the global
//! `--host`. When `-R` is omitted, we infer the repo from the current
//! directory's git remotes, preferring `upstream` then `origin`.
use anyhow::{Context, Result};
use clap::Args;
use crate::api;
use crate::client::Client;
use crate::config::hosts::Hosts;
use crate::git;
/// Flattened `-R/--repo` flag used by every repo-scoped subcommand. When
/// omitted, the repo is inferred from the current directory's git remotes.
#[derive(Debug, Args, Clone, Default)]
pub struct RepoFlag {
/// Target repository as `<owner>/<name>`. Inferred from the git remote
/// when omitted.
#[arg(short = 'R', long = "repo", global = true)]
pub repo: Option<String>,
}
/// A repo target resolved from either `-R` or git remotes, plus a connected
/// API client for the correct host.
pub struct RepoContext {
pub client: Client,
pub host: String,
pub owner: String,
pub name: String,
}
impl RepoContext {
#[allow(dead_code)]
pub fn slug(&self) -> String {
format!("{}/{}", self.owner, self.name)
}
}
/// Resolve a repo from `-R` (`owner/name`) or auto-detect from the git remote.
/// Honors the global `--host` flag for host selection.
pub fn resolve_repo(
explicit_repo: Option<&str>,
explicit_host: Option<&str>,
) -> Result<RepoContext> {
let (host_for_client, owner, name) = match explicit_repo {
Some(slug) => {
let (o, n) = api::split_repo(slug)?;
(
explicit_host.map(str::to_string),
o.to_string(),
n.to_string(),
)
}
None => {
let detected = git::discover(explicit_host)
.context("repository not specified and could not be inferred from git remote")?;
// If the user passed --host, keep that as the API host even though
// the remote URL also happens to name a host. They should match in
// practice, but the explicit flag wins.
(
Some(explicit_host.unwrap_or(&detected.host).to_string()),
detected.owner,
detected.name,
)
}
};
// If the user didn't pass --host AND we didn't autodetect, fall back to
// the configured default host.
let client_host = host_for_client.or_else(|| {
Hosts::load()
.ok()
.and_then(|h| h.resolve_host(None).ok().map(|s| s.to_string()))
});
let client = Client::connect(client_host.as_deref())?;
let host = client_host.unwrap_or_else(|| client.host().to_string());
Ok(RepoContext {
client,
host,
owner,
name,
})
}

116
src/cli/editor.rs Normal file
View file

@ -0,0 +1,116 @@
//! Editor + interactive prompt helpers used by `issue create`, `pr create`,
//! and `*-comment` commands.
use std::env;
use std::fs;
use std::io::{Read, Write};
use std::process::Command;
use anyhow::{anyhow, Context, Result};
/// Default editor binary, in order: $VISUAL, $EDITOR, then `vi`.
fn editor_command() -> String {
env::var("VISUAL")
.ok()
.or_else(|| env::var("EDITOR").ok())
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "vi".into())
}
/// Open `$EDITOR` with `initial_contents` and return the saved buffer with
/// trailing whitespace trimmed. `template_name` is just the suffix of the
/// temp file (helps editors pick syntax: `ISSUE_BODY.md`, etc.).
pub fn edit_text(template_name: &str, initial_contents: &str) -> Result<String> {
let mut tmp = env::temp_dir();
tmp.push(format!("fj-{}-{}", std::process::id(), template_name));
{
let mut file = fs::File::create(&tmp)
.with_context(|| format!("creating temp file at {}", tmp.display()))?;
file.write_all(initial_contents.as_bytes())?;
}
let editor = editor_command();
// Split on whitespace so users can set `EDITOR="code -w"`.
let mut parts = editor.split_whitespace();
let program = parts
.next()
.ok_or_else(|| anyhow!("editor command is empty"))?;
let extra_args: Vec<&str> = parts.collect();
let status = Command::new(program)
.args(&extra_args)
.arg(&tmp)
.status()
.with_context(|| format!("launching editor `{editor}`"))?;
if !status.success() {
return Err(anyhow!("editor `{editor}` exited with status {status}"));
}
let mut buf = String::new();
fs::File::open(&tmp)?
.read_to_string(&mut buf)
.context("reading edited buffer back from temp file")?;
let _ = fs::remove_file(&tmp);
Ok(buf.trim_end().to_string())
}
/// For body-style arguments. Resolves the value of a `--body` flag with three
/// modes: literal string, `-` for stdin, or `None` to open `$EDITOR` seeded
/// with `template_initial`. Returns `Ok(None)` only if the user closes the
/// editor with an empty buffer AND the field is optional.
pub fn resolve_body(
flag: Option<&str>,
template_name: &str,
template_initial: &str,
) -> Result<Option<String>> {
match flag {
Some("-") => {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
let trimmed = buf.trim_end().to_string();
if trimmed.is_empty() {
Ok(None)
} else {
Ok(Some(trimmed))
}
}
Some(s) => Ok(Some(s.to_string())),
None => {
// If stdin isn't a TTY we can't reasonably open an editor; default
// to "no body" rather than hanging.
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() {
return Ok(None);
}
let text = edit_text(template_name, template_initial)?;
if text.trim().is_empty() {
Ok(None)
} else {
Ok(Some(text))
}
}
}
}
/// Resolve a `--body` flag without launching the editor on omission. Used by
/// commands where the body is optional and we don't want a surprise editor.
pub fn read_body(flag: Option<&str>) -> Result<Option<String>> {
match flag {
None => Ok(None),
Some("-") => {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
Ok(Some(buf.trim_end().to_string()))
}
Some(s) => Ok(Some(s.to_string())),
}
}
/// Prompt for a single line on stderr, returning a trimmed string.
pub fn prompt_line(label: &str) -> Result<String> {
use std::io::{stderr, stdin};
eprint!("{label}: ");
stderr().flush().ok();
let mut buf = String::new();
stdin().read_line(&mut buf).context("reading line")?;
Ok(buf.trim().to_string())
}

View file

@ -5,9 +5,12 @@ use clap::{Args, Subcommand, ValueEnum};
use crate::api; use crate::api;
use crate::api::issue::{CreateIssue, EditIssue, ListOptions, State}; use crate::api::issue::{CreateIssue, EditIssue, ListOptions, State};
use crate::client::Client; use crate::cli::context::{resolve_repo, RepoFlag};
use crate::output; use crate::output;
use super::editor;
use super::web;
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct IssueCmd { pub struct IssueCmd {
#[command(subcommand)] #[command(subcommand)]
@ -22,12 +25,16 @@ pub enum IssueSub {
View(ViewArgs), View(ViewArgs),
/// Create an issue. /// Create an issue.
Create(CreateArgs), Create(CreateArgs),
/// Edit an issue's title, body, labels, or assignees.
Edit(EditArgs),
/// Close an issue. /// Close an issue.
Close(NumberOnly), Close(NumberOnly),
/// Reopen a closed issue. /// Reopen a closed issue.
Reopen(NumberOnly), Reopen(NumberOnly),
/// Add a comment. /// Add a comment.
Comment(CommentArgs), Comment(CommentArgs),
/// Create a branch tied to the issue.
Develop(DevelopArgs),
} }
#[derive(Debug, Clone, Copy, ValueEnum)] #[derive(Debug, Clone, Copy, ValueEnum)]
@ -49,9 +56,8 @@ impl From<StateFilter> for State {
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct ListArgs { pub struct ListArgs {
/// Repository slug `owner/name`. #[command(flatten)]
#[arg(short = 'R', long = "repo")] pub r: RepoFlag,
pub repo: String,
#[arg(short = 's', long, value_enum, default_value_t = StateFilter::Open)] #[arg(short = 's', long, value_enum, default_value_t = StateFilter::Open)]
pub state: StateFilter, pub state: StateFilter,
#[arg(short = 'L', long, default_value_t = 30)] #[arg(short = 'L', long, default_value_t = 30)]
@ -66,61 +72,108 @@ pub struct ListArgs {
pub query: Option<String>, pub query: Option<String>,
#[arg(long)] #[arg(long)]
pub json: bool, pub json: bool,
/// Open the issue list in your browser.
#[arg(long)]
pub web: bool,
} }
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct ViewArgs { pub struct ViewArgs {
#[arg(short = 'R', long = "repo")] #[command(flatten)]
pub repo: String, pub r: RepoFlag,
pub number: u64, pub number: u64,
#[arg(long, default_value_t = false)] #[arg(long, default_value_t = false)]
pub comments: bool, pub comments: bool,
#[arg(long)] #[arg(long)]
pub json: bool, pub json: bool,
/// Open the issue in your browser.
#[arg(long)]
pub web: bool,
} }
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct CreateArgs { pub struct CreateArgs {
#[arg(short = 'R', long = "repo")] #[command(flatten)]
pub repo: String, pub r: RepoFlag,
#[arg(short = 't', long)] #[arg(short = 't', long)]
pub title: String, pub title: Option<String>,
/// Body. Use `-` to read from stdin. /// Body. Use `-` to read from stdin. Omit to open `$EDITOR`.
#[arg(short = 'b', long)] #[arg(short = 'b', long)]
pub body: Option<String>, pub body: Option<String>,
/// Open the new issue in your browser after creating.
#[arg(long)]
pub web: bool,
}
#[derive(Debug, Args)]
pub struct EditArgs {
#[command(flatten)]
pub r: RepoFlag,
pub number: u64,
#[arg(short = 't', long)]
pub title: Option<String>,
/// New body. Use `-` to read from stdin. Pass `--body-editor` to open `$EDITOR`.
#[arg(short = 'b', long)]
pub body: Option<String>,
/// Open `$EDITOR` to edit the body inline (overrides `--body`).
#[arg(long)]
pub body_editor: bool,
} }
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct NumberOnly { pub struct NumberOnly {
#[arg(short = 'R', long = "repo")] #[command(flatten)]
pub repo: String, pub r: RepoFlag,
pub number: u64, pub number: u64,
} }
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct CommentArgs { pub struct CommentArgs {
#[arg(short = 'R', long = "repo")] #[command(flatten)]
pub repo: String, pub r: RepoFlag,
pub number: u64, pub number: u64,
/// Comment body. Use `-` to read from stdin. /// Comment body. Use `-` to read from stdin. Omit to open `$EDITOR`.
#[arg(short = 'b', long)] #[arg(short = 'b', long)]
pub body: String, pub body: Option<String>,
}
#[derive(Debug, Args)]
pub struct DevelopArgs {
#[command(flatten)]
pub r: RepoFlag,
pub number: u64,
/// Branch name. Defaults to `issue-<number>-<slug-of-title>`.
#[arg(short = 'n', long)]
pub name: Option<String>,
/// Base branch to start from. Defaults to the repo's default branch.
#[arg(long)]
pub base: Option<String>,
/// Just print the branch name; don't run git.
#[arg(long)]
pub dry_run: bool,
} }
pub async fn run(cmd: IssueCmd, host: Option<&str>) -> Result<()> { pub async fn run(cmd: IssueCmd, host: Option<&str>) -> Result<()> {
let client = Client::connect(host)?;
match cmd.command { match cmd.command {
IssueSub::List(args) => list(&client, args).await, IssueSub::List(args) => list(args, host).await,
IssueSub::View(args) => view(&client, args).await, IssueSub::View(args) => view(args, host).await,
IssueSub::Create(args) => create(&client, args).await, IssueSub::Create(args) => create(args, host).await,
IssueSub::Close(args) => set_state(&client, args, "closed").await, IssueSub::Edit(args) => edit(args, host).await,
IssueSub::Reopen(args) => set_state(&client, args, "open").await, IssueSub::Close(args) => set_state(args, host, "closed").await,
IssueSub::Comment(args) => comment(&client, args).await, IssueSub::Reopen(args) => set_state(args, host, "open").await,
IssueSub::Comment(args) => comment(args, host).await,
IssueSub::Develop(args) => develop(args, host).await,
} }
} }
async fn list(client: &Client, args: ListArgs) -> Result<()> { async fn list(args: ListArgs, host: Option<&str>) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?; let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
if args.web {
return web::open(&format!(
"https://{}/{}/{}/issues",
ctx.host, ctx.owner, ctx.name
));
}
let opts = ListOptions { let opts = ListOptions {
state: args.state.into(), state: args.state.into(),
limit: args.limit, limit: args.limit,
@ -129,7 +182,7 @@ async fn list(client: &Client, args: ListArgs) -> Result<()> {
assignee: args.assignee.as_deref(), assignee: args.assignee.as_deref(),
query: args.query.as_deref(), query: args.query.as_deref(),
}; };
let page = api::issue::list(client, owner, name, opts).await?; let page = api::issue::list(&ctx.client, &ctx.owner, &ctx.name, opts).await?;
if args.json { if args.json {
return output::print_json(&serde_json::to_value(&page.items)?); return output::print_json(&serde_json::to_value(&page.items)?);
} }
@ -141,28 +194,40 @@ async fn list(client: &Client, args: ListArgs) -> Result<()> {
.items .items
.iter() .iter()
.map(|i| { .map(|i| {
let labels = i
.labels
.iter()
.map(|l| l.name.as_str())
.collect::<Vec<_>>()
.join(", ");
vec![ vec![
format!("#{}", i.number), format!("#{}", i.number),
output::state_pill(&i.state, false), output::state_pill(&i.state, false),
truncate(&i.title, 70), truncate(&i.title, 60),
output::dim(&labels),
output::dim(&format!("{}c", i.comments)),
output::dim(&output::relative_time(i.updated_at)), output::dim(&output::relative_time(i.updated_at)),
] ]
}) })
.collect(); .collect();
print!( print!(
"{}", "{}",
output::render_table(&["", "STATE", "TITLE", "UPDATED"], &rows) output::render_table(&["", "STATE", "TITLE", "LABELS", "💬", "UPDATED"], &rows)
); );
Ok(()) Ok(())
} }
async fn view(client: &Client, args: ViewArgs) -> Result<()> { async fn view(args: ViewArgs, host: Option<&str>) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?; let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let issue = api::issue::get(client, owner, name, args.number).await?; let issue = api::issue::get(&ctx.client, &ctx.owner, &ctx.name, args.number).await?;
if args.web {
return web::open(&issue.html_url);
}
if args.json { if args.json {
let mut v = serde_json::to_value(&issue)?; let mut v = serde_json::to_value(&issue)?;
if args.comments { if args.comments {
let comments = api::issue::list_comments(client, owner, name, args.number).await?; let comments =
api::issue::list_comments(&ctx.client, &ctx.owner, &ctx.name, args.number).await?;
v["comments_list"] = serde_json::to_value(comments)?; v["comments_list"] = serde_json::to_value(comments)?;
} }
return output::print_json(&v); return output::print_json(&v);
@ -181,7 +246,8 @@ async fn view(client: &Client, args: ViewArgs) -> Result<()> {
println!(); println!();
} }
if args.comments { if args.comments {
let comments = api::issue::list_comments(client, owner, name, args.number).await?; let comments =
api::issue::list_comments(&ctx.client, &ctx.owner, &ctx.name, args.number).await?;
if comments.is_empty() { if comments.is_empty() {
println!("{}", output::dim("(no comments)")); println!("{}", output::dim("(no comments)"));
} else { } else {
@ -199,27 +265,55 @@ async fn view(client: &Client, args: ViewArgs) -> Result<()> {
Ok(()) Ok(())
} }
async fn create(client: &Client, args: CreateArgs) -> Result<()> { async fn create(args: CreateArgs, host: Option<&str>) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?; let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let body = read_body(args.body.as_deref())?; let title = match args.title {
Some(t) => t,
None => editor::prompt_line("Title")?,
};
if title.trim().is_empty() {
return Err(anyhow!("title is required"));
}
let body = editor::resolve_body(args.body.as_deref(), "ISSUE_BODY.md", "")?;
let payload = CreateIssue { let payload = CreateIssue {
title: &args.title, title: &title,
body: body.as_deref(), body: body.as_deref(),
assignees: None, assignees: None,
labels: None, labels: None,
}; };
let issue = api::issue::create(client, owner, name, &payload).await?; let issue = api::issue::create(&ctx.client, &ctx.owner, &ctx.name, &payload).await?;
println!("✓ Created issue #{}: {}", issue.number, issue.title); println!("✓ Created issue #{}: {}", issue.number, issue.title);
println!("{}", issue.html_url); println!("{}", issue.html_url);
if args.web {
web::open(&issue.html_url)?;
}
Ok(()) Ok(())
} }
async fn set_state(client: &Client, args: NumberOnly, state: &str) -> Result<()> { async fn edit(args: EditArgs, host: Option<&str>) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?; let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let body = if args.body_editor {
let existing = api::issue::get(&ctx.client, &ctx.owner, &ctx.name, args.number).await?;
Some(editor::edit_text("ISSUE_BODY.md", &existing.body)?)
} else {
editor::read_body(args.body.as_deref())?
};
let patch = EditIssue {
title: args.title.as_deref(),
body: body.as_deref(),
state: None,
};
let issue = api::issue::edit(&ctx.client, &ctx.owner, &ctx.name, args.number, &patch).await?;
println!("✓ Updated #{}: {}", issue.number, issue.title);
Ok(())
}
async fn set_state(args: NumberOnly, host: Option<&str>, state: &str) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let issue = api::issue::edit( let issue = api::issue::edit(
client, &ctx.client,
owner, &ctx.owner,
name, &ctx.name,
args.number, args.number,
&EditIssue { &EditIssue {
state: Some(state), state: Some(state),
@ -235,33 +329,69 @@ async fn set_state(client: &Client, args: NumberOnly, state: &str) -> Result<()>
Ok(()) Ok(())
} }
async fn comment(client: &Client, args: CommentArgs) -> Result<()> { async fn comment(args: CommentArgs, host: Option<&str>) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?; let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let body = if args.body == "-" { let body = match args.body.as_deref() {
Some("-") => {
let mut buf = String::new(); let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?; std::io::stdin().read_to_string(&mut buf)?;
buf buf
} else { }
args.body Some(s) => s.to_string(),
None => editor::edit_text("ISSUE_COMMENT.md", "")?,
}; };
if body.trim().is_empty() { if body.trim().is_empty() {
return Err(anyhow!("comment body is empty")); return Err(anyhow!("comment body is empty"));
} }
let c = api::issue::comment(client, owner, name, args.number, &body).await?; let c = api::issue::comment(&ctx.client, &ctx.owner, &ctx.name, args.number, &body).await?;
println!("✓ Commented on #{} ({})", args.number, c.html_url); println!("✓ Commented on #{} ({})", args.number, c.html_url);
Ok(()) Ok(())
} }
fn read_body(arg: Option<&str>) -> Result<Option<String>> { async fn develop(args: DevelopArgs, host: Option<&str>) -> Result<()> {
match arg { let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
None => Ok(None), let issue = api::issue::get(&ctx.client, &ctx.owner, &ctx.name, args.number).await?;
Some("-") => { let branch = match args.name {
let mut buf = String::new(); Some(n) => n,
std::io::stdin().read_to_string(&mut buf)?; None => format!("issue-{}-{}", issue.number, slugify(&issue.title)),
Ok(Some(buf)) };
let base = match args.base {
Some(b) => b,
None => {
api::repo::get(&ctx.client, &ctx.owner, &ctx.name)
.await?
.default_branch
} }
Some(s) => Ok(Some(s.to_string())), };
println!("Branch: {branch}");
println!("From: {base}");
if args.dry_run {
return Ok(());
} }
crate::git::run(&["fetch", "origin", &base])?;
crate::git::run(&["checkout", "-b", &branch, &format!("origin/{base}")])?;
Ok(())
}
fn slugify(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut prev_dash = false;
for c in s.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
prev_dash = false;
} else if !prev_dash && !out.is_empty() {
out.push('-');
prev_dash = true;
}
}
while out.ends_with('-') {
out.pop();
}
if out.is_empty() {
out.push_str("issue");
}
out.chars().take(40).collect()
} }
fn truncate(s: &str, max: usize) -> String { fn truncate(s: &str, max: usize) -> String {
@ -272,3 +402,29 @@ fn truncate(s: &str, max: usize) -> String {
out.push('…'); out.push('…');
out out
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slug_basic() {
assert_eq!(slugify("Fix login bug"), "fix-login-bug");
}
#[test]
fn slug_strips_repeated_separators() {
assert_eq!(slugify("a -- b"), "a-b");
}
#[test]
fn slug_falls_back_for_empty() {
assert_eq!(slugify("???"), "issue");
}
#[test]
fn slug_caps_length() {
let long = "x".repeat(100);
assert!(slugify(&long).len() <= 40);
}
}

View file

@ -1,8 +1,11 @@
pub mod api; pub mod api;
pub mod auth; pub mod auth;
pub mod context;
pub mod editor;
pub mod issue; pub mod issue;
pub mod pr; pub mod pr;
pub mod repo; pub mod repo;
pub mod web;
use anyhow::Result; use anyhow::Result;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
@ -60,12 +63,7 @@ pub async fn run(cli: Cli) -> Result<()> {
Command::Completion(args) => { Command::Completion(args) => {
use clap::CommandFactory; use clap::CommandFactory;
let mut cmd = Cli::command(); let mut cmd = Cli::command();
clap_complete::generate( clap_complete::generate(args.shell, &mut cmd, "fj", &mut std::io::stdout());
args.shell,
&mut cmd,
"fj",
&mut std::io::stdout(),
);
Ok(()) Ok(())
} }
} }

View file

@ -1,15 +1,19 @@
use std::io::Read; use std::io::Read;
use anyhow::Result; use anyhow::{anyhow, Result};
use clap::{Args, Subcommand, ValueEnum}; use clap::{Args, Subcommand, ValueEnum};
use crate::api; use crate::api;
use crate::api::issue::State; use crate::api::issue::State;
use crate::api::pull::{CreatePull, EditPull, ListOptions, MergeStyle}; use crate::api::pull::{CreatePull, EditPull, ListOptions, MergeStyle, ReviewEvent};
use crate::cli::context::{resolve_repo, RepoFlag};
use crate::client::Client; use crate::client::Client;
use crate::git; use crate::git;
use crate::output; use crate::output;
use super::editor;
use super::web;
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct PrCmd { pub struct PrCmd {
#[command(subcommand)] #[command(subcommand)]
@ -24,12 +28,30 @@ pub enum PrSub {
View(ViewArgs), View(ViewArgs),
/// Create a pull request. /// Create a pull request.
Create(CreateArgs), Create(CreateArgs),
/// Edit a pull request.
Edit(EditArgs),
/// Show the unified diff for a pull request.
Diff(SimpleArgs),
/// List commits in a pull request.
Commits(SimpleArgs),
/// Show the file change list for a pull request.
Files(SimpleArgs),
/// Show CI / commit status checks for a pull request's head SHA.
Checks(SimpleArgs),
/// Convert a draft pull request to ready-for-review.
Ready(SimpleArgs),
/// Submit a review (approve / request changes / comment).
Review(ReviewArgs),
/// Cross-repo dashboard of your PRs: created, review-requested, mentions.
Status(StatusArgs),
/// Check out a pull request locally. /// Check out a pull request locally.
Checkout(CheckoutArgs), Checkout(CheckoutArgs),
/// Merge a pull request. /// Merge a pull request.
Merge(MergeArgs), Merge(MergeArgs),
/// Close a pull request without merging. /// Close a pull request without merging.
Close(NumberOnly), Close(SimpleArgs),
/// Reopen a closed pull request.
Reopen(SimpleArgs),
} }
#[derive(Debug, Clone, Copy, ValueEnum)] #[derive(Debug, Clone, Copy, ValueEnum)]
@ -68,10 +90,27 @@ impl From<MergeStyleArg> for MergeStyle {
} }
} }
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum ReviewEventArg {
Approve,
RequestChanges,
Comment,
}
impl From<ReviewEventArg> for ReviewEvent {
fn from(value: ReviewEventArg) -> Self {
match value {
ReviewEventArg::Approve => ReviewEvent::Approve,
ReviewEventArg::RequestChanges => ReviewEvent::RequestChanges,
ReviewEventArg::Comment => ReviewEvent::Comment,
}
}
}
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct ListArgs { pub struct ListArgs {
#[arg(short = 'R', long = "repo")] #[command(flatten)]
pub repo: String, pub r: RepoFlag,
#[arg(short = 's', long, value_enum, default_value_t = StateFilter::Open)] #[arg(short = 's', long, value_enum, default_value_t = StateFilter::Open)]
pub state: StateFilter, pub state: StateFilter,
#[arg(short = 'L', long, default_value_t = 30)] #[arg(short = 'L', long, default_value_t = 30)]
@ -80,50 +119,96 @@ pub struct ListArgs {
pub page: u32, pub page: u32,
#[arg(long)] #[arg(long)]
pub json: bool, pub json: bool,
#[arg(long)]
pub web: bool,
} }
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct ViewArgs { pub struct ViewArgs {
#[arg(short = 'R', long = "repo")] #[command(flatten)]
pub repo: String, pub r: RepoFlag,
pub number: u64, pub number: u64,
#[arg(long, default_value_t = false)]
pub comments: bool,
#[arg(long)] #[arg(long)]
pub json: bool, pub json: bool,
#[arg(long)]
pub web: bool,
} }
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct CreateArgs { pub struct CreateArgs {
#[arg(short = 'R', long = "repo")] #[command(flatten)]
pub repo: String, pub r: RepoFlag,
#[arg(short = 't', long)] #[arg(short = 't', long)]
pub title: String, pub title: Option<String>,
#[arg(short = 'B', long, default_value = "main")] #[arg(short = 'B', long, default_value = "main")]
pub base: String, pub base: String,
/// Head branch. Either a branch name on the same repo, or `owner:branch`. /// Head branch. Either a branch name on the same repo, or `owner:branch`.
#[arg(short = 'H', long)] #[arg(short = 'H', long)]
pub head: String, pub head: Option<String>,
/// Body. Use `-` to read from stdin. /// Body. Use `-` to read from stdin. Omit to open `$EDITOR`.
#[arg(short = 'b', long)]
pub body: Option<String>,
/// Mark the PR as draft.
#[arg(long)]
pub draft: bool,
/// Open the new PR in your browser after creating.
#[arg(long)]
pub web: bool,
}
#[derive(Debug, Args)]
pub struct EditArgs {
#[command(flatten)]
pub r: RepoFlag,
pub number: u64,
#[arg(short = 't', long)]
pub title: Option<String>,
#[arg(short = 'b', long)]
pub body: Option<String>,
/// Open `$EDITOR` for the body (replaces the current body).
#[arg(long)]
pub body_editor: bool,
}
#[derive(Debug, Args)]
pub struct ReviewArgs {
#[command(flatten)]
pub r: RepoFlag,
pub number: u64,
/// Review action.
#[arg(value_enum, long, short = 'e')]
pub event: ReviewEventArg,
/// Review body. Use `-` for stdin. Omit to open `$EDITOR` (unless the
/// event is `approve` and you have nothing to say).
#[arg(short = 'b', long)] #[arg(short = 'b', long)]
pub body: Option<String>, pub body: Option<String>,
} }
#[derive(Debug, Args)]
pub struct StatusArgs {
#[arg(short = 'L', long, default_value_t = 20)]
pub limit: u32,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct CheckoutArgs { pub struct CheckoutArgs {
#[arg(short = 'R', long = "repo")] #[command(flatten)]
pub repo: String, pub r: RepoFlag,
pub number: u64, pub number: u64,
/// Override the local branch name.
#[arg(short = 'b', long)] #[arg(short = 'b', long)]
pub branch: Option<String>, pub branch: Option<String>,
/// Git remote to fetch from.
#[arg(long, default_value = "origin")] #[arg(long, default_value = "origin")]
pub remote: String, pub remote: String,
} }
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct MergeArgs { pub struct MergeArgs {
#[arg(short = 'R', long = "repo")] #[command(flatten)]
pub repo: String, pub r: RepoFlag,
pub number: u64, pub number: u64,
#[arg(long, value_enum, default_value_t = MergeStyleArg::Merge)] #[arg(long, value_enum, default_value_t = MergeStyleArg::Merge)]
pub style: MergeStyleArg, pub style: MergeStyleArg,
@ -134,30 +219,44 @@ pub struct MergeArgs {
} }
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct NumberOnly { pub struct SimpleArgs {
#[arg(short = 'R', long = "repo")] #[command(flatten)]
pub repo: String, pub r: RepoFlag,
pub number: u64, pub number: u64,
} }
pub async fn run(cmd: PrCmd, host: Option<&str>) -> Result<()> { pub async fn run(cmd: PrCmd, host: Option<&str>) -> Result<()> {
let client = Client::connect(host)?;
match cmd.command { match cmd.command {
PrSub::List(args) => list(&client, args).await, PrSub::List(args) => list(args, host).await,
PrSub::View(args) => view(&client, args).await, PrSub::View(args) => view(args, host).await,
PrSub::Create(args) => create(&client, args).await, PrSub::Create(args) => create(args, host).await,
PrSub::Checkout(args) => checkout(&client, args).await, PrSub::Edit(args) => edit(args, host).await,
PrSub::Merge(args) => merge(&client, args).await, PrSub::Diff(args) => diff(args, host).await,
PrSub::Close(args) => close(&client, args).await, PrSub::Commits(args) => commits(args, host).await,
PrSub::Files(args) => files(args, host).await,
PrSub::Checks(args) => checks(args, host).await,
PrSub::Ready(args) => ready(args, host).await,
PrSub::Review(args) => review(args, host).await,
PrSub::Status(args) => status(args, host).await,
PrSub::Checkout(args) => checkout(args, host).await,
PrSub::Merge(args) => merge(args, host).await,
PrSub::Close(args) => set_state(args, host, "closed").await,
PrSub::Reopen(args) => set_state(args, host, "open").await,
} }
} }
async fn list(client: &Client, args: ListArgs) -> Result<()> { async fn list(args: ListArgs, host: Option<&str>) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?; let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
if args.web {
return web::open(&format!(
"https://{}/{}/{}/pulls",
ctx.host, ctx.owner, ctx.name
));
}
let page = api::pull::list( let page = api::pull::list(
client, &ctx.client,
owner, &ctx.owner,
name, &ctx.name,
ListOptions { ListOptions {
state: args.state.into(), state: args.state.into(),
limit: args.limit, limit: args.limit,
@ -179,8 +278,12 @@ async fn list(client: &Client, args: ListArgs) -> Result<()> {
vec![ vec![
format!("#{}", p.number), format!("#{}", p.number),
output::state_pill(&p.state, p.merged), output::state_pill(&p.state, p.merged),
truncate(&p.title, 60), truncate(&p.title, 55),
output::dim(&format!("{}{}", branch_label(&p.head), branch_label(&p.base))), output::dim(&format!(
"{} → {}",
branch_label(&p.head),
branch_label(&p.base)
)),
output::dim(&output::relative_time(p.updated_at)), output::dim(&output::relative_time(p.updated_at)),
] ]
}) })
@ -192,11 +295,23 @@ async fn list(client: &Client, args: ListArgs) -> Result<()> {
Ok(()) Ok(())
} }
async fn view(client: &Client, args: ViewArgs) -> Result<()> { async fn view(args: ViewArgs, host: Option<&str>) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?; let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let pr = api::pull::get(client, owner, name, args.number).await?; let pr = api::pull::get(&ctx.client, &ctx.owner, &ctx.name, args.number).await?;
if args.web {
return web::open(&pr.html_url);
}
if args.json { if args.json {
return output::print_json(&serde_json::to_value(&pr)?); let mut v = serde_json::to_value(&pr)?;
if args.comments {
let comments =
api::issue::list_comments(&ctx.client, &ctx.owner, &ctx.name, args.number).await?;
v["comments_list"] = serde_json::to_value(comments)?;
let reviews =
api::pull::list_reviews(&ctx.client, &ctx.owner, &ctx.name, args.number).await?;
v["reviews"] = serde_json::to_value(reviews)?;
}
return output::print_json(&v);
} }
println!( println!(
"{} {} {}", "{} {} {}",
@ -218,39 +333,319 @@ async fn view(client: &Client, args: ViewArgs) -> Result<()> {
println!(); println!();
println!("{}", pr.body); println!("{}", pr.body);
} }
if args.comments {
println!();
let reviews =
api::pull::list_reviews(&ctx.client, &ctx.owner, &ctx.name, args.number).await?;
for r in reviews {
let when = r
.submitted_at
.map(output::relative_time)
.unwrap_or_else(|| "".into());
println!(
"── {} {} {}",
output::bold(&r.user.login),
output::state_pill(&r.state.to_lowercase(), false),
output::dim(&when),
);
if !r.body.is_empty() {
println!("{}", r.body);
println!();
}
}
let comments =
api::issue::list_comments(&ctx.client, &ctx.owner, &ctx.name, args.number).await?;
for c in comments {
println!(
"── {} {}",
output::bold(&c.user.login),
output::dim(&output::relative_time(c.created_at)),
);
println!("{}", c.body);
println!();
}
}
Ok(()) Ok(())
} }
async fn create(client: &Client, args: CreateArgs) -> Result<()> { async fn create(args: CreateArgs, host: Option<&str>) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?; let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let body = match args.body.as_deref() { let head = match args.head {
Some("-") => { Some(h) => h,
let mut buf = String::new(); None => detect_current_branch()
std::io::stdin().read_to_string(&mut buf)?; .ok_or_else(|| anyhow!("could not determine head branch; pass --head"))?,
Some(buf)
}
other => other.map(|s| s.to_string()),
}; };
let title = match args.title {
Some(t) => t,
None => editor::prompt_line("Title")?,
};
if title.trim().is_empty() {
return Err(anyhow!("title is required"));
}
let body = editor::resolve_body(args.body.as_deref(), "PR_BODY.md", "")?;
let pr = api::pull::create( let pr = api::pull::create(
client, &ctx.client,
owner, &ctx.owner,
name, &ctx.name,
&CreatePull { &CreatePull {
title: &args.title, title: &title,
head: &args.head, head: &head,
base: &args.base, base: &args.base,
body: body.as_deref(), body: body.as_deref(),
draft: args.draft,
}, },
) )
.await?; .await?;
println!("✓ Created PR #{}: {}", pr.number, pr.title); println!("✓ Created PR #{}: {}", pr.number, pr.title);
println!("{}", pr.html_url); println!("{}", pr.html_url);
if args.web {
web::open(&pr.html_url)?;
}
Ok(()) Ok(())
} }
async fn checkout(client: &Client, args: CheckoutArgs) -> Result<()> { async fn edit(args: EditArgs, host: Option<&str>) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?; let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let pr = api::pull::get(client, owner, name, args.number).await?; let body = if args.body_editor {
let existing = api::pull::get(&ctx.client, &ctx.owner, &ctx.name, args.number).await?;
Some(editor::edit_text("PR_BODY.md", &existing.body)?)
} else {
editor::read_body(args.body.as_deref())?
};
let pr = api::pull::edit(
&ctx.client,
&ctx.owner,
&ctx.name,
args.number,
&EditPull {
title: args.title.as_deref(),
body: body.as_deref(),
state: None,
},
)
.await?;
println!("✓ Updated PR #{}: {}", pr.number, pr.title);
Ok(())
}
async fn diff(args: SimpleArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let text = api::pull::diff_text(&ctx.client, &ctx.owner, &ctx.name, args.number).await?;
print!("{text}");
Ok(())
}
async fn commits(args: SimpleArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let cs = api::pull::commits(&ctx.client, &ctx.owner, &ctx.name, args.number).await?;
let rows: Vec<Vec<String>> = cs
.iter()
.map(|c| {
let short = c.sha.get(..7).unwrap_or(&c.sha).to_string();
let subject = c.commit.message.lines().next().unwrap_or("").to_string();
let author = c
.commit
.author
.as_ref()
.map(|a| a.name.clone())
.unwrap_or_default();
vec![short, truncate(&subject, 70), output::dim(&author)]
})
.collect();
if rows.is_empty() {
println!("(no commits)");
} else {
print!(
"{}",
output::render_table(&["SHA", "SUBJECT", "AUTHOR"], &rows)
);
}
Ok(())
}
async fn files(args: SimpleArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let fs = api::pull::files(&ctx.client, &ctx.owner, &ctx.name, args.number).await?;
let rows: Vec<Vec<String>> = fs
.iter()
.map(|f| {
vec![
f.status.clone(),
f.filename.clone(),
output::dim(&format!("+{} -{}", f.additions, f.deletions)),
]
})
.collect();
if rows.is_empty() {
println!("(no file changes)");
} else {
print!(
"{}",
output::render_table(&["STATUS", "FILE", "DIFF"], &rows)
);
}
Ok(())
}
async fn checks(args: SimpleArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let pr = api::pull::get(&ctx.client, &ctx.owner, &ctx.name, args.number).await?;
let cs = api::pull::combined_status(&ctx.client, &ctx.owner, &ctx.name, &pr.head.sha).await?;
println!(
"{} {}",
output::bold(&format!(
"Combined: {}",
if cs.state.is_empty() {
""
} else {
&cs.state
}
)),
output::dim(&format!(
"{} checks on {}",
cs.total_count,
&pr.head.sha[..7.min(pr.head.sha.len())]
)),
);
if cs.statuses.is_empty() {
println!("(no per-check statuses reported)");
return Ok(());
}
let rows: Vec<Vec<String>> = cs
.statuses
.iter()
.map(|s| {
let when = s
.updated_at
.map(output::relative_time)
.unwrap_or_else(|| "".into());
vec![
output::state_pill(&s.status.to_lowercase(), false),
s.context.clone(),
truncate(&s.description, 50),
output::dim(&when),
]
})
.collect();
print!(
"{}",
output::render_table(&["STATE", "CHECK", "DESCRIPTION", "WHEN"], &rows)
);
Ok(())
}
async fn ready(args: SimpleArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
api::pull::ready(&ctx.client, &ctx.owner, &ctx.name, args.number).await?;
println!("✓ PR #{} is ready for review", args.number);
Ok(())
}
async fn review(args: ReviewArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let body_required = matches!(args.event, ReviewEventArg::RequestChanges);
let body = match args.body.as_deref() {
Some("-") => {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
Some(buf.trim_end().to_string())
}
Some(s) => Some(s.to_string()),
None => {
if body_required {
Some(editor::edit_text("PR_REVIEW.md", "")?)
} else {
None
}
}
};
let body = body.filter(|s| !s.trim().is_empty());
let r = api::pull::submit_review(
&ctx.client,
&ctx.owner,
&ctx.name,
args.number,
args.event.into(),
body.as_deref(),
)
.await?;
println!("✓ Submitted {} review on #{}", r.state, args.number);
println!("{}", r.html_url);
Ok(())
}
async fn status(args: StatusArgs, host: Option<&str>) -> Result<()> {
// Cross-repo dashboard: PRs authored by you, and PRs where you're listed
// as a reviewer or where you're mentioned.
let client = Client::connect(host)?;
let me = api::user::current(&client).await?;
let limit = args.limit.clamp(1, 50);
let q_authored: Vec<(String, String)> = vec![
("type".into(), "pulls".into()),
("state".into(), "open".into()),
("created_by".into(), me.login.clone()),
("limit".into(), limit.to_string()),
];
let q_review: Vec<(String, String)> = vec![
("type".into(), "pulls".into()),
("state".into(), "open".into()),
("reviewed_by".into(), me.login.clone()),
("limit".into(), limit.to_string()),
];
let q_mentioned: Vec<(String, String)> = vec![
("type".into(), "pulls".into()),
("state".into(), "open".into()),
("mentioned_by".into(), me.login.clone()),
("limit".into(), limit.to_string()),
];
let path = "/api/v1/repos/issues/search";
let authored: Vec<api::issue::Issue> = client
.json(reqwest::Method::GET, path, &q_authored, None::<&()>)
.await?;
let review_requested: Vec<api::issue::Issue> = client
.json(reqwest::Method::GET, path, &q_review, None::<&()>)
.await?;
let mentioned: Vec<api::issue::Issue> = client
.json(reqwest::Method::GET, path, &q_mentioned, None::<&()>)
.await?;
if args.json {
return output::print_json(&serde_json::json!({
"authored": authored,
"review_requested": review_requested,
"mentioned": mentioned,
}));
}
print_status_section("Created by you", &authored);
print_status_section("Requesting your review", &review_requested);
print_status_section("Mentioning you", &mentioned);
Ok(())
}
fn print_status_section(label: &str, items: &[api::issue::Issue]) {
println!("{}", output::bold(label));
if items.is_empty() {
println!(" (none)");
println!();
return;
}
for i in items {
println!(
" #{} {} {}",
i.number,
truncate(&i.title, 70),
output::dim(&i.html_url)
);
}
println!();
}
async fn checkout(args: CheckoutArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
let pr = api::pull::get(&ctx.client, &ctx.owner, &ctx.name, args.number).await?;
let branch = args.branch.unwrap_or_else(|| format!("pr-{}", pr.number)); let branch = args.branch.unwrap_or_else(|| format!("pr-{}", pr.number));
git::fetch_pr(&args.remote, pr.number, &branch)?; git::fetch_pr(&args.remote, pr.number, &branch)?;
git::checkout(&branch)?; git::checkout(&branch)?;
@ -258,12 +653,12 @@ async fn checkout(client: &Client, args: CheckoutArgs) -> Result<()> {
Ok(()) Ok(())
} }
async fn merge(client: &Client, args: MergeArgs) -> Result<()> { async fn merge(args: MergeArgs, host: Option<&str>) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?; let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
api::pull::merge( api::pull::merge(
client, &ctx.client,
owner, &ctx.owner,
name, &ctx.name,
args.number, args.number,
args.style.into(), args.style.into(),
args.title.as_deref(), args.title.as_deref(),
@ -274,20 +669,24 @@ async fn merge(client: &Client, args: MergeArgs) -> Result<()> {
Ok(()) Ok(())
} }
async fn close(client: &Client, args: NumberOnly) -> Result<()> { async fn set_state(args: SimpleArgs, host: Option<&str>, state: &str) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?; let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
api::pull::edit( api::pull::edit(
client, &ctx.client,
owner, &ctx.owner,
name, &ctx.name,
args.number, args.number,
&EditPull { &EditPull {
state: Some("closed"), state: Some(state),
..Default::default() ..Default::default()
}, },
) )
.await?; .await?;
println!("✓ Closed PR #{}", args.number); println!(
"✓ PR #{} is now {}",
args.number,
output::state_pill(state, false)
);
Ok(()) Ok(())
} }
@ -306,6 +705,23 @@ fn branch_label(b: &crate::api::pull::Branch) -> String {
.to_string() .to_string()
} }
fn detect_current_branch() -> Option<String> {
use std::process::Command;
let out = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let name = String::from_utf8_lossy(&out.stdout).trim().to_string();
if name.is_empty() || name == "HEAD" {
None
} else {
Some(name)
}
}
fn truncate(s: &str, max: usize) -> String { fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max { if s.chars().count() <= max {
return s.to_string(); return s.to_string();
@ -315,3 +731,46 @@ fn truncate(s: &str, max: usize) -> String {
out out
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::api::pull::Branch;
fn br(label: &str, r: &str) -> Branch {
Branch {
label: label.into(),
ref_: r.into(),
sha: String::new(),
}
}
#[test]
fn prefers_label_when_present() {
let b = br("feature/foo", "refs/pull/3/head");
assert_eq!(branch_label(&b), "feature/foo");
}
#[test]
fn strips_refs_heads_when_no_label() {
let b = br("", "refs/heads/main");
assert_eq!(branch_label(&b), "main");
}
#[test]
fn passes_through_synthetic_when_no_label_no_prefix() {
let b = br("", "refs/pull/7/head");
assert_eq!(branch_label(&b), "refs/pull/7/head");
}
#[test]
fn truncate_short_string_untouched() {
assert_eq!(truncate("hello", 10), "hello");
}
#[test]
fn truncate_long_string_gets_ellipsis() {
let out = truncate("abcdefghij", 5);
assert_eq!(out.chars().count(), 5);
assert!(out.ends_with('…'));
}
}

View file

@ -1,12 +1,16 @@
use anyhow::Result; use anyhow::{anyhow, Result};
use clap::{Args, Subcommand}; use clap::{Args, Subcommand};
use crate::api; use crate::api;
use crate::cli::context::{resolve_repo, RepoFlag};
use crate::client::Client; use crate::client::Client;
use crate::config::hosts::Hosts; use crate::config::hosts::Hosts;
use crate::git; use crate::git;
use crate::output; use crate::output;
use super::editor;
use super::web;
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct RepoCmd { pub struct RepoCmd {
#[command(subcommand)] #[command(subcommand)]
@ -23,6 +27,24 @@ pub enum RepoSub {
Clone(CloneArgs), Clone(CloneArgs),
/// Create a new repository. /// Create a new repository.
Create(CreateArgs), Create(CreateArgs),
/// Fork a repository.
Fork(ForkArgs),
/// Sync a fork with its upstream default branch.
Sync(SyncArgs),
/// Edit a repository's description, visibility, default branch.
Edit(EditArgs),
/// Rename a repository (in-place).
Rename(RenameArgs),
/// Archive a repository (read-only).
Archive(ArchiveArgs),
/// Unarchive a repository.
Unarchive(ArchiveArgs),
/// Delete a repository. Destructive.
Delete(DeleteArgs),
/// List branches.
Branches(BranchesArgs),
/// Manage repo topics (tags).
Topics(TopicsArgs),
} }
#[derive(Debug, Args)] #[derive(Debug, Args)]
@ -41,10 +63,13 @@ pub struct ListArgs {
#[derive(Debug, Args)] #[derive(Debug, Args)]
pub struct ViewArgs { pub struct ViewArgs {
/// `owner/name` slug. /// `owner/name` slug. Inferred from the git remote when omitted.
pub repo: String, pub repo: Option<String>,
#[arg(long)] #[arg(long)]
pub json: bool, pub json: bool,
/// Open the repo's web page.
#[arg(long)]
pub web: bool,
} }
#[derive(Debug, Args)] #[derive(Debug, Args)]
@ -65,28 +90,117 @@ pub struct CreateArgs {
pub private: bool, pub private: bool,
#[arg(long, default_value_t = false)] #[arg(long, default_value_t = false)]
pub init: bool, pub init: bool,
/// Clone the new repo into the current directory.
#[arg(long)]
pub clone: bool,
/// Open the new repo in your browser.
#[arg(long)]
pub web: bool,
}
#[derive(Debug, Args)]
pub struct ForkArgs {
/// Source repo `owner/name`. Inferred when omitted.
pub repo: Option<String>,
/// Place the fork under this organization instead of your user account.
#[arg(long)]
pub org: Option<String>,
/// New repository name.
#[arg(long)]
pub name: Option<String>,
/// Clone the fork after creation.
#[arg(long)]
pub clone: bool,
}
#[derive(Debug, Args)]
pub struct SyncArgs {
pub repo: Option<String>,
/// Branch to sync. Defaults to the repo's default branch.
#[arg(long)]
pub branch: Option<String>,
}
#[derive(Debug, Args)]
pub struct EditArgs {
pub repo: Option<String>,
#[arg(long)]
pub description: Option<String>,
#[arg(long)]
pub website: Option<String>,
#[arg(long)]
pub default_branch: Option<String>,
/// Force private/public. Accepts `true` or `false`.
#[arg(long)]
pub private: Option<bool>,
/// Open `$EDITOR` for the description.
#[arg(long)]
pub description_editor: bool,
}
#[derive(Debug, Args)]
pub struct RenameArgs {
pub repo: String,
pub new_name: String,
}
#[derive(Debug, Args)]
pub struct ArchiveArgs {
pub repo: Option<String>,
}
#[derive(Debug, Args)]
pub struct DeleteArgs {
pub repo: String,
/// Skip the confirmation prompt.
#[arg(short = 'y', long)]
pub yes: bool,
}
#[derive(Debug, Args)]
pub struct BranchesArgs {
pub repo: Option<String>,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct TopicsArgs {
pub repo: Option<String>,
/// Set the topic list (comma-separated). Omit to just print.
#[arg(long)]
pub set: Option<String>,
} }
pub async fn run(cmd: RepoCmd, host: Option<&str>) -> Result<()> { pub async fn run(cmd: RepoCmd, host: Option<&str>) -> Result<()> {
let client = Client::connect(host)?;
match cmd.command { match cmd.command {
RepoSub::List(args) => list(&client, args).await, RepoSub::List(args) => list(args, host).await,
RepoSub::View(args) => view(&client, args).await, RepoSub::View(args) => view(args, host).await,
RepoSub::Clone(args) => clone(&client, host, args).await, RepoSub::Clone(args) => clone(args, host).await,
RepoSub::Create(args) => create(&client, args).await, RepoSub::Create(args) => create(args, host).await,
RepoSub::Fork(args) => fork(args, host).await,
RepoSub::Sync(args) => sync(args, host).await,
RepoSub::Edit(args) => edit(args, host).await,
RepoSub::Rename(args) => rename(args, host).await,
RepoSub::Archive(args) => set_archived(args, host, true).await,
RepoSub::Unarchive(args) => set_archived(args, host, false).await,
RepoSub::Delete(args) => delete(args, host).await,
RepoSub::Branches(args) => branches(args, host).await,
RepoSub::Topics(args) => topics(args, host).await,
} }
} }
async fn list(client: &Client, args: ListArgs) -> Result<()> { async fn list(args: ListArgs, host: Option<&str>) -> Result<()> {
let client = Client::connect(host)?;
let opts = api::repo::ListOptions { let opts = api::repo::ListOptions {
limit: args.limit, limit: args.limit,
page: args.page, page: args.page,
query: args.search.as_deref(), query: args.search.as_deref(),
}; };
let page = if args.search.is_some() { let page = if args.search.is_some() {
api::repo::search(client, opts).await? api::repo::search(&client, opts).await?
} else { } else {
api::repo::list_for_user(client, opts).await? api::repo::list_for_user(&client, opts).await?
}; };
if args.json { if args.json {
let v = serde_json::to_value(&page.items)?; let v = serde_json::to_value(&page.items)?;
@ -116,15 +230,23 @@ async fn list(client: &Client, args: ListArgs) -> Result<()> {
Ok(()) Ok(())
} }
async fn view(client: &Client, args: ViewArgs) -> Result<()> { async fn view(args: ViewArgs, host: Option<&str>) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?; let ctx = resolve_repo(args.repo.as_deref(), host)?;
let repo = api::repo::get(client, owner, name).await?; let repo = api::repo::get(&ctx.client, &ctx.owner, &ctx.name).await?;
if args.web {
return web::open(&repo.html_url);
}
if args.json { if args.json {
return output::print_json(&serde_json::to_value(&repo)?); return output::print_json(&serde_json::to_value(&repo)?);
} }
let header = output::bold(&repo.full_name); let header = output::bold(&repo.full_name);
let vis = if repo.private { "private" } else { "public" }; let vis = if repo.private { "private" } else { "public" };
println!("{header} {}", output::dim(vis)); let extra = if repo.archived {
format!(" {}", output::dim("(archived)"))
} else {
String::new()
};
println!("{header} {}{extra}", output::dim(vis));
if !repo.description.is_empty() { if !repo.description.is_empty() {
println!("{}", repo.description); println!("{}", repo.description);
} }
@ -133,7 +255,10 @@ async fn view(client: &Client, args: ViewArgs) -> Result<()> {
println!("Stars: {}", repo.stars_count); println!("Stars: {}", repo.stars_count);
println!("Forks: {}", repo.forks_count); println!("Forks: {}", repo.forks_count);
println!("Open issues: {}", repo.open_issues_count); println!("Open issues: {}", repo.open_issues_count);
println!("Updated: {}", output::relative_time(repo.updated_at)); println!(
"Updated: {}",
output::relative_time(repo.updated_at)
);
println!(); println!();
println!("URL: {}", repo.html_url); println!("URL: {}", repo.html_url);
println!("Clone URL: {}", repo.clone_url); println!("Clone URL: {}", repo.clone_url);
@ -141,11 +266,12 @@ async fn view(client: &Client, args: ViewArgs) -> Result<()> {
Ok(()) Ok(())
} }
async fn clone(client: &Client, host_flag: Option<&str>, args: CloneArgs) -> Result<()> { async fn clone(args: CloneArgs, host: Option<&str>) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?; let (owner, name) = api::split_repo(&args.repo)?;
let repo = api::repo::get(client, owner, name).await?; let client = Client::connect(host)?;
let repo = api::repo::get(&client, owner, name).await?;
let hosts = Hosts::load()?; let hosts = Hosts::load()?;
let hostname = hosts.resolve_host(host_flag)?; let hostname = hosts.resolve_host(host)?;
let proto = hosts let proto = hosts
.hosts .hosts
.get(hostname) .get(hostname)
@ -159,7 +285,8 @@ async fn clone(client: &Client, host_flag: Option<&str>, args: CloneArgs) -> Res
git::clone(&url, args.dir.as_deref()) git::clone(&url, args.dir.as_deref())
} }
async fn create(client: &Client, args: CreateArgs) -> Result<()> { async fn create(args: CreateArgs, host: Option<&str>) -> Result<()> {
let client = Client::connect(host)?;
let (owner, name) = match args.repo.split_once('/') { let (owner, name) = match args.repo.split_once('/') {
Some((o, n)) => (Some(o.to_string()), n.to_string()), Some((o, n)) => (Some(o.to_string()), n.to_string()),
None => (None, args.repo.clone()), None => (None, args.repo.clone()),
@ -172,20 +299,199 @@ async fn create(client: &Client, args: CreateArgs) -> Result<()> {
auto_init: args.init, auto_init: args.init,
}; };
let repo = match owner { let repo = match owner {
Some(o) => { Some(o) => match api::repo::create_for_org(&client, &o, &body).await {
// Try as org first; if 404, fall through to user-namespaced.
match api::repo::create_for_org(client, &o, &body).await {
Ok(r) => r, Ok(r) => r,
Err(_) => api::repo::create_for_current_user(client, &body).await?, Err(_) => api::repo::create_for_current_user(&client, &body).await?,
} },
} None => api::repo::create_for_current_user(&client, &body).await?,
None => api::repo::create_for_current_user(client, &body).await?,
}; };
println!("✓ Created {}", repo.full_name); println!("✓ Created {}", repo.full_name);
println!("{}", repo.html_url); println!("{}", repo.html_url);
if args.clone {
let hosts = Hosts::load()?;
let hostname = hosts.resolve_host(host)?;
let proto = hosts
.hosts
.get(hostname)
.map(|h| h.git_protocol.clone())
.unwrap_or_else(|| "https".into());
let url = if proto == "ssh" {
repo.ssh_url.clone()
} else {
repo.clone_url.clone()
};
git::clone(&url, None)?;
}
if args.web {
web::open(&repo.html_url)?;
}
Ok(()) Ok(())
} }
async fn fork(args: ForkArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.repo.as_deref(), host)?;
let r = api::repo::fork(
&ctx.client,
&ctx.owner,
&ctx.name,
args.org.as_deref(),
args.name.as_deref(),
)
.await?;
println!("✓ Forked to {}", r.full_name);
println!("{}", r.html_url);
if args.clone {
let hosts = Hosts::load()?;
let hostname = hosts.resolve_host(host)?;
let proto = hosts
.hosts
.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 };
git::clone(&url, None)?;
}
Ok(())
}
async fn sync(args: SyncArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.repo.as_deref(), host)?;
let branch = match args.branch {
Some(b) => b,
None => {
api::repo::get(&ctx.client, &ctx.owner, &ctx.name)
.await?
.default_branch
}
};
api::repo::sync_with_upstream(&ctx.client, &ctx.owner, &ctx.name, &branch).await?;
println!("✓ Synced {}/{} branch {} with upstream", ctx.owner, ctx.name, branch);
Ok(())
}
async fn edit(args: EditArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.repo.as_deref(), host)?;
let description = if args.description_editor {
let existing = api::repo::get(&ctx.client, &ctx.owner, &ctx.name).await?;
Some(editor::edit_text(
"REPO_DESCRIPTION.md",
&existing.description,
)?)
} else {
args.description
};
let body = api::repo::EditRepo {
description: description.as_deref(),
website: args.website.as_deref(),
private: args.private,
default_branch: args.default_branch.as_deref(),
archived: None,
};
let r = api::repo::edit(&ctx.client, &ctx.owner, &ctx.name, &body).await?;
println!("✓ Updated {}", r.full_name);
Ok(())
}
async fn rename(args: RenameArgs, host: Option<&str>) -> Result<()> {
let (owner, name) = api::split_repo(&args.repo)?;
let client = Client::connect(host)?;
api::repo::rename(&client, owner, name, &args.new_name).await?;
println!("✓ Renamed {}/{}{}/{}", owner, name, owner, args.new_name);
Ok(())
}
async fn set_archived(args: ArchiveArgs, host: Option<&str>, archived: bool) -> Result<()> {
let ctx = resolve_repo(args.repo.as_deref(), host)?;
let body = api::repo::EditRepo {
archived: Some(archived),
..Default::default()
};
api::repo::edit(&ctx.client, &ctx.owner, &ctx.name, &body).await?;
println!(
"✓ {} {}/{}",
if archived { "Archived" } else { "Unarchived" },
ctx.owner,
ctx.name
);
Ok(())
}
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 answer = editor::prompt_line(&prompt)?;
if answer != args.repo {
return Err(anyhow!("aborted: slug did not match"));
}
}
api::repo::delete(&client, owner, name).await?;
println!("✓ Deleted {owner}/{name}");
Ok(())
}
async fn branches(args: BranchesArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.repo.as_deref(), host)?;
let bs = api::repo::list_branches(&ctx.client, &ctx.owner, &ctx.name).await?;
if args.json {
return output::print_json(&serde_json::to_value(&bs)?);
}
if bs.is_empty() {
println!("(no branches)");
return Ok(());
}
let rows: Vec<Vec<String>> = bs
.iter()
.map(|b| {
vec![
b.name.clone(),
output::dim(&b.commit.id[..7.min(b.commit.id.len())]),
truncate(b.commit.message.lines().next().unwrap_or(""), 60),
if b.protected {
output::bold("protected")
} else {
String::new()
},
]
})
.collect();
print!(
"{}",
output::render_table(&["BRANCH", "SHA", "SUBJECT", ""], &rows)
);
Ok(())
}
async fn topics(args: TopicsArgs, host: Option<&str>) -> Result<()> {
let ctx = resolve_repo(args.repo.as_deref(), host)?;
if let Some(set) = args.set {
let list: Vec<String> = set
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
api::repo::set_topics(&ctx.client, &ctx.owner, &ctx.name, &list).await?;
println!("✓ Topics set to: {}", list.join(", "));
return Ok(());
}
let topics = api::repo::list_topics(&ctx.client, &ctx.owner, &ctx.name).await?;
if topics.is_empty() {
println!("(no topics)");
} else {
for t in topics {
println!("{t}");
}
}
Ok(())
}
// Silence the unused `RepoFlag` import for now — kept for symmetry with other
// subcommands and exported through `cli::context`.
const _: fn() -> Option<RepoFlag> = || None;
fn truncate(s: &str, max: usize) -> String { fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max { if s.chars().count() <= max {
return s.to_string(); return s.to_string();

38
src/cli/web.rs Normal file
View file

@ -0,0 +1,38 @@
//! Open URLs in the user's default browser, like `gh ... --web`.
use std::process::Command;
use anyhow::{anyhow, Context, Result};
pub fn open(url: &str) -> Result<()> {
let (program, args) = picker();
let status = Command::new(program)
.args(args)
.arg(url)
.status()
.with_context(|| format!("launching `{program}` to open URL"))?;
if !status.success() {
return Err(anyhow!("browser open command exited with status {status}"));
}
Ok(())
}
#[cfg(target_os = "macos")]
fn picker() -> (&'static str, &'static [&'static str]) {
("open", &[])
}
#[cfg(target_os = "linux")]
fn picker() -> (&'static str, &'static [&'static str]) {
("xdg-open", &[])
}
#[cfg(target_os = "windows")]
fn picker() -> (&'static str, &'static [&'static str]) {
("cmd", &["/C", "start"])
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn picker() -> (&'static str, &'static [&'static str]) {
("xdg-open", &[])
}

View file

@ -120,13 +120,26 @@ impl Client {
path: &str, path: &str,
query: &[(String, String)], query: &[(String, String)],
body: Option<&serde_json::Value>, body: Option<&serde_json::Value>,
) -> Result<Response> {
self.request_with_headers(method, path, query, body, &HeaderMap::new())
.await
}
/// Like `request` but merges `extra` headers in (they override defaults).
pub async fn request_with_headers(
&self,
method: Method,
path: &str,
query: &[(String, String)],
body: Option<&serde_json::Value>,
extra: &HeaderMap,
) -> Result<Response> { ) -> Result<Response> {
let url = self.url(path)?; let url = self.url(path)?;
let mut req = self let mut headers = self.auth_headers();
.http for (k, v) in extra.iter() {
.request(method, url) headers.insert(k.clone(), v.clone());
.headers(self.auth_headers()) }
.query(query); let mut req = self.http.request(method, url).headers(headers).query(query);
if let Some(body) = body { if let Some(body) = body {
req = req.json(body); req = req.json(body);
} }
@ -154,7 +167,11 @@ impl Client {
let res = self let res = self
.request(method, path, query, body_value.as_ref()) .request(method, path, query, body_value.as_ref())
.await?; .await?;
ensure_success(res).await?.json().await.context("decoding JSON response") ensure_success(res)
.await?
.json()
.await
.context("decoding JSON response")
} }
/// GET that returns a single page along with pagination metadata. /// GET that returns a single page along with pagination metadata.
@ -195,7 +212,8 @@ async fn ensure_success(res: Response) -> Result<Response> {
body: text, body: text,
}; };
if status == StatusCode::UNAUTHORIZED { if status == StatusCode::UNAUTHORIZED {
Err(anyhow::Error::new(err).context("authentication failed (HTTP 401). Token may be invalid or revoked")) Err(anyhow::Error::new(err)
.context("authentication failed (HTTP 401). Token may be invalid or revoked"))
} else { } else {
Err(anyhow::Error::new(err)) Err(anyhow::Error::new(err))
} }

View file

@ -51,7 +51,7 @@ impl<T> Page<T> {
} }
} }
fn parse_link_header(value: &str) -> Vec<(String, String)> { pub(crate) fn parse_link_header(value: &str) -> Vec<(String, String)> {
let mut out = Vec::new(); let mut out = Vec::new();
for part in value.split(',') { for part in value.split(',') {
let part = part.trim(); let part = part.trim();
@ -59,11 +59,11 @@ fn parse_link_header(value: &str) -> Vec<(String, String)> {
let Some((url_part, params)) = part.split_once(';') else { let Some((url_part, params)) = part.split_once(';') else {
continue; continue;
}; };
let url = url_part.trim().trim_start_matches('<').trim_end_matches('>'); let url = url_part
let rel = params .trim()
.split(';') .trim_start_matches('<')
.map(str::trim) .trim_end_matches('>');
.find_map(|p| { let rel = params.split(';').map(str::trim).find_map(|p| {
let (k, v) = p.split_once('=')?; let (k, v) = p.split_once('=')?;
if k.trim().eq_ignore_ascii_case("rel") { if k.trim().eq_ignore_ascii_case("rel") {
Some(v.trim().trim_matches('"').to_string()) Some(v.trim().trim_matches('"').to_string())
@ -77,3 +77,40 @@ fn parse_link_header(value: &str) -> Vec<(String, String)> {
} }
out out
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_single_link() {
let input = r#"<https://example.com/api/v1/repos?page=2>; rel="next""#;
let parsed = parse_link_header(input);
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].0, "https://example.com/api/v1/repos?page=2");
assert_eq!(parsed[0].1, "next");
}
#[test]
fn parses_multiple_rels() {
let input = r#"<https://x/p?page=2>; rel="next", <https://x/p?page=5>; rel="last", <https://x/p?page=1>; rel="first""#;
let parsed = parse_link_header(input);
let rels: Vec<&str> = parsed.iter().map(|(_, r)| r.as_str()).collect();
assert!(rels.contains(&"next"));
assert!(rels.contains(&"last"));
assert!(rels.contains(&"first"));
}
#[test]
fn ignores_unparsable_segments() {
let input = "garbage";
assert!(parse_link_header(input).is_empty());
}
#[test]
fn rel_case_insensitive() {
let input = r#"<https://x/p>; REL="next""#;
let parsed = parse_link_header(input);
assert_eq!(parsed[0].1, "next");
}
}

View file

@ -39,18 +39,17 @@ impl Hosts {
if !path.exists() { if !path.exists() {
return Ok(Self::default()); return Ok(Self::default());
} }
let text = fs::read_to_string(&path) let text =
.with_context(|| format!("reading {}", path.display()))?; fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
let parsed: Self = toml::from_str(&text) let parsed: Self =
.with_context(|| format!("parsing {}", path.display()))?; toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
Ok(parsed) Ok(parsed)
} }
pub fn save(&self) -> Result<()> { pub fn save(&self) -> Result<()> {
let path = hosts_path()?; let path = hosts_path()?;
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent) fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
.with_context(|| format!("creating {}", parent.display()))?;
} }
let text = toml::to_string_pretty(self).context("serializing hosts.toml")?; let text = toml::to_string_pretty(self).context("serializing hosts.toml")?;
fs::write(&path, text).with_context(|| format!("writing {}", path.display()))?; fs::write(&path, text).with_context(|| format!("writing {}", path.display()))?;
@ -109,3 +108,95 @@ impl Hosts {
pub fn api_base_path(host: &Host) -> &str { pub fn api_base_path(host: &Host) -> &str {
host.api_base_path.as_deref().unwrap_or("/api/v1") host.api_base_path.as_deref().unwrap_or("/api/v1")
} }
#[cfg(test)]
mod tests {
use super::*;
fn host(user: &str) -> Host {
Host {
user: Some(user.into()),
git_protocol: "https".into(),
api_base_path: None,
}
}
#[test]
fn upsert_sets_current_on_first_host() {
let mut h = Hosts::default();
h.upsert("a.example", host("alice"));
assert_eq!(h.current.as_deref(), Some("a.example"));
}
#[test]
fn upsert_does_not_steal_current_from_existing() {
let mut h = Hosts::default();
h.upsert("a.example", host("alice"));
h.upsert("b.example", host("bob"));
assert_eq!(h.current.as_deref(), Some("a.example"));
}
#[test]
fn remove_drops_current_when_targeted() {
let mut h = Hosts::default();
h.upsert("a.example", host("alice"));
h.upsert("b.example", host("bob"));
h.remove("a.example");
// current was a.example, falls back to surviving host
assert_eq!(h.current.as_deref(), Some("b.example"));
}
#[test]
fn remove_returns_none_for_missing() {
let mut h = Hosts::default();
assert!(h.remove("nope").is_none());
}
#[test]
fn resolve_explicit_must_exist() {
let mut h = Hosts::default();
h.upsert("a.example", host("alice"));
assert!(h.resolve_host(Some("nope")).is_err());
assert_eq!(h.resolve_host(Some("a.example")).unwrap(), "a.example");
}
#[test]
fn resolve_uses_current() {
let mut h = Hosts::default();
h.upsert("a.example", host("alice"));
h.upsert("b.example", host("bob"));
h.set_current("b.example").unwrap();
assert_eq!(h.resolve_host(None).unwrap(), "b.example");
}
#[test]
fn resolve_falls_back_to_only_host() {
let mut h = Hosts::default();
h.upsert("a.example", host("alice"));
h.current = None;
assert_eq!(h.resolve_host(None).unwrap(), "a.example");
}
#[test]
fn resolve_errors_when_ambiguous() {
let mut h = Hosts::default();
h.upsert("a.example", host("alice"));
h.upsert("b.example", host("bob"));
h.current = None;
assert!(h.resolve_host(None).is_err());
}
#[test]
fn set_current_rejects_unknown() {
let mut h = Hosts::default();
assert!(h.set_current("nope").is_err());
}
#[test]
fn api_base_path_defaults() {
let mut h = host("alice");
assert_eq!(api_base_path(&h), "/api/v1");
h.api_base_path = Some("/api/v2".into());
assert_eq!(api_base_path(&h), "/api/v2");
}
}

View file

@ -1,7 +1,13 @@
pub mod remote;
use std::process::Command; use std::process::Command;
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
pub use remote::discover;
#[allow(unused_imports)]
pub use remote::{parse_remote, RemoteRepo};
/// Spawn `git` synchronously and surface the exit status as an error. /// Spawn `git` synchronously and surface the exit status as an error.
pub fn run(args: &[&str]) -> Result<()> { pub fn run(args: &[&str]) -> Result<()> {
let status = Command::new("git") let status = Command::new("git")

182
src/git/remote.rs Normal file
View file

@ -0,0 +1,182 @@
//! Detect the Forgejo repo associated with the current working directory by
//! reading git remotes. Used so `fj pr list` (etc.) work without `-R` inside
//! a clone.
use std::process::Command;
use anyhow::{anyhow, Context, Result};
/// A parsed `owner/name` plus the hostname it lives on.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteRepo {
pub host: String,
pub owner: String,
pub name: String,
}
impl RemoteRepo {
#[allow(dead_code)]
pub fn slug(&self) -> String {
format!("{}/{}", self.owner, self.name)
}
}
/// Find a Forgejo repo for the current directory.
///
/// Preference order: explicit `--repo`/`-R` (handled by the caller), then
/// `upstream` remote, then `origin`, then any other remote.
pub fn discover(preferred_host: Option<&str>) -> Result<RemoteRepo> {
let remotes = list_remotes()?;
if remotes.is_empty() {
return Err(anyhow!(
"no git remotes found; pass `-R <owner>/<name>` or run inside a clone"
));
}
// upstream first, then origin, then the rest in stable order.
let mut ordered: Vec<&(String, String)> = remotes
.iter()
.filter(|(n, _)| n == "upstream")
.chain(remotes.iter().filter(|(n, _)| n == "origin"))
.chain(
remotes
.iter()
.filter(|(n, _)| n != "upstream" && n != "origin"),
)
.collect();
// Deduplicate while preserving order.
ordered.dedup_by(|a, b| a.0 == b.0);
let mut last_err: Option<anyhow::Error> = None;
for (_, url) in ordered {
match parse_remote(url) {
Ok(r) => {
if preferred_host.is_none_or(|h| h.eq_ignore_ascii_case(&r.host)) {
return Ok(r);
}
}
Err(e) => last_err = Some(e),
}
}
Err(last_err
.unwrap_or_else(|| anyhow!("no remote matched a known Forgejo host"))
.context("could not infer the repository from git remotes"))
}
fn list_remotes() -> Result<Vec<(String, String)>> {
let out = Command::new("git")
.args(["remote", "-v"])
.output()
.context("running `git remote -v`")?;
if !out.status.success() {
// Not a git repo, or git is missing. Return an empty list; the caller
// surfaces a friendlier message.
return Ok(Vec::new());
}
let text = String::from_utf8_lossy(&out.stdout);
let mut seen: Vec<(String, String)> = Vec::new();
for line in text.lines() {
// Format: "<name>\t<url> (fetch|push)"
let Some((name_url, _)) = line.rsplit_once(' ') else {
continue;
};
let Some((name, url)) = name_url.split_once('\t') else {
continue;
};
let entry = (name.to_string(), url.to_string());
if !seen.iter().any(|(n, _)| n == &entry.0) {
seen.push(entry);
}
}
Ok(seen)
}
/// Parse `owner/name` and host out of a git remote URL.
///
/// Accepts:
/// - `https://host/owner/name(.git)?`
/// - `ssh://git@host(:port)?/owner/name(.git)?`
/// - `git@host:owner/name(.git)?` (scp-like form)
pub fn parse_remote(url: &str) -> Result<RemoteRepo> {
let url = url.trim();
// scp-like: git@host:owner/name(.git)?
if !url.contains("://") {
if let Some((user_host, path)) = url.split_once(':') {
let host = user_host
.rsplit_once('@')
.map(|(_, h)| h)
.unwrap_or(user_host);
return split_path(host, path);
}
return Err(anyhow!("can't parse remote URL: {url}"));
}
let parsed = url::Url::parse(url).with_context(|| format!("parsing remote URL '{url}'"))?;
let host = parsed
.host_str()
.ok_or_else(|| anyhow!("remote URL has no host: {url}"))?;
let path = parsed.path();
split_path(host, path)
}
fn split_path(host: &str, path: &str) -> Result<RemoteRepo> {
let path = path.trim_matches('/');
let path = path.strip_suffix(".git").unwrap_or(path);
let Some((owner, name)) = path.split_once('/') else {
return Err(anyhow!("remote path '{path}' is not 'owner/name'"));
};
if owner.is_empty() || name.is_empty() || name.contains('/') {
return Err(anyhow!("remote path '{path}' is not 'owner/name'"));
}
Ok(RemoteRepo {
host: host.to_string(),
owner: owner.to_string(),
name: name.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn https_with_dot_git() {
let r = parse_remote("https://rasterhub.com/rasterstate/fj.git").unwrap();
assert_eq!(r.host, "rasterhub.com");
assert_eq!(r.owner, "rasterstate");
assert_eq!(r.name, "fj");
}
#[test]
fn https_no_dot_git() {
let r = parse_remote("https://rasterhub.com/rasterstate/fj").unwrap();
assert_eq!(r.slug(), "rasterstate/fj");
}
#[test]
fn ssh_url_with_port() {
let r = parse_remote("ssh://git@rasterhub.com:2222/rasterstate/fj.git").unwrap();
assert_eq!(r.host, "rasterhub.com");
assert_eq!(r.slug(), "rasterstate/fj");
}
#[test]
fn scp_like() {
let r = parse_remote("git@rasterhub.com:rasterstate/fj.git").unwrap();
assert_eq!(r.host, "rasterhub.com");
assert_eq!(r.slug(), "rasterstate/fj");
}
#[test]
fn rejects_non_repo_path() {
assert!(parse_remote("https://example.com/").is_err());
assert!(parse_remote("https://example.com/just-one-segment").is_err());
}
#[test]
fn rejects_deep_path() {
assert!(parse_remote("https://example.com/a/b/c").is_err());
}
}