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}