use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::error::{Error, Result};
use crate::event::EventKind;
use url::Url;
use crate::host::{ChangeRequest, ChangeSpec, Check, Hosting, MergeOutcome, RemoteHost, Sha};
use crate::rules::{Gate, GateKind, MergePolicy, Policy};
use crate::session::{Lifecycle, Provenance, SessionToken};
use crate::store::Resolution;
use crate::stream::{self, Stream};
use crate::workspace::{object, Ref};
use crate::{
gate, gh, git, guidance, home, ids, lock, policy, provenance, queue, store, vcs, workspace,
};
pub const SYNC_ATTEMPTS: usize = 3;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PublishRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy: Option<MergePolicy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<Subject>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct Subject(String);
impl std::fmt::Debug for Subject {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Debug::fmt(&self.0, f)
}
}
impl TryFrom<String> for Subject {
type Error = String;
fn try_from(value: String) -> std::result::Result<Self, String> {
provenance::checked_subject(&value).map(Subject)
}
}
impl std::str::FromStr for Subject {
type Err = String;
fn from_str(value: &str) -> std::result::Result<Self, String> {
Subject::try_from(value.to_owned())
}
}
impl From<Subject> for String {
fn from(subject: Subject) -> Self {
subject.0
}
}
impl std::ops::Deref for Subject {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for Subject {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Publication {
pub session: SessionToken,
pub branch: String,
pub policy: MergePolicy,
pub outcome: PublishOutcome,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PublishOutcome {
Merged(Sha),
ChangeOpen(Url),
Queued(Url),
NothingToPublish,
Failed {
kind: FailureKind,
reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
retained: Option<Retention>,
},
}
impl PublishOutcome {
#[must_use]
pub fn describe(&self) -> String {
match self {
PublishOutcome::Merged(sha) => format!("merged at {}", sha.0),
PublishOutcome::ChangeOpen(url) => format!("change request open at {url}"),
PublishOutcome::Queued(url) => format!("merge queued for {url}"),
PublishOutcome::NothingToPublish => {
"nothing to publish: the base already carries this branch's content".to_owned()
}
PublishOutcome::Failed { reason, .. } => reason.clone(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FailureKind {
Gate,
Invalid,
SyncConflict,
NotImplemented,
}
impl FailureKind {
#[must_use]
pub fn exit_code(self) -> u8 {
match self {
FailureKind::Gate => 1,
FailureKind::Invalid => 2,
FailureKind::SyncConflict => 3,
FailureKind::NotImplemented => 70,
}
}
#[must_use]
pub fn of(error: &Error) -> Self {
match error {
Error::GateFailed { .. } => FailureKind::Gate,
Error::SyncConflict { .. } => FailureKind::SyncConflict,
Error::NotImplemented { .. } => FailureKind::NotImplemented,
_ => FailureKind::Invalid,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Retention {
HandedBack(PathBuf),
Refused(PathBuf),
}
pub fn run_for_session(
token: &SessionToken,
request: &PublishRequest,
hosting: &dyn Hosting,
) -> Result<Publication> {
let mut record = workspace::load(&token.0)?;
let registry = store::load()?;
let resolution = store::resolve(®istry, &record.identity)?;
let (file, source) = policy::load(®istry)?;
let normalized = store::normalize(&resolution.identity.origin);
let resolved = policy::resolve(&file, &source, &normalized, &resolution.publication);
let effective = effective_policy(&resolved.policy, request.policy)?;
let mut stream = Stream::open(&record.token)?;
stream.label("identity", &record.identity);
if git::is_dirty(&record.worktree)? {
vcs::preserve_into(&record, &mut stream, Provenance::Complete)?;
}
let target = recorded_target(&record, &resolution.publication)?;
let context = Context {
resolution,
policy: resolved.policy.clone(),
effective,
repo: record.clone.clone(),
worktree: record.worktree.clone(),
branch: record.branch.clone(),
target,
push: Push::Forward,
run_root: record.run_root.clone(),
title: request.title.clone(),
body: request.body.clone(),
trailers: Vec::new(),
provenance: provenance::from_rules(&file),
hosting,
};
let branch = record.branch.to_string();
let outcome = match run(&context, &mut stream) {
Ok(outcome) => {
record.state = Lifecycle::Closed;
workspace::save(&record)?;
outcome
}
Err(error) => {
let checkout = record.execution_checkout.clone();
let retained = match git::copy_branch(&record.clone, &checkout, &record.branch) {
Ok(true) => Retention::HandedBack(checkout),
_ => Retention::Refused(checkout),
};
PublishOutcome::Failed {
kind: FailureKind::of(&error),
reason: error.to_string(),
retained: Some(retained),
}
}
};
Ok(Publication {
session: token.clone(),
branch,
policy: effective,
outcome,
})
}
pub struct Context<'a> {
pub resolution: Resolution,
pub policy: Policy,
pub effective: MergePolicy,
pub repo: PathBuf,
pub worktree: PathBuf,
pub branch: Ref,
pub target: Target,
pub push: Push,
pub run_root: PathBuf,
pub title: Option<Subject>,
pub body: Option<String>,
pub trailers: Vec<String>,
pub provenance: provenance::Trailers,
pub hosting: &'a dyn Hosting,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Push {
Forward,
Replacing {
replaced: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Target {
Base(Ref),
Stacked {
below: Ref,
root: Ref,
tip: String,
},
}
impl Target {
pub fn base(&self) -> &Ref {
match self {
Target::Base(base) => base,
Target::Stacked { below, .. } => below,
}
}
}
fn recorded_target(record: &workspace::Record, publication: &Path) -> Result<Target> {
let base = preserved_change_base(&record.base, record.change_base.as_ref());
let Some(recorded) = record.stack_tip.as_deref() else {
return Ok(Target::Base(base));
};
let Some(tip) = git::tip(&record.clone, recorded) else {
return Err(Error::Invalid {
reason: format!(
"the record for session {token} names {recorded:?} as the commit branch {branch:?} \
was cut from, and the clone at {clone} does not have it, so nothing can tell \
which of that branch's commits belong to the change below it. The branch is \
whole — publish it by name with `{command}`, which reads the stack off the \
branch instead",
token = record.token,
branch = record.branch,
clone = record.clone.display(),
command = guidance::command([
"onevcs",
"publish-branch",
&record.branch,
"--repo",
&record.publication_checkout.to_string_lossy(),
]),
),
});
};
let Some(root) = git::default_branch(publication, "origin").ok() else {
return Ok(Target::Base(base));
};
let root = Ref::from_git(root);
if root == base {
return Ok(Target::Base(base));
}
Ok(Target::Stacked {
below: base,
root,
tip,
})
}
pub(crate) fn root_is_known_to_carry_the_stack(
repo: &Path,
branch: &str,
root: &Ref,
tip: &str,
) -> Result<bool> {
let root = vcs::base_ref(repo, root);
if !git::ref_exists(repo, &format!("refs/remotes/{root}")) && !git::branch_exists(repo, &root) {
return Ok(false);
}
if !git::is_ancestor(repo, tip, branch)? {
return Ok(false);
}
if git::is_ancestor(repo, tip, &root)? {
return Ok(false);
}
let Some(fork) = git::merge_base(repo, &root, tip)? else {
return Ok(false);
};
git::known_to_carry_changes(repo, &root, &fork, tip)
}
impl<'a> Context<'a> {
fn onto(&self, target: Target) -> Context<'a> {
Context {
resolution: self.resolution.clone(),
policy: self.policy.clone(),
effective: self.effective,
repo: self.repo.clone(),
worktree: self.worktree.clone(),
branch: self.branch.clone(),
target,
push: self.push.clone(),
run_root: self.run_root.clone(),
title: self.title.clone(),
body: self.body.clone(),
trailers: self.trailers.clone(),
provenance: self.provenance.clone(),
hosting: self.hosting,
}
}
}
pub fn run(context: &Context<'_>, stream: &mut Stream) -> Result<PublishOutcome> {
let last_seen = git::tip(&context.repo, &format!("origin/{}", context.branch));
if git::has_remote(&context.repo, "origin") {
git::fetch(&context.repo, "origin")?;
stream.emit(
EventKind::Fetch,
object(json!({"remote": "origin", "checkout": context.repo.display().to_string()})),
);
}
let mut replay = None;
let landed;
let mut context = context;
if let Target::Stacked { root, tip, .. } = &context.target {
if root_is_known_to_carry_the_stack(&context.repo, &context.branch, root, tip)? {
replay = Some(tip.clone());
landed = context.onto(Target::Base(root.clone()));
context = &landed;
}
}
let remote_base = format!("origin/{}", context.target.base());
let compared = if git::ref_exists(&context.repo, &format!("refs/remotes/{remote_base}")) {
remote_base.clone()
} else {
context.target.base().to_string()
};
sync(context, stream, &compared, replay.as_deref())?;
if nothing_to_publish(context, &compared)? {
return Ok(PublishOutcome::NothingToPublish);
}
let push = match (&replay, &last_seen) {
(Some(_), Some(replaced)) => Push::Replacing {
replaced: replaced.clone(),
},
_ => context.push.clone(),
};
let (subject, _) = describe(context, &compared)?;
let environment = gate::comparison_env("origin", context.target.base());
verify(context, stream, &environment)?;
match context.effective {
MergePolicy::LocalDirect => publish_locally(context, stream, &compared, &environment),
_ => publish_as_change(context, stream, &subject, &environment, push),
}
}
pub(crate) fn subject_for(
repo: &Path,
compared: &str,
branch: &str,
title: Option<&str>,
trailers: &provenance::Trailers,
) -> Result<String> {
let subject = match provenance::publication_subject(repo, compared, branch, title, trailers)? {
Ok(subject) => subject,
Err(reason) => {
return Err(Error::Invalid {
reason: format!(
"cannot publish {branch:?}: {reason}. A subject that names no change would \
make the base branch a worse record than this refusal does."
),
})
}
};
hold_to_repository_policy(repo, branch, &subject)?;
Ok(subject)
}
fn hold_to_repository_policy(repo: &Path, branch: &str, subject: &str) -> Result<()> {
let git::MessagePolicy::Rejected { status, output } = git::message_policy(repo, subject)?
else {
return Ok(());
};
let said = guidance::quoted_output(output.trim());
let said = if said.is_empty() {
"<no output>".to_owned()
} else {
said
};
Err(Error::GateFailed {
reason: format!(
"the repository's own commit-msg hook rejected the subject publishing {branch:?} \
would land, {subject:?} (exit {status}). Reword the commit the subject comes from, \
or publish with an explicit title that satisfies it. The hook said:\n{said}"
),
})
}
fn nothing_to_publish(context: &Context<'_>, compared: &str) -> Result<bool> {
if git::log_messages(&context.repo, compared, &context.branch)?.is_empty() {
return Ok(true);
}
Ok(!git::trees_differ(
&context.repo,
compared,
&context.branch,
)?)
}
fn describe(context: &Context<'_>, compared: &str) -> Result<(String, Vec<String>)> {
let subject = subject_for(
&context.repo,
compared,
&context.branch,
context.title.as_deref(),
&context.provenance,
)?;
let mut trailers = context.trailers.clone();
trailers.extend(provenance::attestation_trailers(
&context.repo,
compared,
&context.branch,
&context.provenance,
)?);
Ok((subject, trailers))
}
fn verify(
context: &Context<'_>,
stream: &mut Stream,
environment: &[(String, String)],
) -> Result<()> {
let Some(command) = gate::own_command(&context.policy.gate) else {
return Ok(());
};
stream.emit(
EventKind::GateStarted,
object(json!({
"command": command.join(" "),
"comparison_remote": "origin",
"comparison_base": context.target.base(),
})),
);
let verdict = gate::run(&context.worktree, command, environment);
let artifact = stream::store_artifact("log", &verdict.output)?;
let preserved = gate::preserve_log(&context.run_root, &context.branch, &verdict.output)?;
stream.emit_with(
EventKind::GateVerdict,
object(json!({
"verdict": verdict.ruling.describe(),
"command": verdict.command,
"output": verdict.output,
"preserved_log": preserved.display().to_string(),
})),
vec![artifact],
);
if verdict.ruling.passed() {
return Ok(());
}
Err(Error::GateFailed {
reason: format!("{} rejected {:?}", verdict.command, context.branch),
})
}
pub(crate) enum Reconciled {
Settled,
Conflicted(Reconciliation),
}
pub(crate) enum Reconciliation {
Merge,
Replay {
from: String,
},
}
pub(crate) fn reconcile(
worktree: &Path,
compared: &str,
branch: &str,
replay_from: Option<&str>,
) -> Result<Reconciled> {
let (shape, integrated) = match replay_from {
Some(tip) => (
Reconciliation::Replay {
from: tip.to_owned(),
},
git::rebase_onto(worktree, compared, tip, branch)?,
),
None => (
Reconciliation::Merge,
git::merge_into_branch(
worktree,
compared,
&format!("Merge {compared} into {branch}"),
)?,
),
};
Ok(match integrated {
git::Integrated::Settled => Reconciled::Settled,
git::Integrated::Conflicted => Reconciled::Conflicted(shape),
})
}
fn sync(
context: &Context<'_>,
stream: &mut Stream,
compared: &str,
replay_from: Option<&str>,
) -> Result<()> {
if !git::ref_exists(&context.repo, &format!("refs/remotes/{compared}"))
&& !git::branch_exists(&context.repo, compared)
{
return Ok(());
}
let mut attempted = Reconciliation::Merge;
for attempt in 1..=SYNC_ATTEMPTS {
match reconcile(&context.worktree, compared, &context.branch, replay_from)? {
Reconciled::Settled => return Ok(()),
Reconciled::Conflicted(shape) => attempted = shape,
}
if attempt < SYNC_ATTEMPTS && git::has_remote(&context.repo, "origin") {
git::fetch(&context.repo, "origin")?;
}
}
stream.emit(
EventKind::SyncConflict,
object(json!({
"branch": context.branch,
"base": context.target.base(),
"attempts": SYNC_ATTEMPTS,
})),
);
let land = guidance::command([
"onevcs",
"publish-branch",
&context.branch,
"--repo",
&context.resolution.publication.to_string_lossy(),
]);
Err(Error::SyncConflict {
reason: match attempted {
Reconciliation::Replay { from } => {
format!(
"{compared} conflicts with {branch:?} after {SYNC_ATTEMPTS} bounded attempts; the \
branch is retained. {compared} already carries what {branch:?} was stacked on, so \
only its own commits are replayed onto it. Resolve the conflict on it — replay it \
with `{}` — and then land it with `{land}`",
guidance::command(["git", "rebase", "--onto", compared, &from, &context.branch]),
branch = context.branch,
)
}
Reconciliation::Merge => format!(
"{compared} conflicts with {branch:?} after {SYNC_ATTEMPTS} bounded attempts; the \
branch is retained. Resolve the conflict on it — merge {compared} into {branch} — \
and then land it with `{land}`",
branch = context.branch,
),
},
})
}
fn publish_locally(
context: &Context<'_>,
stream: &mut Stream,
compared: &str,
environment: &[(String, String)],
) -> Result<PublishOutcome> {
let publication = &context.resolution.publication;
require_publication_checkout_ready(publication, context.target.base())?;
let judged = git::tip(&context.repo, compared);
let identity = lock::git_identity(&git::common_dir(publication)?);
let turn = queue::turn(&identity)?;
stream.emit(
EventKind::LockWait,
object(json!({
"identity": identity,
"elapsed": turn.waited.as_secs_f64(),
"queue_position": turn.position,
})),
);
stream.emit(
EventKind::LockAcquired,
object(json!({"identity": identity})),
);
stream.emit(
EventKind::MergeQueued,
object(json!({"identity": identity, "queue_position": turn.position})),
);
let outcome = (|| -> Result<PublishOutcome> {
if git::has_remote(&context.repo, "origin") {
git::fetch(&context.repo, "origin")?;
}
if git::tip(&context.repo, compared) != judged {
sync(context, stream, compared, None)?;
verify(context, stream, environment)?;
}
let (subject, trailers) = describe(context, compared)?;
let message = compose_message(&subject, &trailers);
let scratch_parent = context.run_root.join(format!("publish-{}", ids::unique()));
home::ensure_dir(&scratch_parent)?;
let scratch = scratch_parent.join("worktree");
git::worktree_add_detached(&context.repo, &scratch, compared)?;
let landed = (|| -> Result<PublishOutcome> {
let Some(sha) = git::merge_squash(&scratch, &context.branch, &message)? else {
return Ok(PublishOutcome::NothingToPublish);
};
let pushed = git::push(
&scratch,
&format!("HEAD:refs/heads/{}", context.target.base()),
"origin",
environment,
)?;
record_push(context, stream, &pushed)?;
if !pushed.accepted() {
return Err(rejected(context, &pushed));
}
fast_forward_publication(publication, context.target.base())?;
stream.emit(
EventKind::MergeCompleted,
object(json!({"identity": identity, "sha": sha, "base": context.target.base()})),
);
Ok(PublishOutcome::Merged(Sha(sha)))
})();
git::worktree_remove(&context.repo, &scratch)?;
let _ = std::fs::remove_dir_all(&scratch_parent);
landed
})();
drop(turn);
outcome
}
fn rejected(context: &Context<'_>, pushed: &git::Pushed) -> Error {
Error::GateFailed {
reason: format!(
"the publishing push of {:?} was rejected by the merge path: {}",
context.branch,
pushed.refusal().unwrap_or_else(|| pushed
.output()
.lines()
.next_back()
.unwrap_or("")
.trim())
),
}
}
enum Declined {
Moved(git::ObjectId),
Gone,
}
fn declined_the_lease(
context: &Context<'_>,
pushed: &git::Pushed,
replaced: &str,
environment: &[(String, String)],
) -> Result<Option<Declined>> {
if !pushed.refused_branch(&context.branch) {
return Ok(None);
}
Ok(
match git::remote_tip(&context.worktree, "origin", &context.branch, environment)? {
git::RemoteTip::At(tip) if tip.as_str() == replaced => None,
git::RemoteTip::At(tip) => Some(Declined::Moved(tip)),
git::RemoteTip::Absent => Some(Declined::Gone),
git::RemoteTip::Unknown => None,
},
)
}
fn publish_as_change(
context: &Context<'_>,
stream: &mut Stream,
subject: &str,
environment: &[(String, String)],
push: Push,
) -> Result<PublishOutcome> {
let replacing = match &push {
Push::Replacing { replaced } => Some(replaced.as_str()),
Push::Forward => None,
};
let pushed = git::push_replacing(
&context.worktree,
&context.branch,
"origin",
replacing,
environment,
)?;
record_push(context, stream, &pushed)?;
if !pushed.accepted() {
let declined = match replacing {
Some(replaced) => declined_the_lease(context, &pushed, replaced, environment)?,
None => None,
};
let (Some(replaced), Some(declined)) = (replacing, declined) else {
return Err(rejected(context, &pushed));
};
let branch = &context.branch;
let base = context.target.base();
let command = guidance::command([
"onevcs",
"publish-branch",
branch,
"--repo",
&context.resolution.publication.to_string_lossy(),
]);
return Err(Error::SyncConflict {
reason: match declined {
Declined::Moved(tip) => format!(
"{branch:?} moved on the host since this run last had it at {replaced} — it \
is at {tip} now — and this publication replayed it onto {base:?}, so pushing \
would replace whatever was pushed there in between. Nothing was overwritten \
and the branch is retained. Reconcile the two — fetch {branch}, and replay \
or merge what the host has into it — and then land it with `{command}`",
tip = tip.as_str(),
),
Declined::Gone => format!(
"{branch:?} is gone from the host, which this run last had at {replaced}, and \
this publication replayed it onto {base:?} — so the push would put a branch \
back that somebody deleted, out of a history nobody there has seen. Nothing \
was pushed and the branch is retained. Fetch {branch} so this run sees the \
host as it stands — every fetch here prunes — and then land it with \
`{command}`"
),
},
});
}
let slug = change_host(&context.resolution.key)?;
let host = context.hosting.for_repo(&slug)?;
let author = host.authenticated_user()?;
let existing = host.find_changes(&context.branch, context.target.base())?;
let change = match existing.into_iter().next() {
Some(change) => change,
None => host.open_change(ChangeSpec {
head: context.branch.to_string(),
base: context.target.base().to_string(),
title: subject.to_owned(),
body: context.body.clone(),
})?,
};
stream.emit(
EventKind::ChangeOpened,
object(json!({
"url": change.url.to_string(),
"host": "github",
"id": change.id.0,
"base": change.base,
"author": author,
})),
);
if context.effective == MergePolicy::ChangeOpen {
return Ok(PublishOutcome::ChangeOpen(change.url.clone()));
}
let identity = lock::git_identity(&git::common_dir(&context.resolution.publication)?);
let turn = queue::turn(&identity)?;
stream.emit(
EventKind::LockWait,
object(json!({
"identity": identity,
"elapsed": turn.waited.as_secs_f64(),
"queue_position": turn.position,
})),
);
stream.emit(
EventKind::LockAcquired,
object(json!({"identity": identity})),
);
let outcome = (|| -> Result<PublishOutcome> {
if matches!(
context.policy.gate,
Gate::Kind {
kind: GateKind::Checks
}
) {
await_checks(host.as_ref(), &change, stream)?;
}
stream.emit(
EventKind::MergeQueued,
object(json!({
"identity": identity,
"queue_position": turn.position,
"url": change.url.to_string(),
})),
);
match host.merge(&change, context.effective)? {
MergeOutcome::Merged(sha) => {
stream.emit(
EventKind::ChangeMerged,
object(json!({"url": change.url.to_string(), "sha": sha.0})),
);
stream.emit(
EventKind::MergeCompleted,
object(json!({"identity": identity, "sha": sha.0})),
);
fast_forward_publication(&context.resolution.publication, context.target.base())?;
Ok(PublishOutcome::Merged(sha))
}
MergeOutcome::Queued => Ok(PublishOutcome::Queued(change.url.clone())),
MergeOutcome::Open => Ok(PublishOutcome::ChangeOpen(change.url.clone())),
}
})();
drop(turn);
outcome
}
fn await_checks(host: &dyn RemoteHost, change: &ChangeRequest, stream: &mut Stream) -> Result<()> {
let bound = std::time::Duration::from_secs_f64(gh::checks_timeout()?);
let poll = std::time::Duration::from_secs_f64(gh::checks_poll()?);
let started = std::time::Instant::now();
let mut reported: Vec<(String, String)> = Vec::new();
loop {
let checks = host.change_checks(change)?.checks;
for check in &checks {
let previous = reported
.iter()
.find(|(name, _)| name == &check.name)
.map(|(_, status)| status.clone());
if previous.as_deref() == Some(check.status.as_str()) {
continue;
}
let mut artifacts = Vec::new();
if check.settled() {
match host.check_log(change, check) {
Ok(id) => artifacts.push(crate::event::ArtifactRef {
id,
kind: "log".to_owned(),
bytes: 0,
}),
Err(error) => eprintln!(
"onevcs: warning: check {:?} on {} is recorded without its log: {error}",
check.name, change.url
),
}
}
stream.emit_with(
EventKind::ChangeCheck,
object(json!({
"name": check.name,
"required": check.required,
"status": check.status,
"from_status": previous,
"conclusion": check.conclusion,
})),
artifacts,
);
reported.retain(|(name, _)| name != &check.name);
reported.push((check.name.clone(), check.status.clone()));
}
let required: Vec<&Check> = checks.iter().filter(|check| check.required).collect();
if let Some(failed) = required.iter().find(|check| check.red()) {
return Err(Error::GateFailed {
reason: format!(
"required check {:?} concluded {}",
failed.name,
failed
.conclusion
.as_deref()
.unwrap_or("without a conclusion")
),
});
}
if !required.is_empty() && required.iter().all(|check| check.green()) {
return Ok(());
}
if started.elapsed() >= bound {
return Err(Error::GateFailed {
reason: format!(
"the host reported no settled required checks on {} within {}s",
change.url,
bound.as_secs_f64()
),
});
}
std::thread::sleep(poll);
}
}
fn record_push(context: &Context<'_>, stream: &mut Stream, pushed: &git::Pushed) -> Result<()> {
let ruling = if pushed.accepted() {
gate::Ruling::Passed
} else {
gate::Ruling::Rejected
};
let output = pushed.output();
stream.emit(
EventKind::Push,
object(json!({
"branch": context.branch,
"remote": "origin",
"accepted": ruling.passed(),
})),
);
if matches!(
context.policy.gate,
Gate::Kind {
kind: GateKind::PrePush
}
) {
let artifact = stream::store_artifact("log", output)?;
let preserved = gate::preserve_log(&context.run_root, &context.branch, output)?;
stream.emit_with(
EventKind::GateVerdict,
object(json!({
"verdict": ruling.describe(),
"command": "the repository's pre-push hook",
"output": output,
"preserved_log": preserved.display().to_string(),
})),
vec![artifact],
);
}
Ok(())
}
fn require_publication_checkout_ready(publication: &Path, base: &str) -> Result<()> {
let current = git::current_branch(publication)?;
if current != base {
return Err(Error::Invalid {
reason: format!(
"the publication checkout {} has {current:?} checked out, not the base {base:?}; \
it is never worked in and only ever fast-forwarded",
publication.display()
),
});
}
if git::is_dirty(publication)? {
return Err(Error::Invalid {
reason: format!(
"the publication checkout {} is dirty; it is never worked in",
publication.display()
),
});
}
Ok(())
}
pub fn fast_forward_publication(publication: &Path, base: &str) -> Result<()> {
if !git::has_remote(publication, "origin") {
return Ok(());
}
git::fetch(publication, "origin")?;
if git::current_branch(publication)? != base {
return Ok(());
}
git::merge_ff_only(publication, &format!("origin/{base}"))
}
fn change_host(identity: &str) -> Result<String> {
if let Some(slug) = gh::slug(identity) {
return Ok(slug);
}
let hosted = identity.split('/').count() == 3;
if hosted {
return Err(Error::NotImplemented {
operation: "RemoteHost for a host other than github.com",
});
}
Err(Error::Invalid {
reason: format!(
"identity {identity:?} is not a hosted repository, so it cannot publish a change \
request; a local identity publishes with local-direct"
),
})
}
pub fn compose_message(subject: &str, trailers: &[String]) -> String {
if trailers.is_empty() {
subject.to_owned()
} else {
format!("{subject}\n\n{}", trailers.join("\n"))
}
}
pub fn effective_policy(resolved: &Policy, requested: Option<MergePolicy>) -> Result<MergePolicy> {
match requested {
Some(requested) => policy::narrow(resolved, requested),
None => Ok(resolved.publication),
}
}
pub fn exit_code(error: &Error) -> u8 {
FailureKind::of(error).exit_code()
}
pub fn preserved_change_base(record_base: &Ref, recorded: Option<&Ref>) -> Ref {
recorded.unwrap_or(record_base).clone()
}