onevcs-testing 0.10.0

In-memory and file-backed implementations of onevcs's Vcs and RemoteHost interfaces, for driving a real onevcs in a test.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
//! The remote-host side: one implementation of [`RemoteHost`] and [`Hosting`] over
//! either store.
//!
//! What GitHub decides, this decides from what it was seeded with: which change
//! requests exist, what their checks say, and whether a merge lands. What it does
//! *not* do is move a commit — a merge here records an outcome and nothing reaches
//! any origin, which is exactly the boundary the real implementation delegates to
//! the host and the reason a journey about git drives real git.

use std::path::PathBuf;

use url::Url;

use onevcs::{
    ArtifactId, ChangeChecks, ChangeId, ChangeRequest, ChangeSpec, Check, CheckSource, CheckState,
    Description, Error, Hosting, MergeOutcome, MergePolicy, RemoteHost, RequiredChecks, Result,
    Sha,
};

use crate::events;
use crate::state::{Described, HostState};
use crate::store::{FileStore, MemoryStore, Store};

/// The host a change request's URL names, matching the one implementation the
/// crate next door speaks for.
pub const DEFAULT_HOST: &str = "github.com";

/// The repository a host answers for when it was not addressed at one — which is
/// the case only when a journey holds it directly rather than through
/// [`Hosting::for_repo`].
pub const DEFAULT_SLUG: &str = "onevcs/testing";

/// The remote-host side of a run, over whichever store holds its state.
///
/// It is both interfaces at once: a [`RemoteHost`] a journey can call directly, and
/// the [`Hosting`] factory a run is handed. A host taken from the factory shares
/// this one's state, so what a publication did is read back through the value the
/// journey created.
#[derive(Debug)]
pub struct Host<T> {
    store: T,
    // A slug arrives one way only — `Hosting::for_repo`, which takes the `&str` the
    // contract fixes — and `named_repository` refuses it there; every other
    // construction here is `DEFAULT_SLUG`. A newtype would have to be public to be
    // the parameter's type, and the seam is specified without one, which is the same
    // reason recorded on `Hosting::for_repo` in the crate next door.
    // llmlint: ignore[invalid_states_unrepresentable] see the note directly above.
    slug: String,
}

/// A host provider that keeps its state in this process.
pub type MemoryHost = Host<MemoryStore<HostState>>;

/// A host provider that keeps its state in one JSON document, so several `onevcs`
/// invocations see one another's change requests.
pub type FileHost = Host<FileStore<HostState>>;

impl MemoryHost {
    /// A host with nothing opened against it.
    pub fn new() -> Self {
        Self::seeded(HostState::default())
    }

    /// A host that starts from a scenario.
    pub fn seeded(state: HostState) -> Self {
        Self {
            store: MemoryStore::new(state),
            slug: DEFAULT_SLUG.to_owned(),
        }
    }

    /// Everything it knows.
    pub fn state(&self) -> HostState {
        self.store
            .snapshot()
            .expect("an in-memory store always answers")
    }
}

impl Default for MemoryHost {
    fn default() -> Self {
        Self::new()
    }
}

impl FileHost {
    /// A host keeping its state at `path`: whatever is already there, or nothing
    /// opened against it.
    ///
    /// Attaching rather than replacing, so a second host over the same path answers
    /// about the change requests the first one opened.
    pub fn create(path: impl Into<PathBuf>) -> Result<Self> {
        Ok(Self {
            store: FileStore::attach(path, &HostState::default())?,
            slug: DEFAULT_SLUG.to_owned(),
        })
    }

    /// A host that starts from a scenario, keeping its state at `path` and
    /// replacing whatever was there.
    pub fn seeded(path: impl Into<PathBuf>, state: HostState) -> Result<Self> {
        Ok(Self {
            store: FileStore::replace(path, &state)?,
            slug: DEFAULT_SLUG.to_owned(),
        })
    }

    /// Everything it knows, read back out of its document.
    pub fn state(&self) -> Result<HostState> {
        self.store.snapshot()
    }
}

impl<T: Store<HostState> + Clone + std::fmt::Debug + Send + Sync + 'static> Hosting for Host<T> {
    fn for_repo(&self, slug: &str) -> Result<Box<dyn RemoteHost>> {
        Ok(Box::new(Host {
            store: self.store.clone(),
            slug: named_repository(slug)?,
        }))
    }
}

impl<T: Store<HostState>> RemoteHost for Host<T> {
    fn authenticated_user(&self) -> Result<String> {
        let login = self.store.snapshot()?.authenticated_user;
        if login.trim().is_empty() {
            return Err(Error::Invalid {
                reason: "the host reported no authenticated user".to_owned(),
            });
        }
        Ok(login)
    }

