expand: repo auto-detect, --web, editor, PR diff/checks/ready/review/status, repo lifecycle, api headers/paginate
* CI: pre-push hook (fmt, clippy -D warnings, test, release build) plus opt-in FJ_E2E=1 smoke. Install via scripts/install-hooks.sh. * Repo auto-detection from git remote: -R/--repo becomes optional on all repo-scoped subcommands. Detection prefers `upstream` then `origin`, honors --host, and parses https/ssh/scp-style URLs. * `--web` flag wired to every list/view command (open in default browser). * `$EDITOR` integration for issue/pr create + comment + edit (omit `--body` to launch your editor; `-` keeps stdin). * PR: new `diff`, `commits`, `files`, `checks`, `ready`, `review`, `edit`, `status`, `reopen` subcommands. `view --comments` now shows reviews too. * Issue: `edit` and `develop` (creates a branch for the issue). * Repo: `fork`, `sync`, `edit`, `rename`, `archive`, `unarchive`, `delete` (with slug-confirmation), `branches`, `topics`. * `fj api` gains `-H` headers, `--paginate` (follows Link rel=next), `--include` (response headers), `--silent`. The jq-ish projector now supports `[N]`/`[-N]` brackets and `|` pipes. * MSRV bumped to 1.82 (uses `is_none_or`). * 46 unit tests covering pure logic: hosts CRUD, remote URL parsing, link header parser, jq projection, branch label fallback, slugify. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
495276f654
commit
191d941c78
|
|
@ -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 <stephen@rasterstate.com>"]
|
||||
license = "MIT"
|
||||
|
|
|
|||
37
hooks/pre-push
Executable file
37
hooks/pre-push
Executable file
|
|
@ -0,0 +1,37 @@
|
|||
#!/usr/bin/env bash
|
||||
# fj pre-push hook — local CI gate.
|
||||
# Runs fmt-check, clippy, tests, and a release build. With FJ_E2E=1 also
|
||||
# runs the live-API smoke suite against the currently signed-in host.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
# Allow skip in genuine emergencies. Don't use this unless you know why.
|
||||
if [[ "${FJ_SKIP_PREPUSH:-0}" = "1" ]]; then
|
||||
echo "fj pre-push: skipped (FJ_SKIP_PREPUSH=1)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
step() {
|
||||
printf '\n\033[1;34m== %s ==\033[0m\n' "$*"
|
||||
}
|
||||
|
||||
step "cargo fmt --check"
|
||||
cargo fmt --all -- --check
|
||||
|
||||
step "cargo clippy (deny warnings)"
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
step "cargo test"
|
||||
cargo test --all --locked
|
||||
|
||||
step "cargo build --release"
|
||||
cargo build --release --locked
|
||||
|
||||
if [[ "${FJ_E2E:-0}" = "1" ]]; then
|
||||
step "E2E smoke (live API)"
|
||||
./scripts/e2e-smoke.sh
|
||||
fi
|
||||
|
||||
printf '\n\033[1;32m✓ pre-push checks passed\033[0m\n'
|
||||
44
scripts/e2e-smoke.sh
Executable file
44
scripts/e2e-smoke.sh
Executable file
|
|
@ -0,0 +1,44 @@
|
|||
#!/usr/bin/env bash
|
||||
# Live-API smoke: hit a curated set of read-only endpoints via the just-built
|
||||
# binary. Requires the user to be logged in (`fj auth login`). Designed for
|
||||
# the pre-push hook when FJ_E2E=1.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
FJ="${FJ_BIN:-./target/release/fj}"
|
||||
TEST_REPO="${FJ_E2E_REPO:-stephen/fj-cli-test}"
|
||||
|
||||
run() {
|
||||
printf ' • %s\n' "$*"
|
||||
"$@" >/dev/null
|
||||
}
|
||||
|
||||
if [[ ! -x "$FJ" ]]; then
|
||||
echo "missing $FJ; run cargo build --release first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== auth =="
|
||||
run "$FJ" auth status
|
||||
run "$FJ" auth list
|
||||
|
||||
echo "== api =="
|
||||
run "$FJ" api /version
|
||||
run "$FJ" api /user
|
||||
|
||||
echo "== repo =="
|
||||
run "$FJ" repo list -L 5
|
||||
run "$FJ" repo view "$TEST_REPO"
|
||||
run "$FJ" repo view "$TEST_REPO" --json
|
||||
|
||||
echo "== issue =="
|
||||
run "$FJ" issue list -R "$TEST_REPO" --state all
|
||||
run "$FJ" issue list -R "$TEST_REPO" --json
|
||||
|
||||
echo "== pr =="
|
||||
run "$FJ" pr list -R "$TEST_REPO" --state all
|
||||
run "$FJ" pr list -R "$TEST_REPO" --json
|
||||
|
||||
echo "✓ e2e smoke passed against $TEST_REPO"
|
||||
19
scripts/install-hooks.sh
Executable file
19
scripts/install-hooks.sh
Executable file
|
|
@ -0,0 +1,19 @@
|
|||
#!/usr/bin/env bash
|
||||
# Install the repo's hooks into .git/hooks via symlink so updates in tree
|
||||
# are picked up automatically.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
hooks_dir=".git/hooks"
|
||||
mkdir -p "$hooks_dir"
|
||||
|
||||
for src in hooks/*; do
|
||||
name="$(basename "$src")"
|
||||
dest="$hooks_dir/$name"
|
||||
rm -f "$dest"
|
||||
ln -s "../../$src" "$dest"
|
||||
chmod +x "$src"
|
||||
echo "✓ linked $name"
|
||||
done
|
||||
|
|
@ -174,9 +174,7 @@ pub async fn comment(
|
|||
) -> Result<Comment> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/issues/{number}/comments");
|
||||
let payload = CreateComment { body };
|
||||
client
|
||||
.json(Method::POST, &path, &[], Some(&payload))
|
||||
.await
|
||||
client.json(Method::POST, &path, &[], Some(&payload)).await
|
||||
}
|
||||
|
||||
pub async fn list_comments(
|
||||
|
|
|
|||
|
|
@ -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 '<owner>/<name>', got '{repo}'"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_normal_slug() {
|
||||
let (o, n) = split_repo("rasterstate/fj").unwrap();
|
||||
assert_eq!(o, "rasterstate");
|
||||
assert_eq!(n, "fj");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_slash() {
|
||||
assert!(split_repo("fj").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_owner() {
|
||||
assert!(split_repo("/fj").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_name() {
|
||||
assert!(split_repo("rasterstate/").is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
186
src/api/pull.rs
186
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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Commit {
|
||||
pub sha: String,
|
||||
#[serde(default)]
|
||||
pub commit: CommitMeta,
|
||||
#[serde(default)]
|
||||
pub html_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct CommitMeta {
|
||||
#[serde(default)]
|
||||
pub message: String,
|
||||
#[serde(default)]
|
||||
pub author: Option<CommitAuthor>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CommitAuthor {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub email: String,
|
||||
#[serde(default)]
|
||||
pub date: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Review {
|
||||
pub id: u64,
|
||||
pub state: String,
|
||||
#[serde(default)]
|
||||
pub body: String,
|
||||
pub user: User,
|
||||
pub submitted_at: Option<DateTime<Utc>>,
|
||||
pub html_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
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::<Pull, _>(Method::PATCH, &path, &[], Some(&body))
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn files(client: &Client, owner: &str, name: &str, number: u64) -> Result<Vec<PullFile>> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/pulls/{number}/files");
|
||||
client.json(Method::GET, &path, &[], None::<&()>).await
|
||||
}
|
||||
|
||||
pub async fn commits(client: &Client, owner: &str, name: &str, number: u64) -> Result<Vec<Commit>> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/pulls/{number}/commits");
|
||||
client.json(Method::GET, &path, &[], None::<&()>).await
|
||||
}
|
||||
|
||||
/// Raw unified diff for a PR.
|
||||
pub async fn diff_text(client: &Client, owner: &str, name: &str, number: u64) -> Result<String> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/pulls/{number}.diff");
|
||||
let res = client.request(Method::GET, &path, &[], None).await?;
|
||||
let res = res.error_for_status()?;
|
||||
Ok(res.text().await?)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ReviewEvent {
|
||||
Approve,
|
||||
RequestChanges,
|
||||
Comment,
|
||||
}
|
||||
|
||||
impl ReviewEvent {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
ReviewEvent::Approve => "APPROVED",
|
||||
ReviewEvent::RequestChanges => "REQUEST_CHANGES",
|
||||
ReviewEvent::Comment => "COMMENT",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct CreateReviewBody<'a> {
|
||||
event: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
body: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub async fn submit_review(
|
||||
client: &Client,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
number: u64,
|
||||
event: ReviewEvent,
|
||||
body: Option<&str>,
|
||||
) -> Result<Review> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/pulls/{number}/reviews");
|
||||
client
|
||||
.json(
|
||||
Method::POST,
|
||||
&path,
|
||||
&[],
|
||||
Some(&CreateReviewBody {
|
||||
event: event.as_str(),
|
||||
body,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_reviews(
|
||||
client: &Client,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
number: u64,
|
||||
) -> Result<Vec<Review>> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/pulls/{number}/reviews");
|
||||
client.json(Method::GET, &path, &[], None::<&()>).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
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<CommitStatus>,
|
||||
#[serde(default)]
|
||||
pub total_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CommitStatus {
|
||||
#[serde(default)]
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub context: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub target_url: String,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
pub async fn combined_status(
|
||||
client: &Client,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
sha: &str,
|
||||
) -> Result<CombinedStatus> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/commits/{sha}/status");
|
||||
client.json(Method::GET, &path, &[], None::<&()>).await
|
||||
}
|
||||
|
|
|
|||
186
src/api/repo.rs
186
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<Page<Searc
|
|||
query.push(("q".into(), q.into()));
|
||||
}
|
||||
let res = client
|
||||
.request(
|
||||
Method::GET,
|
||||
"/api/v1/repos/search",
|
||||
&query,
|
||||
None,
|
||||
)
|
||||
.request(Method::GET, "/api/v1/repos/search", &query, None)
|
||||
.await?;
|
||||
let headers = res.headers().clone();
|
||||
let body: SearchResponse = res.error_for_status()?.json().await?;
|
||||
Ok(Page::from_headers(body.data, &headers).also_total(body.ok))
|
||||
Ok(Page::from_headers(body.data, &headers))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -80,15 +77,10 @@ struct SearchResponse {
|
|||
#[serde(default)]
|
||||
data: Vec<SearchHit>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
impl<T> Page<T> {
|
||||
fn also_total(self, _ok: bool) -> Self {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub type SearchHit = Repo;
|
||||
|
||||
pub async fn get(client: &Client, owner: &str, name: &str) -> Result<Repo> {
|
||||
|
|
@ -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<Repo> {
|
||||
pub async fn create_for_org(client: &Client, org: &str, body: &CreateRepo<'_>) -> Result<Repo> {
|
||||
let path = format!("/api/v1/orgs/{org}/repos");
|
||||
client.json(Method::POST, &path, &[], Some(body)).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Default)]
|
||||
pub struct EditRepo<'a> {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub website: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub private: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_branch: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub archived: Option<bool>,
|
||||
}
|
||||
|
||||
pub async fn edit(
|
||||
client: &Client,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
body: &EditRepo<'_>,
|
||||
) -> Result<Repo> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}");
|
||||
client.json(Method::PATCH, &path, &[], Some(body)).await
|
||||
}
|
||||
|
||||
pub async fn delete(client: &Client, owner: &str, name: &str) -> Result<()> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}");
|
||||
let res = client.request(Method::DELETE, &path, &[], None).await?;
|
||||
res.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct ForkBody<'a> {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
organization: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub async fn fork(
|
||||
client: &Client,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
organization: Option<&str>,
|
||||
new_name: Option<&str>,
|
||||
) -> Result<Repo> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/forks");
|
||||
client
|
||||
.json(
|
||||
Method::POST,
|
||||
&path,
|
||||
&[],
|
||||
Some(&ForkBody {
|
||||
organization,
|
||||
name: new_name,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn rename(client: &Client, owner: &str, name: &str, new_name: &str) -> Result<()> {
|
||||
// Forgejo: PATCH /repos/{owner}/{name} with `name` field.
|
||||
let path = format!("/api/v1/repos/{owner}/{name}");
|
||||
#[derive(Serialize)]
|
||||
struct Body<'a> {
|
||||
name: &'a str,
|
||||
}
|
||||
let res = client
|
||||
.request(
|
||||
Method::PATCH,
|
||||
&path,
|
||||
&[],
|
||||
Some(&serde_json::to_value(Body { name: new_name })?),
|
||||
)
|
||||
.await?;
|
||||
res.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct MergeUpstream<'a> {
|
||||
pub branch: &'a str,
|
||||
}
|
||||
|
||||
pub async fn sync_with_upstream(
|
||||
client: &Client,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
branch: &str,
|
||||
) -> Result<()> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/merge-upstream");
|
||||
let res = client
|
||||
.request(
|
||||
Method::POST,
|
||||
&path,
|
||||
&[],
|
||||
Some(&serde_json::to_value(MergeUpstream { branch })?),
|
||||
)
|
||||
.await?;
|
||||
res.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Branch {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub protected: bool,
|
||||
pub commit: BranchCommit,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BranchCommit {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub async fn list_branches(
|
||||
client: &Client,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
) -> Result<Vec<Branch>> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/branches");
|
||||
client.json(Method::GET, &path, &[], None::<&()>).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Topic {
|
||||
#[serde(default)]
|
||||
pub topics: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn list_topics(
|
||||
client: &Client,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
) -> Result<Vec<String>> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/topics");
|
||||
let t: Topic = client.json(Method::GET, &path, &[], None::<&()>).await?;
|
||||
Ok(t.topics)
|
||||
}
|
||||
|
||||
pub async fn set_topics(
|
||||
client: &Client,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
topics: &[String],
|
||||
) -> Result<()> {
|
||||
let path = format!("/api/v1/repos/{owner}/{name}/topics");
|
||||
let res = client
|
||||
.request(
|
||||
Method::PUT,
|
||||
&path,
|
||||
&[],
|
||||
Some(&serde_json::json!({ "topics": topics })),
|
||||
)
|
||||
.await?;
|
||||
res.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,5 +19,7 @@ pub struct User {
|
|||
}
|
||||
|
||||
pub async fn current(client: &Client) -> Result<User> {
|
||||
client.json(Method::GET, "/api/v1/user", &[], None::<&()>).await
|
||||
client
|
||||
.json(Method::GET, "/api/v1/user", &[], None::<&()>)
|
||||
.await
|
||||
}
|
||||
|
|
|
|||
354
src/cli/api.rs
354
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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// Like `-f` but interprets the value as raw JSON (numbers, bools, arrays, objects).
|
||||
/// Like `-f` but interprets the value as raw JSON.
|
||||
#[arg(short = 'F', long = "raw-field", value_name = "KEY=VALUE")]
|
||||
pub raw_fields: Vec<String>,
|
||||
|
||||
/// Output a specific JSON path (very small jq-ish subset, e.g. `.items.0.title`).
|
||||
/// Add a custom request header (`-H name=value`). Repeatable.
|
||||
#[arg(short = 'H', long = "header", value_name = "NAME=VALUE")]
|
||||
pub headers: Vec<String>,
|
||||
|
||||
/// Output a JSON path projection. Supports dot paths, [N] indices,
|
||||
/// negative indices like [-1], and pipes (`. | .field`).
|
||||
#[arg(short = 'q', long = "jq")]
|
||||
pub jq: Option<String>,
|
||||
|
||||
/// Send a literal JSON request body. Conflicts with `-f` / `-F`.
|
||||
#[arg(long, conflicts_with_all = ["fields", "raw_fields"])]
|
||||
pub input: Option<String>,
|
||||
|
||||
/// 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<Method> {
|
||||
|
|
@ -70,22 +123,17 @@ fn pick_method(args: &ApiArgs) -> Result<Method> {
|
|||
return Method::from_bytes(m.to_uppercase().as_bytes())
|
||||
.with_context(|| format!("invalid HTTP method '{m}'"));
|
||||
}
|
||||
// Only `--input` (a literal JSON body) implies POST. `-f` / `-F` on a GET
|
||||
// are sent as query params, which is what users hit search endpoints for.
|
||||
// To send a write request, pass `-X POST` (or PATCH/PUT/DELETE) explicitly.
|
||||
if args.input.is_some() {
|
||||
return Ok(Method::POST);
|
||||
}
|
||||
Ok(Method::GET)
|
||||
}
|
||||
|
||||
fn build_query_or_body(
|
||||
args: &ApiArgs,
|
||||
method: &Method,
|
||||
) -> Result<(Vec<(String, String)>, Option<Value>)> {
|
||||
type QueryAndBody = (Vec<(String, String)>, Option<Value>);
|
||||
|
||||
fn build_query_or_body(args: &ApiArgs, method: &Method) -> Result<QueryAndBody> {
|
||||
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<HeaderMap> {
|
||||
let mut map = HeaderMap::new();
|
||||
for item in items {
|
||||
let (k, v) = item
|
||||
.split_once(':')
|
||||
.or_else(|| item.split_once('='))
|
||||
.ok_or_else(|| anyhow!("expected NAME: VALUE, got '{item}'"))?;
|
||||
let name = HeaderName::from_bytes(k.trim().as_bytes())
|
||||
.with_context(|| format!("invalid header name '{k}'"))?;
|
||||
let value = HeaderValue::from_str(v.trim())
|
||||
.with_context(|| format!("invalid header value for '{k}'"))?;
|
||||
map.insert(name, value);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
async fn collect_paginated(
|
||||
client: &Client,
|
||||
endpoint: &str,
|
||||
query: &[(String, String)],
|
||||
extra_headers: &HeaderMap,
|
||||
) -> Result<Value> {
|
||||
let mut all_items = Vec::<Value>::new();
|
||||
let mut current_endpoint: String = endpoint.to_string();
|
||||
let mut current_query: Vec<(String, String)> = query.to_vec();
|
||||
loop {
|
||||
let res = client
|
||||
.request_with_headers(
|
||||
Method::GET,
|
||||
¤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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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::<usize>() {
|
||||
current = current
|
||||
.get(idx)
|
||||
.ok_or_else(|| anyhow!("index {idx} out of range at '{segment}'"))?;
|
||||
let mut owned: Option<Value> = 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 {
|
||||
current = current
|
||||
.get(segment)
|
||||
.ok_or_else(|| anyhow!("no field '{segment}'"))?;
|
||||
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::<usize>() {
|
||||
let v = target
|
||||
.get(idx)
|
||||
.ok_or_else(|| anyhow!("index {idx} out of range"))?
|
||||
.clone();
|
||||
owned = Some(v);
|
||||
current = owned.as_ref().unwrap();
|
||||
} else {
|
||||
let v = target
|
||||
.get(raw_str)
|
||||
.ok_or_else(|| anyhow!("no field '{raw_str}'"))?
|
||||
.clone();
|
||||
owned = Some(v);
|
||||
current = owned.as_ref().unwrap();
|
||||
}
|
||||
}
|
||||
Ok(current.clone())
|
||||
Ok(owned.unwrap_or_else(|| value.clone()))
|
||||
}
|
||||
|
||||
/// Split a stage like `data.0[3].name` into ["data", "0", "[3]", "name"].
|
||||
fn split_segments(stage: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut buf = String::new();
|
||||
let mut chars = stage.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'.' => {
|
||||
if !buf.is_empty() {
|
||||
out.push(std::mem::take(&mut buf));
|
||||
}
|
||||
}
|
||||
'[' => {
|
||||
if !buf.is_empty() {
|
||||
out.push(std::mem::take(&mut buf));
|
||||
}
|
||||
let mut br = String::from('[');
|
||||
for c2 in chars.by_ref() {
|
||||
br.push(c2);
|
||||
if c2 == ']' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.push(br);
|
||||
}
|
||||
_ => buf.push(c),
|
||||
}
|
||||
}
|
||||
if !buf.is_empty() {
|
||||
out.push(buf);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn extract_root() {
|
||||
let v = json!({"a": 1});
|
||||
assert_eq!(extract_path(&v, "").unwrap(), v);
|
||||
assert_eq!(extract_path(&v, ".").unwrap(), v);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_field() {
|
||||
let v = json!({"a": {"b": 42}});
|
||||
assert_eq!(extract_path(&v, ".a.b").unwrap(), json!(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_array_index_dotted() {
|
||||
let v = json!({"data": [{"name": "first"}, {"name": "second"}]});
|
||||
assert_eq!(extract_path(&v, ".data.1.name").unwrap(), json!("second"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_array_index_bracket() {
|
||||
let v = json!({"data": [10, 20, 30]});
|
||||
assert_eq!(extract_path(&v, ".data[1]").unwrap(), json!(20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_negative_index() {
|
||||
let v = json!([1, 2, 3, 4]);
|
||||
assert_eq!(extract_path(&v, "[-1]").unwrap(), json!(4));
|
||||
assert_eq!(extract_path(&v, "[-2]").unwrap(), json!(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_pipe() {
|
||||
let v = json!({"data": [{"x": 7}]});
|
||||
assert_eq!(extract_path(&v, ".data[0] | .x").unwrap(), json!(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_missing_field_errors() {
|
||||
let v = json!({"a": 1});
|
||||
assert!(extract_path(&v, ".missing").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_out_of_range_errors() {
|
||||
let v = json!([1, 2]);
|
||||
assert!(extract_path(&v, ".5").is_err());
|
||||
assert!(extract_path(&v, "[5]").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_kv_works() {
|
||||
let (k, v) = split_kv("a=b").unwrap();
|
||||
assert_eq!(k, "a");
|
||||
assert_eq!(v, "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_kv_keeps_equals_in_value() {
|
||||
let (k, v) = split_kv("filter=a=b").unwrap();
|
||||
assert_eq!(k, "filter");
|
||||
assert_eq!(v, "a=b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_headers_basic() {
|
||||
let h = parse_headers(&["X-Custom: hello".into()]).unwrap();
|
||||
assert_eq!(h.get("x-custom").unwrap(), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_headers_equals_form() {
|
||||
let h = parse_headers(&["X-Custom=world".into()]).unwrap();
|
||||
assert_eq!(h.get("x-custom").unwrap(), "world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_headers_rejects_unfielded() {
|
||||
assert!(parse_headers(&["nope".into()]).is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)? {
|
||||
|
|
|
|||
87
src/cli/context.rs
Normal file
87
src/cli/context.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
//! Shared helpers for resolving the target repo + host across subcommands.
|
||||
//!
|
||||
//! The contract: each subcommand accepts `-R <owner>/<name>` plus the global
|
||||
//! `--host`. When `-R` is omitted, we infer the repo from the current
|
||||
//! directory's git remotes, preferring `upstream` then `origin`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Args;
|
||||
|
||||
use crate::api;
|
||||
use crate::client::Client;
|
||||
use crate::config::hosts::Hosts;
|
||||
use crate::git;
|
||||
|
||||
/// Flattened `-R/--repo` flag used by every repo-scoped subcommand. When
|
||||
/// omitted, the repo is inferred from the current directory's git remotes.
|
||||
#[derive(Debug, Args, Clone, Default)]
|
||||
pub struct RepoFlag {
|
||||
/// Target repository as `<owner>/<name>`. Inferred from the git remote
|
||||
/// when omitted.
|
||||
#[arg(short = 'R', long = "repo", global = true)]
|
||||
pub repo: Option<String>,
|
||||
}
|
||||
|
||||
/// A repo target resolved from either `-R` or git remotes, plus a connected
|
||||
/// API client for the correct host.
|
||||
pub struct RepoContext {
|
||||
pub client: Client,
|
||||
pub host: String,
|
||||
pub owner: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl RepoContext {
|
||||
#[allow(dead_code)]
|
||||
pub fn slug(&self) -> String {
|
||||
format!("{}/{}", self.owner, self.name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a repo from `-R` (`owner/name`) or auto-detect from the git remote.
|
||||
/// Honors the global `--host` flag for host selection.
|
||||
pub fn resolve_repo(
|
||||
explicit_repo: Option<&str>,
|
||||
explicit_host: Option<&str>,
|
||||
) -> Result<RepoContext> {
|
||||
let (host_for_client, owner, name) = match explicit_repo {
|
||||
Some(slug) => {
|
||||
let (o, n) = api::split_repo(slug)?;
|
||||
(
|
||||
explicit_host.map(str::to_string),
|
||||
o.to_string(),
|
||||
n.to_string(),
|
||||
)
|
||||
}
|
||||
None => {
|
||||
let detected = git::discover(explicit_host)
|
||||
.context("repository not specified and could not be inferred from git remote")?;
|
||||
// If the user passed --host, keep that as the API host even though
|
||||
// the remote URL also happens to name a host. They should match in
|
||||
// practice, but the explicit flag wins.
|
||||
(
|
||||
Some(explicit_host.unwrap_or(&detected.host).to_string()),
|
||||
detected.owner,
|
||||
detected.name,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// If the user didn't pass --host AND we didn't autodetect, fall back to
|
||||
// the configured default host.
|
||||
let client_host = host_for_client.or_else(|| {
|
||||
Hosts::load()
|
||||
.ok()
|
||||
.and_then(|h| h.resolve_host(None).ok().map(|s| s.to_string()))
|
||||
});
|
||||
|
||||
let client = Client::connect(client_host.as_deref())?;
|
||||
let host = client_host.unwrap_or_else(|| client.host().to_string());
|
||||
|
||||
Ok(RepoContext {
|
||||
client,
|
||||
host,
|
||||
owner,
|
||||
name,
|
||||
})
|
||||
}
|
||||
116
src/cli/editor.rs
Normal file
116
src/cli/editor.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
//! Editor + interactive prompt helpers used by `issue create`, `pr create`,
|
||||
//! and `*-comment` commands.
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
/// Default editor binary, in order: $VISUAL, $EDITOR, then `vi`.
|
||||
fn editor_command() -> String {
|
||||
env::var("VISUAL")
|
||||
.ok()
|
||||
.or_else(|| env::var("EDITOR").ok())
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| "vi".into())
|
||||
}
|
||||
|
||||
/// Open `$EDITOR` with `initial_contents` and return the saved buffer with
|
||||
/// trailing whitespace trimmed. `template_name` is just the suffix of the
|
||||
/// temp file (helps editors pick syntax: `ISSUE_BODY.md`, etc.).
|
||||
pub fn edit_text(template_name: &str, initial_contents: &str) -> Result<String> {
|
||||
let mut tmp = env::temp_dir();
|
||||
tmp.push(format!("fj-{}-{}", std::process::id(), template_name));
|
||||
{
|
||||
let mut file = fs::File::create(&tmp)
|
||||
.with_context(|| format!("creating temp file at {}", tmp.display()))?;
|
||||
file.write_all(initial_contents.as_bytes())?;
|
||||
}
|
||||
|
||||
let editor = editor_command();
|
||||
// Split on whitespace so users can set `EDITOR="code -w"`.
|
||||
let mut parts = editor.split_whitespace();
|
||||
let program = parts
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("editor command is empty"))?;
|
||||
let extra_args: Vec<&str> = parts.collect();
|
||||
let status = Command::new(program)
|
||||
.args(&extra_args)
|
||||
.arg(&tmp)
|
||||
.status()
|
||||
.with_context(|| format!("launching editor `{editor}`"))?;
|
||||
if !status.success() {
|
||||
return Err(anyhow!("editor `{editor}` exited with status {status}"));
|
||||
}
|
||||
|
||||
let mut buf = String::new();
|
||||
fs::File::open(&tmp)?
|
||||
.read_to_string(&mut buf)
|
||||
.context("reading edited buffer back from temp file")?;
|
||||
let _ = fs::remove_file(&tmp);
|
||||
Ok(buf.trim_end().to_string())
|
||||
}
|
||||
|
||||
/// For body-style arguments. Resolves the value of a `--body` flag with three
|
||||
/// modes: literal string, `-` for stdin, or `None` to open `$EDITOR` seeded
|
||||
/// with `template_initial`. Returns `Ok(None)` only if the user closes the
|
||||
/// editor with an empty buffer AND the field is optional.
|
||||
pub fn resolve_body(
|
||||
flag: Option<&str>,
|
||||
template_name: &str,
|
||||
template_initial: &str,
|
||||
) -> Result<Option<String>> {
|
||||
match flag {
|
||||
Some("-") => {
|
||||
let mut buf = String::new();
|
||||
std::io::stdin().read_to_string(&mut buf)?;
|
||||
let trimmed = buf.trim_end().to_string();
|
||||
if trimmed.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(trimmed))
|
||||
}
|
||||
}
|
||||
Some(s) => Ok(Some(s.to_string())),
|
||||
None => {
|
||||
// If stdin isn't a TTY we can't reasonably open an editor; default
|
||||
// to "no body" rather than hanging.
|
||||
use std::io::IsTerminal;
|
||||
if !std::io::stdin().is_terminal() {
|
||||
return Ok(None);
|
||||
}
|
||||
let text = edit_text(template_name, template_initial)?;
|
||||
if text.trim().is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(text))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a `--body` flag without launching the editor on omission. Used by
|
||||
/// commands where the body is optional and we don't want a surprise editor.
|
||||
pub fn read_body(flag: Option<&str>) -> Result<Option<String>> {
|
||||
match flag {
|
||||
None => Ok(None),
|
||||
Some("-") => {
|
||||
let mut buf = String::new();
|
||||
std::io::stdin().read_to_string(&mut buf)?;
|
||||
Ok(Some(buf.trim_end().to_string()))
|
||||
}
|
||||
Some(s) => Ok(Some(s.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt for a single line on stderr, returning a trimmed string.
|
||||
pub fn prompt_line(label: &str) -> Result<String> {
|
||||
use std::io::{stderr, stdin};
|
||||
eprint!("{label}: ");
|
||||
stderr().flush().ok();
|
||||
let mut buf = String::new();
|
||||
stdin().read_line(&mut buf).context("reading line")?;
|
||||
Ok(buf.trim().to_string())
|
||||
}
|
||||
270
src/cli/issue.rs
270
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<StateFilter> 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<String>,
|
||||
#[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<String>,
|
||||
/// Body. Use `-` to read from stdin. Omit to open `$EDITOR`.
|
||||
#[arg(short = 'b', long)]
|
||||
pub body: Option<String>,
|
||||
/// Open the new issue in your browser after creating.
|
||||
#[arg(long)]
|
||||
pub web: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct EditArgs {
|
||||
#[command(flatten)]
|
||||
pub r: RepoFlag,
|
||||
pub number: u64,
|
||||
#[arg(short = 't', long)]
|
||||
pub title: Option<String>,
|
||||
/// New body. Use `-` to read from stdin. Pass `--body-editor` to open `$EDITOR`.
|
||||
#[arg(short = 'b', long)]
|
||||
pub body: Option<String>,
|
||||
/// Open `$EDITOR` to edit the body inline (overrides `--body`).
|
||||
#[arg(long)]
|
||||
pub body_editor: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct DevelopArgs {
|
||||
#[command(flatten)]
|
||||
pub r: RepoFlag,
|
||||
pub number: u64,
|
||||
/// Branch name. Defaults to `issue-<number>-<slug-of-title>`.
|
||||
#[arg(short = 'n', long)]
|
||||
pub name: Option<String>,
|
||||
/// Base branch to start from. Defaults to the repo's default branch.
|
||||
#[arg(long)]
|
||||
pub base: Option<String>,
|
||||
/// Just print the branch name; don't run git.
|
||||
#[arg(long)]
|
||||
pub dry_run: bool,
|
||||
}
|
||||
|
||||
pub async fn run(cmd: IssueCmd, host: Option<&str>) -> Result<()> {
|
||||
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::<Vec<_>>()
|
||||
.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 == "-" {
|
||||
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
|
||||
} else {
|
||||
args.body
|
||||
}
|
||||
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<Option<String>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
595
src/cli/pr.rs
595
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<MergeStyleArg> for MergeStyle {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
pub enum ReviewEventArg {
|
||||
Approve,
|
||||
RequestChanges,
|
||||
Comment,
|
||||
}
|
||||
|
||||
impl From<ReviewEventArg> for ReviewEvent {
|
||||
fn from(value: ReviewEventArg) -> Self {
|
||||
match value {
|
||||
ReviewEventArg::Approve => ReviewEvent::Approve,
|
||||
ReviewEventArg::RequestChanges => ReviewEvent::RequestChanges,
|
||||
ReviewEventArg::Comment => ReviewEvent::Comment,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
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<String>,
|
||||
#[arg(short = 'B', long, default_value = "main")]
|
||||
pub base: String,
|
||||
/// Head branch. Either a branch name on the same repo, or `owner:branch`.
|
||||
#[arg(short = 'H', long)]
|
||||
pub head: String,
|
||||
/// Body. Use `-` to read from stdin.
|
||||
pub head: Option<String>,
|
||||
/// Body. Use `-` to read from stdin. Omit to open `$EDITOR`.
|
||||
#[arg(short = 'b', long)]
|
||||
pub body: Option<String>,
|
||||
/// Mark the PR as draft.
|
||||
#[arg(long)]
|
||||
pub draft: bool,
|
||||
/// Open the new PR in your browser after creating.
|
||||
#[arg(long)]
|
||||
pub web: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct EditArgs {
|
||||
#[command(flatten)]
|
||||
pub r: RepoFlag,
|
||||
pub number: u64,
|
||||
#[arg(short = 't', long)]
|
||||
pub title: Option<String>,
|
||||
#[arg(short = 'b', long)]
|
||||
pub body: Option<String>,
|
||||
/// Open `$EDITOR` for the body (replaces the current body).
|
||||
#[arg(long)]
|
||||
pub body_editor: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct ReviewArgs {
|
||||
#[command(flatten)]
|
||||
pub r: RepoFlag,
|
||||
pub number: u64,
|
||||
/// Review action.
|
||||
#[arg(value_enum, long, short = 'e')]
|
||||
pub event: ReviewEventArg,
|
||||
/// Review body. Use `-` for stdin. Omit to open `$EDITOR` (unless the
|
||||
/// event is `approve` and you have nothing to say).
|
||||
#[arg(short = 'b', long)]
|
||||
pub body: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct StatusArgs {
|
||||
#[arg(short = 'L', long, default_value_t = 20)]
|
||||
pub limit: u32,
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
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<String>,
|
||||
/// Git remote to fetch from.
|
||||
#[arg(long, default_value = "origin")]
|
||||
pub remote: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct MergeArgs {
|
||||
#[arg(short = 'R', long = "repo")]
|
||||
pub repo: String,
|
||||
#[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<Vec<String>> = cs
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let short = c.sha.get(..7).unwrap_or(&c.sha).to_string();
|
||||
let subject = c.commit.message.lines().next().unwrap_or("").to_string();
|
||||
let author = c
|
||||
.commit
|
||||
.author
|
||||
.as_ref()
|
||||
.map(|a| a.name.clone())
|
||||
.unwrap_or_default();
|
||||
vec![short, truncate(&subject, 70), output::dim(&author)]
|
||||
})
|
||||
.collect();
|
||||
if rows.is_empty() {
|
||||
println!("(no commits)");
|
||||
} else {
|
||||
print!(
|
||||
"{}",
|
||||
output::render_table(&["SHA", "SUBJECT", "AUTHOR"], &rows)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn files(args: SimpleArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
let fs = api::pull::files(&ctx.client, &ctx.owner, &ctx.name, args.number).await?;
|
||||
let rows: Vec<Vec<String>> = fs
|
||||
.iter()
|
||||
.map(|f| {
|
||||
vec![
|
||||
f.status.clone(),
|
||||
f.filename.clone(),
|
||||
output::dim(&format!("+{} -{}", f.additions, f.deletions)),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
if rows.is_empty() {
|
||||
println!("(no file changes)");
|
||||
} else {
|
||||
print!(
|
||||
"{}",
|
||||
output::render_table(&["STATUS", "FILE", "DIFF"], &rows)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn checks(args: SimpleArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
let pr = api::pull::get(&ctx.client, &ctx.owner, &ctx.name, args.number).await?;
|
||||
let cs = api::pull::combined_status(&ctx.client, &ctx.owner, &ctx.name, &pr.head.sha).await?;
|
||||
println!(
|
||||
"{} {}",
|
||||
output::bold(&format!(
|
||||
"Combined: {}",
|
||||
if cs.state.is_empty() {
|
||||
"—"
|
||||
} else {
|
||||
&cs.state
|
||||
}
|
||||
)),
|
||||
output::dim(&format!(
|
||||
"{} checks on {}",
|
||||
cs.total_count,
|
||||
&pr.head.sha[..7.min(pr.head.sha.len())]
|
||||
)),
|
||||
);
|
||||
if cs.statuses.is_empty() {
|
||||
println!("(no per-check statuses reported)");
|
||||
return Ok(());
|
||||
}
|
||||
let rows: Vec<Vec<String>> = cs
|
||||
.statuses
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let when = s
|
||||
.updated_at
|
||||
.map(output::relative_time)
|
||||
.unwrap_or_else(|| "—".into());
|
||||
vec![
|
||||
output::state_pill(&s.status.to_lowercase(), false),
|
||||
s.context.clone(),
|
||||
truncate(&s.description, 50),
|
||||
output::dim(&when),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
print!(
|
||||
"{}",
|
||||
output::render_table(&["STATE", "CHECK", "DESCRIPTION", "WHEN"], &rows)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ready(args: SimpleArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
api::pull::ready(&ctx.client, &ctx.owner, &ctx.name, args.number).await?;
|
||||
println!("✓ PR #{} is ready for review", args.number);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn review(args: ReviewArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
let body_required = matches!(args.event, ReviewEventArg::RequestChanges);
|
||||
let body = match args.body.as_deref() {
|
||||
Some("-") => {
|
||||
let mut buf = String::new();
|
||||
std::io::stdin().read_to_string(&mut buf)?;
|
||||
Some(buf.trim_end().to_string())
|
||||
}
|
||||
Some(s) => Some(s.to_string()),
|
||||
None => {
|
||||
if body_required {
|
||||
Some(editor::edit_text("PR_REVIEW.md", "")?)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let body = body.filter(|s| !s.trim().is_empty());
|
||||
let r = api::pull::submit_review(
|
||||
&ctx.client,
|
||||
&ctx.owner,
|
||||
&ctx.name,
|
||||
args.number,
|
||||
args.event.into(),
|
||||
body.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
println!("✓ Submitted {} review on #{}", r.state, args.number);
|
||||
println!("{}", r.html_url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn status(args: StatusArgs, host: Option<&str>) -> Result<()> {
|
||||
// Cross-repo dashboard: PRs authored by you, and PRs where you're listed
|
||||
// as a reviewer or where you're mentioned.
|
||||
let client = Client::connect(host)?;
|
||||
let me = api::user::current(&client).await?;
|
||||
let limit = args.limit.clamp(1, 50);
|
||||
|
||||
let q_authored: Vec<(String, String)> = vec![
|
||||
("type".into(), "pulls".into()),
|
||||
("state".into(), "open".into()),
|
||||
("created_by".into(), me.login.clone()),
|
||||
("limit".into(), limit.to_string()),
|
||||
];
|
||||
let q_review: Vec<(String, String)> = vec![
|
||||
("type".into(), "pulls".into()),
|
||||
("state".into(), "open".into()),
|
||||
("reviewed_by".into(), me.login.clone()),
|
||||
("limit".into(), limit.to_string()),
|
||||
];
|
||||
let q_mentioned: Vec<(String, String)> = vec![
|
||||
("type".into(), "pulls".into()),
|
||||
("state".into(), "open".into()),
|
||||
("mentioned_by".into(), me.login.clone()),
|
||||
("limit".into(), limit.to_string()),
|
||||
];
|
||||
|
||||
let path = "/api/v1/repos/issues/search";
|
||||
let authored: Vec<api::issue::Issue> = client
|
||||
.json(reqwest::Method::GET, path, &q_authored, None::<&()>)
|
||||
.await?;
|
||||
let review_requested: Vec<api::issue::Issue> = client
|
||||
.json(reqwest::Method::GET, path, &q_review, None::<&()>)
|
||||
.await?;
|
||||
let mentioned: Vec<api::issue::Issue> = client
|
||||
.json(reqwest::Method::GET, path, &q_mentioned, None::<&()>)
|
||||
.await?;
|
||||
|
||||
if args.json {
|
||||
return output::print_json(&serde_json::json!({
|
||||
"authored": authored,
|
||||
"review_requested": review_requested,
|
||||
"mentioned": mentioned,
|
||||
}));
|
||||
}
|
||||
|
||||
print_status_section("Created by you", &authored);
|
||||
print_status_section("Requesting your review", &review_requested);
|
||||
print_status_section("Mentioning you", &mentioned);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_status_section(label: &str, items: &[api::issue::Issue]) {
|
||||
println!("{}", output::bold(label));
|
||||
if items.is_empty() {
|
||||
println!(" (none)");
|
||||
println!();
|
||||
return;
|
||||
}
|
||||
for i in items {
|
||||
println!(
|
||||
" #{} {} {}",
|
||||
i.number,
|
||||
truncate(&i.title, 70),
|
||||
output::dim(&i.html_url)
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
async fn checkout(args: CheckoutArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
||||
let pr = api::pull::get(&ctx.client, &ctx.owner, &ctx.name, args.number).await?;
|
||||
let branch = args.branch.unwrap_or_else(|| format!("pr-{}", pr.number));
|
||||
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<String> {
|
||||
use std::process::Command;
|
||||
let out = Command::new("git")
|
||||
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let name = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if name.is_empty() || name == "HEAD" {
|
||||
None
|
||||
} else {
|
||||
Some(name)
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
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('…'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
360
src/cli/repo.rs
360
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<String>,
|
||||
#[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<String>,
|
||||
/// Place the fork under this organization instead of your user account.
|
||||
#[arg(long)]
|
||||
pub org: Option<String>,
|
||||
/// New repository name.
|
||||
#[arg(long)]
|
||||
pub name: Option<String>,
|
||||
/// Clone the fork after creation.
|
||||
#[arg(long)]
|
||||
pub clone: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct SyncArgs {
|
||||
pub repo: Option<String>,
|
||||
/// Branch to sync. Defaults to the repo's default branch.
|
||||
#[arg(long)]
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct EditArgs {
|
||||
pub repo: Option<String>,
|
||||
#[arg(long)]
|
||||
pub description: Option<String>,
|
||||
#[arg(long)]
|
||||
pub website: Option<String>,
|
||||
#[arg(long)]
|
||||
pub default_branch: Option<String>,
|
||||
/// Force private/public. Accepts `true` or `false`.
|
||||
#[arg(long)]
|
||||
pub private: Option<bool>,
|
||||
/// Open `$EDITOR` for the description.
|
||||
#[arg(long)]
|
||||
pub description_editor: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct RenameArgs {
|
||||
pub repo: String,
|
||||
pub new_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct ArchiveArgs {
|
||||
pub repo: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct DeleteArgs {
|
||||
pub repo: String,
|
||||
/// Skip the confirmation prompt.
|
||||
#[arg(short = 'y', long)]
|
||||
pub yes: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct BranchesArgs {
|
||||
pub repo: Option<String>,
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct TopicsArgs {
|
||||
pub repo: Option<String>,
|
||||
/// Set the topic list (comma-separated). Omit to just print.
|
||||
#[arg(long)]
|
||||
pub set: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn run(cmd: RepoCmd, host: Option<&str>) -> Result<()> {
|
||||
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 {
|
||||
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?,
|
||||
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<Vec<String>> = bs
|
||||
.iter()
|
||||
.map(|b| {
|
||||
vec![
|
||||
b.name.clone(),
|
||||
output::dim(&b.commit.id[..7.min(b.commit.id.len())]),
|
||||
truncate(b.commit.message.lines().next().unwrap_or(""), 60),
|
||||
if b.protected {
|
||||
output::bold("protected")
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
print!(
|
||||
"{}",
|
||||
output::render_table(&["BRANCH", "SHA", "SUBJECT", ""], &rows)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn topics(args: TopicsArgs, host: Option<&str>) -> Result<()> {
|
||||
let ctx = resolve_repo(args.repo.as_deref(), host)?;
|
||||
if let Some(set) = args.set {
|
||||
let list: Vec<String> = set
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
api::repo::set_topics(&ctx.client, &ctx.owner, &ctx.name, &list).await?;
|
||||
println!("✓ Topics set to: {}", list.join(", "));
|
||||
return Ok(());
|
||||
}
|
||||
let topics = api::repo::list_topics(&ctx.client, &ctx.owner, &ctx.name).await?;
|
||||
if topics.is_empty() {
|
||||
println!("(no topics)");
|
||||
} else {
|
||||
for t in topics {
|
||||
println!("{t}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Silence the unused `RepoFlag` import for now — kept for symmetry with other
|
||||
// subcommands and exported through `cli::context`.
|
||||
const _: fn() -> Option<RepoFlag> = || None;
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
return s.to_string();
|
||||
|
|
|
|||
38
src/cli/web.rs
Normal file
38
src/cli/web.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
//! Open URLs in the user's default browser, like `gh ... --web`.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
pub fn open(url: &str) -> Result<()> {
|
||||
let (program, args) = picker();
|
||||
let status = Command::new(program)
|
||||
.args(args)
|
||||
.arg(url)
|
||||
.status()
|
||||
.with_context(|| format!("launching `{program}` to open URL"))?;
|
||||
if !status.success() {
|
||||
return Err(anyhow!("browser open command exited with status {status}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn picker() -> (&'static str, &'static [&'static str]) {
|
||||
("open", &[])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn picker() -> (&'static str, &'static [&'static str]) {
|
||||
("xdg-open", &[])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn picker() -> (&'static str, &'static [&'static str]) {
|
||||
("cmd", &["/C", "start"])
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn picker() -> (&'static str, &'static [&'static str]) {
|
||||
("xdg-open", &[])
|
||||
}
|
||||
|
|
@ -120,13 +120,26 @@ impl Client {
|
|||
path: &str,
|
||||
query: &[(String, String)],
|
||||
body: Option<&serde_json::Value>,
|
||||
) -> Result<Response> {
|
||||
self.request_with_headers(method, path, query, body, &HeaderMap::new())
|
||||
.await
|
||||
}
|
||||
|
||||
/// Like `request` but merges `extra` headers in (they override defaults).
|
||||
pub async fn request_with_headers(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
query: &[(String, String)],
|
||||
body: Option<&serde_json::Value>,
|
||||
extra: &HeaderMap,
|
||||
) -> Result<Response> {
|
||||
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<Response> {
|
|||
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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ impl<T> Page<T> {
|
|||
}
|
||||
}
|
||||
|
||||
fn parse_link_header(value: &str) -> Vec<(String, String)> {
|
||||
pub(crate) fn parse_link_header(value: &str) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
for part in value.split(',') {
|
||||
let part = part.trim();
|
||||
|
|
@ -59,11 +59,11 @@ fn parse_link_header(value: &str) -> Vec<(String, String)> {
|
|||
let Some((url_part, params)) = part.split_once(';') else {
|
||||
continue;
|
||||
};
|
||||
let url = url_part.trim().trim_start_matches('<').trim_end_matches('>');
|
||||
let rel = params
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.find_map(|p| {
|
||||
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())
|
||||
|
|
@ -77,3 +77,40 @@ fn parse_link_header(value: &str) -> Vec<(String, String)> {
|
|||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_single_link() {
|
||||
let input = r#"<https://example.com/api/v1/repos?page=2>; rel="next""#;
|
||||
let parsed = parse_link_header(input);
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0].0, "https://example.com/api/v1/repos?page=2");
|
||||
assert_eq!(parsed[0].1, "next");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_multiple_rels() {
|
||||
let input = r#"<https://x/p?page=2>; rel="next", <https://x/p?page=5>; rel="last", <https://x/p?page=1>; rel="first""#;
|
||||
let parsed = parse_link_header(input);
|
||||
let rels: Vec<&str> = parsed.iter().map(|(_, r)| r.as_str()).collect();
|
||||
assert!(rels.contains(&"next"));
|
||||
assert!(rels.contains(&"last"));
|
||||
assert!(rels.contains(&"first"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_unparsable_segments() {
|
||||
let input = "garbage";
|
||||
assert!(parse_link_header(input).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rel_case_insensitive() {
|
||||
let input = r#"<https://x/p>; REL="next""#;
|
||||
let parsed = parse_link_header(input);
|
||||
assert_eq!(parsed[0].1, "next");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
182
src/git/remote.rs
Normal file
182
src/git/remote.rs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
//! Detect the Forgejo repo associated with the current working directory by
|
||||
//! reading git remotes. Used so `fj pr list` (etc.) work without `-R` inside
|
||||
//! a clone.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
/// A parsed `owner/name` plus the hostname it lives on.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemoteRepo {
|
||||
pub host: String,
|
||||
pub owner: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl RemoteRepo {
|
||||
#[allow(dead_code)]
|
||||
pub fn slug(&self) -> String {
|
||||
format!("{}/{}", self.owner, self.name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a Forgejo repo for the current directory.
|
||||
///
|
||||
/// Preference order: explicit `--repo`/`-R` (handled by the caller), then
|
||||
/// `upstream` remote, then `origin`, then any other remote.
|
||||
pub fn discover(preferred_host: Option<&str>) -> Result<RemoteRepo> {
|
||||
let remotes = list_remotes()?;
|
||||
if remotes.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"no git remotes found; pass `-R <owner>/<name>` or run inside a clone"
|
||||
));
|
||||
}
|
||||
|
||||
// upstream first, then origin, then the rest in stable order.
|
||||
let mut ordered: Vec<&(String, String)> = remotes
|
||||
.iter()
|
||||
.filter(|(n, _)| n == "upstream")
|
||||
.chain(remotes.iter().filter(|(n, _)| n == "origin"))
|
||||
.chain(
|
||||
remotes
|
||||
.iter()
|
||||
.filter(|(n, _)| n != "upstream" && n != "origin"),
|
||||
)
|
||||
.collect();
|
||||
// Deduplicate while preserving order.
|
||||
ordered.dedup_by(|a, b| a.0 == b.0);
|
||||
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for (_, url) in ordered {
|
||||
match parse_remote(url) {
|
||||
Ok(r) => {
|
||||
if preferred_host.is_none_or(|h| h.eq_ignore_ascii_case(&r.host)) {
|
||||
return Ok(r);
|
||||
}
|
||||
}
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_err
|
||||
.unwrap_or_else(|| anyhow!("no remote matched a known Forgejo host"))
|
||||
.context("could not infer the repository from git remotes"))
|
||||
}
|
||||
|
||||
fn list_remotes() -> Result<Vec<(String, String)>> {
|
||||
let out = Command::new("git")
|
||||
.args(["remote", "-v"])
|
||||
.output()
|
||||
.context("running `git remote -v`")?;
|
||||
if !out.status.success() {
|
||||
// Not a git repo, or git is missing. Return an empty list; the caller
|
||||
// surfaces a friendlier message.
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let mut seen: Vec<(String, String)> = Vec::new();
|
||||
for line in text.lines() {
|
||||
// Format: "<name>\t<url> (fetch|push)"
|
||||
let Some((name_url, _)) = line.rsplit_once(' ') else {
|
||||
continue;
|
||||
};
|
||||
let Some((name, url)) = name_url.split_once('\t') else {
|
||||
continue;
|
||||
};
|
||||
let entry = (name.to_string(), url.to_string());
|
||||
if !seen.iter().any(|(n, _)| n == &entry.0) {
|
||||
seen.push(entry);
|
||||
}
|
||||
}
|
||||
Ok(seen)
|
||||
}
|
||||
|
||||
/// Parse `owner/name` and host out of a git remote URL.
|
||||
///
|
||||
/// Accepts:
|
||||
/// - `https://host/owner/name(.git)?`
|
||||
/// - `ssh://git@host(:port)?/owner/name(.git)?`
|
||||
/// - `git@host:owner/name(.git)?` (scp-like form)
|
||||
pub fn parse_remote(url: &str) -> Result<RemoteRepo> {
|
||||
let url = url.trim();
|
||||
|
||||
// scp-like: git@host:owner/name(.git)?
|
||||
if !url.contains("://") {
|
||||
if let Some((user_host, path)) = url.split_once(':') {
|
||||
let host = user_host
|
||||
.rsplit_once('@')
|
||||
.map(|(_, h)| h)
|
||||
.unwrap_or(user_host);
|
||||
return split_path(host, path);
|
||||
}
|
||||
return Err(anyhow!("can't parse remote URL: {url}"));
|
||||
}
|
||||
|
||||
let parsed = url::Url::parse(url).with_context(|| format!("parsing remote URL '{url}'"))?;
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| anyhow!("remote URL has no host: {url}"))?;
|
||||
let path = parsed.path();
|
||||
split_path(host, path)
|
||||
}
|
||||
|
||||
fn split_path(host: &str, path: &str) -> Result<RemoteRepo> {
|
||||
let path = path.trim_matches('/');
|
||||
let path = path.strip_suffix(".git").unwrap_or(path);
|
||||
let Some((owner, name)) = path.split_once('/') else {
|
||||
return Err(anyhow!("remote path '{path}' is not 'owner/name'"));
|
||||
};
|
||||
if owner.is_empty() || name.is_empty() || name.contains('/') {
|
||||
return Err(anyhow!("remote path '{path}' is not 'owner/name'"));
|
||||
}
|
||||
Ok(RemoteRepo {
|
||||
host: host.to_string(),
|
||||
owner: owner.to_string(),
|
||||
name: name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn https_with_dot_git() {
|
||||
let r = parse_remote("https://rasterhub.com/rasterstate/fj.git").unwrap();
|
||||
assert_eq!(r.host, "rasterhub.com");
|
||||
assert_eq!(r.owner, "rasterstate");
|
||||
assert_eq!(r.name, "fj");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn https_no_dot_git() {
|
||||
let r = parse_remote("https://rasterhub.com/rasterstate/fj").unwrap();
|
||||
assert_eq!(r.slug(), "rasterstate/fj");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_url_with_port() {
|
||||
let r = parse_remote("ssh://git@rasterhub.com:2222/rasterstate/fj.git").unwrap();
|
||||
assert_eq!(r.host, "rasterhub.com");
|
||||
assert_eq!(r.slug(), "rasterstate/fj");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scp_like() {
|
||||
let r = parse_remote("git@rasterhub.com:rasterstate/fj.git").unwrap();
|
||||
assert_eq!(r.host, "rasterhub.com");
|
||||
assert_eq!(r.slug(), "rasterstate/fj");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_repo_path() {
|
||||
assert!(parse_remote("https://example.com/").is_err());
|
||||
assert!(parse_remote("https://example.com/just-one-segment").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_deep_path() {
|
||||
assert!(parse_remote("https://example.com/a/b/c").is_err());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue