Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/ci.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//! Watching CI on the Issue branch's head commit.

use anyhow::Result;

use crate::github::{self, Check, CheckState};
use crate::issue::IssueUrl;
use crate::poll;
use crate::progress;

/// How CI on a commit ended.
pub enum Ci {
/// No check or status appeared within the grace period: no CI, which
/// counts as passing.
Absent,
Passed,
/// Red, with the checks that failed.
Failed(Vec<Check>),
}

/// Wait up to the grace period for any check or status on `sha`, then watch
/// them until they have all finished.
pub fn watch(issue: &IssueUrl, sha: &str) -> Result<Ci> {
let grace = poll::grace_period();
let short = &sha[..sha.len().min(7)];
progress::step(format_args!(
"waiting up to {}s for CI on {short}",
grace.as_secs()
));
let appeared = poll::within(grace, || {
Ok((!github::checks_on(issue, sha)?.is_empty()).then_some(()))
})?;
if appeared.is_none() {
progress::step(format_args!(
"no CI checks appeared on {short}; counting that as passing"
));
return Ok(Ci::Absent);
}

let mut reported_pending = None;
let checks = poll::until(|| {
let checks = github::checks_on(issue, sha)?;
let pending = count(&checks, CheckState::Pending);
if pending == 0 {
return Ok(Some(checks));
}
if reported_pending != Some(pending) {
progress::step(format_args!(
"CI on {short}: {pending} of {} checks still running",
checks.len()
));
reported_pending = Some(pending);
}
Ok(None)
})?;
let failed: Vec<Check> = checks
.into_iter()
.filter(|check| check.state == CheckState::Failed)
.collect();
if failed.is_empty() {
progress::step(format_args!("CI passed on {short}"));
return Ok(Ci::Passed);
}
let names: Vec<&str> = failed.iter().map(|check| check.name.as_str()).collect();
progress::step(format_args!("CI failed on {short}: {}", names.join(", ")));
Ok(Ci::Failed(failed))
}

fn count(checks: &[Check], state: CheckState) -> usize {
checks.iter().filter(|check| check.state == state).count()
}
109 changes: 109 additions & 0 deletions src/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,96 @@ pub fn convert_to_draft(issue: &IssueUrl, branch: &str) -> Result<()> {
])
}

/// Whether a pull request can be merged into its base.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mergeable {
Yes,
/// It conflicts with its base.
No,
/// GitHub is still working it out.
Unknown,
}

/// Whether the pull request whose head is `branch` can be merged.
pub fn mergeable(issue: &IssueUrl, branch: &str) -> Result<Mergeable> {
let json = gh_json(&[
"pr",
"view",
branch,
"--repo",
&issue.repo_slug(),
"--json",
"mergeable",
])?;
match json["mergeable"].as_str() {
Some("MERGEABLE") => Ok(Mergeable::Yes),
Some("CONFLICTING") => Ok(Mergeable::No),
Some("UNKNOWN") => Ok(Mergeable::Unknown),
other => bail!("gh output has an unknown mergeable state {other:?}"),
}
}

/// Where a check or status stands.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckState {
Pending,
Passed,
Failed,
}

/// A check run or a commit status.
pub struct Check {
pub name: String,
pub state: CheckState,
pub url: Option<String>,
}

/// Every check run and commit status on commit `sha`.
pub fn checks_on(issue: &IssueUrl, sha: &str) -> Result<Vec<Check>> {
let commit = format!("repos/{}/commits/{sha}", issue.repo_slug());
let runs = gh_api_items(&format!("{commit}/check-runs?per_page=100"), "check_runs")?;
let statuses = gh_api_items(&format!("{commit}/status?per_page=100"), "statuses")?;
let url = |value: &Value| {
value
.as_str()
.filter(|url| !url.is_empty())
.map(String::from)
};

let mut checks = Vec::new();
for run in &runs {
let state = match (run["status"].as_str(), run["conclusion"].as_str()) {
(Some("completed"), Some("success" | "neutral" | "skipped")) => CheckState::Passed,
(Some("completed"), _) => CheckState::Failed,
_ => CheckState::Pending,
};
checks.push(Check {
name: run["name"]
.as_str()
.context("a check run has no name")?
.to_string(),
state,
url: url(&run["details_url"]).or_else(|| url(&run["html_url"])),
});
}
for status in &statuses {
let state = match status["state"].as_str() {
Some("success") => CheckState::Passed,
Some("pending") => CheckState::Pending,
_ => CheckState::Failed,
};
checks.push(Check {
name: status["context"]
.as_str()
.context("a commit status has no context")?
.to_string(),
state,
url: url(&status["target_url"]),
});
}
Ok(checks)
}

/// Run `gh <args>`, failing with its stderr if it exits non-zero.
fn gh(args: &[&str]) -> Result<()> {
let output = Command::new("gh")
Expand All @@ -158,6 +248,25 @@ fn gh(args: &[&str]) -> Result<()> {
Ok(())
}

/// Every item of the `field` list of the GitHub API's `path`, across all its
/// pages.
fn gh_api_items(path: &str, field: &str) -> Result<Vec<Value>> {
let output = Command::new("gh")
.args(["api", "--paginate", "--jq", &format!(".{field}[]"), path])
.output()
.context("could not run gh")?;
if !output.status.success() {
bail!(
"gh api {path} failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
serde_json::Deserializer::from_slice(&output.stdout)
.into_iter()
.collect::<Result<_, _>>()
.with_context(|| format!("gh api {path} returned invalid JSON"))
}

/// Run `gh <args>` and parse its stdout as JSON, failing with gh's stderr if
/// it exits non-zero.
fn gh_json(args: &[&str]) -> Result<Value> {
Expand Down
2 changes: 2 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
mod branch;
mod ci;
mod failed_run;
mod git;
mod github;
mod interrupt;
mod issue;
mod plugin;
mod poll;
mod preflight;
mod progress;
mod prompt;
Expand Down
65 changes: 65 additions & 0 deletions src/poll.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//! Asking GitHub the same question until it has an answer.

use std::time::{Duration, Instant};

use anyhow::{Result, bail};

use crate::interrupt;

/// How long to wait for something GitHub should soon report: CI appearing
/// after a push, or whether a PR is mergeable. `THIRDSHIFT_CI_GRACE_MS`
/// overrides it, for tests.
pub fn grace_period() -> Duration {
millis_from_env("THIRDSHIFT_CI_GRACE_MS").unwrap_or(Duration::from_secs(60))
}

/// Ask `probe` every poll interval until it answers `Some`, and return the
/// answer. Fails with `interrupted` as soon as the Run is interrupted.
pub fn until<T>(mut probe: impl FnMut() -> Result<Option<T>>) -> Result<T> {
loop {
if let Some(answer) = probe()? {
return Ok(answer);
}
sleep(poll_interval())?;
}
}

/// Like [`until`], but give up with `None` once `limit` has passed.
pub fn within<T>(
limit: Duration,
mut probe: impl FnMut() -> Result<Option<T>>,
) -> Result<Option<T>> {
let deadline = Instant::now() + limit;
until(|| match probe()? {
Some(answer) => Ok(Some(Some(answer))),
None if Instant::now() >= deadline => Ok(Some(None)),
None => Ok(None),
})
}

/// How long to wait between two asks. `THIRDSHIFT_POLL_MS` overrides it, for
/// tests.
fn poll_interval() -> Duration {
millis_from_env("THIRDSHIFT_POLL_MS").unwrap_or(Duration::from_secs(10))
}

fn millis_from_env(name: &str) -> Option<Duration> {
let millis = std::env::var(name).ok()?.parse().ok()?;
Some(Duration::from_millis(millis))
}

/// Sleep for `duration`, failing with `interrupted` as soon as the Run is
/// interrupted.
fn sleep(duration: Duration) -> Result<()> {
let deadline = Instant::now() + duration;
loop {
if interrupt::requested() {
bail!("interrupted");
}
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
return Ok(());
}
std::thread::sleep(left.min(Duration::from_millis(100)));
}
}
31 changes: 31 additions & 0 deletions src/prompt.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! The prompts agent sessions are started with.

use crate::github::Check;
use crate::issue::IssueUrl;

/// The fresh prompt, for a run that starts a new Issue branch.
Expand Down Expand Up @@ -61,3 +62,33 @@ pub fn conflict_repair(issue: &IssueUrl, base: &str, branch: &str, pr_url: &str)
url = issue.url,
)
}

/// The CI-fix Repair prompt, for red CI on the PR's head commit, listing
/// each check in `failed`.
pub fn ci_fix_repair(
issue: &IssueUrl,
base: &str,
branch: &str,
pr_url: &str,
failed: &[Check],
) -> String {
let checks: String = failed
.iter()
.map(|check| match &check.url {
Some(url) => format!("- {}: {url}\n", check.name),
None => format!("- {}\n", check.name),
})
.collect();
format!(
"CI failed on pull request {pr_url} (branch {branch}, implementing {url}).\n\
\n\
Failed checks:\n\
{checks}\
\n\
Read the failure logs (e.g. `gh run view <run-id> --log-failed`), find the root cause, and fix it. Do not skip, disable, or weaken tests or checks to make them pass.\n\
Run the affected checks locally, commit, and push {branch}.\n\
\n\
If a failure is not caused by this branch (it is flaky, or also fails on {base}), do not change code for it. Instead, add it to a \"CI notes\" section of the pull request body with a one-line explanation.\n",
url = issue.url,
)
}
Loading
Loading