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, Copy, PartialEq, Eq, Serialize)]
168#[serde(rename_all = "snake_case")]
169pub enum Relation {
170 Account,
172 Organization,
174 Referenced,
176}
177
178#[derive(Debug, Clone, PartialEq, Serialize)]
180pub struct BoardOwner {
181 pub login: String,
182 pub relation: Relation,
183 pub sync: Standing,
184}
185
186#[derive(Debug, Clone, PartialEq, Serialize)]
188pub struct AllBoards {
189 pub owners: Vec<BoardOwner>,
192 pub boards: Vec<GitHubProject>,
194 pub referenced: Vec<String>,
197 pub recent: Vec<BoardActivity>,
199 pub account: Standing,
201 pub problems: Vec<String>,
202}
203
204#[derive(Debug, Clone, PartialEq, Serialize)]
206pub struct BoardActivity {
207 pub board: String,
209 pub newest_change: Option<String>,
212 pub cards: Standing,
214}
215
216#[derive(Debug, Clone, PartialEq, Serialize)]
218pub struct Cards {
219 pub items: Vec<GitHubProjectItem>,
220 pub sync: Standing,
221}
222
223#[derive(Debug, Clone, PartialEq, Serialize)]
225pub struct GistAnswer {
226 pub gist: serde_json::Value,
227 pub from_mirror: bool,
228}
229
230#[derive(Debug, Clone)]
232pub struct Service {
233 mirror: Mirror,
234}
235
236impl Service {
237 pub fn new(mirror: Mirror) -> Self {
238 Self { mirror }
239 }
240
241 pub fn mirror(&self) -> &Mirror {
242 &self.mirror
243 }
244
245 fn standing(&self, scope: &Scope) -> Outcome<Standing> {
248 Ok(self.mirror.state(scope).map_err(mirror_failure)?.standing())
249 }
250
251 pub fn issues(&self, repos: &[String], wanted: IssueState) -> Outcome<IssueList> {
253 let mut issues = Vec::new();
254 let mut failures = Vec::new();
255 let mut standing = BTreeMap::new();
256 for repo in repos {
257 let scope = Scope::Issues(repo.clone());
258 for mut issue in self.mirror.payloads(&scope).map_err(mirror_failure)? {
259 if !wanted.admits(&issue) {
260 continue;
261 }
262 if let Some(object) = issue.as_object_mut() {
263 object.insert("repo".to_string(), serde_json::json!(repo));
264 }
265 issues.push(issue);
266 }
267 let state = self.mirror.state(&scope).map_err(mirror_failure)?;
268 if let Some(error) = &state.last_error {
271 failures.push(RepoFailure {
272 repo: repo.clone(),
273 error: error.clone(),
274 });
275 }
276 standing.insert(repo.clone(), state.standing());
277 }
278 issues.sort_by(|a, b| updated_of(b).cmp(updated_of(a)));
279 Ok(IssueList {
280 issues,
281 failures,
282 sync: standing,
283 })
284 }
285
286 pub fn list(&self, scope: &Scope) -> Outcome<Listed> {
288 Ok(Listed {
289 items: self.mirror.payloads(scope).map_err(mirror_failure)?,
290 sync: self.standing(scope)?,
291 })
292 }
293
294 pub fn single(&self, scope: &Scope) -> Outcome<Single> {
296 Ok(Single {
297 item: self.mirror.only(scope).map_err(mirror_failure)?,
298 sync: self.standing(scope)?,
299 })
300 }
301
302 pub fn overview(&self, repositories: &[Repository]) -> Outcome<BTreeMap<String, RepoFacts>> {
304 let open_issues = self.mirror.open_counts("issues").map_err(mirror_failure)?;
305 let open_pulls = self.mirror.open_counts("pulls").map_err(mirror_failure)?;
306 let mut facts = BTreeMap::new();
307 for repository in repositories {
308 let repo = &repository.name;
309 let metadata = self
310 .mirror
311 .only(&Scope::Repository(repo.clone()))
312 .map_err(mirror_failure)?;
313 let checks = match &repository.head {
314 Some(commit) => self
315 .mirror
316 .only(&Scope::Checks {
317 repo: repo.clone(),
318 commit: commit.clone(),
319 })
320 .map_err(mirror_failure)?,
321 None => None,
322 };
323 facts.insert(
324 repo.clone(),
325 RepoFacts {
326 open_issues: open_issues.get(repo).copied().unwrap_or(0),
327 open_pulls: open_pulls.get(repo).copied().unwrap_or(0),
328 private: metadata.as_ref().and_then(|m| m.get("private")?.as_bool()),
329 archived: metadata.as_ref().and_then(|m| m.get("archived")?.as_bool()),
330 stars: metadata.as_ref().and_then(|m| m.get("stargazers_count")?.as_i64()),
331 checks: checks
332 .as_ref()
333 .and_then(|c| c.get("state")?.as_str().map(str::to_string)),
334 },
335 );
336 }
337 Ok(facts)
338 }
339
340 pub fn gists(&self) -> Outcome<Listed> {
342 self.list(&Scope::Gists)
343 }
344
345 pub async fn gist(&self, client: Option<&GitHubClient>, id: &str) -> Outcome<GistAnswer> {
352 if let Some(held) = self.mirror.document(&Scope::Gists, id).map_err(mirror_failure)? {
353 if held.complete {
354 return Ok(GistAnswer {
355 gist: held.payload,
356 from_mirror: true,
357 });
358 }
359 }
360 let client = client.ok_or_else(|| {
361 Failure::NoToken(format!(
362 "gist {id} is held only as a summary, and there is no GitHub token to read it with \
363 (log in with `gh auth login`)"
364 ))
365 })?;
366 let gist = client.get_gist(id).await.map_err(github_failure)?;
367 let payload = self.record_gist(&gist)?;
368 Ok(GistAnswer {
369 gist: payload,
370 from_mirror: false,
371 })
372 }
373
374 pub async fn comments(
380 &self,
381 client: Option<&GitHubClient>,
382 repo: &str,
383 number: i64,
384 ) -> Outcome<Listed> {
385 let scope = Scope::Comments {
386 repo: repo.to_string(),
387 number,
388 };
389 self.mirror.hold(&scope).map_err(mirror_failure)?;
390 let state = self.mirror.state(&scope).map_err(mirror_failure)?;
391 if !state.ever_synced() {
392 if let Some(client) = client {
393 let report = sync::sync_scopes(&self.mirror, client, &[scope.clone()], true).await;
394 if let Some((_, error)) = report.failed.first() {
395 return Err(Failure::GitHub(anyhow::anyhow!("{error}")));
396 }
397 }
398 }
399 self.list(&scope)
400 }
401
402 fn record_issue(&self, repo: &str, issue: &GitHubIssue) -> Outcome<()> {
405 let payload = serde_json::to_value(issue).map_err(|e| mirror_failure(e.into()))?;
406 self.mirror
407 .put_one(
408 &Scope::Issues(repo.to_string()),
409 Document::of(issue.number.to_string(), payload),
410 )
411 .map_err(mirror_failure)?;
412 Ok(())
413 }
414
415 fn record_gist(&self, gist: &GitHubGist) -> Outcome<serde_json::Value> {
416 let payload = serde_json::to_value(gist).map_err(|e| mirror_failure(e.into()))?;
417 self.mirror
420 .put_one(&Scope::Gists, Document::of(gist.id.clone(), payload.clone()))
421 .map_err(mirror_failure)?;
422 Ok(payload)
423 }
424
425 pub async fn create_issue(
426 &self,
427 client: &GitHubClient,
428 owner: &str,
429 repo: &str,
430 request: &CreateIssueRequest,
431 ) -> Outcome<GitHubIssue> {
432 if request.title.trim().is_empty() {
433 return Err(Failure::Invalid("an issue needs a title".to_string()));
434 }
435 let issue = client
436 .create_issue(owner, repo, request)
437 .await
438 .map_err(github_failure)?;
439 self.record_issue(&format!("{owner}/{repo}"), &issue)?;
442 Ok(issue)
443 }
444
445 pub async fn update_issue(
446 &self,
447 client: &GitHubClient,
448 owner: &str,
449 repo: &str,
450 number: i64,
451 request: &UpdateIssueRequest,
452 ) -> Outcome<GitHubIssue> {
453 let issue = client
454 .update_issue(owner, repo, number, request)
455 .await
456 .map_err(github_failure)?;
457 self.record_issue(&format!("{owner}/{repo}"), &issue)?;
458 Ok(issue)
459 }
460
461 pub async fn add_comment(
462 &self,
463 client: &GitHubClient,
464 owner: &str,
465 repo: &str,
466 number: i64,
467 body: &str,
468 ) -> Outcome<GitHubComment> {
469 if body.trim().is_empty() {
470 return Err(Failure::Invalid("a comment needs a body".to_string()));
471 }
472 let comment = client
473 .add_comment(owner, repo, number, body)
474 .await
475 .map_err(github_failure)?;
476 let scope = Scope::Comments {
477 repo: format!("{owner}/{repo}"),
478 number,
479 };
480 let payload = serde_json::to_value(&comment).map_err(|e| mirror_failure(e.into()))?;
481 self.mirror.hold(&scope).map_err(mirror_failure)?;
482 self.mirror
483 .put_one(&scope, Document::of(comment.id.to_string(), payload))
484 .map_err(mirror_failure)?;
485 Ok(comment)
486 }
487
488 pub async fn create_gist(
489 &self,
490 client: &GitHubClient,
491 request: &CreateGistRequest,
492 ) -> Outcome<serde_json::Value> {
493 if request.files.is_empty() {
494 return Err(Failure::Invalid("a gist needs at least one file".to_string()));
495 }
496 let gist = client.create_gist(request).await.map_err(github_failure)?;
497 self.record_gist(&gist)
498 }
499
500 pub async fn update_gist(
501 &self,
502 client: &GitHubClient,
503 id: &str,
504 request: &UpdateGistRequest,
505 ) -> Outcome<serde_json::Value> {
506 let gist = client.update_gist(id, request).await.map_err(github_failure)?;
507 self.record_gist(&gist)
508 }
509
510 pub fn boards(&self, references: &[BoardRef]) -> Outcome<BoardList> {
514 let wanted = references.iter().cloned().collect();
515 let (boards, problems) =
516 sync::resolve_boards(&self.mirror, &wanted).map_err(mirror_failure)?;
517 let mut ordered = Vec::new();
519 for reference in references {
520 if let Some(board) = boards
521 .iter()
522 .find(|board| board.owner == reference.owner && board.number == reference.number)
523 {
524 ordered.push(board.clone());
525 }
526 }
527 let mut standing = BTreeMap::new();
528 for reference in references {
529 let scope = Scope::Projects(reference.owner.clone());
530 standing.insert(scope.key(), self.standing(&scope)?);
531 }
532 Ok(BoardList {
533 boards: ordered,
534 problems,
535 sync: standing,
536 })
537 }
538
539 pub fn all_boards(&self, references: &[BoardRef]) -> Outcome<AllBoards> {
548 let account = self.standing(&Scope::Viewer)?;
549 let viewer = sync::viewer(&self.mirror).map_err(mirror_failure)?;
550 let mut problems = Vec::new();
551
552 let mut owners: Vec<(String, Relation)> = Vec::new();
553 match viewer {
554 Some(viewer) => {
555 owners.push((viewer.login, Relation::Account));
556 let mut organizations = viewer.organizations;
557 organizations.sort_by_key(|login| login.to_lowercase());
558 owners.extend(organizations.into_iter().map(|login| (login, Relation::Organization)));
559 }
560 None => problems.push(match &account.last_error {
561 Some(error) => format!("whose boards these are could not be read: {error}"),
562 None => "who the GitHub token belongs to has not been read yet, so only referenced \
563 boards are listed"
564 .to_string(),
565 }),
566 }
567 for reference in references {
568 if !owners.iter().any(|(login, _)| *login == reference.owner) {
569 owners.push((reference.owner.clone(), Relation::Referenced));
570 }
571 }
572
573 let mut listed = Vec::new();
574 let mut boards = Vec::new();
575 let mut unread = std::collections::BTreeSet::new();
576 for (login, relation) in owners {
577 let scope = Scope::Projects(login.clone());
578 match sync::owner_boards(&self.mirror, &login).map_err(mirror_failure)? {
579 Ok(mut held) => {
580 held.sort_by(|a, b| a.closed.cmp(&b.closed).then(b.updated_at.cmp(&a.updated_at)));
581 boards.extend(held);
582 }
583 Err(reason) => {
584 problems.push(reason);
585 unread.insert(login.clone());
586 }
587 }
588 listed.push(BoardOwner {
589 sync: self.standing(&scope)?,
590 login,
591 relation,
592 });
593 }
594
595 let mut referenced = Vec::new();
596 for reference in references {
597 match boards
598 .iter()
599 .find(|board| board.owner == reference.owner && board.number == reference.number)
600 {
601 Some(board) => referenced.push(board.id.clone()),
602 None if unread.contains(&reference.owner) => {}
604 None => problems.push(format!("{} has no board {}", reference.owner, reference.number)),
605 }
606 }
607
608 let recent = self.by_activity(&boards)?;
609 Ok(AllBoards {
610 owners: listed,
611 boards,
612 referenced,
613 recent,
614 account,
615 problems,
616 })
617 }
618
619 pub fn by_activity(&self, boards: &[GitHubProject]) -> Outcome<Vec<BoardActivity>> {
624 let mut known = Vec::new();
625 let mut unknown = Vec::new();
626 for board in boards {
627 let scope = Scope::ProjectItems(board.id.clone());
628 let activity = BoardActivity {
629 board: board.id.clone(),
630 newest_change: self.mirror.newest_update_in(&scope).map_err(mirror_failure)?,
631 cards: self.standing(&scope)?,
632 };
633 match activity.newest_change {
634 Some(_) => known.push(activity),
635 None => unknown.push((board.updated_at.clone(), activity)),
636 }
637 }
638 known.sort_by(|a, b| b.newest_change.cmp(&a.newest_change).then(a.board.cmp(&b.board)));
639 unknown.sort_by(|(a_time, a), (b_time, b)| b_time.cmp(a_time).then(a.board.cmp(&b.board)));
640 known.extend(unknown.into_iter().map(|(_, activity)| activity));
641 Ok(known)
642 }
643
644 pub fn cards(&self, board: &str) -> Outcome<Cards> {
651 let scope = Scope::ProjectItems(board.to_string());
652 let items = self
653 .mirror
654 .payloads(&scope)
655 .map_err(mirror_failure)?
656 .into_iter()
657 .map(|payload| {
658 serde_json::from_value::<GitHubProjectItem>(payload).map_err(|e| {
659 Failure::Mirror(anyhow::anyhow!(
660 "a mirrored card of {board} cannot be read by this version ({e}); \
661 sync the board to read it again"
662 ))
663 })
664 })
665 .collect::<Outcome<Vec<_>>>()?;
666 Ok(Cards {
667 items,
668 sync: self.standing(&scope)?,
669 })
670 }
671
672 pub async fn refresh_boards(&self, client: &GitHubClient, references: &[BoardRef]) -> SyncReport {
675 let references = references.iter().cloned().collect();
676 sync::sync_boards(&self.mirror, client, &references, true).await
677 }
678
679 pub async fn refresh_board(
688 &self,
689 client: &GitHubClient,
690 board: &GitHubProject,
691 force: bool,
692 ) -> SyncReport {
693 sync::sync_scopes(&self.mirror, client, &[Scope::ProjectItems(board.id.clone())], force).await
694 }
695
696 pub async fn unlisted_card(
703 &self,
704 client: &GitHubClient,
705 board: &str,
706 item: &str,
707 ) -> Outcome<Option<GitHubProjectItem>> {
708 let found = client
709 .project_items_on(board, &[item.to_string()])
710 .await
711 .map_err(github_failure)?;
712 match found.into_iter().next() {
713 Some(card) => {
714 self.record_card(board, &card)?;
715 Ok(Some(card))
716 }
717 None => Ok(None),
718 }
719 }
720
721 fn record_card(&self, board: &str, item: &GitHubProjectItem) -> Outcome<()> {
722 let payload = serde_json::to_value(item).map_err(|e| mirror_failure(e.into()))?;
723 self.mirror
724 .put_one(
725 &Scope::ProjectItems(board.to_string()),
726 Document::of(item.id.clone(), payload),
727 )
728 .map_err(mirror_failure)?;
729 Ok(())
730 }
731
732 pub async fn set_field(
738 &self,
739 client: &GitHubClient,
740 board: &str,
741 item: &str,
742 field: &str,
743 value: Option<&ProjectFieldWrite>,
744 ) -> Outcome<GitHubProjectItem> {
745 let updated = match value {
746 Some(value) => client.set_project_field(board, item, field, value).await,
747 None => client.clear_project_field(board, item, field).await,
748 }
749 .map_err(github_failure)?;
750 self.record_card(board, &updated)?;
751 Ok(updated)
752 }
753
754 pub async fn move_card(
756 &self,
757 client: &GitHubClient,
758 board: &str,
759 item: &str,
760 after: Option<&str>,
761 ) -> Outcome<GitHubProjectItem> {
762 if after == Some(item) {
763 return Err(Failure::Invalid("a card cannot be moved behind itself".to_string()));
764 }
765 let updated = client
766 .move_project_item_after(board, item, after)
767 .await
768 .map_err(github_failure)?;
769 self.record_card(board, &updated)?;
770 Ok(updated)
771 }
772
773 pub async fn set_archived(
776 &self,
777 client: &GitHubClient,
778 board: &str,
779 item: &str,
780 archived: bool,
781 ) -> Outcome<GitHubProjectItem> {
782 let updated = client
783 .archive_project_item(board, item, archived)
784 .await
785 .map_err(github_failure)?;
786 self.record_card(board, &updated)?;
787 Ok(updated)
788 }
789
790 pub async fn edit_draft(
792 &self,
793 client: &GitHubClient,
794 board: &str,
795 item: &str,
796 content: &str,
797 title: &str,
798 body: &str,
799 ) -> Outcome<GitHubProjectItem> {
800 if title.trim().is_empty() {
801 return Err(Failure::Invalid(
802 "a draft card needs a title; an untitled card is one nobody can find".to_string(),
803 ));
804 }
805 let updated = client
806 .update_project_draft(item, content, title, body)
807 .await
808 .map_err(github_failure)?;
809 self.record_card(board, &updated)?;
810 Ok(updated)
811 }
812
813 pub async fn add_draft(
815 &self,
816 client: &GitHubClient,
817 board: &str,
818 title: &str,
819 body: &str,
820 ) -> Outcome<GitHubProjectItem> {
821 if title.trim().is_empty() {
822 return Err(Failure::Invalid("a draft card needs a title".to_string()));
823 }
824 let added = client
825 .add_project_draft(board, title, body)
826 .await
827 .map_err(github_failure)?;
828 self.record_card(board, &added)?;
829 Ok(added)
830 }
831
832 pub async fn add_existing(
834 &self,
835 client: &GitHubClient,
836 board: &str,
837 url: &str,
838 ) -> Outcome<GitHubProjectItem> {
839 let added = client
840 .add_project_item_by_url(board, url)
841 .await
842 .map_err(github_failure)?;
843 self.record_card(board, &added)?;
844 Ok(added)
845 }
846
847 pub async fn remove_card(&self, client: &GitHubClient, board: &str, item: &str) -> Outcome<()> {
850 client
851 .delete_project_item(board, item)
852 .await
853 .map_err(github_failure)?;
854 self.mirror
857 .remove_one(&Scope::ProjectItems(board.to_string()), item)
858 .map_err(mirror_failure)?;
859 Ok(())
860 }
861
862 pub async fn add_column(
864 &self,
865 client: &GitHubClient,
866 board: &str,
867 field: &str,
868 name: &str,
869 color: &str,
870 description: &str,
871 ) -> Outcome<(GitHubProject, Vec<GitHubProjectItem>)> {
872 let _lock = self.column_lock().await?;
873 let updated = client
874 .add_project_column(board, field, name, color, description)
875 .await
876 .map_err(github_failure)?;
877 let items = client.project_items(board).await.map_err(github_failure)?;
878 self.record_board(&updated, &items)?;
879 Ok((updated, items))
880 }
881
882 pub async fn remove_column(
885 &self,
886 client: &GitHubClient,
887 board: &str,
888 field: &str,
889 option: &str,
890 ) -> Outcome<(GitHubProject, Vec<GitHubProjectItem>)> {
891 let _lock = self.column_lock().await?;
892 let updated = client
893 .remove_project_column(board, field, option)
894 .await
895 .map_err(github_failure)?;
896 let items = client.project_items(board).await.map_err(github_failure)?;
897 self.record_board(&updated, &items)?;
898 Ok((updated, items))
899 }
900
901 fn record_board(&self, board: &GitHubProject, items: &[GitHubProjectItem]) -> Outcome<()> {
904 let board_value = serde_json::to_value(board).map_err(|e| mirror_failure(e.into()))?;
905 let documents = items
906 .iter()
907 .map(|item| {
908 serde_json::to_value(item)
909 .map(|value| Document::of(item.id.clone(), value))
910 .map_err(|e| mirror_failure(e.into()))
911 })
912 .collect::<Outcome<Vec<_>>>()?;
913 self.mirror
914 .put_project_write(
915 &Scope::Projects(board.owner.clone()),
916 Document::of(board.id.clone(), board_value),
917 &Scope::ProjectItems(board.id.clone()),
918 &documents,
919 )
920 .map_err(mirror_failure)
921 }
922
923 async fn column_lock(&self) -> Outcome<ColumnLock> {
930 let path = self.mirror.path().with_extension("columns.lock");
931 tokio::task::spawn_blocking(move || ColumnLock::acquire(path))
932 .await
933 .map_err(|e| Failure::Mirror(anyhow::anyhow!("the column lock could not be taken: {e}")))?
934 .map_err(mirror_failure)
935 }
936}
937
938struct ColumnLock {
940 file: File,
941}
942
943impl ColumnLock {
944 fn acquire(path: PathBuf) -> anyhow::Result<Self> {
945 let file = std::fs::OpenOptions::new()
946 .create(true)
947 .truncate(false)
948 .write(true)
949 .open(&path)
950 .with_context(|| format!("cannot open {}", path.display()))?;
951 file.lock()
952 .with_context(|| format!("cannot lock {}", path.display()))?;
953 Ok(Self { file })
954 }
955}
956
957impl Drop for ColumnLock {
958 fn drop(&mut self) {
959 let _ = self.file.unlock();
962 }
963}
964
965fn updated_of(issue: &serde_json::Value) -> &str {
966 issue
967 .get("updated_at")
968 .and_then(|value| value.as_str())
969 .unwrap_or("")
970}
971
972#[cfg(test)]
973#[path = "service_tests.rs"]
974mod tests;