onevcs_testing/repository.rs
1//! The repository side: one implementation of [`Vcs`] over either store.
2//!
3//! What it is not: git. It answers the questions the interface asks, records what
4//! it was asked, and emits the events the real implementation emits. What it
5//! cannot do is tell you whether a tree is dirty or whether a merge conflicts,
6//! because there is no tree — a journey that needs those drives the real `Git`.
7//!
8//! Publishing is the one operation that reaches past the repository: the host side
9//! of it is *performed*, against the [`Hosting`] the publication was handed, and
10//! the repository side of it is neither performed nor claimed. What that leaves
11//! out is written where each piece is left out.
12
13use std::path::{Path, PathBuf};
14
15use serde_json::{json, Map, Value};
16
17use std::collections::BTreeSet;
18
19use onevcs::rules::{Approvals, Drafts};
20use onevcs::{
21 ChangeRequest, ChangeSpec, Check, CheckState, DraftReason, Error, EventKind, FailureKind,
22 HeldBy, Holding, Hosting, Identity, Landed, Lifecycle, MergeOutcome, MergePolicy,
23 PreservedBranch, Provenance, Publication, PublishOutcome, PublishRequest, Recoverable,
24 RemoteHost, Result, Scope, Session, SessionRecord, SessionRequest, SessionToken, Sha, Vcs,
25};
26
27use crate::events::{self, Emission};
28use crate::remote::DEFAULT_HOST;
29use crate::state::{self, VcsState};
30use crate::store::{FileStore, MemoryStore, Store};
31
32/// The base a session is cut from when the request names none.
33///
34/// The real implementation asks the origin for its default branch, and this
35/// provider has no origin to ask.
36pub const DEFAULT_BASE: &str = "main";
37
38/// The policy a publication takes when nothing was seeded and nothing requested.
39///
40/// The policy the contract's own `default:` names, which is what the real
41/// implementation resolves to for a registry with no rules file.
42pub const DEFAULT_PUBLICATION: MergePolicy = MergePolicy::ChangeOpen;
43
44/// The approvals a publication takes when nothing was seeded: the contract's own
45/// `default:`, as [`DEFAULT_PUBLICATION`] is.
46pub const DEFAULT_APPROVALS: Approvals = Approvals::Required;
47
48/// The repository side of a run, over whichever store holds its state.
49///
50/// The two flavours below are this one behaviour with a different store under it,
51/// so neither can learn something the other does not know.
52#[derive(Debug)]
53pub struct Repository<T> {
54 store: T,
55 root: PathBuf,
56 trees: Trees,
57}
58
59/// What a session's worktree path means.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61enum Trees {
62 /// The path is named and nothing is created there: the in-memory provider
63 /// touches no filesystem beyond the event stream.
64 Named,
65 /// The directory is created, so a journey has somewhere to write work.
66 Created,
67}
68
69/// A repository provider that keeps its state in this process: no disk, no
70/// visibility to a second process, and the fastest of the two.
71///
72/// Its sessions name a worktree under the system temporary directory and **do not
73/// create it** — nothing here touches the filesystem except the event stream, which
74/// is the record a journey reads.
75pub type MemoryVcs = Repository<MemoryStore<VcsState>>;
76
77/// A repository provider that keeps its state in one JSON document, so several
78/// `onevcs` invocations see one another's effects.
79///
80/// Its sessions name a worktree beside that document **and create it**, so a
81/// journey that writes a file into a session's tree has somewhere to write it.
82pub type FileVcs = Repository<FileStore<VcsState>>;
83
84impl MemoryVcs {
85 /// A repository provider knowing nothing.
86 pub fn new() -> Self {
87 Self::seeded(VcsState::default())
88 }
89
90 /// A repository provider that starts from a scenario.
91 pub fn seeded(state: VcsState) -> Self {
92 Self {
93 store: MemoryStore::new(state),
94 root: std::env::temp_dir().join("onevcs-testing-memory"),
95 trees: Trees::Named,
96 }
97 }
98
99 /// Everything it knows.
100 pub fn state(&self) -> VcsState {
101 self.store
102 .snapshot()
103 .expect("an in-memory store always answers")
104 }
105}
106
107impl Default for MemoryVcs {
108 fn default() -> Self {
109 Self::new()
110 }
111}
112
113impl FileVcs {
114 /// A repository provider keeping its state at `path`: whatever is already
115 /// there, or nothing.
116 ///
117 /// Attaching rather than replacing, so a second provider over the same path
118 /// picks up what the first one left — which is what a journey driving several
119 /// invocations reaches for this flavour to get.
120 pub fn create(path: impl Into<PathBuf>) -> Result<Self> {
121 Self::over(FileStore::attach(path, &VcsState::default())?)
122 }
123
124 /// A repository provider that starts from a scenario, keeping its state at
125 /// `path` and replacing whatever was there.
126 pub fn seeded(path: impl Into<PathBuf>, state: VcsState) -> Result<Self> {
127 Self::over(FileStore::replace(path, &state)?)
128 }
129
130 fn over(store: FileStore<VcsState>) -> Result<Self> {
131 let root = store
132 .path()
133 .parent()
134 .filter(|parent| !parent.as_os_str().is_empty())
135 .unwrap_or_else(|| Path::new("."))
136 .join("worktrees");
137 Ok(Self {
138 store,
139 root,
140 trees: Trees::Created,
141 })
142 }
143
144 /// Everything it knows, read back out of its document.
145 pub fn state(&self) -> Result<VcsState> {
146 self.store.snapshot()
147 }
148}
149
150impl<T: Store<VcsState>> Vcs for Repository<T> {
151 fn resolve_identity(&self, origin_or_path: &str) -> Result<Identity> {
152 self.store.with(|state| {
153 state::identity_of(state, origin_or_path)
154 .cloned()
155 .ok_or_else(|| Error::Invalid {
156 reason: format!(
157 "{origin_or_path:?} does not name a repository this provider knows; {}",
158 state::known(state)
159 ),
160 })
161 })
162 }
163
164 fn open_session(&self, req: SessionRequest) -> Result<Session> {
165 let root = self.root.clone();
166 let (session, emission) = self.store.with(|state| {
167 let identity = state::identity_of(state, &req.repo)
168 .cloned()
169 .ok_or_else(|| Error::Invalid {
170 reason: format!(
171 "{:?} does not name a repository this provider knows; {}",
172 req.repo,
173 state::known(state)
174 ),
175 })?;
176 // Consecutive and predictable, so a journey can name the session it is
177 // about to open — the one thing a digest-shaped token takes away.
178 let token = SessionToken(format!("s-testing-{}", state.sessions.len() + 1));
179 let run_root = root.join(&token.0);
180 // Both names are checked before they are recorded, because both go on to
181 // spell a ref for whoever holds the session — and a provider that
182 // accepted a name git refuses would let a journey pass where the real
183 // run stops.
184 let base = req.base.clone().unwrap_or_else(|| DEFAULT_BASE.to_owned());
185 state::named_branch(&base, "the base")?;
186 for (key, value) in &req.labels {
187 state::label_pair(key, value)?;
188 }
189 // 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 session that has already landed has nothing the base does not carry,
430 // which is what the real implementation reports for the same reason. One
431 // whose change request is merely open or queued has *not* landed, and
432 // publishing it again adopts that change rather than opening a second —
433 // so it falls through to the host, as it does there.
434 if state.publications.iter().any(|earlier| {
435 earlier.session == *token && matches!(earlier.outcome, PublishOutcome::Merged(_))
436 }) {
437 let (publication, emissions) =
438 published(PublishOutcome::NothingToPublish, Vec::new());
439 state.publications.push(publication.clone());
440 return Ok((publication, emissions));
441 }
442
443 let (outcome, emissions) = if policy == MergePolicy::LocalDirect {
444 // The same refusal the real publication makes at the same boundary: a
445 // local-direct publication opens no change request, so there is
446 // nothing to draft and the work would land carrying the very pin the
447 // draft exists to hold back.
448 match &request.draft {
449 Some(reason) => (
450 failed(&Error::Invalid {
451 reason: format!(
452 "{branch:?} was asked to publish as {asked}, and this identity \
453 publishes with local-direct, which opens no change request at \
454 all",
455 branch = session.branch,
456 asked = asked_for(reason),
457 ),
458 }),
459 Vec::new(),
460 ),
461 None => record_local_landing(&identity, &session, token),
462 }
463 } else {
464 match slug(&identity) {
465 Some(slug) => {
466 // Recorded as it happens, as next door: a publication that
467 // fails after opening, drafting or lifting keeps that record.
468 let mut emissions = Vec::new();
469 let outcome = match publish_as_change(
470 hosting,
471 &Publishing {
472 slug: &slug,
473 identity: &identity,
474 session: &session,
475 policy,
476 approvals: state.approvals.unwrap_or(DEFAULT_APPROVALS),
477 drafts: state.drafts.unwrap_or_default(),
478 request,
479 token,
480 },
481 &mut emissions,
482 ) {
483 Ok(outcome) => outcome,
484 // Once a publication has started, what stops it is an outcome
485 // rather than a refusal — the same split the real
486 // implementation keeps, so a caller reads one shape.
487 Err(error) => failed(&error),
488 };
489 (outcome, emissions)
490 }
491 None => (refusal(&identity), Vec::new()),
492 }
493 };
494 let (publication, emissions) = published(outcome, emissions);
495 state.publications.push(publication.clone());
496 Ok((publication, emissions))
497 })?;
498 for emission in &emissions {
499 events::emit(emission);
500 }
501 Ok(publication)
502 }
503
504 /// The same rows [`Vcs::recoverable`] answers with.
505 ///
506 /// This provider is *handed* its preserved work, and what it is handed is work
507 /// nobody published: there is no base here whose history could record a landing
508 /// and no tree to compare content against, so it withholds nothing and the wider
509 /// question has the same answer as the narrower one.
510 fn preserved(&self, scope: Scope) -> Result<Vec<Recoverable>> {
511 self.recoverable(scope)
512 }
513
514 fn recoverable(&self, scope: Scope) -> Result<Vec<Recoverable>> {
515 self.store.with(|state| {
516 let wanted = match &scope {
517 Scope::All => None,
518 Scope::Repo(repo) => Some(
519 state::identity_of(state, repo)
520 .map(|identity| identity.origin.clone())
521 .ok_or_else(|| Error::Invalid {
522 reason: format!(
523 "{repo:?} does not name a repository this provider knows; {}",
524 state::known(state)
525 ),
526 })?,
527 ),
528 };
529 // Newest first, as the real implementation reports them.
530 Ok(state
531 .preserved
532 .iter()
533 .rev()
534 .filter(|row| wanted.as_ref().is_none_or(|key| *key == row.identity))
535 .cloned()
536 .map(|row| held(state, row))
537 .collect())
538 })
539 }
540}
541
542/// Say which live session still holds a row's branch, when one of this provider's
543/// does.
544///
545/// Read here rather than written when the work was preserved, because closing the
546/// session is what ends the hold and a row that had frozen the answer would go on
547/// saying somebody is in there. What makes it *live* is the same thing that makes it
548/// live for the real implementation in-process: the session is open, and the process
549/// that opened it is this one. A row a scenario seeded the answer into keeps it — a
550/// hand-written state says what it means to say.
551fn held(state: &VcsState, row: Recoverable) -> Recoverable {
552 if row.held_by.is_some() {
553 return row;
554 }
555 let holder = state.sessions.iter().find(|session| {
556 session.branch == row.branch.branch
557 && state.session_identities.get(&session.token) == Some(&row.identity)
558 && !state.closed_sessions.contains(&session.token)
559 });
560 Recoverable {
561 held_by: holder.map(|session| HeldBy {
562 token: Some(session.token.clone()),
563 worktree: session.worktree.clone(),
564 holding: Holding::OwnerRunning,
565 }),
566 ..row
567 }
568}
569
570/// The refusal a session this provider never opened meets.
571fn unknown_session(token: &SessionToken) -> Error {
572 Error::Invalid {
573 reason: format!(
574 "no session {:?} is open; `onevcs session open` prints a token",
575 token.0
576 ),
577 }
578}
579
580/// The `owner/name` slug an identity key spells, when it is a GitHub one.
581///
582/// The host is checked rather than assumed, exactly as the real implementation
583/// checks it: a GitLab origin has the same three segments, and a provider that
584/// published one anyway would let a journey pass where the real run answers that
585/// nobody has implemented that host.
586fn slug(identity: &str) -> Option<String> {
587 let mut parts = identity.split('/');
588 let (host, owner, name) = (parts.next()?, parts.next()?, parts.next()?);
589 if parts.next().is_some() || host != DEFAULT_HOST || owner.is_empty() || name.is_empty() {
590 return None;
591 }
592 Some(format!("{owner}/{name}"))
593}
594
595/// Record that a `local-direct` publication landed — record, and nothing more.
596///
597/// Landing one is entirely repository-side work, a squash built detached and
598/// pushed, which this provider does not perform and must not be named as if it
599/// did. What it does is decide the outcome and emit the completion that says so.
600fn record_local_landing(
601 identity: &str,
602 session: &Session,
603 token: &SessionToken,
604) -> (PublishOutcome, Vec<Emission>) {
605 let sha = events::stable_sha(&["publish", &token.0, &session.branch]);
606 let emission = Emission {
607 stream: token.0.clone(),
608 identity: Some(identity.to_owned()),
609 kind: EventKind::MergeCompleted,
610 // Every record of a landing says when the base received it and at which
611 // commit, as `onevcs`'s own does: here, the moment this provider decided it.
612 payload: object(json!({
613 "identity": identity,
614 "sha": sha,
615 "base": session.base,
616 "landed_at": events::timestamp(),
617 "landing": sha,
618 })),
619 };
620 (PublishOutcome::Merged(Sha(sha)), vec![emission])
621}
622
623/// Everything one provider publication knows about itself.
624struct Publishing<'a> {
625 slug: &'a str,
626 identity: &'a str,
627 session: &'a Session,
628 policy: MergePolicy,
629 approvals: Approvals,
630 drafts: Drafts,
631 request: &'a PublishRequest,
632 token: &'a SessionToken,
633}
634
635impl Publishing<'_> {
636 /// One event on the session's own stream, under this identity.
637 fn emission(&self, kind: EventKind, payload: Value) -> Emission {
638 Emission {
639 stream: self.token.0.clone(),
640 identity: Some(self.identity.to_owned()),
641 kind,
642 payload: object(payload),
643 }
644 }
645}
646
647/// Publish as a change request: open the session's change on the host, or adopt
648/// the one it already holds, watch its required checks, and then do with it what
649/// the policy asks.
650///
651/// **The draft lifecycle, as next door.** A publication carrying no reason opens its
652/// change request as a draft while the checks run — or, adopting a draft, records it
653/// as awaiting them rather than lifting it on the spot — and once they are green lifts
654/// it before asking for any merge, or keeps it under `change-open` with approvals
655/// required. `drafts: {disabled: true}` opens ready and lifts an adopted draft at once.
656///
657/// **What this provider cannot do is wait.** It has no clock, so each phase of the
658/// watch is **one reading** of the host: a reading that has not settled is the bound
659/// elapsing (`checks-unsettled`), and a draft on which no required check has run is
660/// the grace window elapsing — it is lifted early, and the next reading is the host's
661/// answer after the lift, which is where [`HostState::checks_after_lift`](crate::HostState::checks_after_lift)
662/// comes in. A consumer drives each row of the lifecycle by seeding the reading it
663/// wants rather than by timing one.
664fn publish_as_change(
665 hosting: &dyn Hosting,
666 publishing: &Publishing<'_>,
667 emissions: &mut Vec<Emission>,
668) -> Result<PublishOutcome> {
669 let Publishing {
670 slug,
671 session,
672 policy,
673 request,
674 ..
675 } = *publishing;
676 let host = hosting.for_repo(slug)?;
677 // Who the host believes is calling travels with the change, as it does in the
678 // real publication and for the same reason.
679 let author = host.authenticated_user()?;
680 let lifecycle = !publishing.drafts.disabled.unwrap_or(false);
681 // Checked before anything is opened, as the real publication checks it before
682 // anything is pushed.
683 if lifecycle {
684 grace_seconds()?;
685 }
686 let existing = host.find_changes(&session.branch, &session.base)?;
687 // Whether the host already held it, which is what decides whether there can be a
688 // draft to lift — and, as next door, keeps a publication that opens its own change
689 // request from asking this host anything extra.
690 let adopted = !existing.is_empty();
691 let change = match existing.into_iter().next() {
692 Some(change) => change,
693 None => host.open_change(ChangeSpec {
694 head: session.branch.clone(),
695 base: session.base.clone(),
696 // A requested title has been checked by the conversion that built it, so
697 // this provider cannot accept one the real publication would refuse. The
698 // real implementation takes the subject from the branch's commits when no
699 // title was requested, and a provider has no commits to read — so an
700 // unrequested title names the branch instead.
701 title: request
702 .title
703 .as_deref()
704 .map_or_else(|| format!("Publish {}", session.branch), str::to_owned),
705 // Verbatim, and nothing when there is none — the real publication
706 // composes no body either, so a provider that composed one would be a
707 // consumer's suite proving a change request nobody opens.
708 body: request.body.clone(),
709 // The reason travels to the host and no further, as it does there: what
710 // the host does with it is open the change as a draft.
711 draft: request.draft.clone(),
712 draft_awaiting_checks: lifecycle && request.draft.is_none(),
713 })?,
714 };
715 emissions.push(publishing.emission(
716 EventKind::ChangeOpened,
717 json!({
718 "url": change.url.to_string(),
719 "host": "github",
720 "id": change.id.0,
721 "base": change.base,
722 "author": author,
723 }),
724 ));
725 if let Some(reason) = &request.draft {
726 // The same question the real publication asks, for the same reason: a host
727 // that ignored the request, or a change request already open for review,
728 // would otherwise be reported as held back while it can land.
729 if !host.is_draft(&change)? {
730 return Err(Error::Invalid {
731 reason: format!(
732 "{url} is open for review on the host, and this publication asked for \
733 {asked}. A change that is open can land, so reporting it as a draft would \
734 say the work is held back when nothing is holding it",
735 url = change.url,
736 asked = asked_for(reason),
737 ),
738 });
739 }
740 // The publication record, and the only place the reason is written: the real
741 // implementation writes nothing of it into the change request, and a provider
742 // that did would be a consumer's suite proving a body nobody renders. The
743 // payload is the reason's own serialized form — its `kind` and every field of
744 // that kind — under the change request it holds, exactly as next door.
745 let mut drafted = object(json!({
746 "url": change.url.to_string(),
747 "id": change.id.0,
748 "base": change.base,
749 }));
750 if let serde_json::Value::Object(fields) =
751 serde_json::to_value(reason).expect("a draft reason serializes")
752 {
753 drafted.extend(fields);
754 }
755 emissions.push(publishing.emission(EventKind::ChangeDrafted, Value::Object(drafted)));
756 // Under every policy: a draft is unmergeable in that state, so nothing below
757 // asks this host to merge it.
758 return Ok(PublishOutcome::ChangeDraft(change.url.clone()));
759 }
760 // Three answers about whether the host holds it as a draft, told apart as next
761 // door: a host that was never taught to draft one is holding nothing, and a host
762 // that *could not say* is a refusal rather than a change nobody is holding.
763 // Asked of a change this publication just opened only under the lifecycle, which
764 // asked for a draft: without it, one opened moments ago without a reason is one
765 // nobody drafted, and a host written against the earlier surface is asked nothing.
766 let is_draft = match if lifecycle || adopted {
767 host.is_draft(&change)
768 } else {
769 Ok(false)
770 } {
771 Ok(draft) => draft,
772 Err(Error::NotImplemented { .. }) => false,
773 Err(unreadable) => return Err(unreadable),
774 };
775 let drafted = if lifecycle {
776 if is_draft {
777 emissions.push(publishing.emission(
778 EventKind::ChangeDrafted,
779 json!({
780 "url": change.url.to_string(),
781 "id": change.id.0,
782 "base": change.base,
783 "kind": "awaiting-checks",
784 }),
785 ));
786 }
787 is_draft
788 } else {
789 // Without the lifecycle, publishing without a reason lifts an adopted draft
790 // on the spot, as it always did; one opened moments ago is one nobody drafted.
791 if adopted && is_draft {
792 lift(host.as_ref(), publishing, &change, emissions)?;
793 }
794 false
795 };
796
797 // Every change policy watches — except that `change-auto` on a change nobody
798 // drafted arms the host's own merge and leaves the checks to it, as next door.
799 // Next door's merge watch still refuses a required check that already concluded
800 // red rather than arm a merge over it, so this reading does too, and records the
801 // checks settled only where every check the host declares required let it
802 // through; one still running, or declared and not started, arms it, and the
803 // host's hold is what the outcome reports.
804 let still_draft = if drafted || policy != MergePolicy::ChangeAuto {
805 watch(host.as_ref(), publishing, &change, drafted, emissions)?
806 } else {
807 let answered = host.change_checks(&change)?;
808 let checks: Vec<&Check> = answered.checks.iter().collect();
809 let declared = given(
810 declared_required(host.as_ref(), &change),
811 answered.complete(),
812 &checks,
813 );
814 let standing = standings(&checks, &declared);
815 if let Some(failed) = red(&checks, &standing) {
816 return Err(failed);
817 }
818 if through(&standing, &declared) {
819 record_settled(
820 publishing,
821 &change,
822 emissions,
823 skipped(&standing),
824 declared.unread(),
825 );
826 }
827 false
828 };
829 if still_draft {
830 if policy == MergePolicy::ChangeOpen && publishing.approvals == Approvals::Required {
831 emissions.push(publishing.emission(
832 EventKind::DraftKeptForReview,
833 json!({
834 "url": change.url.to_string(),
835 "id": change.id.0,
836 "base": change.base,
837 }),
838 ));
839 return Ok(PublishOutcome::ChangeReviewDraft(change.url.clone()));
840 }
841 // Lifted before any merge is asked for: this host, like GitHub, will neither
842 // merge a draft nor arm its own merge on one.
843 lift(host.as_ref(), publishing, &change, emissions)?;
844 }
845
846 if policy == MergePolicy::ChangeOpen {
847 return Ok(PublishOutcome::ChangeOpen(change.url.clone()));
848 }
849 Ok(match host.merge(&change, policy)? {
850 MergeOutcome::Merged(sha) => {
851 // When the host says the base received it, as `onevcs`'s own record of a
852 // landing carries it. Where the host cannot say there is no landing commit
853 // in this world to read a time from either, so the record says nobody can
854 // time it rather than claim the moment this provider saw the merge.
855 let landed_at = host
856 .merge_time(&change)
857 .ok()
858 .flatten()
859 .and_then(|spelled| events::moment(&spelled));
860 emissions.push(publishing.emission(
861 EventKind::ChangeMerged,
862 json!({
863 "url": change.url.to_string(),
864 "sha": sha.0,
865 "landed_at": landed_at,
866 "landing": sha.0,
867 }),
868 ));
869 emissions.push(publishing.emission(
870 EventKind::MergeCompleted,
871 json!({
872 "identity": publishing.identity,
873 "sha": sha.0,
874 "landed_at": landed_at,
875 "landing": sha.0,
876 }),
877 ));
878 PublishOutcome::Merged(sha)
879 }
880 MergeOutcome::Queued => PublishOutcome::Queued(change.url.clone()),
881 MergeOutcome::Open => PublishOutcome::ChangeOpen(change.url.clone()),
882 })
883}
884
885/// Take a change out of its draft on the host, and record the lift.
886fn lift(
887 host: &dyn RemoteHost,
888 publishing: &Publishing<'_>,
889 change: &ChangeRequest,
890 emissions: &mut Vec<Emission>,
891) -> Result<()> {
892 host.ready_for_review(change)?;
893 emissions.push(publishing.emission(
894 EventKind::DraftLifted,
895 json!({"url": change.url.to_string(), "id": change.id.0}),
896 ));
897 Ok(())
898}
899
900/// Which checks a merge requires, as the host said: nothing, these names, or unknown —
901/// with why, which a settlement read off the host's marking says, as next door.
902#[derive(Clone, PartialEq, Eq)]
903enum Declared {
904 Nothing,
905 Names(BTreeSet<String>),
906 Unknown(String),
907}
908
909impl Declared {
910 /// Why what is required is read off the host's own per-check marking, where it is.
911 fn unread(&self) -> Option<&str> {
912 match self {
913 Declared::Unknown(because) => Some(because),
914 Declared::Nothing | Declared::Names(_) => None,
915 }
916 }
917}
918
919/// What the host declares a merge into the change's base requires, and why it could
920/// not be read where it could not — as next door, which reads every phase of a watch
921/// against this one answer.
922fn declared_required(host: &dyn RemoteHost, change: &ChangeRequest) -> Declared {
923 match host.required_checks_on(&change.base) {
924 Ok(answer) if answer.checks.is_empty() && answer.complete() => Declared::Nothing,
925 Ok(answer) if !answer.checks.is_empty() => Declared::Names(answer.checks),
926 Ok(_) => Declared::Unknown(format!(
927 "the host answered only in part about which checks a merge into {} requires",
928 change.base
929 )),
930 Err(refused) => Declared::Unknown(format!(
931 "the host would not say which checks a merge into {} requires: {refused}",
932 change.base
933 )),
934 }
935}
936
937/// The declaration, sharpened by one reading as next door sharpens it: where the host
938/// would not say what it requires, a complete rollup reporting checks none of which is
939/// required is its answer that nothing is.
940fn given(declared: Declared, complete: bool, checks: &[&Check]) -> Declared {
941 if matches!(declared, Declared::Unknown(_))
942 && complete
943 && !checks.is_empty()
944 && checks.iter().all(|check| !check.required)
945 {
946 return Declared::Nothing;
947 }
948 declared
949}
950
951/// Each required check's name and the state its entries add up to — `None` where the
952/// host reported none — ranked as next door: red, then running, then passed, then no
953/// verdict, then skipped.
954fn standings(checks: &[&Check], declared: &Declared) -> Vec<(String, Option<CheckState>)> {
955 let names: BTreeSet<String> = match declared {
956 Declared::Names(names) => names.clone(),
957 Declared::Nothing => BTreeSet::new(),
958 Declared::Unknown(_) => checks
959 .iter()
960 .filter(|check| check.required)
961 .map(|check| check.name.clone())
962 .collect(),
963 };
964 let rank = |state: CheckState| match state {
965 CheckState::Failed => 0,
966 CheckState::Pending => 1,
967 CheckState::Passed => 2,
968 CheckState::NoVerdict => 3,
969 CheckState::Skipped => 4,
970 };
971 names
972 .into_iter()
973 .map(|name| {
974 let state = checks
975 .iter()
976 .filter(|check| check.name == name)
977 .map(|check| check.state())
978 .min_by_key(|state| rank(*state));
979 (name, state)
980 })
981 .collect()
982}
983
984/// The refusal for the first required check that concluded red.
985fn red(checks: &[&Check], standing: &[(String, Option<CheckState>)]) -> Option<Error> {
986 let (name, _) = standing
987 .iter()
988 .find(|(_, state)| *state == Some(CheckState::Failed))?;
989 let check = checks
990 .iter()
991 .find(|check| &check.name == name && check.state() == CheckState::Failed)?;
992 Some(Error::ChecksFailed {
993 reason: format!(
994 "required check {:?} concluded {}.",
995 check.name,
996 check
997 .conclusion
998 .as_deref()
999 .unwrap_or("without a conclusion")
1000 ),
1001 })
1002}
1003
1004/// Whether every required check lets the change through — passed, or skipped — over
1005/// a set that is empty only where the host answered, completely, that nothing is
1006/// required. A declared check with no run holds it, as next door.
1007fn through(standing: &[(String, Option<CheckState>)], declared: &Declared) -> bool {
1008 (*declared == Declared::Nothing || !standing.is_empty())
1009 && standing
1010 .iter()
1011 .all(|(_, state)| matches!(state, Some(CheckState::Passed | CheckState::Skipped)))
1012}
1013
1014/// What a reading that has not settled names, in the words next door's bound names it
1015/// in: each required check still running or with no run yet, then the ones that ended
1016/// with no verdict or were skipped, and only then that every one settled or that the
1017/// host declared none — and, where the declaration could not be read, that too.
1018fn unsettled_named(
1019 checks: &[&Check],
1020 standing: &[(String, Option<CheckState>)],
1021 declared: &Declared,
1022) -> String {
1023 let pending: Vec<String> = standing
1024 .iter()
1025 .filter_map(|(name, state)| match state {
1026 Some(CheckState::Pending) => Some(format!("{name:?}")),
1027 None => Some(format!("{name:?} (no run yet)")),
1028 _ => None,
1029 })
1030 .collect();
1031 let no_verdict: Vec<String> = standing
1032 .iter()
1033 .filter(|(_, state)| *state == Some(CheckState::NoVerdict))
1034 .map(|(name, _)| {
1035 let conclusion = checks
1036 .iter()
1037 .find(|check| &check.name == name && check.state() == CheckState::NoVerdict)
1038 .and_then(|check| check.conclusion.as_deref())
1039 .unwrap_or("unknown");
1040 format!("{name:?}: {conclusion}")
1041 })
1042 .collect();
1043 let skipped: Vec<String> = skipped(standing)
1044 .iter()
1045 .map(|name| format!("{name:?}"))
1046 .collect();
1047 let named = if !pending.is_empty() {
1048 let verdicts = if no_verdict.is_empty() {
1049 String::new()
1050 } else {
1051 format!("; completed with no verdict: {}", no_verdict.join(", "))
1052 };
1053 format!("still unsettled: {}{verdicts}", pending.join(", "))
1054 } else if !no_verdict.is_empty() {
1055 format!("completed with no verdict: {}", no_verdict.join(", "))
1056 } else if !skipped.is_empty() {
1057 format!(
1058 "skipped on the draft, so not run on it: {}",
1059 skipped.join(", ")
1060 )
1061 } else if standing.is_empty() {
1062 match declared {
1063 Declared::Nothing => "the host declared no required check on it at all".to_owned(),
1064 _ => "it has marked no check required on it".to_owned(),
1065 }
1066 } else {
1067 "every required check it declared had settled".to_owned()
1068 };
1069 let read_from = declared
1070 .unread()
1071 .map(|because| {
1072 format!(
1073 ". Which checks it requires could not be read, so they were read from the \
1074 checks it marked required: {because}"
1075 )
1076 })
1077 .unwrap_or_default();
1078 format!("{named}{read_from}")
1079}
1080
1081/// The skipped required checks, by name.
1082fn skipped(standing: &[(String, Option<CheckState>)]) -> Vec<String> {
1083 standing
1084 .iter()
1085 .filter(|(_, state)| *state == Some(CheckState::Skipped))
1086 .map(|(name, _)| name.clone())
1087 .collect()
1088}
1089
1090/// Record that the required checks stopped blocking — `passed`, or
1091/// `passed-with-skipped` naming the skipped ones.
1092///
1093/// `unread`: the requirement was read from the host's own per-check marking because
1094/// its declaration could not be read, which the record says, as next door.
1095fn record_settled(
1096 publishing: &Publishing<'_>,
1097 change: &ChangeRequest,
1098 emissions: &mut Vec<Emission>,
1099 skipped: Vec<String>,
1100 unread: Option<&str>,
1101) {
1102 let verdict = if skipped.is_empty() {
1103 "passed"
1104 } else {
1105 "passed-with-skipped"
1106 };
1107 let mut payload = json!({
1108 "url": change.url.to_string(),
1109 "id": change.id.0,
1110 "head": change.head_sha.0,
1111 "verdict": verdict,
1112 "skipped": skipped,
1113 });
1114 if let Some(because) = unread {
1115 eprintln!(
1116 "onevcs: warning: the required checks on {} were read from the host's own \
1117 per-check marking, because the declaration could not be read: {because}",
1118 change.url
1119 );
1120 payload["requirement"] = json!({"read_from": "host-marking", "because": because});
1121 }
1122 emissions.push(publishing.emission(EventKind::ChecksSettled, payload));
1123}
1124
1125/// The watch, one reading per phase: `Ok(true)` is green on a draft still standing,
1126/// `Ok(false)` green on a ready change. See [`publish_as_change`] for what a reading
1127/// stands for.
1128fn watch(
1129 host: &dyn RemoteHost,
1130 publishing: &Publishing<'_>,
1131 change: &ChangeRequest,
1132 drafted: bool,
1133 emissions: &mut Vec<Emission>,
1134) -> Result<bool> {
1135 let settled = |emissions: &mut Vec<Emission>, skipped: Vec<String>, unread: Option<&str>| {
1136 record_settled(publishing, change, emissions, skipped, unread);
1137 };
1138 let unsettled = |checks: &[&Check],
1139 standing: &[(String, Option<CheckState>)],
1140 declared: &Declared| Error::ChecksUnsettled {
1141 reason: format!(
1142 "the host had not settled its required checks on {}; {}",
1143 change.url,
1144 unsettled_named(checks, standing, declared)
1145 ),
1146 };
1147 let answered = host.change_checks(change)?;
1148 let complete = answered.complete();
1149 let checks: Vec<&Check> = answered.checks.iter().collect();
1150 // What the host declares it requires, read once and sharpened by this reading.
1151 let declared = given(declared_required(host, change), complete, &checks);
1152 let unread = declared.unread();
1153
1154 if !drafted {
1155 let standing = standings(&checks, &declared);
1156 if let Some(failed) = red(&checks, &standing) {
1157 return Err(failed);
1158 }
1159 if !through(&standing, &declared) {
1160 return Err(unsettled(&checks, &standing, &declared));
1161 }
1162 settled(emissions, skipped(&standing), unread);
1163 return Ok(false);
1164 }
1165
1166 // The draft: a skipped required check has not run.
1167 if matches!(declared, Declared::Nothing) {
1168 settled(emissions, Vec::new(), None);
1169 return Ok(true);
1170 }
1171 let standing = standings(&checks, &declared);
1172 if let Some(failed) = red(&checks, &standing) {
1173 return Err(failed);
1174 }
1175 if !standing.is_empty()
1176 && standing
1177 .iter()
1178 .all(|(_, state)| *state == Some(CheckState::Passed))
1179 {
1180 settled(emissions, Vec::new(), unread);
1181 return Ok(true);
1182 }
1183 let not_run: Vec<String> = standing
1184 .iter()
1185 .filter(|(_, state)| matches!(state, None | Some(CheckState::Skipped)))
1186 .map(|(name, _)| name.clone())
1187 .collect();
1188 let running = standing
1189 .iter()
1190 .any(|(_, state)| matches!(state, Some(CheckState::Pending | CheckState::NoVerdict)));
1191 let unseen = matches!(declared, Declared::Unknown(_)) && standing.is_empty();
1192 // The grace window, elapsed: where the host would not say what it requires and
1193 // checks ran on the draft with none skipped, its own marking is the answer.
1194 if unseen
1195 && !checks.is_empty()
1196 && checks
1197 .iter()
1198 .all(|check| check.state() != CheckState::Skipped)
1199 {
1200 settled(emissions, Vec::new(), unread);
1201 return Ok(true);
1202 }
1203 if running || (not_run.is_empty() && !unseen) {
1204 return Err(unsettled(&checks, &standing, &declared));
1205 }
1206
1207 // The grace window elapsed with nothing required run: lift early, say so, and read
1208 // the host again as a ready change whose checks must run after the lift.
1209 let snapshot: Vec<Check> = answered.checks.clone();
1210 lift(host, publishing, change, emissions)?;
1211 let warned = publishing.drafts.warn_on_early_lift.unwrap_or(true);
1212 let grace = grace_seconds()?;
1213 if warned {
1214 eprintln!(
1215 "onevcs: warning: {} was lifted out of its draft before its required checks ran on \
1216 it ({}): none had run within {grace}s, which is what a workflow that skips drafts \
1217 looks like",
1218 change.url,
1219 not_run.join(", ")
1220 );
1221 }
1222 emissions.push(publishing.emission(
1223 EventKind::DraftLiftedEarly,
1224 json!({
1225 "url": change.url.to_string(),
1226 "id": change.id.0,
1227 "base": change.base,
1228 "awaited": not_run,
1229 "grace_seconds": grace,
1230 "warned": warned,
1231 }),
1232 ));
1233 let after = host.change_checks(change)?.checks;
1234 let from_the_draft = |check: &Check| !ran_after_the_lift(check, &snapshot);
1235 let counted: Vec<&Check> = after
1236 .iter()
1237 .filter(|check| !(from_the_draft(check) && check.state() == CheckState::Skipped))
1238 .collect();
1239 let standing = standings(&counted, &declared);
1240 if let Some(failed) = red(&counted, &standing) {
1241 return Err(failed);
1242 }
1243 let rerun = after.iter().any(|check| {
1244 !from_the_draft(check)
1245 && (standing.is_empty() || standing.iter().any(|(name, _)| *name == check.name))
1246 });
1247 if !rerun {
1248 return Err(Error::ChecksUnsettled {
1249 reason: format!(
1250 "the required checks on {} did not re-run after its draft was lifted: no run \
1251 the host attached after the lift appeared, and the runs from while it was a \
1252 draft were skipped, which is not a verdict. The likely cause is a workflow that \
1253 does not trigger on `ready_for_review`",
1254 change.url
1255 ),
1256 });
1257 }
1258 // The after-lift rule is next door's: a run attached since the lift, and every
1259 // required check it stands for passed or skipped.
1260 if !standing
1261 .iter()
1262 .all(|(_, state)| matches!(state, Some(CheckState::Passed | CheckState::Skipped)))
1263 {
1264 return Err(unsettled(&counted, &standing, &declared));
1265 }
1266 settled(emissions, skipped(&standing), unread);
1267 Ok(false)
1268}
1269
1270/// Whether `check` is a run the host attached after the lift rather than the draft's
1271/// run of it in `snapshot`, decided as the real publication decides it: by the run's
1272/// own identity, never by its conclusion or its status. A check the draft never
1273/// reported is new, and one it did report is new only where it reports a `started_at`
1274/// no draft-era run of that check reported, running or settled — so a consumer seeds a
1275/// re-run by giving it a start of its own.
1276fn ran_after_the_lift(check: &Check, snapshot: &[Check]) -> bool {
1277 let earlier: Vec<&Check> = snapshot
1278 .iter()
1279 .filter(|seen| seen.name == check.name)
1280 .collect();
1281 if earlier.is_empty() {
1282 return true;
1283 }
1284 check.started_at.as_ref().is_some_and(|started| {
1285 earlier
1286 .iter()
1287 .all(|seen| seen.started_at.as_ref() != Some(started))
1288 })
1289}
1290
1291/// The default grace window of the real publication, which is `onevcs`'s own and
1292/// private to it. A copy, so it is gated: `lifecycle.rs`'s
1293/// `the_grace_window_an_early_lift_records_is_the_one_onevcs_defaults_to` reads the
1294/// constant out of `onevcs`'s source and holds this to it.
1295const DEFAULT_DRAFT_GRACE_SECONDS: f64 = 120.0;
1296
1297/// The knob a real publication waits its grace window by.
1298const DRAFT_GRACE_ENV: &str = "ONEVCS_DRAFT_CHECKS_GRACE_SECONDS";
1299
1300/// The grace window a real publication would have waited out, as the operator set it
1301/// or the default — recorded on `draft-lifted-early` so the payload reads as the real
1302/// one does, though nothing here waits.
1303///
1304/// Validated as the real publication validates it, and refused by name where it is
1305/// not a finite number of seconds above zero: a provider that quietly took the default
1306/// would let a consumer's suite pass on a setting the real one refuses.
1307fn grace_seconds() -> Result<f64> {
1308 let Some(raw) = std::env::var_os(DRAFT_GRACE_ENV) else {
1309 return Ok(DEFAULT_DRAFT_GRACE_SECONDS);
1310 };
1311 let raw = raw.to_string_lossy().into_owned();
1312 let seconds: f64 = raw.trim().parse().map_err(|_| Error::Invalid {
1313 reason: format!("{DRAFT_GRACE_ENV} must be a number of seconds, not {raw:?}"),
1314 })?;
1315 if !seconds.is_finite() || seconds <= 0.0 {
1316 return Err(Error::Invalid {
1317 reason: format!(
1318 "{DRAFT_GRACE_ENV} must be a finite number of seconds above zero, not {raw:?}"
1319 ),
1320 });
1321 }
1322 Ok(seconds)
1323}
1324
1325/// The draft a publication asked for, as the two refusals next door spell it: "a
1326/// draft awaiting <repository> <target>", or "a draft held by the session that opened
1327/// it (<why>)". A restatement, so the refusals here read as the real ones do.
1328fn asked_for(reason: &DraftReason) -> String {
1329 match reason {
1330 DraftReason::AwaitingRelease {
1331 awaiting, target, ..
1332 } => format!("a draft awaiting {awaiting} {target}"),
1333 DraftReason::Held { because } => {
1334 format!("a draft held by the session that opened it ({because})")
1335 }
1336 }
1337}
1338
1339/// What a publication answers for an identity no change request can be opened
1340/// against.
1341///
1342/// Two failures rather than one, as the real implementation keeps them: an
1343/// identity that is not hosted at all is asking for the wrong policy, while a
1344/// hosted one on a host this build does not speak for is asking for an
1345/// implementation that has not arrived.
1346fn refusal(identity: &str) -> PublishOutcome {
1347 failed(&if identity.split('/').count() == 3 {
1348 Error::NotImplemented {
1349 operation: "RemoteHost for a host other than github.com",
1350 }
1351 } else {
1352 Error::Invalid {
1353 reason: format!(
1354 "identity {identity:?} is not a hosted repository, so it cannot publish a \
1355 change request; a local identity publishes with local-direct"
1356 ),
1357 }
1358 })
1359}
1360
1361/// One failure, as the outcome a publication that started and did not land is.
1362fn failed(error: &Error) -> PublishOutcome {
1363 PublishOutcome::Failed {
1364 // Through the crate's own mapping, so the kind a caller branches on is the
1365 // one the real implementation would report for the same failure.
1366 kind: FailureKind::of(error),
1367 reason: error.to_string(),
1368 // A provider has no execution checkout, so there is nowhere a branch could
1369 // have been handed back to and nothing to report about one.
1370 retained: None,
1371 }
1372}
1373
1374/// The argv that lands a preserved branch, as `recoverable` reports it.
1375///
1376/// Both verbs take the branch by name and the repository by path, the way the real
1377/// implementation reports them: the provenance decides which one, and neither
1378/// depends on the directory a reader happens to be standing in.
1379// llmlint: ignore[names_match_behavior] named for the public
1380// `Recoverable::recover_command` field it fills, which `onevcs` publishes and this
1381// crate must answer identically; a provider whose helper were named otherwise would
1382// read as filling something else.
1383fn recover_command(branch: &str, checkout: &Path, provenance: Provenance) -> Vec<String> {
1384 let verb = match provenance {
1385 Provenance::IncompleteStep => "recover",
1386 Provenance::Complete => "publish-branch",
1387 };
1388 vec![
1389 "onevcs".to_owned(),
1390 verb.to_owned(),
1391 branch.to_owned(),
1392 "--repo".to_owned(),
1393 checkout.display().to_string(),
1394 ]
1395}
1396
1397/// How a provenance kind is spelled in an event payload.
1398fn spell(provenance: Provenance) -> &'static str {
1399 match provenance {
1400 Provenance::Complete => "complete",
1401 Provenance::IncompleteStep => "incomplete-step",
1402 }
1403}
1404
1405fn object(value: Value) -> Map<String, Value> {
1406 value.as_object().cloned().unwrap_or_default()
1407}