thirdshift 0.8.4

Turn a GitHub issue into a ready-for-review pull request, unattended
//! A Pickup run up to the issue it takes: from the Launch directory with no
//! Issue URL, the search for the lowest-numbered Ready issue in the
//! repository, for the command to dispatch, saying why it passed over each
//! issue labelled `ready-for-agent` before it. Only one Pickup run or
//! Architect run per repository runs at a time on a machine: one started
//! while another is still running is skipped, before any search. One that
//! runs first makes the Sweep, and is then skipped if it finds the
//! repository at its Claim limit, or with no Ready issue.

use std::fmt;
use std::num::NonZeroUsize;

use anyhow::Result;

use crate::claim;
use crate::issue::{IssueUrl, Repo};
use crate::launch::{self, AlreadyRunning, Launch, Start};
use crate::logs::{self, Pass, Work};
use crate::progress;
use crate::ready::{self, ReadyIssue};

/// How a Pickup run ended, short of a failure and before any dispatch.
pub enum Outcome {
    /// It took this Ready issue, to dispatch.
    Taken(Taken),
    /// Skipped, having done nothing.
    Skipped(Skipped),
}

/// The Ready issue a Pickup run took.
pub struct Taken {
    pub issue: IssueUrl,
    /// Its title, as the search listed it.
    pub title: String,
    /// The Pickup run's Base branch, which the run the issue is dispatched
    /// as takes.
    pub base: String,
    /// Whether it is a Spec, an issue with sub-issues, which is dispatched
    /// as a Spec run.
    pub is_spec: bool,
}

/// Why a Pickup run was skipped. Its `Display` is the reason, as the skipped
/// run's one line gives it.
pub enum Skipped {
    /// An Architect run or another Pickup run on this repository is still
    /// running on this machine, the Spec run or Run it dispatched included.
    AlreadyRunning(AlreadyRunning),
    /// The repository is at its Claim limit: `claimed` open issues carry a
    /// Claim, and `limit` or more stop a Pickup run from taking another.
    AtClaimLimit {
        repo: Repo,
        claimed: usize,
        limit: NonZeroUsize,
    },
    /// The repository has no Ready issue.
    NoReadyIssue(Repo),
}

impl Skipped {
    /// The repository the Pickup run was skipped on.
    fn repo(&self) -> &Repo {
        match self {
            Self::AlreadyRunning(AlreadyRunning(repo))
            | Self::AtClaimLimit { repo, .. }
            | Self::NoReadyIssue(repo) => repo,
        }
    }
}

impl fmt::Display for Skipped {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Self::AlreadyRunning(running) => running.fmt(f),
            Self::AtClaimLimit {
                repo,
                claimed,
                limit,
            } => write!(
                f,
                "at the Claim limit on {}: {claimed} open issue(s) labelled {}, \
                 pickup.limit is {limit}",
                repo.slug(),
                claim::IN_PROGRESS
            ),
            Self::NoReadyIssue(repo) => write!(f, "no Ready issue on {}", repo.slug()),
        }
    }
}

/// Take the lowest-numbered Ready issue in the Launch directory's
/// repository, saying which on stderr, after a line there on each issue
/// labelled `ready-for-agent` it passed over on the way, with why. The Base
/// branch is `base`, the branch the command named, whatever the Launch
/// directory has checked out, or without one the branch checked out there.
/// Nothing is changed in the Launch directory, nor on the issue taken: the
/// run it is dispatched as makes the Claim.
///
/// Once the preflight checks pass, and before any search, the Pickup run is
/// skipped if an Architect run or another Pickup run on the same repository
/// is still running on this machine. Otherwise this process is that
/// repository's one such run until it exits, through whatever it dispatches.
/// It first makes the Sweep, taking the Claim off the repository's closed
/// issues, and is then skipped, still before any search, if the repository
/// is at its Claim limit: `limit` or more of its open issues carry a Claim.
/// A skip is recorded in the repository's Activity log. Once it takes an
/// issue, it records that it started work on it, which keeps its Command
/// log.
pub fn run(base: Option<&str>, limit: NonZeroUsize) -> Result<Outcome> {
    let Launch {
        directory,
        repo,
        base,
    } = match launch::start(base)? {
        Start::Clear(launch) => launch,
        Start::AlreadyRunning(running) => {
            return Ok(skip(Skipped::AlreadyRunning(running)));
        }
    };
    claim::sweep(&repo);
    let claimed = claim::open_count(&repo)?;
    if claimed >= limit.get() {
        return Ok(skip(Skipped::AtClaimLimit {
            repo,
            claimed,
            limit,
        }));
    }
    let Some(ReadyIssue { listed, is_spec }) = ready::first(directory.git(), &repo)? else {
        return Ok(skip(Skipped::NoReadyIssue(repo)));
    };
    progress::step(format_args!(
        "taking Ready issue #{} \"{}\", as thirdshift {} would",
        listed.issue.number, listed.title, listed.issue.url
    ));
    logs::started(Work::PickupRun(&listed.issue));
    Ok(Outcome::Taken(Taken {
        issue: listed.issue,
        title: listed.title,
        base,
        is_spec,
    }))
}

/// The Pickup run skipped as `skipped` says, recorded in its repository's
/// Activity log.
fn skip(skipped: Skipped) -> Outcome {
    logs::skipped(Pass::PickupRun, skipped.repo(), &skipped);
    Outcome::Skipped(skipped)
}