diff --git a/app/components/badge-build-info.js b/app/components/badge-build-info.js new file mode 100644 index 00000000000..2fe3efbaef6 --- /dev/null +++ b/app/components/badge-build-info.js @@ -0,0 +1,41 @@ +import Ember from 'ember'; + +import { formatDay } from 'cargo/helpers/format-day'; + +export default Ember.Component.extend({ + tagName: 'span', + classNames: ['build_info'], + + build_info: Ember.computed('crate.max_build_info_stable', 'crate.max_build_info_beta', 'crate.max_build_info_nightly', function() { + if (this.get('crate.max_build_info_stable')) { + return 'stable'; + } else if (this.get('crate.max_build_info_beta')) { + return 'beta'; + } else if (this.get('crate.max_build_info_nightly')) { + return 'nightly'; + } else { + return null; + } + }), + color: Ember.computed('build_info', function() { + if (this.get('build_info') === 'stable') { + return 'brightgreen'; + } else if (this.get('build_info') === 'beta') { + return 'yellow'; + } else { + return 'orange'; + } + }), + version_display: Ember.computed('build_info', 'crate.max_build_info_stable', 'crate.max_build_info_beta', 'crate.max_build_info_nightly', function() { + if (this.get('build_info') === 'stable') { + return this.get('crate.max_build_info_stable'); + } else if (this.get('build_info') === 'beta') { + return formatDay(this.get('crate.max_build_info_beta')); + } else { + return formatDay(this.get('crate.max_build_info_nightly')); + } + }), + version_for_shields: Ember.computed('version_display', function() { + return this.get('version_display').replace(/-/g, '--'); + }), +}); diff --git a/app/helpers/format-build-result.js b/app/helpers/format-build-result.js new file mode 100644 index 00000000000..948862ae99c --- /dev/null +++ b/app/helpers/format-build-result.js @@ -0,0 +1,13 @@ +import Ember from 'ember'; + +export function formatBuildResult(result) { + if (result === true) { + return 'Pass'; + } else if (result === false) { + return 'Fail'; + } else { + return null; + } +} + +export default Ember.Helper.helper(params => formatBuildResult(params[0])); diff --git a/app/helpers/format-day.js b/app/helpers/format-day.js new file mode 100644 index 00000000000..33c2d80b102 --- /dev/null +++ b/app/helpers/format-day.js @@ -0,0 +1,8 @@ +import Ember from 'ember'; +import moment from 'moment'; + +export function formatDay(date) { + return date ? moment(date).utc().format('YYYY-MM-DD') : null; +} + +export default Ember.Helper.helper(params => formatDay(params[0])); diff --git a/app/helpers/value-or-default.js b/app/helpers/value-or-default.js new file mode 100644 index 00000000000..7dda85e77aa --- /dev/null +++ b/app/helpers/value-or-default.js @@ -0,0 +1,7 @@ +import Ember from 'ember'; + +export function valueOrDefault(value, default_value) { + return value ? value : default_value; +} + +export default Ember.Helper.helper(params => valueOrDefault(params[0], params[1])); diff --git a/app/models/build-info.js b/app/models/build-info.js new file mode 100644 index 00000000000..ebfb9367df9 --- /dev/null +++ b/app/models/build-info.js @@ -0,0 +1,46 @@ +import DS from 'ember-data'; +import Ember from 'ember'; + +const TIER1 = [ + ['x86_64-unknown-linux-gnu', 'Linux'], + ['x86_64-apple-darwin', 'macOS'], + ['x86_64-pc-windows-gnu', 'Windows (GNU)'], + ['x86_64-pc-windows-msvc', 'Windows (MSVC)'], +]; + +const last = name => ( + Ember.computed(name, function() { + const items = this.get(name); + return items[items.length - 1]; + }) +); + +export default DS.Model.extend({ + version: DS.belongsTo('version', { async: true }), + ordering: DS.attr(), + stable: DS.attr(), + beta: DS.attr(), + nightly: DS.attr(), + + has_any_info: Ember.computed('ordering', function() { + const ordering = this.get('ordering'); + const num_results = ordering.stable.length + ordering.nightly.length + ordering.beta.length; + return num_results > 0; + }), + + latest_stable: last('ordering.stable'), + latest_beta: last('ordering.beta'), + latest_nightly: last('ordering.nightly'), + tier1_results: Ember.computed('nightly', 'latest_nightly', 'beta', 'latest_beta', 'stable', 'latest_stable', function() { + const nightly_results = this.get('nightly')[this.get('latest_nightly')] || {}; + const beta_results = this.get('beta')[this.get('latest_beta')] || {}; + const stable_results = this.get('stable')[this.get('latest_stable')] || {}; + + return TIER1.map(([target, display]) => ({ + display_target: display, + nightly: nightly_results[target], + beta: beta_results[target], + stable: stable_results[target] + })); + }), +}); diff --git a/app/models/crate.js b/app/models/crate.js index e5c07f40af8..ceb5cf106b4 100644 --- a/app/models/crate.js +++ b/app/models/crate.js @@ -16,6 +16,9 @@ export default DS.Model.extend({ documentation: DS.attr('string'), repository: DS.attr('string'), license: DS.attr('string'), + max_build_info_nightly: DS.attr('date'), + max_build_info_beta: DS.attr('date'), + max_build_info_stable: DS.attr('string'), versions: DS.hasMany('versions', { async: true }), badges: DS.attr(), diff --git a/app/models/version.js b/app/models/version.js index 0876d3cdf89..53a7cdd62f9 100644 --- a/app/models/version.js +++ b/app/models/version.js @@ -12,6 +12,7 @@ export default DS.Model.extend({ async: false }), authors: DS.hasMany('users', { async: true }), + build_info: DS.belongsTo('build-info', { async: true }), dependencies: DS.hasMany('dependency', { async: true }), version_downloads: DS.hasMany('version-download', { async: true }), diff --git a/app/styles/crate.scss b/app/styles/crate.scss index 05e2b453280..88249a9872c 100644 --- a/app/styles/crate.scss +++ b/app/styles/crate.scss @@ -256,6 +256,25 @@ } } +#crate-build-info { + padding-bottom: 50px; + border-bottom: 5px solid $gray-border; + margin-bottom: 30px; + + .description { + margin-bottom: 30px; + } + + table { + border: 1px solid $gray-border; + td, th { + border: 1px solid $gray-border; + padding: 5px 10px; + text-align: left; + } + } +} + #crate-downloads { @include display-flex; @include flex-wrap(wrap); diff --git a/app/templates/components/badge-build-info.hbs b/app/templates/components/badge-build-info.hbs new file mode 100644 index 00000000000..8d51aedd7e8 --- /dev/null +++ b/app/templates/components/badge-build-info.hbs @@ -0,0 +1,6 @@ +{{#if build_info}} +Known to build on {{ build_info }} {{ version_display }} +{{/if}} \ No newline at end of file diff --git a/app/templates/components/crate-row.hbs b/app/templates/components/crate-row.hbs index 80c27e3e65e..d277fb93e10 100644 --- a/app/templates/components/crate-row.hbs +++ b/app/templates/components/crate-row.hbs @@ -10,6 +10,7 @@ {{#each crate.annotated_badges as |badge|}} {{component badge.component_name badge=badge}} {{/each}} + {{badge-build-info crate=crate}}
diff --git a/app/templates/crate/version.hbs b/app/templates/crate/version.hbs index 30dccfaf25f..cdf5f443cd1 100644 --- a/app/templates/crate/version.hbs +++ b/app/templates/crate/version.hbs @@ -217,6 +217,40 @@
+
+

