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, ChangeChecks, ChangeId, ChangeRequest, ChangeSpec, Check, CheckSource, CheckState,
16    Description, Error, Hosting, MergeOutcome, MergePolicy, RemoteHost, RequiredChecks, Result,
17    Sha,
18};
19
20use crate::events;
21use crate::state::{Described, HostState};
22use crate::store::{FileStore, MemoryStore, Store};
23
24/// The host a change request's URL names, matching the one implementation the
25/// crate next door speaks for.
26pub const DEFAULT_HOST: &str = "github.com";
27
28/// The repository a host answers for when it was not addressed at one — which is
29/// the case only when a journey holds it directly rather than through
30/// [`Hosting::for_repo`].
31pub const DEFAULT_SLUG: &str = "onevcs/testing";
32
33/// The remote-host side of a run, over whichever store holds its state.
34///
35/// It is both interfaces at once: a [`RemoteHost`] a journey can call directly, and
36/// the [`Hosting`] factory a run is handed. A host taken from the factory shares
37/// this one's state, so what a publication did is read back through the value the
38/// journey created.
39#[derive(Debug)]
40pub struct Host<T> {
41    store: T,
42    // A slug arrives one way only — `Hosting::for_repo`, which takes the `&str` the
43    // contract fixes — and `named_repository` refuses it there; every other
44    // construction here is `DEFAULT_SLUG`. A newtype would have to be public to be
45    // the parameter's type, and the seam is specified without one, which is the same
46    // reason recorded on `Hosting::for_repo` in the crate next door.
47    // llmlint: ignore[invalid_states_unrepresentable] see the note directly above.
48    slug: String,
49}
50
51/// A host provider that keeps its state in this process.
52pub type MemoryHost = Host<MemoryStore<HostState>>;
53
54/// A host provider that keeps its state in one JSON document, so several `onevcs`
55/// invocations see one another's change requests.
56pub type FileHost = Host<FileStore<HostState>>;
57
58impl MemoryHost {
59    /// A host with nothing opened against it.
60    pub fn new() -> Self {
61        Self::seeded(HostState::default())
62    }
63
64    /// A host that starts from a scenario.
65    pub fn seeded(state: HostState) -> Self {
66        Self {
67            store: MemoryStore::new(state),
68            slug: DEFAULT_SLUG.to_owned(),
69        }
70    }
71
72    /// Everything it knows.
73    pub fn state(&self) -> HostState {
74        self.store
75            .snapshot()
76            .expect("an in-memory store always answers")
77    }
78}
79
80impl Default for MemoryHost {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86impl FileHost {
87    /// A host keeping its state at `path`: whatever is already there, or nothing
88    /// opened against it.
89    ///
90    /// Attaching rather than replacing, so a second host over the same path answers
91    /// about the change requests the first one opened.
92    pub fn create(path: impl Into<PathBuf>) -> Result<Self> {
93        Ok(Self {
94            store: FileStore::attach(path, &HostState::default())?,
95            slug: DEFAULT_SLUG.to_owned(),
96        })
97    }
98
99    /// A host that starts from a scenario, keeping its state at `path` and
100    /// replacing whatever was there.
101    pub fn seeded(path: impl Into<PathBuf>, state: HostState) -> Result<Self> {
102        Ok(Self {
103            store: FileStore::replace(path, &state)?,
104            slug: DEFAULT_SLUG.to_owned(),
105        })
106    }
107
108    /// Everything it knows, read back out of its document.
109    pub fn state(&self) -> Result<HostState> {
110        self.store.snapshot()
111    }
112}
113
114impl<T: Store<HostState> + Clone + std::fmt::Debug + Send + Sync + 'static> Hosting for Host<T> {
115    fn for_repo(&self, slug: &str) -> Result<Box<dyn RemoteHost>> {
116        Ok(Box::new(Host {
117            store: self.store.clone(),
118            slug: named_repository(slug)?,
119        }))
120    }
121}
122
123impl<T: Store<HostState>> RemoteHost for Host<T> {
124    fn authenticated_user(&self) -> Result<String> {
125        let login = self.store.snapshot()?.authenticated_user;
126        if login.trim().is_empty() {
127            return Err(Error::Invalid {
128                reason: "the host reported no authenticated user".to_owned(),
129            });
130        }
131        Ok(login)
132    }
133
134    fn open_change(&self, req: ChangeSpec) -> Result<ChangeRequest> {
135        addressable(&req.head, "the head branch")?;
136        addressable(&req.base, "the base branch")?;
137        // The same refusal the real host makes: a change request whose title names
138        // nothing is one it will not open.
139        crate::state::titled(&req.title)?;
140        // The publication rule the crate next door states, applied rather than
141        // restated — and applied *here* rather than only where a publication takes a
142        // request in, because this trait is reachable directly: a journey that holds a
143        // host and opens a change on it hands this the reason, and a reason nothing
144        // could render is one the state must not go on to answer from.
145        if let Some(reason) = &req.draft {
146            reason.checked()?;
147        }
148        let slug = self.slug.clone();
149        self.store.with(|state| {
150            // The host numbers its change requests, consecutively from one, so a
151            // journey can seed the checks of a change it has not opened yet.
152            let id = ChangeId((state.changes.len() + 1).to_string());
153            let url = format!("https://{DEFAULT_HOST}/{slug}/pull/{}", id.0);
154            let change = ChangeRequest {
155                head_sha: Sha(events::stable_sha(&[&slug, &req.head, &id.0])),
156                url: Url::parse(&url).map_err(|e| Error::Invalid {
157                    reason: format!("{url:?} is not a URL: {e}"),
158                })?,
159                base: req.base.clone(),
160                id: id.clone(),
161            };
162            state.heads.insert(id.clone(), req.head.clone());
163            state.titles.insert(id.clone(), req.title.clone());
164            // Only when there is one: a change request opened with no body records
165            // none, so a journey can tell "nobody drafted one" from "the body is
166            // empty" — which is the distinction the real host draws too.
167            if let Some(body) = req.body.clone() {
168                state.bodies.insert(id.clone(), body);
169            }
170            // Beside the body and for its reason: a change request opened as a draft
171            // records the reason it was drafted with, and one opened without a reason
172            // records nothing — which is how a journey tells a draft from an ordinary
173            // change request rather than from a flag nobody kept.
174            if let Some(reason) = req.draft.clone() {
175                state.drafts.insert(id, reason);
176            } else if req.draft_awaiting_checks {
177                // The lifecycle's own draft, which carries no reason: recorded apart
178                // from the reasons a caller gave, so a journey can tell the two apart.
179                state.awaiting_checks.insert(id);
180            }
181            state.changes.push(change.clone());
182            Ok(change)
183        })
184    }
185
186    fn find_changes(&self, head: &str, base: &str) -> Result<Vec<ChangeRequest>> {
187        addressable(head, "the head branch")?;
188        addressable(base, "the base branch")?;
189        let state = self.store.snapshot()?;
190        Ok(state
191            .changes
192            .iter()
193            .filter(|change| {
194                change.base == base
195                    && state.heads.get(&change.id).is_some_and(|from| from == head)
196                    // Only the open ones: a change the host has already merged is
197                    // not one to adopt.
198                    && !matches!(state.merges.get(&change.id), Some(MergeOutcome::Merged(_)))
199            })
200            .cloned()
201            .collect())
202    }
203
204    fn change_checks(&self, cr: &ChangeRequest) -> Result<ChangeChecks> {
205        let state = self.store.snapshot()?;
206        let sources = state.check_sources.clone().unwrap_or_else(complete_sources);
207        // The same refusal the real implementation makes when its credential can
208        // read neither the host's check rollup nor its Actions API: what the checks
209        // say is unknown, and answering "none" for "could not look" is what lets a
210        // merge through unguarded.
211        if sources.is_empty() {
212            return Err(Error::Invalid {
213                reason: format!(
214                    "this host was seeded with no check source, so what the checks on {} say \
215                     cannot be read rather than being empty",
216                    cr.url
217                ),
218            });
219        }
220        Ok(ChangeChecks {
221            checks: current_checks(&state, &cr.id),
222            sources,
223        })
224    }
225
226    fn check_log(&self, cr: &ChangeRequest, check: &Check) -> Result<ArtifactId> {
227        let log = self
228            .store
229            .snapshot()?
230            .check_logs
231            .get(&cr.id)
232            .and_then(|logs| logs.get(&check.name))
233            .cloned()
234            .unwrap_or_else(|| format!("the host log for check {}\n", check.name));
235        events::store_artifact(&artifact_id(&cr.id, &check.name), &log)
236    }
237
238    fn merge(&self, cr: &ChangeRequest, policy: MergePolicy) -> Result<MergeOutcome> {
239        self.store.with(|state| {
240            // What GitHub answers: a draft can be neither merged nor armed to merge
241            // itself (`Pull Request is still a draft`), so a publication that asked
242            // before lifting it fails here rather than landing work nobody lifted.
243            if matches!(policy, MergePolicy::ChangeAuto | MergePolicy::ChangeDirect)
244                && drafted(state, &cr.id)
245            {
246                return Err(Error::Invalid {
247                    reason: format!(
248                        "{} is still a draft, and a draft can be neither merged nor armed to \
249                         merge itself",
250                        cr.url
251                    ),
252                });
253            }
254            // A seeded outcome is the host's decision and outranks the policy: it is
255            // how a journey says "this one is queued behind something" or "this one
256            // has already landed".
257            if let Some(decided) = state.merges.get(&cr.id) {
258                return Ok(decided.clone());
259            }
260            let landed = |state: &mut HostState| MergeOutcome::Merged(land(state, cr));
261            Ok(match policy {
262                // Nothing is asked of the host, so nothing is recorded — the same
263                // answer the real implementation gives without a call.
264                MergePolicy::LocalDirect | MergePolicy::ChangeOpen => MergeOutcome::Open,
265                MergePolicy::ChangeAuto => {
266                    if required_checks_green(state, &cr.id) {
267                        landed(state)
268                    } else {
269                        // Native auto-merge: the host holds it and lands it when its
270                        // own required checks pass, so nothing merges now.
271                        state.merges.insert(cr.id.clone(), MergeOutcome::Queued);
272                        MergeOutcome::Queued
273                    }
274                }
275                MergePolicy::ChangeDirect => landed(state),
276            })
277        })
278    }
279
280    /// Take the change request out of its draft state.
281    ///
282    /// The call itself is what is recorded, rather than a flag that would read the
283    /// same whether it was made once or twice: what a journey asks of this is whether
284    /// a second publication over an already-lifted change asked the host for
285    /// anything, and only the calls can answer that. A change already open for review
286    /// is already what this asks for, so it succeeds — the caller wanted it ready,
287    /// and it is.
288    fn ready_for_review(&self, cr: &ChangeRequest) -> Result<()> {
289        self.store.with(|state| {
290            state.made_ready.push(cr.id.clone());
291            Ok(())
292        })
293    }
294
295    // llmlint: ignore[invalid_states_unrepresentable] the signature is the trait's, which
296    // the approved amendment in `docs/contract.md` declares verbatim as `-> Result<bool>`;
297    // an implementation cannot narrow it, and a named domain type would be a public item
298    // the contract does not name. The third state a caller must not read as "not a draft"
299    // — "this host would not say" — is the `Err` arm rather than a value inside it, and the
300    // reason is recorded on the declaration in the crate next door.
301    fn is_draft(&self, cr: &ChangeRequest) -> Result<bool> {
302        Ok(drafted(&self.store.snapshot()?, &cr.id))
303    }
304
305    /// What the repository requires before a merge into `base`: the seeded answer,
306    /// or — unset — exactly the checks seeded as required, completely.
307    fn required_checks_on(&self, _base: &str) -> Result<RequiredChecks> {
308        let state = self.store.snapshot()?;
309        if let Some(seeded) = state.required_checks {
310            return Ok(seeded);
311        }
312        Ok(RequiredChecks {
313            checks: state
314                .checks
315                .values()
316                .chain(state.checks_after_lift.values())
317                .flatten()
318                .filter(|check| check.required)
319                .map(|check| check.name.clone())
320                .collect(),
321            unconsulted: std::collections::BTreeMap::new(),
322        })
323    }
324
325    /// Replace the description, recording the call.
326    ///
327    /// The title and body go where `open_change` put the ones it was given, so what
328    /// `change_description` reads back is the same record whichever call wrote it —
329    /// and the call itself is kept, for the reason `ready_for_review`'s is: a journey
330    /// about a closeout asks what was written and in what order, and the current
331    /// description cannot say. A title that names nothing is refused as the real host
332    /// refuses one; a body is prose, and an empty one is a description a caller may
333    /// mean.
334    fn describe_change(&self, cr: &ChangeRequest, title: Option<&str>, body: &str) -> Result<()> {
335        if let Some(title) = title {
336            crate::state::titled(title)?;
337        }
338        self.store.with(|state| {
339            if !state.changes.iter().any(|change| change.id == cr.id) {
340                return Err(Error::Invalid {
341                    reason: format!(
342                        "no change request {:?} was opened on this host, so there is nothing to \
343                         describe",
344                        cr.id.0
345                    ),
346                });
347            }
348            if let Some(title) = title {
349                state.titles.insert(cr.id.clone(), title.to_owned());
350            }
351            state.bodies.insert(cr.id.clone(), body.to_owned());
352            state.described.push(Described {
353                id: cr.id.clone(),
354                title: title.map(str::to_owned),
355                body: body.to_owned(),
356            });
357            Ok(())
358        })
359    }
360
361    /// The description as this host holds it: the title it was opened or last
362    /// described with, and the body — empty where it was opened with none and never
363    /// described since, which is what the real host answers for one.
364    fn change_description(&self, cr: &ChangeRequest) -> Result<Description> {
365        let state = self.store.snapshot()?;
366        let title = state
367            .titles
368            .get(&cr.id)
369            .cloned()
370            .ok_or_else(|| Error::Invalid {
371                reason: format!(
372                    "no change request {:?} was opened on this host, so it has no description",
373                    cr.id.0
374                ),
375            })?;
376        Ok(Description {
377            title,
378            body: state.bodies.get(&cr.id).cloned().unwrap_or_default(),
379        })
380    }
381
382    fn merged_at(&self, cr: &ChangeRequest) -> Result<Option<Sha>> {
383        self.store.with(|state| {
384            // Native auto-merge, modelled rather than skipped: a change this host is
385            // holding lands the moment its own required checks are green, on the
386            // host's clock and not on the caller's. Asking is what makes that
387            // observable — which is exactly the shape a publication watching the
388            // real GitHub meets, and the reason a journey can drive a change request
389            // from queued to merged without a second process.
390            if matches!(state.merges.get(&cr.id), Some(MergeOutcome::Queued))
391                && required_checks_green(state, &cr.id)
392            {
393                return Ok(Some(land(state, cr)));
394            }
395            Ok(match state.merges.get(&cr.id) {
396                Some(MergeOutcome::Merged(sha)) => Some(sha.clone()),
397                _ => None,
398            })
399        })
400    }
401}
402
403/// Record this change request as merged, and answer with the commit it merged at.
404///
405/// One place, so the merge a policy performs and the merge the host completes on
406/// its own report the same commit for the same change.
407fn land(state: &mut HostState, cr: &ChangeRequest) -> Sha {
408    let sha = Sha(events::stable_sha(&["merge", &cr.id.0, cr.url.as_str()]));
409    state
410        .merges
411        .insert(cr.id.clone(), MergeOutcome::Merged(sha.clone()));
412    sha
413}
414
415/// What a host that was not told otherwise answers about where its checks came
416/// from: the host's own rollup, which is every check anything posted on the change
417/// request — the answer a credential allowed to read check runs gets.
418fn complete_sources() -> std::collections::BTreeSet<CheckSource> {
419    [CheckSource::StatusChecks].into_iter().collect()
420}
421
422/// Whether every required check on a change request lets the host merge it.
423///
424/// A change with no required checks is not green: nothing has vouched for it, which
425/// is the state auto-merge waits in rather than lands from. A skipped required check
426/// lets the merge through, as it does on GitHub's own merge path — which is why that
427/// is a state of its own rather than a pass.
428fn required_checks_green(state: &HostState, id: &ChangeId) -> bool {
429    let checks = current_checks(state, id);
430    let required: Vec<&Check> = checks.iter().filter(|check| check.required).collect();
431    !required.is_empty()
432        && required
433            .iter()
434            .all(|check| matches!(check.state(), CheckState::Passed | CheckState::Skipped))
435}
436
437/// Whether this host holds a change request as a draft: opened as one, for a reason
438/// or while its checks run, and not lifted since.
439fn drafted(state: &HostState, id: &ChangeId) -> bool {
440    (state.drafts.contains_key(id) || state.awaiting_checks.contains(id))
441        && !state.made_ready.contains(id)
442}
443
444/// The checks the host reports on a change request right now: the ones registered
445/// after a lift, once it has been lifted and some are seeded, otherwise its own.
446fn current_checks(state: &HostState, id: &ChangeId) -> Vec<Check> {
447    if state.made_ready.contains(id) {
448        if let Some(after) = state.checks_after_lift.get(id) {
449            return after.clone();
450        }
451    }
452    state.checks.get(id).cloned().unwrap_or_default()
453}
454
455/// The id one check's log is stored under.
456///
457/// Derived from what it is a log *of* rather than minted, so fetching the same
458/// log twice does not leave two artifacts, and a journey can name the id it is
459/// about to assert on.
460fn artifact_id(change: &ChangeId, check: &str) -> String {
461    let safe: String = check
462        .chars()
463        .map(|c| {
464            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
465                c
466            } else {
467                '-'
468            }
469        })
470        .collect();
471    let number: String = change
472        .0
473        .chars()
474        .filter(|c| c.is_ascii_alphanumeric())
475        .collect();
476    format!("a-testing-{number}-{safe}")
477}
478
479/// One value bound for the host's argument vector, checked before it gets there.
480///
481/// The same refusal the real implementation makes, and for the same reason: a name
482/// shaped like an option or an absent value addresses something other than what it
483/// names, and a provider that accepted one would let a journey pass where the real
484/// host rejects.
485fn addressable(value: &str, what: &str) -> Result<()> {
486    if value.is_empty() || value.starts_with('-') || value.contains(char::is_whitespace) {
487        return Err(Error::Invalid {
488            reason: format!(
489                "{what} {value:?} cannot address anything on the host: it must be non-empty, \
490                 must not begin with '-', and must carry no whitespace"
491            ),
492        });
493    }
494    Ok(())
495}
496
497/// A slug that names one repository, as `owner/name`.
498fn named_repository(slug: &str) -> Result<String> {
499    let mut parts = slug.split('/');
500    let named = matches!(
501        (parts.next(), parts.next(), parts.next()),
502        (Some(owner), Some(name), None)
503            if !owner.is_empty()
504                && !name.is_empty()
505                && !slug.starts_with('-')
506                && !slug.contains(char::is_whitespace)
507    );
508    if !named {
509        return Err(Error::Invalid {
510            reason: format!("{slug:?} does not name one repository as owner/name"),
511        });
512    }
513    Ok(slug.to_owned())
514}