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    CommentId, Description, Error, Hosting, MergeOutcome, MergePolicy, PostedReply, ReadMarker,
17    RemoteHost, RequiredChecks, Result, ReviewRead, Sha,
18};
19
20use crate::state::{Described, HostComment, HostState};
21use crate::store::{FileStore, MemoryStore, Store};
22use crate::{events, review};
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
114/// A reviewer's moves on a change request's feedback, which no interface method makes:
115/// the real host takes them from people, so a journey takes them here.
116impl<T: Store<HostState>> Host<T> {
117    /// Post one more comment on `change`, as a reviewer would — a review-thread
118    /// comment, a review's summary or a conversation comment, whichever `comment`'s
119    /// kind says. Its `revision` is this host's to set, and is set past every read so
120    /// far.
121    pub fn add_comment(&self, change: &ChangeId, comment: HostComment) -> Result<()> {
122        review::add(&self.store, change, comment)
123    }
124
125    /// Replace a comment's body, as its author editing it would.
126    pub fn edit_comment(&self, change: &ChangeId, comment: &CommentId, body: &str) -> Result<()> {
127        review::edit(&self.store, change, comment, body)
128    }
129
130    /// Set a review thread's two flags, as resolving it, unresolving it, or a push
131    /// moving past its line would. Every comment in the thread changes with it.
132    pub fn set_thread(
133        &self,
134        change: &ChangeId,
135        thread: &str,
136        resolved: bool,
137        outdated: bool,
138    ) -> Result<()> {
139        review::set_thread(&self.store, change, thread, resolved, outdated)
140    }
141}
142
143impl<T: Store<HostState> + Clone + std::fmt::Debug + Send + Sync + 'static> Hosting for Host<T> {
144    fn for_repo(&self, slug: &str) -> Result<Box<dyn RemoteHost>> {
145        Ok(Box::new(Host {
146            store: self.store.clone(),
147            slug: named_repository(slug)?,
148        }))
149    }
150}
151
152impl<T: Store<HostState>> RemoteHost for Host<T> {
153    fn authenticated_user(&self) -> Result<String> {
154        let login = self.store.snapshot()?.authenticated_user;
155        if login.trim().is_empty() {
156            return Err(Error::Invalid {
157                reason: "the host reported no authenticated user".to_owned(),
158            });
159        }
160        Ok(login)
161    }
162
163    fn open_change(&self, req: ChangeSpec) -> Result<ChangeRequest> {
164        addressable(&req.head, "the head branch")?;
165        addressable(&req.base, "the base branch")?;
166        // The same refusal the real host makes: a change request whose title names
167        // nothing is one it will not open.
168        crate::state::titled(&req.title)?;
169        // The publication rule the crate next door states, applied rather than
170        // restated — and applied *here* rather than only where a publication takes a
171        // request in, because this trait is reachable directly: a journey that holds a
172        // host and opens a change on it hands this the reason, and a reason nothing
173        // could render is one the state must not go on to answer from.
174        if let Some(reason) = &req.draft {
175            reason.checked()?;
176        }
177        let slug = self.slug.clone();
178        self.store.with(|state| {
179            // The host numbers its change requests, consecutively from one, so a
180            // journey can seed the checks of a change it has not opened yet.
181            let id = ChangeId((state.changes.len() + 1).to_string());
182            let url = format!("https://{DEFAULT_HOST}/{slug}/pull/{}", id.0);
183            let change = ChangeRequest {
184                head_sha: Sha(events::stable_sha(&[&slug, &req.head, &id.0])),
185                url: Url::parse(&url).map_err(|e| Error::Invalid {
186                    reason: format!("{url:?} is not a URL: {e}"),
187                })?,
188                base: req.base.clone(),
189                id: id.clone(),
190            };
191            state.heads.insert(id.clone(), req.head.clone());
192            state.titles.insert(id.clone(), req.title.clone());
193            // Only when there is one: a change request opened with no body records
194            // none, so a journey can tell "nobody drafted one" from "the body is
195            // empty" — which is the distinction the real host draws too.
196            if let Some(body) = req.body.clone() {
197                state.bodies.insert(id.clone(), body);
198            }
199            // Beside the body and for its reason: a change request opened as a draft
200            // records the reason it was drafted with, and one opened without a reason
201            // records nothing — which is how a journey tells a draft from an ordinary
202            // change request rather than from a flag nobody kept.
203            if let Some(reason) = req.draft.clone() {
204                state.drafts.insert(id, reason);
205            } else if req.draft_awaiting_checks {
206                // The lifecycle's own draft, which carries no reason: recorded apart
207                // from the reasons a caller gave, so a journey can tell the two apart.
208                state.awaiting_checks.insert(id);
209            }
210            state.changes.push(change.clone());
211            Ok(change)
212        })
213    }
214
215    fn find_changes(&self, head: &str, base: &str) -> Result<Vec<ChangeRequest>> {
216        addressable(head, "the head branch")?;
217        addressable(base, "the base branch")?;
218        let state = self.store.snapshot()?;
219        Ok(state
220            .changes
221            .iter()
222            .filter(|change| {
223                change.base == base
224                    && state.heads.get(&change.id).is_some_and(|from| from == head)
225                    // Only the open ones: a change the host has already merged is
226                    // not one to adopt.
227                    && !matches!(state.merges.get(&change.id), Some(MergeOutcome::Merged(_)))
228            })
229            .cloned()
230            .collect())
231    }
232
233    fn change_checks(&self, cr: &ChangeRequest) -> Result<ChangeChecks> {
234        let state = self.store.snapshot()?;
235        let sources = state.check_sources.clone().unwrap_or_else(complete_sources);
236        // The same refusal the real implementation makes when its credential can
237        // read neither the host's check rollup nor its Actions API: what the checks
238        // say is unknown, and answering "none" for "could not look" is what lets a
239        // merge through unguarded.
240        if sources.is_empty() {
241            return Err(Error::Invalid {
242                reason: format!(
243                    "this host was seeded with no check source, so what the checks on {} say \
244                     cannot be read rather than being empty",
245                    cr.url
246                ),
247            });
248        }
249        Ok(ChangeChecks {
250            checks: current_checks(&state, &cr.id),
251            sources,
252        })
253    }
254
255    fn check_log(&self, cr: &ChangeRequest, check: &Check) -> Result<ArtifactId> {
256        let log = self
257            .store
258            .snapshot()?
259            .check_logs
260            .get(&cr.id)
261            .and_then(|logs| logs.get(&check.name))
262            .cloned()
263            .unwrap_or_else(|| format!("the host log for check {}\n", check.name));
264        events::store_artifact(&artifact_id(&cr.id, &check.name), &log)
265    }
266
267    fn merge(&self, cr: &ChangeRequest, policy: MergePolicy) -> Result<MergeOutcome> {
268        self.store.with(|state| {
269            // What GitHub answers: a draft can be neither merged nor armed to merge
270            // itself (`Pull Request is still a draft`), so a publication that asked
271            // before lifting it fails here rather than landing work nobody lifted.
272            if matches!(policy, MergePolicy::ChangeAuto | MergePolicy::ChangeDirect)
273                && drafted(state, &cr.id)
274            {
275                return Err(Error::Invalid {
276                    reason: format!(
277                        "{} is still a draft, and a draft can be neither merged nor armed to \
278                         merge itself",
279                        cr.url
280                    ),
281                });
282            }
283            // A seeded outcome is the host's decision and outranks the policy: it is
284            // how a journey says "this one is queued behind something" or "this one
285            // has already landed".
286            if let Some(decided) = state.merges.get(&cr.id) {
287                return Ok(decided.clone());
288            }
289            let landed = |state: &mut HostState| MergeOutcome::Merged(land(state, cr));
290            Ok(match policy {
291                // Nothing is asked of the host, so nothing is recorded — the same
292                // answer the real implementation gives without a call.
293                MergePolicy::LocalDirect | MergePolicy::ChangeOpen => MergeOutcome::Open,
294                MergePolicy::ChangeAuto => {
295                    if required_checks_green(state, &cr.id) {
296                        landed(state)
297                    } else {
298                        // Native auto-merge: the host holds it and lands it when its
299                        // own required checks pass, so nothing merges now.
300                        state.merges.insert(cr.id.clone(), MergeOutcome::Queued);
301                        MergeOutcome::Queued
302                    }
303                }
304                MergePolicy::ChangeDirect => landed(state),
305            })
306        })
307    }
308
309    /// Take the change request out of its draft state.
310    ///
311    /// The call itself is what is recorded, rather than a flag that would read the
312    /// same whether it was made once or twice: what a journey asks of this is whether
313    /// a second publication over an already-lifted change asked the host for
314    /// anything, and only the calls can answer that. A change already open for review
315    /// is already what this asks for, so it succeeds — the caller wanted it ready,
316    /// and it is.
317    fn ready_for_review(&self, cr: &ChangeRequest) -> Result<()> {
318        self.store.with(|state| {
319            state.made_ready.push(cr.id.clone());
320            Ok(())
321        })
322    }
323
324    // llmlint: ignore[invalid_states_unrepresentable] the signature is the trait's, which
325    // the approved amendment in `docs/contract.md` declares verbatim as `-> Result<bool>`;
326    // an implementation cannot narrow it, and a named domain type would be a public item
327    // the contract does not name. The third state a caller must not read as "not a draft"
328    // — "this host would not say" — is the `Err` arm rather than a value inside it, and the
329    // reason is recorded on the declaration in the crate next door.
330    fn is_draft(&self, cr: &ChangeRequest) -> Result<bool> {
331        Ok(drafted(&self.store.snapshot()?, &cr.id))
332    }
333
334    /// What the repository requires before a merge into `base`: the seeded answer,
335    /// or — unset — exactly the checks seeded as required, completely.
336    fn required_checks_on(&self, _base: &str) -> Result<RequiredChecks> {
337        let state = self.store.snapshot()?;
338        if let Some(seeded) = state.required_checks {
339            return Ok(seeded);
340        }
341        Ok(RequiredChecks {
342            checks: state
343                .checks
344                .values()
345                .chain(state.checks_after_lift.values())
346                .flatten()
347                .filter(|check| check.required)
348                .map(|check| check.name.clone())
349                .collect(),
350            unconsulted: std::collections::BTreeMap::new(),
351        })
352    }
353
354    /// Replace the description, recording the call.
355    ///
356    /// The title and body go where `open_change` put the ones it was given, so what
357    /// `change_description` reads back is the same record whichever call wrote it —
358    /// and the call itself is kept, for the reason `ready_for_review`'s is: a journey
359    /// about a closeout asks what was written and in what order, and the current
360    /// description cannot say. A title that names nothing is refused as the real host
361    /// refuses one; a body is prose, and an empty one is a description a caller may
362    /// mean.
363    fn describe_change(&self, cr: &ChangeRequest, title: Option<&str>, body: &str) -> Result<()> {
364        if let Some(title) = title {
365            crate::state::titled(title)?;
366        }
367        self.store.with(|state| {
368            if !state.changes.iter().any(|change| change.id == cr.id) {
369                return Err(Error::Invalid {
370                    reason: format!(
371                        "no change request {:?} was opened on this host, so there is nothing to \
372                         describe",
373                        cr.id.0
374                    ),
375                });
376            }
377            if let Some(title) = title {
378                state.titles.insert(cr.id.clone(), title.to_owned());
379            }
380            state.bodies.insert(cr.id.clone(), body.to_owned());
381            state.described.push(Described {
382                id: cr.id.clone(),
383                title: title.map(str::to_owned),
384                body: body.to_owned(),
385            });
386            Ok(())
387        })
388    }
389
390    /// The description as this host holds it: the title it was opened or last
391    /// described with, and the body — empty where it was opened with none and never
392    /// described since, which is what the real host answers for one.
393    fn change_description(&self, cr: &ChangeRequest) -> Result<Description> {
394        let state = self.store.snapshot()?;
395        let title = state
396            .titles
397            .get(&cr.id)
398            .cloned()
399            .ok_or_else(|| Error::Invalid {
400                reason: format!(
401                    "no change request {:?} was opened on this host, so it has no description",
402                    cr.id.0
403                ),
404            })?;
405        Ok(Description {
406            title,
407            body: state.bodies.get(&cr.id).cloned().unwrap_or_default(),
408        })
409    }
410
411    /// What changed in the change request's review feedback since `since`, by this
412    /// host's own count of changes to it — see `review`.
413    fn review_comments(&self, change: &ChangeId, since: Option<&ReadMarker>) -> Result<ReviewRead> {
414        review::read(&self.store, change, since)
415    }
416
417    /// One reply, posted in the comment's thread where it has one and as a
418    /// conversation comment linking it otherwise, and recorded in
419    /// [`HostState::replies`].
420    fn reply_to_comment(
421        &self,
422        change: &ChangeId,
423        comment: &CommentId,
424        body: &str,
425        key: &str,
426    ) -> Result<PostedReply> {
427        review::reply(&self.store, change, comment, body, key)
428    }
429
430    fn merge_time(&self, cr: &ChangeRequest) -> Result<Option<String>> {
431        let state = self.store.snapshot()?;
432        // Only a change this host holds as merged has a merge time: a seeded time on
433        // a change still open is a scenario that says nothing has merged yet.
434        Ok(match state.merges.get(&cr.id) {
435            Some(MergeOutcome::Merged(_)) => state.merge_times.get(&cr.id).cloned(),
436            _ => None,
437        })
438    }
439
440    fn merged_at(&self, cr: &ChangeRequest) -> Result<Option<Sha>> {
441        self.store.with(|state| {
442            // Native auto-merge, modelled rather than skipped: a change this host is
443            // holding lands the moment its own required checks are green, on the
444            // host's clock and not on the caller's. Asking is what makes that
445            // observable — which is exactly the shape a publication watching the
446            // real GitHub meets, and the reason a journey can drive a change request
447            // from queued to merged without a second process.
448            if matches!(state.merges.get(&cr.id), Some(MergeOutcome::Queued))
449                && required_checks_green(state, &cr.id)
450            {
451                return Ok(Some(land(state, cr)));
452            }
453            Ok(match state.merges.get(&cr.id) {
454                Some(MergeOutcome::Merged(sha)) => Some(sha.clone()),
455                _ => None,
456            })
457        })
458    }
459}
460
461/// Record this change request as merged, and answer with the commit it merged at.
462///
463/// One place, so the merge a policy performs and the merge the host completes on
464/// its own report the same commit for the same change.
465fn land(state: &mut HostState, cr: &ChangeRequest) -> Sha {
466    let sha = Sha(events::stable_sha(&["merge", &cr.id.0, cr.url.as_str()]));
467    state
468        .merges
469        .insert(cr.id.clone(), MergeOutcome::Merged(sha.clone()));
470    state.merge_times.insert(cr.id.clone(), events::timestamp());
471    sha
472}
473
474/// What a host that was not told otherwise answers about where its checks came
475/// from: the host's own rollup, which is every check anything posted on the change
476/// request — the answer a credential allowed to read check runs gets.
477fn complete_sources() -> std::collections::BTreeSet<CheckSource> {
478    [CheckSource::StatusChecks].into_iter().collect()
479}
480
481/// Whether every required check on a change request lets the host merge it.
482///
483/// What is required is every check seeded as required and every name the seeded
484/// [`HostState::required_checks`] declares, so a declared check that has not reported
485/// a run holds the merge as it does on GitHub's own merge path. A change with no
486/// required checks is not green: nothing has vouched for it, which is the state
487/// auto-merge waits in rather than lands from. A skipped required check lets the merge
488/// through, as it does on GitHub's own merge path — which is why that is a state of
489/// its own rather than a pass.
490fn required_checks_green(state: &HostState, id: &ChangeId) -> bool {
491    let checks = current_checks(state, id);
492    let required: std::collections::BTreeSet<&str> = checks
493        .iter()
494        .filter(|check| check.required)
495        .map(|check| check.name.as_str())
496        .chain(
497            state
498                .required_checks
499                .iter()
500                .flat_map(|declared| declared.checks.iter().map(String::as_str)),
501        )
502        .collect();
503    !required.is_empty()
504        && required.iter().all(|name| {
505            let mut runs = checks.iter().filter(|check| check.name == *name).peekable();
506            runs.peek().is_some()
507                && runs
508                    .all(|check| matches!(check.state(), CheckState::Passed | CheckState::Skipped))
509        })
510}
511
512/// Whether this host holds a change request as a draft: opened as one, for a reason
513/// or while its checks run, and not lifted since.
514fn drafted(state: &HostState, id: &ChangeId) -> bool {
515    (state.drafts.contains_key(id) || state.awaiting_checks.contains(id))
516        && !state.made_ready.contains(id)
517}
518
519/// The checks the host reports on a change request right now: the ones registered
520/// after a lift, once it has been lifted and some are seeded, otherwise its own.
521fn current_checks(state: &HostState, id: &ChangeId) -> Vec<Check> {
522    if state.made_ready.contains(id) {
523        if let Some(after) = state.checks_after_lift.get(id) {
524            return after.clone();
525        }
526    }
527    state.checks.get(id).cloned().unwrap_or_default()
528}
529
530/// The id one check's log is stored under.
531///
532/// Derived from what it is a log *of* rather than minted, so fetching the same
533/// log twice does not leave two artifacts, and a journey can name the id it is
534/// about to assert on.
535fn artifact_id(change: &ChangeId, check: &str) -> String {
536    let safe: String = check
537        .chars()
538        .map(|c| {
539            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
540                c
541            } else {
542                '-'
543            }
544        })
545        .collect();
546    let number: String = change
547        .0
548        .chars()
549        .filter(|c| c.is_ascii_alphanumeric())
550        .collect();
551    format!("a-testing-{number}-{safe}")
552}
553
554/// One value bound for the host's argument vector, checked before it gets there.
555///
556/// The same refusal the real implementation makes, and for the same reason: a name
557/// shaped like an option or an absent value addresses something other than what it
558/// names, and a provider that accepted one would let a journey pass where the real
559/// host rejects.
560fn addressable(value: &str, what: &str) -> Result<()> {
561    if value.is_empty() || value.starts_with('-') || value.contains(char::is_whitespace) {
562        return Err(Error::Invalid {
563            reason: format!(
564                "{what} {value:?} cannot address anything on the host: it must be non-empty, \
565                 must not begin with '-', and must carry no whitespace"
566            ),
567        });
568    }
569    Ok(())
570}
571
572/// A slug that names one repository, as `owner/name`.
573fn named_repository(slug: &str) -> Result<String> {
574    let mut parts = slug.split('/');
575    let named = matches!(
576        (parts.next(), parts.next(), parts.next()),
577        (Some(owner), Some(name), None)
578            if !owner.is_empty()
579                && !name.is_empty()
580                && !slug.starts_with('-')
581                && !slug.contains(char::is_whitespace)
582    );
583    if !named {
584        return Err(Error::Invalid {
585            reason: format!("{slug:?} does not name one repository as owner/name"),
586        });
587    }
588    Ok(slug.to_owned())
589}