onevcs_testing/repository.rs
1//! The repository side: one implementation of [`Vcs`] over either store.
2//!
3//! What it is not: git. It answers the questions the interface asks, records what
4//! it was asked, and emits the events the real implementation emits. What it
5//! cannot do is tell you whether a tree is dirty or whether a merge conflicts,
6//! because there is no tree — a journey that needs those drives the real `Git`.
7//!
8//! Publishing is the one operation that reaches past the repository: the host side
9//! of it is *performed*, against the [`Hosting`] the publication was handed, and
10//! the repository side of it is neither performed nor claimed. What that leaves
11//! out is written where each piece is left out.
12
13use std::path::{Path, PathBuf};
14
15use serde_json::{json, Map, Value};
16
17use std::collections::BTreeSet;
18
19use onevcs::rules::{Approvals, Drafts};
20use onevcs::{
21 ChangeRequest, ChangeSpec, Check, CheckState, DraftReason, Error, EventKind, FailureKind,
22 HeldBy, Holding, Hosting, Identity, Landed, Lifecycle, MergeOutcome, MergePolicy,
23 PreservedBranch, Provenance, Publication, PublishOutcome, PublishRequest, Recoverable,
24 RemoteHost, Result, Scope, Session, SessionRecord, SessionRequest, SessionToken, Sha, Vcs,
25};
26
27use crate::events::{self, Emission};
28use crate::remote::DEFAULT_HOST;
29use crate::state::{self, VcsState};
30use crate::store::{FileStore, MemoryStore, Store};
31
32/// The base a session is cut from when the request names none.
33///
34/// The real implementation asks the origin for its default branch, and this
35/// provider has no origin to ask.
36pub const DEFAULT_BASE: &str = "main";
37
38/// The policy a publication takes when nothing was seeded and nothing requested.
39///
40/// The policy the contract's own `default:` names, which is what the real
41/// implementation resolves to for a registry with no rules file.
42pub const DEFAULT_PUBLICATION: MergePolicy = MergePolicy::ChangeOpen;
43
44/// The approvals a publication takes when nothing was seeded: the contract's own
45/// `default:`, as [`DEFAULT_PUBLICATION`] is.
46pub const DEFAULT_APPROVALS: Approvals = Approvals::Required;
47
48/// The repository side of a run, over whichever store holds its state.
49///
50/// The two flavours below are this one behaviour with a different store under it,
51/// so neither can learn something the other does not know.
52#[derive(Debug)]
53pub struct Repository<T> {
54 store: T,
55 root: PathBuf,
56 trees: Trees,
57}
58
59/// What a session's worktree path means.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61enum Trees {
62 /// The path is named and nothing is created there: the in-memory provider
63 /// touches no filesystem beyond the event stream.
64 Named,
65 /// The directory is created, so a journey has somewhere to write work.
66 Created,
67}
68
69/// A repository provider that keeps its state in this process: no disk, no
70/// visibility to a second process, and the fastest of the two.
71///
72/// Its sessions name a worktree under the system temporary directory and **do not
73/// create it** — nothing here touches the filesystem except the event stream, which
74/// is the record a journey reads.
75pub type MemoryVcs = Repository<MemoryStore<VcsState>>;
76
77/// A repository provider that keeps its state in one JSON document, so several
78/// `onevcs` invocations see one another's effects.
79///
80/// Its sessions name a worktree beside that document **and create it**, so a
81/// journey that writes a file into a session's tree has somewhere to write it.
82pub type FileVcs = Repository<FileStore<VcsState>>;
83
84impl MemoryVcs {
85 /// A repository provider knowing nothing.
86 pub fn new() -> Self {
87 Self::seeded(VcsState::default())
88 }
89
90 /// A repository provider that starts from a scenario.
91 pub fn seeded(state: VcsState) -> Self {
92 Self {
93 store: MemoryStore::new(state),
94 root: std::env::temp_dir().join("onevcs-testing-memory"),
95 trees: Trees::Named,
96 }
97 }
98
99 /// Everything it knows.
100 pub fn state(&self) -> VcsState {
101 self.store
102 .snapshot()
103 .expect("an in-memory store always answers")
104 }
105}
106
107impl Default for MemoryVcs {
108 fn default() -> Self {
109 Self::new()
110 }
111}
112
113impl FileVcs {
114 /// A repository provider keeping its state at `path`: whatever is already
115 /// there, or nothing.
116 ///
117 /// Attaching rather than replacing, so a second provider over the same path
118 /// picks up what the first one left — which is what a journey driving several
119 /// invocations reaches for this flavour to get.
120 pub fn create(path: impl Into<PathBuf>) -> Result<Self> {
121 Self::over(FileStore::attach(path, &VcsState::default())?)
122 }
123
124 /// A repository provider that starts from a scenario, keeping its state at
125 /// `path` and replacing whatever was there.
126 pub fn seeded(path: impl Into<PathBuf>, state: VcsState) -> Result<Self> {
127 Self::over(FileStore::replace(path, &state)?)
128 }
129
130 fn over(store: FileStore<VcsState>) -> Result<Self> {
131 let root = store
132 .path()
133 .parent()
134 .filter(|parent| !parent.as_os_str().is_empty())
135 .unwrap_or_else(|| Path::new("."))
136 .join("worktrees");
137 Ok(Self {
138 store,
139 root,
140 trees: Trees::Created,
141 })
142 }
143
144 /// Everything it knows, read back out of its document.
145 pub fn state(&self) -> Result<VcsState> {
146 self.store.snapshot()
147 }
148}
149
150impl<T: Store<VcsState>> Vcs for Repository<T> {
151 fn resolve_identity(&self, origin_or_path: &str) -> Result<Identity> {
152 self.store.with(|state| {
153 state::identity_of(state, origin_or_path)
154 .cloned()
155 .ok_or_else(|| Error::Invalid {
156 reason: format!(
157 "{origin_or_path:?} does not name a repository this provider knows; {}",
158 state::known(state)
159 ),
160 })
161 })
162 }
163
164 fn open_session(&self, req: SessionRequest) -> Result<Session> {
165 let root = self.root.clone();
166 let (session, emission) = self.store.with(|state| {
167 let identity = state::identity_of(state, &req.repo)
168 .cloned()
169 .ok_or_else(|| Error::Invalid {
170 reason: format!(
171 "{:?} does not name a repository this provider knows; {}",
172 req.repo,
173 state::known(state)
174 ),
175 })?;
176 // Consecutive and predictable, so a journey can name the session it is
177 // about to open — the one thing a digest-shaped token takes away.
178 let token = SessionToken(format!("s-testing-{}", state.sessions.len() + 1));
179 let run_root = root.join(&token.0);
180 // Both names are checked before they are recorded, because both go on to
181 // spell a ref for whoever holds the session — and a provider that
182 // accepted a name git refuses would let a journey pass where the real
183 // run stops.
184 let base = req.base.clone().unwrap_or_else(|| DEFAULT_BASE.to_owned());
185 state::named_branch(&base, "the base")?;
186 for (key, value) in &req.labels {
187 state::label_pair(key, value)?;
188 }
189 let session = Session {
190 worktree: run_root.join("worktree"),
191 branch: state::requested_branch(&req, &token)?,
192 base,
193 token: token.clone(),
194 };
195 state.sessions.push(session.clone());
196 state
197 .session_identities
198 .insert(token.clone(), identity.origin.clone());
199 if !req.labels.is_empty() {
200 state
201 .session_labels
202 .insert(token.clone(), req.labels.clone());
203 }
204 let emission = Emission {
205 stream: token.0.clone(),
206 identity: Some(identity.origin.clone()),
207 kind: EventKind::SessionOpened,
208 payload: object(json!({
209 "token": token.0,
210 "identity": identity.origin,
211 "branch": session.branch,
212 "base": session.base,
213 "worktree": session.worktree.display().to_string(),
214 // Synthetic, and named anyway: a consumer reading this event
215 // reads the same keys whichever implementation produced it.
216 "clone": run_root.join("clone").display().to_string(),
217 "execution_checkout": run_root.join("checkout").display().to_string(),
218 "publication_checkout": run_root.join("checkout").display().to_string(),
219 // A run root of its own, which is the one placement a provider
220 // that keeps no pool can make — and the one every real session
221 // gets on a host that configures none.
222 "placement": {"kind": "run-root"},
223 })),
224 };
225 Ok((session, emission))
226 })?;
227 if self.trees == Trees::Created {
228 std::fs::create_dir_all(&session.worktree).map_err(|e| Error::Invalid {
229 reason: format!("cannot create {}: {e}", session.worktree.display()),
230 })?;
231 }
232 events::emit(&emission);
233 Ok(session)
234 }
235
236 fn adopt_session(&self, token: SessionToken) -> Result<Session> {
237 self.store.with(|state| {
238 state::session_of(state, &token)
239 .cloned()
240 .ok_or_else(|| Error::Invalid {
241 reason: format!(
242 "no session {:?} is open; `onevcs session open` prints a token",
243 token.0
244 ),
245 })
246 })
247 }
248
249 fn preserve(&self, s: &Session, provenance: Provenance) -> Result<PreservedBranch> {
250 let (branch, emission) = self.store.with(|state| {
251 let identity = state::identity_for(state, &s.token)?;
252 let branch = PreservedBranch {
253 branch: s.branch.clone(),
254 base: s.base.clone(),
255 provenance,
256 change_url: None,
257 change_base: None,
258 };
259 let row = Recoverable {
260 identity: identity.clone(),
261 branch: branch.clone(),
262 checkout: s.worktree.clone(),
263 stopped_because: format!("session {} was left open", s.token.0),
264 recover_command: recover_command(&s.branch, &s.worktree, provenance),
265 // Preserved work this provider was handed is work nobody published:
266 // there is no base here whose history could record a landing, and no
267 // tree to compare content against, so the one answer it can give is
268 // the one it knows.
269 landed: Landed::No,
270 // Both answered where the row is *read* rather than frozen in here:
271 // whether a session still holds its branch is a fact about the session
272 // now, and there is no tree here to count a diff's lines against.
273 held_by: None,
274 net_negative: None,
275 // The session that preserved it is the session that answers for it,
276 // and what it was opened with is what the row carries.
277 session: Some(s.token.clone()),
278 labels: state
279 .session_labels
280 .get(&s.token)
281 .cloned()
282 .unwrap_or_default(),
283 // A preservation onto an origin is a thing only a real repository can
284 // do — there is no git here and no remote to push to — so this provider
285 // answers what a scenario wrote down and nothing else. A hand-written
286 // state may still seed one, exactly as it seeds a hold.
287 on_origin: None,
288 // Whether a branch may be retired is decided from every copy of it this
289 // host holds, its base on the origin, and this host's records — none of
290 // which a provider has — so this provider classifies nothing, and a row
291 // it answers carries no classification rather than an invented one.
292 retirement: None,
293 };
294 // Preserving the same branch twice replaces its row rather than listing
295 // it twice, which is what `recoverable` does across the checkouts a
296 // branch is reachable from.
297 state.preserved.retain(|kept| {
298 kept.identity != row.identity || kept.branch.branch != row.branch.branch
299 });
300 state.preserved.push(row);
301 let emission = Emission {
302 stream: s.token.0.clone(),
303 // No identity label, because the real implementation carries none
304 // here: the label is stamped where a session is opened, and work is
305 // preserved against a stream a later process opened fresh. Claiming
306 // it would be drift in the direction that looks like more
307 // information.
308 identity: None,
309 kind: EventKind::CommitPreserved,
310 payload: object(json!({
311 "branch": s.branch,
312 "sha": events::stable_sha(&[&s.token.0, &s.branch, spell(provenance)]),
313 "provenance": spell(provenance),
314 })),
315 };
316 Ok((branch, emission))
317 })?;
318 events::emit(&emission);
319 Ok(branch)
320 }
321
322 fn session(&self, token: &SessionToken) -> Result<SessionRecord> {
323 self.store.with(|state| {
324 let session = state::session_of(state, token)
325 .cloned()
326 .ok_or_else(|| unknown_session(token))?;
327 let identity = state::identity_for(state, token)?;
328 // Read off what was preserved rather than remembered separately: the
329 // real implementation reads the branch, so a session whose work was
330 // preserved behind an incomplete-step marker answers the same here.
331 let provenance = state
332 .preserved
333 .iter()
334 .find(|row| row.identity == identity && row.branch.branch == session.branch)
335 .map_or(Provenance::Complete, |row| row.branch.provenance);
336 Ok(SessionRecord {
337 lifecycle: if state.closed_sessions.contains(token) {
338 Lifecycle::Closed
339 } else {
340 Lifecycle::Open
341 },
342 session,
343 identity,
344 provenance,
345 // A retry link is a fact about run clones under a state root, and
346 // this provider keeps none: its sessions are records in a store, so
347 // there is no second copy of a branch for one to tell apart.
348 retried_by: None,
349 })
350 })
351 }
352
353 fn close_session(&self, token: &SessionToken) -> Result<Session> {
354 self.store.with(|state| {
355 let session = state::session_of(state, token)
356 .cloned()
357 .ok_or_else(|| unknown_session(token))?;
358 let emission = Emission {
359 stream: token.0.clone(),
360 // No identity label, because the real implementation carries none
361 // here: closing opens the stream fresh, and the label is stamped
362 // where a session is opened.
363 identity: None,
364 kind: EventKind::SessionClosed,
365 payload: object(json!({"token": token.0, "branch": session.branch})),
366 };
367 // Match the real provider's observable ordering: a follower reads the
368 // stream before consulting this state, so the terminator must exist
369 // before `Closed` can be returned to that concurrent reader.
370 events::emit(&emission);
371 state.closed_sessions.insert(token.clone());
372 Ok(session)
373 })
374 }
375
376 /// Publish a session's branch, as far as a provider honestly can.
377 ///
378 /// The host side is performed rather than described: a change request is really
379 /// opened against the [`Hosting`] this was handed, really adopted when one is
380 /// already open, and really merged under the policy — so the six host methods
381 /// are exercised and what the host recorded is what a journey reads back.
382 ///
383 /// The repository side is not, and none of it is claimed. There is no origin to
384 /// fetch from, no tree to build in, no push, and no lock to queue behind, so no
385 /// `fetch`, `push`, `lock-wait`, `lock-acquired`, or `merge-queued` event is
386 /// emitted. What is emitted is what was decided: the change that was opened, and
387 /// the merge that landed.
388 fn publish(
389 &self,
390 token: &SessionToken,
391 request: &PublishRequest,
392 hosting: &dyn Hosting,
393 ) -> Result<Publication> {
394 let (publication, emissions) = self.store.with(|state| {
395 let session = state::session_of(state, token)
396 .cloned()
397 .ok_or_else(|| unknown_session(token))?;
398 let identity = state::identity_for(state, token)?;
399 // The publication rule the crate next door states, applied rather than
400 // restated: a reason that would not render as itself is refused here
401 // exactly where the real implementation refuses it, before any host is
402 // asked and before anything is recorded.
403 if let Some(reason) = &request.draft {
404 reason.checked()?;
405 }
406 let resolved = state.policy.unwrap_or(DEFAULT_PUBLICATION);
407 let policy = match request.policy {
408 Some(requested) => resolved.narrow(requested)?,
409 None => resolved,
410 };
411 let published = |outcome, emissions| {
412 (
413 Publication {
414 session: token.clone(),
415 branch: session.branch.clone(),
416 policy,
417 outcome,
418 },
419 emissions,
420 )
421 };
422 // A session that has already landed has nothing the base does not carry,
423 // which is what the real implementation reports for the same reason. One
424 // whose change request is merely open or queued has *not* landed, and
425 // publishing it again adopts that change rather than opening a second —
426 // so it falls through to the host, as it does there.
427 if state.publications.iter().any(|earlier| {
428 earlier.session == *token && matches!(earlier.outcome, PublishOutcome::Merged(_))
429 }) {
430 let (publication, emissions) =
431 published(PublishOutcome::NothingToPublish, Vec::new());
432 state.publications.push(publication.clone());
433 return Ok((publication, emissions));
434 }
435
436 let (outcome, emissions) = if policy == MergePolicy::LocalDirect {
437 // The same refusal the real publication makes at the same boundary: a
438 // local-direct publication opens no change request, so there is
439 // nothing to draft and the work would land carrying the very pin the
440 // draft exists to hold back.
441 match &request.draft {
442 Some(reason) => (
443 failed(&Error::Invalid {
444 reason: format!(
445 "{branch:?} was asked to publish as {asked}, and this identity \
446 publishes with local-direct, which opens no change request at \
447 all",
448 branch = session.branch,
449 asked = asked_for(reason),
450 ),
451 }),
452 Vec::new(),
453 ),
454 None => record_local_landing(&identity, &session, token),
455 }
456 } else {
457 match slug(&identity) {
458 Some(slug) => {
459 // Recorded as it happens, as next door: a publication that
460 // fails after opening, drafting or lifting keeps that record.
461 let mut emissions = Vec::new();
462 let outcome = match publish_as_change(
463 hosting,
464 &Publishing {
465 slug: &slug,
466 identity: &identity,
467 session: &session,
468 policy,
469 approvals: state.approvals.unwrap_or(DEFAULT_APPROVALS),
470 drafts: state.drafts.unwrap_or_default(),
471 request,
472 token,
473 },
474 &mut emissions,
475 ) {
476 Ok(outcome) => outcome,
477 // Once a publication has started, what stops it is an outcome
478 // rather than a refusal — the same split the real
479 // implementation keeps, so a caller reads one shape.
480 Err(error) => failed(&error),
481 };
482 (outcome, emissions)
483 }
484 None => (refusal(&identity), Vec::new()),
485 }
486 };
487 let (publication, emissions) = published(outcome, emissions);
488 state.publications.push(publication.clone());
489 Ok((publication, emissions))
490 })?;
491 for emission in &emissions {
492 events::emit(emission);
493 }
494 Ok(publication)
495 }
496
497 /// The same rows [`Vcs::recoverable`] answers with.
498 ///
499 /// This provider is *handed* its preserved work, and what it is handed is work
500 /// nobody published: there is no base here whose history could record a landing
501 /// and no tree to compare content against, so it withholds nothing and the wider
502 /// question has the same answer as the narrower one.
503 fn preserved(&self, scope: Scope) -> Result<Vec<Recoverable>> {
504 self.recoverable(scope)
505 }
506
507 fn recoverable(&self, scope: Scope) -> Result<Vec<Recoverable>> {
508 self.store.with(|state| {
509 let wanted = match &scope {
510 Scope::All => None,
511 Scope::Repo(repo) => Some(
512 state::identity_of(state, repo)
513 .map(|identity| identity.origin.clone())
514 .ok_or_else(|| Error::Invalid {
515 reason: format!(
516 "{repo:?} does not name a repository this provider knows; {}",
517 state::known(state)
518 ),
519 })?,
520 ),
521 };
522 // Newest first, as the real implementation reports them.
523 Ok(state
524 .preserved
525 .iter()
526 .rev()
527 .filter(|row| wanted.as_ref().is_none_or(|key| *key == row.identity))
528 .cloned()
529 .map(|row| held(state, row))
530 .collect())
531 })
532 }
533}
534
535/// Say which live session still holds a row's branch, when one of this provider's
536/// does.
537///
538/// Read here rather than written when the work was preserved, because closing the
539/// session is what ends the hold and a row that had frozen the answer would go on
540/// saying somebody is in there. What makes it *live* is the same thing that makes it
541/// live for the real implementation in-process: the session is open, and the process
542/// that opened it is this one. A row a scenario seeded the answer into keeps it — a
543/// hand-written state says what it means to say.
544fn held(state: &VcsState, row: Recoverable) -> Recoverable {
545 if row.held_by.is_some() {
546 return row;
547 }
548 let holder = state.sessions.iter().find(|session| {
549 session.branch == row.branch.branch
550 && state.session_identities.get(&session.token) == Some(&row.identity)
551 && !state.closed_sessions.contains(&session.token)
552 });
553 Recoverable {
554 held_by: holder.map(|session| HeldBy {
555 token: session.token.clone(),
556 worktree: session.worktree.clone(),
557 holding: Holding::OwnerRunning,
558 }),
559 ..row
560 }
561}
562
563/// The refusal a session this provider never opened meets.
564fn unknown_session(token: &SessionToken) -> Error {
565 Error::Invalid {
566 reason: format!(
567 "no session {:?} is open; `onevcs session open` prints a token",
568 token.0
569 ),
570 }
571}
572
573/// The `owner/name` slug an identity key spells, when it is a GitHub one.
574///
575/// The host is checked rather than assumed, exactly as the real implementation
576/// checks it: a GitLab origin has the same three segments, and a provider that
577/// published one anyway would let a journey pass where the real run answers that
578/// nobody has implemented that host.
579fn slug(identity: &str) -> Option<String> {
580 let mut parts = identity.split('/');
581 let (host, owner, name) = (parts.next()?, parts.next()?, parts.next()?);
582 if parts.next().is_some() || host != DEFAULT_HOST || owner.is_empty() || name.is_empty() {
583 return None;
584 }
585 Some(format!("{owner}/{name}"))
586}
587
588/// Record that a `local-direct` publication landed — record, and nothing more.
589///
590/// Landing one is entirely repository-side work, a squash built detached and
591/// pushed, which this provider does not perform and must not be named as if it
592/// did. What it does is decide the outcome and emit the completion that says so.
593fn record_local_landing(
594 identity: &str,
595 session: &Session,
596 token: &SessionToken,
597) -> (PublishOutcome, Vec<Emission>) {
598 let sha = events::stable_sha(&["publish", &token.0, &session.branch]);
599 let emission = Emission {
600 stream: token.0.clone(),
601 identity: Some(identity.to_owned()),
602 kind: EventKind::MergeCompleted,
603 payload: object(json!({"identity": identity, "sha": sha, "base": session.base})),
604 };
605 (PublishOutcome::Merged(Sha(sha)), vec![emission])
606}
607
608/// Everything one provider publication knows about itself.
609struct Publishing<'a> {
610 slug: &'a str,
611 identity: &'a str,
612 session: &'a Session,
613 policy: MergePolicy,
614 approvals: Approvals,
615 drafts: Drafts,
616 request: &'a PublishRequest,
617 token: &'a SessionToken,
618}
619
620impl Publishing<'_> {
621 /// One event on the session's own stream, under this identity.
622 fn emission(&self, kind: EventKind, payload: Value) -> Emission {
623 Emission {
624 stream: self.token.0.clone(),
625 identity: Some(self.identity.to_owned()),
626 kind,
627 payload: object(payload),
628 }
629 }
630}
631
632/// Publish as a change request: open the session's change on the host, or adopt
633/// the one it already holds, watch its required checks, and then do with it what
634/// the policy asks.
635///
636/// **The draft lifecycle, as next door.** A publication carrying no reason opens its
637/// change request as a draft while the checks run — or, adopting a draft, records it
638/// as awaiting them rather than lifting it on the spot — and once they are green lifts
639/// it before asking for any merge, or keeps it under `change-open` with approvals
640/// required. `drafts: {disabled: true}` opens ready and lifts an adopted draft at once.
641///
642/// **What this provider cannot do is wait.** It has no clock, so each phase of the
643/// watch is **one reading** of the host: a reading that has not settled is the bound
644/// elapsing (`checks-unsettled`), and a draft on which no required check has run is
645/// the grace window elapsing — it is lifted early, and the next reading is the host's
646/// answer after the lift, which is where [`HostState::checks_after_lift`](crate::HostState::checks_after_lift)
647/// comes in. A consumer drives each row of the lifecycle by seeding the reading it
648/// wants rather than by timing one.
649fn publish_as_change(
650 hosting: &dyn Hosting,
651 publishing: &Publishing<'_>,
652 emissions: &mut Vec<Emission>,
653) -> Result<PublishOutcome> {
654 let Publishing {
655 slug,
656 session,
657 policy,
658 request,
659 ..
660 } = *publishing;
661 let host = hosting.for_repo(slug)?;
662 // Who the host believes is calling travels with the change, as it does in the
663 // real publication and for the same reason.
664 let author = host.authenticated_user()?;
665 let lifecycle = !publishing.drafts.disabled.unwrap_or(false);
666 // Checked before anything is opened, as the real publication checks it before
667 // anything is pushed.
668 if lifecycle {
669 grace_seconds()?;
670 }
671 let existing = host.find_changes(&session.branch, &session.base)?;
672 // Whether the host already held it, which is what decides whether there can be a
673 // draft to lift — and, as next door, keeps a publication that opens its own change
674 // request from asking this host anything extra.
675 let adopted = !existing.is_empty();
676 let change = match existing.into_iter().next() {
677 Some(change) => change,
678 None => host.open_change(ChangeSpec {
679 head: session.branch.clone(),
680 base: session.base.clone(),
681 // A requested title has been checked by the conversion that built it, so
682 // this provider cannot accept one the real publication would refuse. The
683 // real implementation takes the subject from the branch's commits when no
684 // title was requested, and a provider has no commits to read — so an
685 // unrequested title names the branch instead.
686 title: request
687 .title
688 .as_deref()
689 .map_or_else(|| format!("Publish {}", session.branch), str::to_owned),
690 // Verbatim, and nothing when there is none — the real publication
691 // composes no body either, so a provider that composed one would be a
692 // consumer's suite proving a change request nobody opens.
693 body: request.body.clone(),
694 // The reason travels to the host and no further, as it does there: what
695 // the host does with it is open the change as a draft.
696 draft: request.draft.clone(),
697 draft_awaiting_checks: lifecycle && request.draft.is_none(),
698 })?,
699 };
700 emissions.push(publishing.emission(
701 EventKind::ChangeOpened,
702 json!({
703 "url": change.url.to_string(),
704 "host": "github",
705 "id": change.id.0,
706 "base": change.base,
707 "author": author,
708 }),
709 ));
710 if let Some(reason) = &request.draft {
711 // The same question the real publication asks, for the same reason: a host
712 // that ignored the request, or a change request already open for review,
713 // would otherwise be reported as held back while it can land.
714 if !host.is_draft(&change)? {
715 return Err(Error::Invalid {
716 reason: format!(
717 "{url} is open for review on the host, and this publication asked for \
718 {asked}. A change that is open can land, so reporting it as a draft would \
719 say the work is held back when nothing is holding it",
720 url = change.url,
721 asked = asked_for(reason),
722 ),
723 });
724 }
725 // The publication record, and the only place the reason is written: the real
726 // implementation writes nothing of it into the change request, and a provider
727 // that did would be a consumer's suite proving a body nobody renders. The
728 // payload is the reason's own serialized form — its `kind` and every field of
729 // that kind — under the change request it holds, exactly as next door.
730 let mut drafted = object(json!({
731 "url": change.url.to_string(),
732 "id": change.id.0,
733 "base": change.base,
734 }));
735 if let serde_json::Value::Object(fields) =
736 serde_json::to_value(reason).expect("a draft reason serializes")
737 {
738 drafted.extend(fields);
739 }
740 emissions.push(publishing.emission(EventKind::ChangeDrafted, Value::Object(drafted)));
741 // Under every policy: a draft is unmergeable in that state, so nothing below
742 // asks this host to merge it.
743 return Ok(PublishOutcome::ChangeDraft(change.url.clone()));
744 }
745 // Three answers about whether the host holds it as a draft, told apart as next
746 // door: a host that was never taught to draft one is holding nothing, and a host
747 // that *could not say* is a refusal rather than a change nobody is holding.
748 // Asked of a change this publication just opened only under the lifecycle, which
749 // asked for a draft: without it, one opened moments ago without a reason is one
750 // nobody drafted, and a host written against the earlier surface is asked nothing.
751 let is_draft = match if lifecycle || adopted {
752 host.is_draft(&change)
753 } else {
754 Ok(false)
755 } {
756 Ok(draft) => draft,
757 Err(Error::NotImplemented { .. }) => false,
758 Err(unreadable) => return Err(unreadable),
759 };
760 let drafted = if lifecycle {
761 if is_draft {
762 emissions.push(publishing.emission(
763 EventKind::ChangeDrafted,
764 json!({
765 "url": change.url.to_string(),
766 "id": change.id.0,
767 "base": change.base,
768 "kind": "awaiting-checks",
769 }),
770 ));
771 }
772 is_draft
773 } else {
774 // Without the lifecycle, publishing without a reason lifts an adopted draft
775 // on the spot, as it always did; one opened moments ago is one nobody drafted.
776 if adopted && is_draft {
777 lift(host.as_ref(), publishing, &change, emissions)?;
778 }
779 false
780 };
781
782 // Every change policy watches — except that `change-auto` on a change nobody
783 // drafted arms the host's own merge and leaves the checks to it, as next door.
784 // Next door's merge watch still refuses a required check that already concluded
785 // red rather than arm a merge over it, so this reading does too; one still
786 // running arms it, and the host's hold is what the outcome reports.
787 let still_draft = if drafted || policy != MergePolicy::ChangeAuto {
788 watch(host.as_ref(), publishing, &change, drafted, emissions)?
789 } else {
790 let answered = host.change_checks(&change)?;
791 let checks: Vec<&Check> = answered.checks.iter().collect();
792 if let Some(failed) = red(&checks, &standings(&checks, &Declared::Unknown)) {
793 return Err(failed);
794 }
795 false
796 };
797 if still_draft {
798 if policy == MergePolicy::ChangeOpen && publishing.approvals == Approvals::Required {
799 emissions.push(publishing.emission(
800 EventKind::DraftKeptForReview,
801 json!({
802 "url": change.url.to_string(),
803 "id": change.id.0,
804 "base": change.base,
805 }),
806 ));
807 return Ok(PublishOutcome::ChangeReviewDraft(change.url.clone()));
808 }
809 // Lifted before any merge is asked for: this host, like GitHub, will neither
810 // merge a draft nor arm its own merge on one.
811 lift(host.as_ref(), publishing, &change, emissions)?;
812 }
813
814 if policy == MergePolicy::ChangeOpen {
815 return Ok(PublishOutcome::ChangeOpen(change.url.clone()));
816 }
817 Ok(match host.merge(&change, policy)? {
818 MergeOutcome::Merged(sha) => {
819 emissions.push(publishing.emission(
820 EventKind::ChangeMerged,
821 json!({"url": change.url.to_string(), "sha": sha.0}),
822 ));
823 emissions.push(publishing.emission(
824 EventKind::MergeCompleted,
825 json!({"identity": publishing.identity, "sha": sha.0}),
826 ));
827 PublishOutcome::Merged(sha)
828 }
829 MergeOutcome::Queued => PublishOutcome::Queued(change.url.clone()),
830 MergeOutcome::Open => PublishOutcome::ChangeOpen(change.url.clone()),
831 })
832}
833
834/// Take a change out of its draft on the host, and record the lift.
835fn lift(
836 host: &dyn RemoteHost,
837 publishing: &Publishing<'_>,
838 change: &ChangeRequest,
839 emissions: &mut Vec<Emission>,
840) -> Result<()> {
841 host.ready_for_review(change)?;
842 emissions.push(publishing.emission(
843 EventKind::DraftLifted,
844 json!({"url": change.url.to_string(), "id": change.id.0}),
845 ));
846 Ok(())
847}
848
849/// Which checks a merge requires, as the host said: nothing, these names, or unknown.
850enum Declared {
851 Nothing,
852 Names(BTreeSet<String>),
853 Unknown,
854}
855
856/// Each required check's name and the state its entries add up to — `None` where the
857/// host reported none — ranked as next door: red, then running, then passed, then no
858/// verdict, then skipped.
859fn standings(checks: &[&Check], declared: &Declared) -> Vec<(String, Option<CheckState>)> {
860 let names: BTreeSet<String> = match declared {
861 Declared::Names(names) => names.clone(),
862 Declared::Nothing => BTreeSet::new(),
863 Declared::Unknown => checks
864 .iter()
865 .filter(|check| check.required)
866 .map(|check| check.name.clone())
867 .collect(),
868 };
869 let rank = |state: CheckState| match state {
870 CheckState::Failed => 0,
871 CheckState::Pending => 1,
872 CheckState::Passed => 2,
873 CheckState::NoVerdict => 3,
874 CheckState::Skipped => 4,
875 };
876 names
877 .into_iter()
878 .map(|name| {
879 let state = checks
880 .iter()
881 .filter(|check| check.name == name)
882 .map(|check| check.state())
883 .min_by_key(|state| rank(*state));
884 (name, state)
885 })
886 .collect()
887}
888
889/// The refusal for the first required check that concluded red.
890fn red(checks: &[&Check], standing: &[(String, Option<CheckState>)]) -> Option<Error> {
891 let (name, _) = standing
892 .iter()
893 .find(|(_, state)| *state == Some(CheckState::Failed))?;
894 let check = checks
895 .iter()
896 .find(|check| &check.name == name && check.state() == CheckState::Failed)?;
897 Some(Error::ChecksFailed {
898 reason: format!(
899 "required check {:?} concluded {}.",
900 check.name,
901 check
902 .conclusion
903 .as_deref()
904 .unwrap_or("without a conclusion")
905 ),
906 })
907}
908
909/// Whether every required check lets the change through: passed, or skipped.
910fn through(standing: &[(String, Option<CheckState>)]) -> bool {
911 standing
912 .iter()
913 .all(|(_, state)| matches!(state, Some(CheckState::Passed | CheckState::Skipped)))
914}
915
916/// The skipped required checks, by name.
917fn skipped(standing: &[(String, Option<CheckState>)]) -> Vec<String> {
918 standing
919 .iter()
920 .filter(|(_, state)| *state == Some(CheckState::Skipped))
921 .map(|(name, _)| name.clone())
922 .collect()
923}
924
925/// The watch, one reading per phase: `Ok(true)` is green on a draft still standing,
926/// `Ok(false)` green on a ready change. See [`publish_as_change`] for what a reading
927/// stands for.
928fn watch(
929 host: &dyn RemoteHost,
930 publishing: &Publishing<'_>,
931 change: &ChangeRequest,
932 drafted: bool,
933 emissions: &mut Vec<Emission>,
934) -> Result<bool> {
935 // `unread`: the requirement was read from the host's own per-check marking because
936 // its declaration could not be read, which the record says, as next door.
937 let settled = |emissions: &mut Vec<Emission>, skipped: Vec<String>, unread: Option<&str>| {
938 let verdict = if skipped.is_empty() {
939 "passed"
940 } else {
941 "passed-with-skipped"
942 };
943 let mut payload = json!({
944 "url": change.url.to_string(),
945 "id": change.id.0,
946 "head": change.head_sha.0,
947 "verdict": verdict,
948 "skipped": skipped,
949 });
950 if let Some(because) = unread {
951 eprintln!(
952 "onevcs: warning: the required checks on {} were read from the host's own \
953 per-check marking, because the declaration could not be read: {because}",
954 change.url
955 );
956 payload["requirement"] = json!({"read_from": "host-marking", "because": because});
957 }
958 emissions.push(publishing.emission(EventKind::ChecksSettled, payload));
959 };
960 let unsettled = |standing: &[(String, Option<CheckState>)]| Error::ChecksUnsettled {
961 reason: format!(
962 "the host had not settled its required checks on {}; still unsettled: {}",
963 change.url,
964 standing
965 .iter()
966 .filter(|(_, state)| !matches!(state, Some(CheckState::Passed)))
967 .map(|(name, _)| format!("{name:?}"))
968 .collect::<Vec<_>>()
969 .join(", ")
970 ),
971 };
972 let answered = host.change_checks(change)?;
973 let complete = answered.complete();
974 let checks: Vec<&Check> = answered.checks.iter().collect();
975
976 if !drafted {
977 let standing = standings(&checks, &Declared::Unknown);
978 if let Some(failed) = red(&checks, &standing) {
979 return Err(failed);
980 }
981 if !through(&standing) {
982 return Err(unsettled(&standing));
983 }
984 settled(emissions, skipped(&standing), None);
985 return Ok(false);
986 }
987
988 // The draft: what the host requires, and a skipped check has not run.
989 let (mut declared, mut unread) = match host.required_checks_on(&change.base) {
990 Ok(answer) if answer.checks.is_empty() && answer.complete() => (Declared::Nothing, None),
991 Ok(answer) if !answer.checks.is_empty() => (Declared::Names(answer.checks), None),
992 Ok(_) => (
993 Declared::Unknown,
994 Some(format!(
995 "the host answered only in part about which checks a merge into {} requires",
996 change.base
997 )),
998 ),
999 Err(refused) => (
1000 Declared::Unknown,
1001 Some(format!(
1002 "the host would not say which checks a merge into {} requires: {refused}",
1003 change.base
1004 )),
1005 ),
1006 };
1007 if matches!(declared, Declared::Unknown)
1008 && complete
1009 && !checks.is_empty()
1010 && checks.iter().all(|check| !check.required)
1011 {
1012 declared = Declared::Nothing;
1013 unread = None;
1014 }
1015 if matches!(declared, Declared::Nothing) {
1016 settled(emissions, Vec::new(), None);
1017 return Ok(true);
1018 }
1019 let unread = unread.as_deref();
1020 let standing = standings(&checks, &declared);
1021 if let Some(failed) = red(&checks, &standing) {
1022 return Err(failed);
1023 }
1024 if !standing.is_empty()
1025 && standing
1026 .iter()
1027 .all(|(_, state)| *state == Some(CheckState::Passed))
1028 {
1029 settled(emissions, Vec::new(), unread);
1030 return Ok(true);
1031 }
1032 let not_run: Vec<String> = standing
1033 .iter()
1034 .filter(|(_, state)| matches!(state, None | Some(CheckState::Skipped)))
1035 .map(|(name, _)| name.clone())
1036 .collect();
1037 let running = standing
1038 .iter()
1039 .any(|(_, state)| matches!(state, Some(CheckState::Pending | CheckState::NoVerdict)));
1040 let unseen = matches!(declared, Declared::Unknown) && standing.is_empty();
1041 // The grace window, elapsed: where the host would not say what it requires and
1042 // checks ran on the draft with none skipped, its own marking is the answer.
1043 if unseen
1044 && !checks.is_empty()
1045 && checks
1046 .iter()
1047 .all(|check| check.state() != CheckState::Skipped)
1048 {
1049 settled(emissions, Vec::new(), unread);
1050 return Ok(true);
1051 }
1052 if running || (not_run.is_empty() && !unseen) {
1053 return Err(unsettled(&standing));
1054 }
1055
1056 // The grace window elapsed with nothing required run: lift early, say so, and read
1057 // the host again as a ready change whose checks must run after the lift.
1058 let snapshot: Vec<Check> = answered.checks.clone();
1059 lift(host, publishing, change, emissions)?;
1060 let warned = publishing.drafts.warn_on_early_lift.unwrap_or(true);
1061 let grace = grace_seconds()?;
1062 if warned {
1063 eprintln!(
1064 "onevcs: warning: {} was lifted out of its draft before its required checks ran on \
1065 it ({}): none had run within {grace}s, which is what a workflow that skips drafts \
1066 looks like",
1067 change.url,
1068 not_run.join(", ")
1069 );
1070 }
1071 emissions.push(publishing.emission(
1072 EventKind::DraftLiftedEarly,
1073 json!({
1074 "url": change.url.to_string(),
1075 "id": change.id.0,
1076 "base": change.base,
1077 "awaited": not_run,
1078 "grace_seconds": grace,
1079 "warned": warned,
1080 }),
1081 ));
1082 let after = host.change_checks(change)?.checks;
1083 let from_the_draft = |check: &Check| !ran_after_the_lift(check, &snapshot);
1084 let counted: Vec<&Check> = after
1085 .iter()
1086 .filter(|check| !(from_the_draft(check) && check.state() == CheckState::Skipped))
1087 .collect();
1088 let standing = standings(&counted, &declared);
1089 if let Some(failed) = red(&counted, &standing) {
1090 return Err(failed);
1091 }
1092 let rerun = after.iter().any(|check| {
1093 !from_the_draft(check)
1094 && (standing.is_empty() || standing.iter().any(|(name, _)| *name == check.name))
1095 });
1096 if !rerun {
1097 return Err(Error::ChecksUnsettled {
1098 reason: format!(
1099 "the required checks on {} did not re-run after its draft was lifted: no run \
1100 the host attached after the lift appeared, and the runs from while it was a \
1101 draft were skipped, which is not a verdict. The likely cause is a workflow that \
1102 does not trigger on `ready_for_review`",
1103 change.url
1104 ),
1105 });
1106 }
1107 if !through(&standing) {
1108 return Err(unsettled(&standing));
1109 }
1110 settled(emissions, skipped(&standing), unread);
1111 Ok(false)
1112}
1113
1114/// Whether `check` is a run the host attached after the lift rather than the draft's
1115/// run of it in `snapshot`, decided as the real publication decides it: by the run's
1116/// own identity, never by its conclusion or its status. A check the draft never
1117/// reported is new, and one it did report is new only where it reports a `started_at`
1118/// no draft-era run of that check reported, running or settled — so a consumer seeds a
1119/// re-run by giving it a start of its own.
1120fn ran_after_the_lift(check: &Check, snapshot: &[Check]) -> bool {
1121 let earlier: Vec<&Check> = snapshot
1122 .iter()
1123 .filter(|seen| seen.name == check.name)
1124 .collect();
1125 if earlier.is_empty() {
1126 return true;
1127 }
1128 check.started_at.as_ref().is_some_and(|started| {
1129 earlier
1130 .iter()
1131 .all(|seen| seen.started_at.as_ref() != Some(started))
1132 })
1133}
1134
1135/// The default grace window of the real publication, which is `onevcs`'s own and
1136/// private to it. A copy, so it is gated: `lifecycle.rs`'s
1137/// `the_grace_window_an_early_lift_records_is_the_one_onevcs_defaults_to` reads the
1138/// constant out of `onevcs`'s source and holds this to it.
1139const DEFAULT_DRAFT_GRACE_SECONDS: f64 = 120.0;
1140
1141/// The knob a real publication waits its grace window by.
1142const DRAFT_GRACE_ENV: &str = "ONEVCS_DRAFT_CHECKS_GRACE_SECONDS";
1143
1144/// The grace window a real publication would have waited out, as the operator set it
1145/// or the default — recorded on `draft-lifted-early` so the payload reads as the real
1146/// one does, though nothing here waits.
1147///
1148/// Validated as the real publication validates it, and refused by name where it is
1149/// not a finite number of seconds above zero: a provider that quietly took the default
1150/// would let a consumer's suite pass on a setting the real one refuses.
1151fn grace_seconds() -> Result<f64> {
1152 let Some(raw) = std::env::var_os(DRAFT_GRACE_ENV) else {
1153 return Ok(DEFAULT_DRAFT_GRACE_SECONDS);
1154 };
1155 let raw = raw.to_string_lossy().into_owned();
1156 let seconds: f64 = raw.trim().parse().map_err(|_| Error::Invalid {
1157 reason: format!("{DRAFT_GRACE_ENV} must be a number of seconds, not {raw:?}"),
1158 })?;
1159 if !seconds.is_finite() || seconds <= 0.0 {
1160 return Err(Error::Invalid {
1161 reason: format!(
1162 "{DRAFT_GRACE_ENV} must be a finite number of seconds above zero, not {raw:?}"
1163 ),
1164 });
1165 }
1166 Ok(seconds)
1167}
1168
1169/// The draft a publication asked for, as the two refusals next door spell it: "a
1170/// draft awaiting <repository> <target>", or "a draft held by the session that opened
1171/// it (<why>)". A restatement, so the refusals here read as the real ones do.
1172fn asked_for(reason: &DraftReason) -> String {
1173 match reason {
1174 DraftReason::AwaitingRelease {
1175 awaiting, target, ..
1176 } => format!("a draft awaiting {awaiting} {target}"),
1177 DraftReason::Held { because } => {
1178 format!("a draft held by the session that opened it ({because})")
1179 }
1180 }
1181}
1182
1183/// What a publication answers for an identity no change request can be opened
1184/// against.
1185///
1186/// Two failures rather than one, as the real implementation keeps them: an
1187/// identity that is not hosted at all is asking for the wrong policy, while a
1188/// hosted one on a host this build does not speak for is asking for an
1189/// implementation that has not arrived.
1190fn refusal(identity: &str) -> PublishOutcome {
1191 failed(&if identity.split('/').count() == 3 {
1192 Error::NotImplemented {
1193 operation: "RemoteHost for a host other than github.com",
1194 }
1195 } else {
1196 Error::Invalid {
1197 reason: format!(
1198 "identity {identity:?} is not a hosted repository, so it cannot publish a \
1199 change request; a local identity publishes with local-direct"
1200 ),
1201 }
1202 })
1203}
1204
1205/// One failure, as the outcome a publication that started and did not land is.
1206fn failed(error: &Error) -> PublishOutcome {
1207 PublishOutcome::Failed {
1208 // Through the crate's own mapping, so the kind a caller branches on is the
1209 // one the real implementation would report for the same failure.
1210 kind: FailureKind::of(error),
1211 reason: error.to_string(),
1212 // A provider has no execution checkout, so there is nowhere a branch could
1213 // have been handed back to and nothing to report about one.
1214 retained: None,
1215 }
1216}
1217
1218/// The argv that lands a preserved branch, as `recoverable` reports it.
1219///
1220/// Both verbs take the branch by name and the repository by path, the way the real
1221/// implementation reports them: the provenance decides which one, and neither
1222/// depends on the directory a reader happens to be standing in.
1223// llmlint: ignore[names_match_behavior] named for the public
1224// `Recoverable::recover_command` field it fills, which `onevcs` publishes and this
1225// crate must answer identically; a provider whose helper were named otherwise would
1226// read as filling something else.
1227fn recover_command(branch: &str, checkout: &Path, provenance: Provenance) -> Vec<String> {
1228 let verb = match provenance {
1229 Provenance::IncompleteStep => "recover",
1230 Provenance::Complete => "publish-branch",
1231 };
1232 vec![
1233 "onevcs".to_owned(),
1234 verb.to_owned(),
1235 branch.to_owned(),
1236 "--repo".to_owned(),
1237 checkout.display().to_string(),
1238 ]
1239}
1240
1241/// How a provenance kind is spelled in an event payload.
1242fn spell(provenance: Provenance) -> &'static str {
1243 match provenance {
1244 Provenance::Complete => "complete",
1245 Provenance::IncompleteStep => "incomplete-step",
1246 }
1247}
1248
1249fn object(value: Value) -> Map<String, Value> {
1250 value.as_object().cloned().unwrap_or_default()
1251}