1use std::path::PathBuf;
11
12use url::Url;
13
14use onevcs::{
15 ArtifactId, ChangeChecks, ChangeId, ChangeRequest, ChangeSpec, Check, CheckSource, Error,
16 Hosting, MergeOutcome, MergePolicy, RemoteHost, Result, Sha,
17};
18
19use crate::events;
20use crate::state::HostState;
21use crate::store::{FileStore, MemoryStore, Store};
22
23pub const DEFAULT_HOST: &str = "github.com";
26
27pub const DEFAULT_SLUG: &str = "onevcs/testing";
31
32#[derive(Debug)]
39pub struct Host<T> {
40 store: T,
41 slug: String,
48}
49
50pub type MemoryHost = Host<MemoryStore<HostState>>;
52
53pub type FileHost = Host<FileStore<HostState>>;
56
57impl MemoryHost {
58 pub fn new() -> Self {
60 Self::seeded(HostState::default())
61 }
62
63 pub fn seeded(state: HostState) -> Self {
65 Self {
66 store: MemoryStore::new(state),
67 slug: DEFAULT_SLUG.to_owned(),
68 }
69 }
70
71 pub fn state(&self) -> HostState {
73 self.store
74 .snapshot()
75 .expect("an in-memory store always answers")
76 }
77}
78
79impl Default for MemoryHost {
80 fn default() -> Self {
81 Self::new()
82 }
83}
84
85impl FileHost {
86 pub fn create(path: impl Into<PathBuf>) -> Result<Self> {
92 Ok(Self {
93 store: FileStore::attach(path, &HostState::default())?,
94 slug: DEFAULT_SLUG.to_owned(),
95 })
96 }
97
98 pub fn seeded(path: impl Into<PathBuf>, state: HostState) -> Result<Self> {
101 Ok(Self {
102 store: FileStore::replace(path, &state)?,
103 slug: DEFAULT_SLUG.to_owned(),
104 })
105 }
106
107 pub fn state(&self) -> Result<HostState> {
109 self.store.snapshot()
110 }
111}
112
113impl<T: Store<HostState> + Clone + std::fmt::Debug + Send + Sync + 'static> Hosting for Host<T> {
114 fn for_repo(&self, slug: &str) -> Result<Box<dyn RemoteHost>> {
115 Ok(Box::new(Host {
116 store: self.store.clone(),
117 slug: named_repository(slug)?,
118 }))
119 }
120}
121
122impl<T: Store<HostState>> RemoteHost for Host<T> {
123 fn authenticated_user(&self) -> Result<String> {
124 let login = self.store.snapshot()?.authenticated_user;
125 if login.trim().is_empty() {
126 return Err(Error::Invalid {
127 reason: "the host reported no authenticated user".to_owned(),
128 });
129 }
130 Ok(login)
131 }
132
133 fn open_change(&self, req: ChangeSpec) -> Result<ChangeRequest> {
134 addressable(&req.head, "the head branch")?;
135 addressable(&req.base, "the base branch")?;
136 crate::state::titled(&req.title)?;
139 let slug = self.slug.clone();
140 self.store.with(|state| {
141 let id = ChangeId((state.changes.len() + 1).to_string());
144 let url = format!("https://{DEFAULT_HOST}/{slug}/pull/{}", id.0);
145 let change = ChangeRequest {
146 head_sha: Sha(events::stable_sha(&[&slug, &req.head, &id.0])),
147 url: Url::parse(&url).map_err(|e| Error::Invalid {
148 reason: format!("{url:?} is not a URL: {e}"),
149 })?,
150 base: req.base.clone(),
151 id: id.clone(),
152 };
153 state.heads.insert(id.clone(), req.head.clone());
154 state.titles.insert(id, req.title.clone());
155 state.changes.push(change.clone());
156 Ok(change)
157 })
158 }
159
160 fn find_changes(&self, head: &str, base: &str) -> Result<Vec<ChangeRequest>> {
161 addressable(head, "the head branch")?;
162 addressable(base, "the base branch")?;
163 let state = self.store.snapshot()?;
164 Ok(state
165 .changes
166 .iter()
167 .filter(|change| {
168 change.base == base
169 && state.heads.get(&change.id).is_some_and(|from| from == head)
170 && !matches!(state.merges.get(&change.id), Some(MergeOutcome::Merged(_)))
173 })
174 .cloned()
175 .collect())
176 }
177
178 fn change_checks(&self, cr: &ChangeRequest) -> Result<ChangeChecks> {
179 let state = self.store.snapshot()?;
180 let sources = state.check_sources.clone().unwrap_or_else(complete_sources);
181 if sources.is_empty() {
186 return Err(Error::Invalid {
187 reason: format!(
188 "this host was seeded with no check source, so what the checks on {} say \
189 cannot be read rather than being empty",
190 cr.url
191 ),
192 });
193 }
194 Ok(ChangeChecks {
195 checks: state.checks.get(&cr.id).cloned().unwrap_or_default(),
196 sources,
197 })
198 }
199
200 fn check_log(&self, cr: &ChangeRequest, check: &Check) -> Result<ArtifactId> {
201 let log = self
202 .store
203 .snapshot()?
204 .check_logs
205 .get(&cr.id)
206 .and_then(|logs| logs.get(&check.name))
207 .cloned()
208 .unwrap_or_else(|| format!("the host log for check {}\n", check.name));
209 events::store_artifact(&artifact_id(&cr.id, &check.name), &log)
210 }
211
212 fn merge(&self, cr: &ChangeRequest, policy: MergePolicy) -> Result<MergeOutcome> {
213 self.store.with(|state| {
214 if let Some(decided) = state.merges.get(&cr.id) {
218 return Ok(decided.clone());
219 }
220 let landed = |state: &mut HostState| {
221 let sha = Sha(events::stable_sha(&["merge", &cr.id.0, cr.url.as_str()]));
222 state
223 .merges
224 .insert(cr.id.clone(), MergeOutcome::Merged(sha.clone()));
225 MergeOutcome::Merged(sha)
226 };
227 Ok(match policy {
228 MergePolicy::LocalDirect | MergePolicy::ChangeOpen => MergeOutcome::Open,
231 MergePolicy::ChangeAuto => {
232 if required_checks_green(state, &cr.id) {
233 landed(state)
234 } else {
235 state.merges.insert(cr.id.clone(), MergeOutcome::Queued);
238 MergeOutcome::Queued
239 }
240 }
241 MergePolicy::ChangeDirect => landed(state),
242 })
243 })
244 }
245}
246
247fn complete_sources() -> std::collections::BTreeSet<CheckSource> {
251 [CheckSource::StatusChecks].into_iter().collect()
252}
253
254fn required_checks_green(state: &HostState, id: &ChangeId) -> bool {
259 let checks = match state.checks.get(id) {
260 Some(checks) => checks,
261 None => return false,
262 };
263 let required: Vec<&Check> = checks.iter().filter(|check| check.required).collect();
264 !required.is_empty() && required.iter().all(|check| check.green())
265}
266
267fn artifact_id(change: &ChangeId, check: &str) -> String {
273 let safe: String = check
274 .chars()
275 .map(|c| {
276 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
277 c
278 } else {
279 '-'
280 }
281 })
282 .collect();
283 let number: String = change
284 .0
285 .chars()
286 .filter(|c| c.is_ascii_alphanumeric())
287 .collect();
288 format!("a-testing-{number}-{safe}")
289}
290
291fn addressable(value: &str, what: &str) -> Result<()> {
298 if value.is_empty() || value.starts_with('-') || value.contains(char::is_whitespace) {
299 return Err(Error::Invalid {
300 reason: format!(
301 "{what} {value:?} cannot address anything on the host: it must be non-empty, \
302 must not begin with '-', and must carry no whitespace"
303 ),
304 });
305 }
306 Ok(())
307}
308
309fn named_repository(slug: &str) -> Result<String> {
311 let mut parts = slug.split('/');
312 let named = matches!(
313 (parts.next(), parts.next(), parts.next()),
314 (Some(owner), Some(name), None)
315 if !owner.is_empty()
316 && !name.is_empty()
317 && !slug.starts_with('-')
318 && !slug.contains(char::is_whitespace)
319 );
320 if !named {
321 return Err(Error::Invalid {
322 reason: format!("{slug:?} does not name one repository as owner/name"),
323 });
324 }
325 Ok(slug.to_owned())
326}