Build info

+ + {{#if currentVersion.build_info.has_any_info }} +
+ Most recent reported build information for + version {{ currentVersion.num }}: +
+ + + + + + + + + + {{#each currentVersion.build_info.tier1_results as |result|}} + + + + + + + {{/each}} +
Tier 1 TargetStable: {{ value-or-default currentVersion.build_info.latest_stable 'None' }}Beta: {{ value-or-default (format-day currentVersion.build_info.latest_beta) 'None' }}Nightly: {{ value-or-default (format-day currentVersion.build_info.latest_nightly) 'None' }}
{{ result.display_target }}{{ format-build-result result.stable }}{{ format-build-result result.beta }}{{ format-build-result result.nightly }}
+ {{else}} +
+ No build information available for this version. If you are the + crate owner... // TODO LINK TO DOCS +
+ {{/if}} +
+

Stats Overview

diff --git a/src/bin/migrate.rs b/src/bin/migrate.rs index ce635d3db5a..07e14140bbf 100644 --- a/src/bin/migrate.rs +++ b/src/bin/migrate.rs @@ -831,6 +831,31 @@ fn migrations() -> Vec { try!(tx.execute("DROP INDEX badges_crate_type", &[])); Ok(()) }), + Migration::add_table(20170127104519, "build_info", " \ + version_id INTEGER NOT NULL, \ + rust_version VARCHAR NOT NULL, \ + target VARCHAR NOT NULL, \ + passed BOOLEAN NOT NULL, \ + created_at TIMESTAMP NOT NULL DEFAULT now(), \ + updated_at TIMESTAMP NOT NULL DEFAULT now()"), + Migration::new(20170127104925, |tx| { + try!(tx.execute("CREATE UNIQUE INDEX build_info_combo \ + ON build_info (version_id, rust_version, target)", &[])); + Ok(()) + }, |tx| { + try!(tx.execute("DROP INDEX build_info_combo", &[])); + Ok(()) + }), + Migration::run(20170127143020, + "ALTER TABLE crates \ + ADD COLUMN max_build_info_stable VARCHAR, \ + ADD COLUMN max_build_info_beta TIMESTAMP, \ + ADD COLUMN max_build_info_nightly TIMESTAMP", + "ALTER TABLE crates \ + DROP COLUMN max_build_info_stable, \ + DROP COLUMN max_build_info_beta, \ + DROP COLUMN max_build_info_nightly", + ), ]; // NOTE: Generate a new id via `date +"%Y%m%d%H%M%S"` diff --git a/src/krate.rs b/src/krate.rs index f0636beda4a..32a3262277b 100644 --- a/src/krate.rs +++ b/src/krate.rs @@ -52,6 +52,9 @@ pub struct Crate { pub license: Option, pub repository: Option, pub max_upload_size: Option, + pub max_build_info_stable: Option, + pub max_build_info_beta: Option, + pub max_build_info_nightly: Option, } #[derive(RustcEncodable, RustcDecodable)] @@ -66,6 +69,9 @@ pub struct EncodableCrate { pub created_at: String, pub downloads: i32, pub max_version: String, + pub max_build_info_stable: Option, + pub max_build_info_beta: Option, + pub max_build_info_nightly: Option, pub description: Option, pub homepage: Option, pub documentation: Option, @@ -246,6 +252,7 @@ impl Crate { name, created_at, updated_at, downloads, max_version, description, homepage, documentation, license, repository, readme: _, id: _, max_upload_size: _, + max_build_info_stable, max_build_info_beta, max_build_info_nightly, } = self; let versions_link = match versions { Some(..) => None, @@ -267,6 +274,9 @@ impl Crate { categories: category_ids, badges: badges, max_version: max_version.to_string(), + max_build_info_stable: max_build_info_stable.map(|s| s.to_string()), + max_build_info_beta: max_build_info_beta.map(::encode_time), + max_build_info_nightly: max_build_info_nightly.map(::encode_time), documentation: documentation, homepage: homepage, description: description, @@ -386,11 +396,28 @@ impl Crate { let zero = semver::Version::parse("0.0.0").unwrap(); if *ver > self.max_version || self.max_version == zero { self.max_version = ver.clone(); + self.max_build_info_stable = None; + self.max_build_info_beta = None; + self.max_build_info_nightly = None; } - let stmt = try!(conn.prepare("UPDATE crates SET max_version = $1 - WHERE id = $2 RETURNING updated_at")); - let rows = try!(stmt.query(&[&self.max_version.to_string(), &self.id])); + + let stmt = conn.prepare(" \ + UPDATE crates \ + SET max_version = $1, + max_build_info_stable = $2, + max_build_info_beta = $3, + max_build_info_nightly = $4 \ + WHERE id = $5 \ + RETURNING updated_at")?; + let rows = try!(stmt.query(&[ + &self.max_version.to_string(), + &self.max_build_info_stable.as_ref().map(|vers| vers.to_string()), + &self.max_build_info_beta, + &self.max_build_info_nightly, + &self.id + ])); self.updated_at = rows.get(0).get("updated_at"); + Version::insert(conn, self.id, ver, features, authors) } @@ -460,6 +487,7 @@ impl Crate { impl Model for Crate { fn from_row(row: &Row) -> Crate { let max: String = row.get("max_version"); + let max_build_info_stable: Option = row.get("max_build_info_stable"); Crate { id: row.get("id"), name: row.get("name"), @@ -474,6 +502,11 @@ impl Model for Crate { license: row.get("license"), repository: row.get("repository"), max_upload_size: row.get("max_upload_size"), + max_build_info_stable: max_build_info_stable.map(|stable| { + semver::Version::parse(&stable).unwrap() + }), + max_build_info_beta: row.get("max_build_info_beta"), + max_build_info_nightly: row.get("max_build_info_nightly"), } } fn table_name(_: Option) -> &'static str { "crates" } diff --git a/src/lib.rs b/src/lib.rs index f3efe3fedb2..9015f037c33 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -101,6 +101,8 @@ pub fn middleware(app: Arc) -> MiddlewareBuilder { api_router.delete("/crates/:crate_id/owners", C(krate::remove_owners)); api_router.delete("/crates/:crate_id/:version/yank", C(version::yank)); api_router.put("/crates/:crate_id/:version/unyank", C(version::unyank)); + api_router.get("/crates/:crate_id/:version/build_info", C(version::build_info)); + api_router.put("/crates/:crate_id/:version/build_info", C(version::publish_build_info)); api_router.get("/crates/:crate_id/reverse_dependencies", C(krate::reverse_dependencies)); api_router.get("/versions", C(version::index)); api_router.get("/versions/:version_id", C(version::show)); diff --git a/src/tests/all.rs b/src/tests/all.rs index a008de37d67..74a529ca706 100755 --- a/src/tests/all.rs +++ b/src/tests/all.rs @@ -194,6 +194,9 @@ fn krate(name: &str) -> Crate { created_at: time::now().to_timespec(), downloads: 10, max_version: semver::Version::parse("0.0.0").unwrap(), + max_build_info_stable: None, + max_build_info_beta: None, + max_build_info_nightly: None, documentation: None, homepage: None, description: None, diff --git a/src/tests/version.rs b/src/tests/version.rs index 5e4b13c7de5..e40f51ae561 100644 --- a/src/tests/version.rs +++ b/src/tests/version.rs @@ -3,9 +3,12 @@ use rustc_serialize::json::Json; use conduit::{Handler, Request, Method}; use semver; +use time; use cargo_registry::db::RequestTransaction; -use cargo_registry::version::{EncodableVersion, Version}; +use cargo_registry::version::{EncodableVersion, Version, EncodableBuildInfo}; +use cargo_registry::upload; +use cargo_registry::krate::Crate; #[derive(RustcDecodable)] struct VersionList { versions: Vec } @@ -16,6 +19,10 @@ fn sv(s: &str) -> semver::Version { semver::Version::parse(s).unwrap() } +fn ts(s: &str) -> time::Timespec { + time::strptime(s, "%Y-%m-%d").expect("Bad date string").to_timespec() +} + #[test] fn index() { let (_b, app, middle) = ::app(); @@ -70,3 +77,345 @@ fn authors() { let json = json.as_object().unwrap(); assert!(json.contains_key(&"users".to_string())); } + +#[test] +fn publish_build_info() { + #[derive(RustcDecodable)] struct O { ok: bool } + let (_b, app, middle) = ::app(); + + let mut req = ::new_req(app.clone(), "publish-build-info", "1.0.0"); + ::mock_user(&mut req, ::user("foo")); + ::mock_crate(&mut req, ::krate("publish-build-info")); + + let body = "{\ + \"name\":\"publish-build-info\",\ + \"vers\":\"1.0.0\",\ + \"rust_version\":\"rustc 1.16.0-nightly (df8debf6d 2017-01-25)\",\ + \"target\":\"x86_64-pc-windows-gnu\",\ + \"passed\":true}"; + + let mut response = ok_resp!(middle.call(req.with_path( + "/api/v1/crates/publish-build-info/1.0.0/build_info") + .with_method(Method::Put) + .with_body(body.as_bytes()))); + assert!(::json::(&mut response).ok); + + let body = "{\ + \"name\":\"publish-build-info\",\ + \"vers\":\"1.0.0\",\ + \"rust_version\":\"rustc 1.13.0 (df8debf6d 2017-01-25)\",\ + \"target\":\"x86_64-pc-windows-gnu\",\ + \"passed\":true}"; + + let mut response = ok_resp!(middle.call(req.with_path( + "/api/v1/crates/publish-build-info/1.0.0/build_info") + .with_method(Method::Put) + .with_body(body.as_bytes()))); + assert!(::json::(&mut response).ok); + + let body = "{\ + \"name\":\"publish-build-info\",\ + \"vers\":\"1.0.0\",\ + \"rust_version\":\"rustc 1.15.0-beta (df8debf6d 2017-01-20)\",\ + \"target\":\"x86_64-pc-windows-gnu\",\ + \"passed\":true}"; + + let mut response = ok_resp!(middle.call(req.with_path( + "/api/v1/crates/publish-build-info/1.0.0/build_info") + .with_method(Method::Put) + .with_body(body.as_bytes()))); + assert!(::json::(&mut response).ok); + + let mut response = ok_resp!(middle.call(req.with_path( + "/api/v1/crates/publish-build-info/1.0.0/build_info") + .with_method(Method::Get))); + + #[derive(RustcDecodable)] + struct R { build_info: EncodableBuildInfo } + + let json = ::json::(&mut response); + assert_eq!( + json.build_info.ordering.get("nightly"), + Some(&vec![String::from("2017-01-25T00:00:00Z")]) + ); + assert_eq!( + json.build_info.ordering.get("beta"), + Some(&vec![String::from("2017-01-20T00:00:00Z")]) + ); + assert_eq!( + json.build_info.ordering.get("stable"), + Some(&vec![String::from("1.13.0")]) + ); +} + +#[test] +fn bad_rust_version_publish_build_info() { + let (_b, app, middle) = ::app(); + + let mut req = ::new_req(app.clone(), "bad-rust-vers", "1.0.0"); + ::mock_user(&mut req, ::user("foo")); + ::mock_crate(&mut req, ::krate("bad-rust-vers")); + + let body = "{\ + \"name\":\"bad-rust-vers\",\ + \"vers\":\"1.0.0\",\ + \"rust_version\":\"rustc 1.16.0-dev (df8debf6d 2017-01-25)\",\ + \"target\":\"x86_64-pc-windows-gnu\",\ + \"passed\":true}"; + + let response = bad_resp!(middle.call(req.with_path( + "/api/v1/crates/bad-rust-vers/1.0.0/build_info") + .with_method(Method::Put) + .with_body(body.as_bytes()))); + + assert_eq!( + response.errors[0].detail, + "rust_version `rustc 1.16.0-dev (df8debf6d 2017-01-25)` \ + not recognized as nightly, beta, or stable"); + + let body = "{\ + \"name\":\"bad-rust-vers\",\ + \"vers\":\"1.0.0\",\ + \"rust_version\":\"1.15.0\",\ + \"target\":\"x86_64-pc-windows-gnu\",\ + \"passed\":true}"; + + let response = bad_resp!(middle.call(req.with_path( + "/api/v1/crates/bad-rust-vers/1.0.0/build_info") + .with_method(Method::Put) + .with_body(body.as_bytes()))); + + assert_eq!( + response.errors[0].detail, + "rust_version `1.15.0` not recognized; \ + expected format like `rustc X.Y.Z (SHA YYYY-MM-DD)`"); +} + +#[test] +fn no_existing_max_build_info() { + let (_b, app, _middle) = ::app(); + let mut req = ::req(app, Method::Get, "/api/v1/versions"); + ::mock_user(&mut req, ::user("foo")); + let (krate, version) = ::mock_crate(&mut req, ::krate("no-existing-max")); + let req: &mut Request = &mut req; + let tx = req.tx().unwrap(); + + let info = upload::VersionBuildInfo { + rust_version: String::from("rustc 1.14.0 (e8a012324 2016-12-16)"), + target: String::from("anything"), + passed: true, + }; + version.store_build_info(tx, info, &krate).unwrap(); + + let info = upload::VersionBuildInfo { + rust_version: String::from("rustc 1.15.0-beta.5 (10893a9a3 2017-01-19)"), + target: String::from("anything"), + passed: true, + }; + version.store_build_info(tx, info, &krate).unwrap(); + + let info = upload::VersionBuildInfo { + rust_version: String::from("rustc 1.16.0-nightly (df8debf6d 2017-01-25)"), + target: String::from("anything"), + passed: true, + }; + version.store_build_info(tx, info, &krate).unwrap(); + + let krate = Crate::find_by_name(tx, "no-existing-max").unwrap(); + assert_eq!(krate.max_build_info_stable, Some(sv("1.14.0"))); + assert_eq!(krate.max_build_info_beta, Some(ts("2017-01-19"))); + assert_eq!(krate.max_build_info_nightly, Some(ts("2017-01-25"))); +} + +#[test] +fn failed_build_info_doesnt_update_max() { + let (_b, app, _middle) = ::app(); + let mut req = ::req(app, Method::Get, "/api/v1/versions"); + ::mock_user(&mut req, ::user("foo")); + let (krate, version) = ::mock_crate(&mut req, ::krate("failed-build")); + let req: &mut Request = &mut req; + let tx = req.tx().unwrap(); + + let info = upload::VersionBuildInfo { + rust_version: String::from("rustc 1.14.0 (e8a012324 2016-12-16)"), + target: String::from("anything"), + passed: false, // this is the different part + }; + version.store_build_info(tx, info, &krate).unwrap(); + + let info = upload::VersionBuildInfo { + rust_version: String::from("rustc 1.15.0-beta.5 (10893a9a3 2017-01-19)"), + target: String::from("anything"), + passed: false, // this is the different part + }; + version.store_build_info(tx, info, &krate).unwrap(); + + let info = upload::VersionBuildInfo { + rust_version: String::from("rustc 1.16.0-nightly (df8debf6d 2017-01-25)"), + target: String::from("anything"), + passed: false, // this is the different part + }; + version.store_build_info(tx, info, &krate).unwrap(); + + let krate = Crate::find_by_name(tx, "failed-build").unwrap(); + assert_eq!(krate.max_build_info_stable, None); + assert_eq!(krate.max_build_info_beta, None); + assert_eq!(krate.max_build_info_nightly, None); +} + +#[test] +fn not_max_version_build_info_doesnt_update_max() { + let (_b, app, _middle) = ::app(); + let mut req = ::req(app, Method::Get, "/api/v1/versions"); + ::mock_user(&mut req, ::user("foo")); + let (krate, version) = ::mock_crate(&mut req, ::krate("old-rust-vers")); + let req: &mut Request = &mut req; + let tx = req.tx().unwrap(); + + // Setup: store build info for: + // stable 1.14.0, beta 2017-01-19, nightly 2017-01-25 + let info = upload::VersionBuildInfo { + rust_version: String::from("rustc 1.14.0 (e8a012324 2016-12-16)"), + target: String::from("anything"), + passed: true, + }; + version.store_build_info(tx, info, &krate).unwrap(); + + let info = upload::VersionBuildInfo { + rust_version: String::from("rustc 1.15.0-beta.5 (10893a9a3 2017-01-19)"), + target: String::from("anything"), + passed: true, + }; + version.store_build_info(tx, info, &krate).unwrap(); + + let info = upload::VersionBuildInfo { + rust_version: String::from("rustc 1.16.0-nightly (df8debf6d 2017-01-25)"), + target: String::from("anything"), + passed: true, + }; + version.store_build_info(tx, info, &krate).unwrap(); + + // Need to reload to see versions added in setup + let krate = Crate::find_by_name(tx, "old-rust-vers").unwrap(); + + // Report build info for: + // stable 1.13.0, beta 2017-01-01, nightly 2017-01-24 + let info = upload::VersionBuildInfo { + rust_version: String::from("rustc 1.13.0 (2c6933acc 2016-11-07)"), + target: String::from("anything"), + passed: true, + }; + version.store_build_info(tx, info, &krate).unwrap(); + + let info = upload::VersionBuildInfo { + rust_version: String::from("rustc 1.15.0-beta.3 (beefcafe 2017-01-01)"), + target: String::from("anything"), + passed: true, + }; + version.store_build_info(tx, info, &krate).unwrap(); + + let info = upload::VersionBuildInfo { + rust_version: String::from("rustc 1.16.0-nightly (deadbeef 2017-01-24)"), + target: String::from("anything"), + passed: true, + }; + version.store_build_info(tx, info, &krate).unwrap(); + + // Max build info should still be 1.14.0, 2017-01-19, and 2017-01-25 + let krate = Crate::find_by_name(tx, "old-rust-vers").unwrap(); + assert_eq!(krate.max_build_info_stable, Some(sv("1.14.0"))); + assert_eq!(krate.max_build_info_beta, Some(ts("2017-01-19"))); + assert_eq!(krate.max_build_info_nightly, Some(ts("2017-01-25"))); +} + +#[test] +fn older_crate_version_in_build_info_doesnt_update_max() { + let (_b, app, _middle) = ::app(); + let mut req = ::req(app, Method::Get, "/api/v1/versions"); + ::mock_user(&mut req, ::user("foo")); + + let req: &mut Request = &mut req; + + let krate = ::krate("older-crate-version"); + + // Publish a version of the crate + let (krate, _) = ::mock_crate_vers(req, krate, &sv("2.0.0")); + + // Then go back and publish a lower version + let (krate, version) = ::mock_crate(req, krate); + + assert_eq!(krate.max_version, sv("2.0.0")); + assert_eq!(version.num, sv("1.0.0")); + + let tx = req.tx().unwrap(); + + // Publish the build info for the lower version + let info = upload::VersionBuildInfo { + rust_version: String::from("rustc 1.14.0 (e8a012324 2016-12-16)"), + target: String::from("anything"), + passed: true, + }; + version.store_build_info(tx, info, &krate).unwrap(); + + let info = upload::VersionBuildInfo { + rust_version: String::from("rustc 1.15.0-beta.5 (10893a9a3 2017-01-19)"), + target: String::from("anything"), + passed: true, + }; + version.store_build_info(tx, info, &krate).unwrap(); + + let info = upload::VersionBuildInfo { + rust_version: String::from("rustc 1.16.0-nightly (df8debf6d 2017-01-25)"), + target: String::from("anything"), + passed: true, + }; + version.store_build_info(tx, info, &krate).unwrap(); + + let krate = Crate::find_by_name(tx, "older-crate-version").unwrap(); + assert_eq!(krate.max_build_info_stable, None); + assert_eq!(krate.max_build_info_beta, None); + assert_eq!(krate.max_build_info_nightly, None); +} + +#[test] +fn clear_max_build_info_on_new_crate_max_version() { + let (_b, app, _middle) = ::app(); + let mut req = ::req(app, Method::Get, "/api/v1/versions"); + ::mock_user(&mut req, ::user("foo")); + let (krate, version) = ::mock_crate(&mut req, ::krate("no-existing-max")); + let req: &mut Request = &mut req; + { + let tx = req.tx().unwrap(); + + // Setup: publish some build info for the current max version + let info = upload::VersionBuildInfo { + rust_version: String::from("rustc 1.14.0 (e8a012324 2016-12-16)"), + target: String::from("anything"), + passed: true, + }; + version.store_build_info(tx, info, &krate).unwrap(); + + let info = upload::VersionBuildInfo { + rust_version: String::from("rustc 1.15.0-beta.5 (10893a9a3 2017-01-19)"), + target: String::from("anything"), + passed: true, + }; + version.store_build_info(tx, info, &krate).unwrap(); + + let info = upload::VersionBuildInfo { + rust_version: String::from("rustc 1.16.0-nightly (df8debf6d 2017-01-25)"), + target: String::from("anything"), + passed: true, + }; + version.store_build_info(tx, info, &krate).unwrap(); + } + + // Then publish a higher version of the crate + ::mock_crate_vers(req, krate, &sv("2.0.0")); + + let tx = req.tx().unwrap(); + let krate = Crate::find_by_name(tx, "no-existing-max").unwrap(); + assert_eq!(krate.max_build_info_stable, None); + assert_eq!(krate.max_build_info_beta, None); + assert_eq!(krate.max_build_info_nightly, None); +} diff --git a/src/upload.rs b/src/upload.rs index 3a775645f6e..c98418cc149 100644 --- a/src/upload.rs +++ b/src/upload.rs @@ -4,9 +4,11 @@ use std::ops::Deref; use rustc_serialize::{Decodable, Decoder, Encoder, Encodable}; use semver; use dependency::Kind as DependencyKind; +use util::CargoResult; use keyword::Keyword as CrateKeyword; use krate::Crate; +use version::ChannelVersion; #[derive(RustcDecodable, RustcEncodable)] pub struct NewCrate { @@ -48,6 +50,19 @@ pub struct CrateDependency { pub kind: Option, } +#[derive(RustcDecodable, RustcEncodable)] +pub struct VersionBuildInfo { + pub rust_version: String, + pub target: String, + pub passed: bool, +} + +impl VersionBuildInfo { + pub fn channel_version(&self) -> CargoResult { + self.rust_version.parse() + } +} + impl Decodable for CrateName { fn decode(d: &mut D) -> Result { let s = try!(d.read_str()); diff --git a/src/version.rs b/src/version.rs index 2265d445db8..48335e65210 100644 --- a/src/version.rs +++ b/src/version.rs @@ -1,4 +1,5 @@ -use std::collections::HashMap; +use std::collections::{HashMap, BTreeSet}; +use std::str::FromStr; use conduit::{Request, Response}; use conduit_router::RequestParams; @@ -6,8 +7,7 @@ use pg::GenericConnection; use pg::rows::Row; use rustc_serialize::json; use semver; -use time::Duration; -use time::Timespec; +use time::{self, Duration, Timespec}; use url; use {Model, Crate, User}; @@ -19,7 +19,7 @@ use git; use upload; use user::RequestUser; use owner::{rights, Rights}; -use util::{RequestUtils, CargoResult, ChainError, internal, human}; +use util::{RequestUtils, CargoResult, ChainError, internal, human, CargoError}; #[derive(Clone)] pub struct Version { @@ -57,6 +57,60 @@ pub struct VersionLinks { pub dependencies: String, pub version_downloads: String, pub authors: String, + pub build_info: String, +} + +#[derive(RustcEncodable, RustcDecodable, Default)] +pub struct EncodableBuildInfo { + id: i32, + pub ordering: HashMap>, + pub stable: HashMap>, + pub beta: HashMap>, + pub nightly: HashMap>, +} + +pub enum ChannelVersion { + Stable(semver::Version), + Beta(Timespec), + Nightly(Timespec), +} + +impl FromStr for ChannelVersion { + type Err = Box; + + fn from_str(s: &str) -> CargoResult { + // Recognized formats: + // rustc 1.14.0 (e8a012324 2016-12-16) + // rustc 1.15.0-beta.5 (10893a9a3 2017-01-19) + // rustc 1.16.0-nightly (df8debf6d 2017-01-25) + + let pieces: Vec<_> = s.split(&[' ', '(', ')'][..]) + .filter(|s| !s.trim().is_empty()) + .collect(); + if pieces.len() != 4 { + return Err(human(format!( + "rust_version `{}` not recognized; \ + expected format like `rustc X.Y.Z (SHA YYYY-MM-DD)`", + s + ))); + } + + if pieces[1].contains("nightly") { + Ok(ChannelVersion::Nightly(time::strptime(pieces[3], "%Y-%m-%d")?.to_timespec())) + } else if pieces[1].contains("beta") { + Ok(ChannelVersion::Beta(time::strptime(pieces[3], "%Y-%m-%d")?.to_timespec())) + } else { + let v = semver::Version::parse(pieces[1])?; + if v.pre.is_empty() { + Ok(ChannelVersion::Stable(v)) + } else { + return Err(human(format!( + "rust_version `{}` not recognized as nightly, beta, or stable", + s + ))); + } + } + } } impl Version { @@ -117,6 +171,7 @@ impl Version { version_downloads: format!("/api/v1/crates/{}/{}/downloads", crate_name, num), authors: format!("/api/v1/crates/{}/{}/authors", crate_name, num), + build_info: format!("/api/v1/crates/{}/{}/build_info", crate_name, num), }, } } @@ -194,6 +249,72 @@ impl Version { &[&yanked, &self.id])); Ok(()) } + + pub fn store_build_info(&self, + conn: &GenericConnection, + info: upload::VersionBuildInfo, + krate: &Crate) -> CargoResult<()> { + + // Verify specified Rust version will parse before doing any inserting + let channel_version = info.channel_version()?; + + // Future improvement: allow overwriting an existing row, in which case + // the ON CONFLICT DO should set `passed` instead of doing nothing. + let inserted = conn.execute("INSERT INTO build_info \ + (version_id, rust_version, target, passed) \ + VALUES ($1, $2, $3, $4) \ + ON CONFLICT (version_id, rust_version, target) DO NOTHING", + &[&self.id, &info.rust_version, &info.target, &info.passed])?; + + if inserted == 0 { + return Err(human(format!( + "Build info already specified for {} v{} with {} and target {}", + krate.name, self.num, info.rust_version, info.target))); + } + + if info.passed && self.num == krate.max_version { + match channel_version { + ChannelVersion::Nightly(date) => { + let should_update = krate.max_build_info_nightly.as_ref().map(|max| { + date > *max + }).unwrap_or(true); + + if should_update { + conn.execute("UPDATE crates \ + SET max_build_info_nightly = $1 \ + WHERE id = $2", + &[&date, &krate.id])?; + } + }, + ChannelVersion::Beta(date) => { + let should_update = krate.max_build_info_beta.as_ref().map(|max| { + date > *max + }).unwrap_or(true); + + if should_update { + conn.execute("UPDATE crates \ + SET max_build_info_beta = $1 \ + WHERE id = $2", + &[&date, &krate.id])?; + } + }, + ChannelVersion::Stable(vers) => { + let should_update = krate.max_build_info_stable.as_ref().map(|max| { + vers > *max + }).unwrap_or(true); + + if should_update { + conn.execute("UPDATE crates \ + SET max_build_info_stable = $1 \ + WHERE id = $2", + &[&vers.to_string(), &krate.id])?; + } + }, + } + } + + Ok(()) + } } impl Model for Version { @@ -324,6 +445,73 @@ pub fn downloads(req: &mut Request) -> CargoResult { Ok(req.json(&R{ version_downloads: downloads })) } +/// Handles the `GET /crates/:crate_id/:version/build_info` route. +pub fn build_info(req: &mut Request) -> CargoResult { + let (version, _) = try!(version_and_crate(req)); + + let tx = try!(req.tx()); + + let stmt = try!(tx.prepare("SELECT * FROM build_info + WHERE version_id = $1")); + let rows = stmt.query(&[&version.id])?; + + let mut build_info = EncodableBuildInfo::default(); + build_info.id = version.id; + let mut stables = BTreeSet::new(); + let mut betas = BTreeSet::new(); + let mut nightlies = BTreeSet::new(); + + for row in &rows { + let rust_version: String = row.get("rust_version"); + let rust_version: ChannelVersion = rust_version.parse()?; + let target = row.get("target"); + let passed = row.get("passed"); + + match rust_version { + ChannelVersion::Stable(semver) => { + let key = semver.to_string(); + stables.insert(semver); + build_info.stable.entry(key) + .or_insert_with(HashMap::new) + .insert(target, passed); + } + ChannelVersion::Beta(date) => { + let key = ::encode_time(date); + betas.insert(date); + build_info.beta.entry(key) + .or_insert_with(HashMap::new) + .insert(target, passed); + } + ChannelVersion::Nightly(date) => { + let key = ::encode_time(date); + nightlies.insert(date); + build_info.nightly.entry(key) + .or_insert_with(HashMap::new) + .insert(target, passed); + } + } + } + + build_info.ordering.insert( + String::from("stable"), + stables.into_iter().map(|s| s.to_string()).collect() + ); + + build_info.ordering.insert( + String::from("beta"), + betas.into_iter().map(::encode_time).collect() + ); + + build_info.ordering.insert( + String::from("nightly"), + nightlies.into_iter().map(::encode_time).collect() + ); + + #[derive(RustcEncodable)] + struct R { build_info: EncodableBuildInfo } + Ok(req.json(&R{ build_info: build_info })) +} + /// Handles the `GET /crates/:crate_id/:version/authors` route. pub fn authors(req: &mut Request) -> CargoResult { let (version, _) = try!(version_and_crate(req)); @@ -371,3 +559,26 @@ fn modify_yank(req: &mut Request, yanked: bool) -> CargoResult { struct R { ok: bool } Ok(req.json(&R{ ok: true })) } + +/// Handles the `POST /crates/:crate_id/:version/build_info` route. +pub fn publish_build_info(req: &mut Request) -> CargoResult { + let mut body = String::new(); + try!(req.body().read_to_string(&mut body)); + let info: upload::VersionBuildInfo = try!(json::decode(&body).map_err(|e| { + human(format!("invalid upload request: {:?}", e)) + })); + + let (version, krate) = try!(version_and_crate(req)); + let user = try!(req.user()); + let tx = try!(req.tx()); + let owners = try!(krate.owners(tx)); + if try!(rights(req.app(), &owners, &user)) < Rights::Publish { + return Err(human("must already be an owner to publish build info")) + } + + version.store_build_info(tx, info, &krate)?; + + #[derive(RustcEncodable)] + struct R { ok: bool } + Ok(req.json(&R{ ok: true })) +}