1use std::collections::BTreeMap;
11use std::fs::File;
12use std::path::PathBuf;
13
14use anyhow::Context;
15use serde::Serialize;
16
17use crate::boards::BoardRef;
18use crate::checkout::Repository;
19use crate::client::{
20 CreateGistRequest, CreateIssueRequest, GitHubClient, GitHubComment, GitHubGist, GitHubIssue,
21 GitHubProject, GitHubProjectItem, ProjectFieldWrite, UpdateGistRequest, UpdateIssueRequest,
22};
23use crate::mirror::{Document, Mirror, Scope, Standing};
24use crate::sync::{self, SyncReport};
25
26#[derive(Debug)]
32pub enum Failure {
33 Invalid(String),
35 NoToken(String),
37 GitHub(anyhow::Error),
39 Mirror(anyhow::Error),
41}
42
43impl Failure {
44 pub fn is_authorization(&self) -> bool {
47 match self {
48 Self::NoToken(_) => true,
49 Self::GitHub(error) => {
50 let message = format!("{error:#}");
51 message.contains("status 401") || message.contains("status 403")
52 }
53 _ => false,
54 }
55 }
56}
57
58impl std::fmt::Display for Failure {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 match self {
61 Self::Invalid(message) | Self::NoToken(message) => f.write_str(message),
62 Self::GitHub(error) => write!(f, "{error:#}"),
63 Self::Mirror(error) => write!(f, "the GitHub mirror failed: {error:#}"),
64 }
65 }
66}
67
68impl std::error::Error for Failure {}
69
70pub type Outcome<T> = std::result::Result<T, Failure>;
71
72fn mirror_failure(error: anyhow::Error) -> Failure {
73 Failure::Mirror(error)
74}
75
76fn github_failure(error: anyhow::Error) -> Failure {
77 Failure::GitHub(error)
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum IssueState {
83 Open,
84 Closed,
85 All,
86}
87
88impl std::str::FromStr for IssueState {
89 type Err = String;
90 fn from_str(text: &str) -> Result<Self, String> {
91 match text {
92 "open" => Ok(Self::Open),
93 "closed" => Ok(Self::Closed),
94 "all" => Ok(Self::All),
95 other => Err(format!("unsupported issue state '{other}': open, closed or all")),
96 }
97 }
98}
99
100impl IssueState {
101 fn admits(self, issue: &serde_json::Value) -> bool {
102 let state = issue.get("state").and_then(|value| value.as_str()).unwrap_or("");
103 match self {
104 Self::Open => state == "open",
105 Self::Closed => state == "closed",
106 Self::All => true,
107 }
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
113pub struct RepoFailure {
114 pub repo: String,
115 pub error: String,
116}
117
118#[derive(Debug, Clone, PartialEq, Serialize)]
120pub struct IssueList {
121 pub issues: Vec<serde_json::Value>,
123 pub failures: Vec<RepoFailure>,
124 pub sync: BTreeMap<String, Standing>,
126}
127
128#[derive(Debug, Clone, PartialEq, Serialize)]
130pub struct Listed {
131 pub items: Vec<serde_json::Value>,
132 pub sync: Standing,
133}
134
135#[derive(Debug, Clone, PartialEq, Serialize)]
138pub struct Single {
139 pub item: Option<serde_json::Value>,
140 pub sync: Standing,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
145pub struct RepoFacts {
146 pub open_issues: i64,
147 pub open_pulls: i64,
148 pub private: Option<bool>,
151 pub archived: Option<bool>,
152 pub stars: Option<i64>,
153 pub checks: Option<String>,
155}
156
157#[derive(Debug, Clone, PartialEq, Serialize)]
159pub struct BoardList {
160 pub boards: Vec<GitHubProject>,
161 pub problems: Vec<String>,
162 pub sync: BTreeMap<String, Standing>,
164}
165
166#[derive(Debug, Clone, PartialEq, Serialize)]
168pub struct Cards {
169 pub items: Vec<GitHubProjectItem>,
170 pub sync: Standing,
171}
172
173#[derive(Debug, Clone, PartialEq, Serialize)]
175pub struct GistAnswer {
176 pub gist: serde_json::Value,
177 pub from_mirror: bool,
178}
179
180#[derive(Debug, Clone)]
182pub struct Service {
183 mirror: Mirror,
184}
185
186impl Service {
187 pub fn new(mirror: Mirror) -> Self {
188 Self { mirror }
189 }
190
191 pub fn mirror(&self) -> &Mirror {
192 &self.mirror
193 }
194
195 fn standing(&self, scope: &Scope) -> Outcome<Standing> {
198 Ok(self.mirror.state(scope).map_err(mirror_failure)?.standing())
199 }
200
201 pub fn issues(&self, repos: &[String], wanted: IssueState) -> Outcome<IssueList> {
203 let mut issues = Vec::new();
204 let mut failures = Vec::new();
205 let mut standing = BTreeMap::new();
206 for repo in repos {
207 let scope = Scope::Issues(repo.clone());
208 for mut issue in self.mirror.payloads(&scope).map_err(mirror_failure)? {
209 if !wanted.admits(&issue) {
210 continue;
211 }
212 if let Some(object) = issue.as_object_mut() {
213 object.insert("repo".to_string(), serde_json::json!(repo));
214 }
215 issues.push(issue);
216 }
217 let state = self.mirror.state(&scope).map_err(mirror_failure)?;
218 if let Some(error) = &state.last_error {
221 failures.push(RepoFailure {
222 repo: repo.clone(),
223 error: error.clone(),
224 });
225 }
226 standing.insert(repo.clone(), state.standing());
227 }
228 issues.sort_by(|a, b| updated_of(b).cmp(updated_of(a)));
229 Ok(IssueList {
230 issues,
231 failures,
232 sync: standing,
233 })
234 }
235
236 pub fn list(&self, scope: &Scope) -> Outcome<Listed> {
238 Ok(Listed {
239 items: self.mirror.payloads(scope).map_err(mirror_failure)?,
240 sync: self.standing(scope)?,
241 })
242 }
243
244 pub fn single(&self, scope: &Scope) -> Outcome<Single> {
246 Ok(Single {
247 item: self.mirror.only(scope).map_err(mirror_failure)?,
248 sync: self.standing(scope)?,
249 })
250 }
251
252 pub fn overview(&self, repositories: &[Repository]) -> Outcome<BTreeMap<String, RepoFacts>> {
254 let open_issues = self.mirror.open_counts("issues").map_err(mirror_failure)?;
255 let open_pulls = self.mirror.open_counts("pulls").map_err(mirror_failure)?;
256 let mut facts = BTreeMap::new();
257 for repository in repositories {
258 let repo = &repository.name;
259 let metadata = self
260 .mirror
261 .only(&Scope::Repository(repo.clone()))
262 .map_err(mirror_failure)?;
263 let checks = match &repository.head {
264 Some(commit) => self
265 .mirror
266 .only(&Scope::Checks {
267 repo: repo.clone(),
268 commit: commit.clone(),
269 })
270 .map_err(mirror_failure)?,
271 None => None,
272 };
273 facts.insert(
274 repo.clone(),
275 RepoFacts {
276 open_issues: open_issues.get(repo).copied().unwrap_or(0),
277 open_pulls: open_pulls.get(repo).copied().unwrap_or(0),
278 private: metadata.as_ref().and_then(|m| m.get("private")?.as_bool()),
279 archived: metadata.as_ref().and_then(|m| m.get("archived")?.as_bool()),
280 stars: metadata.as_ref().and_then(|m| m.get("stargazers_count")?.as_i64()),
281 checks: checks
282 .as_ref()
283 .and_then(|c| c.get("state")?.as_str().map(str::to_string)),
284 },
285 );
286 }
287 Ok(facts)
288 }
289
290 pub fn gists(&self) -> Outcome<Listed> {
292 self.list(&Scope::Gists)
293 }
294
295 pub async fn gist(&self, client: Option<&GitHubClient>, id: &str) -> Outcome<GistAnswer> {
302 if let Some(held) = self.mirror.document(&Scope::Gists, id).map_err(mirror_failure)? {
303 if held.complete {
304 return Ok(GistAnswer {
305 gist: held.payload,
306 from_mirror: true,
307 });
308 }
309 }
310 let client = client.ok_or_else(|| {
311 Failure::NoToken(format!(
312 "gist {id} is held only as a summary, and there is no GitHub token to read it with \
313 (log in with `gh auth login`)"
314 ))
315 })?;
316 let gist = client.get_gist(id).await.map_err(github_failure)?;
317 let payload = self.record_gist(&gist)?;
318 Ok(GistAnswer {
319 gist: payload,
320 from_mirror: false,
321 })
322 }
323
324 pub async fn comments(
330 &self,
331 client: Option<&GitHubClient>,
332 repo: &str,
333 number: i64,
334 ) -> Outcome<Listed> {
335 let scope = Scope::Comments {
336 repo: repo.to_string(),
337 number,
338 };
339 self.mirror.hold(&scope).map_err(mirror_failure)?;
340 let state = self.mirror.state(&scope).map_err(mirror_failure)?;
341 if !state.ever_synced() {
342 if let Some(client) = client {
343 let report = sync::sync_scopes(&self.mirror, client, &[scope.clone()], true).await;
344 if let Some((_, error)) = report.failed.first() {
345 return Err(Failure::GitHub(anyhow::anyhow!("{error}")));
346 }
347 }
348 }
349 self.list(&scope)
350 }
351
352 fn record_issue(&self, repo: &str, issue: &GitHubIssue) -> Outcome<()> {
355 let payload = serde_json::to_value(issue).map_err(|e| mirror_failure(e.into()))?;
356 self.mirror
357 .put_one(
358 &Scope::Issues(repo.to_string()),
359 Document::of(issue.number.to_string(), payload),
360 )
361 .map_err(mirror_failure)?;
362 Ok(())
363 }
364
365 fn record_gist(&self, gist: &GitHubGist) -> Outcome<serde_json::Value> {
366 let payload = serde_json::to_value(gist).map_err(|e| mirror_failure(e.into()))?;
367 self.mirror
370 .put_one(&Scope::Gists, Document::of(gist.id.clone(), payload.clone()))
371 .map_err(mirror_failure)?;
372 Ok(payload)
373 }
374
375 pub async fn create_issue(
376 &self,
377 client: &GitHubClient,
378 owner: &str,
379 repo: &str,
380 request: &CreateIssueRequest,
381 ) -> Outcome<GitHubIssue> {
382 if request.title.trim().is_empty() {
383 return Err(Failure::Invalid("an issue needs a title".to_string()));
384 }
385 let issue = client
386 .create_issue(owner, repo, request)
387 .await
388 .map_err(github_failure)?;
389 self.record_issue(&format!("{owner}/{repo}"), &issue)?;
392 Ok(issue)
393 }
394
395 pub async fn update_issue(
396 &self,
397 client: &GitHubClient,
398 owner: &str,
399 repo: &str,
400 number: i64,
401 request: &UpdateIssueRequest,
402 ) -> Outcome<GitHubIssue> {
403 let issue = client
404 .update_issue(owner, repo, number, request)
405 .await
406 .map_err(github_failure)?;
407 self.record_issue(&format!("{owner}/{repo}"), &issue)?;
408 Ok(issue)
409 }
410
411 pub async fn add_comment(
412 &self,
413 client: &GitHubClient,
414 owner: &str,
415 repo: &str,
416 number: i64,
417 body: &str,
418 ) -> Outcome<GitHubComment> {
419 if body.trim().is_empty() {
420 return Err(Failure::Invalid("a comment needs a body".to_string()));
421 }
422 let comment = client
423 .add_comment(owner, repo, number, body)
424 .await
425 .map_err(github_failure)?;
426 let scope = Scope::Comments {
427 repo: format!("{owner}/{repo}"),
428 number,
429 };
430 let payload = serde_json::to_value(&comment).map_err(|e| mirror_failure(e.into()))?;
431 self.mirror.hold(&scope).map_err(mirror_failure)?;
432 self.mirror
433 .put_one(&scope, Document::of(comment.id.to_string(), payload))
434 .map_err(mirror_failure)?;
435 Ok(comment)
436 }
437
438 pub async fn create_gist(
439 &self,
440 client: &GitHubClient,
441 request: &CreateGistRequest,
442 ) -> Outcome<serde_json::Value> {
443 if request.files.is_empty() {
444 return Err(Failure::Invalid("a gist needs at least one file".to_string()));
445 }
446 let gist = client.create_gist(request).await.map_err(github_failure)?;
447 self.record_gist(&gist)
448 }
449
450 pub async fn update_gist(
451 &self,
452 client: &GitHubClient,
453 id: &str,
454 request: &UpdateGistRequest,
455 ) -> Outcome<serde_json::Value> {
456 let gist = client.update_gist(id, request).await.map_err(github_failure)?;
457 self.record_gist(&gist)
458 }
459
460 pub fn boards(&self, references: &[BoardRef]) -> Outcome<BoardList> {
464 let wanted = references.iter().cloned().collect();
465 let (boards, problems) =
466 sync::resolve_boards(&self.mirror, &wanted).map_err(mirror_failure)?;
467 let mut ordered = Vec::new();
469 for reference in references {
470 if let Some(board) = boards
471 .iter()
472 .find(|board| board.owner == reference.owner && board.number == reference.number)
473 {
474 ordered.push(board.clone());
475 }
476 }
477 let mut standing = BTreeMap::new();
478 for reference in references {
479 let scope = Scope::Projects(reference.owner.clone());
480 standing.insert(scope.key(), self.standing(&scope)?);
481 }
482 Ok(BoardList {
483 boards: ordered,
484 problems,
485 sync: standing,
486 })
487 }
488
489 pub fn cards(&self, board: &str) -> Outcome<Cards> {
496 let scope = Scope::ProjectItems(board.to_string());
497 let items = self
498 .mirror
499 .payloads(&scope)
500 .map_err(mirror_failure)?
501 .into_iter()
502 .map(|payload| {
503 serde_json::from_value::<GitHubProjectItem>(payload).map_err(|e| {
504 Failure::Mirror(anyhow::anyhow!(
505 "a mirrored card of {board} cannot be read by this version ({e}); \
506 sync the board to read it again"
507 ))
508 })
509 })
510 .collect::<Outcome<Vec<_>>>()?;
511 Ok(Cards {
512 items,
513 sync: self.standing(&scope)?,
514 })
515 }
516
517 pub async fn refresh_board(
520 &self,
521 client: &GitHubClient,
522 board: &GitHubProject,
523 force: bool,
524 ) -> SyncReport {
525 sync::sync_scopes(
526 &self.mirror,
527 client,
528 &[
529 Scope::Projects(board.owner.clone()),
530 Scope::ProjectItems(board.id.clone()),
531 ],
532 force,
533 )
534 .await
535 }
536
537 fn record_card(&self, board: &str, item: &GitHubProjectItem) -> Outcome<()> {
538 let payload = serde_json::to_value(item).map_err(|e| mirror_failure(e.into()))?;
539 self.mirror
540 .put_one(
541 &Scope::ProjectItems(board.to_string()),
542 Document::of(item.id.clone(), payload),
543 )
544 .map_err(mirror_failure)?;
545 Ok(())
546 }
547
548 pub async fn set_field(
554 &self,
555 client: &GitHubClient,
556 board: &str,
557 item: &str,
558 field: &str,
559 value: Option<&ProjectFieldWrite>,
560 ) -> Outcome<GitHubProjectItem> {
561 let updated = match value {
562 Some(value) => client.set_project_field(board, item, field, value).await,
563 None => client.clear_project_field(board, item, field).await,
564 }
565 .map_err(github_failure)?;
566 self.record_card(board, &updated)?;
567 Ok(updated)
568 }
569
570 pub async fn move_card(
572 &self,
573 client: &GitHubClient,
574 board: &str,
575 item: &str,
576 after: Option<&str>,
577 ) -> Outcome<GitHubProjectItem> {
578 if after == Some(item) {
579 return Err(Failure::Invalid("a card cannot be moved behind itself".to_string()));
580 }
581 let updated = client
582 .move_project_item_after(board, item, after)
583 .await
584 .map_err(github_failure)?;
585 self.record_card(board, &updated)?;
586 Ok(updated)
587 }
588
589 pub async fn set_archived(
592 &self,
593 client: &GitHubClient,
594 board: &str,
595 item: &str,
596 archived: bool,
597 ) -> Outcome<GitHubProjectItem> {
598 let updated = client
599 .archive_project_item(board, item, archived)
600 .await
601 .map_err(github_failure)?;
602 self.record_card(board, &updated)?;
603 Ok(updated)
604 }
605
606 pub async fn edit_draft(
608 &self,
609 client: &GitHubClient,
610 board: &str,
611 item: &str,
612 content: &str,
613 title: &str,
614 body: &str,
615 ) -> Outcome<GitHubProjectItem> {
616 if title.trim().is_empty() {
617 return Err(Failure::Invalid(
618 "a draft card needs a title; an untitled card is one nobody can find".to_string(),
619 ));
620 }
621 let updated = client
622 .update_project_draft(item, content, title, body)
623 .await
624 .map_err(github_failure)?;
625 self.record_card(board, &updated)?;
626 Ok(updated)
627 }
628
629 pub async fn add_draft(
631 &self,
632 client: &GitHubClient,
633 board: &str,
634 title: &str,
635 body: &str,
636 ) -> Outcome<GitHubProjectItem> {
637 if title.trim().is_empty() {
638 return Err(Failure::Invalid("a draft card needs a title".to_string()));
639 }
640 let added = client
641 .add_project_draft(board, title, body)
642 .await
643 .map_err(github_failure)?;
644 self.record_card(board, &added)?;
645 Ok(added)
646 }
647
648 pub async fn add_existing(
650 &self,
651 client: &GitHubClient,
652 board: &str,
653 url: &str,
654 ) -> Outcome<GitHubProjectItem> {
655 let added = client
656 .add_project_item_by_url(board, url)
657 .await
658 .map_err(github_failure)?;
659 self.record_card(board, &added)?;
660 Ok(added)
661 }
662
663 pub async fn remove_card(&self, client: &GitHubClient, board: &str, item: &str) -> Outcome<()> {
666 client
667 .delete_project_item(board, item)
668 .await
669 .map_err(github_failure)?;
670 self.mirror
673 .remove_one(&Scope::ProjectItems(board.to_string()), item)
674 .map_err(mirror_failure)?;
675 Ok(())
676 }
677
678 pub async fn add_column(
680 &self,
681 client: &GitHubClient,
682 board: &str,
683 field: &str,
684 name: &str,
685 color: &str,
686 description: &str,
687 ) -> Outcome<(GitHubProject, Vec<GitHubProjectItem>)> {
688 let _lock = self.column_lock().await?;
689 let updated = client
690 .add_project_column(board, field, name, color, description)
691 .await
692 .map_err(github_failure)?;
693 let items = client.project_items(board).await.map_err(github_failure)?;
694 self.record_board(&updated, &items)?;
695 Ok((updated, items))
696 }
697
698 pub async fn remove_column(
701 &self,
702 client: &GitHubClient,
703 board: &str,
704 field: &str,
705 option: &str,
706 ) -> Outcome<(GitHubProject, Vec<GitHubProjectItem>)> {
707 let _lock = self.column_lock().await?;
708 let updated = client
709 .remove_project_column(board, field, option)
710 .await
711 .map_err(github_failure)?;
712 let items = client.project_items(board).await.map_err(github_failure)?;
713 self.record_board(&updated, &items)?;
714 Ok((updated, items))
715 }
716
717 fn record_board(&self, board: &GitHubProject, items: &[GitHubProjectItem]) -> Outcome<()> {
720 let board_value = serde_json::to_value(board).map_err(|e| mirror_failure(e.into()))?;
721 let documents = items
722 .iter()
723 .map(|item| {
724 serde_json::to_value(item)
725 .map(|value| Document::of(item.id.clone(), value))
726 .map_err(|e| mirror_failure(e.into()))
727 })
728 .collect::<Outcome<Vec<_>>>()?;
729 self.mirror
730 .put_project_write(
731 &Scope::Projects(board.owner.clone()),
732 Document::of(board.id.clone(), board_value),
733 &Scope::ProjectItems(board.id.clone()),
734 &documents,
735 )
736 .map_err(mirror_failure)
737 }
738
739 async fn column_lock(&self) -> Outcome<ColumnLock> {
746 let path = self.mirror.path().with_extension("columns.lock");
747 tokio::task::spawn_blocking(move || ColumnLock::acquire(path))
748 .await
749 .map_err(|e| Failure::Mirror(anyhow::anyhow!("the column lock could not be taken: {e}")))?
750 .map_err(mirror_failure)
751 }
752}
753
754struct ColumnLock {
756 file: File,
757}
758
759impl ColumnLock {
760 fn acquire(path: PathBuf) -> anyhow::Result<Self> {
761 let file = std::fs::OpenOptions::new()
762 .create(true)
763 .truncate(false)
764 .write(true)
765 .open(&path)
766 .with_context(|| format!("cannot open {}", path.display()))?;
767 file.lock()
768 .with_context(|| format!("cannot lock {}", path.display()))?;
769 Ok(Self { file })
770 }
771}
772
773impl Drop for ColumnLock {
774 fn drop(&mut self) {
775 let _ = self.file.unlock();
778 }
779}
780
781fn updated_of(issue: &serde_json::Value) -> &str {
782 issue
783 .get("updated_at")
784 .and_then(|value| value.as_str())
785 .unwrap_or("")
786}
787
788#[cfg(test)]
789#[path = "service_tests.rs"]
790mod tests;