1use std::path::PathBuf;
11
12use url::Url;
13
14use onevcs::{
15 ArtifactId, ChangeChecks, ChangeId, ChangeRequest, ChangeSpec, ChangeStandings, Check,
16 CheckSource, CheckState, CommentId, Description, Error, Hosting, MergeOutcome, MergePolicy,
17 Mergeability, PostedReply, ReadCost, ReadMarker, RemoteHost, RequiredChecks, Result,
18 ReviewRead, ReviewStandings, Sha, Visibility,
19};
20
21use crate::landings::{self, HeadAfter, PersonMerge};
22use crate::state::{Described, HostCall, HostComment, HostState};
23use crate::store::{FileStore, MemoryStore, Store};
24use crate::{events, review};
25
26pub const DEFAULT_HOST: &str = "github.com";
29
30pub const DEFAULT_SLUG: &str = "onevcs/testing";
34
35#[derive(Debug)]
42pub struct Host<T> {
43 store: T,
44 slug: String,
51}
52
53pub type MemoryHost = Host<MemoryStore<HostState>>;
55
56pub type FileHost = Host<FileStore<HostState>>;
59
60impl MemoryHost {
61 pub fn new() -> Self {
63 Self::seeded(HostState::default())
64 }
65
66 pub fn seeded(state: HostState) -> Self {
68 Self {
69 store: MemoryStore::new(state),
70 slug: DEFAULT_SLUG.to_owned(),
71 }
72 }
73
74 pub fn state(&self) -> HostState {
76 self.store
77 .snapshot()
78 .expect("an in-memory store always answers")
79 }
80}
81
82impl Default for MemoryHost {
83 fn default() -> Self {
84 Self::new()
85 }
86}
87
88impl FileHost {
89 pub fn create(path: impl Into<PathBuf>) -> Result<Self> {
95 Ok(Self {
96 store: FileStore::attach(path, &HostState::default())?,
97 slug: DEFAULT_SLUG.to_owned(),
98 })
99 }
100
101 pub fn seeded(path: impl Into<PathBuf>, state: HostState) -> Result<Self> {
104 Ok(Self {
105 store: FileStore::replace(path, &state)?,
106 slug: DEFAULT_SLUG.to_owned(),
107 })
108 }
109
110 pub fn state(&self) -> Result<HostState> {
112 self.store.snapshot()
113 }
114}
115
116impl<T: Store<HostState>> Host<T> {
120 pub fn merge_by_person(
126 &self,
127 change: &ChangeId,
128 how: PersonMerge,
129 at: &str,
130 head: HeadAfter,
131 ) -> Result<Sha> {
132 self.store
133 .with(|state| landings::merge_by_person(state, change, how, at, head))
134 }
135
136 pub fn close_unmerged(&self, change: &ChangeId) -> Result<()> {
138 self.store.with(|state| {
139 if !state.changes.iter().any(|held| held.id == *change) {
140 return Err(Error::Invalid {
141 reason: format!("no change request {:?} was opened on this host", change.0),
142 });
143 }
144 state.closed.insert(change.clone());
145 Ok(())
146 })
147 }
148
149 pub fn retarget_by_person(&self, change: &ChangeId, base: &str) -> Result<()> {
151 crate::state::named_branch(base, "the base branch")?;
152 self.store.with(|state| {
153 if !state.changes.iter().any(|held| held.id == *change) {
154 return Err(Error::Invalid {
155 reason: format!("no change request {:?} was opened on this host", change.0),
156 });
157 }
158 landings::retarget_in(state, change, base);
159 Ok(())
160 })
161 }
162
163 pub fn set_head_checks(&self, head: &Sha, checks: Vec<Check>) -> Result<()> {
165 self.store.with(|state| {
166 state.head_checks.insert(head.clone(), checks);
167 Ok(())
168 })
169 }
170
171 pub fn ledger(&self) -> Result<Vec<HostCall>> {
173 Ok(self.store.snapshot()?.ledger)
174 }
175
176 fn called(&self, method: &str, changes: &[&ChangeId], charge: ReadCost) -> Result<()> {
178 let call = HostCall {
179 method: method.to_owned(),
180 changes: changes.iter().map(|id| (*id).clone()).collect(),
181 charge,
182 };
183 call.checked()?;
184 self.store.with(|state| {
185 state.ledger.push(call);
186 Ok(())
187 })
188 }
189}
190
191impl<T: Store<HostState>> Host<T> {
194 pub fn add_comment(&self, change: &ChangeId, comment: HostComment) -> Result<()> {
199 review::add(&self.store, change, comment)
200 }
201
202 pub fn edit_comment(&self, change: &ChangeId, comment: &CommentId, body: &str) -> Result<()> {
204 review::edit(&self.store, change, comment, body)
205 }
206
207 pub fn set_thread(
210 &self,
211 change: &ChangeId,
212 thread: &str,
213 resolved: bool,
214 outdated: bool,
215 ) -> Result<()> {
216 review::set_thread(&self.store, change, thread, resolved, outdated)
217 }
218}
219
220impl<T: Store<HostState> + Clone + std::fmt::Debug + Send + Sync + 'static> Hosting for Host<T> {
221 fn for_repo(&self, slug: &str) -> Result<Box<dyn RemoteHost>> {
222 Ok(Box::new(Host {
223 store: self.store.clone(),
224 slug: named_repository(slug)?,
225 }))
226 }
227}
228
229impl<T: Store<HostState>> RemoteHost for Host<T> {
230 fn authenticated_user(&self) -> Result<String> {
231 self.called("authenticated_user", &[], landings::rest(1))?;
232 let login = self.store.snapshot()?.authenticated_user;
233 if login.trim().is_empty() {
234 return Err(Error::Invalid {
235 reason: "the host reported no authenticated user".to_owned(),
236 });
237 }
238 Ok(login)
239 }
240
241 fn open_change(&self, req: ChangeSpec) -> Result<ChangeRequest> {
242 self.called("open_change", &[], landings::graphql(2))?;
244 addressable(&req.head, "the head branch")?;
245 addressable(&req.base, "the base branch")?;
246 crate::state::titled(&req.title)?;
249 if let Some(reason) = &req.draft {
255 reason.checked()?;
256 }
257 let slug = self.slug.clone();
258 self.store.with(|state| {
259 let id = ChangeId((state.changes.len() + 1).to_string());
262 let url = format!("https://{DEFAULT_HOST}/{slug}/pull/{}", id.0);
263 let change = ChangeRequest {
264 head_sha: Sha(events::stable_sha(&[&slug, &req.head, &id.0])),
265 url: Url::parse(&url).map_err(|e| Error::Invalid {
266 reason: format!("{url:?} is not a URL: {e}"),
267 })?,
268 base: req.base.clone(),
269 id: id.clone(),
270 };
271 state.heads.insert(id.clone(), req.head.clone());
272 state.titles.insert(id.clone(), req.title.clone());
273 if let Some(body) = req.body.clone() {
277 state.bodies.insert(id.clone(), body);
278 }
279 if let Some(reason) = req.draft.clone() {
284 state.drafts.insert(id, reason);
285 } else if req.draft_awaiting_checks {
286 state.awaiting_checks.insert(id);
289 }
290 state.changes.push(change.clone());
291 Ok(change)
292 })
293 }
294
295 fn find_changes(&self, head: &str, base: &str) -> Result<Vec<ChangeRequest>> {
296 self.called("find_changes", &[], landings::graphql(1))?;
297 addressable(head, "the head branch")?;
298 addressable(base, "the base branch")?;
299 let state = self.store.snapshot()?;
300 Ok(state
301 .changes
302 .iter()
303 .filter(|change| {
304 change.base == base
305 && state.heads.get(&change.id).is_some_and(|from| from == head)
306 && !matches!(state.merges.get(&change.id), Some(MergeOutcome::Merged(_)))
309 })
310 .cloned()
311 .collect())
312 }
313
314 fn change_checks(&self, cr: &ChangeRequest) -> Result<ChangeChecks> {
315 self.called("change_checks", &[&cr.id], landings::graphql(2))?;
317 let state = self.store.snapshot()?;
318 let sources = state.check_sources.clone().unwrap_or_else(complete_sources);
319 if sources.is_empty() {
324 return Err(Error::Invalid {
325 reason: format!(
326 "this host was seeded with no check source, so what the checks on {} say \
327 cannot be read rather than being empty",
328 cr.url
329 ),
330 });
331 }
332 Ok(ChangeChecks {
333 checks: current_checks(&state, &cr.id),
334 sources,
335 })
336 }
337
338 fn check_log(&self, cr: &ChangeRequest, check: &Check) -> Result<ArtifactId> {
339 self.called("check_log", &[&cr.id], landings::rest(1))?;
340 let log = self
341 .store
342 .snapshot()?
343 .check_logs
344 .get(&cr.id)
345 .and_then(|logs| logs.get(&check.name))
346 .cloned()
347 .unwrap_or_else(|| format!("the host log for check {}\n", check.name));
348 events::store_artifact(&artifact_id(&cr.id, &check.name), &log)
349 }
350
351 fn merge(&self, cr: &ChangeRequest, policy: MergePolicy) -> Result<MergeOutcome> {
352 let charge = match policy {
355 MergePolicy::ChangeAuto | MergePolicy::ChangeDirect => landings::graphql(2),
356 MergePolicy::LocalDirect | MergePolicy::ChangeOpen => ReadCost::default(),
357 };
358 self.called("merge", &[&cr.id], charge)?;
359 self.store.with(|state| {
360 if matches!(policy, MergePolicy::ChangeAuto | MergePolicy::ChangeDirect)
364 && drafted(state, &cr.id)
365 {
366 return Err(Error::Invalid {
367 reason: format!(
368 "{} is still a draft, and a draft can be neither merged nor armed to \
369 merge itself",
370 cr.url
371 ),
372 });
373 }
374 if let Some(decided) = state.merges.get(&cr.id) {
378 return Ok(decided.clone());
379 }
380 let landed = |state: &mut HostState| MergeOutcome::Merged(land(state, cr));
381 Ok(match policy {
382 MergePolicy::LocalDirect | MergePolicy::ChangeOpen => MergeOutcome::Open,
385 MergePolicy::ChangeAuto => {
386 if required_checks_green(state, &cr.id) {
387 landed(state)
388 } else {
389 state.merges.insert(cr.id.clone(), MergeOutcome::Queued);
392 MergeOutcome::Queued
393 }
394 }
395 MergePolicy::ChangeDirect => landed(state),
396 })
397 })
398 }
399
400 fn ready_for_review(&self, cr: &ChangeRequest) -> Result<()> {
409 self.called("ready_for_review", &[&cr.id], landings::graphql(1))?;
410 self.store.with(|state| {
411 state.made_ready.push(cr.id.clone());
412 Ok(())
413 })
414 }
415
416 fn is_draft(&self, cr: &ChangeRequest) -> Result<bool> {
423 self.called("is_draft", &[&cr.id], landings::graphql(1))?;
424 Ok(drafted(&self.store.snapshot()?, &cr.id))
425 }
426
427 fn required_checks_on(&self, _base: &str) -> Result<RequiredChecks> {
430 self.called("required_checks_on", &[], landings::rest(2))?;
432 let state = self.store.snapshot()?;
433 if let Some(seeded) = state.required_checks {
434 return Ok(seeded);
435 }
436 Ok(RequiredChecks {
437 checks: state
438 .checks
439 .values()
440 .chain(state.checks_after_lift.values())
441 .flatten()
442 .filter(|check| check.required)
443 .map(|check| check.name.clone())
444 .collect(),
445 unconsulted: std::collections::BTreeMap::new(),
446 })
447 }
448
449 fn describe_change(&self, cr: &ChangeRequest, title: Option<&str>, body: &str) -> Result<()> {
459 self.called("describe_change", &[&cr.id], landings::graphql(1))?;
460 if let Some(title) = title {
461 crate::state::titled(title)?;
462 }
463 self.store.with(|state| {
464 if !state.changes.iter().any(|change| change.id == cr.id) {
465 return Err(Error::Invalid {
466 reason: format!(
467 "no change request {:?} was opened on this host, so there is nothing to \
468 describe",
469 cr.id.0
470 ),
471 });
472 }
473 if let Some(title) = title {
474 state.titles.insert(cr.id.clone(), title.to_owned());
475 }
476 state.bodies.insert(cr.id.clone(), body.to_owned());
477 state.described.push(Described {
478 id: cr.id.clone(),
479 title: title.map(str::to_owned),
480 body: body.to_owned(),
481 });
482 Ok(())
483 })
484 }
485
486 fn change_description(&self, cr: &ChangeRequest) -> Result<Description> {
490 self.called("change_description", &[&cr.id], landings::graphql(1))?;
491 let state = self.store.snapshot()?;
492 let title = state
493 .titles
494 .get(&cr.id)
495 .cloned()
496 .ok_or_else(|| Error::Invalid {
497 reason: format!(
498 "no change request {:?} was opened on this host, so it has no description",
499 cr.id.0
500 ),
501 })?;
502 Ok(Description {
503 title,
504 body: state.bodies.get(&cr.id).cloned().unwrap_or_default(),
505 })
506 }
507
508 fn review_comments(&self, change: &ChangeId, since: Option<&ReadMarker>) -> Result<ReviewRead> {
511 let read = review::read(&self.store, change, since);
512 let charge = read.as_ref().map_or(landings::graphql(1), |read| read.cost);
513 self.called("review_comments", &[change], charge)?;
514 read
515 }
516
517 fn reply_to_comment(
521 &self,
522 change: &ChangeId,
523 comment: &CommentId,
524 body: &str,
525 key: &str,
526 ) -> Result<PostedReply> {
527 self.called("reply_to_comment", &[change], landings::rest(1))?;
528 review::reply(&self.store, change, comment, body, key)
529 }
530
531 fn mergeability(&self, cr: &ChangeRequest) -> Result<Mergeability> {
535 self.called("mergeability", &[&cr.id], landings::graphql(1))?;
536 Err(Error::NotImplemented {
537 operation: "RemoteHost::mergeability",
538 })
539 }
540
541 fn visibility(&self) -> Result<Visibility> {
545 self.called("visibility", &[], landings::rest(1))?;
546 Err(Error::NotImplemented {
547 operation: "RemoteHost::visibility",
548 })
549 }
550
551 fn change_standings(&self, root: &str, changes: &[ChangeId]) -> Result<ChangeStandings> {
554 let cost = landings::standings_charge(changes.len());
555 let named: Vec<&ChangeId> = changes.iter().collect();
556 self.called("change_standings", &named, cost)?;
557 let state = self.store.snapshot()?;
558 Ok(ChangeStandings {
559 standings: changes
560 .iter()
561 .map(|id| landings::standing(&state, root, id))
562 .collect::<Result<_>>()?,
563 cost,
564 })
565 }
566
567 fn review_standings(
570 &self,
571 root: &str,
572 changes: &[(ChangeId, Option<ReadMarker>)],
573 ) -> Result<ReviewStandings> {
574 let cost = landings::review_charge(changes.len());
575 let named: Vec<&ChangeId> = changes.iter().map(|(id, _)| id).collect();
576 self.called("review_standings", &named, cost)?;
577 self.store.with(|state| {
578 let mut standings = Vec::with_capacity(changes.len());
579 let mut reads = Vec::with_capacity(changes.len());
580 for (id, since) in changes {
581 standings.push(landings::standing(state, root, id)?);
582 reads.push(review::read_in(state, id, since.as_ref(), false)?);
583 }
584 Ok(ReviewStandings {
585 standings,
586 reads,
587 cost,
588 })
589 })
590 }
591
592 fn retarget_change(
596 &self,
597 change: &ChangeId,
598 base: &str,
599 body: Option<&str>,
600 ) -> Result<ReadCost> {
601 self.called("retarget_change", &[change], landings::rest(1))?;
602 addressable(base, "the base branch")?;
603 self.store.with(|state| {
604 let opened = state
605 .changes
606 .iter()
607 .find(|held| held.id == *change)
608 .cloned()
609 .ok_or_else(|| Error::Invalid {
610 reason: format!("no change request {:?} was opened on this host", change.0),
611 })?;
612 if matches!(state.merges.get(change), Some(MergeOutcome::Merged(_))) {
613 return Err(Error::Invalid {
614 reason: format!(
615 "Cannot change the base branch of a merged pull request: {}",
616 opened.url
617 ),
618 });
619 }
620 if state.closed.contains(change) {
621 return Err(Error::Invalid {
622 reason: format!(
623 "Cannot change the base branch of a closed pull request: {}",
624 opened.url
625 ),
626 });
627 }
628 if state.deleted_branches.contains(base) {
629 return Err(Error::Invalid {
630 reason: format!("Proposed base branch '{base}' was not found"),
631 });
632 }
633 landings::retarget_in(state, change, base);
634 if let Some(body) = body {
635 state.bodies.insert(change.clone(), body.to_owned());
636 }
637 Ok(landings::rest(1))
638 })
639 }
640
641 fn reopen_change(&self, change: &ChangeId) -> Result<ReadCost> {
645 self.called("reopen_change", &[change], landings::rest(1))?;
646 self.store.with(|state| {
647 let opened = state
648 .changes
649 .iter()
650 .find(|held| held.id == *change)
651 .cloned()
652 .ok_or_else(|| Error::Invalid {
653 reason: format!("no change request {:?} was opened on this host", change.0),
654 })?;
655 if !state.closed.contains(change) {
656 return Err(Error::Invalid {
657 reason: format!("{} is not closed, so its state cannot change", opened.url),
658 });
659 }
660 if state.deleted_branches.contains(&opened.base) {
661 return Err(Error::Invalid {
662 reason: format!(
663 "the base branch of {} was deleted, so it cannot be reopened",
664 opened.url
665 ),
666 });
667 }
668 state.closed.remove(change);
669 Ok(landings::rest(1))
670 })
671 }
672
673 fn merge_time(&self, cr: &ChangeRequest) -> Result<Option<String>> {
674 self.called("merge_time", &[&cr.id], landings::graphql(1))?;
675 let state = self.store.snapshot()?;
676 Ok(match state.merges.get(&cr.id) {
679 Some(MergeOutcome::Merged(_)) => state.merge_times.get(&cr.id).cloned(),
680 _ => None,
681 })
682 }
683
684 fn merged_at(&self, cr: &ChangeRequest) -> Result<Option<Sha>> {
685 self.called("merged_at", &[&cr.id], landings::graphql(1))?;
686 self.store.with(|state| {
687 if matches!(state.merges.get(&cr.id), Some(MergeOutcome::Queued))
694 && required_checks_green(state, &cr.id)
695 {
696 return Ok(Some(land(state, cr)));
697 }
698 Ok(match state.merges.get(&cr.id) {
699 Some(MergeOutcome::Merged(sha)) => Some(sha.clone()),
700 _ => None,
701 })
702 })
703 }
704}
705
706fn land(state: &mut HostState, cr: &ChangeRequest) -> Sha {
711 let sha = Sha(events::stable_sha(&["merge", &cr.id.0, cr.url.as_str()]));
712 state
713 .merges
714 .insert(cr.id.clone(), MergeOutcome::Merged(sha.clone()));
715 state.merge_times.insert(cr.id.clone(), events::timestamp());
716 sha
717}
718
719fn complete_sources() -> std::collections::BTreeSet<CheckSource> {
723 [CheckSource::StatusChecks].into_iter().collect()
724}
725
726fn required_checks_green(state: &HostState, id: &ChangeId) -> bool {
736 let checks = current_checks(state, id);
737 let required: std::collections::BTreeSet<&str> = checks
738 .iter()
739 .filter(|check| check.required)
740 .map(|check| check.name.as_str())
741 .chain(
742 state
743 .required_checks
744 .iter()
745 .flat_map(|declared| declared.checks.iter().map(String::as_str)),
746 )
747 .collect();
748 !required.is_empty()
749 && required.iter().all(|name| {
750 let mut runs = checks.iter().filter(|check| check.name == *name).peekable();
751 runs.peek().is_some()
752 && runs
753 .all(|check| matches!(check.state(), CheckState::Passed | CheckState::Skipped))
754 })
755}
756
757pub(crate) fn drafted(state: &HostState, id: &ChangeId) -> bool {
760 (state.drafts.contains_key(id) || state.awaiting_checks.contains(id))
761 && !state.made_ready.contains(id)
762}
763
764pub(crate) fn current_checks(state: &HostState, id: &ChangeId) -> Vec<Check> {
767 if state.made_ready.contains(id) {
768 if let Some(after) = state.checks_after_lift.get(id) {
769 return after.clone();
770 }
771 }
772 state.checks.get(id).cloned().unwrap_or_default()
773}
774
775fn artifact_id(change: &ChangeId, check: &str) -> String {
781 let safe: String = check
782 .chars()
783 .map(|c| {
784 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
785 c
786 } else {
787 '-'
788 }
789 })
790 .collect();
791 let number: String = change
792 .0
793 .chars()
794 .filter(|c| c.is_ascii_alphanumeric())
795 .collect();
796 format!("a-testing-{number}-{safe}")
797}
798
799fn addressable(value: &str, what: &str) -> Result<()> {
806 if value.is_empty() || value.starts_with('-') || value.contains(char::is_whitespace) {
807 return Err(Error::Invalid {
808 reason: format!(
809 "{what} {value:?} cannot address anything on the host: it must be non-empty, \
810 must not begin with '-', and must carry no whitespace"
811 ),
812 });
813 }
814 Ok(())
815}
816
817fn named_repository(slug: &str) -> Result<String> {
819 let mut parts = slug.split('/');
820 let named = matches!(
821 (parts.next(), parts.next(), parts.next()),
822 (Some(owner), Some(name), None)
823 if !owner.is_empty()
824 && !name.is_empty()
825 && !slug.starts_with('-')
826 && !slug.contains(char::is_whitespace)
827 );
828 if !named {
829 return Err(Error::Invalid {
830 reason: format!("{slug:?} does not name one repository as owner/name"),
831 });
832 }
833 Ok(slug.to_owned())
834}