    fn open_change(&self, req: ChangeSpec) -> Result<ChangeRequest> {
        addressable(&req.head, "the head branch")?;
        addressable(&req.base, "the base branch")?;
        // The same refusal the real host makes: a change request whose title names
        // nothing is one it will not open.
        crate::state::titled(&req.title)?;
        // The publication rule the crate next door states, applied rather than
        // restated — and applied *here* rather than only where a publication takes a
        // request in, because this trait is reachable directly: a journey that holds a
        // host and opens a change on it hands this the reason, and a reason nothing
        // could render is one the state must not go on to answer from.
        if let Some(reason) = &req.draft {
            reason.checked()?;
        }
        let slug = self.slug.clone();
        self.store.with(|state| {
            // The host numbers its change requests, consecutively from one, so a
            // journey can seed the checks of a change it has not opened yet.
            let id = ChangeId((state.changes.len() + 1).to_string());
            let url = format!("https://{DEFAULT_HOST}/{slug}/pull/{}", id.0);
            let change = ChangeRequest {
                head_sha: Sha(events::stable_sha(&[&slug, &req.head, &id.0])),
                url: Url::parse(&url).map_err(|e| Error::Invalid {
                    reason: format!("{url:?} is not a URL: {e}"),
                })?,
                base: req.base.clone(),
                id: id.clone(),
            };
            state.heads.insert(id.clone(), req.head.clone());
            state.titles.insert(id.clone(), req.title.clone());
            // Only when there is one: a change request opened with no body records
            // none, so a journey can tell "nobody drafted one" from "the body is
            // empty" — which is the distinction the real host draws too.
            if let Some(body) = req.body.clone() {
                state.bodies.insert(id.clone(), body);
            }
            // Beside the body and for its reason: a change request opened as a draft
            // records the reason it was drafted with, and one opened without a reason
            // records nothing — which is how a journey tells a draft from an ordinary
            // change request rather than from a flag nobody kept.
            if let Some(reason) = req.draft.clone() {
                state.drafts.insert(id, reason);
            } else if req.draft_awaiting_checks {
                // The lifecycle's own draft, which carries no reason: recorded apart
                // from the reasons a caller gave, so a journey can tell the two apart.
                state.awaiting_checks.insert(id);
            }
            state.changes.push(change.clone());
            Ok(change)
        })
    }

    fn find_changes(&self, head: &str, base: &str) -> Result<Vec<ChangeRequest>> {
        addressable(head, "the head branch")?;
        addressable(base, "the base branch")?;
        let state = self.store.snapshot()?;
        Ok(state
            .changes
            .iter()
            .filter(|change| {
                change.base == base
                    && state.heads.get(&change.id).is_some_and(|from| from == head)
                    // Only the open ones: a change the host has already merged is
                    // not one to adopt.
                    && !matches!(state.merges.get(&change.id), Some(MergeOutcome::Merged(_)))
            })
            .cloned()
            .collect())
    }

    fn change_checks(&self, cr: &ChangeRequest) -> Result<ChangeChecks> {
        let state = self.store.snapshot()?;
        let sources = state.check_sources.clone().unwrap_or_else(complete_sources);
        // The same refusal the real implementation makes when its credential can
        // read neither the host's check rollup nor its Actions API: what the checks
        // say is unknown, and answering "none" for "could not look" is what lets a
        // merge through unguarded.
        if sources.is_empty() {
            return Err(Error::Invalid {
                reason: format!(
                    "this host was seeded with no check source, so what the checks on {} say \
                     cannot be read rather than being empty",
                    cr.url
                ),
            });
        }
        Ok(ChangeChecks {
            checks: current_checks(&state, &cr.id),
            sources,
        })
    }

    fn check_log(&self, cr: &ChangeRequest, check: &Check) -> Result<ArtifactId> {
        let log = self
            .store
            .snapshot()?
            .check_logs
            .get(&cr.id)
            .and_then(|logs| logs.get(&check.name))
            .cloned()
            .unwrap_or_else(|| format!("the host log for check {}\n", check.name));
        events::store_artifact(&artifact_id(&cr.id, &check.name), &log)
    }

