quinjet 0.0.49

A fast, live, keyboard-first Git source-control interface for the terminal
use std::io::{self, Read};

#[cfg_attr(not(test), expect(clippy::wildcard_imports, reason = "shared"))]
use super::*;

#[derive(Debug, Subcommand)]
pub(super) enum PrReviewVerb {
    #[doc = " Print review threads and pending-review state"]
    Show(PrArgs),
    #[doc = " Add a pending line or file comment"]
    Comment(PrReviewCommentArgs),
    #[doc = " Add a pending reply to a review thread"]
    Reply(PrReviewReplyArgs),
    #[doc = " Replace one of your review comments"]
    Edit(PrReviewEditArgs),
    #[doc = " Delete one of your review comments"]
    Delete(PrReviewDeleteArgs),
    #[doc = " Submit the current pending review"]
    Submit(PrReviewSubmitArgs),
    #[doc = " Discard the current pending review"]
    Discard(PrMutateArgs),
    #[doc = " Resolve a review thread"]
    Resolve(PrReviewThreadArgs),
    #[doc = " Reopen a resolved review thread"]
    Unresolve(PrReviewThreadArgs),
}

#[derive(Debug, Args)]
#[group(required = true, multiple = false)]
pub(super) struct PrReviewBodyArgs {
    #[doc = " Review text"]
    #[arg(short, long, value_name = "TEXT")]
    pub(super) body: Option<String>,
    #[doc = " Read review text from a file, or standard input with `-`"]
    #[arg(long, value_name = "PATH", value_hint = ValueHint::FilePath)]
    pub(super) body_file: Option<PathBuf>,
}

#[derive(Debug, Clone, Copy, ValueEnum)]
pub(super) enum PrReviewSideArg {
    Left,
    Right,
}

impl From<PrReviewSideArg> for PullRequestReviewSide {
    fn from(side: PrReviewSideArg) -> Self {
        match side {
            PrReviewSideArg::Left => Self::Left,
            PrReviewSideArg::Right => Self::Right,
        }
    }
}

#[derive(Debug, Args)]
pub(super) struct PrReviewCommentArgs {
    #[command(flatten)]
    pub(super) pull_request: PrArgs,
    #[doc = " Repository-relative path to comment on"]
    #[arg(value_name = "PATH", value_hint = ValueHint::AnyPath)]
    pub(super) path: PathBuf,
    #[doc = " End line in the old or new file"]
    #[arg(long, required_unless_present = "file")]
    pub(super) line: Option<usize>,
    #[doc = " Side of the diff containing the end line"]
    #[arg(long, value_enum, required_unless_present = "file")]
    pub(super) side: Option<PrReviewSideArg>,
    #[doc = " First line of a multi-line comment"]
    #[arg(long, requires = "line")]
    pub(super) start_line: Option<usize>,
    #[doc = " Side containing the first line"]
    #[arg(long, value_enum, requires = "start_line")]
    pub(super) start_side: Option<PrReviewSideArg>,
    #[doc = " Comment on the whole file"]
    #[arg(long, conflicts_with_all = ["line", "side", "start_line", "start_side"])]
    pub(super) file: bool,
    #[command(flatten)]
    pub(super) text: PrReviewBodyArgs,
}

#[derive(Debug, Args)]
pub(super) struct PrReviewReplyArgs {
    #[command(flatten)]
    pub(super) pull_request: PrArgs,
    #[doc = " Review thread node ID"]
    #[arg(value_name = "THREAD_ID")]
    pub(super) thread_id: String,
    #[command(flatten)]
    pub(super) text: PrReviewBodyArgs,
}

#[derive(Debug, Args)]
pub(super) struct PrReviewEditArgs {
    #[command(flatten)]
    pub(super) pull_request: PrArgs,
    #[doc = " Review comment node ID"]
    #[arg(value_name = "COMMENT_ID")]
    pub(super) comment_id: String,
    #[command(flatten)]
    pub(super) text: PrReviewBodyArgs,
}

