1 /** 2 Copyright: © 2013 rejectedsoftware e.K. 3 License: Subject to the terms of the GNU GPLv3 license, as written in the included LICENSE.txt file. 4 Authors: Sönke Ludwig 5 */ 6 module dubregistry.registry; 7 8 import dubregistry.cache : FileNotFoundException; 9 import dubregistry.dbcontroller; 10 import dubregistry.internal.utils; 11 import dubregistry.internal.workqueue; 12 import dubregistry.repositories.repository; 13 14 import dub.semver; 15 import dub.package_ : packageInfoFilenames; 16 import std.algorithm : canFind, countUntil, filter, map, sort, swap, equal, startsWith, endsWith; 17 import std.array; 18 import std.conv; 19 import std.datetime : Clock, UTC, hours, SysTime; 20 import std.digest.digest : toHexString; 21 import std.encoding : sanitize; 22 import std.exception : enforce; 23 import std.range : chain, walkLength; 24 import std.path : extension; 25 import std..string : format, toLower, representation; 26 import std.uni : sicmp; 27 import std.typecons; 28 import std.uni : asUpperCase; 29 import userman.db.controller; 30 import vibe.core.core; 31 import vibe.core.log; 32 import vibe.data.bson; 33 import vibe.data.json; 34 import vibe.stream.operations; 35 36 37 /// Settings to configure the package registry. 38 class DubRegistrySettings { 39 string databaseName = "vpmreg"; 40 } 41 42 class DubRegistry { 43 @safe: 44 45 private { 46 DubRegistrySettings m_settings; 47 DbController m_db; 48 49 // list of package names to check for updates 50 PackageWorkQueue m_updateQueue; 51 // list of packages whose statistics need to be updated 52 PackageWorkQueue m_updateStatsQueue; 53 DbStatDistributions m_statDistributions; 54 } 55 56 this(DubRegistrySettings settings) 57 { 58 m_settings = settings; 59 m_db = new DbController(settings.databaseName); 60 61 // recompute scores on startup to pick up any algorithm changes 62 m_statDistributions = m_db.getStatDistributions(); 63 recomputeScores(m_statDistributions); 64 65 m_updateQueue = new PackageWorkQueue(&updatePackage); 66 m_updateStatsQueue = new PackageWorkQueue((p) { updatePackageStats(p); }); 67 } 68 69 @property DbController db() nothrow { return m_db; } 70 71 @property auto availablePackages() { return m_db.getAllPackages(); } 72 @property auto availablePackageIDs() { return m_db.getAllPackageIDs(); } 73 74 auto getPackageDump() 75 { 76 return m_db.getPackageDump(); 77 } 78 79 void triggerPackageUpdate(string pack_name) 80 { 81 m_updateQueue.putFront(pack_name); 82 } 83 84 bool isPackageScheduledForUpdate(string pack_name) 85 { 86 return m_updateQueue.isPending(pack_name); 87 } 88 89 /** Returns the current index of a given package in the update queue. 90 91 An index of zero indicates that the package is currently being updated. 92 A negative index is returned when the package is not in the update 93 queue. 94 */ 95 sizediff_t getUpdateQueuePosition(string pack_name) 96 { 97 return m_updateQueue.getPosition(pack_name); 98 } 99 100 auto searchPackages(string query) 101 { 102 static struct Info { string name; DbPackageStats stats; DbPackageVersion _base; alias _base this; } 103 return m_db.searchPackages(query).filter!(p => p.versions.length > 0).map!(p => 104 Info(p.name, p.stats, m_db.getVersionInfo(p.name, p.versions[$ - 1].version_))); 105 } 106 107 RepositoryInfo getRepositoryInfo(DbRepository repository) 108 { 109 auto rep = getRepository(repository); 110 return rep.getInfo(); 111 } 112 113 void addPackage(DbRepository repository, User.ID user) 114 { 115 auto pack_name = validateRepository(repository); 116 117 DbPackage pack; 118 pack.owner = user.bsonObjectIDValue; 119 pack.name = pack_name; 120 pack.repository = repository; 121 m_db.addPackage(pack); 122 123 triggerPackageUpdate(pack.name); 124 } 125 126 void addOrSetPackage(DbPackage pack) 127 { 128 m_db.addOrSetPackage(pack); 129 } 130 131 void addDownload(BsonObjectID pack_id, string ver, string agent) 132 { 133 m_db.addDownload(pack_id, ver, agent); 134 } 135 136 void removePackage(string packname, User.ID user) 137 { 138 logInfo("Package %s: removing package owned by %s", packname, user); 139 m_db.removePackage(packname, user.bsonObjectIDValue); 140 } 141 142 auto getPackages(User.ID user) 143 { 144 return m_db.getUserPackages(user.bsonObjectIDValue); 145 } 146 147 bool isUserPackage(User.ID user, string package_name) 148 { 149 return m_db.isUserPackage(user.bsonObjectIDValue, package_name); 150 } 151 152 /// get stats (including downloads of all version) for a package 153 DbPackageStats getPackageStats(string packname) 154 { 155 auto cached = m_db.getPackageStats(packname); 156 if (cached.updatedAt > Clock.currTime(UTC()) - 24.hours) 157 return cached; 158 return updatePackageStats(packname); 159 } 160 161 private DbPackageStats updatePackageStats(string packname) 162 { 163 logDiagnostic("Updating stats for %s", packname); 164 165 DbPackageStats stats; 166 DbPackage pack = m_db.getPackage(packname); 167 stats.downloads = m_db.aggregateDownloadStats(pack._id); 168 169 try { 170 stats.repo = getRepositoryInfo(pack.repository).stats; 171 } catch (FileNotFoundException e) { 172 // repo no longer exists, rate it down to zero (#221) 173 logInfo("Zero scoring %s because the repo no longer exists.", packname); 174 stats.score = 0; 175 } catch (Exception e) { 176 logWarn("Failed to get repository info for %s: %s", packname, e.msg); 177 return typeof(return).init; 178 } 179 180 if (auto pStatDist = pack.repository.kind in m_statDistributions.repos) 181 stats.score = computeScore(stats, m_statDistributions.downloads, *pStatDist); 182 else 183 logError("Missing stat distribution for %s repositories.", pack.repository.kind); 184 185 m_db.updatePackageStats(pack._id, stats); 186 return stats; 187 } 188 189 /// get downloads for a package version 190 DbDownloadStats getDownloadStats(string packname, string ver) 191 { 192 auto packid = m_db.getPackageID(packname); 193 if (ver == "latest") ver = getLatestVersion(packname); 194 enforce!RecordNotFound(m_db.hasVersion(packname, ver), "Unknown version for package."); 195 return m_db.aggregateDownloadStats(packid, ver); 196 } 197 198 Json getPackageVersionInfo(string packname, string ver) 199 { 200 if (ver == "latest") ver = getLatestVersion(packname); 201 if (!m_db.hasVersion(packname, ver)) return Json(null); 202 return m_db.getVersionInfo(packname, ver).serializeToJson(); 203 } 204 205 string getLatestVersion(string packname) 206 { 207 return m_db.getLatestVersion(packname); 208 } 209 210 PackageInfo getPackageInfo(string packname, bool include_errors = false) 211 { 212 DbPackage pack; 213 try pack = m_db.getPackage(packname); 214 catch(Exception) return PackageInfo.init; 215 216 return getPackageInfo(pack, include_errors); 217 } 218 219 /** Gets information about multiple packages at once. 220 221 The order and count of packages returned may not correspond to the list 222 of supplied package names. Only those packages that actually reference 223 an existing package will yield a result element. 224 225 The consequence is that the caller must manually match the result to 226 the supplied package names. 227 228 This function requires only a single query to the database. 229 230 Returns: 231 An unordered input range of `PackageInfo` values is returned, 232 corresponding to all or part of the packages of the given names. 233 */ 234 auto getPackageInfos(scope string[] pack_names, bool include_errors = false) 235 { 236 return m_db.getPackages(pack_names) 237 .map!(pack => getPackageInfo(pack, include_errors)); 238 } 239 240 PackageInfo getPackageInfo(DbPackage pack, bool include_errors) 241 { 242 auto rep = getRepository(pack.repository); 243 244 PackageInfo ret; 245 ret.versions = pack.versions.map!(v => getPackageVersionInfo(v, rep)).array; 246 ret.logo = pack.logo; 247 248 Json nfo = Json.emptyObject; 249 nfo["id"] = pack._id.toString(); 250 nfo["dateAdded"] = pack._id.timeStamp.toISOExtString(); 251 nfo["owner"] = pack.owner.toString(); 252 nfo["name"] = pack.name; 253 nfo["versions"] = Json(ret.versions.map!(v => v.info).array); 254 nfo["repository"] = serializeToJson(pack.repository); 255 nfo["categories"] = serializeToJson(pack.categories); 256 if(include_errors) nfo["errors"] = serializeToJson(pack.errors); 257 258 ret.info = nfo; 259 260 return ret; 261 } 262 263 private PackageVersionInfo getPackageVersionInfo(DbPackageVersion v, Repository rep) 264 { 265 // JSON package version info as reported to the client 266 auto nfo = v.info.get!(Json[string]).dup; 267 nfo["version"] = v.version_; 268 nfo["date"] = v.date.toISOExtString(); 269 nfo["readme"] = v.readme; 270 nfo["commitID"] = v.commitID; 271 272 PackageVersionInfo ret; 273 ret.info = Json(nfo); 274 ret.date = v.date; 275 ret.sha = v.commitID; 276 ret.version_ = v.version_; 277 ret.downloadURL = rep.getDownloadUrl(v.version_.startsWith("~") ? v.version_ : "v"~v.version_); 278 return ret; 279 } 280 281 string getReadme(Json version_info, DbRepository repository) 282 { 283 auto readme = version_info["readme"].opt!string; 284 285 // compat migration, read file from repo if README hasn't yet been stored in the db 286 if (readme.length && readme.length < 256 && readme[0] == '/') { 287 try { 288 auto rep = getRepository(repository); 289 logDebug("reading readme file for %s: %s", version_info["name"].get!string, readme); 290 rep.readFile(version_info["commitID"].get!string, InetPath(readme), (scope data) { 291 readme = data.readAllUTF8(); 292 }); 293 } catch (Exception e) { 294 logDiagnostic("Failed to read README file (%s) for %s %s: %s", 295 readme, version_info["name"].get!string, 296 version_info["version"].get!string, e.msg); 297 } 298 } 299 return readme; 300 } 301 302 void downloadPackageZip(string packname, string vers, void delegate(scope InputStream) @safe del) 303 { 304 DbPackage pack = m_db.getPackage(packname); 305 auto rep = getRepository(pack.repository); 306 rep.download(vers, del); 307 } 308 309 void setPackageCategories(string pack_name, string[] categories) 310 { 311 m_db.setPackageCategories(pack_name, categories); 312 } 313 314 void setPackageRepository(string pack_name, DbRepository repository) 315 { 316 auto new_name = validateRepository(repository); 317 enforce(pack_name == new_name, "The package name of the new repository doesn't match the existing one: "~new_name); 318 m_db.setPackageRepository(pack_name, repository); 319 } 320 321 void setPackageLogo(string pack_name, NativePath path) 322 { 323 auto png = generateLogo(path); 324 if (png.length) 325 m_db.setPackageLogo(pack_name, png); 326 else 327 throw new Exception("Failed to generate logo"); 328 } 329 330 void unsetPackageLogo(string pack_name) 331 { 332 m_db.setPackageLogo(pack_name, null); 333 } 334 335 bdata_t getPackageLogo(string pack_name, out bdata_t rev) 336 { 337 return m_db.getPackageLogo(pack_name, rev); 338 } 339 340 void updatePackages() 341 { 342 logDiagnostic("Triggering package update..."); 343 // update stat distributions before score packages 344 m_statDistributions = m_db.getStatDistributions(); 345 foreach (packname; this.availablePackages) 346 triggerPackageUpdate(packname); 347 } 348 349 protected string validateRepository(DbRepository repository) 350 { 351 // find the packge info of ~master or any available branch 352 PackageVersionInfo info; 353 auto rep = getRepository(repository); 354 auto branches = rep.getBranches(); 355 enforce(branches.length > 0, "The repository contains no branches."); 356 auto idx = branches.countUntil!(b => b.name == "master"); 357 if (idx > 0) swap(branches[0], branches[idx]); 358 string branch_errors; 359 foreach (b; branches) { 360 try { 361 info = rep.getVersionInfo(b, null); 362 enforce (info.info.type == Json.Type.object, 363 "JSON package description must be a JSON object."); 364 break; 365 } catch (Exception e) { 366 logDiagnostic("Error getting package info for %s", b); 367 branch_errors ~= format("\n%s: %s", b.name, e.msg); 368 } 369 } 370 enforce (info.info.type == Json.Type.object, 371 "Failed to find a branch containing a valid package description file:" ~ branch_errors); 372 373 // derive package name and perform various sanity checks 374 auto name = info.info["name"].get!string; 375 string package_desc_file = info.info["packageDescriptionFile"].get!string; 376 string package_check_string = format(`Check your %s.`, package_desc_file); 377 enforce(name.length <= 60, 378 "Package names must not be longer than 60 characters: \""~name[0 .. 60]~"...\" - "~package_check_string); 379 enforce(name == name.toLower(), 380 "Package names must be all lower case, not \""~name~"\". "~package_check_string); 381 enforce(info.info["license"].opt!string.length > 0, 382 `A "license" field in the package description file is missing or empty. `~package_check_string); 383 enforce(info.info["description"].opt!string.length > 0, 384 `A "description" field in the package description file is missing or empty. `~package_check_string); 385 checkPackageName(name, format(`Check the "name" field of your %s.`, package_desc_file)); 386 foreach (string n, vspec; info.info["dependencies"].opt!(Json[string])) { 387 auto parts = n.split(":").array; 388 // allow shortcut syntax ":subpack" 389 if (parts.length > 1 && parts[0].length == 0) parts = parts[1 .. $]; 390 // verify all other parts of the package name 391 foreach (p; parts) 392 checkPackageName(p, format(`Check the "dependencies" field of your %s.`, package_desc_file)); 393 } 394 395 // ensure that at least one tagged version is present 396 auto tags = rep.getTags(); 397 enforce(tags.canFind!(t => t.name.startsWith("v") && t.name[1 .. $].isValidVersion), 398 `The repository must have at least one tagged version (SemVer format, e.g. ` 399 ~ `"v1.0.0" or "v0.0.1") to be published on the registry. Please add a proper tag using ` 400 ~ `"git tag" or equivalent means and see http://semver.org for more information.`); 401 402 return name; 403 } 404 405 protected bool addVersion(in ref DbPackage dbpack, string ver, Repository rep, RefInfo reference) 406 { 407 logDiagnostic("Adding new version info %s for %s", ver, dbpack.name); 408 assert(ver.startsWith("~") && !ver.startsWith("~~") || isValidVersion(ver)); 409 410 string deffile; 411 foreach (t; dbpack.versions) 412 if (t.version_ == ver) { 413 deffile = t.info["packageDescriptionFile"].opt!string; 414 break; 415 } 416 auto info = getVersionInfo(rep, reference, deffile); 417 418 //assert(info.info.name == info.info.name.get!string.toLower(), "Package names must be all lower case."); 419 info.info["name"] = info.info["name"].get!string.toLower(); 420 enforce(info.info["name"] == dbpack.name, 421 format("Package name (%s) does not match the original package name (%s). Check %s.", 422 info.info["name"].get!string, dbpack.name, info.info["packageDescriptionFile"].get!string)); 423 424 foreach( string n, vspec; info.info["dependencies"].opt!(Json[string]) ) 425 foreach (p; n.split(":")) 426 checkPackageName(p, "Check "~info.info["packageDescriptionFile"].get!string~"."); 427 428 DbPackageVersion dbver; 429 dbver.date = info.date; 430 dbver.version_ = ver; 431 dbver.commitID = info.sha; 432 dbver.info = info.info; 433 434 try { 435 auto files = rep.listFiles(reference.sha, InetPath("/")); 436 // check exactly for readme.me 437 ptrdiff_t readme; 438 readme = files.countUntil!(a => a.type == RepositoryFile.Type.file && a.path.head.name.asUpperCase.equal("README.MD")); 439 if (readme == -1) { 440 // check exactly for readme 441 readme = files.countUntil!(a => a.type == RepositoryFile.Type.file && a.path.head.name.asUpperCase.equal("README")); 442 } 443 if (readme == -1) { 444 // check for all other readmes such as README.txt, README.jp.md, etc. 445 readme = files.countUntil!(a => a.type == RepositoryFile.Type.file && a.path.head.name.asUpperCase.startsWith("README")); 446 } 447 448 if (readme != -1) { 449 rep.readFile(reference.sha, files[readme].path, (scope input) { 450 dbver.readme = input.readAllUTF8(); 451 string ext = files[readme].path.head.name.extension; 452 // endsWith doesn't like to work with asLowerCase 453 dbver.readmeMarkdown = ext.sicmp(".md") == 0; 454 }); 455 } else logDiagnostic("No README.md found for %s %s", dbpack.name, ver); 456 457 // TODO: load in example(s), sample(s), test(s) and docs for the view package page here. 458 // possibly also parsing the README.md file for a documentation link 459 } catch (Exception e) { logDiagnostic("Failed to read README.md for %s %s: %s", dbpack.name, ver, e.msg); } 460 461 if (m_db.hasVersion(dbpack.name, ver)) { 462 logDebug("Updating existing version info."); 463 m_db.updateVersion(dbpack.name, dbver); 464 return false; 465 } 466 467 if ("description" !in info.info || "license" !in info.info) { 468 throw new Exception( 469 "Published packages must contain \"description\" and \"license\" fields."); 470 } 471 //enforce(!m_db.hasVersion(packname, dbver.version_), "Version already exists."); 472 if (auto pv = "version" in info.info) 473 enforce(pv.get!string == ver, format("Package description contains an obsolete \"version\" field and does not match tag %s: %s", ver, pv.get!string)); 474 logDebug("Adding new version info."); 475 m_db.addVersion(dbpack.name, dbver); 476 return true; 477 } 478 479 protected void removeVersion(string packname, string ver) 480 { 481 assert(ver.startsWith("~") && !ver.startsWith("~~") || isValidVersion(ver)); 482 483 m_db.removeVersion(packname, ver); 484 } 485 486 private void updatePackage(string packname) 487 { 488 import std.encoding; 489 string[] errors; 490 491 DbPackage pack; 492 try pack = m_db.getPackage(packname); 493 catch( Exception e ){ 494 errors ~= format("Error getting package info: %s", e.msg); 495 () @trusted { logDebug("%s", sanitize(e.toString())); } (); 496 return; 497 } 498 499 Repository rep; 500 try rep = getRepository(pack.repository); 501 catch( Exception e ){ 502 errors ~= format("Error accessing repository: %s", e.msg); 503 () @trusted { logDebug("%s", sanitize(e.toString())); } (); 504 return; 505 } 506 507 bool[string] existing; 508 RefInfo[] tags, branches; 509 bool got_all_tags_and_branches = false; 510 try { 511 tags = rep.getTags() 512 .filter!(a => a.name.startsWith("v") && a.name[1 .. $].isValidVersion) 513 .array 514 .sort!((a, b) => compareVersions(a.name[1 .. $], b.name[1 .. $]) < 0) 515 .array; 516 branches = rep.getBranches(); 517 got_all_tags_and_branches = true; 518 } catch (Exception e) { 519 errors ~= format("Failed to get GIT tags/branches: %s", e.msg); 520 } 521 logDiagnostic("Updating tags for %s: %s", packname, tags.map!(t => t.name).array); 522 foreach (tag; tags) { 523 auto name = tag.name[1 .. $]; 524 existing[name] = true; 525 try { 526 if (addVersion(pack, name, rep, tag)) 527 logInfo("Package %s: added version %s", packname, name); 528 } catch( Exception e ){ 529 logDiagnostic("Error for version %s of %s: %s", name, packname, e.msg); 530 () @trusted { logDebug("Full error: %s", sanitize(e.toString())); } (); 531 errors ~= format("Version %s: %s", name, e.msg); 532 } 533 } 534 logDiagnostic("Updating branches for %s: %s", packname, branches.map!(t => t.name).array); 535 foreach (branch; branches) { 536 auto name = "~" ~ branch.name; 537 existing[name] = true; 538 try { 539 if (addVersion(pack, name, rep, branch)) 540 logInfo("Package %s: added branch %s", packname, name); 541 } catch( Exception e ){ 542 logDiagnostic("Error for branch %s of %s: %s", name, packname, e.msg); 543 () @trusted { logDebug("Full error: %s", sanitize(e.toString())); } (); 544 if (branch.name != "gh-pages") // ignore errors on the special GitHub website branch 545 errors ~= format("Branch %s: %s", name, e.msg); 546 } 547 } 548 if (got_all_tags_and_branches) { 549 foreach (ref v; pack.versions) { 550 auto ver = v.version_; 551 if (ver !in existing) { 552 logInfo("Package %s: removing version %s as the branch/tag was removed.", packname, ver); 553 removeVersion(packname, ver); 554 } 555 } 556 } 557 m_db.setPackageErrors(packname, errors); 558 559 m_updateStatsQueue.put(packname); 560 } 561 562 /// recompute all scores based on cached stats, e.g. after updating algorithm 563 private void recomputeScores(DbStatDistributions dists) 564 { 565 foreach (pack; this.getPackageDump()) { 566 auto stats = pack.stats; 567 stats.score = computeScore(stats, dists.downloads, dists.repos[pack.repository.kind]); 568 if (stats.score != pack.stats.score) 569 m_db.updatePackageStats(pack._id, stats); 570 } 571 } 572 } 573 574 private PackageVersionInfo getVersionInfo(Repository rep, RefInfo commit, string first_filename_try, InetPath sub_path = InetPath("/")) 575 @safe { 576 import dub.recipe.io; 577 import dub.recipe.json; 578 579 PackageVersionInfo ret; 580 ret.date = commit.date.toSysTime(); 581 ret.sha = commit.sha; 582 string[1] first_try; 583 first_try[0] = first_filename_try; 584 auto all_filenames = () @trusted { return packageInfoFilenames(); } (); 585 foreach (filename; chain(first_try[], all_filenames.filter!(f => f != first_filename_try))) { 586 if (!filename.length) continue; 587 try { 588 rep.readFile(commit.sha, sub_path ~ filename, (scope input) @safe { 589 auto text = input.readAllUTF8(false); 590 auto recipe = () @trusted { return parsePackageRecipe(text, filename); } (); 591 ret.info = () @trusted { return recipe.toJson(); } (); 592 }); 593 594 ret.info["packageDescriptionFile"] = filename; 595 logDebug("Found package description file %s.", filename); 596 597 foreach (ref sp; ret.info["subPackages"].opt!(Json[])) { 598 if (sp.type == Json.Type..string) { 599 auto path = sp.get!string; 600 logDebug("Fetching path based sub package at %s", sub_path ~ path); 601 auto subpack = getVersionInfo(rep, commit, first_filename_try, sub_path ~ path); 602 sp = subpack.info; 603 sp["path"] = path; 604 } 605 } 606 607 break; 608 } catch (FileNotFoundException) { 609 logDebug("Package description file %s not found...", filename); 610 } 611 } 612 if (ret.info.type == Json.Type.undefined) 613 throw new Exception("Found no package description file in the repository."); 614 return ret; 615 } 616 617 private void checkPackageName(string n, string error_suffix) 618 @safe { 619 enforce(n.length > 0, "Package names may not be empty. "~error_suffix); 620 foreach( ch; n ){ 621 switch(ch){ 622 default: 623 throw new Exception("Package names may only contain ASCII letters and numbers, as well as '_' and '-': "~n~" - "~error_suffix); 624 case 'a': .. case 'z': 625 case 'A': .. case 'Z': 626 case '0': .. case '9': 627 case '_', '-': 628 break; 629 } 630 } 631 } 632 633 struct PackageVersionInfo { 634 string version_; 635 SysTime date; 636 string sha; 637 string downloadURL; 638 Json info; /// JSON version information, as reported to the client 639 } 640 641 struct PackageInfo { 642 PackageVersionInfo[] versions; 643 BsonObjectID logo; 644 Json info; /// JSON package information, as reported to the client 645 } 646 647 /// Computes a package score from given package stats and global distributions of those stats. 648 private float computeScore(DownDist, RepoDist)(in ref DbPackageStats stats, DownDist downDist, RepoDist repoDist) 649 @safe { 650 import std.algorithm.comparison : max; 651 import std.math : log1p, round, tanh; 652 653 if (!downDist.total.sum) // no stat distribution yet 654 return 0; 655 656 /// Using monthly downloads to penalize stale packages, logarithm to 657 /// offset exponential distribution, and tanh as smooth limiter to [0..1]. 658 immutable downloadScore = tanh(log1p(stats.downloads.monthly / downDist.monthly.mean)); 659 logDebug("downloadScore %s %s %s", downloadScore, stats.downloads.monthly, downDist.monthly.mean); 660 661 // Compute score for repo 662 float sum=0, wsum=0; 663 void add(T)(float weight, float value, T dist) 664 { 665 if (dist.sum == 0) 666 return; // ignore metrics missing for that repository kind 667 sum += weight * log1p(value / dist.mean); 668 wsum += weight; 669 } 670 with (stats.repo) 671 { 672 alias d = repoDist; 673 // all of those values are highly correlated 674 add(1.0f, stars, d.stars); 675 add(1.0f, watchers, d.watchers); 676 add(1.0f, forks, d.forks); 677 add(-1.0f, issues, d.issues); // penalize many open issues/PRs 678 } 679 680 immutable repoScore = max(0.0, tanh(sum / wsum)); 681 logDebug("repoScore: %s %s %s", repoScore, sum, wsum); 682 683 // average scores 684 immutable avgScore = (repoScore + downloadScore) / 2; 685 assert(0 <= avgScore && avgScore <= 1.0, "%s %s".format(repoScore, downloadScore)); 686 immutable scaled = stats.minScore + avgScore * (stats.maxScore - stats.minScore); 687 logDebug("score: %s %s %s %s %s %s", stats.downloads.monthly, downDist.monthly.mean, downloadScore, repoScore, avgScore, scaled); 688 689 return scaled; 690 }