    fn merge(&self, cr: &ChangeRequest, policy: MergePolicy) -> Result<MergeOutcome> {
        self.store.with(|state| {
            // What GitHub answers: a draft can be neither merged nor armed to merge
            // itself (`Pull Request is still a draft`), so a publication that asked
            // before lifting it fails here rather than landing work nobody lifted.
            if matches!(policy, MergePolicy::ChangeAuto | MergePolicy::ChangeDirect)
                && drafted(state, &cr.id)
            {
                return Err(Error::Invalid {
                    reason: format!(
                        "{} is still a draft, and a draft can be neither merged nor armed to \
                         merge itself",
                        cr.url
                    ),
                });
            }
            // A seeded outcome is the host's decision and outranks the policy: it is
            // how a journey says "this one is queued behind something" or "this one
            // has already landed".
            if let Some(decided) = state.merges.get(&cr.id) {
                return Ok(decided.clone());
            }
            let landed = |state: &mut HostState| MergeOutcome::Merged(land(state, cr));
            Ok(match policy {
                // Nothing is asked of the host, so nothing is recorded — the same
                // answer the real implementation gives without a call.
                MergePolicy::LocalDirect | MergePolicy::ChangeOpen => MergeOutcome::Open,
                MergePolicy::ChangeAuto => {
                    if required_checks_green(state, &cr.id) {
                        landed(state)
                    } else {
                        // Native auto-merge: the host holds it and lands it when its
                        // own required checks pass, so nothing merges now.
                        state.merges.insert(cr.id.clone(), MergeOutcome::Queued);
                        MergeOutcome::Queued
                    }
                }
                MergePolicy::ChangeDirect => landed(state),
            })
        })
    }

    /// Take the change request out of its draft state.
    ///
    /// The call itself is what is recorded, rather than a flag that would read the
    /// same whether it was made once or twice: what a journey asks of this is whether
    /// a second publication over an already-lifted change asked the host for
    /// anything, and only the calls can answer that. A change already open for review
    /// is already what this asks for, so it succeeds — the caller wanted it ready,
    /// and it is.
    fn ready_for_review(&self, cr: &ChangeRequest) -> Result<()> {
        self.store.with(|state| {
            state.made_ready.push(cr.id.clone());
            Ok(())
        })
    }

    // llmlint: ignore[invalid_states_unrepresentable] the signature is the trait's, which
    // the approved amendment in `docs/contract.md` declares verbatim as `-> Result<bool>`;
    // an implementation cannot narrow it, and a named domain type would be a public item
    // the contract does not name. The third state a caller must not read as "not a draft"
    // — "this host would not say" — is the `Err` arm rather than a value inside it, and the
    // reason is recorded on the declaration in the crate next door.
    fn is_draft(&self, cr: &ChangeRequest) -> Result<bool> {
        Ok(drafted(&self.store.snapshot()?, &cr.id))
    }

    /// What the repository requires before a merge into `base`: the seeded answer,
    /// or — unset — exactly the checks seeded as required, completely.
    fn required_checks_on(&self, _base: &str) -> Result<RequiredChecks> {
        let state = self.store.snapshot()?;
        if let Some(seeded) = state.required_checks {
            return Ok(seeded);
        }
        Ok(RequiredChecks {
            checks: state
                .checks
                .values()
                .chain(state.checks_after_lift.values())
                .flatten()
                .filter(|check| check.required)
                .map(|check| check.name.clone())
                .collect(),
            unconsulted: std::collections::BTreeMap::new(),
        })
    }

    /// Replace the description, recording the call.
    ///
    /// The title and body go where `open_change` put the ones it was given, so what
    /// `change_description` reads back is the same record whichever call wrote it —
    /// and the call itself is kept, for the reason `ready_for_review`'s is: a journey
    /// about a closeout asks what was written and in what order, and the current
    /// description cannot say. A title that names nothing is refused as the real host
    /// refuses one; a body is prose, and an empty one is a description a caller may
    /// mean.
    fn describe_change(&self, cr: &ChangeRequest, title: Option<&str>, body: &str) -> Result<()> {
        if let Some(title) = title {
            crate::state::titled(title)?;
        }
        self.store.with(|state| {
            if !state.changes.iter().any(|change| change.id == cr.id) {
                return Err(Error::Invalid {
                    reason: format!(
                        "no change request {:?} was opened on this host, so there is nothing to \
                         describe",
                        cr.id.0
                    ),
                });
            }
            if let Some(title) = title {
                state.titles.insert(cr.id.clone(), title.to_owned());
            }
            state.bodies.insert(cr.id.clone(), body.to_owned());
            state.described.push(Described {
                id: cr.id.clone(),
                title: title.map(str::to_owned),
                body: body.to_owned(),
            });
            Ok(())
        })
    }

    /// The description as this host holds it: the title it was opened or last
    /// described with, and the body — empty where it was opened with none and never
    /// described since, which is what the real host answers for one.
    fn change_description(&self, cr: &ChangeRequest) -> Result<Description> {
        let state = self.store.snapshot()?;
        let title = state
            .titles
            .get(&cr.id)
            .cloned()
            .ok_or_else(|| Error::Invalid {
                reason: format!(
                    "no change request {:?} was opened on this host, so it has no description",
                    cr.id.0
                ),
            })?;
        Ok(Description {
            title,
            body: state.bodies.get(&cr.id).cloned().unwrap_or_default(),
        })
    }

