use std::path::PathBuf;
use url::Url;
use onevcs::{
ArtifactId, ChangeChecks, ChangeId, ChangeRequest, ChangeSpec, ChangeStandings, Check,
CheckSource, CheckState, CommentId, Description, Error, HeadActivity, Hosting, MergeOutcome,
MergePolicy, Mergeability, MergedChange, PostedReply, ReadCost, ReadMarker, RemoteHost,
RequiredChecks, Result, ReviewRead, ReviewStandings, Sha, Visibility,
};
use crate::landings::{self, HeadAfter, PersonMerge};
use crate::state::{ActivityAnswer, Described, HostCall, HostComment, HostState};
use crate::store::{FileStore, MemoryStore, Store};
use crate::{events, review};
pub const DEFAULT_HOST: &str = "github.com";
pub const DEFAULT_SLUG: &str = "onevcs/testing";
#[derive(Debug)]
pub struct Host<T> {
store: T,
slug: String,
}
pub type MemoryHost = Host<MemoryStore<HostState>>;
pub type FileHost = Host<FileStore<HostState>>;
impl MemoryHost {
pub fn new() -> Self {
Self::seeded(HostState::default())
}
pub fn seeded(state: HostState) -> Self {
Self {
store: MemoryStore::new(state),
slug: DEFAULT_SLUG.to_owned(),
}
}
pub fn state(&self) -> HostState {
self.store
.snapshot()
.expect("an in-memory store always answers")
}
}
impl Default for MemoryHost {
fn default() -> Self {
Self::new()
}
}
impl FileHost {
pub fn create(path: impl Into<PathBuf>) -> Result<Self> {
Ok(Self {
store: FileStore::attach(path, &HostState::default())?,
slug: DEFAULT_SLUG.to_owned(),
})
}
pub fn seeded(path: impl Into<PathBuf>, state: HostState) -> Result<Self> {
Ok(Self {
store: FileStore::replace(path, &state)?,
slug: DEFAULT_SLUG.to_owned(),
})
}
pub fn state(&self) -> Result<HostState> {
self.store.snapshot()
}
}
impl<T: Store<HostState>> Host<T> {
pub fn merge_by_person(
&self,
change: &ChangeId,
how: PersonMerge,
at: &str,
head: HeadAfter,
) -> Result<Sha> {
self.store
.with(|state| landings::merge_by_person(state, change, how, at, head))
}
pub fn close_unmerged(&self, change: &ChangeId) -> Result<()> {
self.store.with(|state| {
if !state.changes.iter().any(|held| held.id == *change) {
return Err(Error::Invalid {
reason: format!("no change request {:?} was opened on this host", change.0),
});
}
state.closed.insert(change.clone());
Ok(())
})
}
pub fn retarget_by_person(&self, change: &ChangeId, base: &str) -> Result<()> {
crate::state::named_branch(base, "the base branch")?;
self.store.with(|state| {
if !state.changes.iter().any(|held| held.id == *change) {
return Err(Error::Invalid {
reason: format!("no change request {:?} was opened on this host", change.0),
});
}
landings::retarget_in(state, change, base);
Ok(())
})
}
pub fn set_head_checks(&self, head: &Sha, checks: Vec<Check>) -> Result<()> {
self.store.with(|state| {
state.head_checks.insert(head.clone(), checks);
Ok(())
})
}
pub fn ledger(&self) -> Result<Vec<HostCall>> {
Ok(self.store.snapshot()?.ledger)
}
fn called(&self, method: &str, changes: &[&ChangeId], charge: ReadCost) -> Result<()> {
let call = HostCall {
method: method.to_owned(),
changes: changes.iter().map(|id| (*id).clone()).collect(),
charge,
};
call.checked()?;
self.store.with(|state| {
state.ledger.push(call);
Ok(())
})
}
}
impl<T: Store<HostState>> Host<T> {
pub fn add_comment(&self, change: &ChangeId, comment: HostComment) -> Result<()> {
review::add(&self.store, change, comment)
}
pub fn edit_comment(&self, change: &ChangeId, comment: &CommentId, body: &str) -> Result<()> {
review::edit(&self.store, change, comment, body)
}
pub fn set_thread(
&self,
change: &ChangeId,
thread: &str,
resolved: bool,
outdated: bool,
) -> Result<()> {
review::set_thread(&self.store, change, thread, resolved, outdated)
}
}
impl<T: Store<HostState> + Clone + std::fmt::Debug + Send + Sync + 'static> Hosting for Host<T> {
fn for_repo(&self, slug: &str) -> Result<Box<dyn RemoteHost>> {
Ok(Box::new(Host {
store: self.store.clone(),
slug: named_repository(slug)?,
}))
}
}
impl<T: Store<HostState>> RemoteHost for Host<T> {
fn authenticated_user(&self) -> Result<String> {
self.called("authenticated_user", &[], landings::rest(1))?;
let login = self.store.snapshot()?.authenticated_user;
if login.trim().is_empty() {
return Err(Error::Invalid {
reason: "the host reported no authenticated user".to_owned(),
});
}
Ok(login)
}
fn open_change(&self, req: ChangeSpec) -> Result<ChangeRequest> {
self.called("open_change", &[], landings::graphql(2))?;
addressable(&req.head, "the head branch")?;
addressable(&req.base, "the base branch")?;
crate::state::titled(&req.title)?;
if let Some(reason) = &req.draft {
reason.checked()?;
}
let slug = self.slug.clone();
self.store.with(|state| {
let id = ChangeId((state.changes.len() + 1).to_string());
let url = format!("https://{DEFAULT_HOST}/{slug}/pull/{}", id.0);
let change = ChangeRequest {
head_sha: Sha(events::stable_sha(&[&slug, &req.head, &id.0])),
url: Url::parse(&url).map_err(|e| Error::Invalid {
reason: format!("{url:?} is not a URL: {e}"),
})?,
base: req.base.clone(),
id: id.clone(),
};
state.heads.insert(id.clone(), req.head.clone());
state.titles.insert(id.clone(), req.title.clone());
if let Some(body) = req.body.clone() {
state.bodies.insert(id.clone(), body);
}
if let Some(reason) = req.draft.clone() {
state.drafts.insert(id, reason);
} else if req.draft_awaiting_checks {
state.awaiting_checks.insert(id);
}
state.changes.push(change.clone());
Ok(change)
})
}
fn find_changes(&self, head: &str, base: &str) -> Result<Vec<ChangeRequest>> {
self.called("find_changes", &[], landings::graphql(1))?;
addressable(head, "the head branch")?;
addressable(base, "the base branch")?;
let state = self.store.snapshot()?;
Ok(state
.changes
.iter()
.filter(|change| {
change.base == base
&& state.heads.get(&change.id).is_some_and(|from| from == head)
&& !matches!(state.merges.get(&change.id), Some(MergeOutcome::Merged(_)))
})
.cloned()
.collect())
}
fn change_checks(&self, cr: &ChangeRequest) -> Result<ChangeChecks> {
self.called("change_checks", &[&cr.id], landings::graphql(2))?;
let state = self.store.snapshot()?;
let sources = state.check_sources.clone().unwrap_or_else(complete_sources);
if sources.is_empty() {
return Err(Error::Invalid {
reason: format!(
"this host was seeded with no check source, so what the checks on {} say \
cannot be read rather than being empty",
cr.url
),
});
}
Ok(ChangeChecks {
checks: current_checks(&state, &cr.id),
sources,
})
}
fn check_log(&self, cr: &ChangeRequest, check: &Check) -> Result<ArtifactId> {
self.called("check_log", &[&cr.id], landings::rest(1))?;
let log = self
.store
.snapshot()?
.check_logs
.get(&cr.id)
.and_then(|logs| logs.get(&check.name))
.cloned()
.unwrap_or_else(|| format!("the host log for check {}\n", check.name));
events::store_artifact(&artifact_id(&cr.id, &check.name), &log)
}
fn merge(&self, cr: &ChangeRequest, policy: MergePolicy) -> Result<MergeOutcome> {
let charge = match policy {
MergePolicy::ChangeAuto | MergePolicy::ChangeDirect => landings::graphql(2),
MergePolicy::LocalDirect | MergePolicy::ChangeOpen => ReadCost::default(),
};
self.called("merge", &[&cr.id], charge)?;
self.store.with(|state| {
if matches!(policy, MergePolicy::ChangeAuto | MergePolicy::ChangeDirect)
&& drafted(state, &cr.id)
{
return Err(Error::Invalid {
reason: format!(
"{} is still a draft, and a draft can be neither merged nor armed to \
merge itself",
cr.url
),
});
}
if state.armed.contains(&cr.id)
&& matches!(state.merges.get(&cr.id), Some(MergeOutcome::Queued))
&& required_checks_green(state, &cr.id)
{
return Ok(MergeOutcome::Merged(land(state, cr)));
}
if let Some(decided) = state.merges.get(&cr.id) {
return Ok(decided.clone());
}
let landed = |state: &mut HostState| MergeOutcome::Merged(land(state, cr));
Ok(match policy {
MergePolicy::LocalDirect | MergePolicy::ChangeOpen => MergeOutcome::Open,
MergePolicy::ChangeAuto => {
if required_checks_green(state, &cr.id) {
landed(state)
} else {
state.merges.insert(cr.id.clone(), MergeOutcome::Queued);
state.armed.insert(cr.id.clone());
MergeOutcome::Queued
}
}
MergePolicy::ChangeDirect => landed(state),
})
})
}
fn ready_for_review(&self, cr: &ChangeRequest) -> Result<()> {
self.called("ready_for_review", &[&cr.id], landings::graphql(1))?;
self.store.with(|state| {
state.made_ready.push(cr.id.clone());
Ok(())
})
}
fn is_draft(&self, cr: &ChangeRequest) -> Result<bool> {
self.called("is_draft", &[&cr.id], landings::graphql(1))?;
Ok(drafted(&self.store.snapshot()?, &cr.id))
}
fn required_checks_on(&self, _base: &str) -> Result<RequiredChecks> {
self.called("required_checks_on", &[], landings::rest(2))?;
let state = self.store.snapshot()?;
if let Some(seeded) = state.required_checks {
return Ok(seeded);
}
Ok(RequiredChecks {
checks: state
.checks
.values()
.chain(state.checks_after_lift.values())
.flatten()
.chain(
state
.check_readings
.values()
.chain(state.check_readings_after_lift.values())
.flatten()
.flatten(),
)
.filter(|check| check.required)
.map(|check| check.name.clone())
.collect(),
unconsulted: std::collections::BTreeMap::new(),
})
}
fn describe_change(&self, cr: &ChangeRequest, title: Option<&str>, body: &str) -> Result<()> {
self.called("describe_change", &[&cr.id], landings::graphql(1))?;
if let Some(title) = title {
crate::state::titled(title)?;
}
self.store.with(|state| {
if !state.changes.iter().any(|change| change.id == cr.id) {
return Err(Error::Invalid {
reason: format!(
"no change request {:?} was opened on this host, so there is nothing to \
describe",
cr.id.0
),
});
}
if let Some(title) = title {
state.titles.insert(cr.id.clone(), title.to_owned());
}
state.bodies.insert(cr.id.clone(), body.to_owned());
state.described.push(Described {
id: cr.id.clone(),
title: title.map(str::to_owned),
body: body.to_owned(),
});
Ok(())
})
}
fn change_description(&self, cr: &ChangeRequest) -> Result<Description> {
self.called("change_description", &[&cr.id], landings::graphql(1))?;
let state = self.store.snapshot()?;
let title = state
.titles
.get(&cr.id)
.cloned()
.ok_or_else(|| Error::Invalid {
reason: format!(
"no change request {:?} was opened on this host, so it has no description",
cr.id.0
),
})?;
Ok(Description {
title,
body: state.bodies.get(&cr.id).cloned().unwrap_or_default(),
})
}
fn review_comments(&self, change: &ChangeId, since: Option<&ReadMarker>) -> Result<ReviewRead> {
let read = review::read(&self.store, change, since);
let charge = read.as_ref().map_or(landings::graphql(1), |read| read.cost);
self.called("review_comments", &[change], charge)?;
read
}
fn reply_to_comment(
&self,
change: &ChangeId,
comment: &CommentId,
body: &str,
key: &str,
) -> Result<PostedReply> {
self.called("reply_to_comment", &[change], landings::rest(1))?;
review::reply(&self.store, change, comment, body, key)
}
fn mergeability(&self, cr: &ChangeRequest) -> Result<Mergeability> {
self.called("mergeability", &[&cr.id], landings::graphql(1))?;
Err(Error::NotImplemented {
operation: "RemoteHost::mergeability",
})
}
fn head_activity(&self, cr: &ChangeRequest) -> Result<HeadActivity> {
self.called("head_activity", &[&cr.id], landings::rest(2))?;
let state = self.store.snapshot()?;
let call = asked(&state, "head_activity", &cr.id);
let scripted = state.head_activity.get(&cr.id).and_then(|answers| {
answers
.get(call.saturating_sub(1))
.or_else(|| answers.last())
});
match scripted {
None => Ok(HeadActivity {
head: cr.head_sha.clone(),
in_progress: Vec::new(),
complete: true,
}),
Some(ActivityAnswer::Head {
in_progress,
complete,
}) => Ok(HeadActivity {
head: cr.head_sha.clone(),
in_progress: in_progress.clone(),
complete: *complete,
}),
Some(ActivityAnswer::Other { head, in_progress }) => Ok(HeadActivity {
head: head.clone(),
in_progress: in_progress.clone(),
complete: true,
}),
Some(ActivityAnswer::Refused) => Err(Error::Invalid {
reason: format!(
"this host was scripted to refuse what is running on the head of {}",
cr.url
),
}),
}
}
fn requires_up_to_date(&self, _base: &str) -> Result<bool> {
self.called("requires_up_to_date", &[], landings::rest(2))?;
Ok(self.store.snapshot()?.up_to_date_required)
}
fn allows_auto_merge(&self) -> Result<bool> {
self.called("allows_auto_merge", &[], landings::graphql(1))?;
Ok(self.store.snapshot()?.allow_auto_merge)
}
fn visibility(&self) -> Result<Visibility> {
self.called("visibility", &[], landings::rest(1))?;
Err(Error::NotImplemented {
operation: "RemoteHost::visibility",
})
}
fn change_standings(&self, root: &str, changes: &[ChangeId]) -> Result<ChangeStandings> {
let cost = landings::standings_charge(changes.len());
let named: Vec<&ChangeId> = changes.iter().collect();
self.called("change_standings", &named, cost)?;
let state = self.store.snapshot()?;
Ok(ChangeStandings {
standings: changes
.iter()
.map(|id| landings::standing(&state, root, id))
.collect::<Result<_>>()?,
cost,
})
}
fn review_standings(
&self,
root: &str,
changes: &[(ChangeId, Option<ReadMarker>)],
) -> Result<ReviewStandings> {
let cost = landings::review_charge(changes.len());
let named: Vec<&ChangeId> = changes.iter().map(|(id, _)| id).collect();
self.called("review_standings", &named, cost)?;
self.store.with(|state| {
let mut standings = Vec::with_capacity(changes.len());
let mut reads = Vec::with_capacity(changes.len());
for (id, since) in changes {
standings.push(landings::standing(state, root, id)?);
reads.push(review::read_in(state, id, since.as_ref(), false)?);
}
Ok(ReviewStandings {
standings,
reads,
cost,
})
})
}
fn retarget_change(
&self,
change: &ChangeId,
base: &str,
body: Option<&str>,
) -> Result<ReadCost> {
self.called("retarget_change", &[change], landings::rest(1))?;
addressable(base, "the base branch")?;
self.store.with(|state| {
let opened = state
.changes
.iter()
.find(|held| held.id == *change)
.cloned()
.ok_or_else(|| Error::Invalid {
reason: format!("no change request {:?} was opened on this host", change.0),
})?;
if matches!(state.merges.get(change), Some(MergeOutcome::Merged(_))) {
return Err(Error::Invalid {
reason: format!(
"Cannot change the base branch of a merged pull request: {}",
opened.url
),
});
}
if state.closed.contains(change) {
return Err(Error::Invalid {
reason: format!(
"Cannot change the base branch of a closed pull request: {}",
opened.url
),
});
}
if state.deleted_branches.contains(base) {
return Err(Error::Invalid {
reason: format!("Proposed base branch '{base}' was not found"),
});
}
landings::retarget_in(state, change, base);
if let Some(body) = body {
state.bodies.insert(change.clone(), body.to_owned());
}
Ok(landings::rest(1))
})
}
fn reopen_change(&self, change: &ChangeId) -> Result<ReadCost> {
self.called("reopen_change", &[change], landings::rest(1))?;
self.store.with(|state| {
let opened = state
.changes
.iter()
.find(|held| held.id == *change)
.cloned()
.ok_or_else(|| Error::Invalid {
reason: format!("no change request {:?} was opened on this host", change.0),
})?;
if !state.closed.contains(change) {
return Err(Error::Invalid {
reason: format!("{} is not closed, so its state cannot change", opened.url),
});
}
if state.deleted_branches.contains(&opened.base) {
return Err(Error::Invalid {
reason: format!(
"the base branch of {} was deleted, so it cannot be reopened",
opened.url
),
});
}
state.closed.remove(change);
Ok(landings::rest(1))
})
}
fn merge_time(&self, cr: &ChangeRequest) -> Result<Option<String>> {
self.called("merge_time", &[&cr.id], landings::graphql(1))?;
let state = self.store.snapshot()?;
Ok(match state.merges.get(&cr.id) {
Some(MergeOutcome::Merged(_)) => state.merge_times.get(&cr.id).cloned(),
_ => None,
})
}
fn merged_head(&self, cr: &ChangeRequest) -> Result<Option<MergedChange>> {
self.called("merged_head", &[&cr.id], landings::graphql(1))?;
self.store.with(|state| {
let merged = merged_now(state, cr);
let head = state
.changes
.iter()
.find(|held| held.id == cr.id)
.map_or_else(|| cr.head_sha.clone(), |held| held.head_sha.clone());
Ok(merged.map(|commit| MergedChange { commit, head }))
})
}
fn merged_at(&self, cr: &ChangeRequest) -> Result<Option<Sha>> {
self.called("merged_at", &[&cr.id], landings::graphql(1))?;
self.store.with(|state| Ok(merged_now(state, cr)))
}
}
fn merged_now(state: &mut HostState, cr: &ChangeRequest) -> Option<Sha> {
if matches!(state.merges.get(&cr.id), Some(MergeOutcome::Queued))
&& required_checks_green(state, &cr.id)
{
return Some(land(state, cr));
}
match state.merges.get(&cr.id) {
Some(MergeOutcome::Merged(sha)) => Some(sha.clone()),
_ => None,
}
}
fn land(state: &mut HostState, cr: &ChangeRequest) -> Sha {
let sha = Sha(events::stable_sha(&["merge", &cr.id.0, cr.url.as_str()]));
state
.merges
.insert(cr.id.clone(), MergeOutcome::Merged(sha.clone()));
state.merge_times.insert(cr.id.clone(), events::timestamp());
state.armed.remove(&cr.id);
sha
}
fn complete_sources() -> std::collections::BTreeSet<CheckSource> {
[CheckSource::StatusChecks].into_iter().collect()
}
fn required_checks_green(state: &HostState, id: &ChangeId) -> bool {
let checks = current_checks(state, id);
let required: std::collections::BTreeSet<&str> = checks
.iter()
.filter(|check| check.required)
.map(|check| check.name.as_str())
.chain(
state
.required_checks
.iter()
.flat_map(|declared| declared.checks.iter().map(String::as_str)),
)
.collect();
!required.is_empty()
&& required.iter().all(|name| {
let mut runs = checks.iter().filter(|check| check.name == *name).peekable();
runs.peek().is_some()
&& runs
.all(|check| matches!(check.state(), CheckState::Passed | CheckState::Skipped))
})
}
pub(crate) fn drafted(state: &HostState, id: &ChangeId) -> bool {
(state.drafts.contains_key(id) || state.awaiting_checks.contains(id))
&& !state.made_ready.contains(id)
}
pub(crate) fn current_checks(state: &HostState, id: &ChangeId) -> Vec<Check> {
let lifted = state
.ledger
.iter()
.rposition(|call| call.method == "ready_for_review" && call.changes.contains(id));
if let (Some(lifted), Some(readings)) = (lifted, state.check_readings_after_lift.get(id)) {
let read = state.ledger[lifted..]
.iter()
.filter(|call| call.method == "change_checks" && call.changes.contains(id))
.count();
if let Some(reading) = readings
.get(read.saturating_sub(1))
.or_else(|| readings.last())
{
return reading.clone();
}
}
if let Some(readings) = state.check_readings.get(id) {
let read = asked(state, "change_checks", id);
if let Some(reading) = readings
.get(read.saturating_sub(1))
.or_else(|| readings.last())
{
return reading.clone();
}
}
if state.made_ready.contains(id) {
if let Some(after) = state.checks_after_lift.get(id) {
return after.clone();
}
}
state.checks.get(id).cloned().unwrap_or_default()
}
fn asked(state: &HostState, method: &str, id: &ChangeId) -> usize {
state
.ledger
.iter()
.filter(|call| call.method == method && call.changes.contains(id))
.count()
}
fn artifact_id(change: &ChangeId, check: &str) -> String {
let safe: String = check
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'-'
}
})
.collect();
let number: String = change
.0
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect();
format!("a-testing-{number}-{safe}")
}
fn addressable(value: &str, what: &str) -> Result<()> {
if value.is_empty() || value.starts_with('-') || value.contains(char::is_whitespace) {
return Err(Error::Invalid {
reason: format!(
"{what} {value:?} cannot address anything on the host: it must be non-empty, \
must not begin with '-', and must carry no whitespace"
),
});
}
Ok(())
}
fn named_repository(slug: &str) -> Result<String> {
let mut parts = slug.split('/');
let named = matches!(
(parts.next(), parts.next(), parts.next()),
(Some(owner), Some(name), None)
if !owner.is_empty()
&& !name.is_empty()
&& !slug.starts_with('-')
&& !slug.contains(char::is_whitespace)
);
if !named {
return Err(Error::Invalid {
reason: format!("{slug:?} does not name one repository as owner/name"),
});
}
Ok(slug.to_owned())
}