From 191d941c7876798ee89c0146a26b05ab01898f4d Mon Sep 17 00:00:00 2001 From: Stephen Way Date: Wed, 13 May 2026 08:22:40 -0700 Subject: [PATCH] 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) --- Cargo.toml | 2 +- hooks/pre-push | 37 +++ scripts/e2e-smoke.sh | 44 +++ scripts/install-hooks.sh | 19 ++ src/api/issue.rs | 4 +- src/api/mod.rs | 27 ++ src/api/pull.rs | 186 +++++++++++- src/api/repo.rs | 186 ++++++++++-- src/api/user.rs | 4 +- src/cli/api.rs | 352 ++++++++++++++++++++--- src/cli/auth.rs | 6 +- src/cli/context.rs | 87 ++++++ src/cli/editor.rs | 116 ++++++++ src/cli/issue.rs | 276 ++++++++++++++---- src/cli/mod.rs | 10 +- src/cli/pr.rs | 595 ++++++++++++++++++++++++++++++++++----- src/cli/repo.rs | 362 ++++++++++++++++++++++-- src/cli/web.rs | 38 +++ src/client/mod.rs | 32 ++- src/client/pagination.rs | 63 ++++- src/config/hosts.rs | 103 ++++++- src/git/mod.rs | 6 + src/git/remote.rs | 182 ++++++++++++ 23 files changed, 2492 insertions(+), 245 deletions(-) create mode 100755 hooks/pre-push create mode 100755 scripts/e2e-smoke.sh create mode 100755 scripts/install-hooks.sh create mode 100644 src/cli/context.rs create mode 100644 src/cli/editor.rs create mode 100644 src/cli/web.rs create mode 100644 src/git/remote.rs diff --git a/Cargo.toml b/Cargo.toml index 0883b41..b6f3f2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "fj" version = "0.1.0" edition = "2021" -rust-version = "1.80" +rust-version = "1.82" description = "Command-line tool for Forgejo, in the spirit of gh" authors = ["Stephen Way "] license = "MIT" diff --git a/hooks/pre-push b/hooks/pre-push new file mode 100755 index 0000000..9d5893f --- /dev/null +++ b/hooks/pre-push @@ -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' diff --git a/scripts/e2e-smoke.sh b/scripts/e2e-smoke.sh new file mode 100755 index 0000000..92091a0 --- /dev/null +++ b/scripts/e2e-smoke.sh @@ -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" diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh new file mode 100755 index 0000000..c1d89ac --- /dev/null +++ b/scripts/install-hooks.sh @@ -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 diff --git a/src/api/issue.rs b/src/api/issue.rs index bb1ec5a..8898c28 100644 --- a/src/api/issue.rs +++ b/src/api/issue.rs @@ -174,9 +174,7 @@ pub async fn comment( ) -> Result { let path = format!("/api/v1/repos/{owner}/{name}/issues/{number}/comments"); let payload = CreateComment { body }; - client - .json(Method::POST, &path, &[], Some(&payload)) - .await + client.json(Method::POST, &path, &[], Some(&payload)).await } pub async fn list_comments( diff --git a/src/api/mod.rs b/src/api/mod.rs index 180cfba..3dd6a46 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -11,3 +11,30 @@ pub fn split_repo(repo: &str) -> Result<(&str, &str)> { .filter(|(o, n)| !o.is_empty() && !n.is_empty()) .ok_or_else(|| anyhow!("expected '/', 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()); + } +} diff --git a/src/api/pull.rs b/src/api/pull.rs index 9904924..564a4f6 100644 --- a/src/api/pull.rs +++ b/src/api/pull.rs @@ -44,6 +44,57 @@ pub struct Branch { 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, +} + +#[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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommitAuthor { + pub name: String, + #[serde(default)] + pub email: String, + #[serde(default)] + pub date: Option, +} + +#[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>, + pub html_url: String, +} + #[derive(Debug, Clone)] pub struct ListOptions { pub state: State, @@ -88,6 +139,8 @@ pub struct CreatePull<'a> { pub base: &'a str, #[serde(skip_serializing_if = "Option::is_none")] pub body: Option<&'a str>, + #[serde(default)] + pub draft: bool, } pub async fn create( @@ -121,6 +174,97 @@ pub async fn edit( 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::(Method::PATCH, &path, &[], Some(&body)) + .await + .map(|_| ()) +} + +pub async fn files(client: &Client, owner: &str, name: &str, number: u64) -> Result> { + 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> { + 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 { + 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 { + 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> { + let path = format!("/api/v1/repos/{owner}/{name}/pulls/{number}/reviews"); + client.json(Method::GET, &path, &[], None::<&()>).await +} + #[derive(Debug, Clone, Copy)] pub enum MergeStyle { Merge, @@ -166,8 +310,48 @@ pub async fn merge( message, }; let res = client - .request(Method::POST, &path, &[], Some(&serde_json::to_value(&body)?)) + .request( + Method::POST, + &path, + &[], + Some(&serde_json::to_value(&body)?), + ) .await?; res.error_for_status()?; Ok(()) } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CombinedStatus { + #[serde(default)] + pub state: String, + pub sha: String, + #[serde(default)] + pub statuses: Vec, + #[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>, +} + +pub async fn combined_status( + client: &Client, + owner: &str, + name: &str, + sha: &str, +) -> Result { + let path = format!("/api/v1/repos/{owner}/{name}/commits/{sha}/status"); + client.json(Method::GET, &path, &[], None::<&()>).await +} diff --git a/src/api/repo.rs b/src/api/repo.rs index e0f7007..c4c7c19 100644 --- a/src/api/repo.rs +++ b/src/api/repo.rs @@ -21,6 +21,8 @@ pub struct Repo { pub fork: bool, #[serde(default)] pub archived: bool, + #[serde(default)] + pub mirror: bool, pub html_url: String, pub clone_url: String, pub ssh_url: String, @@ -63,16 +65,11 @@ pub async fn search(client: &Client, opts: ListOptions<'_>) -> Result, #[serde(default)] + #[allow(dead_code)] ok: bool, } -impl Page { - fn also_total(self, _ok: bool) -> Self { - self - } -} - pub type SearchHit = Repo; pub async fn get(client: &Client, owner: &str, name: &str) -> Result { @@ -115,11 +107,169 @@ pub async fn create_for_current_user(client: &Client, body: &CreateRepo<'_>) -> .await } -pub async fn create_for_org( - client: &Client, - org: &str, - body: &CreateRepo<'_>, -) -> Result { +pub async fn create_for_org(client: &Client, org: &str, body: &CreateRepo<'_>) -> Result { let path = format!("/api/v1/orgs/{org}/repos"); 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_branch: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub archived: Option, +} + +pub async fn edit( + client: &Client, + owner: &str, + name: &str, + body: &EditRepo<'_>, +) -> Result { + 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 { + 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> { + 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, +} + +pub async fn list_topics( + client: &Client, + owner: &str, + name: &str, +) -> Result> { + 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(()) +} diff --git a/src/api/user.rs b/src/api/user.rs index 5656f3b..7df4237 100644 --- a/src/api/user.rs +++ b/src/api/user.rs @@ -19,5 +19,7 @@ pub struct User { } pub async fn current(client: &Client) -> Result { - client.json(Method::GET, "/api/v1/user", &[], None::<&()>).await + client + .json(Method::GET, "/api/v1/user", &[], None::<&()>) + .await } diff --git a/src/cli/api.rs b/src/cli/api.rs index 691ec78..1058726 100644 --- a/src/cli/api.rs +++ b/src/cli/api.rs @@ -1,9 +1,11 @@ use anyhow::{anyhow, Context, Result}; use clap::Args; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; use reqwest::Method; use serde_json::Value; +use url::Url; -use crate::client::Client; +use crate::client::{pagination::parse_link_header, Client}; use crate::output; #[derive(Debug, Args)] @@ -12,44 +14,92 @@ pub struct ApiArgs { /// are also accepted. 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")] pub method: Option, - /// Add a query parameter (`-f key=value`). For GET; for non-GET methods these - /// are sent as a JSON body field instead. Repeatable. + /// Add a query parameter for GET/DELETE (`-f key=value`); for non-GET + /// methods these are sent as JSON body fields. Repeatable. #[arg(short = 'f', long = "field", value_name = "KEY=VALUE")] pub fields: Vec, - /// 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")] pub raw_fields: Vec, - /// 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, + + /// Output a JSON path projection. Supports dot paths, [N] indices, + /// negative indices like [-1], and pipes (`. | .field`). #[arg(short = 'q', long = "jq")] pub jq: Option, /// Send a literal JSON request body. Conflicts with `-f` / `-F`. #[arg(long, conflicts_with_all = ["fields", "raw_fields"])] pub input: Option, + + /// 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<()> { let client = Client::connect(host)?; - let method = pick_method(&args)?; 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 - .request(method.clone(), &args.endpoint, &query, body.as_ref()) + .request_with_headers( + method.clone(), + &args.endpoint, + &query, + body.as_ref(), + &extra_headers, + ) .await?; let status = res.status(); + let resp_headers = res.headers().clone(); 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() { Value::Null } 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() { @@ -62,7 +112,10 @@ pub async fn run(args: ApiArgs, host: Option<&str>) -> Result<()> { Some(path) => extract_path(&parsed, path)?, None => parsed, }; - output::print_json(&projected) + if !args.silent { + output::print_json(&projected)?; + } + Ok(()) } fn pick_method(args: &ApiArgs) -> Result { @@ -70,22 +123,17 @@ fn pick_method(args: &ApiArgs) -> Result { return Method::from_bytes(m.to_uppercase().as_bytes()) .with_context(|| format!("invalid HTTP method '{m}'")); } - // Only `--input` (a literal JSON body) implies POST. `-f` / `-F` on a GET - // are sent as query params, which is what users hit search endpoints for. - // To send a write request, pass `-X POST` (or PATCH/PUT/DELETE) explicitly. if args.input.is_some() { return Ok(Method::POST); } Ok(Method::GET) } -fn build_query_or_body( - args: &ApiArgs, - method: &Method, -) -> Result<(Vec<(String, String)>, Option)> { +type QueryAndBody = (Vec<(String, String)>, Option); + +fn build_query_or_body(args: &ApiArgs, method: &Method) -> Result { if let Some(input) = &args.input { - let body: Value = - serde_json::from_str(input).context("--input must be valid JSON")?; + let body: Value = serde_json::from_str(input).context("--input must be valid JSON")?; return Ok((Vec::new(), Some(body))); } @@ -116,29 +164,269 @@ fn build_query_or_body( Ok((Vec::new(), Some(Value::Object(obj)))) } +fn parse_headers(items: &[String]) -> Result { + 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 { + let mut all_items = Vec::::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, + ¤t_endpoint, + ¤t_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)> { s.split_once('=') .ok_or_else(|| anyhow!("expected KEY=VALUE, got '{s}'")) } -/// Very small jq-ish projector: dot-separated keys + numeric indices. -/// e.g. `.data.0.name`, `.message`. -fn extract_path(value: &Value, path: &str) -> Result { - let path = path.trim_start_matches('.'); - if path.is_empty() { +/// jq-ish projector. Supports: +/// - dot fields (`.a.b`) +/// - numeric indices (`.0`, `.data.1`) +/// - bracket indices (`.data[0]`, negative supported: `.data[-1]`) +/// - pipes (`.foo | .bar`) +fn extract_path(value: &Value, expression: &str) -> Result { + let mut current = value.clone(); + for stage in expression.split('|') { + current = apply_stage(¤t, stage.trim())?; + } + Ok(current) +} + +fn apply_stage(value: &Value, stage: &str) -> Result { + let stage = stage.trim_start_matches('.'); + if stage.is_empty() { return Ok(value.clone()); } + let mut current = value; - for segment in path.split('.') { - if let Ok(idx) = segment.parse::() { - current = current + let mut owned: Option = None; + + for raw in split_segments(stage) { + let target = owned.as_ref().unwrap_or(current); + 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 { + idx + }; + 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::() { + let v = target .get(idx) - .ok_or_else(|| anyhow!("index {idx} out of range at '{segment}'"))?; + .ok_or_else(|| anyhow!("index {idx} out of range"))? + .clone(); + owned = Some(v); + current = owned.as_ref().unwrap(); } else { - current = current - .get(segment) - .ok_or_else(|| anyhow!("no field '{segment}'"))?; + 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 { + 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()); + } } diff --git a/src/cli/auth.rs b/src/cli/auth.rs index c99bd39..ece7f60 100644 --- a/src/cli/auth.rs +++ b/src/cli/auth.rs @@ -177,7 +177,11 @@ async fn status(args: StatusArgs) -> Result<()> { } println!( " ✓ Token: {}", - if token_present { "present in keychain" } else { "missing" } + if token_present { + "present in keychain" + } else { + "missing" + } ); if args.show_token { if let Some(t) = token_store::load_token(name)? { diff --git a/src/cli/context.rs b/src/cli/context.rs new file mode 100644 index 0000000..70afbf9 --- /dev/null +++ b/src/cli/context.rs @@ -0,0 +1,87 @@ +//! Shared helpers for resolving the target repo + host across subcommands. +//! +//! The contract: each subcommand accepts `-R /` 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 `/`. Inferred from the git remote + /// when omitted. + #[arg(short = 'R', long = "repo", global = true)] + pub repo: Option, +} + +/// 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 { + 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, + }) +} diff --git a/src/cli/editor.rs b/src/cli/editor.rs new file mode 100644 index 0000000..9a9e905 --- /dev/null +++ b/src/cli/editor.rs @@ -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 { + 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> { + 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> { + 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 { + 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()) +} diff --git a/src/cli/issue.rs b/src/cli/issue.rs index 5778146..87250d4 100644 --- a/src/cli/issue.rs +++ b/src/cli/issue.rs @@ -5,9 +5,12 @@ use clap::{Args, Subcommand, ValueEnum}; use crate::api; use crate::api::issue::{CreateIssue, EditIssue, ListOptions, State}; -use crate::client::Client; +use crate::cli::context::{resolve_repo, RepoFlag}; use crate::output; +use super::editor; +use super::web; + #[derive(Debug, Args)] pub struct IssueCmd { #[command(subcommand)] @@ -22,12 +25,16 @@ pub enum IssueSub { View(ViewArgs), /// Create an issue. Create(CreateArgs), + /// Edit an issue's title, body, labels, or assignees. + Edit(EditArgs), /// Close an issue. Close(NumberOnly), /// Reopen a closed issue. Reopen(NumberOnly), /// Add a comment. Comment(CommentArgs), + /// Create a branch tied to the issue. + Develop(DevelopArgs), } #[derive(Debug, Clone, Copy, ValueEnum)] @@ -49,9 +56,8 @@ impl From for State { #[derive(Debug, Args)] pub struct ListArgs { - /// Repository slug `owner/name`. - #[arg(short = 'R', long = "repo")] - pub repo: String, + #[command(flatten)] + pub r: RepoFlag, #[arg(short = 's', long, value_enum, default_value_t = StateFilter::Open)] pub state: StateFilter, #[arg(short = 'L', long, default_value_t = 30)] @@ -66,61 +72,108 @@ pub struct ListArgs { pub query: Option, #[arg(long)] pub json: bool, + /// Open the issue list in your browser. + #[arg(long)] + pub web: bool, } #[derive(Debug, Args)] pub struct ViewArgs { - #[arg(short = 'R', long = "repo")] - pub repo: String, + #[command(flatten)] + pub r: RepoFlag, pub number: u64, #[arg(long, default_value_t = false)] pub comments: bool, #[arg(long)] pub json: bool, + /// Open the issue in your browser. + #[arg(long)] + pub web: bool, } #[derive(Debug, Args)] pub struct CreateArgs { - #[arg(short = 'R', long = "repo")] - pub repo: String, + #[command(flatten)] + pub r: RepoFlag, #[arg(short = 't', long)] - pub title: String, - /// Body. Use `-` to read from stdin. + pub title: Option, + /// Body. Use `-` to read from stdin. Omit to open `$EDITOR`. #[arg(short = 'b', long)] pub body: Option, + /// 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, + /// New body. Use `-` to read from stdin. Pass `--body-editor` to open `$EDITOR`. + #[arg(short = 'b', long)] + pub body: Option, + /// Open `$EDITOR` to edit the body inline (overrides `--body`). + #[arg(long)] + pub body_editor: bool, } #[derive(Debug, Args)] pub struct NumberOnly { - #[arg(short = 'R', long = "repo")] - pub repo: String, + #[command(flatten)] + pub r: RepoFlag, pub number: u64, } #[derive(Debug, Args)] pub struct CommentArgs { - #[arg(short = 'R', long = "repo")] - pub repo: String, + #[command(flatten)] + pub r: RepoFlag, 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)] - pub body: String, + pub body: Option, +} + +#[derive(Debug, Args)] +pub struct DevelopArgs { + #[command(flatten)] + pub r: RepoFlag, + pub number: u64, + /// Branch name. Defaults to `issue--`. + #[arg(short = 'n', long)] + pub name: Option, + /// Base branch to start from. Defaults to the repo's default branch. + #[arg(long)] + pub base: Option, + /// 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<()> { - let client = Client::connect(host)?; match cmd.command { - IssueSub::List(args) => list(&client, args).await, - IssueSub::View(args) => view(&client, args).await, - IssueSub::Create(args) => create(&client, args).await, - IssueSub::Close(args) => set_state(&client, args, "closed").await, - IssueSub::Reopen(args) => set_state(&client, args, "open").await, - IssueSub::Comment(args) => comment(&client, args).await, + IssueSub::List(args) => list(args, host).await, + IssueSub::View(args) => view(args, host).await, + IssueSub::Create(args) => create(args, host).await, + IssueSub::Edit(args) => edit(args, host).await, + IssueSub::Close(args) => set_state(args, host, "closed").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<()> { - let (owner, name) = api::split_repo(&args.repo)?; +async fn list(args: ListArgs, host: Option<&str>) -> Result<()> { + let ctx = resolve_repo(args.r.repo.as_deref(), host)?; + if args.web { + return web::open(&format!( + "https://{}/{}/{}/issues", + ctx.host, ctx.owner, ctx.name + )); + } let opts = ListOptions { state: args.state.into(), limit: args.limit, @@ -129,7 +182,7 @@ async fn list(client: &Client, args: ListArgs) -> Result<()> { assignee: args.assignee.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 { return output::print_json(&serde_json::to_value(&page.items)?); } @@ -141,28 +194,40 @@ async fn list(client: &Client, args: ListArgs) -> Result<()> { .items .iter() .map(|i| { + let labels = i + .labels + .iter() + .map(|l| l.name.as_str()) + .collect::>() + .join(", "); vec![ format!("#{}", i.number), 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)), ] }) .collect(); print!( "{}", - output::render_table(&["", "STATE", "TITLE", "UPDATED"], &rows) + output::render_table(&["", "STATE", "TITLE", "LABELS", "💬", "UPDATED"], &rows) ); Ok(()) } -async fn view(client: &Client, args: ViewArgs) -> Result<()> { - let (owner, name) = api::split_repo(&args.repo)?; - let issue = api::issue::get(client, owner, name, args.number).await?; +async fn view(args: ViewArgs, host: Option<&str>) -> Result<()> { + let ctx = resolve_repo(args.r.repo.as_deref(), host)?; + 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 { let mut v = serde_json::to_value(&issue)?; 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)?; } return output::print_json(&v); @@ -181,7 +246,8 @@ async fn view(client: &Client, args: ViewArgs) -> Result<()> { println!(); } 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() { println!("{}", output::dim("(no comments)")); } else { @@ -199,27 +265,55 @@ async fn view(client: &Client, args: ViewArgs) -> Result<()> { Ok(()) } -async fn create(client: &Client, args: CreateArgs) -> Result<()> { - let (owner, name) = api::split_repo(&args.repo)?; - let body = read_body(args.body.as_deref())?; +async fn create(args: CreateArgs, host: Option<&str>) -> Result<()> { + let ctx = resolve_repo(args.r.repo.as_deref(), host)?; + 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 { - title: &args.title, + title: &title, body: body.as_deref(), assignees: 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!("{}", issue.html_url); + if args.web { + web::open(&issue.html_url)?; + } Ok(()) } -async fn set_state(client: &Client, args: NumberOnly, state: &str) -> Result<()> { - let (owner, name) = api::split_repo(&args.repo)?; +async fn edit(args: EditArgs, host: Option<&str>) -> Result<()> { + 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( - client, - owner, - name, + &ctx.client, + &ctx.owner, + &ctx.name, args.number, &EditIssue { state: Some(state), @@ -235,33 +329,69 @@ async fn set_state(client: &Client, args: NumberOnly, state: &str) -> Result<()> Ok(()) } -async fn comment(client: &Client, args: CommentArgs) -> Result<()> { - let (owner, name) = api::split_repo(&args.repo)?; - let body = if args.body == "-" { - let mut buf = String::new(); - std::io::stdin().read_to_string(&mut buf)?; - buf - } else { - args.body +async fn comment(args: CommentArgs, host: Option<&str>) -> Result<()> { + let ctx = resolve_repo(args.r.repo.as_deref(), host)?; + let body = match args.body.as_deref() { + Some("-") => { + let mut buf = String::new(); + std::io::stdin().read_to_string(&mut buf)?; + buf + } + Some(s) => s.to_string(), + None => editor::edit_text("ISSUE_COMMENT.md", "")?, }; if body.trim().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); Ok(()) } -fn read_body(arg: Option<&str>) -> Result> { - match arg { - None => Ok(None), - Some("-") => { - let mut buf = String::new(); - std::io::stdin().read_to_string(&mut buf)?; - Ok(Some(buf)) +async fn develop(args: DevelopArgs, host: Option<&str>) -> Result<()> { + let ctx = resolve_repo(args.r.repo.as_deref(), host)?; + let issue = api::issue::get(&ctx.client, &ctx.owner, &ctx.name, args.number).await?; + let branch = match args.name { + Some(n) => n, + None => format!("issue-{}-{}", issue.number, slugify(&issue.title)), + }; + 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 { @@ -272,3 +402,29 @@ fn truncate(s: &str, max: usize) -> String { out.push('…'); 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); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index ce3e84e..7bba013 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,8 +1,11 @@ pub mod api; pub mod auth; +pub mod context; +pub mod editor; pub mod issue; pub mod pr; pub mod repo; +pub mod web; use anyhow::Result; use clap::{Parser, Subcommand}; @@ -60,12 +63,7 @@ pub async fn run(cli: Cli) -> Result<()> { Command::Completion(args) => { use clap::CommandFactory; let mut cmd = Cli::command(); - clap_complete::generate( - args.shell, - &mut cmd, - "fj", - &mut std::io::stdout(), - ); + clap_complete::generate(args.shell, &mut cmd, "fj", &mut std::io::stdout()); Ok(()) } } diff --git a/src/cli/pr.rs b/src/cli/pr.rs index 468897c..ccdda36 100644 --- a/src/cli/pr.rs +++ b/src/cli/pr.rs @@ -1,15 +1,19 @@ use std::io::Read; -use anyhow::Result; +use anyhow::{anyhow, Result}; use clap::{Args, Subcommand, ValueEnum}; use crate::api; use crate::api::issue::State; -use crate::api::pull::{CreatePull, EditPull, ListOptions, MergeStyle}; +use crate::api::pull::{CreatePull, EditPull, ListOptions, MergeStyle, ReviewEvent}; +use crate::cli::context::{resolve_repo, RepoFlag}; use crate::client::Client; use crate::git; use crate::output; +use super::editor; +use super::web; + #[derive(Debug, Args)] pub struct PrCmd { #[command(subcommand)] @@ -24,12 +28,30 @@ pub enum PrSub { View(ViewArgs), /// Create a pull request. 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. Checkout(CheckoutArgs), /// Merge a pull request. Merge(MergeArgs), /// Close a pull request without merging. - Close(NumberOnly), + Close(SimpleArgs), + /// Reopen a closed pull request. + Reopen(SimpleArgs), } #[derive(Debug, Clone, Copy, ValueEnum)] @@ -68,10 +90,27 @@ impl From for MergeStyle { } } +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum ReviewEventArg { + Approve, + RequestChanges, + Comment, +} + +impl From for ReviewEvent { + fn from(value: ReviewEventArg) -> Self { + match value { + ReviewEventArg::Approve => ReviewEvent::Approve, + ReviewEventArg::RequestChanges => ReviewEvent::RequestChanges, + ReviewEventArg::Comment => ReviewEvent::Comment, + } + } +} + #[derive(Debug, Args)] pub struct ListArgs { - #[arg(short = 'R', long = "repo")] - pub repo: String, + #[command(flatten)] + pub r: RepoFlag, #[arg(short = 's', long, value_enum, default_value_t = StateFilter::Open)] pub state: StateFilter, #[arg(short = 'L', long, default_value_t = 30)] @@ -80,50 +119,96 @@ pub struct ListArgs { pub page: u32, #[arg(long)] pub json: bool, + #[arg(long)] + pub web: bool, } #[derive(Debug, Args)] pub struct ViewArgs { - #[arg(short = 'R', long = "repo")] - pub repo: String, + #[command(flatten)] + pub r: RepoFlag, pub number: u64, + #[arg(long, default_value_t = false)] + pub comments: bool, #[arg(long)] pub json: bool, + #[arg(long)] + pub web: bool, } #[derive(Debug, Args)] pub struct CreateArgs { - #[arg(short = 'R', long = "repo")] - pub repo: String, + #[command(flatten)] + pub r: RepoFlag, #[arg(short = 't', long)] - pub title: String, + pub title: Option, #[arg(short = 'B', long, default_value = "main")] pub base: String, /// Head branch. Either a branch name on the same repo, or `owner:branch`. #[arg(short = 'H', long)] - pub head: String, - /// Body. Use `-` to read from stdin. + pub head: Option, + /// Body. Use `-` to read from stdin. Omit to open `$EDITOR`. + #[arg(short = 'b', long)] + pub body: Option, + /// 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, + #[arg(short = 'b', long)] + pub body: Option, + /// 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)] pub body: Option, } +#[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)] pub struct CheckoutArgs { - #[arg(short = 'R', long = "repo")] - pub repo: String, + #[command(flatten)] + pub r: RepoFlag, pub number: u64, - /// Override the local branch name. #[arg(short = 'b', long)] pub branch: Option, - /// Git remote to fetch from. #[arg(long, default_value = "origin")] pub remote: String, } #[derive(Debug, Args)] pub struct MergeArgs { - #[arg(short = 'R', long = "repo")] - pub repo: String, + #[command(flatten)] + pub r: RepoFlag, pub number: u64, #[arg(long, value_enum, default_value_t = MergeStyleArg::Merge)] pub style: MergeStyleArg, @@ -134,30 +219,44 @@ pub struct MergeArgs { } #[derive(Debug, Args)] -pub struct NumberOnly { - #[arg(short = 'R', long = "repo")] - pub repo: String, +pub struct SimpleArgs { + #[command(flatten)] + pub r: RepoFlag, pub number: u64, } pub async fn run(cmd: PrCmd, host: Option<&str>) -> Result<()> { - let client = Client::connect(host)?; match cmd.command { - PrSub::List(args) => list(&client, args).await, - PrSub::View(args) => view(&client, args).await, - PrSub::Create(args) => create(&client, args).await, - PrSub::Checkout(args) => checkout(&client, args).await, - PrSub::Merge(args) => merge(&client, args).await, - PrSub::Close(args) => close(&client, args).await, + PrSub::List(args) => list(args, host).await, + PrSub::View(args) => view(args, host).await, + PrSub::Create(args) => create(args, host).await, + PrSub::Edit(args) => edit(args, host).await, + PrSub::Diff(args) => diff(args, host).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<()> { - let (owner, name) = api::split_repo(&args.repo)?; +async fn list(args: ListArgs, host: Option<&str>) -> Result<()> { + let ctx = resolve_repo(args.r.repo.as_deref(), host)?; + if args.web { + return web::open(&format!( + "https://{}/{}/{}/pulls", + ctx.host, ctx.owner, ctx.name + )); + } let page = api::pull::list( - client, - owner, - name, + &ctx.client, + &ctx.owner, + &ctx.name, ListOptions { state: args.state.into(), limit: args.limit, @@ -179,8 +278,12 @@ async fn list(client: &Client, args: ListArgs) -> Result<()> { vec![ format!("#{}", p.number), output::state_pill(&p.state, p.merged), - truncate(&p.title, 60), - output::dim(&format!("{} → {}", branch_label(&p.head), branch_label(&p.base))), + truncate(&p.title, 55), + output::dim(&format!( + "{} → {}", + branch_label(&p.head), + branch_label(&p.base) + )), output::dim(&output::relative_time(p.updated_at)), ] }) @@ -192,11 +295,23 @@ async fn list(client: &Client, args: ListArgs) -> Result<()> { Ok(()) } -async fn view(client: &Client, args: ViewArgs) -> Result<()> { - let (owner, name) = api::split_repo(&args.repo)?; - let pr = api::pull::get(client, owner, name, args.number).await?; +async fn view(args: ViewArgs, 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?; + if args.web { + return web::open(&pr.html_url); + } 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!( "{} {} {}", @@ -218,39 +333,319 @@ async fn view(client: &Client, args: ViewArgs) -> Result<()> { println!(); 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(()) } -async fn create(client: &Client, args: CreateArgs) -> Result<()> { - let (owner, name) = api::split_repo(&args.repo)?; - let body = match args.body.as_deref() { - Some("-") => { - let mut buf = String::new(); - std::io::stdin().read_to_string(&mut buf)?; - Some(buf) - } - other => other.map(|s| s.to_string()), +async fn create(args: CreateArgs, host: Option<&str>) -> Result<()> { + let ctx = resolve_repo(args.r.repo.as_deref(), host)?; + let head = match args.head { + Some(h) => h, + None => detect_current_branch() + .ok_or_else(|| anyhow!("could not determine head branch; pass --head"))?, }; + 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( - client, - owner, - name, + &ctx.client, + &ctx.owner, + &ctx.name, &CreatePull { - title: &args.title, - head: &args.head, + title: &title, + head: &head, base: &args.base, body: body.as_deref(), + draft: args.draft, }, ) .await?; println!("✓ Created PR #{}: {}", pr.number, pr.title); println!("{}", pr.html_url); + if args.web { + web::open(&pr.html_url)?; + } Ok(()) } -async fn checkout(client: &Client, args: CheckoutArgs) -> Result<()> { - let (owner, name) = api::split_repo(&args.repo)?; - let pr = api::pull::get(client, owner, name, args.number).await?; +async fn edit(args: EditArgs, host: Option<&str>) -> Result<()> { + let ctx = resolve_repo(args.r.repo.as_deref(), host)?; + 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> = 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> = 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> = 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 = client + .json(reqwest::Method::GET, path, &q_authored, None::<&()>) + .await?; + let review_requested: Vec = client + .json(reqwest::Method::GET, path, &q_review, None::<&()>) + .await?; + let mentioned: Vec = 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)); git::fetch_pr(&args.remote, pr.number, &branch)?; git::checkout(&branch)?; @@ -258,12 +653,12 @@ async fn checkout(client: &Client, args: CheckoutArgs) -> Result<()> { Ok(()) } -async fn merge(client: &Client, args: MergeArgs) -> Result<()> { - let (owner, name) = api::split_repo(&args.repo)?; +async fn merge(args: MergeArgs, host: Option<&str>) -> Result<()> { + let ctx = resolve_repo(args.r.repo.as_deref(), host)?; api::pull::merge( - client, - owner, - name, + &ctx.client, + &ctx.owner, + &ctx.name, args.number, args.style.into(), args.title.as_deref(), @@ -274,20 +669,24 @@ async fn merge(client: &Client, args: MergeArgs) -> Result<()> { Ok(()) } -async fn close(client: &Client, args: NumberOnly) -> Result<()> { - let (owner, name) = api::split_repo(&args.repo)?; +async fn set_state(args: SimpleArgs, host: Option<&str>, state: &str) -> Result<()> { + let ctx = resolve_repo(args.r.repo.as_deref(), host)?; api::pull::edit( - client, - owner, - name, + &ctx.client, + &ctx.owner, + &ctx.name, args.number, &EditPull { - state: Some("closed"), + state: Some(state), ..Default::default() }, ) .await?; - println!("✓ Closed PR #{}", args.number); + println!( + "✓ PR #{} is now {}", + args.number, + output::state_pill(state, false) + ); Ok(()) } @@ -306,6 +705,23 @@ fn branch_label(b: &crate::api::pull::Branch) -> String { .to_string() } +fn detect_current_branch() -> Option { + 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 { if s.chars().count() <= max { return s.to_string(); @@ -315,3 +731,46 @@ fn truncate(s: &str, max: usize) -> String { 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('…')); + } +} diff --git a/src/cli/repo.rs b/src/cli/repo.rs index af06ed8..3a3c42d 100644 --- a/src/cli/repo.rs +++ b/src/cli/repo.rs @@ -1,12 +1,16 @@ -use anyhow::Result; +use anyhow::{anyhow, Result}; use clap::{Args, Subcommand}; use crate::api; +use crate::cli::context::{resolve_repo, RepoFlag}; use crate::client::Client; use crate::config::hosts::Hosts; use crate::git; use crate::output; +use super::editor; +use super::web; + #[derive(Debug, Args)] pub struct RepoCmd { #[command(subcommand)] @@ -23,6 +27,24 @@ pub enum RepoSub { Clone(CloneArgs), /// Create a new repository. 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)] @@ -41,10 +63,13 @@ pub struct ListArgs { #[derive(Debug, Args)] pub struct ViewArgs { - /// `owner/name` slug. - pub repo: String, + /// `owner/name` slug. Inferred from the git remote when omitted. + pub repo: Option, #[arg(long)] pub json: bool, + /// Open the repo's web page. + #[arg(long)] + pub web: bool, } #[derive(Debug, Args)] @@ -65,28 +90,117 @@ pub struct CreateArgs { pub private: bool, #[arg(long, default_value_t = false)] 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, + /// Place the fork under this organization instead of your user account. + #[arg(long)] + pub org: Option, + /// New repository name. + #[arg(long)] + pub name: Option, + /// Clone the fork after creation. + #[arg(long)] + pub clone: bool, +} + +#[derive(Debug, Args)] +pub struct SyncArgs { + pub repo: Option, + /// Branch to sync. Defaults to the repo's default branch. + #[arg(long)] + pub branch: Option, +} + +#[derive(Debug, Args)] +pub struct EditArgs { + pub repo: Option, + #[arg(long)] + pub description: Option, + #[arg(long)] + pub website: Option, + #[arg(long)] + pub default_branch: Option, + /// Force private/public. Accepts `true` or `false`. + #[arg(long)] + pub private: Option, + /// 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, +} + +#[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, + #[arg(long)] + pub json: bool, +} + +#[derive(Debug, Args)] +pub struct TopicsArgs { + pub repo: Option, + /// Set the topic list (comma-separated). Omit to just print. + #[arg(long)] + pub set: Option, } pub async fn run(cmd: RepoCmd, host: Option<&str>) -> Result<()> { - let client = Client::connect(host)?; match cmd.command { - RepoSub::List(args) => list(&client, args).await, - RepoSub::View(args) => view(&client, args).await, - RepoSub::Clone(args) => clone(&client, host, args).await, - RepoSub::Create(args) => create(&client, args).await, + RepoSub::List(args) => list(args, host).await, + RepoSub::View(args) => view(args, host).await, + RepoSub::Clone(args) => clone(args, host).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 { limit: args.limit, page: args.page, query: args.search.as_deref(), }; let page = if args.search.is_some() { - api::repo::search(client, opts).await? + api::repo::search(&client, opts).await? } else { - api::repo::list_for_user(client, opts).await? + api::repo::list_for_user(&client, opts).await? }; if args.json { let v = serde_json::to_value(&page.items)?; @@ -116,15 +230,23 @@ async fn list(client: &Client, args: ListArgs) -> Result<()> { Ok(()) } -async fn view(client: &Client, args: ViewArgs) -> Result<()> { - let (owner, name) = api::split_repo(&args.repo)?; - let repo = api::repo::get(client, owner, name).await?; +async fn view(args: ViewArgs, host: Option<&str>) -> Result<()> { + let ctx = resolve_repo(args.repo.as_deref(), host)?; + let repo = api::repo::get(&ctx.client, &ctx.owner, &ctx.name).await?; + if args.web { + return web::open(&repo.html_url); + } if args.json { return output::print_json(&serde_json::to_value(&repo)?); } let header = output::bold(&repo.full_name); let vis = if repo.private { "private" } else { "public" }; - println!("{header} {}", output::dim(vis)); + let extra = if repo.archived { + format!(" {}", output::dim("(archived)")) + } else { + String::new() + }; + println!("{header} {}{extra}", output::dim(vis)); if !repo.description.is_empty() { println!("{}", repo.description); } @@ -133,7 +255,10 @@ async fn view(client: &Client, args: ViewArgs) -> Result<()> { println!("Stars: {}", repo.stars_count); println!("Forks: {}", repo.forks_count); println!("Open issues: {}", repo.open_issues_count); - println!("Updated: {}", output::relative_time(repo.updated_at)); + println!( + "Updated: {}", + output::relative_time(repo.updated_at) + ); println!(); println!("URL: {}", repo.html_url); println!("Clone URL: {}", repo.clone_url); @@ -141,11 +266,12 @@ async fn view(client: &Client, args: ViewArgs) -> Result<()> { 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 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 hostname = hosts.resolve_host(host_flag)?; + let hostname = hosts.resolve_host(host)?; let proto = hosts .hosts .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()) } -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('/') { Some((o, n)) => (Some(o.to_string()), n.to_string()), None => (None, args.repo.clone()), @@ -172,20 +299,199 @@ async fn create(client: &Client, args: CreateArgs) -> Result<()> { auto_init: args.init, }; let repo = match owner { - Some(o) => { - // Try as org first; if 404, fall through to user-namespaced. - match api::repo::create_for_org(client, &o, &body).await { - Ok(r) => r, - Err(_) => api::repo::create_for_current_user(client, &body).await?, - } - } - None => api::repo::create_for_current_user(client, &body).await?, + Some(o) => match api::repo::create_for_org(&client, &o, &body).await { + Ok(r) => r, + Err(_) => api::repo::create_for_current_user(&client, &body).await?, + }, + None => api::repo::create_for_current_user(&client, &body).await?, }; println!("✓ Created {}", repo.full_name); println!("{}", repo.html_url); + 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(()) } +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> = 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 = 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 = || None; + fn truncate(s: &str, max: usize) -> String { if s.chars().count() <= max { return s.to_string(); diff --git a/src/cli/web.rs b/src/cli/web.rs new file mode 100644 index 0000000..760bb6b --- /dev/null +++ b/src/cli/web.rs @@ -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", &[]) +} diff --git a/src/client/mod.rs b/src/client/mod.rs index 7f9ff9a..d2650d1 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -120,13 +120,26 @@ impl Client { path: &str, query: &[(String, String)], body: Option<&serde_json::Value>, + ) -> Result { + 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 { let url = self.url(path)?; - let mut req = self - .http - .request(method, url) - .headers(self.auth_headers()) - .query(query); + let mut headers = self.auth_headers(); + for (k, v) in extra.iter() { + headers.insert(k.clone(), v.clone()); + } + let mut req = self.http.request(method, url).headers(headers).query(query); if let Some(body) = body { req = req.json(body); } @@ -154,7 +167,11 @@ impl Client { let res = self .request(method, path, query, body_value.as_ref()) .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. @@ -195,7 +212,8 @@ async fn ensure_success(res: Response) -> Result { body: text, }; 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 { Err(anyhow::Error::new(err)) } diff --git a/src/client/pagination.rs b/src/client/pagination.rs index 719c2cf..fdfe186 100644 --- a/src/client/pagination.rs +++ b/src/client/pagination.rs @@ -51,7 +51,7 @@ impl Page { } } -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(); for part in value.split(',') { let part = part.trim(); @@ -59,21 +59,58 @@ fn parse_link_header(value: &str) -> Vec<(String, String)> { let Some((url_part, params)) = part.split_once(';') else { continue; }; - let url = url_part.trim().trim_start_matches('<').trim_end_matches('>'); - let rel = params - .split(';') - .map(str::trim) - .find_map(|p| { - let (k, v) = p.split_once('=')?; - if k.trim().eq_ignore_ascii_case("rel") { - Some(v.trim().trim_matches('"').to_string()) - } else { - None - } - }); + let url = url_part + .trim() + .trim_start_matches('<') + .trim_end_matches('>'); + let rel = params.split(';').map(str::trim).find_map(|p| { + let (k, v) = p.split_once('=')?; + if k.trim().eq_ignore_ascii_case("rel") { + Some(v.trim().trim_matches('"').to_string()) + } else { + None + } + }); if let Some(rel) = rel { out.push((url.to_string(), rel)); } } out } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_single_link() { + let input = r#"; 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#"; rel="next", ; rel="last", ; 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#"; REL="next""#; + let parsed = parse_link_header(input); + assert_eq!(parsed[0].1, "next"); + } +} diff --git a/src/config/hosts.rs b/src/config/hosts.rs index ec4f13c..381bc7a 100644 --- a/src/config/hosts.rs +++ b/src/config/hosts.rs @@ -39,18 +39,17 @@ impl Hosts { if !path.exists() { return Ok(Self::default()); } - let text = fs::read_to_string(&path) - .with_context(|| format!("reading {}", path.display()))?; - let parsed: Self = toml::from_str(&text) - .with_context(|| format!("parsing {}", path.display()))?; + let text = + fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; + let parsed: Self = + toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?; Ok(parsed) } pub fn save(&self) -> Result<()> { let path = hosts_path()?; if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("creating {}", parent.display()))?; + fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; } let text = toml::to_string_pretty(self).context("serializing hosts.toml")?; fs::write(&path, text).with_context(|| format!("writing {}", path.display()))?; @@ -109,3 +108,95 @@ impl Hosts { pub fn api_base_path(host: &Host) -> &str { 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"); + } +} diff --git a/src/git/mod.rs b/src/git/mod.rs index f763dac..159a993 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -1,7 +1,13 @@ +pub mod remote; + use std::process::Command; 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. pub fn run(args: &[&str]) -> Result<()> { let status = Command::new("git") diff --git a/src/git/remote.rs b/src/git/remote.rs new file mode 100644 index 0000000..36115d2 --- /dev/null +++ b/src/git/remote.rs @@ -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 { + let remotes = list_remotes()?; + if remotes.is_empty() { + return Err(anyhow!( + "no git remotes found; pass `-R /` 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 = 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> { + 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: "\t (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 { + 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 { + 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()); + } +}