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 merge_time(&self, cr: &ChangeRequest) -> Result<Option<String>> {
383 let state = self.store.snapshot()?;
384 // Only a change this host holds as merged has a merge time: a seeded time on
385 // a change still open is a scenario that says nothing has merged yet.
386 Ok(match state.merges.get(&cr.id) {
387 Some(MergeOutcome::Merged(_)) => state.merge_times.get(&cr.id).cloned(),
388 _ => None,
389 })
390 }
391
392 fn merged_at(&self, cr: &ChangeRequest) -> Result<Option<Sha>> {
393 self.store.with(|state| {
394 // Native auto-merge, modelled rather than skipped: a change this host is
395 // holding lands the moment its own required checks are green, on the
396 // host's clock and not on the caller's. Asking is what makes that
397 // observable — which is exactly the shape a publication watching the
398 // real GitHub meets, and the reason a journey can drive a change request
399 // from queued to merged without a second process.
400 if matches!(state.merges.get(&cr.id), Some(MergeOutcome::Queued))
401 && required_checks_green(state, &cr.id)
402 {
403 return Ok(Some(land(state, cr)));
404 }
405 Ok(match state.merges.get(&cr.id) {
406 Some(MergeOutcome::Merged(sha)) => Some(sha.clone()),
407 _ => None,
408 })
409 })
410 }
411}
412
413/// Record this change request as merged, and answer with the commit it merged at.
414///
415/// One place, so the merge a policy performs and the merge the host completes on
416/// its own report the same commit for the same change.
417fn land(state: &mut HostState, cr: &ChangeRequest) -> Sha {
418 let sha = Sha(events::stable_sha(&["merge", &cr.id.0, cr.url.as_str()]));
419 state
420 .merges
421 .insert(cr.id.clone(), MergeOutcome::Merged(sha.clone()));
422 state.merge_times.insert(cr.id.clone(), events::timestamp());
423 sha
424}
425
426/// What a host that was not told otherwise answers about where its checks came
427/// from: the host's own rollup, which is every check anything posted on the change
428/// request — the answer a credential allowed to read check runs gets.
429fn complete_sources() -> std::collections::BTreeSet<CheckSource> {
430 [CheckSource::StatusChecks].into_iter().collect()
431}
432
433/// Whether every required check on a change request lets the host merge it.
434///
435/// A change with no required checks is not green: nothing has vouched for it, which
436/// is the state auto-merge waits in rather than lands from. A skipped required check
437/// lets the merge through, as it does on GitHub's own merge path — which is why that
438/// is a state of its own rather than a pass.
439fn required_checks_green(state: &HostState, id: &ChangeId) -> bool {
440 let checks = current_checks(state, id);
441 let required: Vec<&Check> = checks.iter().filter(|check| check.required).collect();
442 !required.is_empty()
443 && required
444 .iter()
445 .all(|check| matches!(check.state(), CheckState::Passed | CheckState::Skipped))
446}
447
448/// Whether this host holds a change request as a draft: opened as one, for a reason
449/// or while its checks run, and not lifted since.
450fn drafted(state: &HostState, id: &ChangeId) -> bool {
451 (state.drafts.contains_key(id) || state.awaiting_checks.contains(id))
452 && !state.made_ready.contains(id)
453}
454
455/// The checks the host reports on a change request right now: the ones registered
456/// after a lift, once it has been lifted and some are seeded, otherwise its own.
457fn current_checks(state: &HostState, id: &ChangeId) -> Vec<Check> {
458 if state.made_ready.contains(id) {
459 if let Some(after) = state.checks_after_lift.get(id) {
460 return after.clone();
461 }
462 }
463 state.checks.get(id).cloned().unwrap_or_default()
464}
465
466/// The id one check's log is stored under.
467///
468/// Derived from what it is a log *of* rather than minted, so fetching the same
469/// log twice does not leave two artifacts, and a journey can name the id it is
470/// about to assert on.
471fn artifact_id(change: &ChangeId, check: &str) -> String {
472 let safe: String = check
473 .chars()
474 .map(|c| {
475 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
476 c
477 } else {
478 '-'
479 }
480 })
481 .collect();
482 let number: String = change
483 .0
484 .chars()
485 .filter(|c| c.is_ascii_alphanumeric())
486 .collect();
487 format!("a-testing-{number}-{safe}")
488}
489
490/// One value bound for the host's argument vector, checked before it gets there.
491///
492/// The same refusal the real implementation makes, and for the same reason: a name
493/// shaped like an option or an absent value addresses something other than what it
494/// names, and a provider that accepted one would let a journey pass where the real
495/// host rejects.
496fn addressable(value: &str, what: &str) -> Result<()> {
497 if value.is_empty() || value.starts_with('-') || value.contains(char::is_whitespace) {
498 return Err(Error::Invalid {
499 reason: format!(
500 "{what} {value:?} cannot address anything on the host: it must be non-empty, \
501 must not begin with '-', and must carry no whitespace"
502 ),
503 });
504 }
505 Ok(())
506}
507
508/// A slug that names one repository, as `owner/name`.
509fn named_repository(slug: &str) -> Result<String> {
510 let mut parts = slug.split('/');
511 let named = matches!(
512 (parts.next(), parts.next(), parts.next()),
513 (Some(owner), Some(name), None)
514 if !owner.is_empty()
515 && !name.is_empty()
516 && !slug.starts_with('-')
517 && !slug.contains(char::is_whitespace)
518 );
519 if !named {
520 return Err(Error::Invalid {
521 reason: format!("{slug:?} does not name one repository as owner/name"),
522 });
523 }
524 Ok(slug.to_owned())
525}