cli: Implement `rad cob migrate`

Adds the new `migrate` sub-command to migrate the COB database.
Checks for version when opening the COBs database and suggests the
command on out of date version.

Also adds the 2nd migration to the COBs cache, which updates the patch
JSON schema.
This commit is contained in:
cloudhead 2024-11-18 12:27:07 +01:00
parent 2d13591ec2
commit 3ad84420bd
No known key found for this signature in database
42 changed files with 1013 additions and 205 deletions

View File

@ -0,0 +1,24 @@
``` (fail)
$ rad patch list
✗ Error: collaborative objects database is out of date
✗ Hint: run `rad cob migrate` to update your database
```
``` (fail)
$ rad issue list
✗ Error: collaborative objects database is out of date
✗ Hint: run `rad cob migrate` to update your database
```
```
$ rad cob migrate
✓ Migration [..]/[..] in progress.. (100%)
✓ Migrated collaborative objects database successfully (version = [..])
```
```
$ rad issue list
Nothing to show.
$ rad patch list
Nothing to show.
```

View File

@ -9,7 +9,6 @@ use radicle::issue::cache::Issues as _;
use radicle::patch::cache::Patches as _;
use thiserror::Error;
use radicle::cob::migrate;
use radicle::git::raw;
use radicle::identity::doc;
use radicle::identity::doc::{DocError, RepoId};
@ -183,8 +182,8 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
info.push([term::format::bold(proj.name()).into()]);
info.push([term::format::italic(proj.description()).into()]);
let issues = profile.issues(&repo, migrate::ignore)?.counts()?;
let patches = profile.patches(&repo, migrate::ignore)?.counts()?;
let issues = term::cob::issues(&profile, &repo)?.counts()?;
let patches = term::cob::patches(&profile, &repo)?.counts()?;
info.push([term::Line::spaced([
term::format::tertiary(issues.open).into(),

View File

@ -5,7 +5,6 @@ use anyhow::anyhow;
use chrono::prelude::*;
use nonempty::NonEmpty;
use radicle::cob;
use radicle::cob::migrate;
use radicle::cob::Op;
use radicle::identity::Identity;
use radicle::issue::cache::Issues;
@ -27,14 +26,16 @@ pub const HELP: Help = Help {
Usage
rad cob <command> [<option>...]
rad cob list --repo <rid> --type <typename>
rad cob log --repo <rid> --type <typename> --object <oid> [<option>...]
rad cob show --repo <rid> --type <typename> --object <oid> [<option>...]
rad cob list --repo <rid> --type <typename>
rad cob log --repo <rid> --type <typename> --object <oid> [<option>...]
rad cob show --repo <rid> --type <typename> --object <oid> [<option>...]
rad cob migrate [<option>...]
Commands
list List all COBs of a given type (--object is not needed)
log Print a log of all raw operations on a COB
migrate Migrate the COB database to the latest version
Log options
@ -54,13 +55,26 @@ Other options
enum OperationName {
List,
Log,
Migrate,
Show,
}
enum Operation {
List,
Log(Rev),
Show(Rev),
List {
repo: RepoId,
type_name: cob::TypeName,
},
Log {
repo: RepoId,
rev: Rev,
type_name: cob::TypeName,
},
Migrate,
Show {
repo: RepoId,
rev: Rev,
type_name: cob::TypeName,
},
}
enum Format {
@ -69,9 +83,7 @@ enum Format {
}
pub struct Options {
rid: RepoId,
op: Operation,
type_name: cob::TypeName,
format: Format,
}
@ -91,6 +103,7 @@ impl Args for Options {
Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
"list" => op = Some(OperationName::List),
"log" => op = Some(OperationName::Log),
"migrate" => op = Some(OperationName::Migrate),
"show" => op = Some(OperationName::Show),
unknown => anyhow::bail!("unknown operation '{unknown}'"),
},
@ -129,24 +142,35 @@ impl Args for Options {
_ => return Err(anyhow::anyhow!(arg.unexpected())),
}
}
let repo = rid.ok_or_else(|| anyhow!("a repository id must be specified with `--repo`"));
let type_name =
type_name.ok_or_else(|| anyhow!("an object type must be specified with `--type`"));
Ok((
Options {
op: {
match op.ok_or_else(|| anyhow!("a command must be specified"))? {
OperationName::List => Operation::List,
OperationName::Log => Operation::Log(oid.ok_or_else(|| {
anyhow!("an object id must be specified with `--object`")
})?),
OperationName::Show => Operation::Show(oid.ok_or_else(|| {
anyhow!("an object id must be specified with `--object`")
})?),
OperationName::List => Operation::List {
repo: repo?,
type_name: type_name?,
},
OperationName::Log => Operation::Log {
repo: repo?,
rev: oid.ok_or_else(|| {
anyhow!("an object id must be specified with `--object`")
})?,
type_name: type_name?,
},
OperationName::Migrate => Operation::Migrate,
OperationName::Show => Operation::Show {
repo: repo?,
rev: oid.ok_or_else(|| {
anyhow!("an object id must be specified with `--object`")
})?,
type_name: type_name?,
},
}
},
rid: rid
.ok_or_else(|| anyhow!("a repository id must be specified with `--repo`"))?,
type_name: type_name
.ok_or_else(|| anyhow!("an object type must be specified with `--type`"))?,
format: format.unwrap_or(Format::Pretty),
},
vec![],
@ -157,18 +181,23 @@ impl Args for Options {
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
let profile = ctx.profile()?;
let storage = &profile.storage;
let repo = storage.repository(options.rid)?;
match options.op {
Operation::List => {
let cobs = list::<NonEmpty<cob::Entry>, _>(&repo, &options.type_name)?;
Operation::List { repo, type_name } => {
let repo = storage.repository(repo)?;
let cobs = list::<NonEmpty<cob::Entry>, _>(&repo, &type_name)?;
for cob in cobs {
println!("{}", cob.id);
}
}
Operation::Log(oid) => {
Operation::Log {
repo,
rev: oid,
type_name,
} => {
let repo = storage.repository(repo)?;
let oid = oid.resolve(&repo.backend)?;
let ops = cob::store::ops(&oid, &options.type_name, &repo)?;
let ops = cob::store::ops(&oid, &type_name, &repo)?;
for op in ops.into_iter().rev() {
match options.format {
@ -177,28 +206,44 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
}
}
}
Operation::Show(oid) => {
Operation::Migrate => {
let mut db = profile.cobs_db_mut()?;
if db.check_version().is_ok() {
term::success!("Collaborative objects database is already up to date");
} else {
let version = db.migrate(term::cob::migrate::spinner())?;
term::success!(
"Migrated collaborative objects database successfully (version={version})"
);
}
}
Operation::Show {
repo,
rev: oid,
type_name,
} => {
let repo = storage.repository(repo)?;
let oid = &oid.resolve(&repo.backend)?;
if options.type_name == cob::patch::TYPENAME.clone() {
let patches = profile.patches(&repo, migrate::ignore)?;
if type_name == cob::patch::TYPENAME.clone() {
let patches = term::cob::patches(&profile, &repo)?;
let Some(patch) = patches.get(oid)? else {
anyhow::bail!(cob::store::Error::NotFound(options.type_name, *oid))
anyhow::bail!(cob::store::Error::NotFound(type_name, *oid))
};
serde_json::to_writer_pretty(std::io::stdout(), &patch)?
} else if options.type_name == cob::issue::TYPENAME.clone() {
let issues = profile.issues(&repo, migrate::ignore)?;
} else if type_name == cob::issue::TYPENAME.clone() {
let issues = term::cob::issues(&profile, &repo)?;
let Some(issue) = issues.get(oid)? else {
anyhow::bail!(cob::store::Error::NotFound(options.type_name, *oid))
anyhow::bail!(cob::store::Error::NotFound(type_name, *oid))
};
serde_json::to_writer_pretty(std::io::stdout(), &issue)?
} else if options.type_name == cob::identity::TYPENAME.clone() {
let Some(cob) = cob::get::<Identity, _>(&repo, &options.type_name, oid)? else {
anyhow::bail!(cob::store::Error::NotFound(options.type_name, *oid))
} else if type_name == cob::identity::TYPENAME.clone() {
let Some(cob) = cob::get::<Identity, _>(&repo, &type_name, oid)? else {
anyhow::bail!(cob::store::Error::NotFound(type_name, *oid))
};
serde_json::to_writer_pretty(std::io::stdout(), &cob.object)?
} else {
anyhow::bail!("the type name '{}' is unknown", options.type_name);
anyhow::bail!("the type name '{type_name}' is unknown");
}
println!();
}

View File

@ -6,7 +6,6 @@ use anyhow::anyhow;
use git_ref_format::Qualified;
use localtime::LocalTime;
use radicle::cob::migrate;
use radicle::cob::TypedId;
use radicle::identity::Identity;
use radicle::issue::cache::Issues as _;
@ -272,8 +271,8 @@ where
let (_, head) = repo.head()?;
let doc = repo.identity_doc()?;
let proj = doc.project()?;
let issues = profile.issues(&repo, migrate::ignore)?;
let patches = profile.patches(&repo, migrate::ignore)?;
let issues = term::cob::issues(profile, &repo)?;
let patches = term::cob::patches(profile, &repo)?;
let mut notifs = notifs.by_repo(&rid, sort_by.field)?.collect::<Vec<_>>();
if !sort_by.reverse {
@ -536,7 +535,7 @@ fn show(
match n.kind {
NotificationKind::Cob { typed_id } if typed_id.is_issue() => {
let issues = profile.issues(&repo, migrate::ignore)?;
let issues = term::cob::issues(profile, &repo)?;
let issue = issues.get(&typed_id.id)?.unwrap();
term::issue::show(
@ -547,7 +546,7 @@ fn show(
)?;
}
NotificationKind::Cob { typed_id } if typed_id.is_patch() => {
let patches = profile.patches(&repo, migrate::ignore)?;
let patches = term::cob::patches(profile, &repo)?;
let patch = patches.get(&typed_id.id)?.unwrap();
term::patch::show(&patch, &typed_id.id, false, &repo, None, profile)?;

View File

@ -9,7 +9,7 @@ use anyhow::{anyhow, Context as _};
use radicle::cob::common::{Label, Reaction};
use radicle::cob::issue::{CloseReason, State};
use radicle::cob::{issue, migrate, thread};
use radicle::cob::{issue, thread};
use radicle::crypto::Signer;
use radicle::issue::cache::Issues as _;
use radicle::prelude::{Did, RepoId};
@ -459,8 +459,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
| Operation::Edit { .. }
| Operation::Comment { .. }
);
let mut issues = profile.issues_mut(&repo, migrate::ignore)?;
let mut issues = term::cob::issues_mut(&profile, &repo)?;
match options.op {
Operation::Edit {

View File

@ -1,6 +1,5 @@
use std::ops::ControlFlow;
use radicle::cob::migrate;
use radicle::issue::IssueId;
use radicle::storage::git::Repository;
use radicle::storage::ReadStorage as _;
@ -38,9 +37,8 @@ pub fn run(mode: CacheMode, profile: &Profile) -> anyhow::Result<()> {
}
fn cache(id: Option<IssueId>, repository: &Repository, profile: &Profile) -> anyhow::Result<()> {
let mut issues = profile.issues_mut(repository, migrate::ignore)?;
let mut issues = term::cob::issues_mut(profile, repository)?;
// FIXME: Should this change?
match id {
Some(id) => {
issues.write(&id)?;

View File

@ -37,7 +37,7 @@ use std::ffi::OsString;
use anyhow::anyhow;
use radicle::cob::patch::PatchId;
use radicle::cob::{migrate, patch, Label};
use radicle::cob::{patch, Label};
use radicle::git::RefString;
use radicle::patch::cache::Patches as _;
use radicle::storage::git::transport;
@ -928,7 +928,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
label::run(&patch_id, add, delete, &profile, &repository)?;
}
Operation::Set { patch_id, remote } => {
let patches = profile.patches(&repository, migrate::ignore)?;
let patches = term::cob::patches(&profile, &repository)?;
let patch_id = patch_id.resolve(&repository.backend)?;
let patch = patches
.get(&patch_id)?

View File

@ -1,9 +1,8 @@
use super::*;
use radicle::cob::migrate;
use radicle::prelude::*;
use radicle::storage::git::Repository;
use super::*;
pub fn run(
patch_id: &PatchId,
undo: bool,
@ -11,7 +10,7 @@ pub fn run(
repository: &Repository,
) -> anyhow::Result<()> {
let signer = term::signer(profile)?;
let mut patches = profile.patches_mut(repository, migrate::ignore)?;
let mut patches = term::cob::patches_mut(profile, repository)?;
let Ok(mut patch) = patches.get_mut(patch_id) else {
anyhow::bail!("Patch `{patch_id}` not found");
};

View File

@ -2,7 +2,6 @@ use std::collections::BTreeSet;
use super::*;
use radicle::cob::migrate;
use radicle::prelude::Did;
use radicle::storage::git::Repository;
@ -16,7 +15,7 @@ pub fn run(
repository: &Repository,
) -> anyhow::Result<()> {
let signer = term::signer(profile)?;
let mut patches = profile.patches_mut(repository, migrate::ignore)?;
let mut patches = term::cob::patches_mut(profile, repository)?;
let Ok(mut patch) = patches.get_mut(patch_id) else {
anyhow::bail!("Patch `{patch_id}` not found");
};

View File

@ -1,6 +1,5 @@
use std::ops::ControlFlow;
use radicle::cob::migrate;
use radicle::patch::PatchId;
use radicle::storage::git::Repository;
use radicle::storage::ReadStorage as _;
@ -38,7 +37,7 @@ pub fn run(mode: CacheMode, profile: &Profile) -> anyhow::Result<()> {
}
fn cache(id: Option<PatchId>, repository: &Repository, profile: &Profile) -> anyhow::Result<()> {
let mut patches = profile.patches_mut(repository, migrate::ignore)?;
let mut patches = term::cob::patches_mut(profile, repository)?;
match id {
Some(id) => {

View File

@ -1,7 +1,6 @@
use anyhow::anyhow;
use git_ref_format::Qualified;
use radicle::cob::migrate;
use radicle::cob::patch;
use radicle::cob::patch::RevisionId;
use radicle::git::RefString;
@ -39,7 +38,7 @@ pub fn run(
profile: &Profile,
opts: Options,
) -> anyhow::Result<()> {
let patches = profile.patches(stored, migrate::ignore)?;
let patches = term::cob::patches(profile, stored)?;
let patch = patches
.get(patch_id)?
.ok_or_else(|| anyhow!("Patch `{patch_id}` not found"))?;

View File

@ -1,8 +1,8 @@
use super::*;
use radicle::cob;
use radicle::cob::patch;
use radicle::cob::thread::CommentId;
use radicle::cob::{migrate, patch};
use radicle::patch::ByRevision;
use radicle::prelude::*;
use radicle::storage::git::Repository;
@ -20,7 +20,7 @@ pub fn run(
profile: &Profile,
) -> anyhow::Result<()> {
let signer = term::signer(profile)?;
let mut patches = profile.patches_mut(repo, migrate::ignore)?;
let mut patches = term::cob::patches_mut(profile, repo)?;
let revision_id = revision_id.resolve::<cob::EntryId>(&repo.backend)?;
let ByRevision {

View File

@ -1,4 +1,3 @@
use radicle::cob::migrate;
use radicle::prelude::*;
use radicle::storage::git::Repository;
@ -6,7 +5,7 @@ use super::*;
pub fn run(patch_id: &PatchId, profile: &Profile, repository: &Repository) -> anyhow::Result<()> {
let signer = &term::signer(profile)?;
let mut patches = profile.patches_mut(repository, migrate::ignore)?;
let mut patches = term::cob::patches_mut(profile, repository)?;
patches.remove(patch_id, signer)?;
Ok(())

View File

@ -1,6 +1,6 @@
use std::process;
use radicle::cob::{migrate, patch};
use radicle::cob::patch;
use radicle::storage::git::Repository;
use super::*;
@ -11,7 +11,7 @@ pub fn run(
stored: &Repository,
profile: &Profile,
) -> anyhow::Result<()> {
let patches = profile.patches(stored, migrate::ignore)?;
let patches = term::cob::patches(profile, stored)?;
let Some(patch) = patches.get(patch_id)? else {
anyhow::bail!("Patch `{patch_id}` not found");
};

View File

@ -1,7 +1,7 @@
use super::*;
use radicle::cob;
use radicle::cob::{migrate, patch};
use radicle::cob::patch;
use radicle::crypto;
use radicle::prelude::*;
use radicle::storage::git::Repository;
@ -16,7 +16,7 @@ pub fn run(
repository: &Repository,
) -> anyhow::Result<()> {
let signer = term::signer(profile)?;
let mut patches = profile.patches_mut(repository, migrate::ignore)?;
let mut patches = term::cob::patches_mut(profile, repository)?;
let Ok(patch) = patches.get_mut(patch_id) else {
anyhow::bail!("Patch `{patch_id}` not found");
};

View File

@ -1,8 +1,7 @@
use super::*;
use radicle::cob::migrate;
use radicle::storage::git::Repository;
use super::*;
use crate::terminal as term;
pub fn run(
@ -13,7 +12,7 @@ pub fn run(
repository: &Repository,
) -> anyhow::Result<()> {
let signer = term::signer(profile)?;
let mut patches = profile.patches_mut(repository, migrate::ignore)?;
let mut patches = term::cob::patches_mut(profile, repository)?;
let Ok(mut patch) = patches.get_mut(patch_id) else {
anyhow::bail!("Patch `{patch_id}` not found");
};

View File

@ -1,7 +1,7 @@
use std::collections::{BTreeMap, BTreeSet};
use radicle::cob::patch;
use radicle::cob::patch::{Patch, PatchId};
use radicle::cob::{migrate, patch};
use radicle::patch::cache::Patches as _;
use radicle::prelude::*;
use radicle::profile::Profile;
@ -21,7 +21,7 @@ pub fn run(
repository: &Repository,
profile: &Profile,
) -> anyhow::Result<()> {
let patches = profile.patches(repository, migrate::ignore)?;
let patches = term::cob::patches(profile, repository)?;
let mut all = Vec::new();
let iter = match filter {

View File

@ -1,9 +1,8 @@
use super::*;
use radicle::cob::migrate;
use radicle::prelude::*;
use radicle::storage::git::Repository;
use super::*;
pub fn run(
patch_id: &PatchId,
undo: bool,
@ -11,7 +10,7 @@ pub fn run(
repository: &Repository,
) -> anyhow::Result<bool> {
let signer = term::signer(profile)?;
let mut patches = profile.patches_mut(repository, migrate::ignore)?;
let mut patches = term::cob::patches_mut(profile, repository)?;
let Ok(mut patch) = patches.get_mut(patch_id) else {
anyhow::bail!("Patch `{patch_id}` not found");
};

View File

@ -1,4 +1,4 @@
use radicle::cob::{migrate, patch};
use radicle::cob::patch;
use radicle::git::Oid;
use radicle::prelude::*;
use radicle::storage::git::Repository;
@ -13,7 +13,7 @@ pub fn run(
repository: &Repository,
) -> anyhow::Result<()> {
let signer = &term::signer(profile)?;
let mut patches = profile.patches_mut(repository, migrate::ignore)?;
let mut patches = term::cob::patches_mut(profile, repository)?;
let revision_id = revision_id.resolve::<Oid>(&repository.backend)?;
let patch::ByRevision {

View File

@ -1,5 +1,4 @@
use anyhow::anyhow;
use radicle::cob::migrate;
use radicle::cob::thread::CommentId;
use radicle::patch::{self, PatchId};
use radicle::patch::{cache::Patches as _, ReviewId};
@ -16,7 +15,7 @@ pub fn resolve(
profile: &Profile,
) -> anyhow::Result<()> {
let signer = term::signer(profile)?;
let mut patches = profile.patches_mut(repo, migrate::ignore)?;
let mut patches = term::cob::patches_mut(profile, repo)?;
let patch = patches
.get(&patch_id)?
.ok_or_else(|| anyhow!("Patch `{patch_id}` not found"))?;
@ -33,7 +32,7 @@ pub fn unresolve(
profile: &Profile,
) -> anyhow::Result<()> {
let signer = term::signer(profile)?;
let mut patches = profile.patches_mut(repo, migrate::ignore)?;
let mut patches = term::cob::patches_mut(profile, repo)?;
let patch = patches
.get(&patch_id)?
.ok_or_else(|| anyhow!("Patch `{patch_id}` not found"))?;

View File

@ -3,7 +3,6 @@ mod builder;
use anyhow::{anyhow, Context};
use radicle::cob::migrate;
use radicle::cob::patch::{PatchId, RevisionId, Verdict};
use radicle::git;
use radicle::prelude::*;
@ -62,7 +61,7 @@ pub fn run(
"couldn't load repository {} from local state",
repository.id
))?;
let mut patches = profile.patches_mut(repository, migrate::ignore)?;
let mut patches = term::cob::patches_mut(profile, repository)?;
let mut patch = patches
.get_mut(&patch_id)
.context(format!("couldn't find patch {patch_id} locally"))?;

View File

@ -1,6 +1,6 @@
use std::process;
use radicle::cob::{migrate, patch};
use radicle::cob::patch;
use radicle::git;
use radicle::storage::git::Repository;
@ -32,7 +32,7 @@ pub fn run(
stored: &Repository,
workdir: Option<&git::raw::Repository>,
) -> anyhow::Result<()> {
let patches = profile.patches(stored, migrate::ignore)?;
let patches = term::cob::patches(profile, stored)?;
let Some(patch) = patches.get(patch_id).map_err(|e| Error::WithHint {
err: e.into(),
hint: "reset the cache with `rad patch cache` and try again",

View File

@ -1,4 +1,4 @@
use radicle::cob::{migrate, patch};
use radicle::cob::patch;
use radicle::git;
use radicle::prelude::*;
use radicle::storage::git::Repository;
@ -19,7 +19,7 @@ pub fn run(
let head_branch = try_branch(workdir.head()?)?;
let (_, target_oid) = get_merge_target(repository, &head_branch)?;
let mut patches = profile.patches_mut(repository, migrate::ignore)?;
let mut patches = term::cob::patches_mut(profile, repository)?;
let Ok(mut patch) = patches.get_mut(&patch_id) else {
anyhow::bail!("Patch `{patch_id}` not found");
};

View File

@ -3,7 +3,6 @@ use std::path::Path;
use localtime::LocalDuration;
use localtime::LocalTime;
use radicle::cob::migrate;
use radicle::git;
use radicle::issue::cache::Issues as _;
use radicle::node::address;
@ -96,8 +95,8 @@ pub fn run(_options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
for repo in storage.repositories()? {
let repo = storage.repository(repo.rid)?;
let issues = profile.issues(&repo, migrate::ignore)?.counts()?;
let patches = profile.patches(&repo, migrate::ignore)?.counts()?;
let issues = term::cob::issues(&profile, &repo)?.counts()?;
let patches = term::cob::patches(&profile, &repo)?.counts()?;
stats.local.issues += issues.total();
stats.local.patches += patches.total();

View File

@ -4,6 +4,7 @@ pub mod format;
pub mod io;
pub mod job;
pub use io::signer;
pub mod cob;
pub mod comment;
pub mod highlight;
pub mod issue;

View File

@ -0,0 +1,113 @@
use radicle::{
cob::{
self,
cache::{MigrateCallback, MigrateProgress},
},
profile,
storage::ReadRepository,
Profile,
};
use radicle_term as term;
use crate::terminal;
/// Hint to migrate COB database.
pub const MIGRATION_HINT: &str = "run `rad cob migrate` to update your database";
/// COB migration progress spinner.
pub struct MigrateSpinner {
spinner: Option<term::Spinner>,
}
impl Default for MigrateSpinner {
/// Create a new [`MigrateSpinner`].
fn default() -> Self {
Self { spinner: None }
}
}
impl MigrateCallback for MigrateSpinner {
fn progress(&mut self, progress: MigrateProgress) {
self.spinner
.get_or_insert_with(|| term::spinner("Migration in progress.."))
.message(format!(
"Migration {}/{} in progress.. ({}%)",
progress.migration.current(),
progress.migration.total(),
progress.rows.percentage()
));
if progress.is_done() {
if let Some(spinner) = self.spinner.take() {
spinner.finish()
}
}
}
}
/// Migrate functions.
pub mod migrate {
use super::MigrateSpinner;
/// Display migration progress via a spinner.
pub fn spinner() -> MigrateSpinner {
MigrateSpinner::default()
}
}
/// Return a read-only handle for the patches cache.
pub fn patches<'a, R>(
profile: &Profile,
repository: &'a R,
) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, cob::cache::StoreReader>, anyhow::Error>
where
R: ReadRepository + cob::Store,
{
profile.patches(repository).map_err(with_hint)
}
/// Return a read-write handle for the patches cache.
pub fn patches_mut<'a, R>(
profile: &Profile,
repository: &'a R,
) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, cob::cache::StoreWriter>, anyhow::Error>
where
R: ReadRepository + cob::Store,
{
profile.patches_mut(repository).map_err(with_hint)
}
/// Return a read-only handle for the issues cache.
pub fn issues<'a, R>(
profile: &Profile,
repository: &'a R,
) -> Result<cob::issue::Cache<cob::issue::Issues<'a, R>, cob::cache::StoreReader>, anyhow::Error>
where
R: ReadRepository + cob::Store,
{
profile.issues(repository).map_err(with_hint)
}
/// Return a read-write handle for the issues cache.
pub fn issues_mut<'a, R>(
profile: &Profile,
repository: &'a R,
) -> Result<cob::issue::Cache<cob::issue::Issues<'a, R>, cob::cache::StoreWriter>, anyhow::Error>
where
R: ReadRepository + cob::Store,
{
profile.issues_mut(repository).map_err(with_hint)
}
/// Adds a hint to the COB out-of-date database error.
fn with_hint(e: profile::Error) -> anyhow::Error {
match e {
profile::Error::CobsCache(cob::cache::Error::OutOfDate) => {
anyhow::Error::from(terminal::args::Error::WithHint {
err: e.into(),
hint: MIGRATION_HINT,
})
}
e => anyhow::Error::from(e),
}
}

View File

@ -186,6 +186,25 @@ fn rad_cob_show() {
test("examples/rad-cob-show.md", &working, Some(home), []).unwrap();
}
#[test]
fn rad_cob_migrate() {
let mut environment = Environment::new();
let profile = environment.profile(config::profile("alice"));
let home = &profile.home;
let working = environment.tmp().join("working");
home.cobs_db_mut()
.unwrap()
.raw_query(|conn| conn.execute("PRAGMA user_version = 0"))
.unwrap();
// Setup a test repository.
fixtures::repository(&working);
test("examples/rad-init.md", &working, Some(home), []).unwrap();
test("examples/rad-cob-migrate.md", &working, Some(home), []).unwrap();
}
#[test]
fn rad_init() {
let mut environment = Environment::new();

View File

@ -8,6 +8,7 @@ use std::{fs, io, net};
use crossbeam_channel as chan;
use cyphernet::Ecdh;
use netservices::resource::NetAccept;
use radicle::cob::migrate;
use radicle_fetch::FetchLimit;
use radicle_signals::Signal;
use reactor::poller::popol;
@ -141,7 +142,17 @@ impl Runtime {
let policies = home.policies_mut()?;
let policies = policy::Config::new(policy, policies);
let notifications = home.notifications_mut()?;
let cobs_cache = cob::cache::Store::open(home.cobs().join(cob::cache::COBS_DB_FILE))?;
let mut cobs_cache = cob::cache::Store::open(home.cobs().join(cob::cache::COBS_DB_FILE))?;
match cobs_cache.check_version() {
Ok(()) => {}
Err(cob::cache::Error::OutOfDate) => {
log::info!(target: "node", "Migrating COBs cache..");
let version = cobs_cache.migrate(migrate::log)?;
log::info!(target: "node", "Migration of COBs cache complete (version={version})..");
}
Err(e) => return Err(e.into()),
}
log::info!(target: "node", "Default seeding policy set to '{}'", &policy);
log::info!(target: "node", "Initializing service ({:?})..", network);

View File

@ -10,8 +10,7 @@ use std::{
use crossbeam_channel as chan;
use localtime::LocalTime;
use radicle::cob::cache::COBS_DB_FILE;
use radicle::cob::issue;
use radicle::cob::{issue, migrate};
use radicle::crypto::ssh::{keystore::MemorySigner, Keystore};
use radicle::crypto::test::signer::MockSigner;
use radicle::crypto::{KeyPair, Seed, Signer};
@ -111,7 +110,6 @@ impl Environment {
let keystore = Keystore::new(&home.keys());
let keypair = KeyPair::from_seed(Seed::from([!(self.users as u8); 32]));
let policies_db = home.node().join(POLICIES_DB_FILE);
let cobs_db = home.cobs().join(COBS_DB_FILE);
let now = LocalTime::now();
config.write(&home.config()).unwrap();
@ -126,8 +124,10 @@ impl Environment {
.unwrap();
let public_key = keypair.pk.into();
let mut db = home.cobs_db_mut().unwrap();
db.migrate(migrate::ignore).unwrap();
policy::Store::open(policies_db).unwrap();
cob::cache::Store::open(cobs_db).unwrap();
home.database_mut()
.unwrap()
.init(

View File

@ -11,12 +11,13 @@ use std::path::PathBuf;
use std::str::FromStr;
use std::{env, fmt, io};
use radicle_cli::git::Rev;
use thiserror::Error;
use radicle::git;
use radicle::storage::git::transport::local::{Url, UrlError};
use radicle::storage::{ReadRepository, WriteStorage};
use radicle::{cob, profile};
use radicle::{git, storage, Profile};
use radicle_cli::git::Rev;
use radicle_cli::terminal as cli;
#[derive(Debug, Error)]
@ -259,3 +260,36 @@ pub(crate) fn hint(s: impl fmt::Display) {
pub(crate) fn warn(s: impl fmt::Display) {
eprintln!("{}", cli::format::hint(format!("warn: {s}")));
}
/// Get the patch store.
pub(crate) fn patches<'a, R: ReadRepository + cob::Store>(
profile: &Profile,
repo: &'a R,
) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, cob::cache::StoreReader>, list::Error> {
match profile.patches(repo) {
Ok(patches) => Ok(patches),
Err(err @ profile::Error::CobsCache(cob::cache::Error::OutOfDate)) => {
hint(cli::cob::MIGRATION_HINT);
Err(err.into())
}
Err(err) => Err(err.into()),
}
}
/// Get the mutable patch store.
pub(crate) fn patches_mut<'a>(
profile: &Profile,
repo: &'a storage::git::Repository,
) -> Result<
cob::patch::Cache<cob::patch::Patches<'a, storage::git::Repository>, cob::cache::StoreWriter>,
push::Error,
> {
match profile.patches_mut(repo) {
Ok(patches) => Ok(patches),
Err(err @ profile::Error::CobsCache(cob::cache::Error::OutOfDate)) => {
hint(cli::cob::MIGRATION_HINT);
Err(err.into())
}
Err(err) => Err(err.into()),
}
}

View File

@ -3,7 +3,6 @@ use radicle::profile;
use thiserror::Error;
use radicle::cob;
use radicle::cob::migrate;
use radicle::git;
use radicle::storage::git::transport::local::Url;
use radicle::storage::ReadRepository;
@ -88,7 +87,7 @@ fn patch_refs<R: ReadRepository + cob::Store + 'static>(
profile: &Profile,
stored: &R,
) -> Result<(), Error> {
let patches = profile.patches(stored, migrate::ignore)?;
let patches = crate::patches(profile, stored)?;
for patch in patches.list()? {
let Ok((id, patch)) = patch else {
// Ignore patches that fail to decode.

View File

@ -9,8 +9,8 @@ use thiserror::Error;
use radicle::cob;
use radicle::cob::object::ParseObjectId;
use radicle::cob::patch;
use radicle::cob::patch::cache::Patches as _;
use radicle::cob::{migrate, patch};
use radicle::crypto::Signer;
use radicle::explorer::ExplorerResource;
use radicle::git::canonical;
@ -219,6 +219,7 @@ pub fn run(
}
Command::Push(git::Refspec { src, dst, force }) => {
let working = git::raw::Repository::open(working)?;
let patches = crate::patches_mut(profile, stored)?;
if dst == &*rad::PATCHES_REFNAME {
patch_open(
@ -227,7 +228,7 @@ pub fn run(
&nid,
&working,
stored,
profile.patches_mut(stored, migrate::ignore)?,
patches,
&signer,
profile,
opts.clone(),
@ -247,7 +248,7 @@ pub fn run(
&nid,
&working,
stored,
profile.patches_mut(stored, migrate::ignore)?,
patches,
&signer,
opts.clone(),
)
@ -323,16 +324,7 @@ pub fn run(
Err(e) => return Err(e.into()),
};
}
push(
src,
&dst,
*force,
&nid,
&working,
stored,
profile.patches_mut(stored, migrate::ignore)?,
&signer,
)
push(src, &dst, *force, &nid, &working, stored, patches, &signer)
}
}
}

View File

@ -2,7 +2,6 @@ use std::collections::HashSet;
use std::env;
use anyhow::{anyhow, bail};
use radicle::cob::migrate;
use radicle::cob::patch::{PatchId, RevisionId};
use radicle::git::Oid;
use radicle::storage::ReadStorage;
@ -27,7 +26,7 @@ fn main() -> anyhow::Result<()> {
let profile = radicle::Profile::load()?;
let (working, rid) = radicle::rad::cwd()?;
let stored = profile.storage.repository(rid)?;
let mut patches = profile.patches_mut(&stored, migrate::ignore)?;
let mut patches = profile.patches_mut(&stored)?;
let mut patch = patches.get_mut(&pid)?;
if patch.is_merged() {

View File

@ -1,3 +1,5 @@
mod migrations;
use std::collections::HashMap;
use std::convert::Infallible;
use std::fmt;
@ -23,34 +25,51 @@ const DB_WRITE_TIMEOUT: time::Duration = time::Duration::from_secs(6);
/// Database migrations.
/// The first migration is the creation of the initial tables.
const MIGRATIONS: &[Migration] = &[Migration::Sql(include_str!("cache/migrations/1.sql"))];
const MIGRATIONS: &[Migration] = &[
Migration::Sql(include_str!("cache/migrations/1.sql")),
Migration::Native(migrations::_2::run),
];
/// Function signature for native migrations.
type MigrateFn = fn(&sql::Connection, Progress, &dyn MigrateCallback) -> Result<usize, Error>;
type MigrateFn = fn(&sql::Connection, &Progress, &mut dyn MigrateCallback) -> Result<usize, Error>;
/// A database migration.
enum Migration {
/// Migration written in SQL.
Sql(&'static str),
/// Migration function written in Rust.
#[allow(dead_code)]
Native(MigrateFn),
}
/// Progress of a database migration.
#[derive(Debug)]
pub struct MigrateProgress<'a> {
/// Progress in the list of migrations.
pub migration: &'a Progress,
/// Progress within each individual migration.
pub rows: &'a Progress,
}
impl<'a> MigrateProgress<'a> {
/// If we're done with the migration.
pub fn is_done(&self) -> bool {
self.migration.current() == self.migration.total()
&& self.rows.current() == self.rows.total()
}
}
/// Something that can process migration progress.
pub trait MigrateCallback {
/// A migration has progressed.
/// The first [`Progress`] parameter refers to the progress within the list of migrations.
/// The second [`Progress`] parameter refers to the progress within the current migration.
fn progress(&mut self, migration: Progress, item: Progress) -> Result<bool, Error>;
fn progress(&mut self, progress: MigrateProgress<'_>);
}
impl<F> MigrateCallback for F
where
F: Fn(Progress, Progress) -> Result<bool, Error>,
F: Fn(MigrateProgress),
{
fn progress(&mut self, migration: Progress, item: Progress) -> Result<bool, Error> {
(self)(migration, item)
fn progress(&mut self, progress: MigrateProgress) {
(self)(progress)
}
}
@ -59,21 +78,18 @@ pub mod migrate {
use super::*;
/// Log progress via installed logger at "info" level.
pub fn log(migration: Progress, item: Progress) -> Result<bool, Error> {
log::info!(
pub fn log(progress: MigrateProgress<'_>) {
log::trace!(
target: "db",
"Migration {}/{} in progress.. ({}%)",
migration.current() + 1,
migration.total(),
item.percentage()
progress.migration.current(),
progress.migration.total(),
progress.rows.percentage()
);
Ok(true)
}
/// Ignore progress, just migrate.
pub fn ignore(_migration: Progress, _item: Progress) -> Result<bool, Error> {
Ok(true)
}
pub fn ignore(_progress: MigrateProgress<'_>) {}
}
#[derive(Error, Debug)]
@ -81,9 +97,18 @@ pub enum Error {
/// An Internal error.
#[error("internal error: {0}")]
Internal(#[from] sql::Error),
/// Malformed JSON schema, eg. missing fields or wrong field types.
#[error("malformed JSON schema")]
MalformedJsonSchema,
/// Malformed JSON data, ie. not valid JSON.
#[error("malformed JSON data: {0}")]
MalformedJson(serde_json::Error),
/// No rows returned in query result.
#[error("no rows returned")]
NoRows,
/// Schema is out of date, migrations need to be run.
#[error("collaborative objects database is out of date")]
OutOfDate,
}
/// Read and write to the store.
@ -148,7 +173,6 @@ impl Store<Write> {
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
let mut db = sql::Connection::open_thread_safe(path)?;
db.set_busy_timeout(DB_WRITE_TIMEOUT.as_millis() as usize)?;
migrate(&db, migrate::ignore)?;
Ok(Self {
db: Arc::new(db),
@ -159,7 +183,6 @@ impl Store<Write> {
/// Create a new in-memory database.
pub fn memory() -> Result<Self, Error> {
let db = Arc::new(sql::Connection::open_thread_safe(":memory:")?);
migrate(&db, migrate::ignore)?;
Ok(Self {
db,
@ -167,6 +190,11 @@ impl Store<Write> {
})
}
/// Builder method that migrates the database.
pub fn with_migrations<M: MigrateCallback>(mut self, callback: M) -> Result<Self, Error> {
self.migrate(callback).map(|_| self)
}
/// Turn this handle into a read-only handle.
pub fn read_only(self) -> Store<Read> {
Store {
@ -183,6 +211,47 @@ impl Store<Write> {
{
transaction(&self.db, query)
}
/// Migrate this database to the latest version.
/// Returns the verison migrated to.
pub fn migrate<M: MigrateCallback>(&mut self, callback: M) -> Result<usize, Error> {
self.migrate_to(MIGRATIONS.len(), callback)
}
/// Migrate this database to the given target version.
/// Returns the verison migrated to.
pub fn migrate_to<M: MigrateCallback>(
&mut self,
target: usize,
mut callback: M,
) -> Result<usize, Error> {
let db = &self.db;
let mut version = version(db)?;
let total = MIGRATIONS.len();
for (i, migration) in MIGRATIONS.iter().enumerate().take(target).skip(version) {
let current = i + 1;
transaction(db, |db| {
match migration {
Migration::Sql(query) => {
db.execute(query)?;
callback.progress(MigrateProgress {
migration: &Progress { total, current },
rows: &Progress::done(1),
});
}
Migration::Native(migrate) => {
migrate(db, &Progress { total, current }, &mut callback)?;
}
}
version = bump(db)?;
Ok::<_, Error>(())
})?;
}
Ok(version)
}
}
impl<T> Store<T> {
@ -190,6 +259,14 @@ impl<T> Store<T> {
pub fn version(&self) -> Result<usize, Error> {
version(&self.db)
}
/// Check if the database version is out of date, ie. we need to migrate.
pub fn check_version(&self) -> Result<(), Error> {
if version(&self.db)? < MIGRATIONS.len() {
return Err(Error::OutOfDate);
}
Ok(())
}
}
/// Get the `user_version` value from the database header.
@ -214,39 +291,6 @@ fn bump(db: &sql::Connection) -> Result<usize, Error> {
Ok(new as usize)
}
/// Migrate the database to the latest schema.
fn migrate<M>(db: &sql::Connection, mut callback: M) -> Result<usize, Error>
where
M: MigrateCallback,
{
let mut version = version(db)?;
let total = MIGRATIONS.len();
for (i, migration) in MIGRATIONS.iter().enumerate() {
if i < version {
continue;
}
transaction(db, |db| {
match migration {
Migration::Sql(query) => {
db.execute(query)?;
callback.progress(
Progress { total, current: i },
Progress::done(db.change_count()),
)?;
}
Migration::Native(migrate) => {
migrate(db, Progress { total, current: i }, &callback)?;
}
}
version = bump(db)?;
Ok::<_, Error>(())
})?;
}
Ok(version)
}
/// Update a COB object in the cache.
pub trait Update<T> {
/// The output type, if any, for a successful update.
@ -351,6 +395,7 @@ impl<T> Remove<T> for NoCache {
///
/// See [`crate::cob::issue::Cache::write_all`] and
/// [`crate::cob::patch::Cache::write_all`].
#[derive(Debug)]
pub struct Progress {
current: usize,
total: usize,
@ -386,11 +431,45 @@ impl Progress {
}
/// Return the percentage of the progress made.
///
/// # Panics
///
/// If the `total` provided is `0`.
pub fn percentage(&self) -> f32 {
(self.current as f32 / self.total as f32) * 100.0
if self.total == 0 {
100.
} else {
(self.current as f32 / self.total as f32) * 100.0
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::assert_matches;
#[test]
fn test_check_version() {
let mut db = StoreWriter::memory().unwrap();
assert_matches!(db.check_version(), Err(Error::OutOfDate));
db.migrate(migrate::ignore).unwrap();
assert_matches!(db.check_version(), Ok(()));
}
#[test]
fn test_migrate_to() {
let mut db = StoreWriter::memory().unwrap();
assert_eq!(db.version().unwrap(), 0);
assert_eq!(db.migrate_to(1, migrate::ignore).unwrap(), 1); // 0 -> 1
assert_eq!(db.version().unwrap(), 1);
assert_eq!(db.migrate_to(2, migrate::ignore).unwrap(), 2); // 1 -> 2
assert_eq!(db.version().unwrap(), 2);
assert_eq!(db.migrate_to(1, migrate::ignore).unwrap(), 2); // No-op.
assert_eq!(db.version().unwrap(), 2);
assert_eq!(db.migrate_to(99, migrate::ignore).unwrap(), 2); // No-op.
assert_eq!(db.version().unwrap(), 2);
}
}

2
radicle/src/cob/cache/migrations.rs vendored Normal file
View File

@ -0,0 +1,2 @@
#[path = "migrations/2.rs"]
pub mod _2;

132
radicle/src/cob/cache/migrations/2.rs vendored Normal file
View File

@ -0,0 +1,132 @@
//! Migration to update the patch `reviews` JSON representation.
use crate::cob::cache::*;
use serde_json as json;
/// Run migration.
pub fn run(
db: &sql::Connection,
migration: &Progress,
callback: &mut dyn MigrateCallback,
) -> Result<usize, Error> {
// Select patches with reviews.
let rows = db
.prepare("SELECT id, patch FROM patches WHERE json_extract(patch, '$.reviews') != '{}'")?
.into_iter()
.collect::<Vec<_>>();
// Query to update a patch to the new schema.
let mut update = db.prepare(
"UPDATE patches
SET patch = ?1
WHERE id = ?2",
)?;
let mut progress = Progress::new(rows.len());
callback.progress(MigrateProgress {
migration,
rows: &progress,
});
for row in rows {
let row = row?;
let id = row.read::<&str, _>("id");
let mut patch = json::from_str::<json::Value>(row.read::<&str, _>("patch"))
.map_err(Error::MalformedJson)?;
let patch = patch.as_object_mut().ok_or(Error::MalformedJsonSchema)?;
let revisions = patch["revisions"]
.as_object_mut()
.ok_or(Error::MalformedJsonSchema)?;
let mut transformed = false;
for (_, r) in revisions.iter_mut() {
let Some(revision) = r.as_object_mut() else {
// Redacted revision (`null`).
continue;
};
let reviews = revision
.get_mut("reviews")
.ok_or(Error::MalformedJsonSchema)?
.as_object_mut()
.ok_or(Error::MalformedJsonSchema)?;
for (_, review) in reviews.iter_mut() {
if let Some(list) = review.as_array_mut() {
if let Some(last) = list.pop() {
*review = last;
transformed = true;
}
}
}
}
if transformed {
let obj = json::to_string(&patch).map_err(Error::MalformedJson)?;
update.reset()?;
update.bind((1, obj.as_str()))?;
update.bind((2, id))?;
update.next()?;
progress.inc();
callback.progress(MigrateProgress {
migration,
rows: &progress,
});
}
}
Ok(progress.current())
}
#[allow(clippy::unwrap_used)]
#[cfg(test)]
mod tests {
use crate::cob::cache::*;
// Before the migration.
const PATCH_V1: &str = include_str!("samples/patch.v1.json");
// After the migration.
const PATCH_V2: &str = include_str!("samples/patch.v2.json");
#[test]
fn test_migration_2() {
let mut db = StoreWriter::memory().unwrap();
db.migrate_to(1, migrate::ignore).unwrap();
db.raw_query(|conn| {
let mut stmt = conn.prepare(
"INSERT INTO patches (id, repo, patch)
VALUES (?1, ?2, ?3)",
)?;
stmt.bind((1, "016a91d2029ee71b9aee8d927664caf1b7885346"))?;
stmt.bind((2, "rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5"))?;
stmt.bind((3, PATCH_V1))?;
stmt.next()?;
Ok::<_, sql::Error>(())
})
.unwrap();
assert_eq!(db.migrate_to(2, migrate::ignore).unwrap(), 2);
let row = db
.raw_query(|conn| {
Ok::<_, sql::Error>(
conn.prepare("SELECT patch FROM patches LIMIT 1")?
.into_iter()
.next()
.unwrap()
.unwrap(),
)
})
.unwrap();
let patch = row.read::<&str, _>("patch");
let actual: serde_json::Value = serde_json::from_str(patch).unwrap();
let expected: serde_json::Value = serde_json::from_str(PATCH_V2).unwrap();
assert_eq!(actual, expected);
}
#[test]
fn test_patch_json_deserialization() {
serde_json::from_str::<crate::cob::patch::Patch>(PATCH_V1).unwrap_err();
serde_json::from_str::<crate::cob::patch::Patch>(PATCH_V2).unwrap();
}
}

View File

@ -0,0 +1,179 @@
{
"title": "Remove loading indicator from Share button",
"author": {
"id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5"
},
"state": {
"status": "merged",
"revision": "7bdb1c8a273ace2a0be4b93e9b1b525dc1696e68",
"commit": "f6a77bec9401c35b1baa19d356405a9d77ed8e0d"
},
"target": "delegates",
"labels": [],
"merges": {},
"revisions": {
"016a91d2029ee71b9aee8d927664caf1b7885346": {
"id": "016a91d2029ee71b9aee8d927664caf1b7885346",
"author": { "id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5" },
"description": [
{
"author": "z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"timestamp": 1710884599000,
"body": "",
"embeds": []
}
],
"base": "daba9fca8281405134d64036bde300f2181f73fe",
"oid": "c24c942261647993fcb0d34224f5c98b0f30266f",
"discussion": {
"comments": {},
"timeline": []
},
"reviews": {},
"timestamp": 1710884599000,
"resolves": [],
"reactions": []
},
"4a98168bfe6e16934b922e7d5df3cf68c7e7d085": {
"id": "4a98168bfe6e16934b922e7d5df3cf68c7e7d085",
"author": { "id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5" },
"description": [
{
"author": "z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"timestamp": 1710939751000,
"body": "Instead of disabeling the Share button, adds a min-width param",
"embeds": []
}
],
"base": "9c65ee97e8f97a2ae130c36b6bbc1071cc31fd93",
"oid": "5dd21bdb51a6a37dc5957d81f63058344ec6b8e5",
"discussion": {
"comments": {},
"timeline": []
},
"reviews": {
"z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx": [
{
"id": "0076749b04e878ca4387bb60cd80a281af2285bb",
"author": { "id": "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx" },
"verdict": "accept",
"summary": null,
"comments": {
"comments": {},
"timeline": []
},
"labels": [],
"timestamp": 1710941558000
},
{
"id": "89d45fb371eb2622ba88188d474347cc526d80bb",
"author": { "id": "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx" },
"verdict": "accept",
"summary": null,
"comments": {
"comments": {},
"timeline": []
},
"labels": [],
"timestamp": 1710947885000
}
]
},
"timestamp": 1710939751000,
"resolves": [],
"reactions": []
},
"6a71b034d661994d188abac920b41e7f9c57d85d": {
"id": "6a71b034d661994d188abac920b41e7f9c57d85d",
"author": { "id": "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx" },
"description": [
{
"author": "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
"timestamp": 1710940872000,
"body": "Rebase.",
"embeds": []
}
],
"base": "cddf8b40fc352eb84885e5adeed65b3abb0639fc",
"oid": "239bc7e5c786ac65d8456164a97bab2419da441a",
"discussion": {
"comments": {},
"timeline": []
},
"reviews": {},
"timestamp": 1710940872000,
"resolves": [],
"reactions": []
},
"7bdb1c8a273ace2a0be4b93e9b1b525dc1696e68": {
"id": "7bdb1c8a273ace2a0be4b93e9b1b525dc1696e68",
"author": { "id": "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx" },
"description": [
{
"author": "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
"timestamp": 1710947911000,
"body": "Rebase.",
"embeds": []
}
],
"base": "fe7cd1b4e52d15c27f89e23d98e8ccad9c3a8bff",
"oid": "f6a77bec9401c35b1baa19d356405a9d77ed8e0d",
"discussion": {
"comments": {},
"timeline": []
},
"reviews": {},
"timestamp": 1710947911000,
"resolves": [],
"reactions": []
},
"eee18b0bb43e8673c3b32f3e151556906b1214f6": {
"id": "eee18b0bb43e8673c3b32f3e151556906b1214f6",
"author": {
"id": "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx"
},
"description": [
{
"author": "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
"timestamp": 1710941119000,
"body": "Rebase.",
"embeds": []
}
],
"base": "cddf8b40fc352eb84885e5adeed65b3abb0639fc",
"oid": "a9bdc6b43df080adb8086427b363590aea17bf82",
"discussion": {
"comments": {},
"timeline": []
},
"reviews": {},
"timestamp": 1710941119000,
"resolves": [],
"reactions": []
}
},
"assignees": [],
"timeline": [
"016a91d2029ee71b9aee8d927664caf1b7885346",
"4a98168bfe6e16934b922e7d5df3cf68c7e7d085",
"6a71b034d661994d188abac920b41e7f9c57d85d",
"eee18b0bb43e8673c3b32f3e151556906b1214f6",
"0076749b04e878ca4387bb60cd80a281af2285bb",
"e5b200b71a7f413db5697d0e1f9ce267a2095a45",
"27c4e9b9e3837adf417eb816bcea8521b563a030",
"89d45fb371eb2622ba88188d474347cc526d80bb",
"7bdb1c8a273ace2a0be4b93e9b1b525dc1696e68",
"c7d8213bab37f40c8cb6668a98ce7292f6d05bf3",
"081559bfae2f729486f50d93600d1dac0f996d81"
],
"reviews": {
"0076749b04e878ca4387bb60cd80a281af2285bb": [
"4a98168bfe6e16934b922e7d5df3cf68c7e7d085",
"z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx"
],
"89d45fb371eb2622ba88188d474347cc526d80bb": [
"4a98168bfe6e16934b922e7d5df3cf68c7e7d085",
"z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx"
]
}
}

View File

@ -0,0 +1,165 @@
{
"title": "Remove loading indicator from Share button",
"author": {
"id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5"
},
"state": {
"status": "merged",
"revision": "7bdb1c8a273ace2a0be4b93e9b1b525dc1696e68",
"commit": "f6a77bec9401c35b1baa19d356405a9d77ed8e0d"
},
"target": "delegates",
"labels": [],
"merges": {},
"revisions": {
"016a91d2029ee71b9aee8d927664caf1b7885346": {
"id": "016a91d2029ee71b9aee8d927664caf1b7885346",
"author": { "id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5" },
"description": [
{
"author": "z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"timestamp": 1710884599000,
"body": "",
"embeds": []
}
],
"base": "daba9fca8281405134d64036bde300f2181f73fe",
"oid": "c24c942261647993fcb0d34224f5c98b0f30266f",
"discussion": {
"comments": {},
"timeline": []
},
"reviews": {},
"timestamp": 1710884599000,
"resolves": [],
"reactions": []
},
"4a98168bfe6e16934b922e7d5df3cf68c7e7d085": {
"id": "4a98168bfe6e16934b922e7d5df3cf68c7e7d085",
"author": { "id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5" },
"description": [
{
"author": "z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
"timestamp": 1710939751000,
"body": "Instead of disabeling the Share button, adds a min-width param",
"embeds": []
}
],
"base": "9c65ee97e8f97a2ae130c36b6bbc1071cc31fd93",
"oid": "5dd21bdb51a6a37dc5957d81f63058344ec6b8e5",
"discussion": {
"comments": {},
"timeline": []
},
"reviews": {
"z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx": {
"id": "89d45fb371eb2622ba88188d474347cc526d80bb",
"author": { "id": "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx" },
"verdict": "accept",
"summary": null,
"comments": {
"comments": {},
"timeline": []
},
"labels": [],
"timestamp": 1710947885000
}
},
"timestamp": 1710939751000,
"resolves": [],
"reactions": []
},
"6a71b034d661994d188abac920b41e7f9c57d85d": {
"id": "6a71b034d661994d188abac920b41e7f9c57d85d",
"author": { "id": "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx" },
"description": [
{
"author": "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
"timestamp": 1710940872000,
"body": "Rebase.",
"embeds": []
}
],
"base": "cddf8b40fc352eb84885e5adeed65b3abb0639fc",
"oid": "239bc7e5c786ac65d8456164a97bab2419da441a",
"discussion": {
"comments": {},
"timeline": []
},
"reviews": {},
"timestamp": 1710940872000,
"resolves": [],
"reactions": []
},
"7bdb1c8a273ace2a0be4b93e9b1b525dc1696e68": {
"id": "7bdb1c8a273ace2a0be4b93e9b1b525dc1696e68",
"author": { "id": "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx" },
"description": [
{
"author": "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
"timestamp": 1710947911000,
"body": "Rebase.",
"embeds": []
}
],
"base": "fe7cd1b4e52d15c27f89e23d98e8ccad9c3a8bff",
"oid": "f6a77bec9401c35b1baa19d356405a9d77ed8e0d",
"discussion": {
"comments": {},
"timeline": []
},
"reviews": {},
"timestamp": 1710947911000,
"resolves": [],
"reactions": []
},
"eee18b0bb43e8673c3b32f3e151556906b1214f6": {
"id": "eee18b0bb43e8673c3b32f3e151556906b1214f6",
"author": {
"id": "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx"
},
"description": [
{
"author": "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
"timestamp": 1710941119000,
"body": "Rebase.",
"embeds": []
}
],
"base": "cddf8b40fc352eb84885e5adeed65b3abb0639fc",
"oid": "a9bdc6b43df080adb8086427b363590aea17bf82",
"discussion": {
"comments": {},
"timeline": []
},
"reviews": {},
"timestamp": 1710941119000,
"resolves": [],
"reactions": []
}
},
"assignees": [],
"timeline": [
"016a91d2029ee71b9aee8d927664caf1b7885346",
"4a98168bfe6e16934b922e7d5df3cf68c7e7d085",
"6a71b034d661994d188abac920b41e7f9c57d85d",
"eee18b0bb43e8673c3b32f3e151556906b1214f6",
"0076749b04e878ca4387bb60cd80a281af2285bb",
"e5b200b71a7f413db5697d0e1f9ce267a2095a45",
"27c4e9b9e3837adf417eb816bcea8521b563a030",
"89d45fb371eb2622ba88188d474347cc526d80bb",
"7bdb1c8a273ace2a0be4b93e9b1b525dc1696e68",
"c7d8213bab37f40c8cb6668a98ce7292f6d05bf3",
"081559bfae2f729486f50d93600d1dac0f996d81"
],
"reviews": {
"0076749b04e878ca4387bb60cd80a281af2285bb": [
"4a98168bfe6e16934b922e7d5df3cf68c7e7d085",
"z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx"
],
"89d45fb371eb2622ba88188d474347cc526d80bb": [
"4a98168bfe6e16934b922e7d5df3cf68c7e7d085",
"z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx"
]
}
}

View File

@ -534,6 +534,7 @@ mod tests {
use radicle_cob::ObjectId;
use crate::cob::cache::{Store, Update, Write};
use crate::cob::migrate;
use crate::cob::thread::Thread;
use crate::issue::{CloseReason, Issue, IssueCounts, IssueId, State};
use crate::test::arbitrary;
@ -542,7 +543,10 @@ mod tests {
use super::{Cache, Issues};
fn memory(store: MockRepository) -> Cache<MockRepository, Store<Write>> {
let cache = Store::<Write>::memory().unwrap();
let cache = Store::<Write>::memory()
.unwrap()
.with_migrations(migrate::ignore)
.unwrap();
Cache { store, cache }
}

View File

@ -2739,6 +2739,8 @@ mod test {
use crate::test::arbitrary::gen;
use crate::test::storage::MockRepository;
use cob::migrate;
#[test]
fn test_json_serialization() {
let edit = Action::Label {
@ -3352,8 +3354,10 @@ mod test {
let branch = checkout.branch_with([("README", b"Hello World!")]);
let mut patches = {
let path = alice.tmp.path().join("cobs.db");
let db = cob::cache::Store::open(path).unwrap();
let mut db = cob::cache::Store::open(path).unwrap();
let store = cob::patch::Patches::open(&*alice.repo).unwrap();
db.migrate(migrate::ignore).unwrap();
cob::patch::Cache::open(store, db)
};
let mut patch = patches

View File

@ -362,7 +362,7 @@ impl Update<Patch> for StoreWriter {
"INSERT INTO patches (id, repo, patch)
VALUES (?1, ?2, ?3)
ON CONFLICT DO UPDATE
SET patch = (?3)",
SET patch = (?3)",
)?;
stmt.bind((1, sql::Value::String(id.to_string())))?;
@ -705,6 +705,7 @@ mod tests {
use radicle_cob::ObjectId;
use crate::cob::cache::{Store, Update, Write};
use crate::cob::migrate;
use crate::cob::thread::{Comment, Thread};
use crate::cob::Author;
use crate::patch::{
@ -718,7 +719,10 @@ mod tests {
use super::{Cache, Patches};
fn memory(store: MockRepository) -> Cache<MockRepository, Store<Write>> {
let cache = Store::<Write>::memory().unwrap();
let cache = Store::<Write>::memory()
.unwrap()
.with_migrations(migrate::ignore)
.unwrap();
Cache { store, cache }
}

View File

@ -20,6 +20,7 @@ use std::{fs, io};
use localtime::LocalTime;
use thiserror::Error;
use crate::cob::migrate;
use crate::crypto::ssh::agent::Agent;
use crate::crypto::ssh::{keystore, Keystore, Passphrase};
use crate::crypto::{PublicKey, Signer};
@ -229,6 +230,10 @@ impl Profile {
config.node.external_addresses.iter(),
)?;
// Migrate COBs cache.
let mut cobs = home.cobs_db_mut()?;
cobs.migrate(migrate::ignore)?;
transport::local::register(storage.clone());
Ok(Profile {
@ -561,71 +566,83 @@ impl Home {
self.database_mut()
}
/// Return a read-only handle for the issues cache.
pub fn issues<'a, R, M>(
&self,
repository: &'a R,
_migrate: M,
) -> Result<cob::issue::Cache<cob::issue::Issues<'a, R>, cob::cache::StoreReader>, Error>
where
M: cob::MigrateCallback,
R: ReadRepository + cob::Store,
{
/// Get read access to the COBs cache.
pub fn cobs_db(&self) -> Result<cob::cache::StoreReader, Error> {
let path = self.cobs().join(cob::cache::COBS_DB_FILE);
let db = cob::cache::Store::reader(path)?;
Ok(db)
}
/// Get write access to the COBs cache.
pub fn cobs_db_mut(&self) -> Result<cob::cache::StoreWriter, Error> {
let path = self.cobs().join(cob::cache::COBS_DB_FILE);
let db = cob::cache::Store::open(path)?;
Ok(db)
}
/// Return a read-only handle for the issues cache.
pub fn issues<'a, R>(
&self,
repository: &'a R,
) -> Result<cob::issue::Cache<cob::issue::Issues<'a, R>, cob::cache::StoreReader>, Error>
where
R: ReadRepository + cob::Store,
{
let db = self.cobs_db()?;
let store = cob::issue::Issues::open(repository)?;
db.check_version()?;
Ok(cob::issue::Cache::reader(store, db))
}
/// Return a read-write handle for the issues cache.
pub fn issues_mut<'a, R, M>(
pub fn issues_mut<'a, R>(
&self,
repository: &'a R,
_migrate: M,
) -> Result<cob::issue::Cache<cob::issue::Issues<'a, R>, cob::cache::StoreWriter>, Error>
where
M: cob::MigrateCallback,
R: ReadRepository + cob::Store,
{
let path = self.cobs().join(cob::cache::COBS_DB_FILE);
let db = cob::cache::Store::open(path)?;
let db = self.cobs_db_mut()?;
let store = cob::issue::Issues::open(repository)?;
db.check_version()?;
Ok(cob::issue::Cache::open(store, db))
}
/// Return a read-only handle for the patches cache.
pub fn patches<'a, R, M>(
pub fn patches<'a, R>(
&self,
repository: &'a R,
_migrate: M,
) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, cob::cache::StoreReader>, Error>
where
M: cob::MigrateCallback,
R: ReadRepository + cob::Store,
{
let path = self.cobs().join(cob::cache::COBS_DB_FILE);
let db = cob::cache::Store::reader(path)?;
let db = self.cobs_db()?;
let store = cob::patch::Patches::open(repository)?;
db.check_version()?;
Ok(cob::patch::Cache::reader(store, db))
}
/// Return a read-write handle for the patches cache.
pub fn patches_mut<'a, R, M>(
pub fn patches_mut<'a, R>(
&self,
repository: &'a R,
_migrate: M,
) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, cob::cache::StoreWriter>, Error>
where
M: cob::MigrateCallback,
R: ReadRepository + cob::Store,
{
let path = self.cobs().join(cob::cache::COBS_DB_FILE);
let db = cob::cache::Store::open(path)?;
let db = self.cobs_db_mut()?;
let store = cob::patch::Patches::open(repository)?;
db.check_version()?;
Ok(cob::patch::Cache::open(store, db))
}
}