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, ChangeStandings, Check,
16    CheckSource, CheckState, CommentId, Description, Error, Hosting, MergeOutcome, MergePolicy,
17    Mergeability, PostedReply, ReadCost, ReadMarker, RemoteHost, RequiredChecks, Result,
18    ReviewRead, ReviewStandings, Sha, Visibility,
19};
20
21use crate::landings::{self, HeadAfter, PersonMerge};
22use crate::state::{Described, HostCall, HostComment, HostState};
23use crate::store::{FileStore, MemoryStore, Store};
24use crate::{events, review};
25
26/// The host a change request's URL names, matching the one implementation the
27/// crate next door speaks for.
28pub const DEFAULT_HOST: &str = "github.com";
29
30/// The repository a host answers for when it was not addressed at one — which is
31/// the case only when a journey holds it directly rather than through
32/// [`Hosting::for_repo`].
33pub const DEFAULT_SLUG: &str = "onevcs/testing";
34
35/// The remote-host side of a run, over whichever store holds its state.
36///
37/// It is both interfaces at once: a [`RemoteHost`] a journey can call directly, and
38/// the [`Hosting`] factory a run is handed. A host taken from the factory shares
39/// this one's state, so what a publication did is read back through the value the
40/// journey created.
41#[derive(Debug)]
42pub struct Host<T> {
43    store: T,
44    // A slug arrives one way only — `Hosting::for_repo`, which takes the `&str` the
45    // contract fixes — and `named_repository` refuses it there; every other
46    // construction here is `DEFAULT_SLUG`. A newtype would have to be public to be
47    // the parameter's type, and the seam is specified without one, which is the same
48    // reason recorded on `Hosting::for_repo` in the crate next door.
49    // llmlint: ignore[invalid_states_unrepresentable] see the note directly above.
50    slug: String,
51}
52
53/// A host provider that keeps its state in this process.
54pub type MemoryHost = Host<MemoryStore<HostState>>;
55
56/// A host provider that keeps its state in one JSON document, so several `onevcs`
57/// invocations see one another's change requests.
58pub type FileHost = Host<FileStore<HostState>>;
59
60impl MemoryHost {
61    /// A host with nothing opened against it.
62    pub fn new() -> Self {
63        Self::seeded(HostState::default())
64    }
65
66    /// A host that starts from a scenario.
67    pub fn seeded(state: HostState) -> Self {
68        Self {
69            store: MemoryStore::new(state),
70            slug: DEFAULT_SLUG.to_owned(),
71        }
72    }
73
74    /// Everything it knows.
75    pub fn state(&self) -> HostState {
76        self.store
77            .snapshot()
78            .expect("an in-memory store always answers")
79    }
80}
81
82impl Default for MemoryHost {
83    fn default() -> Self {
84        Self::new()
85    }
86}
87
88impl FileHost {
89    /// A host keeping its state at `path`: whatever is already there, or nothing
90    /// opened against it.
91    ///
92    /// Attaching rather than replacing, so a second host over the same path answers
93    /// about the change requests the first one opened.
94    pub fn create(path: impl Into<PathBuf>) -> Result<Self> {
95        Ok(Self {
96            store: FileStore::attach(path, &HostState::default())?,
97            slug: DEFAULT_SLUG.to_owned(),
98        })
99    }
100
101    /// A host that starts from a scenario, keeping its state at `path` and
102    /// replacing whatever was there.
103    pub fn seeded(path: impl Into<PathBuf>, state: HostState) -> Result<Self> {
104        Ok(Self {
105            store: FileStore::replace(path, &state)?,
106            slug: DEFAULT_SLUG.to_owned(),
107        })
108    }
109
110    /// Everything it knows, read back out of its document.
111    pub fn state(&self) -> Result<HostState> {
112        self.store.snapshot()
113    }
114}
115
116/// A person's moves on a stack of change requests, which no interface method makes: the
117/// real host takes them from people — a merge on its page, a branch deleted, a change
118/// request closed or moved — so a journey takes them here.
119impl<T: Store<HostState>> Host<T> {
120    /// A person merges `change` on the host into the base it targets now — `how` a squash
121    /// or a merge commit — at `at`, an RFC 3339 moment on the host's clock, and then keeps
122    /// or deletes its head as `head` says: a deleted head closes or retargets every open
123    /// change request based on it. Answers the commit the base reached. Nothing is
124    /// written to the ledger: nobody on this host made a call.
125    pub fn merge_by_person(
126        &self,
127        change: &ChangeId,
128        how: PersonMerge,
129        at: &str,
130        head: HeadAfter,
131    ) -> Result<Sha> {
132        self.store
133            .with(|state| landings::merge_by_person(state, change, how, at, head))
134    }
135
136    /// A person closes `change` on the host without merging it.
137    pub fn close_unmerged(&self, change: &ChangeId) -> Result<()> {
138        self.store.with(|state| {
139            if !state.changes.iter().any(|held| held.id == *change) {
140                return Err(Error::Invalid {
141                    reason: format!("no change request {:?} was opened on this host", change.0),
142                });
143            }
144            state.closed.insert(change.clone());
145            Ok(())
146        })
147    }
148
149    /// A person moves `change` onto `base` on the host.
150    pub fn retarget_by_person(&self, change: &ChangeId, base: &str) -> Result<()> {
151        crate::state::named_branch(base, "the base branch")?;
152        self.store.with(|state| {
153            if !state.changes.iter().any(|held| held.id == *change) {
154                return Err(Error::Invalid {
155                    reason: format!("no change request {:?} was opened on this host", change.0),
156                });
157            }
158            landings::retarget_in(state, change, base);
159            Ok(())
160        })
161    }
162
163    /// Each check's state on one head commit, in place of the change request's own.
164    pub fn set_head_checks(&self, head: &Sha, checks: Vec<Check>) -> Result<()> {
165        self.store.with(|state| {
166            state.head_checks.insert(head.clone(), checks);
167            Ok(())
168        })
169    }
170
171    /// Every host call this host answered, in order, with what GitHub charges for it.
172    pub fn ledger(&self) -> Result<Vec<HostCall>> {
173        Ok(self.store.snapshot()?.ledger)
174    }
175
176    /// Record one call this host answered on its ledger.
177    fn called(&self, method: &str, changes: &[&ChangeId], charge: ReadCost) -> Result<()> {
178        let call = HostCall {
179            method: method.to_owned(),
180            changes: changes.iter().map(|id| (*id).clone()).collect(),
181            charge,
182        };
183        call.checked()?;
184        self.store.with(|state| {
185            state.ledger.push(call);
186            Ok(())
187        })
188    }
189}
190
191/// A reviewer's moves on a change request's feedback, which no interface method makes:
192/// the real host takes them from people, so a journey takes them here.
193impl<T: Store<HostState>> Host<T> {
194    /// Post one more comment on `change`, as a reviewer would — a review-thread
195    /// comment, a review's summary or a conversation comment, whichever `comment`'s
196    /// kind says. Its `revision` is this host's to set, and is set past every read so
197    /// far.
198    pub fn add_comment(&self, change: &ChangeId, comment: HostComment) -> Result<()> {
199        review::add(&self.store, change, comment)
200    }
201
202    /// Replace a comment's body, as its author editing it would.
203    pub fn edit_comment(&self, change: &ChangeId, comment: &CommentId, body: &str) -> Result<()> {
204        review::edit(&self.store, change, comment, body)
205    }
206
207    /// Set a review thread's two flags, as resolving it, unresolving it, or a push
208    /// moving past its line would. Every comment in the thread changes with it.
209    pub fn set_thread(
210        &self,
211        change: &ChangeId,
212        thread: &str,
213        resolved: bool,
214        outdated: bool,
215    ) -> Result<()> {
216        review::set_thread(&self.store, change, thread, resolved, outdated)
217    }
218}
219
220impl<T: Store<HostState> + Clone + std::fmt::Debug + Send + Sync + 'static> Hosting for Host<T> {
221    fn for_repo(&self, slug: &str) -> Result<Box<dyn RemoteHost>> {
222        Ok(Box::new(Host {
223            store: self.store.clone(),
224            slug: named_repository(slug)?,
225        }))
226    }
227}
228
229impl<T: Store<HostState>> RemoteHost for Host<T> {
230    fn authenticated_user(&self) -> Result<String> {
231        self.called("authenticated_user", &[], landings::rest(1))?;
232        let login = self.store.snapshot()?.authenticated_user;
233        if login.trim().is_empty() {
234            return Err(Error::Invalid {
235                reason: "the host reported no authenticated user".to_owned(),
236            });
237        }
238        Ok(login)
239    }
240
241    fn open_change(&self, req: ChangeSpec) -> Result<ChangeRequest> {
242        // One `gh pr create` and the `gh pr view` that reads its head.
243        self.called("open_change", &[], landings::graphql(2))?;
244        addressable(&req.head, "the head branch")?;
245        addressable(&req.base, "the base branch")?;
246        // The same refusal the real host makes: a change request whose title names
247        // nothing is one it will not open.
248        crate::state::titled(&req.title)?;
249        // The publication rule the crate next door states, applied rather than
250        // restated — and applied *here* rather than only where a publication takes a
251        // request in, because this trait is reachable directly: a journey that holds a
252        // host and opens a change on it hands this the reason, and a reason nothing
253        // could render is one the state must not go on to answer from.
254        if let Some(reason) = &req.draft {
255            reason.checked()?;
256        }
257        let slug = self.slug.clone();
258        self.store.with(|state| {
259            // The host numbers its change requests, consecutively from one, so a
260            // journey can seed the checks of a change it has not opened yet.
261            let id = ChangeId((state.changes.len() + 1).to_string());
262            let url = format!("https://{DEFAULT_HOST}/{slug}/pull/{}", id.0);
263            let change = ChangeRequest {
264                head_sha: Sha(events::stable_sha(&[&slug, &req.head, &id.0])),
265                url: Url::parse(&url).map_err(|e| Error::Invalid {
266                    reason: format!("{url:?} is not a URL: {e}"),
267                })?,
268                base: req.base.clone(),
269                id: id.clone(),
270            };
271            state.heads.insert(id.clone(), req.head.clone());
272            state.titles.insert(id.clone(), req.title.clone());
273            // Only when there is one: a change request opened with no body records
274            // none, so a journey can tell "nobody drafted one" from "the body is
275            // empty" — which is the distinction the real host draws too.
276            if let Some(body) = req.body.clone() {
277                state.bodies.insert(id.clone(), body);
278            }
279            // Beside the body and for its reason: a change request opened as a draft
280            // records the reason it was drafted with, and one opened without a reason
281            // records nothing — which is how a journey tells a draft from an ordinary
282            // change request rather than from a flag nobody kept.
283            if let Some(reason) = req.draft.clone() {
284                state.drafts.insert(id, reason);
285            } else if req.draft_awaiting_checks {
286                // The lifecycle's own draft, which carries no reason: recorded apart
287                // from the reasons a caller gave, so a journey can tell the two apart.
288                state.awaiting_checks.insert(id);
289            }
290            state.changes.push(change.clone());
291            Ok(change)
292        })
293    }
294
295    fn find_changes(&self, head: &str, base: &str) -> Result<Vec<ChangeRequest>> {
296        self.called("find_changes", &[], landings::graphql(1))?;
297        addressable(head, "the head branch")?;
298        addressable(base, "the base branch")?;
299        let state = self.store.snapshot()?;
300        Ok(state
301            .changes
302            .iter()
303            .filter(|change| {
304                change.base == base
305                    && state.heads.get(&change.id).is_some_and(|from| from == head)
306                    // Only the open ones: a change the host has already merged is
307                    // not one to adopt.
308                    && !matches!(state.merges.get(&change.id), Some(MergeOutcome::Merged(_)))
309            })
310            .cloned()
311            .collect())
312    }
313
314    fn change_checks(&self, cr: &ChangeRequest) -> Result<ChangeChecks> {
315        // The rollup and `gh pr checks --required`: two queries.
316        self.called("change_checks", &[&cr.id], landings::graphql(2))?;
317        let state = self.store.snapshot()?;
318        let sources = state.check_sources.clone().unwrap_or_else(complete_sources);
319        // The same refusal the real implementation makes when its credential can
320        // read neither the host's check rollup nor its Actions API: what the checks
321        // say is unknown, and answering "none" for "could not look" is what lets a
322        // merge through unguarded.
323        if sources.is_empty() {
324            return Err(Error::Invalid {
325                reason: format!(
326                    "this host was seeded with no check source, so what the checks on {} say \
327                     cannot be read rather than being empty",
328                    cr.url
329                ),
330            });
331        }
332        Ok(ChangeChecks {
333            checks: current_checks(&state, &cr.id),
334            sources,
335        })
336    }
337
338    fn check_log(&self, cr: &ChangeRequest, check: &Check) -> Result<ArtifactId> {
339        self.called("check_log", &[&cr.id], landings::rest(1))?;
340        let log = self
341            .store
342            .snapshot()?
343            .check_logs
344            .get(&cr.id)
345            .and_then(|logs| logs.get(&check.name))
346            .cloned()
347            .unwrap_or_else(|| format!("the host log for check {}\n", check.name));
348        events::store_artifact(&artifact_id(&cr.id, &check.name), &log)
349    }
350
351    fn merge(&self, cr: &ChangeRequest, policy: MergePolicy) -> Result<MergeOutcome> {
352        // Asked to land, the real host is one merge mutation and one read of the merge;
353        // under a policy that lands nothing it is asked nothing, and charges nothing.
354        let charge = match policy {
355            MergePolicy::ChangeAuto | MergePolicy::ChangeDirect => landings::graphql(2),
356            MergePolicy::LocalDirect | MergePolicy::ChangeOpen => ReadCost::default(),
357        };
358        self.called("merge", &[&cr.id], charge)?;
359        self.store.with(|state| {
360            // What GitHub answers: a draft can be neither merged nor armed to merge
361            // itself (`Pull Request is still a draft`), so a publication that asked
362            // before lifting it fails here rather than landing work nobody lifted.
363            if matches!(policy, MergePolicy::ChangeAuto | MergePolicy::ChangeDirect)
364                && drafted(state, &cr.id)
365            {
366                return Err(Error::Invalid {
367                    reason: format!(
368                        "{} is still a draft, and a draft can be neither merged nor armed to \
369                         merge itself",
370                        cr.url
371                    ),
372                });
373            }
374            // A seeded outcome is the host's decision and outranks the policy: it is
375            // how a journey says "this one is queued behind something" or "this one
376            // has already landed".
377            if let Some(decided) = state.merges.get(&cr.id) {
378                return Ok(decided.clone());
379            }
380            let landed = |state: &mut HostState| MergeOutcome::Merged(land(state, cr));
381            Ok(match policy {
382                // Nothing is asked of the host, so nothing is recorded — the same
383                // answer the real implementation gives without a call.
384                MergePolicy::LocalDirect | MergePolicy::ChangeOpen => MergeOutcome::Open,
385                MergePolicy::ChangeAuto => {
386                    if required_checks_green(state, &cr.id) {
387                        landed(state)
388                    } else {
389                        // Native auto-merge: the host holds it and lands it when its
390                        // own required checks pass, so nothing merges now.
391                        state.merges.insert(cr.id.clone(), MergeOutcome::Queued);
392                        MergeOutcome::Queued
393                    }
394                }
395                MergePolicy::ChangeDirect => landed(state),
396            })
397        })
398    }
399
400    /// Take the change request out of its draft state.
401    ///
402    /// The call itself is what is recorded, rather than a flag that would read the
403    /// same whether it was made once or twice: what a journey asks of this is whether
404    /// a second publication over an already-lifted change asked the host for
405    /// anything, and only the calls can answer that. A change already open for review
406    /// is already what this asks for, so it succeeds — the caller wanted it ready,
407    /// and it is.
408    fn ready_for_review(&self, cr: &ChangeRequest) -> Result<()> {
409        self.called("ready_for_review", &[&cr.id], landings::graphql(1))?;
410        self.store.with(|state| {
411            state.made_ready.push(cr.id.clone());
412            Ok(())
413        })
414    }
415
416    // llmlint: ignore[invalid_states_unrepresentable] the signature is the trait's, which
417    // the approved amendment in `docs/contract.md` declares verbatim as `-> Result<bool>`;
418    // an implementation cannot narrow it, and a named domain type would be a public item
419    // the contract does not name. The third state a caller must not read as "not a draft"
420    // — "this host would not say" — is the `Err` arm rather than a value inside it, and the
421    // reason is recorded on the declaration in the crate next door.
422    fn is_draft(&self, cr: &ChangeRequest) -> Result<bool> {
423        self.called("is_draft", &[&cr.id], landings::graphql(1))?;
424        Ok(drafted(&self.store.snapshot()?, &cr.id))
425    }
426
427    /// What the repository requires before a merge into `base`: the seeded answer,
428    /// or — unset — exactly the checks seeded as required, completely.
429    fn required_checks_on(&self, _base: &str) -> Result<RequiredChecks> {
430        // The rulesets and classic protection: two REST requests.
431        self.called("required_checks_on", &[], landings::rest(2))?;
432        let state = self.store.snapshot()?;
433        if let Some(seeded) = state.required_checks {
434            return Ok(seeded);
435        }
436        Ok(RequiredChecks {
437            checks: state
438                .checks
439                .values()
440                .chain(state.checks_after_lift.values())
441                .flatten()
442                .filter(|check| check.required)
443                .map(|check| check.name.clone())
444                .collect(),
445            unconsulted: std::collections::BTreeMap::new(),
446        })
447    }
448
449    /// Replace the description, recording the call.
450    ///
451    /// The title and body go where `open_change` put the ones it was given, so what
452    /// `change_description` reads back is the same record whichever call wrote it —
453    /// and the call itself is kept, for the reason `ready_for_review`'s is: a journey
454    /// about a closeout asks what was written and in what order, and the current
455    /// description cannot say. A title that names nothing is refused as the real host
456    /// refuses one; a body is prose, and an empty one is a description a caller may
457    /// mean.
458    fn describe_change(&self, cr: &ChangeRequest, title: Option<&str>, body: &str) -> Result<()> {
459        self.called("describe_change", &[&cr.id], landings::graphql(1))?;
460        if let Some(title) = title {
461            crate::state::titled(title)?;
462        }
463        self.store.with(|state| {
464            if !state.changes.iter().any(|change| change.id == cr.id) {
465                return Err(Error::Invalid {
466                    reason: format!(
467                        "no change request {:?} was opened on this host, so there is nothing to \
468                         describe",
469                        cr.id.0
470                    ),
471                });
472            }
473            if let Some(title) = title {
474                state.titles.insert(cr.id.clone(), title.to_owned());
475            }
476            state.bodies.insert(cr.id.clone(), body.to_owned());
477            state.described.push(Described {
478                id: cr.id.clone(),
479                title: title.map(str::to_owned),
480                body: body.to_owned(),
481            });
482            Ok(())
483        })
484    }
485
486    /// The description as this host holds it: the title it was opened or last
487    /// described with, and the body — empty where it was opened with none and never
488    /// described since, which is what the real host answers for one.
489    fn change_description(&self, cr: &ChangeRequest) -> Result<Description> {
490        self.called("change_description", &[&cr.id], landings::graphql(1))?;
491        let state = self.store.snapshot()?;
492        let title = state
493            .titles
494            .get(&cr.id)
495            .cloned()
496            .ok_or_else(|| Error::Invalid {
497                reason: format!(
498                    "no change request {:?} was opened on this host, so it has no description",
499                    cr.id.0
500                ),
501            })?;
502        Ok(Description {
503            title,
504            body: state.bodies.get(&cr.id).cloned().unwrap_or_default(),
505        })
506    }
507
508    /// What changed in the change request's review feedback since `since`, by this
509    /// host's own count of changes to it — see `review`.
510    fn review_comments(&self, change: &ChangeId, since: Option<&ReadMarker>) -> Result<ReviewRead> {
511        let read = review::read(&self.store, change, since);
512        let charge = read.as_ref().map_or(landings::graphql(1), |read| read.cost);
513        self.called("review_comments", &[change], charge)?;
514        read
515    }
516
517    /// One reply, posted in the comment's thread where it has one and as a
518    /// conversation comment linking it otherwise, and recorded in
519    /// [`HostState::replies`].
520    fn reply_to_comment(
521        &self,
522        change: &ChangeId,
523        comment: &CommentId,
524        body: &str,
525        key: &str,
526    ) -> Result<PostedReply> {
527        self.called("reply_to_comment", &[change], landings::rest(1))?;
528        review::reply(&self.store, change, comment, body, key)
529    }
530
531    /// Recorded on the ledger and answered as the trait's default answers: this host
532    /// holds no mergeability, and a publication reads the refusal as a host that said
533    /// nothing, which is no conflict — so it watches as it always did here.
534    fn mergeability(&self, cr: &ChangeRequest) -> Result<Mergeability> {
535        self.called("mergeability", &[&cr.id], landings::graphql(1))?;
536        Err(Error::NotImplemented {
537            operation: "RemoteHost::mergeability",
538        })
539    }
540
541    /// Recorded on the ledger and answered as the trait's default answers: this host
542    /// holds no visibility, so the boundary records it unknown and screens every write,
543    /// as it always did here.
544    fn visibility(&self) -> Result<Visibility> {
545        self.called("visibility", &[], landings::rest(1))?;
546        Err(Error::NotImplemented {
547            operation: "RemoteHost::visibility",
548        })
549    }
550
551    /// Where each change request stands, one per change request asked about — see
552    /// `landings`. Charged as the real host's one query per 50.
553    fn change_standings(&self, root: &str, changes: &[ChangeId]) -> Result<ChangeStandings> {
554        let cost = landings::standings_charge(changes.len());
555        let named: Vec<&ChangeId> = changes.iter().collect();
556        self.called("change_standings", &named, cost)?;
557        let state = self.store.snapshot()?;
558        Ok(ChangeStandings {
559            standings: changes
560                .iter()
561                .map(|id| landings::standing(&state, root, id))
562                .collect::<Result<_>>()?,
563            cost,
564        })
565    }
566
567    /// Each change request's standing and its review feedback since its marker, in one
568    /// read charged as the real host's one aliased query per 150.
569    fn review_standings(
570        &self,
571        root: &str,
572        changes: &[(ChangeId, Option<ReadMarker>)],
573    ) -> Result<ReviewStandings> {
574        let cost = landings::review_charge(changes.len());
575        let named: Vec<&ChangeId> = changes.iter().map(|(id, _)| id).collect();
576        self.called("review_standings", &named, cost)?;
577        self.store.with(|state| {
578            let mut standings = Vec::with_capacity(changes.len());
579            let mut reads = Vec::with_capacity(changes.len());
580            for (id, since) in changes {
581                standings.push(landings::standing(state, root, id)?);
582                reads.push(review::read_in(state, id, since.as_ref(), false)?);
583            }
584            Ok(ReviewStandings {
585                standings,
586                reads,
587                cost,
588            })
589        })
590    }
591
592    /// Move a change request onto `base`, writing `body` with it — refused, as the real
593    /// host refuses, for one that is merged or closed and for a base the origin no
594    /// longer has. Its draft state never changes. One REST request.
595    fn retarget_change(
596        &self,
597        change: &ChangeId,
598        base: &str,
599        body: Option<&str>,
600    ) -> Result<ReadCost> {
601        self.called("retarget_change", &[change], landings::rest(1))?;
602        addressable(base, "the base branch")?;
603        self.store.with(|state| {
604            let opened = state
605                .changes
606                .iter()
607                .find(|held| held.id == *change)
608                .cloned()
609                .ok_or_else(|| Error::Invalid {
610                    reason: format!("no change request {:?} was opened on this host", change.0),
611                })?;
612            if matches!(state.merges.get(change), Some(MergeOutcome::Merged(_))) {
613                return Err(Error::Invalid {
614                    reason: format!(
615                        "Cannot change the base branch of a merged pull request: {}",
616                        opened.url
617                    ),
618                });
619            }
620            if state.closed.contains(change) {
621                return Err(Error::Invalid {
622                    reason: format!(
623                        "Cannot change the base branch of a closed pull request: {}",
624                        opened.url
625                    ),
626                });
627            }
628            if state.deleted_branches.contains(base) {
629                return Err(Error::Invalid {
630                    reason: format!("Proposed base branch '{base}' was not found"),
631                });
632            }
633            landings::retarget_in(state, change, base);
634            if let Some(body) = body {
635                state.bodies.insert(change.clone(), body.to_owned());
636            }
637            Ok(landings::rest(1))
638        })
639    }
640
641    /// Reopen a closed change request, as the draft it was — refused, as the real host
642    /// refuses, for one that is not closed and for one whose base is still gone. One REST
643    /// request.
644    fn reopen_change(&self, change: &ChangeId) -> Result<ReadCost> {
645        self.called("reopen_change", &[change], landings::rest(1))?;
646        self.store.with(|state| {
647            let opened = state
648                .changes
649                .iter()
650                .find(|held| held.id == *change)
651                .cloned()
652                .ok_or_else(|| Error::Invalid {
653                    reason: format!("no change request {:?} was opened on this host", change.0),
654                })?;
655            if !state.closed.contains(change) {
656                return Err(Error::Invalid {
657                    reason: format!("{} is not closed, so its state cannot change", opened.url),
658                });
659            }
660            if state.deleted_branches.contains(&opened.base) {
661                return Err(Error::Invalid {
662                    reason: format!(
663                        "the base branch of {} was deleted, so it cannot be reopened",
664                        opened.url
665                    ),
666                });
667            }
668            state.closed.remove(change);
669            Ok(landings::rest(1))
670        })
671    }
672
673    fn merge_time(&self, cr: &ChangeRequest) -> Result<Option<String>> {
674        self.called("merge_time", &[&cr.id], landings::graphql(1))?;
675        let state = self.store.snapshot()?;
676        // Only a change this host holds as merged has a merge time: a seeded time on
677        // a change still open is a scenario that says nothing has merged yet.
678        Ok(match state.merges.get(&cr.id) {
679            Some(MergeOutcome::Merged(_)) => state.merge_times.get(&cr.id).cloned(),
680            _ => None,
681        })
682    }
683
684    fn merged_at(&self, cr: &ChangeRequest) -> Result<Option<Sha>> {
685        self.called("merged_at", &[&cr.id], landings::graphql(1))?;
686        self.store.with(|state| {
687            // Native auto-merge, modelled rather than skipped: a change this host is
688            // holding lands the moment its own required checks are green, on the
689            // host's clock and not on the caller's. Asking is what makes that
690            // observable — which is exactly the shape a publication watching the
691            // real GitHub meets, and the reason a journey can drive a change request
692            // from queued to merged without a second process.
693            if matches!(state.merges.get(&cr.id), Some(MergeOutcome::Queued))
694                && required_checks_green(state, &cr.id)
695            {
696                return Ok(Some(land(state, cr)));
697            }
698            Ok(match state.merges.get(&cr.id) {
699                Some(MergeOutcome::Merged(sha)) => Some(sha.clone()),
700                _ => None,
701            })
702        })
703    }
704}
705
706/// Record this change request as merged, and answer with the commit it merged at.
707///
708/// One place, so the merge a policy performs and the merge the host completes on
709/// its own report the same commit for the same change.
710fn land(state: &mut HostState, cr: &ChangeRequest) -> Sha {
711    let sha = Sha(events::stable_sha(&["merge", &cr.id.0, cr.url.as_str()]));
712    state
713        .merges
714        .insert(cr.id.clone(), MergeOutcome::Merged(sha.clone()));
715    state.merge_times.insert(cr.id.clone(), events::timestamp());
716    sha
717}
718
719/// What a host that was not told otherwise answers about where its checks came
720/// from: the host's own rollup, which is every check anything posted on the change
721/// request — the answer a credential allowed to read check runs gets.
722fn complete_sources() -> std::collections::BTreeSet<CheckSource> {
723    [CheckSource::StatusChecks].into_iter().collect()
724}
725
726/// Whether every required check on a change request lets the host merge it.
727///
728/// What is required is every check seeded as required and every name the seeded
729/// [`HostState::required_checks`] declares, so a declared check that has not reported
730/// a run holds the merge as it does on GitHub's own merge path. A change with no
731/// required checks is not green: nothing has vouched for it, which is the state
732/// auto-merge waits in rather than lands from. A skipped required check lets the merge
733/// through, as it does on GitHub's own merge path — which is why that is a state of
734/// its own rather than a pass.
735fn required_checks_green(state: &HostState, id: &ChangeId) -> bool {
736    let checks = current_checks(state, id);
737    let required: std::collections::BTreeSet<&str> = checks
738        .iter()
739        .filter(|check| check.required)
740        .map(|check| check.name.as_str())
741        .chain(
742            state
743                .required_checks
744                .iter()
745                .flat_map(|declared| declared.checks.iter().map(String::as_str)),
746        )
747        .collect();
748    !required.is_empty()
749        && required.iter().all(|name| {
750            let mut runs = checks.iter().filter(|check| check.name == *name).peekable();
751            runs.peek().is_some()
752                && runs
753                    .all(|check| matches!(check.state(), CheckState::Passed | CheckState::Skipped))
754        })
755}
756
757/// Whether this host holds a change request as a draft: opened as one, for a reason
758/// or while its checks run, and not lifted since.
759pub(crate) fn drafted(state: &HostState, id: &ChangeId) -> bool {
760    (state.drafts.contains_key(id) || state.awaiting_checks.contains(id))
761        && !state.made_ready.contains(id)
762}
763
764/// The checks the host reports on a change request right now: the ones registered
765/// after a lift, once it has been lifted and some are seeded, otherwise its own.
766pub(crate) fn current_checks(state: &HostState, id: &ChangeId) -> Vec<Check> {
767    if state.made_ready.contains(id) {
768        if let Some(after) = state.checks_after_lift.get(id) {
769            return after.clone();
770        }
771    }
772    state.checks.get(id).cloned().unwrap_or_default()
773}
774
775/// The id one check's log is stored under.
776///
777/// Derived from what it is a log *of* rather than minted, so fetching the same
778/// log twice does not leave two artifacts, and a journey can name the id it is
779/// about to assert on.
780fn artifact_id(change: &ChangeId, check: &str) -> String {
781    let safe: String = check
782        .chars()
783        .map(|c| {
784            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
785                c
786            } else {
787                '-'
788            }
789        })
790        .collect();
791    let number: String = change
792        .0
793        .chars()
794        .filter(|c| c.is_ascii_alphanumeric())
795        .collect();
796    format!("a-testing-{number}-{safe}")
797}
798
799/// One value bound for the host's argument vector, checked before it gets there.
800///
801/// The same refusal the real implementation makes, and for the same reason: a name
802/// shaped like an option or an absent value addresses something other than what it
803/// names, and a provider that accepted one would let a journey pass where the real
804/// host rejects.
805fn addressable(value: &str, what: &str) -> Result<()> {
806    if value.is_empty() || value.starts_with('-') || value.contains(char::is_whitespace) {
807        return Err(Error::Invalid {
808            reason: format!(
809                "{what} {value:?} cannot address anything on the host: it must be non-empty, \
810                 must not begin with '-', and must carry no whitespace"
811            ),
812        });
813    }
814    Ok(())
815}
816
817/// A slug that names one repository, as `owner/name`.
818fn named_repository(slug: &str) -> Result<String> {
819    let mut parts = slug.split('/');
820    let named = matches!(
821        (parts.next(), parts.next(), parts.next()),
822        (Some(owner), Some(name), None)
823            if !owner.is_empty()
824                && !name.is_empty()
825                && !slug.starts_with('-')
826                && !slug.contains(char::is_whitespace)
827    );
828    if !named {
829        return Err(Error::Invalid {
830            reason: format!("{slug:?} does not name one repository as owner/name"),
831        });
832    }
833    Ok(slug.to_owned())
834}