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 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 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 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, #[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, /// Body. Use `-` to read from stdin. Omit to open `$EDITOR`. #[arg(short = 'b', long)] pub body: Option, /// Mark the PR as draft. #[arg(long)] pub draft: bool, /// Open the new PR in your browser after creating. #[arg(long)] pub web: bool, } #[derive(Debug, Args)] pub struct EditArgs { #[command(flatten)] pub r: RepoFlag, pub number: u64, #[arg(short = 't', long)] pub title: Option, #[arg(short = 'b', long)] pub body: Option, /// Open `$EDITOR` for the body (replaces the current body). #[arg(long)] pub body_editor: bool, } #[derive(Debug, Args)] pub struct ReviewArgs { #[command(flatten)] pub r: RepoFlag, pub number: u64, /// Review action. #[arg(value_enum, long, short = 'e')] pub event: ReviewEventArg, /// Review body. Use `-` for stdin. Omit to open `$EDITOR` (unless the /// event is `approve` and you have nothing to say). #[arg(short = 'b', long)] pub body: Option, } #[derive(Debug, Args)] pub struct StatusArgs { #[arg(short = 'L', long, default_value_t = 20)] pub limit: u32, #[arg(long)] pub json: bool, } #[derive(Debug, Args)] pub struct CheckoutArgs { #[command(flatten)] pub r: RepoFlag, pub number: u64, #[arg(short = 'b', long)] pub branch: Option, #[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, #[arg(long)] pub message: Option, } #[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> = 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> = cs .iter() .map(|c| { let short = c.sha.get(..7).unwrap_or(&c.sha).to_string(); let subject = c.commit.message.lines().next().unwrap_or("").to_string(); let author = c .commit .author .as_ref() .map(|a| a.name.clone()) .unwrap_or_default(); vec![short, truncate(&subject, 70), output::dim(&author)] }) .collect(); if rows.is_empty() { println!("(no commits)"); } else { print!( "{}", output::render_table(&["SHA", "SUBJECT", "AUTHOR"], &rows) ); } Ok(()) } async fn files(args: SimpleArgs, host: Option<&str>) -> Result<()> { let ctx = resolve_repo(args.r.repo.as_deref(), host)?; let fs = api::pull::files(&ctx.client, &ctx.owner, &ctx.name, args.number).await?; let rows: Vec> = fs .iter() .map(|f| { vec![ f.status.clone(), f.filename.clone(), output::dim(&format!("+{} -{}", f.additions, f.deletions)), ] }) .collect(); if rows.is_empty() { println!("(no file changes)"); } else { print!( "{}", output::render_table(&["STATUS", "FILE", "DIFF"], &rows) ); } Ok(()) } async fn checks(args: SimpleArgs, host: Option<&str>) -> Result<()> { let ctx = resolve_repo(args.r.repo.as_deref(), host)?; let pr = api::pull::get(&ctx.client, &ctx.owner, &ctx.name, args.number).await?; let cs = api::pull::combined_status(&ctx.client, &ctx.owner, &ctx.name, &pr.head.sha).await?; println!( "{} {}", output::bold(&format!( "Combined: {}", if cs.state.is_empty() { "—" } else { &cs.state } )), output::dim(&format!( "{} checks on {}", cs.total_count, &pr.head.sha[..7.min(pr.head.sha.len())] )), ); if cs.statuses.is_empty() { println!("(no per-check statuses reported)"); return Ok(()); } let rows: Vec> = cs .statuses .iter() .map(|s| { let when = s .updated_at .map(output::relative_time) .unwrap_or_else(|| "—".into()); vec![ output::state_pill(&s.status.to_lowercase(), false), s.context.clone(), truncate(&s.description, 50), output::dim(&when), ] }) .collect(); print!( "{}", output::render_table(&["STATE", "CHECK", "DESCRIPTION", "WHEN"], &rows) ); Ok(()) } async fn ready(args: SimpleArgs, host: Option<&str>) -> Result<()> { let ctx = resolve_repo(args.r.repo.as_deref(), host)?; api::pull::ready(&ctx.client, &ctx.owner, &ctx.name, args.number).await?; println!("✓ PR #{} is ready for review", args.number); Ok(()) } async fn review(args: ReviewArgs, host: Option<&str>) -> Result<()> { let ctx = resolve_repo(args.r.repo.as_deref(), host)?; let body_required = matches!(args.event, ReviewEventArg::RequestChanges); let body = match args.body.as_deref() { Some("-") => { let mut buf = String::new(); std::io::stdin().read_to_string(&mut buf)?; Some(buf.trim_end().to_string()) } Some(s) => Some(s.to_string()), None => { if body_required { Some(editor::edit_text("PR_REVIEW.md", "")?) } else { None } } }; let body = body.filter(|s| !s.trim().is_empty()); let r = api::pull::submit_review( &ctx.client, &ctx.owner, &ctx.name, args.number, args.event.into(), body.as_deref(), ) .await?; println!("✓ Submitted {} review on #{}", r.state, args.number); println!("{}", r.html_url); Ok(()) } async fn status(args: StatusArgs, host: Option<&str>) -> Result<()> { // Cross-repo dashboard: PRs authored by you, and PRs where you're listed // as a reviewer or where you're mentioned. let client = Client::connect(host)?; let me = api::user::current(&client).await?; let limit = args.limit.clamp(1, 50); let q_authored: Vec<(String, String)> = vec![ ("type".into(), "pulls".into()), ("state".into(), "open".into()), ("created_by".into(), me.login.clone()), ("limit".into(), limit.to_string()), ]; let q_review: Vec<(String, String)> = vec![ ("type".into(), "pulls".into()), ("state".into(), "open".into()), ("reviewed_by".into(), me.login.clone()), ("limit".into(), limit.to_string()), ]; let q_mentioned: Vec<(String, String)> = vec![ ("type".into(), "pulls".into()), ("state".into(), "open".into()), ("mentioned_by".into(), me.login.clone()), ("limit".into(), limit.to_string()), ]; let path = "/api/v1/repos/issues/search"; let authored: Vec = client .json(reqwest::Method::GET, path, &q_authored, None::<&()>) .await?; let review_requested: Vec = client .json(reqwest::Method::GET, path, &q_review, None::<&()>) .await?; let mentioned: Vec = client .json(reqwest::Method::GET, path, &q_mentioned, None::<&()>) .await?; if args.json { return output::print_json(&serde_json::json!({ "authored": authored, "review_requested": review_requested, "mentioned": mentioned, })); } print_status_section("Created by you", &authored); print_status_section("Requesting your review", &review_requested); print_status_section("Mentioning you", &mentioned); Ok(()) } fn print_status_section(label: &str, items: &[api::issue::Issue]) { println!("{}", output::bold(label)); if items.is_empty() { println!(" (none)"); println!(); return; } for i in items { println!( " #{} {} {}", i.number, truncate(&i.title, 70), output::dim(&i.html_url) ); } println!(); } async fn checkout(args: CheckoutArgs, host: Option<&str>) -> Result<()> { let ctx = resolve_repo(args.r.repo.as_deref(), host)?; let pr = api::pull::get(&ctx.client, &ctx.owner, &ctx.name, args.number).await?; let branch = args.branch.unwrap_or_else(|| format!("pr-{}", pr.number)); git::fetch_pr(&args.remote, pr.number, &branch)?; git::checkout(&branch)?; 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//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/` 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 { 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('…')); } }