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