Skip to content

Update owner details more fully during build and add CLI command to trigger it #917

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jul 31, 2020
41 changes: 20 additions & 21 deletions src/bin/cratesfyi.rs
Original file line number Diff line number Diff line change
@@ -4,6 +4,7 @@ use std::path::PathBuf;
use std::sync::Arc;

use cratesfyi::db::{self, add_path_into_database, Pool};
use cratesfyi::index::Index;
use cratesfyi::utils::{remove_crate_priority, set_crate_priority};
use cratesfyi::{
BuildQueue, Config, DocBuilder, DocBuilderOptions, RustwideBuilder, Server, Storage,
@@ -241,22 +242,6 @@ impl PrioritySubcommand {
#[derive(Debug, Clone, PartialEq, Eq, StructOpt)]
#[structopt(rename_all = "kebab-case")]
struct Build {
#[structopt(
name = "PREFIX",
short = "P",
long = "prefix",
env = "CRATESFYI_PREFIX"
)]
prefix: PathBuf,

/// Sets the registry index path, where on disk the registry index will be cloned to
#[structopt(
name = "REGISTRY_INDEX_PATH",
long = "registry-index-path",
alias = "crates-io-index-path"
)]
registry_index_path: Option<PathBuf>,

/// Skips building documentation if documentation exists
#[structopt(name = "SKIP_IF_EXISTS", short = "s", long = "skip")]
skip_if_exists: bool,
@@ -280,11 +265,9 @@ struct Build {
impl Build {
pub fn handle_args(self, ctx: Context) -> Result<(), Error> {
let docbuilder = {
let mut doc_options = DocBuilderOptions::from_prefix(self.prefix);

if let Some(registry_index_path) = self.registry_index_path {
doc_options.registry_index_path = registry_index_path;
}
let config = ctx.config()?;
let mut doc_options =
DocBuilderOptions::new(config.prefix.clone(), config.registry_index_path.clone());

doc_options.skip_if_exists = self.skip_if_exists;
doc_options.skip_if_log_exists = self.skip_if_log_exists;
@@ -435,6 +418,12 @@ enum DatabaseSubcommand {
/// Updates github stats for crates.
UpdateGithubFields,

/// Updates info for a crate from the registry's API
UpdateCrateRegistryFields {
#[structopt(name = "CRATE")]
name: String,
},

AddDirectory {
/// Path of file or directory
#[structopt(name = "DIRECTORY")]
@@ -472,6 +461,16 @@ impl DatabaseSubcommand {
.update_all_crates()?;
}

Self::UpdateCrateRegistryFields { name } => {
let index = Index::new(&ctx.config()?.registry_index_path)?;

db::update_crate_data_in_database(
&*ctx.conn()?,
&name,
&index.api().get_crate_data(&name)?,
)?;
}

Self::AddDirectory { directory, prefix } => {
add_path_into_database(&*ctx.storage()?, &prefix, directory)
.context("Failed to add directory into database")?;
9 changes: 9 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
use failure::{bail, format_err, Error, Fail, ResultExt};
use std::env::VarError;
use std::path::PathBuf;
use std::str::FromStr;

#[derive(Debug)]
pub struct Config {
// Build params
pub(crate) build_attempts: u16,

pub prefix: PathBuf,
pub registry_index_path: PathBuf,

// Database connection params
pub(crate) database_url: String,
pub(crate) max_pool_size: u32,
@@ -23,9 +27,14 @@ pub struct Config {

impl Config {
pub fn from_env() -> Result<Self, Error> {
let prefix: PathBuf = require_env("CRATESFYI_PREFIX")?;

Ok(Self {
build_attempts: env("DOCSRS_BUILD_ATTEMPTS", 5)?,

prefix: prefix.clone(),
registry_index_path: env("REGISTRY_INDEX_PATH", prefix.join("crates.io-index"))?,

database_url: require_env("CRATESFYI_DATABASE_URL")?,
max_pool_size: env("DOCSRS_MAX_POOL_SIZE", 90)?,
min_pool_idle: env("DOCSRS_MIN_POOL_IDLE", 10)?,
98 changes: 77 additions & 21 deletions src/db/add_package.rs
Original file line number Diff line number Diff line change
@@ -7,11 +7,11 @@ use std::{
use crate::{
docbuilder::BuildResult,
error::Result,
index::api::{CrateOwner, RegistryCrateData},
index::api::{CrateData, CrateOwner, ReleaseData},
storage::CompressionAlgorithm,
utils::MetadataPackage,
};
use log::debug;
use log::{debug, info};
use postgres::Connection;
use regex::Regex;
use serde_json::Value;
@@ -32,7 +32,7 @@ pub(crate) fn add_package_into_database(
default_target: &str,
source_files: Option<Value>,
doc_targets: Vec<String>,
registry_data: &RegistryCrateData,
registry_data: &ReleaseData,
has_docs: bool,
has_examples: bool,
compression_algorithms: std::collections::HashSet<CompressionAlgorithm>,
@@ -117,7 +117,6 @@ pub(crate) fn add_package_into_database(

add_keywords_into_database(&conn, &metadata_pkg, release_id)?;
add_authors_into_database(&conn, &metadata_pkg, release_id)?;
add_owners_into_database(&conn, &registry_data.owners, crate_id)?;
add_compression_into_database(&conn, compression_algorithms.into_iter(), release_id)?;

// Update the crates table with the new release
@@ -328,31 +327,88 @@ fn add_authors_into_database(
Ok(())
}

pub fn update_crate_data_in_database(
conn: &Connection,
name: &str,
registry_data: &CrateData,
) -> Result<()> {
info!("Updating crate data for {}", name);
let crate_id = conn
.query("SELECT id FROM crates WHERE crates.name = $1", &[&name])?
.get(0)
.get(0);

update_owners_in_database(conn, &registry_data.owners, crate_id)?;

Ok(())
}

/// Adds owners into database
fn add_owners_into_database(conn: &Connection, owners: &[CrateOwner], crate_id: i32) -> Result<()> {
fn update_owners_in_database(
conn: &Connection,
owners: &[CrateOwner],
crate_id: i32,
) -> Result<()> {
let rows = conn.query(
"
SELECT login
FROM owners
INNER JOIN owner_rels
ON owner_rels.oid = owners.id
WHERE owner_rels.cid = $1
",
&[&crate_id],
)?;
let existing_owners = rows.into_iter().map(|row| -> String { row.get(0) });

for owner in owners {
debug!("Updating owner data for {}: {:?}", owner.login, owner);

// Update any existing owner data since it is mutable and could have changed since last
// time we pulled it
let owner_id: i32 = {
let rows = conn.query("SELECT id FROM owners WHERE login = $1", &[&owner.login])?;
if !rows.is_empty() {
rows.get(0).get(0)
} else {
conn.query(
"INSERT INTO owners (login, avatar, name, email)
VALUES ($1, $2, $3, $4)
RETURNING id",
&[&owner.login, &owner.avatar, &owner.name, &owner.email],
)?
.get(0)
.get(0)
}
conn.query(
"
INSERT INTO owners (login, avatar, name, email)
VALUES ($1, $2, $3, $4)
ON CONFLICT (login) DO UPDATE
SET
avatar = $2,
name = $3,
email = $4
RETURNING id
",
&[&owner.login, &owner.avatar, &owner.name, &owner.email],
)?
.get(0)
.get(0)
};

// add relationship
let _ = conn.query(
"INSERT INTO owner_rels (cid, oid) VALUES ($1, $2)",
conn.query(
"INSERT INTO owner_rels (cid, oid) VALUES ($1, $2) ON CONFLICT DO NOTHING",
&[&crate_id, &owner_id],
);
)?;
}

let to_remove =
existing_owners.filter(|login| !owners.iter().any(|owner| &owner.login == login));

for login in to_remove {
debug!("Removing owner relationship {}", login);
// remove relationship
conn.query(
"
DELETE FROM owner_rels
USING owners
WHERE owner_rels.cid = $1
AND owner_rels.oid = owners.id
AND owners.login = $2
",
&[&crate_id, &login],
)?;
}

Ok(())
}

4 changes: 2 additions & 2 deletions src/db/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Database operations
pub(crate) use self::add_package::add_build_into_database;
pub(crate) use self::add_package::add_package_into_database;
pub use self::add_package::update_crate_data_in_database;
pub(crate) use self::add_package::{add_build_into_database, add_package_into_database};
pub use self::delete::{delete_crate, delete_version};
pub use self::file::add_path_into_database;
pub use self::migrate::migrate;
47 changes: 3 additions & 44 deletions src/docbuilder/options.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
use crate::error::Result;
use std::path::PathBuf;
use std::{env, fmt};

#[derive(Clone)]
#[derive(Clone, Debug)]
pub struct DocBuilderOptions {
pub keep_build_directory: bool,
pub prefix: PathBuf,
@@ -14,12 +13,8 @@ pub struct DocBuilderOptions {
pub debug: bool,
}

impl Default for DocBuilderOptions {
fn default() -> DocBuilderOptions {
let cwd = env::current_dir().unwrap();

let (prefix, registry_index_path) = generate_paths(cwd);

impl DocBuilderOptions {
pub fn new(prefix: PathBuf, registry_index_path: PathBuf) -> DocBuilderOptions {
DocBuilderOptions {
prefix,
registry_index_path,
@@ -32,36 +27,6 @@ impl Default for DocBuilderOptions {
debug: false,
}
}
}

impl fmt::Debug for DocBuilderOptions {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"DocBuilderOptions {{ \
registry_index_path: {:?}, \
keep_build_directory: {:?}, skip_if_exists: {:?}, \
skip_if_log_exists: {:?}, debug: {:?} }}",
self.registry_index_path,
self.keep_build_directory,
self.skip_if_exists,
self.skip_if_log_exists,
self.debug
)
}
}

impl DocBuilderOptions {
/// Creates new DocBuilderOptions from prefix
pub fn from_prefix(prefix: PathBuf) -> DocBuilderOptions {
let (prefix, registry_index_path) = generate_paths(prefix);

DocBuilderOptions {
prefix,
registry_index_path,
..Default::default()
}
}

pub fn check_paths(&self) -> Result<()> {
if !self.registry_index_path.exists() {
@@ -74,9 +39,3 @@ impl DocBuilderOptions {
Ok(())
}
}

fn generate_paths(prefix: PathBuf) -> (PathBuf, PathBuf) {
let registry_index_path = PathBuf::from(&prefix).join("crates.io-index");

(prefix, registry_index_path)
}
23 changes: 21 additions & 2 deletions src/docbuilder/rustwide_builder.rs
Original file line number Diff line number Diff line change
@@ -2,9 +2,12 @@ use super::DocBuilder;
use super::Metadata;
use crate::db::blacklist::is_blacklisted;
use crate::db::file::add_path_into_database;
use crate::db::{add_build_into_database, add_package_into_database, Pool};
use crate::db::{
add_build_into_database, add_package_into_database, update_crate_data_in_database, Pool,
};
use crate::docbuilder::{crates::crates_from_path, Limits};
use crate::error::Result;
use crate::index::api::ReleaseData;
use crate::storage::CompressionAlgorithms;
use crate::storage::Storage;
use crate::utils::{copy_doc_dir, parse_rustc_version, CargoMetadata};
@@ -397,6 +400,15 @@ impl RustwideBuilder {
} else {
crate::web::metrics::NON_LIBRARY_BUILDS.inc();
}

let release_data = match doc_builder.index.api().get_release_data(name, version) {
Ok(data) => data,
Err(err) => {
warn!("{:#?}", err);
ReleaseData::default()
}
};

let release_id = add_package_into_database(
&conn,
res.cargo_metadata.root(),
@@ -405,13 +417,20 @@ impl RustwideBuilder {
&res.target,
files_list,
successful_targets,
&doc_builder.index.api().get_crate_data(name, version),
&release_data,
has_docs,
has_examples,
algs,
)?;

add_build_into_database(&conn, release_id, &res.result)?;

// Some crates.io crate data is mutable, so we proactively update it during a release
match doc_builder.index.api().get_crate_data(name) {
Ok(crate_data) => update_crate_data_in_database(&conn, name, &crate_data)?,
Err(err) => warn!("{:#?}", err),
}

doc_builder.add_to_cache(name, version);
Ok(res)
})?;
53 changes: 34 additions & 19 deletions src/index/api.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
use chrono::{DateTime, Utc};
use failure::err_msg;
use log::warn;
use failure::{err_msg, ResultExt};
use reqwest::header::{HeaderValue, ACCEPT, USER_AGENT};
use semver::Version;
use serde::Deserialize;
@@ -14,19 +13,36 @@ const APP_USER_AGENT: &str = concat!(
include_str!(concat!(env!("OUT_DIR"), "/git_version"))
);

pub(crate) struct Api {
#[derive(Debug)]
pub struct Api {
api_base: Option<Url>,
client: reqwest::blocking::Client,
}

pub(crate) struct RegistryCrateData {
#[derive(Debug)]
pub struct CrateData {
pub(crate) owners: Vec<CrateOwner>,
}

#[derive(Debug)]
pub(crate) struct ReleaseData {
pub(crate) release_time: DateTime<Utc>,
pub(crate) yanked: bool,
pub(crate) downloads: i32,
pub(crate) owners: Vec<CrateOwner>,
}

pub(crate) struct CrateOwner {
impl Default for ReleaseData {
fn default() -> ReleaseData {
ReleaseData {
release_time: Utc::now(),
yanked: false,
downloads: 0,
}
}
}

#[derive(Debug)]
pub struct CrateOwner {
pub(crate) avatar: String,
pub(crate) email: String,
pub(crate) login: String,
@@ -55,25 +71,24 @@ impl Api {
.ok_or_else(|| err_msg("index is missing an api base url"))
}

pub(crate) fn get_crate_data(&self, name: &str, version: &str) -> RegistryCrateData {
pub fn get_crate_data(&self, name: &str) -> Result<CrateData> {
let owners = self
.get_owners(name)
.context(format!("Failed to get owners for {}", name))?;

Ok(CrateData { owners })
}

pub(crate) fn get_release_data(&self, name: &str, version: &str) -> Result<ReleaseData> {
let (release_time, yanked, downloads) = self
.get_release_time_yanked_downloads(name, version)
.unwrap_or_else(|err| {
warn!("Failed to get crate data for {}-{}: {}", name, version, err);
(Utc::now(), false, 0)
});

let owners = self.get_owners(name).unwrap_or_else(|err| {
warn!("Failed to get owners for {}-{}: {}", name, version, err);
Vec::new()
});
.context(format!("Failed to get crate data for {}-{}", name, version))?;

RegistryCrateData {
Ok(ReleaseData {
release_time,
yanked,
downloads,
owners,
}
})
}

/// Get release_time, yanked and downloads from the registry's API
6 changes: 3 additions & 3 deletions src/index/mod.rs
Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@ use failure::ResultExt;

pub(crate) mod api;

pub(crate) struct Index {
pub struct Index {
path: PathBuf,
api: Api,
}
@@ -39,7 +39,7 @@ fn load_config(repo: &git2::Repository) -> Result<IndexConfig> {
}

impl Index {
pub(crate) fn new(path: impl AsRef<Path>) -> Result<Self> {
pub fn new(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref().to_owned();
// This initializes the repository, then closes it afterwards to avoid leaking file descriptors.
// See https://github.com/rust-lang/docs.rs/pull/847
@@ -56,7 +56,7 @@ impl Index {
Ok(diff)
}

pub(crate) fn api(&self) -> &Api {
pub fn api(&self) -> &Api {
&self.api
}
}
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
@@ -15,7 +15,7 @@ mod config;
pub mod db;
mod docbuilder;
mod error;
mod index;
pub mod index;
pub mod storage;
#[cfg(test)]
mod test;
27 changes: 19 additions & 8 deletions src/test/fakes.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use super::TestDatabase;
use crate::docbuilder::BuildResult;
use crate::index::api::RegistryCrateData;
use crate::index::api::{CrateData, CrateOwner, ReleaseData};
use crate::storage::Storage;
use crate::utils::{Dependency, MetadataPackage, Target};
use chrono::{DateTime, Utc};
@@ -19,7 +19,8 @@ pub(crate) struct FakeRelease<'a> {
rustdoc_files: Vec<(&'a str, &'a [u8])>,
doc_targets: Vec<String>,
default_target: Option<&'a str>,
registry_crate_data: RegistryCrateData,
registry_crate_data: CrateData,
registry_release_data: ReleaseData,
has_docs: bool,
has_examples: bool,
/// This stores the content, while `package.readme` stores the filename
@@ -60,11 +61,11 @@ impl<'a> FakeRelease<'a> {
rustdoc_files: Vec::new(),
doc_targets: Vec::new(),
default_target: None,
registry_crate_data: RegistryCrateData {
registry_crate_data: CrateData { owners: Vec::new() },
registry_release_data: ReleaseData {
release_time: Utc::now(),
yanked: false,
downloads: 0,
owners: Vec::new(),
},
has_docs: true,
has_examples: false,
@@ -73,7 +74,7 @@ impl<'a> FakeRelease<'a> {
}

pub(crate) fn downloads(mut self, downloads: i32) -> Self {
self.registry_crate_data.downloads = downloads;
self.registry_release_data.downloads = downloads;
self
}

@@ -83,7 +84,7 @@ impl<'a> FakeRelease<'a> {
}

pub(crate) fn release_time(mut self, new: DateTime<Utc>) -> Self {
self.registry_crate_data.release_time = new;
self.registry_release_data.release_time = new;
self
}

@@ -116,7 +117,7 @@ impl<'a> FakeRelease<'a> {
}

pub(crate) fn yanked(mut self, new: bool) -> Self {
self.registry_crate_data.yanked = new;
self.registry_release_data.yanked = new;
self
}

@@ -166,6 +167,11 @@ impl<'a> FakeRelease<'a> {
self.source_file("README.md", content.as_bytes())
}

pub(crate) fn add_owner(mut self, owner: CrateOwner) -> Self {
self.registry_crate_data.owners.push(owner);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm ... now I wonder if owners should be a set instead. I guess it doesn't really matter either way since we're using UPSERT.

self
}

/// Returns the release_id
pub(crate) fn create(self) -> Result<i32, Error> {
use std::collections::HashSet;
@@ -249,11 +255,16 @@ impl<'a> FakeRelease<'a> {
self.default_target.unwrap_or("x86_64-unknown-linux-gnu"),
source_meta,
self.doc_targets,
&self.registry_crate_data,
&self.registry_release_data,
self.has_docs,
self.has_examples,
algs,
)?;
crate::db::update_crate_data_in_database(
&db.conn(),
&package.name,
&self.registry_crate_data,
)?;
crate::db::add_build_into_database(&db.conn(), release_id, &self.build_result)?;

Ok(release_id)
40 changes: 15 additions & 25 deletions src/utils/daemon.rs
Original file line number Diff line number Diff line change
@@ -11,20 +11,23 @@ use crate::{
use chrono::{Timelike, Utc};
use failure::Error;
use log::{debug, error, info};
use std::path::PathBuf;
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use std::{env, thread};

fn start_registry_watcher(pool: Pool, build_queue: Arc<BuildQueue>) -> Result<(), Error> {
fn start_registry_watcher(
opts: DocBuilderOptions,
pool: Pool,
build_queue: Arc<BuildQueue>,
) -> Result<(), Error> {
thread::Builder::new()
.name("registry index reader".to_string())
.spawn(move || {
// space this out to prevent it from clashing against the queue-builder thread on launch
thread::sleep(Duration::from_secs(30));
loop {
let opts = opts();
let mut doc_builder = DocBuilder::new(opts, pool.clone(), build_queue.clone());
let mut doc_builder =
DocBuilder::new(opts.clone(), pool.clone(), build_queue.clone());

if doc_builder.is_locked() {
debug!("Lock file exists, skipping checking new crates");
@@ -50,23 +53,14 @@ pub fn start_daemon(
storage: Arc<Storage>,
enable_registry_watcher: bool,
) -> Result<(), Error> {
const CRATE_VARIABLES: &[&str] = &["CRATESFYI_PREFIX"];

// first check required environment variables
for v in CRATE_VARIABLES.iter() {
if env::var(v).is_err() {
panic!("Environment variable {} not found", v)
}
}

let dbopts = opts();
let dbopts = DocBuilderOptions::new(config.prefix.clone(), config.registry_index_path.clone());

// check paths once
dbopts.check_paths().unwrap();

if enable_registry_watcher {
// check new crates every minute
start_registry_watcher(db.clone(), build_queue.clone())?;
start_registry_watcher(dbopts.clone(), db.clone(), build_queue.clone())?;
}

// build new crates every minute
@@ -76,8 +70,11 @@ pub fn start_daemon(
thread::Builder::new()
.name("build queue reader".to_string())
.spawn(move || {
let doc_builder =
DocBuilder::new(opts(), cloned_db.clone(), cloned_build_queue.clone());
let doc_builder = DocBuilder::new(
dbopts.clone(),
cloned_db.clone(),
cloned_build_queue.clone(),
);
queue_builder(doc_builder, cloned_db, cloned_build_queue, cloned_storage).unwrap();
})
.unwrap();
@@ -131,10 +128,3 @@ where
})?;
Ok(())
}

fn opts() -> DocBuilderOptions {
let prefix = PathBuf::from(
env::var("CRATESFYI_PREFIX").expect("CRATESFYI_PREFIX environment variable not found"),
);
DocBuilderOptions::from_prefix(prefix)
}
92 changes: 92 additions & 0 deletions src/web/crate_details.rs
Original file line number Diff line number Diff line change
@@ -315,6 +315,7 @@ pub fn crate_details_handler(req: &mut Request) -> IronResult<Response> {
#[cfg(test)]
mod tests {
use super::*;
use crate::index::api::CrateOwner;
use crate::test::{wrapper, TestDatabase};
use failure::Error;
use kuchiki::traits::TendrilSink;
@@ -610,4 +611,95 @@ mod tests {
Ok(())
});
}

#[test]
fn test_updating_owners() {
wrapper(|env| {
let db = env.db();

env.fake_release()
.name("foo")
.version("0.0.1")
.add_owner(CrateOwner {
login: "foobar".into(),
avatar: "https://example.org/foobar".into(),
name: "Foo Bar".into(),
email: "foobar@example.org".into(),
})
.create()?;

let details = CrateDetails::new(&db.conn(), "foo", "0.0.1").unwrap();
assert_eq!(
details.owners,
vec![("foobar".into(), "https://example.org/foobar".into())]
);

// Adding a new owner, and changing details on an existing owner
env.fake_release()
.name("foo")
.version("0.0.2")
.add_owner(CrateOwner {
login: "foobar".into(),
avatar: "https://example.org/foobarv2".into(),
name: "Foo Bar".into(),
email: "foobar@example.org".into(),
})
.add_owner(CrateOwner {
login: "barfoo".into(),
avatar: "https://example.org/barfoo".into(),
name: "Bar Foo".into(),
email: "foobar@example.org".into(),
})
.create()?;

let details = CrateDetails::new(&db.conn(), "foo", "0.0.1").unwrap();
let mut owners = details.owners.clone();
owners.sort();
assert_eq!(
owners,
vec![
("barfoo".into(), "https://example.org/barfoo".into()),
("foobar".into(), "https://example.org/foobarv2".into())
]
);

// Removing an existing owner
env.fake_release()
.name("foo")
.version("0.0.3")
.add_owner(CrateOwner {
login: "barfoo".into(),
avatar: "https://example.org/barfoo".into(),
name: "Bar Foo".into(),
email: "foobar@example.org".into(),
})
.create()?;

let details = CrateDetails::new(&db.conn(), "foo", "0.0.1").unwrap();
assert_eq!(
details.owners,
vec![("barfoo".into(), "https://example.org/barfoo".into())]
);

// Changing owner details on another of their crates applies the change to both
env.fake_release()
.name("bar")
.version("0.0.1")
.add_owner(CrateOwner {
login: "barfoo".into(),
avatar: "https://example.org/barfoov2".into(),
name: "Bar Foo".into(),
email: "foobar@example.org".into(),
})
.create()?;

let details = CrateDetails::new(&db.conn(), "foo", "0.0.1").unwrap();
assert_eq!(
details.owners,
vec![("barfoo".into(), "https://example.org/barfoov2".into())]
);

Ok(())
});
}
}