use std::collections::BTreeMap;
use std::fs::File;
use std::path::PathBuf;
use anyhow::Context;
use serde::Serialize;
use crate::boards::BoardRef;
use crate::checkout::Repository;
use crate::client::{
CreateGistRequest, CreateIssueRequest, GitHubClient, GitHubComment, GitHubGist, GitHubIssue,
GitHubProject, GitHubProjectItem, ProjectFieldWrite, UpdateGistRequest, UpdateIssueRequest,
};
use crate::mirror::{Document, Mirror, Scope, Standing};
use crate::sync::{self, SyncReport};
#[derive(Debug)]
pub enum Failure {
Invalid(String),
NoToken(String),
GitHub(anyhow::Error),
Mirror(anyhow::Error),
}
impl Failure {
pub fn is_authorization(&self) -> bool {
match self {
Self::NoToken(_) => true,
Self::GitHub(error) => {
let message = format!("{error:#}");
message.contains("status 401") || message.contains("status 403")
}
_ => false,
}
}
}
impl std::fmt::Display for Failure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Invalid(message) | Self::NoToken(message) => f.write_str(message),
Self::GitHub(error) => write!(f, "{error:#}"),
Self::Mirror(error) => write!(f, "the GitHub mirror failed: {error:#}"),
}
}
}
impl std::error::Error for Failure {}
pub type Outcome<T> = std::result::Result<T, Failure>;
fn mirror_failure(error: anyhow::Error) -> Failure {
Failure::Mirror(error)
}
fn github_failure(error: anyhow::Error) -> Failure {
Failure::GitHub(error)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IssueState {
Open,
Closed,
All,
}
impl std::str::FromStr for IssueState {
type Err = String;
fn from_str(text: &str) -> Result<Self, String> {
match text {
"open" => Ok(Self::Open),
"closed" => Ok(Self::Closed),
"all" => Ok(Self::All),
other => Err(format!("unsupported issue state '{other}': open, closed or all")),
}
}
}
impl IssueState {
fn admits(self, issue: &serde_json::Value) -> bool {
let state = issue.get("state").and_then(|value| value.as_str()).unwrap_or("");
match self {
Self::Open => state == "open",
Self::Closed => state == "closed",
Self::All => true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RepoFailure {
pub repo: String,
pub error: String,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct IssueList {
pub issues: Vec<serde_json::Value>,
pub failures: Vec<RepoFailure>,
pub sync: BTreeMap<String, Standing>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Listed {
pub items: Vec<serde_json::Value>,
pub sync: Standing,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Single {
pub item: Option<serde_json::Value>,
pub sync: Standing,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RepoFacts {
pub open_issues: i64,
pub open_pulls: i64,
pub private: Option<bool>,
pub archived: Option<bool>,
pub stars: Option<i64>,
pub checks: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct BoardList {
pub boards: Vec<GitHubProject>,
pub problems: Vec<String>,
pub sync: BTreeMap<String, Standing>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Relation {
Account,
Organization,
Referenced,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct BoardOwner {
pub login: String,
pub relation: Relation,
pub sync: Standing,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct AllBoards {
pub owners: Vec<BoardOwner>,
pub boards: Vec<GitHubProject>,
pub referenced: Vec<String>,
pub account: Standing,
pub problems: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Cards {
pub items: Vec<GitHubProjectItem>,
pub sync: Standing,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct GistAnswer {
pub gist: serde_json::Value,
pub from_mirror: bool,
}
#[derive(Debug, Clone)]
pub struct Service {
mirror: Mirror,
}
impl Service {
pub fn new(mirror: Mirror) -> Self {
Self { mirror }
}
pub fn mirror(&self) -> &Mirror {
&self.mirror
}
fn standing(&self, scope: &Scope) -> Outcome<Standing> {
Ok(self.mirror.state(scope).map_err(mirror_failure)?.standing())
}
pub fn issues(&self, repos: &[String], wanted: IssueState) -> Outcome<IssueList> {
let mut issues = Vec::new();
let mut failures = Vec::new();
let mut standing = BTreeMap::new();
for repo in repos {
let scope = Scope::Issues(repo.clone());
for mut issue in self.mirror.payloads(&scope).map_err(mirror_failure)? {
if !wanted.admits(&issue) {
continue;
}
if let Some(object) = issue.as_object_mut() {
object.insert("repo".to_string(), serde_json::json!(repo));
}
issues.push(issue);
}
let state = self.mirror.state(&scope).map_err(mirror_failure)?;
if let Some(error) = &state.last_error {
failures.push(RepoFailure {
repo: repo.clone(),
error: error.clone(),
});
}
standing.insert(repo.clone(), state.standing());
}
issues.sort_by(|a, b| updated_of(b).cmp(updated_of(a)));
Ok(IssueList {
issues,
failures,
sync: standing,
})
}
pub fn list(&self, scope: &Scope) -> Outcome<Listed> {
Ok(Listed {
items: self.mirror.payloads(scope).map_err(mirror_failure)?,
sync: self.standing(scope)?,
})
}
pub fn single(&self, scope: &Scope) -> Outcome<Single> {
Ok(Single {
item: self.mirror.only(scope).map_err(mirror_failure)?,
sync: self.standing(scope)?,
})
}
pub fn overview(&self, repositories: &[Repository]) -> Outcome<BTreeMap<String, RepoFacts>> {
let open_issues = self.mirror.open_counts("issues").map_err(mirror_failure)?;
let open_pulls = self.mirror.open_counts("pulls").map_err(mirror_failure)?;
let mut facts = BTreeMap::new();
for repository in repositories {
let repo = &repository.name;
let metadata = self
.mirror
.only(&Scope::Repository(repo.clone()))
.map_err(mirror_failure)?;
let checks = match &repository.head {
Some(commit) => self
.mirror
.only(&Scope::Checks {
repo: repo.clone(),
commit: commit.clone(),
})
.map_err(mirror_failure)?,
None => None,
};
facts.insert(
repo.clone(),
RepoFacts {
open_issues: open_issues.get(repo).copied().unwrap_or(0),
open_pulls: open_pulls.get(repo).copied().unwrap_or(0),
private: metadata.as_ref().and_then(|m| m.get("private")?.as_bool()),
archived: metadata.as_ref().and_then(|m| m.get("archived")?.as_bool()),
stars: metadata.as_ref().and_then(|m| m.get("stargazers_count")?.as_i64()),
checks: checks
.as_ref()
.and_then(|c| c.get("state")?.as_str().map(str::to_string)),
},
);
}
Ok(facts)
}
pub fn gists(&self) -> Outcome<Listed> {
self.list(&Scope::Gists)
}
pub async fn gist(&self, client: Option<&GitHubClient>, id: &str) -> Outcome<GistAnswer> {
if let Some(held) = self.mirror.document(&Scope::Gists, id).map_err(mirror_failure)? {
if held.complete {
return Ok(GistAnswer {
gist: held.payload,
from_mirror: true,
});
}
}
let client = client.ok_or_else(|| {
Failure::NoToken(format!(
"gist {id} is held only as a summary, and there is no GitHub token to read it with \
(log in with `gh auth login`)"
))
})?;
let gist = client.get_gist(id).await.map_err(github_failure)?;
let payload = self.record_gist(&gist)?;
Ok(GistAnswer {
gist: payload,
from_mirror: false,
})
}
pub async fn comments(
&self,
client: Option<&GitHubClient>,
repo: &str,
number: i64,
) -> Outcome<Listed> {
let scope = Scope::Comments {
repo: repo.to_string(),
number,
};
self.mirror.hold(&scope).map_err(mirror_failure)?;
let state = self.mirror.state(&scope).map_err(mirror_failure)?;
if !state.ever_synced() {
if let Some(client) = client {
let report = sync::sync_scopes(&self.mirror, client, &[scope.clone()], true).await;
if let Some((_, error)) = report.failed.first() {
return Err(Failure::GitHub(anyhow::anyhow!("{error}")));
}
}
}
self.list(&scope)
}
fn record_issue(&self, repo: &str, issue: &GitHubIssue) -> Outcome<()> {
let payload = serde_json::to_value(issue).map_err(|e| mirror_failure(e.into()))?;
self.mirror
.put_one(
&Scope::Issues(repo.to_string()),
Document::of(issue.number.to_string(), payload),
)
.map_err(mirror_failure)?;
Ok(())
}
fn record_gist(&self, gist: &GitHubGist) -> Outcome<serde_json::Value> {
let payload = serde_json::to_value(gist).map_err(|e| mirror_failure(e.into()))?;
self.mirror
.put_one(&Scope::Gists, Document::of(gist.id.clone(), payload.clone()))
.map_err(mirror_failure)?;
Ok(payload)
}
pub async fn create_issue(
&self,
client: &GitHubClient,
owner: &str,
repo: &str,
request: &CreateIssueRequest,
) -> Outcome<GitHubIssue> {
if request.title.trim().is_empty() {
return Err(Failure::Invalid("an issue needs a title".to_string()));
}
let issue = client
.create_issue(owner, repo, request)
.await
.map_err(github_failure)?;
self.record_issue(&format!("{owner}/{repo}"), &issue)?;
Ok(issue)
}
pub async fn update_issue(
&self,
client: &GitHubClient,
owner: &str,
repo: &str,
number: i64,
request: &UpdateIssueRequest,
) -> Outcome<GitHubIssue> {
let issue = client
.update_issue(owner, repo, number, request)
.await
.map_err(github_failure)?;
self.record_issue(&format!("{owner}/{repo}"), &issue)?;
Ok(issue)
}
pub async fn add_comment(
&self,
client: &GitHubClient,
owner: &str,
repo: &str,
number: i64,
body: &str,
) -> Outcome<GitHubComment> {
if body.trim().is_empty() {
return Err(Failure::Invalid("a comment needs a body".to_string()));
}
let comment = client
.add_comment(owner, repo, number, body)
.await
.map_err(github_failure)?;
let scope = Scope::Comments {
repo: format!("{owner}/{repo}"),
number,
};
let payload = serde_json::to_value(&comment).map_err(|e| mirror_failure(e.into()))?;
self.mirror.hold(&scope).map_err(mirror_failure)?;
self.mirror
.put_one(&scope, Document::of(comment.id.to_string(), payload))
.map_err(mirror_failure)?;
Ok(comment)
}
pub async fn create_gist(
&self,
client: &GitHubClient,
request: &CreateGistRequest,
) -> Outcome<serde_json::Value> {
if request.files.is_empty() {
return Err(Failure::Invalid("a gist needs at least one file".to_string()));
}
let gist = client.create_gist(request).await.map_err(github_failure)?;
self.record_gist(&gist)
}
pub async fn update_gist(
&self,
client: &GitHubClient,
id: &str,
request: &UpdateGistRequest,
) -> Outcome<serde_json::Value> {
let gist = client.update_gist(id, request).await.map_err(github_failure)?;
self.record_gist(&gist)
}
pub fn boards(&self, references: &[BoardRef]) -> Outcome<BoardList> {
let wanted = references.iter().cloned().collect();
let (boards, problems) =
sync::resolve_boards(&self.mirror, &wanted).map_err(mirror_failure)?;
let mut ordered = Vec::new();
for reference in references {
if let Some(board) = boards
.iter()
.find(|board| board.owner == reference.owner && board.number == reference.number)
{
ordered.push(board.clone());
}
}
let mut standing = BTreeMap::new();
for reference in references {
let scope = Scope::Projects(reference.owner.clone());
standing.insert(scope.key(), self.standing(&scope)?);
}
Ok(BoardList {
boards: ordered,
problems,
sync: standing,
})
}
pub fn all_boards(&self, references: &[BoardRef]) -> Outcome<AllBoards> {
let account = self.standing(&Scope::Viewer)?;
let viewer = sync::viewer(&self.mirror).map_err(mirror_failure)?;
let mut problems = Vec::new();
let mut owners: Vec<(String, Relation)> = Vec::new();
match viewer {
Some(viewer) => {
owners.push((viewer.login, Relation::Account));
let mut organizations = viewer.organizations;
organizations.sort_by_key(|login| login.to_lowercase());
owners.extend(organizations.into_iter().map(|login| (login, Relation::Organization)));
}
None => problems.push(match &account.last_error {
Some(error) => format!("whose boards these are could not be read: {error}"),
None => "who the GitHub token belongs to has not been read yet, so only referenced \
boards are listed"
.to_string(),
}),
}
for reference in references {
if !owners.iter().any(|(login, _)| *login == reference.owner) {
owners.push((reference.owner.clone(), Relation::Referenced));
}
}
let mut listed = Vec::new();
let mut boards = Vec::new();
let mut unread = std::collections::BTreeSet::new();
for (login, relation) in owners {
let scope = Scope::Projects(login.clone());
match sync::owner_boards(&self.mirror, &login).map_err(mirror_failure)? {
Ok(mut held) => {
held.sort_by(|a, b| a.closed.cmp(&b.closed).then(b.updated_at.cmp(&a.updated_at)));
boards.extend(held);
}
Err(reason) => {
problems.push(reason);
unread.insert(login.clone());
}
}
listed.push(BoardOwner {
sync: self.standing(&scope)?,
login,
relation,
});
}
let mut referenced = Vec::new();
for reference in references {
match boards
.iter()
.find(|board| board.owner == reference.owner && board.number == reference.number)
{
Some(board) => referenced.push(board.id.clone()),
None if unread.contains(&reference.owner) => {}
None => problems.push(format!("{} has no board {}", reference.owner, reference.number)),
}
}
Ok(AllBoards {
owners: listed,
boards,
referenced,
account,
problems,
})
}
pub fn cards(&self, board: &str) -> Outcome<Cards> {
let scope = Scope::ProjectItems(board.to_string());
let items = self
.mirror
.payloads(&scope)
.map_err(mirror_failure)?
.into_iter()
.map(|payload| {
serde_json::from_value::<GitHubProjectItem>(payload).map_err(|e| {
Failure::Mirror(anyhow::anyhow!(
"a mirrored card of {board} cannot be read by this version ({e}); \
sync the board to read it again"
))
})
})
.collect::<Outcome<Vec<_>>>()?;
Ok(Cards {
items,
sync: self.standing(&scope)?,
})
}
pub async fn refresh_boards(&self, client: &GitHubClient, references: &[BoardRef]) -> SyncReport {
let references = references.iter().cloned().collect();
sync::sync_boards(&self.mirror, client, &references, true).await
}
pub async fn refresh_board(
&self,
client: &GitHubClient,
board: &GitHubProject,
force: bool,
) -> SyncReport {
sync::sync_scopes(&self.mirror, client, &[Scope::ProjectItems(board.id.clone())], force).await
}
fn record_card(&self, board: &str, item: &GitHubProjectItem) -> Outcome<()> {
let payload = serde_json::to_value(item).map_err(|e| mirror_failure(e.into()))?;
self.mirror
.put_one(
&Scope::ProjectItems(board.to_string()),
Document::of(item.id.clone(), payload),
)
.map_err(mirror_failure)?;
Ok(())
}
pub async fn set_field(
&self,
client: &GitHubClient,
board: &str,
item: &str,
field: &str,
value: Option<&ProjectFieldWrite>,
) -> Outcome<GitHubProjectItem> {
let updated = match value {
Some(value) => client.set_project_field(board, item, field, value).await,
None => client.clear_project_field(board, item, field).await,
}
.map_err(github_failure)?;
self.record_card(board, &updated)?;
Ok(updated)
}
pub async fn move_card(
&self,
client: &GitHubClient,
board: &str,
item: &str,
after: Option<&str>,
) -> Outcome<GitHubProjectItem> {
if after == Some(item) {
return Err(Failure::Invalid("a card cannot be moved behind itself".to_string()));
}
let updated = client
.move_project_item_after(board, item, after)
.await
.map_err(github_failure)?;
self.record_card(board, &updated)?;
Ok(updated)
}
pub async fn set_archived(
&self,
client: &GitHubClient,
board: &str,
item: &str,
archived: bool,
) -> Outcome<GitHubProjectItem> {
let updated = client
.archive_project_item(board, item, archived)
.await
.map_err(github_failure)?;
self.record_card(board, &updated)?;
Ok(updated)
}
pub async fn edit_draft(
&self,
client: &GitHubClient,
board: &str,
item: &str,
content: &str,
title: &str,
body: &str,
) -> Outcome<GitHubProjectItem> {
if title.trim().is_empty() {
return Err(Failure::Invalid(
"a draft card needs a title; an untitled card is one nobody can find".to_string(),
));
}
let updated = client
.update_project_draft(item, content, title, body)
.await
.map_err(github_failure)?;
self.record_card(board, &updated)?;
Ok(updated)
}
pub async fn add_draft(
&self,
client: &GitHubClient,
board: &str,
title: &str,
body: &str,
) -> Outcome<GitHubProjectItem> {
if title.trim().is_empty() {
return Err(Failure::Invalid("a draft card needs a title".to_string()));
}
let added = client
.add_project_draft(board, title, body)
.await
.map_err(github_failure)?;
self.record_card(board, &added)?;
Ok(added)
}
pub async fn add_existing(
&self,
client: &GitHubClient,
board: &str,
url: &str,
) -> Outcome<GitHubProjectItem> {
let added = client
.add_project_item_by_url(board, url)
.await
.map_err(github_failure)?;
self.record_card(board, &added)?;
Ok(added)
}
pub async fn remove_card(&self, client: &GitHubClient, board: &str, item: &str) -> Outcome<()> {
client
.delete_project_item(board, item)
.await
.map_err(github_failure)?;
self.mirror
.remove_one(&Scope::ProjectItems(board.to_string()), item)
.map_err(mirror_failure)?;
Ok(())
}
pub async fn add_column(
&self,
client: &GitHubClient,
board: &str,
field: &str,
name: &str,
color: &str,
description: &str,
) -> Outcome<(GitHubProject, Vec<GitHubProjectItem>)> {
let _lock = self.column_lock().await?;
let updated = client
.add_project_column(board, field, name, color, description)
.await
.map_err(github_failure)?;
let items = client.project_items(board).await.map_err(github_failure)?;
self.record_board(&updated, &items)?;
Ok((updated, items))
}
pub async fn remove_column(
&self,
client: &GitHubClient,
board: &str,
field: &str,
option: &str,
) -> Outcome<(GitHubProject, Vec<GitHubProjectItem>)> {
let _lock = self.column_lock().await?;
let updated = client
.remove_project_column(board, field, option)
.await
.map_err(github_failure)?;
let items = client.project_items(board).await.map_err(github_failure)?;
self.record_board(&updated, &items)?;
Ok((updated, items))
}
fn record_board(&self, board: &GitHubProject, items: &[GitHubProjectItem]) -> Outcome<()> {
let board_value = serde_json::to_value(board).map_err(|e| mirror_failure(e.into()))?;
let documents = items
.iter()
.map(|item| {
serde_json::to_value(item)
.map(|value| Document::of(item.id.clone(), value))
.map_err(|e| mirror_failure(e.into()))
})
.collect::<Outcome<Vec<_>>>()?;
self.mirror
.put_project_write(
&Scope::Projects(board.owner.clone()),
Document::of(board.id.clone(), board_value),
&Scope::ProjectItems(board.id.clone()),
&documents,
)
.map_err(mirror_failure)
}
async fn column_lock(&self) -> Outcome<ColumnLock> {
let path = self.mirror.path().with_extension("columns.lock");
tokio::task::spawn_blocking(move || ColumnLock::acquire(path))
.await
.map_err(|e| Failure::Mirror(anyhow::anyhow!("the column lock could not be taken: {e}")))?
.map_err(mirror_failure)
}
}
struct ColumnLock {
file: File,
}
impl ColumnLock {
fn acquire(path: PathBuf) -> anyhow::Result<Self> {
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&path)
.with_context(|| format!("cannot open {}", path.display()))?;
file.lock()
.with_context(|| format!("cannot lock {}", path.display()))?;
Ok(Self { file })
}
}
impl Drop for ColumnLock {
fn drop(&mut self) {
let _ = self.file.unlock();
}
}
fn updated_of(issue: &serde_json::Value) -> &str {
issue
.get("updated_at")
.and_then(|value| value.as_str())
.unwrap_or("")
}
#[cfg(test)]
#[path = "service_tests.rs"]
mod tests;