#[derive(Debug, Args)]
pub(super) struct PrReviewDeleteArgs {
    #[command(flatten)]
    pub(super) pull_request: PrArgs,
    #[doc = " Review comment node ID"]
    #[arg(value_name = "COMMENT_ID")]
    pub(super) comment_id: String,
    #[doc = " Confirm; without it the command reports what it would delete"]
    #[arg(long)]
    pub(super) yes: bool,
}

#[derive(Debug, Args)]
#[group(required = true, multiple = false)]
pub(super) struct PrReviewDecisionArgs {
    #[doc = " Submit general feedback"]
    #[arg(long)]
    pub(super) comment: bool,
    #[doc = " Approve the pull request"]
    #[arg(long)]
    pub(super) approve: bool,
    #[doc = " Request changes"]
    #[arg(long)]
    pub(super) request_changes: bool,
}

impl PrReviewDecisionArgs {
    const fn decision(&self) -> PullRequestReviewDecision {
        if self.approve {
            PullRequestReviewDecision::Approve
        } else if self.request_changes {
            PullRequestReviewDecision::RequestChanges
        } else {
            PullRequestReviewDecision::Comment
        }
    }
}

#[derive(Debug, Args)]
pub(super) struct PrReviewSubmitArgs {
    #[command(flatten)]
    pub(super) pull_request: PrArgs,
    #[command(flatten)]
    pub(super) decision: PrReviewDecisionArgs,
    #[command(flatten)]
    pub(super) text: PrReviewBodyArgs,
}

#[derive(Debug, Args)]
pub(super) struct PrReviewThreadArgs {
    #[command(flatten)]
    pub(super) pull_request: PrArgs,
    #[doc = " Review thread node ID"]
    #[arg(value_name = "THREAD_ID")]
    pub(super) thread_id: String,
}

pub(super) fn review(session: &mut Session, out: &Emitter, command: PrReviewVerb) -> Result<u8> {
    match command {
        PrReviewVerb::Show(args) => {
            let pull_request = lookup(session, out, &args)?;
            let review = out
                .execute(
                    session,
                    Command::PullRequestReview {
                        pull_request: Box::new(pull_request),
                    },
                )?
                .review()?;
            out.emit(&review, || render::pull_request_review(&review))?;
            Ok(0)
        }
        PrReviewVerb::Comment(args) => {
            let body = review_body(&args.text)?;
            let subject = if args.file {
                PullRequestReviewThreadSubject::File
            } else {
                PullRequestReviewThreadSubject::Line
            };
            mutate(
                session,
                out,
                &args.pull_request,
                PullRequestReviewOperation::AddThread {
                    body,
                    path: args.path,
                    line: args.line,
                    side: args.side.map(Into::into),
                    start_line: args.start_line,
                    start_side: args.start_side.map(Into::into),
                    subject,
                },
            )
        }
        PrReviewVerb::Reply(args) => mutate(
            session,
            out,
            &args.pull_request,
            PullRequestReviewOperation::Reply {
                thread_id: args.thread_id,
                body: review_body(&args.text)?,
            },
        ),
        PrReviewVerb::Edit(args) => mutate(
            session,
            out,
            &args.pull_request,
            PullRequestReviewOperation::UpdateComment {
                comment_id: args.comment_id,
                body: review_body(&args.text)?,
            },
        ),
        PrReviewVerb::Delete(args) => {
            if !args.yes {
                out.message(&format!(
                    "Would delete review comment `{}`. Pass --yes to delete it.",
                    args.comment_id
                ))?;
                return Ok(0);
            }
            mutate(
                session,
                out,
                &args.pull_request,
                PullRequestReviewOperation::DeleteComment {
                    comment_id: args.comment_id,
                },
            )
        }
        PrReviewVerb::Submit(args) => mutate(
            session,
            out,
            &args.pull_request,
            PullRequestReviewOperation::Submit {
                body: review_body(&args.text)?,
                decision: args.decision.decision(),
            },
        ),
        PrReviewVerb::Discard(args) => {
            if !args.yes {
                out.message("Would discard the pending review. Pass --yes to discard it.")?;
                return Ok(0);
            }
            mutate(
                session,
                out,
                &args.pull_request,
                PullRequestReviewOperation::Discard,
            )
        }
        PrReviewVerb::Resolve(args) => mutate(
            session,
            out,
            &args.pull_request,
            PullRequestReviewOperation::Resolve {
                thread_id: args.thread_id,
            },
        ),
        PrReviewVerb::Unresolve(args) => mutate(
            session,
            out,
            &args.pull_request,
            PullRequestReviewOperation::Unresolve {
                thread_id: args.thread_id,
            },
        ),
    }
}

