Skip to main content

onevcs_testing/
remote.rs

1//! The remote-host side: one implementation of [`RemoteHost`] and [`Hosting`] over
2//! either store.
3//!
4//! What GitHub decides, this decides from what it was seeded with: which change
5//! requests exist, what their checks say, and whether a merge lands. What it does
6//! *not* do is move a commit — a merge here records an outcome and nothing reaches
7//! any origin, which is exactly the boundary the real implementation delegates to
8//! the host and the reason a journey about git drives real git.
9
10use std::path::PathBuf;
11
12use url::Url;
13
14use onevcs::{
15    ArtifactId, ChangeId, ChangeRequest, ChangeSpec, Check, Error, Hosting, MergeOutcome,
16    MergePolicy, RemoteHost, Result, Sha,
17};
18
19use crate::events;
20use crate::state::HostState;
21use crate::store::{FileStore, MemoryStore, Store};
22
23/// The host a change request's URL names, matching the one implementation the
24/// crate next door speaks for.
25pub const DEFAULT_HOST: &str = "github.com";
26
27/// The repository a host answers for when it was not addressed at one — which is
28/// the case only when a journey holds it directly rather than through
29/// [`Hosting::for_repo`].
30pub const DEFAULT_SLUG: &str = "onevcs/testing";
31
32/// The remote-host side of a run, over whichever store holds its state.
33///
34/// It is both interfaces at once: a [`RemoteHost`] a journey can call directly, and
35/// the [`Hosting`] factory a run is handed. A host taken from the factory shares
36/// this one's state, so what a publication did is read back through the value the
37/// journey created.
38#[derive(Debug)]
39pub struct Host<T> {
40    store: T,
41    // A slug arrives one way only — `Hosting::for_repo`, which takes the `&str` the
42    // contract fixes — and `named_repository` refuses it there; every other
43    // construction here is `DEFAULT_SLUG`. A newtype would have to be public to be
44    // the parameter's type, and the seam is specified without one, which is the same
45    // reason recorded on `Hosting::for_repo` in the crate next door.
46    // llmlint: ignore[invalid_states_unrepresentable] see the note directly above.
47    slug: String,
48}
49
50/// A host provider that keeps its state in this process.
51pub type MemoryHost = Host<MemoryStore<HostState>>;
52
53/// A host provider that keeps its state in one JSON document, so several `onevcs`
54/// invocations see one another's change requests.
55pub type FileHost = Host<FileStore<HostState>>;
56
57impl MemoryHost {
58    /// A host with nothing opened against it.
59    pub fn new() -> Self {
60        Self::seeded(HostState::default())
61    }
62
63    /// A host that starts from a scenario.
64    pub fn seeded(state: HostState) -> Self {
65        Self {
66            store: MemoryStore::new(state),
67            slug: DEFAULT_SLUG.to_owned(),
68        }
69    }
70
71    /// Everything it knows.
72    pub fn state(&self) -> HostState {
73        self.store
74            .snapshot()
75            .expect("an in-memory store always answers")
76    }
77}
78
79impl Default for MemoryHost {
80    fn default() -> Self {
81        Self::new()
82    }
83}
84
85impl FileHost {
86    /// A host keeping its state at `path`: whatever is already there, or nothing
87    /// opened against it.
88    ///
89    /// Attaching rather than replacing, so a second host over the same path answers
90    /// about the change requests the first one opened.
91    pub fn create(path: impl Into<PathBuf>) -> Result<Self> {
92        Ok(Self {
93            store: FileStore::attach(path, &HostState::default())?,
94            slug: DEFAULT_SLUG.to_owned(),
95        })
96    }
97
98    /// A host that starts from a scenario, keeping its state at `path` and
99    /// replacing whatever was there.
100    pub fn seeded(path: impl Into<PathBuf>, state: HostState) -> Result<Self> {
101        Ok(Self {
102            store: FileStore::replace(path, &state)?,
103            slug: DEFAULT_SLUG.to_owned(),
104        })
105    }
106
107    /// Everything it knows, read back out of its document.
108    pub fn state(&self) -> Result<HostState> {
109        self.store.snapshot()
110    }
111}
112
113impl<T: Store<HostState> + Clone + std::fmt::Debug + Send + Sync + 'static> Hosting for Host<T> {
114    fn for_repo(&self, slug: &str) -> Result<Box<dyn RemoteHost>> {
115        Ok(Box::new(Host {
116            store: self.store.clone(),
117            slug: named_repository(slug)?,
118        }))
119    }
120}
121
122impl<T: Store<HostState>> RemoteHost for Host<T> {
123    fn authenticated_user(&self) -> Result<String> {
124        let login = self.store.snapshot()?.authenticated_user;
125        if login.trim().is_empty() {
126            return Err(Error::Invalid {
127                reason: "the host reported no authenticated user".to_owned(),
128            });
129        }
130        Ok(login)
131    }
132
133    fn open_change(&self, req: ChangeSpec) -> Result<ChangeRequest> {
134        addressable(&req.head, "the head branch")?;
135        addressable(&req.base, "the base branch")?;
136        let slug = self.slug.clone();
137        self.store.with(|state| {
138            // The host numbers its change requests, consecutively from one, so a
139            // journey can seed the checks of a change it has not opened yet.
140            let id = ChangeId((state.changes.len() + 1).to_string());
141            let url = format!("https://{DEFAULT_HOST}/{slug}/pull/{}", id.0);
142            let change = ChangeRequest {
143                head_sha: Sha(events::stable_sha(&[&slug, &req.head, &id.0])),
144                url: Url::parse(&url).map_err(|e| Error::Invalid {
145                    reason: format!("{url:?} is not a URL: {e}"),
146                })?,
147                base: req.base.clone(),
148                id: id.clone(),
149            };
150            state.heads.insert(id, req.head.clone());
151            state.changes.push(change.clone());
152            Ok(change)
153        })
154    }
155
156    fn find_changes(&self, head: &str, base: &str) -> Result<Vec<ChangeRequest>> {
157        addressable(head, "the head branch")?;
158        addressable(base, "the base branch")?;
159        let state = self.store.snapshot()?;
160        Ok(state
161            .changes
162            .iter()
163            .filter(|change| {
164                change.base == base
165                    && state.heads.get(&change.id).is_some_and(|from| from == head)
166                    // Only the open ones: a change the host has already merged is
167                    // not one to adopt.
168                    && !matches!(state.merges.get(&change.id), Some(MergeOutcome::Merged(_)))
169            })
170            .cloned()
171            .collect())
172    }
173
174    fn change_checks(&self, cr: &ChangeRequest) -> Result<Vec<Check>> {
175        Ok(self
176            .store
177            .snapshot()?
178            .checks
179            .get(&cr.id)
180            .cloned()
181            .unwrap_or_default())
182    }
183
184    fn check_log(&self, cr: &ChangeRequest, check: &Check) -> Result<ArtifactId> {
185        let log = self
186            .store
187            .snapshot()?
188            .check_logs
189            .get(&cr.id)
190            .and_then(|logs| logs.get(&check.name))
191            .cloned()
192            .unwrap_or_else(|| format!("the host log for check {}\n", check.name));
193        events::store_artifact(&artifact_id(&cr.id, &check.name), &log)
194    }
195
196    fn merge(&self, cr: &ChangeRequest, policy: MergePolicy) -> Result<MergeOutcome> {
197        self.store.with(|state| {
198            // A seeded outcome is the host's decision and outranks the policy: it is
199            // how a journey says "this one is queued behind something" or "this one
200            // has already landed".
201            if let Some(decided) = state.merges.get(&cr.id) {
202                return Ok(decided.clone());
203            }
204            let landed = |state: &mut HostState| {
205                let sha = Sha(events::stable_sha(&["merge", &cr.id.0, cr.url.as_str()]));
206                state
207                    .merges
208                    .insert(cr.id.clone(), MergeOutcome::Merged(sha.clone()));
209                MergeOutcome::Merged(sha)
210            };
211            Ok(match policy {
212                // Nothing is asked of the host, so nothing is recorded — the same
213                // answer the real implementation gives without a call.
214                MergePolicy::LocalDirect | MergePolicy::ChangeOpen => MergeOutcome::Open,
215                MergePolicy::ChangeAuto => {
216                    if required_checks_green(state, &cr.id) {
217                        landed(state)
218                    } else {
219                        // Native auto-merge: the host holds it and lands it when its
220                        // own required checks pass, so nothing merges now.
221                        state.merges.insert(cr.id.clone(), MergeOutcome::Queued);
222                        MergeOutcome::Queued
223                    }
224                }
225                MergePolicy::ChangeDirect => landed(state),
226            })
227        })
228    }
229}
230
231/// Whether every required check on a change request has settled green.
232///
233/// A change with no required checks is not green: nothing has vouched for it, which
234/// is the state auto-merge waits in rather than lands from.
235fn required_checks_green(state: &HostState, id: &ChangeId) -> bool {
236    let checks = match state.checks.get(id) {
237        Some(checks) => checks,
238        None => return false,
239    };
240    let required: Vec<&Check> = checks.iter().filter(|check| check.required).collect();
241    !required.is_empty() && required.iter().all(|check| check.green())
242}
243
244/// The id one check's log is stored under.
245///
246/// Derived from what it is a log *of* rather than minted, so fetching the same
247/// log twice does not leave two artifacts, and a journey can name the id it is
248/// about to assert on.
249fn artifact_id(change: &ChangeId, check: &str) -> String {
250    let safe: String = check
251        .chars()
252        .map(|c| {
253            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
254                c
255            } else {
256                '-'
257            }
258        })
259        .collect();
260    let number: String = change
261        .0
262        .chars()
263        .filter(|c| c.is_ascii_alphanumeric())
264        .collect();
265    format!("a-testing-{number}-{safe}")
266}
267
268/// One value bound for the host's argument vector, checked before it gets there.
269///
270/// The same refusal the real implementation makes, and for the same reason: a name
271/// shaped like an option or an absent value addresses something other than what it
272/// names, and a provider that accepted one would let a journey pass where the real
273/// host rejects.
274fn addressable(value: &str, what: &str) -> Result<()> {
275    if value.is_empty() || value.starts_with('-') || value.contains(char::is_whitespace) {
276        return Err(Error::Invalid {
277            reason: format!(
278                "{what} {value:?} cannot address anything on the host: it must be non-empty, \
279                 must not begin with '-', and must carry no whitespace"
280            ),
281        });
282    }
283    Ok(())
284}
285
286/// A slug that names one repository, as `owner/name`.
287fn named_repository(slug: &str) -> Result<String> {
288    let mut parts = slug.split('/');
289    let named = matches!(
290        (parts.next(), parts.next(), parts.next()),
291        (Some(owner), Some(name), None)
292            if !owner.is_empty()
293                && !name.is_empty()
294                && !slug.starts_with('-')
295                && !slug.contains(char::is_whitespace)
296    );
297    if !named {
298        return Err(Error::Invalid {
299            reason: format!("{slug:?} does not name one repository as owner/name"),
300        });
301    }
302    Ok(slug.to_owned())
303}