Skip to main content

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::testing::EmissionResult;
21use onevcs::{
22    ArmedMerge, ChangeRequest, ChangeSpec, Check, CheckState, DraftReason, Error, EventKind,
23    FailureKind, HeldBy, Holding, Hosting, Identity, Landed, Lifecycle, MergeOutcome, MergePolicy,
24    PreservedBranch, Provenance, Publication, PublishOutcome, PublishRequest, Recoverable,
25    RemoteHost, Result, Scope, Session, SessionRecord, SessionRequest, SessionToken, Sha, Vcs,
26};
27
28use crate::events::{self, Emission};
29use crate::remote::DEFAULT_HOST;
30use crate::state::{self, VcsState};
31use crate::store::{FileStore, MemoryStore, Store};
32
33/// The base a session is cut from when the request names none.
34///
35/// The real implementation asks the origin for its default branch, and this
36/// provider has no origin to ask.
37pub const DEFAULT_BASE: &str = "main";
38
39/// The policy a publication takes when nothing was seeded and nothing requested.
40///
41/// The policy the contract's own `default:` names, which is what the real
42/// implementation resolves to for a registry with no rules file.
43pub const DEFAULT_PUBLICATION: MergePolicy = MergePolicy::ChangeOpen;
44
45/// The approvals a publication takes when nothing was seeded: the contract's own
46/// `default:`, as [`DEFAULT_PUBLICATION`] is.
47pub const DEFAULT_APPROVALS: Approvals = Approvals::Required;
48
49/// The repository side of a run, over whichever store holds its state.
50///
51/// The two flavours below are this one behaviour with a different store under it,
52/// so neither can learn something the other does not know.
53#[derive(Debug)]
54pub struct Repository<T> {
55    store: T,
56    root: PathBuf,
57    trees: Trees,
58}
59
60/// What a session's worktree path means.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62enum Trees {
63    /// The path is named and nothing is created there: the in-memory provider
64    /// touches no filesystem beyond the event stream.
65    Named,
66    /// The directory is created, so a journey has somewhere to write work.
67    Created,
68}
69
70/// A repository provider that keeps its state in this process: no disk, no
71/// visibility to a second process, and the fastest of the two.
72///
73/// Its sessions name a worktree under the system temporary directory and **do not
74/// create it** — nothing here touches the filesystem except the event stream, which
75/// is the record a journey reads.
76pub type MemoryVcs = Repository<MemoryStore<VcsState>>;
77
78/// A repository provider that keeps its state in one JSON document, so several
79/// `onevcs` invocations see one another's effects.
80///
81/// Its sessions name a worktree beside that document **and create it**, so a
82/// journey that writes a file into a session's tree has somewhere to write it.
83pub type FileVcs = Repository<FileStore<VcsState>>;
84
85impl MemoryVcs {
86    /// A repository provider knowing nothing.
87    pub fn new() -> Self {
88        Self::seeded(VcsState::default())
89    }
90
91    /// A repository provider that starts from a scenario.
92    pub fn seeded(state: VcsState) -> Self {
93        Self {
94            store: MemoryStore::new(state),
95            root: std::env::temp_dir().join("onevcs-testing-memory"),
96            trees: Trees::Named,
97        }
98    }
99
100    /// Everything it knows.
101    pub fn state(&self) -> VcsState {
102        self.store
103            .snapshot()
104            .expect("an in-memory store always answers")
105    }
106}
107
108impl Default for MemoryVcs {
109    fn default() -> Self {
110        Self::new()
111    }
112}
113
114impl FileVcs {
115    /// A repository provider keeping its state at `path`: whatever is already
116    /// there, or nothing.
117    ///
118    /// Attaching rather than replacing, so a second provider over the same path
119    /// picks up what the first one left — which is what a journey driving several
120    /// invocations reaches for this flavour to get.
121    pub fn create(path: impl Into<PathBuf>) -> Result<Self> {
122        Self::over(FileStore::attach(path, &VcsState::default())?)
123    }
124
125    /// A repository provider that starts from a scenario, keeping its state at
126    /// `path` and replacing whatever was there.
127    pub fn seeded(path: impl Into<PathBuf>, state: VcsState) -> Result<Self> {
128        Self::over(FileStore::replace(path, &state)?)
129    }
130
131    fn over(store: FileStore<VcsState>) -> Result<Self> {
132        let root = store
133            .path()
134            .parent()
135            .filter(|parent| !parent.as_os_str().is_empty())
136            .unwrap_or_else(|| Path::new("."))
137            .join("worktrees");
138        Ok(Self {
139            store,
140            root,
141            trees: Trees::Created,
142        })
143    }
144
145    /// Everything it knows, read back out of its document.
146    pub fn state(&self) -> Result<VcsState> {
147        self.store.snapshot()
148    }
149}
150
151impl<T: Store<VcsState>> Vcs for Repository<T> {
152    fn resolve_identity(&self, origin_or_path: &str) -> Result<Identity> {
153        self.store.with(|state| {
154            state::identity_of(state, origin_or_path)
155                .cloned()
156                .ok_or_else(|| Error::Invalid {
157                    reason: format!(
158                        "{origin_or_path:?} does not name a repository this provider knows; {}",
159                        state::known(state)
160                    ),
161                })
162        })
163    }
164
165    fn open_session(&self, req: SessionRequest) -> Result<Session> {
166        let root = self.root.clone();
167        let (session, emission) = self.store.with(|state| {
168            let identity = state::identity_of(state, &req.repo)
169                .cloned()
170                .ok_or_else(|| Error::Invalid {
171                    reason: format!(
172                        "{:?} does not name a repository this provider knows; {}",
173                        req.repo,
174                        state::known(state)
175                    ),
176                })?;
177            // Consecutive and predictable, so a journey can name the session it is
178            // about to open — the one thing a digest-shaped token takes away.
179            let token = SessionToken(format!("s-testing-{}", state.sessions.len() + 1));
180            let run_root = root.join(&token.0);
181            // Both names are checked before they are recorded, because both go on to
182            // spell a ref for whoever holds the session — and a provider that
183            // accepted a name git refuses would let a journey pass where the real
184            // run stops.
185            let base = req.base.clone().unwrap_or_else(|| DEFAULT_BASE.to_owned());
186            state::named_branch(&base, "the base")?;
187            for (key, value) in &req.labels {
188                state::label_pair(key, value)?;
189            }
190            // Never a conflict: a provider has no base to merge into a continued
191            // branch, so every session it opens opens clean — which is also why its
192            // `session-opened` below never carries the `conflict` key the real one
193            // writes over a merge it left in progress, and why `refuse_conflicts`
194            // has nothing here to refuse.
195            let session = Session {
196                worktree: run_root.join("worktree"),
197                branch: state::requested_branch(&req, &token)?,
198                base,
199                token: token.clone(),
200                conflict: None,
201            };
202            state.sessions.push(session.clone());
203            state
204                .session_identities
205                .insert(token.clone(), identity.origin.clone());
206            if !req.labels.is_empty() {
207                state
208                    .session_labels
209                    .insert(token.clone(), req.labels.clone());
210            }
211            let emission = Emission {
212                stream: token.0.clone(),
213                identity: Some(identity.origin.clone()),
214                kind: EventKind::SessionOpened,
215                result: EmissionResult::Success,
216                payload: object(json!({
217                    "token": token.0,
218                    "identity": identity.origin,
219                    "branch": session.branch,
220                    "base": session.base,
221                    "worktree": session.worktree.display().to_string(),
222                    // Synthetic, and named anyway: a consumer reading this event
223                    // reads the same keys whichever implementation produced it.
224                    "clone": run_root.join("clone").display().to_string(),
225                    "execution_checkout": run_root.join("checkout").display().to_string(),
226                    "publication_checkout": run_root.join("checkout").display().to_string(),
227                    // A run root of its own, which is the one placement a provider
228                    // that keeps no pool can make — and the one every real session
229                    // gets on a host that configures none.
230                    "placement": {"kind": "run-root"},
231                })),
232            };
233            Ok((session, emission))
234        })?;
235        if self.trees == Trees::Created {
236            std::fs::create_dir_all(&session.worktree).map_err(|e| Error::Invalid {
237                reason: format!("cannot create {}: {e}", session.worktree.display()),
238            })?;
239        }
240        events::emit(&emission);
241        Ok(session)
242    }
243
244    fn adopt_session(&self, token: SessionToken) -> Result<Session> {
245        self.store.with(|state| {
246            state::session_of(state, &token)
247                .cloned()
248                .ok_or_else(|| Error::Invalid {
249                    reason: format!(
250                        "no session {:?} is open; `onevcs session open` prints a token",
251                        token.0
252                    ),
253                })
254        })
255    }
256
257    fn preserve(&self, s: &Session, provenance: Provenance) -> Result<PreservedBranch> {
258        let (branch, emission) = self.store.with(|state| {
259            let identity = state::identity_for(state, &s.token)?;
260            let branch = PreservedBranch {
261                branch: s.branch.clone(),
262                base: s.base.clone(),
263                provenance,
264                change_url: None,
265                change_base: None,
266            };
267            let row = Recoverable {
268                identity: identity.clone(),
269                branch: branch.clone(),
270                checkout: s.worktree.clone(),
271                tip: None,
272                stopped_because: format!("session {} was left open", s.token.0),
273                recover_command: recover_command(&s.branch, &s.worktree, provenance),
274                // Preserved work this provider was handed is work nobody published:
275                // there is no base here whose history could record a landing, and no
276                // tree to compare content against, so the one answer it can give is
277                // the one it knows.
278                landed: Landed::No,
279                // Both answered where the row is *read* rather than frozen in here:
280                // whether a session still holds its branch is a fact about the session
281                // now, and there is no tree here to count a diff's lines against.
282                held_by: None,
283                net_negative: None,
284                // The session that preserved it is the session that answers for it,
285                // and what it was opened with is what the row carries.
286                session: Some(s.token.clone()),
287                labels: state
288                    .session_labels
289                    .get(&s.token)
290                    .cloned()
291                    .unwrap_or_default(),
292                // A preservation onto an origin is a thing only a real repository can
293                // do — there is no git here and no remote to push to — so this provider
294                // answers what a scenario wrote down and nothing else. A hand-written
295                // state may still seed one, exactly as it seeds a hold.
296                on_origin: None,
297                // Whether a branch may be retired is decided from every copy of it this
298                // host holds, its base on the origin, and this host's records — none of
299                // which a provider has — so this provider classifies nothing, and a row
300                // it answers carries no classification rather than an invented one.
301                retirement: None,
302            };
303            // Preserving the same branch twice replaces its row rather than listing
304            // it twice, which is what `recoverable` does across the checkouts a
305            // branch is reachable from.
306            state.preserved.retain(|kept| {
307                kept.identity != row.identity || kept.branch.branch != row.branch.branch
308            });
309            state.preserved.push(row);
310            let emission = Emission {
311                stream: s.token.0.clone(),
312                // No identity label, because the real implementation carries none
313                // here: the label is stamped where a session is opened, and work is
314                // preserved against a stream a later process opened fresh. Claiming
315                // it would be drift in the direction that looks like more
316                // information.
317                identity: None,
318                kind: EventKind::CommitPreserved,
319                result: EmissionResult::Success,
320                payload: object(json!({
321                    "branch": s.branch,
322                    "sha": events::stable_sha(&[&s.token.0, &s.branch, spell(provenance)]),
323                    "provenance": spell(provenance),
324                })),
325            };
326            Ok((branch, emission))
327        })?;
328        events::emit(&emission);
329        Ok(branch)
330    }
331
332    fn session(&self, token: &SessionToken) -> Result<SessionRecord> {
333        self.store.with(|state| {
334            let session = state::session_of(state, token)
335                .cloned()
336                .ok_or_else(|| unknown_session(token))?;
337            let identity = state::identity_for(state, token)?;
338            // Read off what was preserved rather than remembered separately: the
339            // real implementation reads the branch, so a session whose work was
340            // preserved behind an incomplete-step marker answers the same here.
341            let provenance = state
342                .preserved
343                .iter()
344                .find(|row| row.identity == identity && row.branch.branch == session.branch)
345                .map_or(Provenance::Complete, |row| row.branch.provenance);
346            Ok(SessionRecord {
347                lifecycle: if state.closed_sessions.contains(token) {
348                    Lifecycle::Closed
349                } else {
350                    Lifecycle::Open
351                },
352                session,
353                identity,
354                provenance,
355                // A retry link is a fact about run clones under a state root, and
356                // this provider keeps none: its sessions are records in a store, so
357                // there is no second copy of a branch for one to tell apart.
358                retried_by: None,
359            })
360        })
361    }
362
363    fn close_session(&self, token: &SessionToken) -> Result<Session> {
364        self.store.with(|state| {
365            let session = state::session_of(state, token)
366                .cloned()
367                .ok_or_else(|| unknown_session(token))?;
368            let emission = Emission {
369                stream: token.0.clone(),
370                // No identity label, because the real implementation carries none
371                // here: closing opens the stream fresh, and the label is stamped
372                // where a session is opened.
373                identity: None,
374                kind: EventKind::SessionClosed,
375                result: EmissionResult::Success,
376                payload: object(json!({"token": token.0, "branch": session.branch})),
377            };
378            // Match the real provider's observable ordering: a follower reads the
379            // stream before consulting this state, so the terminator must exist
380            // before `Closed` can be returned to that concurrent reader.
381            events::emit(&emission);
382            state.closed_sessions.insert(token.clone());
383            Ok(session)
384        })
385    }
386
387    /// Publish a session's branch, as far as a provider honestly can.
388    ///
389    /// The host side is performed rather than described: a change request is really
390    /// opened against the [`Hosting`] this was handed, really adopted when one is
391    /// already open, and really merged under the policy — so the six host methods
392    /// are exercised and what the host recorded is what a journey reads back.
393    ///
394    /// The repository side is not, and none of it is claimed. There is no origin to
395    /// fetch from, no tree to build in, no push, and no lock to queue behind, so no
396    /// `fetch`, `push`, `lock-wait`, `lock-acquired`, or `merge-queued` event is
397    /// emitted. What is emitted is what was decided: the change that was opened, and
398    /// the merge that landed.
399    fn publish(
400        &self,
401        token: &SessionToken,
402        request: &PublishRequest,
403        hosting: &dyn Hosting,
404    ) -> Result<Publication> {
405        let (publication, emissions) = self.store.with(|state| {
406            let session = state::session_of(state, token)
407                .cloned()
408                .ok_or_else(|| unknown_session(token))?;
409            let identity = state::identity_for(state, token)?;
410            // The publication rule the crate next door states, applied rather than
411            // restated: a reason that would not render as itself is refused here
412            // exactly where the real implementation refuses it, before any host is
413            // asked and before anything is recorded.
414            if let Some(reason) = &request.draft {
415                reason.checked()?;
416            }
417            let resolved = state.policy.unwrap_or(DEFAULT_PUBLICATION);
418            let policy = match request.policy {
419                Some(requested) => resolved.narrow(requested)?,
420                None => resolved,
421            };
422            let published = |outcome, emissions| {
423                (
424                    Publication {
425                        session: token.clone(),
426                        branch: session.branch.clone(),
427                        policy,
428                        outcome,
429                    },
430                    emissions,
431                )
432            };
433            // A publication that defers its checks asks the host nothing: everything it
434            // decides is decided here, from what this provider holds, and it is refused
435            // whole — before anything is recorded — exactly where the real one refuses.
436            if request.defer_checks || request.adopt.is_some() {
437                let approvals = state.approvals.unwrap_or(DEFAULT_APPROVALS);
438                let adopt = deferred(
439                    state, &session, &identity, request, policy, approvals, token,
440                )?;
441                let number = adopt
442                    .path_segments()
443                    .and_then(|mut segments| segments.nth(3))
444                    .unwrap_or_default()
445                    .to_owned();
446                let emission = Emission {
447                    stream: token.0.clone(),
448                    identity: Some(identity.clone()),
449                    kind: EventKind::ChangeOpened,
450                    result: EmissionResult::Success,
451                    payload: object(json!({
452                        "url": adopt.to_string(),
453                        "host": "github",
454                        "id": number,
455                        "base": session.base,
456                    })),
457                };
458                let (publication, emissions) = published(
459                    PublishOutcome::ChangeReviewDraftUnchecked(adopt),
460                    vec![emission],
461                );
462                state.publications.push(publication.clone());
463                return Ok((publication, emissions));
464            }
465            // A session that has already landed has nothing the base does not carry,
466            // which is what the real implementation reports for the same reason. One
467            // whose change request is merely open or queued has *not* landed, and
468            // publishing it again adopts that change rather than opening a second —
469            // so it falls through to the host, as it does there.
470            if state.publications.iter().any(|earlier| {
471                earlier.session == *token && matches!(earlier.outcome, PublishOutcome::Merged(_))
472            }) {
473                let (publication, emissions) =
474                    published(PublishOutcome::NothingToPublish, Vec::new());
475                state.publications.push(publication.clone());
476                return Ok((publication, emissions));
477            }
478
479            let (outcome, emissions) = if policy == MergePolicy::LocalDirect {
480                // The same refusal the real publication makes at the same boundary: a
481                // local-direct publication opens no change request, so there is
482                // nothing to draft and the work would land carrying the very pin the
483                // draft exists to hold back.
484                match &request.draft {
485                    Some(reason) => (
486                        failed(&Error::Invalid {
487                            reason: format!(
488                                "{branch:?} was asked to publish as {asked}, and this identity \
489                                 publishes with local-direct, which opens no change request at \
490                                 all",
491                                branch = session.branch,
492                                asked = asked_for(reason),
493                            ),
494                        }),
495                        Vec::new(),
496                    ),
497                    None => record_local_landing(&identity, &session, token),
498                }
499            } else {
500                match slug(&identity) {
501                    Some(slug) => {
502                        // Recorded as it happens, as next door: a publication that
503                        // fails after opening, drafting or lifting keeps that record.
504                        let mut emissions = Vec::new();
505                        let outcome = match publish_as_change(
506                            hosting,
507                            &Publishing {
508                                slug: &slug,
509                                identity: &identity,
510                                session: &session,
511                                policy,
512                                approvals: state.approvals.unwrap_or(DEFAULT_APPROVALS),
513                                drafts: state.drafts.unwrap_or_default(),
514                                request,
515                                token,
516                            },
517                            &mut emissions,
518                        ) {
519                            Ok(outcome) => outcome,
520                            // Once a publication has started, what stops it is an outcome
521                            // rather than a refusal — the same split the real
522                            // implementation keeps, so a caller reads one shape.
523                            Err(error) => failed(&error),
524                        };
525                        (outcome, emissions)
526                    }
527                    None => (refusal(&identity), Vec::new()),
528                }
529            };
530            let (publication, emissions) = published(outcome, emissions);
531            state.publications.push(publication.clone());
532            Ok((publication, emissions))
533        })?;
534        for emission in &emissions {
535            events::emit(emission);
536        }
537        Ok(publication)
538    }
539
540    /// The same rows [`Vcs::recoverable`] answers with.
541    ///
542    /// This provider is *handed* its preserved work, and what it is handed is work
543    /// nobody published: there is no base here whose history could record a landing
544    /// and no tree to compare content against, so it withholds nothing and the wider
545    /// question has the same answer as the narrower one.
546    fn preserved(&self, scope: Scope) -> Result<Vec<Recoverable>> {
547        self.recoverable(scope)
548    }
549
550    fn recoverable(&self, scope: Scope) -> Result<Vec<Recoverable>> {
551        self.store.with(|state| {
552            let wanted = match &scope {
553                Scope::All => None,
554                Scope::Repo(repo) => Some(
555                    state::identity_of(state, repo)
556                        .map(|identity| identity.origin.clone())
557                        .ok_or_else(|| Error::Invalid {
558                            reason: format!(
559                                "{repo:?} does not name a repository this provider knows; {}",
560                                state::known(state)
561                            ),
562                        })?,
563                ),
564            };
565            // Newest first, as the real implementation reports them.
566            Ok(state
567                .preserved
568                .iter()
569                .rev()
570                .filter(|row| wanted.as_ref().is_none_or(|key| *key == row.identity))
571                .cloned()
572                .map(|row| held(state, row))
573                .collect())
574        })
575    }
576}
577
578/// Say which live session still holds a row's branch, when one of this provider's
579/// does.
580///
581/// Read here rather than written when the work was preserved, because closing the
582/// session is what ends the hold and a row that had frozen the answer would go on
583/// saying somebody is in there. What makes it *live* is the same thing that makes it
584/// live for the real implementation in-process: the session is open, and the process
585/// that opened it is this one. A row a scenario seeded the answer into keeps it — a
586/// hand-written state says what it means to say.
587fn held(state: &VcsState, row: Recoverable) -> Recoverable {
588    if row.held_by.is_some() {
589        return row;
590    }
591    let holder = state.sessions.iter().find(|session| {
592        session.branch == row.branch.branch
593            && state.session_identities.get(&session.token) == Some(&row.identity)
594            && !state.closed_sessions.contains(&session.token)
595    });
596    Recoverable {
597        held_by: holder.map(|session| HeldBy {
598            token: Some(session.token.clone()),
599            worktree: session.worktree.clone(),
600            holding: Holding::OwnerRunning,
601        }),
602        ..row
603    }
604}
605
606/// The refusal a session this provider never opened meets.
607fn unknown_session(token: &SessionToken) -> Error {
608    Error::Invalid {
609        reason: format!(
610            "no session {:?} is open; `onevcs session open` prints a token",
611            token.0
612        ),
613    }
614}
615
616/// The `owner/name` slug an identity key spells, when it is a GitHub one.
617///
618/// The host is checked rather than assumed, exactly as the real implementation
619/// checks it: a GitLab origin has the same three segments, and a provider that
620/// published one anyway would let a journey pass where the real run answers that
621/// nobody has implemented that host.
622fn slug(identity: &str) -> Option<String> {
623    let mut parts = identity.split('/');
624    let (host, owner, name) = (parts.next()?, parts.next()?, parts.next()?);
625    if parts.next().is_some() || host != DEFAULT_HOST || owner.is_empty() || name.is_empty() {
626        return None;
627    }
628    Some(format!("{owner}/{name}"))
629}
630
631/// The draft a deferred publication pushes into, held to what the real implementation
632/// holds it to: `adopt` named, no draft reason, a policy that keeps the change request
633/// for review, a change request of the session's repository, and this provider's newest
634/// record of it — a publication of it, which is the record a repository side keeps here
635/// — naming the session's branch. With no record at all the refusal is
636/// [`Error::UnrecordedChangeHead`], as there. A standings read's record is the real
637/// state root's, which a provider does not read.
638fn deferred(
639    state: &VcsState,
640    session: &Session,
641    identity: &str,
642    request: &PublishRequest,
643    policy: MergePolicy,
644    approvals: Approvals,
645    token: &SessionToken,
646) -> Result<url::Url> {
647    let refused = |reason: String| Error::Invalid { reason };
648    let adopt = match (request.defer_checks, &request.adopt) {
649        (true, Some(adopt)) => adopt,
650        (true, None) => {
651            return Err(refused(
652                "--defer-checks publishes into a draft that is already open: name it with \
653                 --adopt <URL>. Nothing was pushed"
654                    .to_owned(),
655            ))
656        }
657        (false, _) => {
658            return Err(refused(
659                "--adopt names the draft a --defer-checks publication pushes into, and this \
660                 publication does not defer its checks. Nothing was pushed"
661                    .to_owned(),
662            ))
663        }
664    };
665    if request.draft.is_some() {
666        return Err(refused(
667            "--defer-checks pushes into a draft held for review, and a draft reason asks for a \
668             different draft. Nothing was pushed"
669                .to_owned(),
670        ));
671    }
672    if policy != MergePolicy::ChangeOpen || approvals != Approvals::Required {
673        return Err(refused(format!(
674            "--defer-checks is for a change request held for review — a change-open identity \
675             whose approvals are required — and {identity} does not hold this one for review. \
676             Nothing was pushed"
677        )));
678    }
679    // Parsed as a change request's URL — `https://<host>/<owner>/<name>/pull/<number>`
680    // with a number of digits — and held to the session's repository, as the real one
681    // holds it, rather than matched by its prefix.
682    let segments: Vec<&str> = adopt
683        .path_segments()
684        .map(|segments| segments.collect())
685        .unwrap_or_default();
686    let named = match segments.as_slice() {
687        [owner, name, "pull", number, ..]
688            if adopt.scheme() == "https"
689                && !number.is_empty()
690                && number.chars().all(|c| c.is_ascii_digit()) =>
691        {
692            adopt
693                .host_str()
694                .map(|host| format!("{}/{owner}/{name}", host.to_ascii_lowercase()))
695        }
696        _ => None,
697    };
698    if named.as_deref() != Some(identity) || slug(identity).is_none() {
699        return Err(refused(format!(
700            "{adopt} is not a change request of {identity}: a change request is named as \
701             https://{DEFAULT_HOST}/<owner>/<name>/pull/<number>. Nothing was pushed"
702        )));
703    }
704    let recorded = state.publications.iter().rev().find_map(|earlier| {
705        let carried = match &earlier.outcome {
706            PublishOutcome::ChangeOpen(url)
707            | PublishOutcome::ChangeDraft(url)
708            | PublishOutcome::ChangeReviewDraft(url)
709            | PublishOutcome::ChangeReviewDraftUnchecked(url)
710            | PublishOutcome::Queued(url) => Some(url),
711            _ => None,
712        };
713        (carried == Some(adopt)).then(|| earlier.branch.clone())
714    });
715    match recorded {
716        Some(branch) if branch == session.branch => Ok(adopt.clone()),
717        Some(branch) => Err(refused(format!(
718            "{adopt} carries {branch:?} by this provider's newest record of it, and session {} \
719             is on {:?}. Nothing was pushed",
720            token.0, session.branch
721        ))),
722        None => Err(Error::UnrecordedChangeHead {
723            change: adopt.to_string(),
724            reason: format!(
725                "this provider holds no record of which branch {adopt} carries, and a \
726                 --defer-checks publication asks the host nothing. Nothing was pushed"
727            ),
728        }),
729    }
730}
731
732/// Record that a `local-direct` publication landed — record, and nothing more.
733///
734/// Landing one is entirely repository-side work, a squash built detached and
735/// pushed, which this provider does not perform and must not be named as if it
736/// did. What it does is decide the outcome and emit the completion that says so.
737fn record_local_landing(
738    identity: &str,
739    session: &Session,
740    token: &SessionToken,
741) -> (PublishOutcome, Vec<Emission>) {
742    let sha = events::stable_sha(&["publish", &token.0, &session.branch]);
743    let emission = Emission {
744        stream: token.0.clone(),
745        identity: Some(identity.to_owned()),
746        kind: EventKind::MergeCompleted,
747        // Every record of a landing says when the base received it and at which
748        // commit, as `onevcs`'s own does: here, the moment this provider decided it.
749        result: EmissionResult::Success,
750        payload: object(json!({
751            "identity": identity,
752            "sha": sha,
753            "base": session.base,
754            "landed_at": events::timestamp(),
755            "landing": sha,
756        })),
757    };
758    (PublishOutcome::Merged(Sha(sha)), vec![emission])
759}
760
761/// Everything one provider publication knows about itself.
762struct Publishing<'a> {
763    slug: &'a str,
764    identity: &'a str,
765    session: &'a Session,
766    policy: MergePolicy,
767    approvals: Approvals,
768    drafts: Drafts,
769    request: &'a PublishRequest,
770    token: &'a SessionToken,
771}
772
773impl Publishing<'_> {
774    /// One event on the session's own stream, under this identity.
775    fn emission(&self, kind: EventKind, payload: Value) -> Emission {
776        Emission {
777            stream: self.token.0.clone(),
778            identity: Some(self.identity.to_owned()),
779            kind,
780            result: EmissionResult::Success,
781            payload: object(payload),
782        }
783    }
784}
785
786/// Publish as a change request: open the session's change on the host, or adopt
787/// the one it already holds, watch its required checks, and then do with it what
788/// the policy asks.
789///
790/// **The draft lifecycle, as next door.** A publication carrying no reason opens its
791/// change request as a draft while the checks run — or, adopting a draft, records it
792/// as awaiting them rather than lifting it on the spot — and once they are green lifts
793/// it before asking for any merge, or keeps it under `change-open` with approvals
794/// required. `drafts: {disabled: true}` opens ready and lifts an adopted draft at once.
795///
796/// **What this provider cannot do is wait.** It has no clock, so each phase of the
797/// watch is **one reading** of the host: a reading that has not settled is the bound
798/// elapsing (`checks-unsettled`), and a draft on which no required check has run is
799/// the grace window elapsing — it is lifted early, and the next reading is the host's
800/// answer after the lift, which is where [`HostState::checks_after_lift`](crate::HostState::checks_after_lift)
801/// comes in. A consumer drives each row of the lifecycle by seeding the reading it
802/// wants rather than by timing one.
803fn publish_as_change(
804    hosting: &dyn Hosting,
805    publishing: &Publishing<'_>,
806    emissions: &mut Vec<Emission>,
807) -> Result<PublishOutcome> {
808    let Publishing {
809        slug,
810        session,
811        policy,
812        request,
813        ..
814    } = *publishing;
815    let host = hosting.for_repo(slug)?;
816    // Who the host believes is calling travels with the change, as it does in the
817    // real publication and for the same reason.
818    let author = host.authenticated_user()?;
819    let lifecycle = !publishing.drafts.disabled.unwrap_or(false);
820    // Checked before anything is opened, as the real publication checks it before
821    // anything is pushed.
822    if lifecycle {
823        grace_seconds()?;
824    }
825    let existing = host.find_changes(&session.branch, &session.base)?;
826    // Whether the host already held it, which is what decides whether there can be a
827    // draft to lift — and, as next door, keeps a publication that opens its own change
828    // request from asking this host anything extra.
829    let adopted = !existing.is_empty();
830    let change = match existing.into_iter().next() {
831        Some(change) => change,
832        None => host.open_change(ChangeSpec {
833            head: session.branch.clone(),
834            base: session.base.clone(),
835            // A requested title has been checked by the conversion that built it, so
836            // this provider cannot accept one the real publication would refuse. The
837            // real implementation takes the subject from the branch's commits when no
838            // title was requested, and a provider has no commits to read — so an
839            // unrequested title names the branch instead.
840            title: request
841                .title
842                .as_deref()
843                .map_or_else(|| format!("Publish {}", session.branch), str::to_owned),
844            // Verbatim, and nothing when there is none — the real publication
845            // composes no body either, so a provider that composed one would be a
846            // consumer's suite proving a change request nobody opens.
847            body: request.body.clone(),
848            // The reason travels to the host and no further, as it does there: what
849            // the host does with it is open the change as a draft.
850            draft: request.draft.clone(),
851            draft_awaiting_checks: lifecycle && request.draft.is_none(),
852        })?,
853    };
854    emissions.push(publishing.emission(
855        EventKind::ChangeOpened,
856        json!({
857            "url": change.url.to_string(),
858            "host": "github",
859            "id": change.id.0,
860            "base": change.base,
861            "author": author,
862        }),
863    ));
864    if let Some(reason) = &request.draft {
865        // The same question the real publication asks, for the same reason: a host
866        // that ignored the request, or a change request already open for review,
867        // would otherwise be reported as held back while it can land.
868        if !host.is_draft(&change)? {
869            return Err(Error::Invalid {
870                reason: format!(
871                    "{url} is open for review on the host, and this publication asked for \
872                     {asked}. A change that is open can land, so reporting it as a draft would \
873                     say the work is held back when nothing is holding it",
874                    url = change.url,
875                    asked = asked_for(reason),
876                ),
877            });
878        }
879        // The publication record, and the only place the reason is written: the real
880        // implementation writes nothing of it into the change request, and a provider
881        // that did would be a consumer's suite proving a body nobody renders. The
882        // payload is the reason's own serialized form — its `kind` and every field of
883        // that kind — under the change request it holds, exactly as next door.
884        let mut drafted = object(json!({
885            "url": change.url.to_string(),
886            "id": change.id.0,
887            "base": change.base,
888        }));
889        if let serde_json::Value::Object(fields) =
890            serde_json::to_value(reason).expect("a draft reason serializes")
891        {
892            drafted.extend(fields);
893        }
894        emissions.push(publishing.emission(EventKind::ChangeDrafted, Value::Object(drafted)));
895        // Under every policy: a draft is unmergeable in that state, so nothing below
896        // asks this host to merge it.
897        return Ok(PublishOutcome::ChangeDraft(change.url.clone()));
898    }
899    // Three answers about whether the host holds it as a draft, told apart as next
900    // door: a host that was never taught to draft one is holding nothing, and a host
901    // that *could not say* is a refusal rather than a change nobody is holding.
902    // Asked of a change this publication just opened only under the lifecycle, which
903    // asked for a draft: without it, one opened moments ago without a reason is one
904    // nobody drafted, and a host written against the earlier surface is asked nothing.
905    let is_draft = match if lifecycle || adopted {
906        host.is_draft(&change)
907    } else {
908        Ok(false)
909    } {
910        Ok(draft) => draft,
911        Err(Error::NotImplemented { .. }) => false,
912        Err(unreadable) => return Err(unreadable),
913    };
914    let drafted = if lifecycle {
915        if is_draft {
916            emissions.push(publishing.emission(
917                EventKind::ChangeDrafted,
918                json!({
919                    "url": change.url.to_string(),
920                    "id": change.id.0,
921                    "base": change.base,
922                    "kind": "awaiting-checks",
923                }),
924            ));
925        }
926        is_draft
927    } else {
928        // Without the lifecycle, publishing without a reason lifts an adopted draft
929        // on the spot, as it always did; one opened moments ago is one nobody drafted.
930        if adopted && is_draft {
931            lift(host.as_ref(), publishing, &change, emissions)?;
932        }
933        false
934    };
935
936    // Every change policy watches — except that `change-auto` on a change nobody
937    // drafted arms the host's own merge and leaves the checks to it, as next door.
938    // Next door's merge watch still refuses a required check that already concluded
939    // red rather than arm a merge over it, so this reading does too, and records the
940    // checks settled only where every check the host declares required let it
941    // through; one still running, or declared and not started, arms it, and the
942    // host's hold is what the outcome reports.
943    let still_draft = if drafted || policy != MergePolicy::ChangeAuto {
944        match watch(host.as_ref(), publishing, &change, drafted, emissions)? {
945            Watched::Draft => true,
946            Watched::Ready => false,
947            // A merge is armed in place of the elapsed wait's refusal where the policy
948            // asks for one and the host allows it, as next door.
949            Watched::Elapsed { error, skipped } => {
950                return match policy {
951                    MergePolicy::ChangeAuto | MergePolicy::ChangeDirect => arm_on_elapse(
952                        host.as_ref(),
953                        publishing,
954                        &change,
955                        emissions,
956                        error,
957                        &skipped,
958                    ),
959                    MergePolicy::ChangeOpen | MergePolicy::LocalDirect => Err(error),
960                };
961            }
962        }
963    } else {
964        let answered = host.change_checks(&change)?;
965        let checks: Vec<&Check> = answered.checks.iter().collect();
966        let declared = given(
967            declared_required(host.as_ref(), &change),
968            answered.complete(),
969            &checks,
970        );
971        let standing = standings(&checks, &declared);
972        if let Some(failed) = red(&checks, &standing) {
973            return Err(failed);
974        }
975        if through(&standing, &declared) {
976            record_settled(
977                publishing,
978                &change,
979                emissions,
980                skipped(&standing),
981                declared.unread(),
982            );
983        }
984        false
985    };
986    if still_draft {
987        if policy == MergePolicy::ChangeOpen && publishing.approvals == Approvals::Required {
988            emissions.push(publishing.emission(
989                EventKind::DraftKeptForReview,
990                json!({
991                    "url": change.url.to_string(),
992                    "id": change.id.0,
993                    "base": change.base,
994                }),
995            ));
996            return Ok(PublishOutcome::ChangeReviewDraft(change.url.clone()));
997        }
998        // Lifted before any merge is asked for: this host, like GitHub, will neither
999        // merge a draft nor arm its own merge on one.
1000        lift(host.as_ref(), publishing, &change, emissions)?;
1001    }
1002
1003    if policy == MergePolicy::ChangeOpen {
1004        return Ok(PublishOutcome::ChangeOpen(change.url.clone()));
1005    }
1006    Ok(match host.merge(&change, policy)? {
1007        MergeOutcome::Merged(sha) => merged(host.as_ref(), publishing, &change, emissions, &sha),
1008        MergeOutcome::Queued => PublishOutcome::Queued(change.url.clone()),
1009        MergeOutcome::Open => PublishOutcome::ChangeOpen(change.url.clone()),
1010    })
1011}
1012
1013/// Record a merge the host performed, and answer it as the publication's outcome.
1014fn merged(
1015    host: &dyn RemoteHost,
1016    publishing: &Publishing<'_>,
1017    change: &ChangeRequest,
1018    emissions: &mut Vec<Emission>,
1019    sha: &Sha,
1020) -> PublishOutcome {
1021    // When the host says the base received it, as `onevcs`'s own record of a
1022    // landing carries it. Where the host cannot say there is no landing commit
1023    // in this world to read a time from either, so the record says nobody can
1024    // time it rather than claim the moment this provider saw the merge.
1025    let landed_at = host
1026        .merge_time(change)
1027        .ok()
1028        .flatten()
1029        .and_then(|spelled| events::moment(&spelled));
1030    emissions.push(publishing.emission(
1031        EventKind::ChangeMerged,
1032        json!({
1033            "url": change.url.to_string(),
1034            "sha": sha.0,
1035            "landed_at": landed_at,
1036            "landing": sha.0,
1037        }),
1038    ));
1039    emissions.push(publishing.emission(
1040        EventKind::MergeCompleted,
1041        json!({
1042            "identity": publishing.identity,
1043            "sha": sha.0,
1044            "landed_at": landed_at,
1045            "landing": sha.0,
1046        }),
1047    ));
1048    PublishOutcome::Merged(sha.clone())
1049}
1050
1051/// What a `change-auto` or `change-direct` publication does when its watch elapses
1052/// with no required check red, as next door: ask the host whether it allows
1053/// auto-merge before changing anything, and where it does, lift the draft awaiting its
1054/// checks and arm the host's merge, record `merge-armed`, and end `checks-unsettled`
1055/// carrying the [`ArmedMerge`]. A host that does not allow it — or will not say — is
1056/// left as the elapsed watch left it, and the refusal says why.
1057fn arm_on_elapse(
1058    host: &dyn RemoteHost,
1059    publishing: &Publishing<'_>,
1060    change: &ChangeRequest,
1061    emissions: &mut Vec<Emission>,
1062    elapsed: Error,
1063    skipped: &[String],
1064) -> Result<PublishOutcome> {
1065    let Error::ChecksUnsettled { reason } = elapsed else {
1066        return Err(elapsed);
1067    };
1068    let unarmed = |why: String| Error::ChecksUnsettled {
1069        reason: format!(
1070            "{reason}. {why}, so {url} is left as it was — nothing lifted it or armed its merge",
1071            url = change.url
1072        ),
1073    };
1074    // A skipped required check is one the host's auto-merge reads as satisfied, so a
1075    // merge armed over one could land with it never run, as next door refuses to.
1076    if !skipped.is_empty() {
1077        return Err(unarmed(format!(
1078            "Required check {} concluded skipped, which the host's auto-merge would read as \
1079             passed",
1080            skipped.join(", ")
1081        )));
1082    }
1083    match host.allows_auto_merge() {
1084        Ok(true) => {}
1085        Ok(false) => {
1086            return Err(unarmed(
1087                "The repository does not allow auto-merge".to_owned(),
1088            ))
1089        }
1090        Err(unread) => {
1091            return Err(unarmed(format!(
1092                "Whether the repository allows auto-merge could not be read ({unread})"
1093            )))
1094        }
1095    }
1096    let is_draft = match host.is_draft(change) {
1097        Ok(draft) => draft,
1098        Err(Error::NotImplemented { .. }) => false,
1099        Err(unreadable) => return Err(unreadable),
1100    };
1101    if is_draft {
1102        lift(host, publishing, change, emissions)?;
1103    }
1104    if let MergeOutcome::Merged(sha) = host.merge(change, MergePolicy::ChangeAuto)? {
1105        return Ok(merged(host, publishing, change, emissions, &sha));
1106    }
1107    emissions.push(publishing.emission(
1108        EventKind::MergeArmed,
1109        json!({"url": change.url.to_string(), "head": change.head_sha.0}),
1110    ));
1111    Ok(PublishOutcome::Failed {
1112        kind: FailureKind::ChecksUnsettled,
1113        reason: format!(
1114            "{}. Its merge is armed: the host lands {url} once its required checks pass, and \
1115             publishing the session again watches it",
1116            Error::ChecksUnsettled { reason },
1117            url = change.url,
1118        ),
1119        retained: None,
1120        armed: Some(ArmedMerge {
1121            change: change.url.clone(),
1122            head: change.head_sha.0.clone(),
1123        }),
1124    })
1125}
1126
1127/// Take a change out of its draft on the host, and record the lift.
1128fn lift(
1129    host: &dyn RemoteHost,
1130    publishing: &Publishing<'_>,
1131    change: &ChangeRequest,
1132    emissions: &mut Vec<Emission>,
1133) -> Result<()> {
1134    host.ready_for_review(change)?;
1135    emissions.push(publishing.emission(
1136        EventKind::DraftLifted,
1137        json!({"url": change.url.to_string(), "id": change.id.0}),
1138    ));
1139    Ok(())
1140}
1141
1142/// Which checks a merge requires, as the host said: nothing, these names, or unknown —
1143/// with why, which a settlement read off the host's marking says, as next door.
1144#[derive(Clone, PartialEq, Eq)]
1145enum Declared {
1146    Nothing,
1147    Names(BTreeSet<String>),
1148    Unknown(String),
1149}
1150
1151impl Declared {
1152    /// Why what is required is read off the host's own per-check marking, where it is.
1153    fn unread(&self) -> Option<&str> {
1154        match self {
1155            Declared::Unknown(because) => Some(because),
1156            Declared::Nothing | Declared::Names(_) => None,
1157        }
1158    }
1159}
1160
1161/// What the host declares a merge into the change's base requires, and why it could
1162/// not be read where it could not — as next door, which reads every phase of a watch
1163/// against this one answer.
1164fn declared_required(host: &dyn RemoteHost, change: &ChangeRequest) -> Declared {
1165    match host.required_checks_on(&change.base) {
1166        Ok(answer) if answer.checks.is_empty() && answer.complete() => Declared::Nothing,
1167        Ok(answer) if !answer.checks.is_empty() => Declared::Names(answer.checks),
1168        Ok(_) => Declared::Unknown(format!(
1169            "the host answered only in part about which checks a merge into {} requires",
1170            change.base
1171        )),
1172        Err(refused) => Declared::Unknown(format!(
1173            "the host would not say which checks a merge into {} requires: {refused}",
1174            change.base
1175        )),
1176    }
1177}
1178
1179/// The declaration, sharpened by one reading as next door sharpens it: where the host
1180/// would not say what it requires, a complete rollup reporting checks none of which is
1181/// required is its answer that nothing is.
1182fn given(declared: Declared, complete: bool, checks: &[&Check]) -> Declared {
1183    if matches!(declared, Declared::Unknown(_))
1184        && complete
1185        && !checks.is_empty()
1186        && checks.iter().all(|check| !check.required)
1187    {
1188        return Declared::Nothing;
1189    }
1190    declared
1191}
1192
1193/// Each required check's name and the state its entries add up to — `None` where the
1194/// host reported none — ranked as next door: red, then running, then passed, then no
1195/// verdict, then skipped.
1196fn standings(checks: &[&Check], declared: &Declared) -> Vec<(String, Option<CheckState>)> {
1197    let names: BTreeSet<String> = match declared {
1198        Declared::Names(names) => names.clone(),
1199        Declared::Nothing => BTreeSet::new(),
1200        Declared::Unknown(_) => checks
1201            .iter()
1202            .filter(|check| check.required)
1203            .map(|check| check.name.clone())
1204            .collect(),
1205    };
1206    let rank = |state: CheckState| match state {
1207        CheckState::Failed => 0,
1208        CheckState::Pending => 1,
1209        CheckState::Passed => 2,
1210        CheckState::NoVerdict => 3,
1211        CheckState::Skipped => 4,
1212    };
1213    names
1214        .into_iter()
1215        .map(|name| {
1216            let state = checks
1217                .iter()
1218                .filter(|check| check.name == name)
1219                .map(|check| check.state())
1220                .min_by_key(|state| rank(*state));
1221            (name, state)
1222        })
1223        .collect()
1224}
1225
1226/// The refusal for the first required check that concluded red.
1227fn red(checks: &[&Check], standing: &[(String, Option<CheckState>)]) -> Option<Error> {
1228    let (name, _) = standing
1229        .iter()
1230        .find(|(_, state)| *state == Some(CheckState::Failed))?;
1231    let check = checks
1232        .iter()
1233        .find(|check| &check.name == name && check.state() == CheckState::Failed)?;
1234    Some(Error::ChecksFailed {
1235        reason: format!(
1236            "required check {:?} concluded {}.",
1237            check.name,
1238            check
1239                .conclusion
1240                .as_deref()
1241                .unwrap_or("without a conclusion")
1242        ),
1243    })
1244}
1245
1246/// Whether every required check lets the change through — passed, or skipped — over
1247/// a set that is empty only where the host answered, completely, that nothing is
1248/// required. A declared check with no run holds it, as next door.
1249fn through(standing: &[(String, Option<CheckState>)], declared: &Declared) -> bool {
1250    (*declared == Declared::Nothing || !standing.is_empty())
1251        && standing
1252            .iter()
1253            .all(|(_, state)| matches!(state, Some(CheckState::Passed | CheckState::Skipped)))
1254}
1255
1256/// What a reading that has not settled names, in the words next door's bound names it
1257/// in: each required check still running or with no run yet, then the ones that ended
1258/// with no verdict or were skipped, and only then that every one settled or that the
1259/// host declared none — and, where the declaration could not be read, that too.
1260fn unsettled_named(
1261    checks: &[&Check],
1262    standing: &[(String, Option<CheckState>)],
1263    declared: &Declared,
1264) -> String {
1265    let pending: Vec<String> = standing
1266        .iter()
1267        .filter_map(|(name, state)| match state {
1268            Some(CheckState::Pending) => Some(format!("{name:?}")),
1269            None => Some(format!("{name:?} (no run yet)")),
1270            _ => None,
1271        })
1272        .collect();
1273    let no_verdict: Vec<String> = standing
1274        .iter()
1275        .filter(|(_, state)| *state == Some(CheckState::NoVerdict))
1276        .map(|(name, _)| {
1277            let conclusion = checks
1278                .iter()
1279                .find(|check| &check.name == name && check.state() == CheckState::NoVerdict)
1280                .and_then(|check| check.conclusion.as_deref())
1281                .unwrap_or("unknown");
1282            format!("{name:?}: {conclusion}")
1283        })
1284        .collect();
1285    let skipped: Vec<String> = skipped(standing)
1286        .iter()
1287        .map(|name| format!("{name:?}"))
1288        .collect();
1289    let named = if !pending.is_empty() {
1290        let verdicts = if no_verdict.is_empty() {
1291            String::new()
1292        } else {
1293            format!("; completed with no verdict: {}", no_verdict.join(", "))
1294        };
1295        format!("still unsettled: {}{verdicts}", pending.join(", "))
1296    } else if !no_verdict.is_empty() {
1297        format!("completed with no verdict: {}", no_verdict.join(", "))
1298    } else if !skipped.is_empty() {
1299        format!(
1300            "skipped on the draft, so not run on it: {}",
1301            skipped.join(", ")
1302        )
1303    } else if standing.is_empty() {
1304        match declared {
1305            Declared::Nothing => "the host declared no required check on it at all".to_owned(),
1306            _ => "it has marked no check required on it".to_owned(),
1307        }
1308    } else {
1309        "every required check it declared had settled".to_owned()
1310    };
1311    let read_from = declared
1312        .unread()
1313        .map(|because| {
1314            format!(
1315                ". Which checks it requires could not be read, so they were read from the \
1316                 checks it marked required: {because}"
1317            )
1318        })
1319        .unwrap_or_default();
1320    format!("{named}{read_from}")
1321}
1322
1323/// The skipped required checks, by name.
1324fn skipped(standing: &[(String, Option<CheckState>)]) -> Vec<String> {
1325    standing
1326        .iter()
1327        .filter(|(_, state)| *state == Some(CheckState::Skipped))
1328        .map(|(name, _)| name.clone())
1329        .collect()
1330}
1331
1332/// Record that the required checks stopped blocking — `passed`, or
1333/// `passed-with-skipped` naming the skipped ones.
1334///
1335/// `unread`: the requirement was read from the host's own per-check marking because
1336/// its declaration could not be read, which the record says, as next door.
1337fn record_settled(
1338    publishing: &Publishing<'_>,
1339    change: &ChangeRequest,
1340    emissions: &mut Vec<Emission>,
1341    skipped: Vec<String>,
1342    unread: Option<&str>,
1343) {
1344    let verdict = if skipped.is_empty() {
1345        "passed"
1346    } else {
1347        "passed-with-skipped"
1348    };
1349    let mut payload = json!({
1350        "url": change.url.to_string(),
1351        "id": change.id.0,
1352        "head": change.head_sha.0,
1353        "verdict": verdict,
1354        "skipped": skipped,
1355    });
1356    if let Some(because) = unread {
1357        eprintln!(
1358            "onevcs: warning: the required checks on {} were read from the host's own \
1359             per-check marking, because the declaration could not be read: {because}",
1360            change.url
1361        );
1362        payload["requirement"] = json!({"read_from": "host-marking", "because": because});
1363    }
1364    let mut settled = publishing.emission(EventKind::ChecksSettled, payload);
1365    settled.result = if verdict == "passed" {
1366        EmissionResult::Passed
1367    } else {
1368        EmissionResult::PassedWithSkipped
1369    };
1370    emissions.push(settled);
1371}
1372
1373/// The watch, one reading per phase. See [`publish_as_change`] for what a reading
1374/// stands for.
1375fn watch(
1376    host: &dyn RemoteHost,
1377    publishing: &Publishing<'_>,
1378    change: &ChangeRequest,
1379    drafted: bool,
1380    emissions: &mut Vec<Emission>,
1381) -> Result<Watched> {
1382    let settled = |emissions: &mut Vec<Emission>, skipped: Vec<String>, unread: Option<&str>| {
1383        record_settled(publishing, change, emissions, skipped, unread);
1384    };
1385    let elapsed_naming = |checks: &[&Check],
1386                          standing: &[(String, Option<CheckState>)],
1387                          declared: &Declared,
1388                          skipped: Vec<String>| Watched::Elapsed {
1389        error: Error::ChecksUnsettled {
1390            reason: format!(
1391                "the host had not settled its required checks on {}; {}",
1392                change.url,
1393                unsettled_named(checks, standing, declared)
1394            ),
1395        },
1396        skipped,
1397    };
1398    let unsettled =
1399        |checks: &[&Check], standing: &[(String, Option<CheckState>)], declared: &Declared| {
1400            elapsed_naming(checks, standing, declared, skipped(standing))
1401        };
1402    let answered = host.change_checks(change)?;
1403    let complete = answered.complete();
1404    let checks: Vec<&Check> = answered.checks.iter().collect();
1405    // What the host declares it requires, read once and sharpened by this reading.
1406    let declared = given(declared_required(host, change), complete, &checks);
1407    let unread = declared.unread();
1408
1409    if !drafted {
1410        let standing = standings(&checks, &declared);
1411        if let Some(failed) = red(&checks, &standing) {
1412            return Err(failed);
1413        }
1414        if !through(&standing, &declared) {
1415            return Ok(unsettled(&checks, &standing, &declared));
1416        }
1417        settled(emissions, skipped(&standing), unread);
1418        return Ok(Watched::Ready);
1419    }
1420
1421    // The draft: a skipped required check has not run.
1422    if matches!(declared, Declared::Nothing) {
1423        settled(emissions, Vec::new(), None);
1424        return Ok(Watched::Draft);
1425    }
1426    let standing = standings(&checks, &declared);
1427    if let Some(failed) = red(&checks, &standing) {
1428        return Err(failed);
1429    }
1430    if !standing.is_empty()
1431        && standing
1432            .iter()
1433            .all(|(_, state)| *state == Some(CheckState::Passed))
1434    {
1435        settled(emissions, Vec::new(), unread);
1436        return Ok(Watched::Draft);
1437    }
1438    let not_run: Vec<String> = standing
1439        .iter()
1440        .filter(|(_, state)| matches!(state, None | Some(CheckState::Skipped)))
1441        .map(|(name, _)| name.clone())
1442        .collect();
1443    let running = standing
1444        .iter()
1445        .any(|(_, state)| matches!(state, Some(CheckState::Pending | CheckState::NoVerdict)));
1446    let unseen = matches!(declared, Declared::Unknown(_)) && standing.is_empty();
1447    // The grace window, elapsed: where the host would not say what it requires and
1448    // checks ran on the draft with none skipped, its own marking is the answer.
1449    if unseen
1450        && !checks.is_empty()
1451        && checks
1452            .iter()
1453            .all(|check| check.state() != CheckState::Skipped)
1454    {
1455        settled(emissions, Vec::new(), unread);
1456        return Ok(Watched::Draft);
1457    }
1458    if running || (not_run.is_empty() && !unseen) || still_running(host, change, &checks) {
1459        return Ok(unsettled(&checks, &standing, &declared));
1460    }
1461
1462    // The grace window elapsed with nothing required run: lift early, say so, and read
1463    // the host again as a ready change whose checks must run after the lift.
1464    let snapshot: Vec<Check> = answered.checks.clone();
1465    lift(host, publishing, change, emissions)?;
1466    let warned = publishing.drafts.warn_on_early_lift.unwrap_or(true);
1467    let grace = grace_seconds()?;
1468    if warned {
1469        eprintln!(
1470            "onevcs: warning: {} was lifted out of its draft before its required checks ran on \
1471             it ({}): none had run within {grace}s and nothing on its head was still running, \
1472             which is what a workflow that skips drafts looks like. {DRAFT_CI}",
1473            change.url,
1474            not_run.join(", ")
1475        );
1476    }
1477    emissions.push(publishing.emission(
1478        EventKind::DraftLiftedEarly,
1479        json!({
1480            "url": change.url.to_string(),
1481            "id": change.id.0,
1482            "base": change.base,
1483            "awaited": not_run,
1484            "grace_seconds": grace,
1485            "warned": warned,
1486        }),
1487    ));
1488    let after = host.change_checks(change)?.checks;
1489    let from_the_draft = |check: &Check| !ran_after_the_lift(check, &snapshot);
1490    let counted: Vec<&Check> = after
1491        .iter()
1492        .filter(|check| !(from_the_draft(check) && check.state() == CheckState::Skipped))
1493        .collect();
1494    let standing = standings(&counted, &declared);
1495    if let Some(failed) = red(&counted, &standing) {
1496        return Err(failed);
1497    }
1498    let rerun = after.iter().any(|check| {
1499        !from_the_draft(check)
1500            && (standing.is_empty() || standing.iter().any(|(name, _)| *name == check.name))
1501    });
1502    // A watch that elapses after the lift names a required check standing on nothing
1503    // but a draft-era skip as skipped, as next door's does: the host's auto-merge would
1504    // read it as satisfied, so the change is left unarmed rather than merging on it.
1505    let reported: Vec<&Check> = after.iter().collect();
1506    let elapsed = || {
1507        let mut named = skipped(&standing);
1508        for name in skipped(&standings(&reported, &declared)) {
1509            if !named.contains(&name) {
1510                named.push(name);
1511            }
1512        }
1513        elapsed_naming(&counted, &standing, &declared, named)
1514    };
1515    if !rerun {
1516        // The draft's own workflow still going will attach the check it has not created
1517        // yet, so that is a watch still waiting rather than one that ended.
1518        if still_running(host, change, &reported) {
1519            return Ok(elapsed());
1520        }
1521        return Err(Error::ChecksUnsettled {
1522            reason: format!(
1523                "the required checks on {} did not re-run after its draft was lifted: no run \
1524                 the host attached after the lift appeared and nothing on its head is still \
1525                 running, while the runs from while it was a draft were skipped or never \
1526                 started, which is not a verdict. {DRAFT_CI}",
1527                change.url
1528            ),
1529        });
1530    }
1531    // The after-lift rule is next door's: a run attached since the lift, and every
1532    // required check it stands for passed or skipped.
1533    if !standing
1534        .iter()
1535        .all(|(_, state)| matches!(state, Some(CheckState::Passed | CheckState::Skipped)))
1536    {
1537        return Ok(elapsed());
1538    }
1539    settled(emissions, skipped(&standing), unread);
1540    Ok(Watched::Ready)
1541}
1542
1543/// What to do about CI that does not run a draft's required checks, in the words the
1544/// crate next door says it in.
1545const DRAFT_CI: &str = "Run the required checks on drafts: their results attach to the head \
1546     commit, so the change is then kept a draft until they are green, and a workflow that runs \
1547     on drafts needs no `ready_for_review` trigger. Only a workflow that skips its required \
1548     jobs on drafts needs `ready_for_review` to run them after a lift, at the cost of one more \
1549     run per lift";
1550
1551/// Whether anything on the change's head is still running, by the two readings next
1552/// door's watch holds both of a draft's "nothing is coming" decisions to: a check the
1553/// host reports that has not completed, and then — only where none has — the host's
1554/// own answer about its head. A refusal, or an answer about another commit, is no
1555/// answer.
1556fn still_running(host: &dyn RemoteHost, change: &ChangeRequest, checks: &[&Check]) -> bool {
1557    checks.iter().any(|check| !check.settled())
1558        || host
1559            .head_activity(change)
1560            .is_ok_and(|answer| answer.head == change.head_sha && !answer.in_progress.is_empty())
1561}
1562
1563/// Where one watch ended.
1564enum Watched {
1565    /// Green on a draft still standing, for the caller to lift or keep.
1566    Draft,
1567    /// Green on a ready change.
1568    Ready,
1569    /// The reading had not settled, which is the bound elapsing here; the error is the
1570    /// `checks-unsettled` it ends as unless the merge is armed instead.
1571    Elapsed {
1572        /// The `checks-unsettled` the bound ends as.
1573        error: Error,
1574        /// The required checks that had concluded skipped when the bound elapsed,
1575        /// which keep its merge from being armed.
1576        skipped: Vec<String>,
1577    },
1578}
1579
1580/// Whether `check` is a run the host attached after the lift rather than the draft's
1581/// run of it in `snapshot`, decided as the real publication decides it: by the run's
1582/// own identity, never by its conclusion or its status. A check the draft never
1583/// reported is new, and one it did report is new only where it reports a `started_at`
1584/// no draft-era run of that check reported, running or settled — so a consumer seeds a
1585/// re-run by giving it a start of its own.
1586fn ran_after_the_lift(check: &Check, snapshot: &[Check]) -> bool {
1587    let earlier: Vec<&Check> = snapshot
1588        .iter()
1589        .filter(|seen| seen.name == check.name)
1590        .collect();
1591    if earlier.is_empty() {
1592        return true;
1593    }
1594    check.started_at.as_ref().is_some_and(|started| {
1595        earlier
1596            .iter()
1597            .all(|seen| seen.started_at.as_ref() != Some(started))
1598    })
1599}
1600
1601/// The default grace window of the real publication, which is `onevcs`'s own and
1602/// private to it. A copy, so it is gated: `lifecycle.rs`'s
1603/// `the_grace_window_an_early_lift_records_is_the_one_onevcs_defaults_to` reads the
1604/// constant out of `onevcs`'s source and holds this to it.
1605const DEFAULT_DRAFT_GRACE_SECONDS: f64 = 120.0;
1606
1607/// The knob a real publication waits its grace window by.
1608const DRAFT_GRACE_ENV: &str = "ONEVCS_DRAFT_CHECKS_GRACE_SECONDS";
1609
1610/// The grace window a real publication would have waited out, as the operator set it
1611/// or the default — recorded on `draft-lifted-early` so the payload reads as the real
1612/// one does, though nothing here waits.
1613///
1614/// Validated as the real publication validates it, and refused by name where it is
1615/// not a finite number of seconds above zero: a provider that quietly took the default
1616/// would let a consumer's suite pass on a setting the real one refuses.
1617fn grace_seconds() -> Result<f64> {
1618    let Some(raw) = std::env::var_os(DRAFT_GRACE_ENV) else {
1619        return Ok(DEFAULT_DRAFT_GRACE_SECONDS);
1620    };
1621    let raw = raw.to_string_lossy().into_owned();
1622    let seconds: f64 = raw.trim().parse().map_err(|_| Error::Invalid {
1623        reason: format!("{DRAFT_GRACE_ENV} must be a number of seconds, not {raw:?}"),
1624    })?;
1625    if !seconds.is_finite() || seconds <= 0.0 {
1626        return Err(Error::Invalid {
1627            reason: format!(
1628                "{DRAFT_GRACE_ENV} must be a finite number of seconds above zero, not {raw:?}"
1629            ),
1630        });
1631    }
1632    Ok(seconds)
1633}
1634
1635/// The draft a publication asked for, as the two refusals next door spell it: "a
1636/// draft awaiting <repository> <target>", or "a draft held by the session that opened
1637/// it (<why>)". A restatement, so the refusals here read as the real ones do.
1638fn asked_for(reason: &DraftReason) -> String {
1639    match reason {
1640        DraftReason::AwaitingRelease {
1641            awaiting, target, ..
1642        } => format!("a draft awaiting {awaiting} {target}"),
1643        DraftReason::Held { because } => {
1644            format!("a draft held by the session that opened it ({because})")
1645        }
1646    }
1647}
1648
1649/// What a publication answers for an identity no change request can be opened
1650/// against.
1651///
1652/// Two failures rather than one, as the real implementation keeps them: an
1653/// identity that is not hosted at all is asking for the wrong policy, while a
1654/// hosted one on a host this build does not speak for is asking for an
1655/// implementation that has not arrived.
1656fn refusal(identity: &str) -> PublishOutcome {
1657    failed(&if identity.split('/').count() == 3 {
1658        Error::NotImplemented {
1659            operation: "RemoteHost for a host other than github.com",
1660        }
1661    } else {
1662        Error::Invalid {
1663            reason: format!(
1664                "identity {identity:?} is not a hosted repository, so it cannot publish a \
1665                 change request; a local identity publishes with local-direct"
1666            ),
1667        }
1668    })
1669}
1670
1671/// One failure, as the outcome a publication that started and did not land is.
1672fn failed(error: &Error) -> PublishOutcome {
1673    PublishOutcome::Failed {
1674        // Through the crate's own mapping, so the kind a caller branches on is the
1675        // one the real implementation would report for the same failure.
1676        kind: FailureKind::of(error),
1677        reason: error.to_string(),
1678        // A provider has no execution checkout, so there is nowhere a branch could
1679        // have been handed back to and nothing to report about one.
1680        retained: None,
1681        armed: None,
1682    }
1683}
1684
1685/// The argv that lands a preserved branch, as `recoverable` reports it.
1686///
1687/// Both verbs take the branch by name and the repository by path, the way the real
1688/// implementation reports them: the provenance decides which one, and neither
1689/// depends on the directory a reader happens to be standing in.
1690// llmlint: ignore[names_match_behavior] named for the public
1691// `Recoverable::recover_command` field it fills, which `onevcs` publishes and this
1692// crate must answer identically; a provider whose helper were named otherwise would
1693// read as filling something else.
1694fn recover_command(branch: &str, checkout: &Path, provenance: Provenance) -> Vec<String> {
1695    let verb = match provenance {
1696        Provenance::IncompleteStep => "recover",
1697        Provenance::Complete => "publish-branch",
1698    };
1699    vec![
1700        "onevcs".to_owned(),
1701        verb.to_owned(),
1702        branch.to_owned(),
1703        "--repo".to_owned(),
1704        checkout.display().to_string(),
1705    ]
1706}
1707
1708/// How a provenance kind is spelled in an event payload.
1709fn spell(provenance: Provenance) -> &'static str {
1710    match provenance {
1711        Provenance::Complete => "complete",
1712        Provenance::IncompleteStep => "incomplete-step",
1713    }
1714}
1715
1716fn object(value: Value) -> Map<String, Value> {
1717    value.as_object().cloned().unwrap_or_default()
1718}