use std::path::{Path, PathBuf};
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::store::Resolution;
use crate::stream::{self, Stream};
use crate::workspace::{object, Ref};
use crate::{gate, gh, git, home, ids, lock, policy, provenance, queue};
pub const SYNC_ATTEMPTS: usize = 3;
#[derive(Debug, Clone)]
pub enum Outcome {
Merged(Sha),
Open(Url),
Queued(Url),
NothingToPublish,
}
impl Outcome {
pub fn describe(&self) -> String {
match self {
Outcome::Merged(sha) => format!("merged at {}", sha.0),
Outcome::Open(url) => format!("change request open at {url}"),
Outcome::Queued(url) => format!("merge queued for {url}"),
Outcome::NothingToPublish => {
"nothing to publish: the base already carries this branch's content".to_owned()
}
}
}
}
pub struct Context<'a> {
pub resolution: Resolution,
pub policy: Policy,
pub effective: MergePolicy,
pub repo: PathBuf,
pub worktree: PathBuf,
pub branch: Ref,
pub base: Ref,
pub change_base: Ref,
pub run_root: PathBuf,
pub title: Option<String>,
pub trailers: Vec<String>,
pub provenance: provenance::Trailers,
pub hosting: &'a dyn Hosting,
}
pub fn run(context: &Context<'_>, stream: &mut Stream) -> Result<Outcome> {
let remote_base = format!("origin/{}", context.change_base);
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 compared = if git::ref_exists(&context.repo, &format!("refs/remotes/{remote_base}")) {
remote_base.clone()
} else {
context.change_base.to_string()
};
sync(context, stream, &compared)?;
if git::log_messages(&context.repo, &compared, &context.branch)?.is_empty() {
return Ok(Outcome::NothingToPublish);
}
let (subject, trailers) = describe(context, &compared)?;
let environment = gate::comparison_env("origin", &context.change_base);
verify(context, stream, &environment)?;
match context.effective {
MergePolicy::LocalDirect => publish_locally(context, stream, &compared, &environment),
_ => publish_as_change(context, stream, &subject, &trailers, &environment),
}
}
fn describe(context: &Context<'_>, compared: &str) -> Result<(String, Vec<String>)> {
let subject = match provenance::publication_subject(
&context.repo,
compared,
&context.branch,
context.title.as_deref(),
&context.provenance,
)? {
Ok(subject) => subject,
Err(reason) => {
return Err(Error::Invalid {
reason: format!(
"cannot publish {:?}: {reason}. A subject that names no change would make \
the base branch a worse record than this refusal does.",
context.branch
),
})
}
};
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.change_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),
})
}
fn sync(context: &Context<'_>, stream: &mut Stream, compared: &str) -> Result<()> {
if !git::ref_exists(&context.repo, &format!("refs/remotes/{compared}"))
&& !git::branch_exists(&context.repo, compared)
{
return Ok(());
}
for attempt in 1..=SYNC_ATTEMPTS {
let merged = git::merge_into_branch(
&context.worktree,
compared,
&format!("Merge {compared} into {}", context.branch),
)?;
if merged {
return Ok(());
}
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.change_base,
"attempts": SYNC_ATTEMPTS,
})),
);
Err(Error::SyncConflict {
reason: format!(
"{compared} conflicts with {:?} after {SYNC_ATTEMPTS} bounded attempts; the branch \
is retained for recovery",
context.branch
),
})
}
fn publish_locally(
context: &Context<'_>,
stream: &mut Stream,
compared: &str,
environment: &[(String, String)],
) -> Result<Outcome> {
let publication = &context.resolution.publication;
require_publication_checkout_ready(publication, &context.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<Outcome> {
if git::has_remote(&context.repo, "origin") {
git::fetch(&context.repo, "origin")?;
}
if git::tip(&context.repo, compared) != judged {
sync(context, stream, compared)?;
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<Outcome> {
let Some(sha) = git::merge_squash(&scratch, &context.branch, &message)? else {
return Ok(Outcome::NothingToPublish);
};
let pushed = git::push(
&scratch,
&format!("HEAD:refs/heads/{}", context.base),
"origin",
environment,
)?;
record_push(context, stream, &pushed)?;
pushed.map_err(|output| Error::GateFailed {
reason: format!(
"the publishing push of {:?} was rejected by the merge path: {}",
context.branch,
output.lines().next_back().unwrap_or("").trim()
),
})?;
fast_forward_publication(publication, &context.base)?;
stream.emit(
EventKind::MergeCompleted,
object(json!({"identity": identity, "sha": sha, "base": context.base})),
);
Ok(Outcome::Merged(Sha(sha)))
})();
git::worktree_remove(&context.repo, &scratch)?;
let _ = std::fs::remove_dir_all(&scratch_parent);
landed
})();
drop(turn);
outcome
}
fn publish_as_change(
context: &Context<'_>,
stream: &mut Stream,
subject: &str,
trailers: &[String],
environment: &[(String, String)],
) -> Result<Outcome> {
let pushed = git::push(&context.worktree, &context.branch, "origin", environment)?;
record_push(context, stream, &pushed)?;
pushed.map_err(|output| Error::GateFailed {
reason: format!(
"the publishing push of {:?} was rejected by the merge path: {}",
context.branch,
output.lines().next_back().unwrap_or("").trim()
),
})?;
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.change_base)?;
let change = match existing.into_iter().next() {
Some(change) => change,
None => host.open_change(ChangeSpec {
head: context.branch.to_string(),
base: context.change_base.to_string(),
title: subject.to_owned(),
body: Some(compose_body(subject, trailers)),
})?,
};
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(Outcome::Open(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<Outcome> {
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.base)?;
Ok(Outcome::Merged(sha))
}
MergeOutcome::Queued => Ok(Outcome::Queued(change.url.clone())),
MergeOutcome::Open => Ok(Outcome::Open(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)?;
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() {
artifacts.push(crate::event::ArtifactRef {
id: host.check_log(change, check)?,
kind: "log".to_owned(),
bytes: 0,
});
}
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: &std::result::Result<String, String>,
) -> Result<()> {
let (ruling, output) = match pushed {
Ok(output) => (gate::Ruling::Passed, output),
Err(output) => (gate::Ruling::Rejected, 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 compose_body(subject: &str, trailers: &[String]) -> String {
let mut body = format!("## What\n\n{subject}\n\n## Why\n\nPublished by onevcs.\n");
if !trailers.is_empty() {
body.push_str("\n## Additional info\n\n");
body.push_str(&trailers.join("\n"));
body.push('\n');
}
body
}
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 {
match error {
Error::GateFailed { .. } => 1,
Error::SyncConflict { .. } => 3,
Error::NotImplemented { .. } => 70,
_ => 2,
}
}
pub fn preserved_change_base(record_base: &Ref, recorded: Option<&Ref>) -> Ref {
recorded.unwrap_or(record_base).clone()
}