* 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>
777 lines
22 KiB
Rust
777 lines
22 KiB
Rust
use std::io::Read;
|
|
|
|
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, 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)]
|
|
pub command: PrSub,
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
pub enum PrSub {
|
|
/// List pull requests.
|
|
List(ListArgs),
|
|
/// View a pull request.
|
|
View(ViewArgs),
|
|
/// Create a pull request.
|
|
Create(CreateArgs),
|
|
/// 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(SimpleArgs),
|
|
/// Reopen a closed pull request.
|
|
Reopen(SimpleArgs),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, ValueEnum)]
|
|
pub enum StateFilter {
|
|
Open,
|
|
Closed,
|
|
All,
|
|
}
|
|
|
|
impl From<StateFilter> for State {
|
|
fn from(value: StateFilter) -> Self {
|
|
match value {
|
|
StateFilter::Open => State::Open,
|
|
StateFilter::Closed => State::Closed,
|
|
StateFilter::All => State::All,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, ValueEnum)]
|
|
pub enum MergeStyleArg {
|
|
Merge,
|
|
Rebase,
|
|
RebaseMerge,
|
|
Squash,
|
|
}
|
|
|
|
impl From<MergeStyleArg> for MergeStyle {
|
|
fn from(value: MergeStyleArg) -> Self {
|
|
match value {
|
|
MergeStyleArg::Merge => MergeStyle::Merge,
|
|
MergeStyleArg::Rebase => MergeStyle::Rebase,
|
|
MergeStyleArg::RebaseMerge => MergeStyle::RebaseMerge,
|
|
MergeStyleArg::Squash => MergeStyle::Squash,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, 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 {
|
|
#[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)]
|
|
pub limit: u32,
|
|
#[arg(long, default_value_t = 1)]
|
|
pub page: u32,
|
|
#[arg(long)]
|
|
pub json: bool,
|
|
#[arg(long)]
|
|
pub web: bool,
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
pub struct ViewArgs {
|
|
#[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 {
|
|
#[command(flatten)]
|
|
pub r: RepoFlag,
|
|
#[arg(short = 't', long)]
|
|
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: 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 {
|
|
#[command(flatten)]
|
|
pub r: RepoFlag,
|
|
pub number: u64,
|
|
#[arg(short = 'b', long)]
|
|
pub branch: Option<String>,
|
|
#[arg(long, default_value = "origin")]
|
|
pub remote: String,
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
pub struct MergeArgs {
|
|
#[command(flatten)]
|
|
pub r: RepoFlag,
|
|
pub number: u64,
|
|
#[arg(long, value_enum, default_value_t = MergeStyleArg::Merge)]
|
|
pub style: MergeStyleArg,
|
|
#[arg(long)]
|
|
pub title: Option<String>,
|
|
#[arg(long)]
|
|
pub message: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
pub struct SimpleArgs {
|
|
#[command(flatten)]
|
|
pub r: RepoFlag,
|
|
pub number: u64,
|
|
}
|
|
|
|
pub async fn run(cmd: PrCmd, host: Option<&str>) -> Result<()> {
|
|
match cmd.command {
|
|
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(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(
|
|
&ctx.client,
|
|
&ctx.owner,
|
|
&ctx.name,
|
|
ListOptions {
|
|
state: args.state.into(),
|
|
limit: args.limit,
|
|
page: args.page,
|
|
},
|
|
)
|
|
.await?;
|
|
if args.json {
|
|
return output::print_json(&serde_json::to_value(&page.items)?);
|
|
}
|
|
if page.items.is_empty() {
|
|
println!("(no pull requests)");
|
|
return Ok(());
|
|
}
|
|
let rows: Vec<Vec<String>> = page
|
|
.items
|
|
.iter()
|
|
.map(|p| {
|
|
vec![
|
|
format!("#{}", p.number),
|
|
output::state_pill(&p.state, p.merged),
|
|
truncate(&p.title, 55),
|
|
output::dim(&format!(
|
|
"{} → {}",
|
|
branch_label(&p.head),
|
|
branch_label(&p.base)
|
|
)),
|
|
output::dim(&output::relative_time(p.updated_at)),
|
|
]
|
|
})
|
|
.collect();
|
|
print!(
|
|
"{}",
|
|
output::render_table(&["", "STATE", "TITLE", "BRANCHES", "UPDATED"], &rows)
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
async fn view(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 {
|
|
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!(
|
|
"{} {} {}",
|
|
output::bold(&format!("#{} {}", pr.number, pr.title)),
|
|
output::state_pill(&pr.state, pr.merged),
|
|
output::dim(&output::relative_time(pr.updated_at)),
|
|
);
|
|
println!("{}", output::dim(&pr.html_url));
|
|
println!();
|
|
println!(
|
|
"Branches: {} → {}",
|
|
branch_label(&pr.head),
|
|
branch_label(&pr.base)
|
|
);
|
|
if pr.draft {
|
|
println!("Draft: yes");
|
|
}
|
|
if !pr.body.is_empty() {
|
|
println!();
|
|
println!("{}", pr.body);
|
|
}
|
|
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(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(
|
|
&ctx.client,
|
|
&ctx.owner,
|
|
&ctx.name,
|
|
&CreatePull {
|
|
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 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)?;
|
|
println!("✓ Checked out #{} into {branch}", pr.number);
|
|
Ok(())
|
|
}
|
|
|
|
async fn merge(args: MergeArgs, host: Option<&str>) -> Result<()> {
|
|
let ctx = resolve_repo(args.r.repo.as_deref(), host)?;
|
|
api::pull::merge(
|
|
&ctx.client,
|
|
&ctx.owner,
|
|
&ctx.name,
|
|
args.number,
|
|
args.style.into(),
|
|
args.title.as_deref(),
|
|
args.message.as_deref(),
|
|
)
|
|
.await?;
|
|
println!("✓ Merged PR #{}", args.number);
|
|
Ok(())
|
|
}
|
|
|
|
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(
|
|
&ctx.client,
|
|
&ctx.owner,
|
|
&ctx.name,
|
|
args.number,
|
|
&EditPull {
|
|
state: Some(state),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await?;
|
|
println!(
|
|
"✓ PR #{} is now {}",
|
|
args.number,
|
|
output::state_pill(state, false)
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Render a branch name for display. Forgejo populates `head.ref` with
|
|
/// `refs/pull/<n>/head` (the synthetic PR ref) when the source branch is gone
|
|
/// or the PR comes from a fork; in that case the real name is in `label`.
|
|
/// For same-repo PRs `ref` is `refs/heads/<branch>` and `label` is the bare
|
|
/// branch name. Prefer `label` whenever it's non-empty.
|
|
fn branch_label(b: &crate::api::pull::Branch) -> String {
|
|
if !b.label.is_empty() {
|
|
return b.label.clone();
|
|
}
|
|
b.ref_
|
|
.strip_prefix("refs/heads/")
|
|
.unwrap_or(&b.ref_)
|
|
.to_string()
|
|
}
|
|
|
|
fn 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();
|
|
}
|
|
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
|
|
out.push('…');
|
|
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('…'));
|
|
}
|
|
}
|