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 works from, in the
195    /// order it lists them.
196    pub referenced: Vec<String>,
197    /// Every board, the most recently changed first: what wsb opens on.
198    pub recent: Vec<BoardActivity>,
199    /// How current the knowledge of whose boards these are is.
200    pub account: Standing,
201    pub problems: Vec<String>,
202}
203
204/// When anything on a board last changed.
205#[derive(Debug, Clone, PartialEq, Serialize)]
206pub struct BoardActivity {
207    /// The board's node id.
208    pub board: String,
209    /// The newest change to any of its cards, as GitHub reports it; `None`
210    /// while its cards have not been read, or when it has none.
211    pub newest_change: Option<String>,
212    /// How current its cards are.
213    pub cards: Standing,
214}
215
216/// One board's cards and how current they are.
217#[derive(Debug, Clone, PartialEq, Serialize)]
218pub struct Cards {
219    pub items: Vec<GitHubProjectItem>,
220    pub sync: Standing,
221}
222
223/// A gist, and whether it came from the mirror or had to be fetched.
224#[derive(Debug, Clone, PartialEq, Serialize)]
225pub struct GistAnswer {
226    pub gist: serde_json::Value,
227    pub from_mirror: bool,
228}
229
230/// The machine's GitHub, read through the mirror and written through GitHub.
231#[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    // ------------------------------------------------------------- reading
246
247    fn standing(&self, scope: &Scope) -> Outcome<Standing> {
248        Ok(self.mirror.state(scope).map_err(mirror_failure)?.standing())
249    }
250
251    /// Every mirrored issue of these repositories.
252    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            // A repository that has never synced is not a failure to report;
269            // it is one not reached yet, and its standing says so.
270            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    /// A list scope as it stands.
287    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    /// A single-document scope as it stands.
295    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    /// What the mirror knows about each repository of a checkout, in one read.
303    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    /// The person's gists. Summaries until one is opened.
341    pub fn gists(&self) -> Outcome<Listed> {
342        self.list(&Scope::Gists)
343    }
344
345    /// One gist with its file contents.
346    ///
347    /// The mirror answers when it holds the whole gist. Often it does not —
348    /// the list endpoint omits file contents — and opening one is the moment
349    /// where reaching GitHub is right: a person is waiting for exactly this,
350    /// and what comes back is stored, so the next open is instant.
351    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    /// Comments on an issue or pull request.
375    ///
376    /// Opening a thread is what puts it in the sweep: a thread nobody opened
377    /// is not worth a request per sweep. The first open reads it now, when a
378    /// token is there to read with; after that the mirror answers.
379    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    // ------------------------------------------------------------- writing
403
404    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        // Whole: this came from the single-gist endpoint or from an edit, so it
418        // carries the file contents an editor needs.
419        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        // Mirrored now, not at the next sweep: a person who just created an
440        // issue must see it.
441        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    // -------------------------------------------------------------- boards
511
512    /// The boards a project references, as the mirror knows them.
513    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        // In the order the project lists them, not the order they resolved.
518        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    /// Every board of the token's account, of each of its organisations, and
540    /// of the owner of every board `references` names — with which of them
541    /// those references are.
542    ///
543    /// Boards belong to owners, not to projects, so this is the same list from
544    /// whichever project it is asked; the references only mark the boards that
545    /// project works with. An owner whose list has never been read is reported
546    /// beside the boards that could be listed, never dropped silently.
547    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                // An owner whose list was never read is already reported.
603                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    /// `boards`, the most recently changed first: by the newest change to any
620    /// of their cards. A board whose cards have not been read, or that has
621    /// none, has no such change; those follow the rest, the most recently
622    /// changed board first by GitHub's own time for it.
623    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    /// One board's cards, in TODAY'S shape.
645    ///
646    /// Every card is parsed into the type this version defines: a row an
647    /// older version wrote takes the defaults of the fields it did not have,
648    /// and a payload that does not parse AT ALL is raised — half a board is
649    /// the silent wrong answer.
650    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    /// Bring every board current now — who the token is, each owner's list,
673    /// the cards being kept — without the rest of the machine's mirror.
674    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    /// Bring one board's cards current before it is shown, if due — or whether
680    /// due or not, with `force` — and nothing else.
681    ///
682    /// The board is one the mirror already lists, so its owner's list has
683    /// nothing to add. Reading more inside the request that opens a board made
684    /// that request slow enough for its caller to give up, and a cancelled
685    /// read left the board "not read yet" until the next scheduled pass
686    /// (20 Oct 2026). A board's cards need nothing but the board.
687    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    /// A card of `board` the mirror does not hold, asked of GitHub by its id
697    /// and mirrored; `None` when GitHub has no such card on that board.
698    ///
699    /// For a card GitHub's listing of the board has not caught up with:
700    /// added on GitHub by somebody else, a moment ago, and named by the id a
701    /// person has in hand.
702    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    /// Set one field of one card, or take its value off with `None`.
733    ///
734    /// Clearing is its own act rather than an empty value: an empty text IS a
735    /// value, and a board that could not tell "no due date" from "a due date
736    /// of nothing" shows a column of blanks where it means to show none.
737    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    /// Order a card: behind `after`, or first with `None`.
755    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    /// Archive a card, or bring it back. Stated rather than toggled: a toggle
774    /// acts on what the caller last saw.
775    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    /// Rewrite a draft card's title and body, whole.
791    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    /// A card that lives only on the board.
814    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    /// A card for an issue or pull request that already exists, by its URL.
833    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    /// Take a card off the board. The issue itself is untouched — a board
848    /// owns cards, not issues — and a draft has nowhere else to be.
849    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        // Gone from the mirror now rather than at the next sync: a card that
855        // stayed until then is one somebody removes twice.
856        self.mirror
857            .remove_one(&Scope::ProjectItems(board.to_string()), item)
858            .map_err(mirror_failure)?;
859        Ok(())
860    }
861
862    /// Add a column to a board's single-select field.
863    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    /// Remove a column. GitHub clears that value from affected cards; every
883    /// card is read again before the mirror changes, so they land in no column.
884    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    /// Store a board and the complete card set GitHub returned after it
902    /// changed, as one act.
903    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    /// Serialise column writes across every process on the machine.
924    ///
925    /// GitHub replaces a single-select field's WHOLE option list. Two writers
926    /// that both read the same list and each wrote it back with one change
927    /// would have the later erase the earlier — and the writers are separate
928    /// processes, a command line and a daemon, so the lock is a file.
929    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
938/// An exclusive lock on a file, held while this value lives.
939struct 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        // Closing the file releases the lock as well; unlocking first says so
960        // in the code rather than leaving it to the descriptor's end.
961        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;