    fn merged_at(&self, cr: &ChangeRequest) -> Result<Option<Sha>> {
        self.store.with(|state| {
            // Native auto-merge, modelled rather than skipped: a change this host is
            // holding lands the moment its own required checks are green, on the
            // host's clock and not on the caller's. Asking is what makes that
            // observable — which is exactly the shape a publication watching the
            // real GitHub meets, and the reason a journey can drive a change request
            // from queued to merged without a second process.
            if matches!(state.merges.get(&cr.id), Some(MergeOutcome::Queued))
                && required_checks_green(state, &cr.id)
            {
                return Ok(Some(land(state, cr)));
            }
            Ok(match state.merges.get(&cr.id) {
                Some(MergeOutcome::Merged(sha)) => Some(sha.clone()),
                _ => None,
            })
        })
    }
}

/// Record this change request as merged, and answer with the commit it merged at.
///
/// One place, so the merge a policy performs and the merge the host completes on
/// its own report the same commit for the same change.
fn land(state: &mut HostState, cr: &ChangeRequest) -> Sha {
    let sha = Sha(events::stable_sha(&["merge", &cr.id.0, cr.url.as_str()]));
    state
        .merges
        .insert(cr.id.clone(), MergeOutcome::Merged(sha.clone()));
    sha
}

/// What a host that was not told otherwise answers about where its checks came
/// from: the host's own rollup, which is every check anything posted on the change
/// request — the answer a credential allowed to read check runs gets.
fn complete_sources() -> std::collections::BTreeSet<CheckSource> {
    [CheckSource::StatusChecks].into_iter().collect()
}

/// Whether every required check on a change request lets the host merge it.
///
/// A change with no required checks is not green: nothing has vouched for it, which
/// is the state auto-merge waits in rather than lands from. A skipped required check
/// lets the merge through, as it does on GitHub's own merge path — which is why that
/// is a state of its own rather than a pass.
fn required_checks_green(state: &HostState, id: &ChangeId) -> bool {
    let checks = current_checks(state, id);
    let required: Vec<&Check> = checks.iter().filter(|check| check.required).collect();
    !required.is_empty()
        && required
            .iter()
            .all(|check| matches!(check.state(), CheckState::Passed | CheckState::Skipped))
}

/// Whether this host holds a change request as a draft: opened as one, for a reason
/// or while its checks run, and not lifted since.
fn drafted(state: &HostState, id: &ChangeId) -> bool {
    (state.drafts.contains_key(id) || state.awaiting_checks.contains(id))
        && !state.made_ready.contains(id)
}

/// The checks the host reports on a change request right now: the ones registered
/// after a lift, once it has been lifted and some are seeded, otherwise its own.
fn current_checks(state: &HostState, id: &ChangeId) -> Vec<Check> {
    if state.made_ready.contains(id) {
        if let Some(after) = state.checks_after_lift.get(id) {
            return after.clone();
        }
    }
    state.checks.get(id).cloned().unwrap_or_default()
}

/// The id one check's log is stored under.
///
/// Derived from what it is a log *of* rather than minted, so fetching the same
/// log twice does not leave two artifacts, and a journey can name the id it is
/// about to assert on.
fn artifact_id(change: &ChangeId, check: &str) -> String {
    let safe: String = check
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
                c
            } else {
                '-'
            }
        })
        .collect();
    let number: String = change
        .0
        .chars()
        .filter(|c| c.is_ascii_alphanumeric())
        .collect();
    format!("a-testing-{number}-{safe}")
}

/// One value bound for the host's argument vector, checked before it gets there.
///
/// The same refusal the real implementation makes, and for the same reason: a name
/// shaped like an option or an absent value addresses something other than what it
/// names, and a provider that accepted one would let a journey pass where the real
/// host rejects.
fn addressable(value: &str, what: &str) -> Result<()> {
    if value.is_empty() || value.starts_with('-') || value.contains(char::is_whitespace) {
        return Err(Error::Invalid {
            reason: format!(
                "{what} {value:?} cannot address anything on the host: it must be non-empty, \
                 must not begin with '-', and must carry no whitespace"
            ),
        });
    }
    Ok(())
}

/// A slug that names one repository, as `owner/name`.
fn named_repository(slug: &str) -> Result<String> {
    let mut parts = slug.split('/');
    let named = matches!(
        (parts.next(), parts.next(), parts.next()),
        (Some(owner), Some(name), None)
            if !owner.is_empty()
                && !name.is_empty()
                && !slug.starts_with('-')
                && !slug.contains(char::is_whitespace)
    );
    if !named {
        return Err(Error::Invalid {
            reason: format!("{slug:?} does not name one repository as owner/name"),
        });
    }
    Ok(slug.to_owned())
}