fn mutate(
    session: &mut Session,
    out: &Emitter,
    args: &PrArgs,
    operation: PullRequestReviewOperation,
) -> Result<u8> {
    let pull_request = lookup(session, out, args)?;
    let message = out
        .execute(
            session,
            Command::OperatePullRequestReview {
                pull_request: Box::new(pull_request),
                operation,
            },
        )?
        .operation()?
        .2;
    out.message(&message)?;
    Ok(0)
}

fn review_body(args: &PrReviewBodyArgs) -> Result<String> {
    let body = match (&args.body, &args.body_file) {
        (Some(body), None) => body.clone(),
        (None, Some(path)) if path == Path::new("-") => {
            let mut body = String::new();
            io::stdin()
                .lock()
                .read_to_string(&mut body)
                .map(|_| ())
                .context("unable to read review text from standard input")?;
            body
        }
        (None, Some(path)) => fs::read_to_string(path)
            .with_context(|| format!("unable to read review text from {}", path.display()))?,
        _ => return Err(Failure::new(EXIT_FAILURE, "review text is required").into()),
    };
    if body.trim().is_empty() {
        return Err(Failure::new(EXIT_FAILURE, "review text cannot be empty").into());
    }
    Ok(body)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn review_body_accepts_inline_and_file_text() {
        let inline = PrReviewBodyArgs {
            body: Some("Looks good".to_owned()),
            body_file: None,
        };
        assert_eq!(review_body(&inline).unwrap(), "Looks good");
        let directory = tempfile::tempdir().unwrap();
        let path = directory.path().join("review.txt");
        fs::write(&path, "Please adjust this\n").unwrap();
        let file = PrReviewBodyArgs {
            body: None,
            body_file: Some(path),
        };
        assert_eq!(review_body(&file).unwrap(), "Please adjust this\n");
    }

    #[test]
    fn review_body_rejects_missing_empty_and_unreadable_text() {
        let missing = PrReviewBodyArgs {
            body: None,
            body_file: None,
        };
        drop(review_body(&missing).unwrap_err());
        let empty = PrReviewBodyArgs {
            body: Some("  \n".to_owned()),
            body_file: None,
        };
        drop(review_body(&empty).unwrap_err());
        let unreadable = PrReviewBodyArgs {
            body: None,
            body_file: Some(PathBuf::from("missing-review.txt")),
        };
        drop(review_body(&unreadable).unwrap_err());
    }

    #[test]
    fn review_arguments_map_to_domain_values() {
        assert_eq!(
            PullRequestReviewSide::from(PrReviewSideArg::Left),
            PullRequestReviewSide::Left
        );
        assert_eq!(
            PullRequestReviewSide::from(PrReviewSideArg::Right),
            PullRequestReviewSide::Right
        );
        let decision = |approve: bool, request_changes: bool| {
            PrReviewDecisionArgs {
                comment: !approve && !request_changes,
                approve,
                request_changes,
            }
            .decision()
        };
        assert_eq!(decision(false, false), PullRequestReviewDecision::Comment);
        assert_eq!(decision(true, false), PullRequestReviewDecision::Approve);
        assert_eq!(
            decision(false, true),
            PullRequestReviewDecision::RequestChanges
        );
    }
}