thirdshift 0.8.4

Turn a GitHub issue into a ready-for-review pull request, unattended
//! Issue branch selection: start a fresh Issue branch, or continue the one
//! already on origin (ADR-0002).

use anyhow::{Result, bail};

use crate::git::Git;
use crate::github::{self, PrState, PullRequest};
use crate::issue::IssueUrl;
use crate::progress;

pub enum Selection {
    /// No Issue branch has been used yet, or the highest-numbered one's PR is
    /// merged or closed: start `branch` from the Base branch.
    Fresh { branch: String },
    /// `branch` is on origin, with no PR or the open `pr`.
    Continuation {
        branch: String,
        pr: Option<PullRequest>,
    },
}

impl Selection {
    pub fn branch(&self) -> &str {
        match self {
            Selection::Fresh { branch } | Selection::Continuation { branch, .. } => branch,
        }
    }

    /// The open PR's base in a Continuation that has one, which names the
    /// Base branch whatever else would. `given` is the Base branch the Run was
    /// given by what started it, if any, and `checked_out` the branch
    /// checked out in the Launch directory (`None` on a detached HEAD). Says
    /// so on stderr when the PR's base replaces a different given or
    /// checked-out branch.
    pub fn pr_base(&self, given: Option<&str>, checked_out: Option<&str>) -> Option<&str> {
        let Selection::Continuation {
            branch,
            pr: Some(pr),
        } = self
        else {
            return None;
        };
        if let Some(replaced) = given
            .or(checked_out)
            .filter(|&replaced| replaced != pr.base)
        {
            let which = if given.is_some() {
                "given"
            } else {
                "checked-out"
            };
            progress::step(format_args!(
                "continuing {branch} and its PR {}, so the Base branch is {}, not the {which} {replaced}",
                pr.url, pr.base
            ));
        }
        Some(&pr.base)
    }
}

/// Pick the Issue branch for `issue` from the Issue branches on origin and
/// their PRs in any state: the highest number seen on either decides. Fails
/// if a local copy of the chosen branch in the Launch directory differs from
/// origin's: the Run replaces it and deletes it at cleanup.
pub fn select(launch: &Git, issue: &IssueUrl) -> Result<Selection> {
    let first_branch = branch_name(issue, 1);
    let Used { on_origin, prs } = used(launch, issue)?;

    let highest = on_origin
        .iter()
        .map(|(number, _)| *number)
        .chain(prs.iter().map(|(number, _)| *number))
        .max();
    let Some(highest) = highest else {
        check_local_branch(launch, &first_branch, None)?;
        return Ok(Selection::Fresh {
            branch: first_branch,
        });
    };
    let branch = branch_name(issue, highest);
    // A branch can have had several PRs; the newest one decides.
    let pr = prs
        .into_iter()
        .filter_map(|(number, pr)| (number == highest).then_some(pr))
        .max_by_key(|pr| pr.number);
    let origin_sha = on_origin
        .into_iter()
        .find_map(|(number, sha)| (number == highest).then_some(sha));
    match (origin_sha, pr) {
        // A merged or closed PR uses its number up, even if its branch was deleted.
        (_, Some(pr)) if pr.state != PrState::Open => {
            let next = branch_name(issue, highest + 1);
            check_local_branch(launch, &next, None)?;
            Ok(Selection::Fresh { branch: next })
        }
        (Some(origin_sha), pr) => {
            check_local_branch(launch, &branch, Some(&origin_sha))?;
            Ok(Selection::Continuation { branch, pr })
        }
        (None, _) => bail!("{branch} has an open PR but is gone from origin"),
    }
}

/// What shows an issue was started.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Started {
    /// This Issue branch for it is on origin.
    Branch(String),
    /// A pull request from an Issue branch for it exists, with this URL.
    PullRequest(String),
}

/// What shows `issue` was ever started, if it was: the first Issue branch for
/// it on origin, or else the newest pull request from one, open, merged or
/// closed.
pub fn started(launch: &Git, issue: &IssueUrl) -> Result<Option<Started>> {
    let Used { on_origin, prs } = used(launch, issue)?;
    let branch = on_origin.iter().map(|(number, _)| *number).min();
    let pr = prs.into_iter().map(|(_, pr)| pr).max_by_key(|pr| pr.number);
    Ok(branch
        .map(|number| Started::Branch(branch_name(issue, number)))
        .or(pr.map(|pr| Started::PullRequest(pr.url))))
}

/// What has been used of an issue's Issue branches.
struct Used {
    /// Those on origin, each by its number, with its head.
    on_origin: Vec<(u64, String)>,
    /// The pull requests from them, in any state, each with its Issue
    /// branch's number.
    prs: Vec<(u64, PullRequest)>,
}

/// What has been used of `issue`'s Issue branches: those on origin (one `git
/// ls-remote`) and the pull requests from them (one `gh pr list`).
fn used(launch: &Git, issue: &IssueUrl) -> Result<Used> {
    let first_branch = branch_name(issue, 1);
    let remote = launch.run(&[
        "ls-remote",
        "--heads",
        "origin",
        &format!("refs/heads/{first_branch}"),
        &format!("refs/heads/{first_branch}-branch-*"),
    ])?;
    let on_origin = remote
        .lines()
        .filter_map(|line| {
            let (sha, name) = line.split_once('\t')?;
            let number = branch_number(issue, name.strip_prefix("refs/heads/")?)?;
            Some((number, sha.to_string()))
        })
        .collect();
    let prs = github::pull_requests_with_head_prefix(issue, &first_branch)?
        .into_iter()
        .filter_map(|pr| Some((branch_number(issue, &pr.head)?, pr)))
        .collect();
    Ok(Used { on_origin, prs })
}

/// A local `branch` in the launch repository must be at `origin_sha`, or not
/// exist when origin has no copy, so no local-only commits are destroyed.
fn check_local_branch(launch: &Git, branch: &str, origin_sha: Option<&str>) -> Result<()> {
    let Ok(local_sha) = launch.run(&[
        "rev-parse",
        "--verify",
        "--quiet",
        &format!("refs/heads/{branch}"),
    ]) else {
        return Ok(());
    };
    match origin_sha {
        Some(origin_sha) if origin_sha == local_sha => Ok(()),
        Some(_) => bail!(
            "the local branch {branch} differs from origin/{branch}; push, reset or delete it first"
        ),
        None => {
            bail!("the local branch {branch} is not on origin; push, rename or delete it first")
        }
    }
}

/// `issue-<n>` is branch 1, `issue-<n>-branch-<k>` is branch k for k ≥ 2.
fn branch_number(issue: &IssueUrl, branch: &str) -> Option<u64> {
    let rest = branch.strip_prefix(&branch_name(issue, 1))?;
    if rest.is_empty() {
        return Some(1);
    }
    let k: u64 = rest.strip_prefix("-branch-")?.parse().ok()?;
    (k >= 2 && branch == branch_name(issue, k)).then_some(k)
}

fn branch_name(issue: &IssueUrl, number: u64) -> String {
    match number {
        1 => format!("issue-{}", issue.number),
        k => format!("issue-{}-branch-{k}", issue.number),
    }
}