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