Skip to main content

wsb_github/
service.rs

1//! Everything a person does with GitHub here, once.
2//!
3//! A command line, a daemon serving pages, and any other surface call these
4//! and nothing beneath them. Reads come from the mirror: no token, no network,
5//! no waiting, and every answer carries how old it is. Writes go to GitHub
6//! FIRST and are mirrored from what GitHub hands back — the other order shows
7//! a change and takes it away again on the next sync, and a surface that does
8//! that is one nobody believes.
9
10use 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/// Why an operation did not happen.
27///
28/// Three different things, and every surface says them differently: a request
29/// that was wrong (a draft with no title), GitHub refusing or failing, and the
30/// mirror itself failing.
31#[derive(Debug)]
32pub enum Failure {
33    /// The request itself is wrong; nothing was asked of anybody.
34    Invalid(String),
35    /// Answering needs GitHub, and there is no token to ask with.
36    NoToken(String),
37    /// GitHub refused or could not be reached.
38    GitHub(anyhow::Error),
39    /// The mirror could not be read or written.
40    Mirror(anyhow::Error),
41}
42
43impl Failure {
44    /// Whether this is about the token: there is none, or GitHub refused the
45    /// one there is — it is invalid, or lacks the access the request needs.
46    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/// Which issues a read wants.
81#[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/// A repository whose last refresh failed, and why.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
113pub struct RepoFailure {
114    pub repo: String,
115    pub error: String,
116}
117
118/// Issues across several repositories, as the mirror holds them.
119#[derive(Debug, Clone, PartialEq, Serialize)]
120pub struct IssueList {
121    /// Newest movement first, each carrying `repo`.
122    pub issues: Vec<serde_json::Value>,
123    pub failures: Vec<RepoFailure>,
124    /// Per repository, how current its copy is.
125    pub sync: BTreeMap<String, Standing>,
126}
127
128/// A list scope's documents and how current they are.
129#[derive(Debug, Clone, PartialEq, Serialize)]
130pub struct Listed {
131    pub items: Vec<serde_json::Value>,
132    pub sync: Standing,
133}
134
135/// A single-document scope's document — `None` when not read yet — and how
136/// current it is.
137#[derive(Debug, Clone, PartialEq, Serialize)]
138pub struct Single {
139    pub item: Option<serde_json::Value>,
140    pub sync: Standing,
141}
142
143/// What the mirror knows about one repository, for a row that draws it.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
145pub struct RepoFacts {
146    pub open_issues: i64,
147    pub open_pulls: i64,
148    /// Absent until the repository has been read: a repository wrongly drawn
149    /// as public is how something private gets pasted somewhere.
150    pub private: Option<bool>,
151    pub archived: Option<bool>,
152    pub stars: Option<i64>,
153    /// CI's verdict on the commit this checkout has the repository at.
154    pub checks: Option<String>,
155}
156
157/// The boards a project references, and why any could not be shown.
158#[derive(Debug, Clone, PartialEq, Serialize)]
159pub struct BoardList {
160    pub boards: Vec<GitHubProject>,
161    pub problems: Vec<String>,
162    /// Per owner board-list scope, how current it is.
163    pub sync: BTreeMap<String, Standing>,
164}
165
166/// One board's cards and how current they are.
167#[derive(Debug, Clone, PartialEq, Serialize)]
168pub struct Cards {
169    pub items: Vec<GitHubProjectItem>,
170    pub sync: Standing,
171}
172
173/// A gist, and whether it came from the mirror or had to be fetched.
174#[derive(Debug, Clone, PartialEq, Serialize)]
175pub struct GistAnswer {
176    pub gist: serde_json::Value,
177    pub from_mirror: bool,
178}
179
180/// The machine's GitHub, read through the mirror and written through GitHub.
181#[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    // ------------------------------------------------------------- reading
196
197    fn standing(&self, scope: &Scope) -> Outcome<Standing> {
198        Ok(self.mirror.state(scope).map_err(mirror_failure)?.standing())
199    }
200
201    /// Every mirrored issue of these repositories.
202    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            // A repository that has never synced is not a failure to report;
219            // it is one not reached yet, and its standing says so.
220            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    /// A list scope as it stands.
237    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    /// A single-document scope as it stands.
245    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    /// What the mirror knows about each repository of a checkout, in one read.
253    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    /// The person's gists. Summaries until one is opened.
291    pub fn gists(&self) -> Outcome<Listed> {
292        self.list(&Scope::Gists)
293    }
294
295    /// One gist with its file contents.
296    ///
297    /// The mirror answers when it holds the whole gist. Often it does not —
298    /// the list endpoint omits file contents — and opening one is the moment
299    /// where reaching GitHub is right: a person is waiting for exactly this,
300    /// and what comes back is stored, so the next open is instant.
301    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    /// Comments on an issue or pull request.
325    ///
326    /// Opening a thread is what puts it in the sweep: a thread nobody opened
327    /// is not worth a request per sweep. The first open reads it now, when a
328    /// token is there to read with; after that the mirror answers.
329    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    // ------------------------------------------------------------- writing
353
354    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        // Whole: this came from the single-gist endpoint or from an edit, so it
368        // carries the file contents an editor needs.
369        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        // Mirrored now, not at the next sweep: a person who just created an
390        // issue must see it.
391        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    // -------------------------------------------------------------- boards
461
462    /// The boards a project references, as the mirror knows them.
463    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        // In the order the project lists them, not the order they resolved.
468        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    /// One board's cards, in TODAY'S shape.
490    ///
491    /// Every card is parsed into the type this version defines: a row an
492    /// older version wrote takes the defaults of the fields it did not have,
493    /// and a payload that does not parse AT ALL is raised — half a board is
494    /// the silent wrong answer.
495    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    /// Bring one board current before it is shown: its owner's board list,
518    /// then its cards, each if due — or whether due or not, with `force`.
519    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    /// Set one field of one card, or take its value off with `None`.
549    ///
550    /// Clearing is its own act rather than an empty value: an empty text IS a
551    /// value, and a board that could not tell "no due date" from "a due date
552    /// of nothing" shows a column of blanks where it means to show none.
553    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    /// Order a card: behind `after`, or first with `None`.
571    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    /// Archive a card, or bring it back. Stated rather than toggled: a toggle
590    /// acts on what the caller last saw.
591    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    /// Rewrite a draft card's title and body, whole.
607    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    /// A card that lives only on the board.
630    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    /// A card for an issue or pull request that already exists, by its URL.
649    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    /// Take a card off the board. The issue itself is untouched — a board
664    /// owns cards, not issues — and a draft has nowhere else to be.
665    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        // Gone from the mirror now rather than at the next sync: a card that
671        // stayed until then is one somebody removes twice.
672        self.mirror
673            .remove_one(&Scope::ProjectItems(board.to_string()), item)
674            .map_err(mirror_failure)?;
675        Ok(())
676    }
677
678    /// Add a column to a board's single-select field.
679    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    /// Remove a column. GitHub clears that value from affected cards; every
699    /// card is read again before the mirror changes, so they land in no column.
700    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    /// Store a board and the complete card set GitHub returned after it
718    /// changed, as one act.
719    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    /// Serialise column writes across every process on the machine.
740    ///
741    /// GitHub replaces a single-select field's WHOLE option list. Two writers
742    /// that both read the same list and each wrote it back with one change
743    /// would have the later erase the earlier — and the writers are separate
744    /// processes, a command line and a daemon, so the lock is a file.
745    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
754/// An exclusive lock on a file, held while this value lives.
755struct 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        // Closing the file releases the lock as well; unlocking first says so
776        // in the code rather than leaving it to the descriptor's end.
777        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;