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 onevcs::{
18 ChangeSpec, DraftReason, Error, EventKind, FailureKind, HeldBy, Holding, Hosting, Identity,
19 Landed, Lifecycle, MergeOutcome, MergePolicy, PreservedBranch, Provenance, Publication,
20 PublishOutcome, PublishRequest, Recoverable, Result, Scope, Session, SessionRecord,
21 SessionRequest, SessionToken, Sha, Vcs,
22};
23
24use crate::events::{self, Emission};
25use crate::remote::DEFAULT_HOST;
26use crate::state::{self, VcsState};
27use crate::store::{FileStore, MemoryStore, Store};
28
29/// The base a session is cut from when the request names none.
30///
31/// The real implementation asks the origin for its default branch, and this
32/// provider has no origin to ask.
33pub const DEFAULT_BASE: &str = "main";
34
35/// The policy a publication takes when nothing was seeded and nothing requested.
36///
37/// The policy the contract's own `default:` names, which is what the real
38/// implementation resolves to for a registry with no rules file.
39pub const DEFAULT_PUBLICATION: MergePolicy = MergePolicy::ChangeOpen;
40
41/// The repository side of a run, over whichever store holds its state.
42///
43/// The two flavours below are this one behaviour with a different store under it,
44/// so neither can learn something the other does not know.
45#[derive(Debug)]
46pub struct Repository<T> {
47 store: T,
48 root: PathBuf,
49 trees: Trees,
50}
51
52/// What a session's worktree path means.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54enum Trees {
55 /// The path is named and nothing is created there: the in-memory provider
56 /// touches no filesystem beyond the event stream.
57 Named,
58 /// The directory is created, so a journey has somewhere to write work.
59 Created,
60}
61
62/// A repository provider that keeps its state in this process: no disk, no
63/// visibility to a second process, and the fastest of the two.
64///
65/// Its sessions name a worktree under the system temporary directory and **do not
66/// create it** — nothing here touches the filesystem except the event stream, which
67/// is the record a journey reads.
68pub type MemoryVcs = Repository<MemoryStore<VcsState>>;
69
70/// A repository provider that keeps its state in one JSON document, so several
71/// `onevcs` invocations see one another's effects.
72///
73/// Its sessions name a worktree beside that document **and create it**, so a
74/// journey that writes a file into a session's tree has somewhere to write it.
75pub type FileVcs = Repository<FileStore<VcsState>>;
76
77impl MemoryVcs {
78 /// A repository provider knowing nothing.
79 pub fn new() -> Self {
80 Self::seeded(VcsState::default())
81 }
82
83 /// A repository provider that starts from a scenario.
84 pub fn seeded(state: VcsState) -> Self {
85 Self {
86 store: MemoryStore::new(state),
87 root: std::env::temp_dir().join("onevcs-testing-memory"),
88 trees: Trees::Named,
89 }
90 }
91
92 /// Everything it knows.
93 pub fn state(&self) -> VcsState {
94 self.store
95 .snapshot()
96 .expect("an in-memory store always answers")
97 }
98}
99
100impl Default for MemoryVcs {
101 fn default() -> Self {
102 Self::new()
103 }
104}
105
106impl FileVcs {
107 /// A repository provider keeping its state at `path`: whatever is already
108 /// there, or nothing.
109 ///
110 /// Attaching rather than replacing, so a second provider over the same path
111 /// picks up what the first one left — which is what a journey driving several
112 /// invocations reaches for this flavour to get.
113 pub fn create(path: impl Into<PathBuf>) -> Result<Self> {
114 Self::over(FileStore::attach(path, &VcsState::default())?)
115 }
116
117 /// A repository provider that starts from a scenario, keeping its state at
118 /// `path` and replacing whatever was there.
119 pub fn seeded(path: impl Into<PathBuf>, state: VcsState) -> Result<Self> {
120 Self::over(FileStore::replace(path, &state)?)
121 }
122
123 fn over(store: FileStore<VcsState>) -> Result<Self> {
124 let root = store
125 .path()
126 .parent()
127 .filter(|parent| !parent.as_os_str().is_empty())
128 .unwrap_or_else(|| Path::new("."))
129 .join("worktrees");
130 Ok(Self {
131 store,
132 root,
133 trees: Trees::Created,
134 })
135 }
136
137 /// Everything it knows, read back out of its document.
138 pub fn state(&self) -> Result<VcsState> {
139 self.store.snapshot()
140 }
141}
142
143impl<T: Store<VcsState>> Vcs for Repository<T> {
144 fn resolve_identity(&self, origin_or_path: &str) -> Result<Identity> {
145 self.store.with(|state| {
146 state::identity_of(state, origin_or_path)
147 .cloned()
148 .ok_or_else(|| Error::Invalid {
149 reason: format!(
150 "{origin_or_path:?} does not name a repository this provider knows; {}",
151 state::known(state)
152 ),
153 })
154 })
155 }
156
157 fn open_session(&self, req: SessionRequest) -> Result<Session> {
158 let root = self.root.clone();
159 let (session, emission) = self.store.with(|state| {
160 let identity = state::identity_of(state, &req.repo)
161 .cloned()
162 .ok_or_else(|| Error::Invalid {
163 reason: format!(
164 "{:?} does not name a repository this provider knows; {}",
165 req.repo,
166 state::known(state)
167 ),
168 })?;
169 // Consecutive and predictable, so a journey can name the session it is
170 // about to open — the one thing a digest-shaped token takes away.
171 let token = SessionToken(format!("s-testing-{}", state.sessions.len() + 1));
172 let run_root = root.join(&token.0);
173 // Both names are checked before they are recorded, because both go on to
174 // spell a ref for whoever holds the session — and a provider that
175 // accepted a name git refuses would let a journey pass where the real
176 // run stops.
177 let base = req.base.clone().unwrap_or_else(|| DEFAULT_BASE.to_owned());
178 state::named_branch(&base, "the base")?;
179 for (key, value) in &req.labels {
180 state::label_pair(key, value)?;
181 }
182 let session = Session {
183 worktree: run_root.join("worktree"),
184 branch: state::requested_branch(&req, &token)?,
185 base,
186 token: token.clone(),
187 };
188 state.sessions.push(session.clone());
189 state
190 .session_identities
191 .insert(token.clone(), identity.origin.clone());
192 if !req.labels.is_empty() {
193 state
194 .session_labels
195 .insert(token.clone(), req.labels.clone());
196 }
197 let emission = Emission {
198 stream: token.0.clone(),
199 identity: Some(identity.origin.clone()),
200 kind: EventKind::SessionOpened,
201 payload: object(json!({
202 "token": token.0,
203 "identity": identity.origin,
204 "branch": session.branch,
205 "base": session.base,
206 "worktree": session.worktree.display().to_string(),
207 // Synthetic, and named anyway: a consumer reading this event
208 // reads the same keys whichever implementation produced it.
209 "clone": run_root.join("clone").display().to_string(),
210 "execution_checkout": run_root.join("checkout").display().to_string(),
211 "publication_checkout": run_root.join("checkout").display().to_string(),
212 // A run root of its own, which is the one placement a provider
213 // that keeps no pool can make — and the one every real session
214 // gets on a host that configures none.
215 "placement": {"kind": "run-root"},
216 })),
217 };
218 Ok((session, emission))
219 })?;
220 if self.trees == Trees::Created {
221 std::fs::create_dir_all(&session.worktree).map_err(|e| Error::Invalid {
222 reason: format!("cannot create {}: {e}", session.worktree.display()),
223 })?;
224 }
225 events::emit(&emission);
226 Ok(session)
227 }
228
229 fn adopt_session(&self, token: SessionToken) -> Result<Session> {
230 self.store.with(|state| {
231 state::session_of(state, &token)
232 .cloned()
233 .ok_or_else(|| Error::Invalid {
234 reason: format!(
235 "no session {:?} is open; `onevcs session open` prints a token",
236 token.0
237 ),
238 })
239 })
240 }
241
242 fn preserve(&self, s: &Session, provenance: Provenance) -> Result<PreservedBranch> {
243 let (branch, emission) = self.store.with(|state| {
244 let identity = state::identity_for(state, &s.token)?;
245 let branch = PreservedBranch {
246 branch: s.branch.clone(),
247 base: s.base.clone(),
248 provenance,
249 change_url: None,
250 change_base: None,
251 };
252 let row = Recoverable {
253 identity: identity.clone(),
254 branch: branch.clone(),
255 checkout: s.worktree.clone(),
256 stopped_because: format!("session {} was left open", s.token.0),
257 recover_command: recover_command(&s.branch, &s.worktree, provenance),
258 // Preserved work this provider was handed is work nobody published:
259 // there is no base here whose history could record a landing, and no
260 // tree to compare content against, so the one answer it can give is
261 // the one it knows.
262 landed: Landed::No,
263 // Both answered where the row is *read* rather than frozen in here:
264 // whether a session still holds its branch is a fact about the session
265 // now, and there is no tree here to count a diff's lines against.
266 held_by: None,
267 net_negative: None,
268 // The session that preserved it is the session that answers for it,
269 // and what it was opened with is what the row carries.
270 session: Some(s.token.clone()),
271 labels: state
272 .session_labels
273 .get(&s.token)
274 .cloned()
275 .unwrap_or_default(),
276 // A preservation onto an origin is a thing only a real repository can
277 // do — there is no git here and no remote to push to — so this provider
278 // answers what a scenario wrote down and nothing else. A hand-written
279 // state may still seed one, exactly as it seeds a hold.
280 on_origin: None,
281 };
282 // Preserving the same branch twice replaces its row rather than listing
283 // it twice, which is what `recoverable` does across the checkouts a
284 // branch is reachable from.
285 state.preserved.retain(|kept| {
286 kept.identity != row.identity || kept.branch.branch != row.branch.branch
287 });
288 state.preserved.push(row);
289 let emission = Emission {
290 stream: s.token.0.clone(),
291 // No identity label, because the real implementation carries none
292 // here: the label is stamped where a session is opened, and work is
293 // preserved against a stream a later process opened fresh. Claiming
294 // it would be drift in the direction that looks like more
295 // information.
296 identity: None,
297 kind: EventKind::CommitPreserved,
298 payload: object(json!({
299 "branch": s.branch,
300 "sha": events::stable_sha(&[&s.token.0, &s.branch, spell(provenance)]),
301 "provenance": spell(provenance),
302 })),
303 };
304 Ok((branch, emission))
305 })?;
306 events::emit(&emission);
307 Ok(branch)
308 }
309
310 fn session(&self, token: &SessionToken) -> Result<SessionRecord> {
311 self.store.with(|state| {
312 let session = state::session_of(state, token)
313 .cloned()
314 .ok_or_else(|| unknown_session(token))?;
315 let identity = state::identity_for(state, token)?;
316 // Read off what was preserved rather than remembered separately: the
317 // real implementation reads the branch, so a session whose work was
318 // preserved behind an incomplete-step marker answers the same here.
319 let provenance = state
320 .preserved
321 .iter()
322 .find(|row| row.identity == identity && row.branch.branch == session.branch)
323 .map_or(Provenance::Complete, |row| row.branch.provenance);
324 Ok(SessionRecord {
325 lifecycle: if state.closed_sessions.contains(token) {
326 Lifecycle::Closed
327 } else {
328 Lifecycle::Open
329 },
330 session,
331 identity,
332 provenance,
333 // A retry link is a fact about run clones under a state root, and
334 // this provider keeps none: its sessions are records in a store, so
335 // there is no second copy of a branch for one to tell apart.
336 retried_by: None,
337 })
338 })
339 }
340
341 fn close_session(&self, token: &SessionToken) -> Result<Session> {
342 self.store.with(|state| {
343 let session = state::session_of(state, token)
344 .cloned()
345 .ok_or_else(|| unknown_session(token))?;
346 let emission = Emission {
347 stream: token.0.clone(),
348 // No identity label, because the real implementation carries none
349 // here: closing opens the stream fresh, and the label is stamped
350 // where a session is opened.
351 identity: None,
352 kind: EventKind::SessionClosed,
353 payload: object(json!({"token": token.0, "branch": session.branch})),
354 };
355 // Match the real provider's observable ordering: a follower reads the
356 // stream before consulting this state, so the terminator must exist
357 // before `Closed` can be returned to that concurrent reader.
358 events::emit(&emission);
359 state.closed_sessions.insert(token.clone());
360 Ok(session)
361 })
362 }
363
364 /// Publish a session's branch, as far as a provider honestly can.
365 ///
366 /// The host side is performed rather than described: a change request is really
367 /// opened against the [`Hosting`] this was handed, really adopted when one is
368 /// already open, and really merged under the policy — so the six host methods
369 /// are exercised and what the host recorded is what a journey reads back.
370 ///
371 /// The repository side is not, and none of it is claimed. There is no origin to
372 /// fetch from, no tree to build in, no push, and no lock to queue behind, so no
373 /// `fetch`, `push`, `lock-wait`, `lock-acquired`, or `merge-queued` event is
374 /// emitted. What is emitted is what was decided: the change that was opened, and
375 /// the merge that landed.
376 fn publish(
377 &self,
378 token: &SessionToken,
379 request: &PublishRequest,
380 hosting: &dyn Hosting,
381 ) -> Result<Publication> {
382 let (publication, emissions) = self.store.with(|state| {
383 let session = state::session_of(state, token)
384 .cloned()
385 .ok_or_else(|| unknown_session(token))?;
386 let identity = state::identity_for(state, token)?;
387 // The publication rule the crate next door states, applied rather than
388 // restated: a reason that would not render as itself is refused here
389 // exactly where the real implementation refuses it, before any host is
390 // asked and before anything is recorded.
391 if let Some(reason) = &request.draft {
392 reason.checked()?;
393 }
394 let resolved = state.policy.unwrap_or(DEFAULT_PUBLICATION);
395 let policy = match request.policy {
396 Some(requested) => resolved.narrow(requested)?,
397 None => resolved,
398 };
399 let published = |outcome, emissions| {
400 (
401 Publication {
402 session: token.clone(),
403 branch: session.branch.clone(),
404 policy,
405 outcome,
406 },
407 emissions,
408 )
409 };
410 // A session that has already landed has nothing the base does not carry,
411 // which is what the real implementation reports for the same reason. One
412 // whose change request is merely open or queued has *not* landed, and
413 // publishing it again adopts that change rather than opening a second —
414 // so it falls through to the host, as it does there.
415 if state.publications.iter().any(|earlier| {
416 earlier.session == *token && matches!(earlier.outcome, PublishOutcome::Merged(_))
417 }) {
418 let (publication, emissions) =
419 published(PublishOutcome::NothingToPublish, Vec::new());
420 state.publications.push(publication.clone());
421 return Ok((publication, emissions));
422 }
423
424 let (outcome, emissions) = if policy == MergePolicy::LocalDirect {
425 // The same refusal the real publication makes at the same boundary: a
426 // local-direct publication opens no change request, so there is
427 // nothing to draft and the work would land carrying the very pin the
428 // draft exists to hold back.
429 match &request.draft {
430 Some(reason) => (
431 failed(&Error::Invalid {
432 reason: format!(
433 "{branch:?} was asked to publish as {asked}, and this identity \
434 publishes with local-direct, which opens no change request at \
435 all",
436 branch = session.branch,
437 asked = asked_for(reason),
438 ),
439 }),
440 Vec::new(),
441 ),
442 None => record_local_landing(&identity, &session, token),
443 }
444 } else {
445 match slug(&identity) {
446 Some(slug) => match publish_as_change(
447 hosting, &slug, &identity, &session, policy, request, token,
448 ) {
449 Ok(published) => published,
450 // Once a publication has started, what stops it is an outcome
451 // rather than a refusal — the same split the real
452 // implementation keeps, so a caller reads one shape.
453 Err(error) => (failed(&error), Vec::new()),
454 },
455 None => (refusal(&identity), Vec::new()),
456 }
457 };
458 let (publication, emissions) = published(outcome, emissions);
459 state.publications.push(publication.clone());
460 Ok((publication, emissions))
461 })?;
462 for emission in &emissions {
463 events::emit(emission);
464 }
465 Ok(publication)
466 }
467
468 /// The same rows [`Vcs::recoverable`] answers with.
469 ///
470 /// This provider is *handed* its preserved work, and what it is handed is work
471 /// nobody published: there is no base here whose history could record a landing
472 /// and no tree to compare content against, so it withholds nothing and the wider
473 /// question has the same answer as the narrower one.
474 fn preserved(&self, scope: Scope) -> Result<Vec<Recoverable>> {
475 self.recoverable(scope)
476 }
477
478 fn recoverable(&self, scope: Scope) -> Result<Vec<Recoverable>> {
479 self.store.with(|state| {
480 let wanted = match &scope {
481 Scope::All => None,
482 Scope::Repo(repo) => Some(
483 state::identity_of(state, repo)
484 .map(|identity| identity.origin.clone())
485 .ok_or_else(|| Error::Invalid {
486 reason: format!(
487 "{repo:?} does not name a repository this provider knows; {}",
488 state::known(state)
489 ),
490 })?,
491 ),
492 };
493 // Newest first, as the real implementation reports them.
494 Ok(state
495 .preserved
496 .iter()
497 .rev()
498 .filter(|row| wanted.as_ref().is_none_or(|key| *key == row.identity))
499 .cloned()
500 .map(|row| held(state, row))
501 .collect())
502 })
503 }
504}
505
506/// Say which live session still holds a row's branch, when one of this provider's
507/// does.
508///
509/// Read here rather than written when the work was preserved, because closing the
510/// session is what ends the hold and a row that had frozen the answer would go on
511/// saying somebody is in there. What makes it *live* is the same thing that makes it
512/// live for the real implementation in-process: the session is open, and the process
513/// that opened it is this one. A row a scenario seeded the answer into keeps it — a
514/// hand-written state says what it means to say.
515fn held(state: &VcsState, row: Recoverable) -> Recoverable {
516 if row.held_by.is_some() {
517 return row;
518 }
519 let holder = state.sessions.iter().find(|session| {
520 session.branch == row.branch.branch
521 && state.session_identities.get(&session.token) == Some(&row.identity)
522 && !state.closed_sessions.contains(&session.token)
523 });
524 Recoverable {
525 held_by: holder.map(|session| HeldBy {
526 token: session.token.clone(),
527 worktree: session.worktree.clone(),
528 holding: Holding::OwnerRunning,
529 }),
530 ..row
531 }
532}
533
534/// The refusal a session this provider never opened meets.
535fn unknown_session(token: &SessionToken) -> Error {
536 Error::Invalid {
537 reason: format!(
538 "no session {:?} is open; `onevcs session open` prints a token",
539 token.0
540 ),
541 }
542}
543
544/// The `owner/name` slug an identity key spells, when it is a GitHub one.
545///
546/// The host is checked rather than assumed, exactly as the real implementation
547/// checks it: a GitLab origin has the same three segments, and a provider that
548/// published one anyway would let a journey pass where the real run answers that
549/// nobody has implemented that host.
550fn slug(identity: &str) -> Option<String> {
551 let mut parts = identity.split('/');
552 let (host, owner, name) = (parts.next()?, parts.next()?, parts.next()?);
553 if parts.next().is_some() || host != DEFAULT_HOST || owner.is_empty() || name.is_empty() {
554 return None;
555 }
556 Some(format!("{owner}/{name}"))
557}
558
559/// Record that a `local-direct` publication landed — record, and nothing more.
560///
561/// Landing one is entirely repository-side work, a squash built detached and
562/// pushed, which this provider does not perform and must not be named as if it
563/// did. What it does is decide the outcome and emit the completion that says so.
564fn record_local_landing(
565 identity: &str,
566 session: &Session,
567 token: &SessionToken,
568) -> (PublishOutcome, Vec<Emission>) {
569 let sha = events::stable_sha(&["publish", &token.0, &session.branch]);
570 let emission = Emission {
571 stream: token.0.clone(),
572 identity: Some(identity.to_owned()),
573 kind: EventKind::MergeCompleted,
574 payload: object(json!({"identity": identity, "sha": sha, "base": session.base})),
575 };
576 (PublishOutcome::Merged(Sha(sha)), vec![emission])
577}
578
579/// Publish as a change request: open the session's change on the host, or adopt
580/// the one it already holds, and then do with it what the policy asks — which for
581/// `change-open` is to leave it open and ask the host for nothing more.
582fn publish_as_change(
583 hosting: &dyn Hosting,
584 slug: &str,
585 identity: &str,
586 session: &Session,
587 policy: MergePolicy,
588 request: &PublishRequest,
589 token: &SessionToken,
590) -> Result<(PublishOutcome, Vec<Emission>)> {
591 let host = hosting.for_repo(slug)?;
592 // Who the host believes is calling travels with the change, as it does in the
593 // real publication and for the same reason.
594 let author = host.authenticated_user()?;
595 let existing = host.find_changes(&session.branch, &session.base)?;
596 // Whether the host already held it, which is what decides whether there can be a
597 // draft to lift — and, as next door, keeps a publication that opens its own change
598 // request from asking this host anything extra.
599 let adopted = !existing.is_empty();
600 let change = match existing.into_iter().next() {
601 Some(change) => change,
602 None => host.open_change(ChangeSpec {
603 head: session.branch.clone(),
604 base: session.base.clone(),
605 // A requested title has been checked by the conversion that built it, so
606 // this provider cannot accept one the real publication would refuse. The
607 // real implementation takes the subject from the branch's commits when no
608 // title was requested, and a provider has no commits to read — so an
609 // unrequested title names the branch instead.
610 title: request
611 .title
612 .as_deref()
613 .map_or_else(|| format!("Publish {}", session.branch), str::to_owned),
614 // Verbatim, and nothing when there is none — the real publication
615 // composes no body either, so a provider that composed one would be a
616 // consumer's suite proving a change request nobody opens.
617 body: request.body.clone(),
618 // The reason travels to the host and no further, as it does there: what
619 // the host does with it is open the change as a draft.
620 draft: request.draft.clone(),
621 })?,
622 };
623 let mut emissions = vec![Emission {
624 stream: token.0.clone(),
625 identity: Some(identity.to_owned()),
626 kind: EventKind::ChangeOpened,
627 payload: object(json!({
628 "url": change.url.to_string(),
629 "host": "github",
630 "id": change.id.0,
631 "base": change.base,
632 "author": author,
633 })),
634 }];
635 if let Some(reason) = &request.draft {
636 // The same question the real publication asks, for the same reason: a host
637 // that ignored the request, or a change request already open for review,
638 // would otherwise be reported as held back while it can land.
639 if !host.is_draft(&change)? {
640 return Err(Error::Invalid {
641 reason: format!(
642 "{url} is open for review on the host, and this publication asked for \
643 {asked}. A change that is open can land, so reporting it as a draft would \
644 say the work is held back when nothing is holding it",
645 url = change.url,
646 asked = asked_for(reason),
647 ),
648 });
649 }
650 // The publication record, and the only place the reason is written: the real
651 // implementation writes nothing of it into the change request, and a provider
652 // that did would be a consumer's suite proving a body nobody renders. The
653 // payload is the reason's own serialized form — its `kind` and every field of
654 // that kind — under the change request it holds, exactly as next door.
655 let mut drafted = object(json!({
656 "url": change.url.to_string(),
657 "id": change.id.0,
658 "base": change.base,
659 }));
660 if let serde_json::Value::Object(fields) =
661 serde_json::to_value(reason).expect("a draft reason serializes")
662 {
663 drafted.extend(fields);
664 }
665 emissions.push(Emission {
666 stream: token.0.clone(),
667 identity: Some(identity.to_owned()),
668 kind: EventKind::ChangeDrafted,
669 payload: drafted,
670 });
671 // Under every policy: a draft is unmergeable in that state, so nothing below
672 // asks this host to merge it.
673 return Ok((PublishOutcome::ChangeDraft(change.url.clone()), emissions));
674 }
675 // Publishing without a reason is what lifts a draft, and a change that is not one
676 // is asked for nothing — which is what makes a second publication idempotent.
677 // Asked only of a change the host already held, as next door: one opened moments
678 // ago without a reason is one nobody drafted. Three answers, told apart the same
679 // way — a host that was never taught to draft one has nothing to lift and is
680 // passed over, and a host that *could not say* is a refusal rather than a change
681 // nobody is holding.
682 match if adopted {
683 host.is_draft(&change)
684 } else {
685 Ok(false)
686 } {
687 Ok(true) => {
688 host.ready_for_review(&change)?;
689 emissions.push(Emission {
690 stream: token.0.clone(),
691 identity: Some(identity.to_owned()),
692 kind: EventKind::DraftLifted,
693 payload: object(json!({"url": change.url.to_string(), "id": change.id.0})),
694 });
695 }
696 Ok(false) => {}
697 Err(Error::NotImplemented { .. }) => {}
698 Err(unreadable) => return Err(unreadable),
699 }
700
701 if policy == MergePolicy::ChangeOpen {
702 return Ok((PublishOutcome::ChangeOpen(change.url.clone()), emissions));
703 }
704 Ok(match host.merge(&change, policy)? {
705 MergeOutcome::Merged(sha) => {
706 emissions.push(Emission {
707 stream: token.0.clone(),
708 identity: Some(identity.to_owned()),
709 kind: EventKind::ChangeMerged,
710 payload: object(json!({"url": change.url.to_string(), "sha": sha.0})),
711 });
712 emissions.push(Emission {
713 stream: token.0.clone(),
714 identity: Some(identity.to_owned()),
715 kind: EventKind::MergeCompleted,
716 payload: object(json!({"identity": identity, "sha": sha.0})),
717 });
718 (PublishOutcome::Merged(sha), emissions)
719 }
720 MergeOutcome::Queued => (PublishOutcome::Queued(change.url.clone()), emissions),
721 MergeOutcome::Open => (PublishOutcome::ChangeOpen(change.url.clone()), emissions),
722 })
723}
724
725/// The draft a publication asked for, as the two refusals next door spell it: "a
726/// draft awaiting <repository> <target>", or "a draft held by the session that opened
727/// it (<why>)". A restatement, so the refusals here read as the real ones do.
728fn asked_for(reason: &DraftReason) -> String {
729 match reason {
730 DraftReason::AwaitingRelease {
731 awaiting, target, ..
732 } => format!("a draft awaiting {awaiting} {target}"),
733 DraftReason::Held { because } => {
734 format!("a draft held by the session that opened it ({because})")
735 }
736 }
737}
738
739/// What a publication answers for an identity no change request can be opened
740/// against.
741///
742/// Two failures rather than one, as the real implementation keeps them: an
743/// identity that is not hosted at all is asking for the wrong policy, while a
744/// hosted one on a host this build does not speak for is asking for an
745/// implementation that has not arrived.
746fn refusal(identity: &str) -> PublishOutcome {
747 failed(&if identity.split('/').count() == 3 {
748 Error::NotImplemented {
749 operation: "RemoteHost for a host other than github.com",
750 }
751 } else {
752 Error::Invalid {
753 reason: format!(
754 "identity {identity:?} is not a hosted repository, so it cannot publish a \
755 change request; a local identity publishes with local-direct"
756 ),
757 }
758 })
759}
760
761/// One failure, as the outcome a publication that started and did not land is.
762fn failed(error: &Error) -> PublishOutcome {
763 PublishOutcome::Failed {
764 // Through the crate's own mapping, so the kind a caller branches on is the
765 // one the real implementation would report for the same failure.
766 kind: FailureKind::of(error),
767 reason: error.to_string(),
768 // A provider has no execution checkout, so there is nowhere a branch could
769 // have been handed back to and nothing to report about one.
770 retained: None,
771 }
772}
773
774/// The argv that lands a preserved branch, as `recoverable` reports it.
775///
776/// Both verbs take the branch by name and the repository by path, the way the real
777/// implementation reports them: the provenance decides which one, and neither
778/// depends on the directory a reader happens to be standing in.
779// llmlint: ignore[names_match_behavior] named for the public
780// `Recoverable::recover_command` field it fills, which `onevcs` publishes and this
781// crate must answer identically; a provider whose helper were named otherwise would
782// read as filling something else.
783fn recover_command(branch: &str, checkout: &Path, provenance: Provenance) -> Vec<String> {
784 let verb = match provenance {
785 Provenance::IncompleteStep => "recover",
786 Provenance::Complete => "publish-branch",
787 };
788 vec![
789 "onevcs".to_owned(),
790 verb.to_owned(),
791 branch.to_owned(),
792 "--repo".to_owned(),
793 checkout.display().to_string(),
794 ]
795}
796
797/// How a provenance kind is spelled in an event payload.
798fn spell(provenance: Provenance) -> &'static str {
799 match provenance {
800 Provenance::Complete => "complete",
801 Provenance::IncompleteStep => "incomplete-step",
802 }
803}
804
805fn object(value: Value) -> Map<String, Value> {
806 value.as_object().cloned().unwrap_or_default()
807}