quinjet 0.0.35

A fast, live, keyboard-first Git source-control interface for the terminal
#[cfg_attr(not(test), expect(clippy::wildcard_imports, reason = "shared"))]
use super::*;

pub(super) struct ApiPage {
    pub(super) data: Vec<u8>,
    pub(super) truncated: bool,
    pub(super) has_next: bool,
    pub(super) last_page: Option<usize>,
}

impl Repository {
    pub(crate) fn perform_pull_request_operation(
        &self,
        pull_request: &PullRequest,
        operation: &PullRequestOperation,
    ) -> Result<String> {
        let mut args = vec![
            OsString::from("pr"),
            OsString::from(match operation {
                PullRequestOperation::Merge { .. } => "merge",
                PullRequestOperation::Close => "close",
                PullRequestOperation::Reopen => "reopen",
            }),
            OsString::from(pull_request.number.to_string()),
            OsString::from("--repo"),
            OsString::from(&pull_request.base_repository.url),
        ];
        if let PullRequestOperation::Merge {
            method,
            delete_branch,
        } = operation
        {
            args.push(OsString::from(method.flag()));
            if *delete_branch {
                args.push(OsString::from("--delete-branch"));
            }
        }
        let output = self.run_gh(args)?;
        if !output.status.success() {
            bail!(
                "{}",
                bounded_command_error("unable to update the pull request", &output)
            );
        }
        Ok(operation.success_message(pull_request))
    }

    /// One bounded page of a listing endpoint: its body trimmed to whole
    /// records, plus whether GitHub advertises another page after it.
    pub(super) fn api_page(
        &self,
        endpoint: &str,
        jq: &str,
        page: usize,
        error_context: &str,
    ) -> Result<ApiPage> {
        let output = self.run_gh([
            OsString::from("api"),
            OsString::from("-i"),
            OsString::from(format!("{endpoint}&page={page}")),
            OsString::from("--jq"),
            OsString::from(jq),
        ])?;
        if !output.status.success() && !output.stdout_truncated {
            bail!("{}", bounded_command_error(error_context, &output));
        }
        let (head, body) = split_http_response(&output.stdout);
        let has_next = has_next_page(head.as_ref());
        let mut data = body.to_vec();
        if output.stdout_truncated {
            while data.last().is_some_and(|byte| *byte != b'\n') {
                let _ = data.pop();
            }
        }
        Ok(ApiPage {
            data,
            truncated: output.stdout_truncated,
            has_next,
            last_page: last_page(head.as_ref()),
        })
    }

    /// Per-file additions and deletions from the pull-request files endpoint.
    /// In the blob-less disposable workspace a local `--numstat` would download
    /// every changed blob just to count lines; GitHub already knows the totals.
    pub(super) fn pull_request_file_counts_from_api(
        &self,
        pull_request: &PullRequest,
    ) -> Option<HashMap<PathBuf, DiffLineCounts>> {
        let base = pull_request.base_oid.trim();
        let head = pull_request.head_oid.trim();
        let repository = &pull_request.base_repository;
        if !is_commit_oid(base) || !is_commit_oid(head) || repository.name_with_owner.is_empty() {
            return None;
        }
        let key = format!(
            "pr-file-counts-v3\n{}\n{}\n{base}\n{head}",
            repository.url.trim_end_matches('/'),
            pull_request.number
        );
        if let Some(data) = cache_read_bounded(&key, CacheLife::Immutable, MAX_PR_PATH_BYTES) {
            return Some(parse_api_file_counts(&data));
        }
        let endpoint = format!(
            "repos/{}/pulls/{}/files?per_page=100",
            repository.name_with_owner, pull_request.number
        );
        let jq = ".[] | [.filename, (.additions|tostring), (.deletions|tostring), .status] | @tsv";
        let mut collected: Vec<u8> = Vec::new();
        let mut complete = false;
        for page in 1..=MAX_FILE_COUNT_PAGES {
            let read = self
                .api_page(&endpoint, jq, page, "unable to list pull-request files")
                .ok()?;
            if read.truncated {
                return None;
            }
            collected.extend_from_slice(&read.data);
            if collected.last().is_some_and(|byte| *byte != b'\n') {
                collected.push(b'\n');
            }
            if !read.has_next {
                complete = true;
                break;
            }
        }
        if complete && collected.len() <= MAX_PR_PATH_BYTES {
            cache_write_bounded(&key, &collected, MAX_PR_PATH_BYTES);
        }
        Some(parse_api_file_counts(&collected))
    }

    /// Ask the GitHub compare API for the merge base of the two immutable PR
    /// commits. One metadata request replaces the deepening fetch ladder, which
    /// cannot reach a merge base thousands of commits behind either tip.
    pub(super) fn merge_base_from_api(&self, pull_request: &PullRequest) -> Option<String> {
        let base = pull_request.base_oid.trim();
        let head = pull_request.head_oid.trim();
        let repository = &pull_request.base_repository;
        if !is_commit_oid(base) || !is_commit_oid(head) || repository.name_with_owner.is_empty() {
            return None;
        }
        let key = format!(
            "pr-merge-base-v1\n{}\n{base}\n{head}",
            repository.url.trim_end_matches('/')
        );
        if let Some(cached) = cache_read(&key, CacheLife::Immutable) {
            let cached = String::from_utf8_lossy(trim_ascii(&cached)).into_owned();
            if is_commit_oid(&cached) {
                return Some(cached);
            }
        }
        let output = self
            .run_gh([
                OsString::from("api"),
                OsString::from(format!(
                    "repos/{}/compare/{base}...{head}",
                    repository.name_with_owner
                )),
                OsString::from("--jq"),
                OsString::from(".merge_base_commit.sha"),
            ])
            .ok()?;
        if !output.status.success() || output.stdout_truncated {
            return None;
        }
        let sha = String::from_utf8_lossy(trim_ascii(&output.stdout)).into_owned();
        if !is_commit_oid(&sha) {
            return None;
        }
        cache_write(&key, sha.as_bytes());
        Some(sha)
    }

    pub(super) fn run_gh<I, S>(&self, args: I) -> Result<BoundedOutput>
    where
        I: IntoIterator<Item = S>,
        S: AsRef<OsStr>,
    {
        self.run_gh_bounded(args, MAX_GH_METADATA_BYTES)
    }

    /// Metadata responses are small and share one cap, but a check run log is
    /// arbitrarily large and needs its own.
    pub(super) fn run_gh_bounded<I, S>(&self, args: I, stdout_limit: usize) -> Result<BoundedOutput>
    where
        I: IntoIterator<Item = S>,
        S: AsRef<OsStr>,
    {
        let mut command = Command::new("gh");
        let _ = command
            .current_dir(&self.root)
            .args(args)
            .env("GH_PROMPT_DISABLED", "1")
            .env("GH_PAGER", "cat")
            .env("GH_NO_UPDATE_NOTIFIER", "1")
            .env("NO_COLOR", "1");
        run_bounded_command(&mut command, stdout_limit, MAX_GH_ERROR_BYTES).with_context(|| {
            format!(
                "failed to execute GitHub CLI (`gh`) in {}; install it and run `gh auth login`",
                self.root.display()
            )
        })
    }
}