1use std::collections::{BTreeMap, BTreeSet};
17use std::time::Duration;
18
19use anyhow::{anyhow, Result};
20use serde::Serialize;
21
22use crate::boards::BoardRef;
23use crate::client::{Conditions, Fetched, GitHubClient, GitHubProject};
24use crate::mirror::{gist_is_whole, newest_update, Document, Mirror, Scope, SyncState, SINGLETON};
25use crate::targets::Targets;
26
27pub const SWEEP: Duration = Duration::from_secs(120);
33
34pub const SWEEP_UNCONDITIONAL: Duration = Duration::from_secs(300);
40
41pub const SWEEP_TRAFFIC: Duration = Duration::from_secs(6 * 60 * 60);
45
46pub const AFTER_FAILURE: Duration = Duration::from_secs(600);
50
51#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
53pub struct SyncReport {
54 pub changed: Vec<String>,
56 pub unchanged: Vec<String>,
58 #[serde(serialize_with = "failures_as_objects")]
61 pub failed: Vec<(String, String)>,
62 pub no_token: bool,
65 pub problems: Vec<String>,
67}
68
69impl SyncReport {
70 pub fn touched_anything(&self) -> bool {
71 !self.changed.is_empty()
72 }
73}
74
75fn failures_as_objects<S: serde::Serializer>(
76 failed: &[(String, String)],
77 serializer: S,
78) -> Result<S::Ok, S::Error> {
79 use serde::ser::SerializeSeq;
80 #[derive(Serialize)]
81 struct Failure<'a> {
82 scope: &'a str,
83 error: &'a str,
84 }
85 let mut seq = serializer.serialize_seq(Some(failed.len()))?;
86 for (scope, error) in failed {
87 seq.serialize_element(&Failure { scope, error })?;
88 }
89 seq.end()
90}
91
92pub fn wanted(mirror: &Mirror, targets: &Targets) -> Result<Vec<Scope>> {
98 let mut scopes = vec![Scope::Gists, Scope::Notifications, Scope::RateLimit];
99 for (repo, commits) in &targets.repositories {
100 scopes.extend([
101 Scope::Issues(repo.clone()),
102 Scope::Pulls(repo.clone()),
103 Scope::Repository(repo.clone()),
104 Scope::Releases(repo.clone()),
105 Scope::Tags(repo.clone()),
106 Scope::Milestones(repo.clone()),
107 Scope::Labels(repo.clone()),
108 Scope::Traffic(repo.clone()),
109 ]);
110 scopes.extend(commits.iter().map(|commit| Scope::Checks {
111 repo: repo.clone(),
112 commit: commit.clone(),
113 }));
114 }
115 scopes.extend(targets.board_owners().into_iter().map(Scope::Projects));
116 scopes.extend(
120 mirror
121 .states()?
122 .keys()
123 .filter_map(|key| Scope::parse(key))
124 .filter(|scope| match scope {
125 Scope::Comments { repo, .. } => targets.repositories.contains_key(repo),
126 _ => false,
127 }),
128 );
129 Ok(scopes)
130}
131
132pub fn resolve_boards(
138 mirror: &Mirror,
139 boards: &BTreeSet<BoardRef>,
140) -> Result<(Vec<GitHubProject>, Vec<String>)> {
141 let mut found = Vec::new();
142 let mut problems = Vec::new();
143 let mut lists: BTreeMap<String, std::result::Result<Vec<GitHubProject>, String>> =
145 BTreeMap::new();
146 for board in boards {
147 if !lists.contains_key(&board.owner) {
148 let list = owner_boards(mirror, &board.owner)?;
149 lists.insert(board.owner.clone(), list);
150 }
151 match &lists[&board.owner] {
152 Err(reason) => problems.push(format!("{board}: {reason}")),
153 Ok(list) => match list.iter().find(|project| project.number == board.number) {
154 Some(project) => found.push(project.clone()),
155 None => problems.push(format!("{} has no board {}", board.owner, board.number)),
156 },
157 }
158 }
159 Ok((found, problems))
160}
161
162pub fn owner_boards(
164 mirror: &Mirror,
165 owner: &str,
166) -> Result<std::result::Result<Vec<GitHubProject>, String>> {
167 let scope = Scope::Projects(owner.to_string());
168 let state = mirror.state(&scope)?;
169 if !state.ever_synced() {
170 return Ok(Err(match &state.last_error {
171 Some(error) => format!("{owner}'s boards could not be read: {error}"),
172 None => format!("{owner}'s boards have not been read yet"),
173 }));
174 }
175 let boards = mirror
176 .payloads(&scope)?
177 .into_iter()
178 .map(|payload| {
179 serde_json::from_value::<GitHubProject>(payload).map_err(|e| {
180 anyhow!("a mirrored board of {owner} cannot be read by this version ({e}); sync again")
181 })
182 })
183 .collect::<Result<Vec<_>>>()?;
184 Ok(Ok(boards))
185}
186
187pub fn board_scopes(mirror: &Mirror, targets: &Targets) -> Result<(Vec<Scope>, Vec<String>)> {
189 let (found, problems) = resolve_boards(mirror, &targets.boards)?;
190 Ok((
191 found
192 .into_iter()
193 .map(|board| Scope::ProjectItems(board.id))
194 .collect(),
195 problems,
196 ))
197}
198
199pub async fn sync(
207 mirror: &Mirror,
208 client: Option<&GitHubClient>,
209 targets: &Targets,
210 force: bool,
211) -> SyncReport {
212 let mut report = SyncReport {
213 problems: targets.problems.clone(),
214 ..SyncReport::default()
215 };
216
217 let first = match wanted(mirror, targets) {
218 Ok(scopes) => scopes,
219 Err(error) => {
220 report.problems.push(format!("the mirror cannot be read: {error:#}"));
221 return report;
222 }
223 };
224 if let Some(client) = client {
226 run(mirror, client, &first, force, &mut report).await;
227 } else {
228 report.no_token = true;
229 }
230 let cards = match board_scopes(mirror, targets) {
231 Ok((scopes, problems)) => {
232 report.problems.extend(problems);
233 scopes
234 }
235 Err(error) => {
236 report.problems.push(format!("the boards cannot be resolved: {error:#}"));
237 Vec::new()
238 }
239 };
240 if let Some(client) = client {
241 run(mirror, client, &cards, force, &mut report).await;
242 }
243
244 let mut all = first;
245 all.extend(cards);
246 forget_departed(mirror, targets, &all, &mut report);
247 report
248}
249
250pub async fn sync_scopes(
255 mirror: &Mirror,
256 client: &GitHubClient,
257 scopes: &[Scope],
258 force: bool,
259) -> SyncReport {
260 let mut report = SyncReport::default();
261 run(mirror, client, scopes, force, &mut report).await;
262 report
263}
264
265async fn run(
266 mirror: &Mirror,
267 client: &GitHubClient,
268 scopes: &[Scope],
269 force: bool,
270 report: &mut SyncReport,
271) {
272 let now = chrono::Utc::now();
273 for scope in scopes {
274 let state = match mirror.state(scope) {
275 Ok(state) => state,
276 Err(error) => {
277 report.failed.push((scope.key(), format!("{error:#}")));
278 continue;
279 }
280 };
281 if !force && !due(scope, &state, now) {
282 continue;
283 }
284 match sync_one(client, mirror, scope, &state).await {
285 Ok(true) => report.changed.push(scope.key()),
286 Ok(false) => report.unchanged.push(scope.key()),
287 Err(error) => {
288 let message = format!("{error:#}");
289 if let Err(write) = mirror.failed(scope, &message) {
290 report
291 .failed
292 .push((scope.key(), format!("{message}; and the failure could not be recorded: {write:#}")));
293 continue;
294 }
295 report.failed.push((scope.key(), message));
296 }
297 }
298 }
299}
300
301async fn sync_one(
308 client: &GitHubClient,
309 mirror: &Mirror,
310 scope: &Scope,
311 held: &SyncState,
312) -> Result<bool> {
313 let conditions = Conditions {
314 etag: held.etag.clone(),
315 since: held.cursor.clone(),
316 };
317
318 match scope {
319 Scope::Issues(repo) => {
320 let (owner, name) = split(repo)?;
321 match client.list_issues_changed(owner, name, &conditions).await? {
322 Fetched::Unchanged => unchanged(mirror, scope),
323 Fetched::Fresh { value, etag } => {
324 let documents = documents_by(&value, |issue| issue.number.to_string())?;
325 incremental(mirror, scope, documents, etag)
326 }
327 }
328 }
329 Scope::Pulls(repo) => {
330 let (owner, name) = split(repo)?;
331 match client.list_pulls_changed(owner, name, &conditions).await? {
332 Fetched::Unchanged => unchanged(mirror, scope),
333 Fetched::Fresh { value, etag } => {
334 let documents = documents_by(&value, |pull| pull.number.to_string())?;
335 incremental(mirror, scope, documents, etag)
336 }
337 }
338 }
339 Scope::Repository(repo) => {
340 let (owner, name) = split(repo)?;
341 match client.repository_changed(owner, name, &conditions).await? {
342 Fetched::Unchanged => unchanged(mirror, scope),
343 Fetched::Fresh { value, etag } => {
344 let changed = mirror.put_only(scope, serde_json::to_value(&value)?)?;
345 mirror.synced(scope, etag.as_deref(), None)?;
346 Ok(changed)
347 }
348 }
349 }
350 Scope::Checks { repo, commit } => {
351 let (owner, name) = split(repo)?;
352 let checks = client.checks_for(owner, name, commit).await?;
353 let document =
354 Document::of(SINGLETON, serde_json::to_value(&checks)?).with_state(checks.state.clone());
355 let changed = mirror.put(scope, &[document])?;
356 mirror.synced(scope, None, None)?;
357 Ok(changed)
358 }
359 Scope::Releases(repo) => {
360 let (owner, name) = split(repo)?;
361 match client.list_releases_changed(owner, name, &conditions).await? {
362 Fetched::Unchanged => unchanged(mirror, scope),
363 Fetched::Fresh { value, etag } => {
364 replace(mirror, scope, documents_by(&value, |release| release.id.to_string())?, etag)
365 }
366 }
367 }
368 Scope::Tags(repo) => {
369 let (owner, name) = split(repo)?;
370 match client.list_tags_changed(owner, name, &conditions).await? {
371 Fetched::Unchanged => unchanged(mirror, scope),
372 Fetched::Fresh { value, etag } => {
373 replace(mirror, scope, documents_by(&value, |tag| tag.name.clone())?, etag)
374 }
375 }
376 }
377 Scope::Milestones(repo) => {
378 let (owner, name) = split(repo)?;
379 match client.list_milestones_changed(owner, name, &conditions).await? {
380 Fetched::Unchanged => unchanged(mirror, scope),
381 Fetched::Fresh { value, etag } => replace(
382 mirror,
383 scope,
384 documents_by(&value, |milestone| milestone.number.to_string())?,
385 etag,
386 ),
387 }
388 }
389 Scope::Labels(repo) => {
390 let (owner, name) = split(repo)?;
391 match client.list_labels_changed(owner, name, &conditions).await? {
392 Fetched::Unchanged => unchanged(mirror, scope),
393 Fetched::Fresh { value, etag } => {
394 replace(mirror, scope, documents_by(&value, |label| label.name.clone())?, etag)
395 }
396 }
397 }
398 Scope::Traffic(repo) => {
399 let (owner, name) = split(repo)?;
400 let traffic = client.traffic(owner, name).await?;
403 let changed = mirror.put_only(scope, serde_json::to_value(&traffic)?)?;
404 mirror.synced(scope, None, None)?;
405 Ok(changed)
406 }
407 Scope::Comments { repo, number } => {
408 let (owner, name) = split(repo)?;
409 match client
410 .list_comments_changed(owner, name, *number, &conditions)
411 .await?
412 {
413 Fetched::Unchanged => unchanged(mirror, scope),
414 Fetched::Fresh { value, etag } => {
415 replace(mirror, scope, documents_by(&value, |comment| comment.id.to_string())?, etag)
416 }
417 }
418 }
419 Scope::Gists => match client.list_gists_changed(&conditions).await? {
420 Fetched::Unchanged => unchanged(mirror, scope),
421 Fetched::Fresh { value, etag } => {
422 let mut documents = Vec::new();
423 for gist in &value {
424 let payload = serde_json::to_value(gist)?;
425 documents.push(if gist_is_whole(gist) {
426 Document::of(gist.id.clone(), payload)
427 } else {
428 Document::summary(gist.id.clone(), payload)
432 });
433 }
434 replace(mirror, scope, documents, etag)
435 }
436 },
437 Scope::Notifications => match client.list_notifications_changed(&conditions).await? {
438 Fetched::Unchanged => unchanged(mirror, scope),
439 Fetched::Fresh { value, etag } => replace(
440 mirror,
441 scope,
442 documents_by(&value, |notification| notification.id.clone())?,
443 etag,
444 ),
445 },
446 Scope::RateLimit => {
447 let limit = client.rate_limit().await?;
448 let changed = mirror.put_only(scope, serde_json::to_value(&limit)?)?;
449 mirror.synced(scope, None, None)?;
450 Ok(changed)
451 }
452 Scope::Projects(login) => {
453 let projects = client.list_projects(login).await?;
454 replace(mirror, scope, documents_by(&projects, |project| project.id.clone())?, None)
455 }
456 Scope::ProjectItems(id) => {
457 let items = client.project_items(id).await?;
458 replace(mirror, scope, documents_by(&items, |item| item.id.clone())?, None)
459 }
460 }
461}
462
463fn unchanged(mirror: &Mirror, scope: &Scope) -> Result<bool> {
465 mirror.synced(scope, None, None)?;
466 Ok(false)
467}
468
469fn incremental(
471 mirror: &Mirror,
472 scope: &Scope,
473 documents: Vec<Document>,
474 etag: Option<String>,
475) -> Result<bool> {
476 let cursor = newest_update(documents.iter());
477 let changed = mirror.put(scope, &documents)?;
478 mirror.synced(scope, etag.as_deref(), cursor.as_deref())?;
479 Ok(changed)
480}
481
482fn replace(
484 mirror: &Mirror,
485 scope: &Scope,
486 documents: Vec<Document>,
487 etag: Option<String>,
488) -> Result<bool> {
489 let changed = mirror.put(scope, &documents)?;
490 mirror.synced(scope, etag.as_deref(), None)?;
491 Ok(changed)
492}
493
494fn documents_by<T: Serialize>(values: &[T], key: impl Fn(&T) -> String) -> Result<Vec<Document>> {
496 values
497 .iter()
498 .map(|value| Ok(Document::of(key(value), serde_json::to_value(value)?)))
499 .collect()
500}
501
502fn split(repo: &str) -> Result<(&str, &str)> {
503 repo.split_once('/')
504 .ok_or_else(|| anyhow!("{repo} is not owner/repo"))
505}
506
507pub fn due(scope: &Scope, state: &SyncState, now: chrono::DateTime<chrono::Utc>) -> bool {
513 let Some(attempted) = state.attempted_at.as_deref().and_then(parse_time) else {
514 return true;
517 };
518 let waited = (now - attempted).to_std().unwrap_or(Duration::ZERO);
519 if state.last_error.is_some() {
520 return waited >= AFTER_FAILURE;
521 }
522 waited >= interval(scope)
523}
524
525pub fn interval(scope: &Scope) -> Duration {
529 match scope {
530 Scope::Traffic(_) => SWEEP_TRAFFIC,
531 Scope::Checks { .. } | Scope::Projects(_) | Scope::ProjectItems(_) => SWEEP_UNCONDITIONAL,
532 _ => SWEEP,
533 }
534}
535
536fn forget_departed(mirror: &Mirror, targets: &Targets, wanted: &[Scope], report: &mut SyncReport) {
543 if !report.problems.is_empty() {
544 return;
545 }
546 let keep: BTreeSet<String> = wanted.iter().map(Scope::key).collect();
547 let known = match mirror.states() {
548 Ok(known) => known,
549 Err(error) => {
550 report.problems.push(format!("the mirror cannot be read: {error:#}"));
551 return;
552 }
553 };
554 for key in known.keys() {
555 if keep.contains(key) {
556 continue;
557 }
558 let Some(scope) = Scope::parse(key) else {
559 report.problems.push(format!("the mirror holds a scope it cannot read: {key}"));
560 continue;
561 };
562 let forget = match &scope {
563 Scope::Gists | Scope::Notifications | Scope::RateLimit => false,
565 Scope::Checks { .. } | Scope::Projects(_) | Scope::ProjectItems(_) => true,
568 other => other
571 .repository()
572 .is_some_and(|repo| !targets.repositories.contains_key(repo)),
573 };
574 if forget {
575 match mirror.forget(&scope) {
576 Ok(()) => report.changed.push(scope.key()),
577 Err(error) => report
578 .failed
579 .push((scope.key(), format!("cannot forget it: {error:#}"))),
580 }
581 }
582 }
583}
584
585pub const TICK: Duration = Duration::from_secs(30);
589
590pub async fn keep_current(
598 prj: wsb_prj::Prj,
599 mirror: Mirror,
600 mut on_pass: impl FnMut(&SyncReport) + Send,
601) {
602 loop {
603 let targets_of = prj.clone();
604 let read = tokio::task::spawn_blocking(move || {
605 (Targets::of(&targets_of), crate::token::resolve())
606 })
607 .await;
608 let report = match read {
609 Ok((Ok(targets), token)) => {
610 let client = token.ok().map(|token| GitHubClient::new(token.value()));
611 sync(&mirror, client.as_ref(), &targets, false).await
612 }
613 Ok((Err(error), _)) => SyncReport {
614 problems: vec![format!("the projects under {} cannot be read: {error:#}", prj.root().display())],
615 ..SyncReport::default()
616 },
617 Err(error) => SyncReport {
618 problems: vec![format!("reading what to mirror failed: {error}")],
619 ..SyncReport::default()
620 },
621 };
622 on_pass(&report);
623 tokio::time::sleep(TICK).await;
624 }
625}
626
627fn parse_time(value: &str) -> Option<chrono::DateTime<chrono::Utc>> {
628 chrono::DateTime::parse_from_rfc3339(value)
629 .ok()
630 .map(|time| time.with_timezone(&chrono::Utc))
631}
632
633#[cfg(test)]
634#[path = "sync_tests.rs"]
635mod tests;