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 state.armed.contains(&cr.id)
378 && matches!(state.merges.get(&cr.id), Some(MergeOutcome::Queued))
379 && required_checks_green(state, &cr.id)
380 {
381 return Ok(MergeOutcome::Merged(land(state, cr)));
382 }
383 if let Some(decided) = state.merges.get(&cr.id) {
387 return Ok(decided.clone());
388 }
389 let landed = |state: &mut HostState| MergeOutcome::Merged(land(state, cr));
390 Ok(match policy {
391 MergePolicy::LocalDirect | MergePolicy::ChangeOpen => MergeOutcome::Open,
394 MergePolicy::ChangeAuto => {
395 if required_checks_green(state, &cr.id) {
396 landed(state)
397 } else {
398 state.merges.insert(cr.id.clone(), MergeOutcome::Queued);
401 state.armed.insert(cr.id.clone());
402 MergeOutcome::Queued
403 }
404 }
405 MergePolicy::ChangeDirect => landed(state),
406 })
407 })
408 }
409
410 fn ready_for_review(&self, cr: &ChangeRequest) -> Result<()> {
419 self.called("ready_for_review", &[&cr.id], landings::graphql(1))?;
420 self.store.with(|state| {
421 state.made_ready.push(cr.id.clone());
422 Ok(())
423 })
424 }
425
426 fn is_draft(&self, cr: &ChangeRequest) -> Result<bool> {
433 self.called("is_draft", &[&cr.id], landings::graphql(1))?;
434 Ok(drafted(&self.store.snapshot()?, &cr.id))
435 }
436
437 fn required_checks_on(&self, _base: &str) -> Result<RequiredChecks> {
440 self.called("required_checks_on", &[], landings::rest(2))?;
442 let state = self.store.snapshot()?;
443 if let Some(seeded) = state.required_checks {
444 return Ok(seeded);
445 }
446 Ok(RequiredChecks {
447 checks: state
448 .checks
449 .values()
450 .chain(state.checks_after_lift.values())
451 .flatten()
452 .filter(|check| check.required)
453 .map(|check| check.name.clone())
454 .collect(),
455 unconsulted: std::collections::BTreeMap::new(),
456 })
457 }
458
459 fn describe_change(&self, cr: &ChangeRequest, title: Option<&str>, body: &str) -> Result<()> {
469 self.called("describe_change", &[&cr.id], landings::graphql(1))?;
470 if let Some(title) = title {
471 crate::state::titled(title)?;
472 }
473 self.store.with(|state| {
474 if !state.changes.iter().any(|change| change.id == cr.id) {
475 return Err(Error::Invalid {
476 reason: format!(
477 "no change request {:?} was opened on this host, so there is nothing to \
478 describe",
479 cr.id.0
480 ),
481 });
482 }
483 if let Some(title) = title {
484 state.titles.insert(cr.id.clone(), title.to_owned());
485 }
486 state.bodies.insert(cr.id.clone(), body.to_owned());
487 state.described.push(Described {
488 id: cr.id.clone(),
489 title: title.map(str::to_owned),
490 body: body.to_owned(),
491 });
492 Ok(())
493 })
494 }
495
496 fn change_description(&self, cr: &ChangeRequest) -> Result<Description> {
500 self.called("change_description", &[&cr.id], landings::graphql(1))?;
501 let state = self.store.snapshot()?;
502 let title = state
503 .titles
504 .get(&cr.id)
505 .cloned()
506 .ok_or_else(|| Error::Invalid {
507 reason: format!(
508 "no change request {:?} was opened on this host, so it has no description",
509 cr.id.0
510 ),
511 })?;
512 Ok(Description {
513 title,
514 body: state.bodies.get(&cr.id).cloned().unwrap_or_default(),
515 })
516 }
517
518 fn review_comments(&self, change: &ChangeId, since: Option<&ReadMarker>) -> Result<ReviewRead> {
521 let read = review::read(&self.store, change, since);
522 let charge = read.as_ref().map_or(landings::graphql(1), |read| read.cost);
523 self.called("review_comments", &[change], charge)?;
524 read
525 }
526
527 fn reply_to_comment(
531 &self,
532 change: &ChangeId,
533 comment: &CommentId,
534 body: &str,
535 key: &str,
536 ) -> Result<PostedReply> {
537 self.called("reply_to_comment", &[change], landings::rest(1))?;
538 review::reply(&self.store, change, comment, body, key)
539 }
540
541 fn mergeability(&self, cr: &ChangeRequest) -> Result<Mergeability> {
545 self.called("mergeability", &[&cr.id], landings::graphql(1))?;
546 Err(Error::NotImplemented {
547 operation: "RemoteHost::mergeability",
548 })
549 }
550
551 fn requires_up_to_date(&self, _base: &str) -> Result<bool> {
554 self.called("requires_up_to_date", &[], landings::rest(2))?;
555 Ok(self.store.snapshot()?.up_to_date_required)
556 }
557
558 fn allows_auto_merge(&self) -> Result<bool> {
561 self.called("allows_auto_merge", &[], landings::graphql(1))?;
562 Ok(self.store.snapshot()?.allow_auto_merge)
563 }
564
565 fn visibility(&self) -> Result<Visibility> {
569 self.called("visibility", &[], landings::rest(1))?;
570 Err(Error::NotImplemented {
571 operation: "RemoteHost::visibility",
572 })
573 }
574
575 fn change_standings(&self, root: &str, changes: &[ChangeId]) -> Result<ChangeStandings> {
578 let cost = landings::standings_charge(changes.len());
579 let named: Vec<&ChangeId> = changes.iter().collect();
580 self.called("change_standings", &named, cost)?;
581 let state = self.store.snapshot()?;
582 Ok(ChangeStandings {
583 standings: changes
584 .iter()
585 .map(|id| landings::standing(&state, root, id))
586 .collect::<Result<_>>()?,
587 cost,
588 })
589 }
590
591 fn review_standings(
594 &self,
595 root: &str,
596 changes: &[(ChangeId, Option<ReadMarker>)],
597 ) -> Result<ReviewStandings> {
598 let cost = landings::review_charge(changes.len());
599 let named: Vec<&ChangeId> = changes.iter().map(|(id, _)| id).collect();
600 self.called("review_standings", &named, cost)?;
601 self.store.with(|state| {
602 let mut standings = Vec::with_capacity(changes.len());
603 let mut reads = Vec::with_capacity(changes.len());
604 for (id, since) in changes {
605 standings.push(landings::standing(state, root, id)?);
606 reads.push(review::read_in(state, id, since.as_ref(), false)?);
607 }
608 Ok(ReviewStandings {
609 standings,
610 reads,
611 cost,
612 })
613 })
614 }
615
616 fn retarget_change(
620 &self,
621 change: &ChangeId,
622 base: &str,
623 body: Option<&str>,
624 ) -> Result<ReadCost> {
625 self.called("retarget_change", &[change], landings::rest(1))?;
626 addressable(base, "the base branch")?;
627 self.store.with(|state| {
628 let opened = state
629 .changes
630 .iter()
631 .find(|held| held.id == *change)
632 .cloned()
633 .ok_or_else(|| Error::Invalid {
634 reason: format!("no change request {:?} was opened on this host", change.0),
635 })?;
636 if matches!(state.merges.get(change), Some(MergeOutcome::Merged(_))) {
637 return Err(Error::Invalid {
638 reason: format!(
639 "Cannot change the base branch of a merged pull request: {}",
640 opened.url
641 ),
642 });
643 }
644 if state.closed.contains(change) {
645 return Err(Error::Invalid {
646 reason: format!(
647 "Cannot change the base branch of a closed pull request: {}",
648 opened.url
649 ),
650 });
651 }
652 if state.deleted_branches.contains(base) {
653 return Err(Error::Invalid {
654 reason: format!("Proposed base branch '{base}' was not found"),
655 });
656 }
657 landings::retarget_in(state, change, base);
658 if let Some(body) = body {
659 state.bodies.insert(change.clone(), body.to_owned());
660 }
661 Ok(landings::rest(1))
662 })
663 }
664
665 fn reopen_change(&self, change: &ChangeId) -> Result<ReadCost> {
669 self.called("reopen_change", &[change], landings::rest(1))?;
670 self.store.with(|state| {
671 let opened = state
672 .changes
673 .iter()
674 .find(|held| held.id == *change)
675 .cloned()
676 .ok_or_else(|| Error::Invalid {
677 reason: format!("no change request {:?} was opened on this host", change.0),
678 })?;
679 if !state.closed.contains(change) {
680 return Err(Error::Invalid {
681 reason: format!("{} is not closed, so its state cannot change", opened.url),
682 });
683 }
684 if state.deleted_branches.contains(&opened.base) {
685 return Err(Error::Invalid {
686 reason: format!(
687 "the base branch of {} was deleted, so it cannot be reopened",
688 opened.url
689 ),
690 });
691 }
692 state.closed.remove(change);
693 Ok(landings::rest(1))
694 })
695 }
696
697 fn merge_time(&self, cr: &ChangeRequest) -> Result<Option<String>> {
698 self.called("merge_time", &[&cr.id], landings::graphql(1))?;
699 let state = self.store.snapshot()?;
700 Ok(match state.merges.get(&cr.id) {
703 Some(MergeOutcome::Merged(_)) => state.merge_times.get(&cr.id).cloned(),
704 _ => None,
705 })
706 }
707
708 fn merged_at(&self, cr: &ChangeRequest) -> Result<Option<Sha>> {
709 self.called("merged_at", &[&cr.id], landings::graphql(1))?;
710 self.store.with(|state| {
711 if matches!(state.merges.get(&cr.id), Some(MergeOutcome::Queued))
718 && required_checks_green(state, &cr.id)
719 {
720 return Ok(Some(land(state, cr)));
721 }
722 Ok(match state.merges.get(&cr.id) {
723 Some(MergeOutcome::Merged(sha)) => Some(sha.clone()),
724 _ => None,
725 })
726 })
727 }
728}
729
730fn land(state: &mut HostState, cr: &ChangeRequest) -> Sha {
735 let sha = Sha(events::stable_sha(&["merge", &cr.id.0, cr.url.as_str()]));
736 state
737 .merges
738 .insert(cr.id.clone(), MergeOutcome::Merged(sha.clone()));
739 state.merge_times.insert(cr.id.clone(), events::timestamp());
740 state.armed.remove(&cr.id);
741 sha
742}
743
744fn complete_sources() -> std::collections::BTreeSet<CheckSource> {
748 [CheckSource::StatusChecks].into_iter().collect()
749}
750
751fn required_checks_green(state: &HostState, id: &ChangeId) -> bool {
761 let checks = current_checks(state, id);
762 let required: std::collections::BTreeSet<&str> = checks
763 .iter()
764 .filter(|check| check.required)
765 .map(|check| check.name.as_str())
766 .chain(
767 state
768 .required_checks
769 .iter()
770 .flat_map(|declared| declared.checks.iter().map(String::as_str)),
771 )
772 .collect();
773 !required.is_empty()
774 && required.iter().all(|name| {
775 let mut runs = checks.iter().filter(|check| check.name == *name).peekable();
776 runs.peek().is_some()
777 && runs
778 .all(|check| matches!(check.state(), CheckState::Passed | CheckState::Skipped))
779 })
780}
781
782pub(crate) fn drafted(state: &HostState, id: &ChangeId) -> bool {
785 (state.drafts.contains_key(id) || state.awaiting_checks.contains(id))
786 && !state.made_ready.contains(id)
787}
788
789pub(crate) fn current_checks(state: &HostState, id: &ChangeId) -> Vec<Check> {
792 if state.made_ready.contains(id) {
793 if let Some(after) = state.checks_after_lift.get(id) {
794 return after.clone();
795 }
796 }
797 state.checks.get(id).cloned().unwrap_or_default()
798}
799
800fn artifact_id(change: &ChangeId, check: &str) -> String {
806 let safe: String = check
807 .chars()
808 .map(|c| {
809 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
810 c
811 } else {
812 '-'
813 }
814 })
815 .collect();
816 let number: String = change
817 .0
818 .chars()
819 .filter(|c| c.is_ascii_alphanumeric())
820 .collect();
821 format!("a-testing-{number}-{safe}")
822}
823
824fn addressable(value: &str, what: &str) -> Result<()> {
831 if value.is_empty() || value.starts_with('-') || value.contains(char::is_whitespace) {
832 return Err(Error::Invalid {
833 reason: format!(
834 "{what} {value:?} cannot address anything on the host: it must be non-empty, \
835 must not begin with '-', and must carry no whitespace"
836 ),
837 });
838 }
839 Ok(())
840}
841
842fn named_repository(slug: &str) -> Result<String> {
844 let mut parts = slug.split('/');
845 let named = matches!(
846 (parts.next(), parts.next(), parts.next()),
847 (Some(owner), Some(name), None)
848 if !owner.is_empty()
849 && !name.is_empty()
850 && !slug.starts_with('-')
851 && !slug.contains(char::is_whitespace)
852 );
853 if !named {
854 return Err(Error::Invalid {
855 reason: format!("{slug:?} does not name one repository as owner/name"),
856 });
857 }
858 Ok(slug.to_owned())
859}