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/// Why an owner's boards are listed.
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
168#[serde(rename_all = "snake_case")]
169pub enum Relation {
170    /// The account the token belongs to.
171    Account,
172    /// An organisation that account is a member of.
173    Organization,
174    /// Neither — it owns a board some project references.
175    Referenced,
176}
177
178/// One owner of boards, and how current its board list is.
179#[derive(Debug, Clone, PartialEq, Serialize)]
180pub struct BoardOwner {
181    pub login: String,
182    pub relation: Relation,
183    pub sync: Standing,
184}
185
186/// Every board this person reaches, as the mirror holds them.
187#[derive(Debug, Clone, PartialEq, Serialize)]
188pub struct AllBoards {
189    /// The account first, then its organisations, then the owners of
190    /// referenced boards that are neither — the order the boards are in.
191    pub owners: Vec<BoardOwner>,
192    /// Owner by owner, open boards before closed, most recently changed first.
193    pub boards: Vec<GitHubProject>,
194    /// The node ids of the boards the asking project references, in the order
195    /// it lists them.
196    pub referenced: Vec<String>,
197    /// How current the knowledge of whose boards these are is.
198    pub account: Standing,
199    pub problems: Vec<String>,
200}
201
202/// One board's cards and how current they are.
203#[derive(Debug, Clone, PartialEq, Serialize)]
204pub struct Cards {
205    pub items: Vec<GitHubProjectItem>,
206    pub sync: Standing,
207}
208
209/// A gist, and whether it came from the mirror or had to be fetched.
210#[derive(Debug, Clone, PartialEq, Serialize)]
211pub struct GistAnswer {
212    pub gist: serde_json::Value,
213    pub from_mirror: bool,
214}
215
216/// The machine's GitHub, read through the mirror and written through GitHub.
217#[derive(Debug, Clone)]
218pub struct Service {
219    mirror: Mirror,
220}
221
222impl Service {
223    pub fn new(mirror: Mirror) -> Self {
224        Self { mirror }
225    }
226
227    pub fn mirror(&self) -> &Mirror {
228        &self.mirror
229    }
230
231    // ------------------------------------------------------------- reading
232
233    fn standing(&self, scope: &Scope) -> Outcome<Standing> {
234        Ok(self.mirror.state(scope).map_err(mirror_failure)?.standing())
235    }
236
237    /// Every mirrored issue of these repositories.
238    pub fn issues(&self, repos: &[String], wanted: IssueState) -> Outcome<IssueList> {
239        let mut issues = Vec::new();
240        let mut failures = Vec::new();
241        let mut standing = BTreeMap::new();
242        for repo in repos {
243            let scope = Scope::Issues(repo.clone());
244            for mut issue in self.mirror.payloads(&scope).map_err(mirror_failure)? {
245                if !wanted.admits(&issue) {
246                    continue;
247                }
248                if let Some(object) = issue.as_object_mut() {
249                    object.insert("repo".to_string(), serde_json::json!(repo));
250                }
251                issues.push(issue);
252            }
253            let state = self.mirror.state(&scope).map_err(mirror_failure)?;
254            // A repository that has never synced is not a failure to report;
255            // it is one not reached yet, and its standing says so.
256            if let Some(error) = &state.last_error {
257                failures.push(RepoFailure {
258                    repo: repo.clone(),
259                    error: error.clone(),
260                });
261            }
262            standing.insert(repo.clone(), state.standing());
263        }
264        issues.sort_by(|a, b| updated_of(b).cmp(updated_of(a)));
265        Ok(IssueList {
266            issues,
267            failures,
268            sync: standing,
269        })
270    }
271
272    /// A list scope as it stands.
273    pub fn list(&self, scope: &Scope) -> Outcome<Listed> {
274        Ok(Listed {
275            items: self.mirror.payloads(scope).map_err(mirror_failure)?,
276            sync: self.standing(scope)?,
277        })
278    }
279
280    /// A single-document scope as it stands.
281    pub fn single(&self, scope: &Scope) -> Outcome<Single> {
282        Ok(Single {
283            item: self.mirror.only(scope).map_err(mirror_failure)?,
284            sync: self.standing(scope)?,
285        })
286    }
287
288    /// What the mirror knows about each repository of a checkout, in one read.
289    pub fn overview(&self, repositories: &[Repository]) -> Outcome<BTreeMap<String, RepoFacts>> {
290        let open_issues = self.mirror.open_counts("issues").map_err(mirror_failure)?;
291        let open_pulls = self.mirror.open_counts("pulls").map_err(mirror_failure)?;
292        let mut facts = BTreeMap::new();
293        for repository in repositories {
294            let repo = &repository.name;
295            let metadata = self
296                .mirror
297                .only(&Scope::Repository(repo.clone()))
298                .map_err(mirror_failure)?;
299            let checks = match &repository.head {
300                Some(commit) => self
301                    .mirror
302                    .only(&Scope::Checks {
303                        repo: repo.clone(),
304                        commit: commit.clone(),
305                    })
306                    .map_err(mirror_failure)?,
307                None => None,
308            };
309            facts.insert(
310                repo.clone(),
311                RepoFacts {
312                    open_issues: open_issues.get(repo).copied().unwrap_or(0),
313                    open_pulls: open_pulls.get(repo).copied().unwrap_or(0),
314                    private: metadata.as_ref().and_then(|m| m.get("private")?.as_bool()),
315                    archived: metadata.as_ref().and_then(|m| m.get("archived")?.as_bool()),
316                    stars: metadata.as_ref().and_then(|m| m.get("stargazers_count")?.as_i64()),
317                    checks: checks
318                        .as_ref()
319                        .and_then(|c| c.get("state")?.as_str().map(str::to_string)),
320                },
321            );
322        }
323        Ok(facts)
324    }
325
326    /// The person's gists. Summaries until one is opened.
327    pub fn gists(&self) -> Outcome<Listed> {
328        self.list(&Scope::Gists)
329    }
330
331    /// One gist with its file contents.
332    ///
333    /// The mirror answers when it holds the whole gist. Often it does not —
334    /// the list endpoint omits file contents — and opening one is the moment
335    /// where reaching GitHub is right: a person is waiting for exactly this,
336    /// and what comes back is stored, so the next open is instant.
337    pub async fn gist(&self, client: Option<&GitHubClient>, id: &str) -> Outcome<GistAnswer> {
338        if let Some(held) = self.mirror.document(&Scope::Gists, id).map_err(mirror_failure)? {
339            if held.complete {
340                return Ok(GistAnswer {
341                    gist: held.payload,
342                    from_mirror: true,
343                });
344            }
345        }
346        let client = client.ok_or_else(|| {
347            Failure::NoToken(format!(
348                "gist {id} is held only as a summary, and there is no GitHub token to read it with \
349                 (log in with `gh auth login`)"
350            ))
351        })?;
352        let gist = client.get_gist(id).await.map_err(github_failure)?;
353        let payload = self.record_gist(&gist)?;
354        Ok(GistAnswer {
355            gist: payload,
356            from_mirror: false,
357        })
358    }
359
360    /// Comments on an issue or pull request.
361    ///
362    /// Opening a thread is what puts it in the sweep: a thread nobody opened
363    /// is not worth a request per sweep. The first open reads it now, when a
364    /// token is there to read with; after that the mirror answers.
365    pub async fn comments(
366        &self,
367        client: Option<&GitHubClient>,
368        repo: &str,
369        number: i64,
370    ) -> Outcome<Listed> {
371        let scope = Scope::Comments {
372            repo: repo.to_string(),
373            number,
374        };
375        self.mirror.hold(&scope).map_err(mirror_failure)?;
376        let state = self.mirror.state(&scope).map_err(mirror_failure)?;
377        if !state.ever_synced() {
378            if let Some(client) = client {
379                let report = sync::sync_scopes(&self.mirror, client, &[scope.clone()], true).await;
380                if let Some((_, error)) = report.failed.first() {
381                    return Err(Failure::GitHub(anyhow::anyhow!("{error}")));
382                }
383            }
384        }
385        self.list(&scope)
386    }
387
388    // ------------------------------------------------------------- writing
389
390    fn record_issue(&self, repo: &str, issue: &GitHubIssue) -> Outcome<()> {
391        let payload = serde_json::to_value(issue).map_err(|e| mirror_failure(e.into()))?;
392        self.mirror
393            .put_one(
394                &Scope::Issues(repo.to_string()),
395                Document::of(issue.number.to_string(), payload),
396            )
397            .map_err(mirror_failure)?;
398        Ok(())
399    }
400
401    fn record_gist(&self, gist: &GitHubGist) -> Outcome<serde_json::Value> {
402        let payload = serde_json::to_value(gist).map_err(|e| mirror_failure(e.into()))?;
403        // Whole: this came from the single-gist endpoint or from an edit, so it
404        // carries the file contents an editor needs.
405        self.mirror
406            .put_one(&Scope::Gists, Document::of(gist.id.clone(), payload.clone()))
407            .map_err(mirror_failure)?;
408        Ok(payload)
409    }
410
411    pub async fn create_issue(
412        &self,
413        client: &GitHubClient,
414        owner: &str,
415        repo: &str,
416        request: &CreateIssueRequest,
417    ) -> Outcome<GitHubIssue> {
418        if request.title.trim().is_empty() {
419            return Err(Failure::Invalid("an issue needs a title".to_string()));
420        }
421        let issue = client
422            .create_issue(owner, repo, request)
423            .await
424            .map_err(github_failure)?;
425        // Mirrored now, not at the next sweep: a person who just created an
426        // issue must see it.
427        self.record_issue(&format!("{owner}/{repo}"), &issue)?;
428        Ok(issue)
429    }
430
431    pub async fn update_issue(
432        &self,
433        client: &GitHubClient,
434        owner: &str,
435        repo: &str,
436        number: i64,
437        request: &UpdateIssueRequest,
438    ) -> Outcome<GitHubIssue> {
439        let issue = client
440            .update_issue(owner, repo, number, request)
441            .await
442            .map_err(github_failure)?;
443        self.record_issue(&format!("{owner}/{repo}"), &issue)?;
444        Ok(issue)
445    }
446
447    pub async fn add_comment(
448        &self,
449        client: &GitHubClient,
450        owner: &str,
451        repo: &str,
452        number: i64,
453        body: &str,
454    ) -> Outcome<GitHubComment> {
455        if body.trim().is_empty() {
456            return Err(Failure::Invalid("a comment needs a body".to_string()));
457        }
458        let comment = client
459            .add_comment(owner, repo, number, body)
460            .await
461            .map_err(github_failure)?;
462        let scope = Scope::Comments {
463            repo: format!("{owner}/{repo}"),
464            number,
465        };
466        let payload = serde_json::to_value(&comment).map_err(|e| mirror_failure(e.into()))?;
467        self.mirror.hold(&scope).map_err(mirror_failure)?;
468        self.mirror
469            .put_one(&scope, Document::of(comment.id.to_string(), payload))
470            .map_err(mirror_failure)?;
471        Ok(comment)
472    }
473
474    pub async fn create_gist(
475        &self,
476        client: &GitHubClient,
477        request: &CreateGistRequest,
478    ) -> Outcome<serde_json::Value> {
479        if request.files.is_empty() {
480            return Err(Failure::Invalid("a gist needs at least one file".to_string()));
481        }
482        let gist = client.create_gist(request).await.map_err(github_failure)?;
483        self.record_gist(&gist)
484    }
485
486    pub async fn update_gist(
487        &self,
488        client: &GitHubClient,
489        id: &str,
490        request: &UpdateGistRequest,
491    ) -> Outcome<serde_json::Value> {
492        let gist = client.update_gist(id, request).await.map_err(github_failure)?;
493        self.record_gist(&gist)
494    }
495
496    // -------------------------------------------------------------- boards
497
498    /// The boards a project references, as the mirror knows them.
499    pub fn boards(&self, references: &[BoardRef]) -> Outcome<BoardList> {
500        let wanted = references.iter().cloned().collect();
501        let (boards, problems) =
502            sync::resolve_boards(&self.mirror, &wanted).map_err(mirror_failure)?;
503        // In the order the project lists them, not the order they resolved.
504        let mut ordered = Vec::new();
505        for reference in references {
506            if let Some(board) = boards
507                .iter()
508                .find(|board| board.owner == reference.owner && board.number == reference.number)
509            {
510                ordered.push(board.clone());
511            }
512        }
513        let mut standing = BTreeMap::new();
514        for reference in references {
515            let scope = Scope::Projects(reference.owner.clone());
516            standing.insert(scope.key(), self.standing(&scope)?);
517        }
518        Ok(BoardList {
519            boards: ordered,
520            problems,
521            sync: standing,
522        })
523    }
524
525    /// Every board of the token's account, of each of its organisations, and
526    /// of the owner of every board `references` names — with which of them
527    /// those references are.
528    ///
529    /// Boards belong to owners, not to projects, so this is the same list from
530    /// whichever project it is asked; the references only mark the boards that
531    /// project works with. An owner whose list has never been read is reported
532    /// beside the boards that could be listed, never dropped silently.
533    pub fn all_boards(&self, references: &[BoardRef]) -> Outcome<AllBoards> {
534        let account = self.standing(&Scope::Viewer)?;
535        let viewer = sync::viewer(&self.mirror).map_err(mirror_failure)?;
536        let mut problems = Vec::new();
537
538        let mut owners: Vec<(String, Relation)> = Vec::new();
539        match viewer {
540            Some(viewer) => {
541                owners.push((viewer.login, Relation::Account));
542                let mut organizations = viewer.organizations;
543                organizations.sort_by_key(|login| login.to_lowercase());
544                owners.extend(organizations.into_iter().map(|login| (login, Relation::Organization)));
545            }
546            None => problems.push(match &account.last_error {
547                Some(error) => format!("whose boards these are could not be read: {error}"),
548                None => "who the GitHub token belongs to has not been read yet, so only referenced \
549                         boards are listed"
550                    .to_string(),
551            }),
552        }
553        for reference in references {
554            if !owners.iter().any(|(login, _)| *login == reference.owner) {
555                owners.push((reference.owner.clone(), Relation::Referenced));
556            }
557        }
558
559        let mut listed = Vec::new();
560        let mut boards = Vec::new();
561        let mut unread = std::collections::BTreeSet::new();
562        for (login, relation) in owners {
563            let scope = Scope::Projects(login.clone());
564            match sync::owner_boards(&self.mirror, &login).map_err(mirror_failure)? {
565                Ok(mut held) => {
566                    held.sort_by(|a, b| a.closed.cmp(&b.closed).then(b.updated_at.cmp(&a.updated_at)));
567                    boards.extend(held);
568                }
569                Err(reason) => {
570                    problems.push(reason);
571                    unread.insert(login.clone());
572                }
573            }
574            listed.push(BoardOwner {
575                sync: self.standing(&scope)?,
576                login,
577                relation,
578            });
579        }
580
581        let mut referenced = Vec::new();
582        for reference in references {
583            match boards
584                .iter()
585                .find(|board| board.owner == reference.owner && board.number == reference.number)
586            {
587                Some(board) => referenced.push(board.id.clone()),
588                // An owner whose list was never read is already reported.
589                None if unread.contains(&reference.owner) => {}
590                None => problems.push(format!("{} has no board {}", reference.owner, reference.number)),
591            }
592        }
593
594        Ok(AllBoards {
595            owners: listed,
596            boards,
597            referenced,
598            account,
599            problems,
600        })
601    }
602
603    /// One board's cards, in TODAY'S shape.
604    ///
605    /// Every card is parsed into the type this version defines: a row an
606    /// older version wrote takes the defaults of the fields it did not have,
607    /// and a payload that does not parse AT ALL is raised — half a board is
608    /// the silent wrong answer.
609    pub fn cards(&self, board: &str) -> Outcome<Cards> {
610        let scope = Scope::ProjectItems(board.to_string());
611        let items = self
612            .mirror
613            .payloads(&scope)
614            .map_err(mirror_failure)?
615            .into_iter()
616            .map(|payload| {
617                serde_json::from_value::<GitHubProjectItem>(payload).map_err(|e| {
618                    Failure::Mirror(anyhow::anyhow!(
619                        "a mirrored card of {board} cannot be read by this version ({e}); \
620                         sync the board to read it again"
621                    ))
622                })
623            })
624            .collect::<Outcome<Vec<_>>>()?;
625        Ok(Cards {
626            items,
627            sync: self.standing(&scope)?,
628        })
629    }
630
631    /// Bring every board current now — who the token is, each owner's list,
632    /// the cards being kept — without the rest of the machine's mirror.
633    pub async fn refresh_boards(&self, client: &GitHubClient, references: &[BoardRef]) -> SyncReport {
634        let references = references.iter().cloned().collect();
635        sync::sync_boards(&self.mirror, client, &references, true).await
636    }
637
638    /// Bring one board's cards current before it is shown, if due — or whether
639    /// due or not, with `force` — and nothing else.
640    ///
641    /// The board is one the mirror already lists, so its owner's list has
642    /// nothing to add. Reading more inside the request that opens a board made
643    /// that request slow enough for its caller to give up, and a cancelled
644    /// read left the board "not read yet" until the next scheduled pass
645    /// (20 Oct 2026). A board's cards need nothing but the board.
646    pub async fn refresh_board(
647        &self,
648        client: &GitHubClient,
649        board: &GitHubProject,
650        force: bool,
651    ) -> SyncReport {
652        sync::sync_scopes(&self.mirror, client, &[Scope::ProjectItems(board.id.clone())], force).await
653    }
654
655    fn record_card(&self, board: &str, item: &GitHubProjectItem) -> Outcome<()> {
656        let payload = serde_json::to_value(item).map_err(|e| mirror_failure(e.into()))?;
657        self.mirror
658            .put_one(
659                &Scope::ProjectItems(board.to_string()),
660                Document::of(item.id.clone(), payload),
661            )
662            .map_err(mirror_failure)?;
663        Ok(())
664    }
665
666    /// Set one field of one card, or take its value off with `None`.
667    ///
668    /// Clearing is its own act rather than an empty value: an empty text IS a
669    /// value, and a board that could not tell "no due date" from "a due date
670    /// of nothing" shows a column of blanks where it means to show none.
671    pub async fn set_field(
672        &self,
673        client: &GitHubClient,
674        board: &str,
675        item: &str,
676        field: &str,
677        value: Option<&ProjectFieldWrite>,
678    ) -> Outcome<GitHubProjectItem> {
679        let updated = match value {
680            Some(value) => client.set_project_field(board, item, field, value).await,
681            None => client.clear_project_field(board, item, field).await,
682        }
683        .map_err(github_failure)?;
684        self.record_card(board, &updated)?;
685        Ok(updated)
686    }
687
688    /// Order a card: behind `after`, or first with `None`.
689    pub async fn move_card(
690        &self,
691        client: &GitHubClient,
692        board: &str,
693        item: &str,
694        after: Option<&str>,
695    ) -> Outcome<GitHubProjectItem> {
696        if after == Some(item) {
697            return Err(Failure::Invalid("a card cannot be moved behind itself".to_string()));
698        }
699        let updated = client
700            .move_project_item_after(board, item, after)
701            .await
702            .map_err(github_failure)?;
703        self.record_card(board, &updated)?;
704        Ok(updated)
705    }
706
707    /// Archive a card, or bring it back. Stated rather than toggled: a toggle
708    /// acts on what the caller last saw.
709    pub async fn set_archived(
710        &self,
711        client: &GitHubClient,
712        board: &str,
713        item: &str,
714        archived: bool,
715    ) -> Outcome<GitHubProjectItem> {
716        let updated = client
717            .archive_project_item(board, item, archived)
718            .await
719            .map_err(github_failure)?;
720        self.record_card(board, &updated)?;
721        Ok(updated)
722    }
723
724    /// Rewrite a draft card's title and body, whole.
725    pub async fn edit_draft(
726        &self,
727        client: &GitHubClient,
728        board: &str,
729        item: &str,
730        content: &str,
731        title: &str,
732        body: &str,
733    ) -> Outcome<GitHubProjectItem> {
734        if title.trim().is_empty() {
735            return Err(Failure::Invalid(
736                "a draft card needs a title; an untitled card is one nobody can find".to_string(),
737            ));
738        }
739        let updated = client
740            .update_project_draft(item, content, title, body)
741            .await
742            .map_err(github_failure)?;
743        self.record_card(board, &updated)?;
744        Ok(updated)
745    }
746
747    /// A card that lives only on the board.
748    pub async fn add_draft(
749        &self,
750        client: &GitHubClient,
751        board: &str,
752        title: &str,
753        body: &str,
754    ) -> Outcome<GitHubProjectItem> {
755        if title.trim().is_empty() {
756            return Err(Failure::Invalid("a draft card needs a title".to_string()));
757        }
758        let added = client
759            .add_project_draft(board, title, body)
760            .await
761            .map_err(github_failure)?;
762        self.record_card(board, &added)?;
763        Ok(added)
764    }
765
766    /// A card for an issue or pull request that already exists, by its URL.
767    pub async fn add_existing(
768        &self,
769        client: &GitHubClient,
770        board: &str,
771        url: &str,
772    ) -> Outcome<GitHubProjectItem> {
773        let added = client
774            .add_project_item_by_url(board, url)
775            .await
776            .map_err(github_failure)?;
777        self.record_card(board, &added)?;
778        Ok(added)
779    }
780
781    /// Take a card off the board. The issue itself is untouched — a board
782    /// owns cards, not issues — and a draft has nowhere else to be.
783    pub async fn remove_card(&self, client: &GitHubClient, board: &str, item: &str) -> Outcome<()> {
784        client
785            .delete_project_item(board, item)
786            .await
787            .map_err(github_failure)?;
788        // Gone from the mirror now rather than at the next sync: a card that
789        // stayed until then is one somebody removes twice.
790        self.mirror
791            .remove_one(&Scope::ProjectItems(board.to_string()), item)
792            .map_err(mirror_failure)?;
793        Ok(())
794    }
795
796    /// Add a column to a board's single-select field.
797    pub async fn add_column(
798        &self,
799        client: &GitHubClient,
800        board: &str,
801        field: &str,
802        name: &str,
803        color: &str,
804        description: &str,
805    ) -> Outcome<(GitHubProject, Vec<GitHubProjectItem>)> {
806        let _lock = self.column_lock().await?;
807        let updated = client
808            .add_project_column(board, field, name, color, description)
809            .await
810            .map_err(github_failure)?;
811        let items = client.project_items(board).await.map_err(github_failure)?;
812        self.record_board(&updated, &items)?;
813        Ok((updated, items))
814    }
815
816    /// Remove a column. GitHub clears that value from affected cards; every
817    /// card is read again before the mirror changes, so they land in no column.
818    pub async fn remove_column(
819        &self,
820        client: &GitHubClient,
821        board: &str,
822        field: &str,
823        option: &str,
824    ) -> Outcome<(GitHubProject, Vec<GitHubProjectItem>)> {
825        let _lock = self.column_lock().await?;
826        let updated = client
827            .remove_project_column(board, field, option)
828            .await
829            .map_err(github_failure)?;
830        let items = client.project_items(board).await.map_err(github_failure)?;
831        self.record_board(&updated, &items)?;
832        Ok((updated, items))
833    }
834
835    /// Store a board and the complete card set GitHub returned after it
836    /// changed, as one act.
837    fn record_board(&self, board: &GitHubProject, items: &[GitHubProjectItem]) -> Outcome<()> {
838        let board_value = serde_json::to_value(board).map_err(|e| mirror_failure(e.into()))?;
839        let documents = items
840            .iter()
841            .map(|item| {
842                serde_json::to_value(item)
843                    .map(|value| Document::of(item.id.clone(), value))
844                    .map_err(|e| mirror_failure(e.into()))
845            })
846            .collect::<Outcome<Vec<_>>>()?;
847        self.mirror
848            .put_project_write(
849                &Scope::Projects(board.owner.clone()),
850                Document::of(board.id.clone(), board_value),
851                &Scope::ProjectItems(board.id.clone()),
852                &documents,
853            )
854            .map_err(mirror_failure)
855    }
856
857    /// Serialise column writes across every process on the machine.
858    ///
859    /// GitHub replaces a single-select field's WHOLE option list. Two writers
860    /// that both read the same list and each wrote it back with one change
861    /// would have the later erase the earlier — and the writers are separate
862    /// processes, a command line and a daemon, so the lock is a file.
863    async fn column_lock(&self) -> Outcome<ColumnLock> {
864        let path = self.mirror.path().with_extension("columns.lock");
865        tokio::task::spawn_blocking(move || ColumnLock::acquire(path))
866            .await
867            .map_err(|e| Failure::Mirror(anyhow::anyhow!("the column lock could not be taken: {e}")))?
868            .map_err(mirror_failure)
869    }
870}
871
872/// An exclusive lock on a file, held while this value lives.
873struct ColumnLock {
874    file: File,
875}
876
877impl ColumnLock {
878    fn acquire(path: PathBuf) -> anyhow::Result<Self> {
879        let file = std::fs::OpenOptions::new()
880            .create(true)
881            .truncate(false)
882            .write(true)
883            .open(&path)
884            .with_context(|| format!("cannot open {}", path.display()))?;
885        file.lock()
886            .with_context(|| format!("cannot lock {}", path.display()))?;
887        Ok(Self { file })
888    }
889}
890
891impl Drop for ColumnLock {
892    fn drop(&mut self) {
893        // Closing the file releases the lock as well; unlocking first says so
894        // in the code rather than leaving it to the descriptor's end.
895        let _ = self.file.unlock();
896    }
897}
898
899fn updated_of(issue: &serde_json::Value) -> &str {
900    issue
901        .get("updated_at")
902        .and_then(|value| value.as_str())
903        .unwrap_or("")
904}
905
906#[cfg(test)]
907#[path = "service_tests.rs"]
908mod tests;