diff --git a/src/manus_agent/tools/get_dependency_blast_radius.py b/src/manus_agent/tools/get_dependency_blast_radius.py index 876527a..96ad342 100644 --- a/src/manus_agent/tools/get_dependency_blast_radius.py +++ b/src/manus_agent/tools/get_dependency_blast_radius.py @@ -14,6 +14,8 @@ 5. PyPI JSON API — package metadata (PyPI only; download counts from pypistats.org with graceful degradation on 429) 6. Maven Central — artifact metadata + version count (Maven only) + 7. RubyGems API — gem metadata + all-time/recent downloads + first-release + date (RubyGems only) The tool deliberately avoids paid/rate-limited APIs so it works without configuration. When a source is unavailable the result degrades gracefully. @@ -24,6 +26,7 @@ from __future__ import annotations +import datetime import logging import re from typing import Any @@ -51,6 +54,8 @@ _PYPI_JSON_URL = "https://pypi.org/pypi/{}/json" _PYPISTATS_URL = "https://pypistats.org/api/packages/{}/recent" _MAVEN_SEARCH_URL = "https://search.maven.org/solrsearch/select" +_RUBYGEMS_GEM_URL = "https://rubygems.org/api/v1/gems/{}.json" +_RUBYGEMS_VERSIONS_URL = "https://rubygems.org/api/v1/versions/{}.json" _TIMEOUT = 20 @@ -373,6 +378,84 @@ def _enrich_maven(name: str) -> dict[str, Any]: return result +def _extract_rubygems_first_release(versions: list[dict]) -> dict[str, Any]: + """Parse RubyGems version list and return first-release metadata. + + Each entry has ``created_at`` (ISO 8601, e.g. ``2010-04-28T21:23:00.000Z``). + Returns ``{first_version, first_release_date, age_years}`` or ``{}`` on failure. + """ + if not versions: + return {} + best_date: datetime.datetime | None = None + best_version = "" + now = datetime.datetime.now(datetime.timezone.utc) + for entry in versions: + created_raw = entry.get("created_at", "") + if not created_raw: + continue + try: + # Normalise: strip trailing Z / milliseconds / offset variants + ts = created_raw + if ts.endswith("Z"): + ts = ts[:-1] + "+00:00" + # Truncate sub-second to ≤6 digits + if "." in ts: + dot = ts.index(".") + end = ts.index("+", dot) if "+" in ts[dot:] else len(ts) + frac = ts[dot + 1 : end] + ts = ts[: dot + 1] + frac[:6] + ts[end:] + dt = datetime.datetime.fromisoformat(ts) + dt = dt.astimezone(datetime.timezone.utc) + except (ValueError, OverflowError): + continue + if best_date is None or dt < best_date: + best_date = dt + best_version = entry.get("number", "") + if best_date is None: + return {} + age_years = round((now - best_date).days / 365.25, 1) + return { + "first_version": best_version, + "first_release_date": best_date.strftime("%Y-%m-%d"), + "age_years": age_years, + } + + +def _enrich_rubygems(name: str) -> dict[str, Any]: + """Fetch RubyGems gem metadata + download stats + first-release date. + + Two calls: + 1. ``/api/v1/gems/{name}.json`` — downloads, version, description, homepage. + 2. ``/api/v1/versions/{name}.json`` — version history for first-release date + (isolated try/except; failure does not affect main metadata). + """ + result: dict[str, Any] = {"ecosystem": "RubyGems", "package_name": name} + try: + gem = _get(_RUBYGEMS_GEM_URL.format(name)) + result["total_downloads"] = gem.get("downloads", 0) + result["recent_downloads"] = gem.get("version_downloads", 0) # current-version DLs + result["latest_version"] = gem.get("version", "") + description = gem.get("info", "") or "" + result["description"] = description[:120] + result["home_page"] = gem.get("homepage_uri") or gem.get("project_uri", "") + # Use total_downloads as the primary blast-score signal + result["weekly_downloads"] = None # RubyGems does not expose weekly; use total below + except Exception as exc: + logger.debug("RubyGems gem fetch failed for %s: %s", name, exc) + return result + + # Second call: version list for first-release date + try: + versions = _get(_RUBYGEMS_VERSIONS_URL.format(name)) + if isinstance(versions, list): + release_meta = _extract_rubygems_first_release(versions) + result.update(release_meta) + except Exception as exc: + logger.debug("RubyGems versions fetch failed for %s: %s", name, exc) + + return result + + def _enrich_package(name: str, ecosystem: str) -> dict[str, Any]: """Dispatch to the right enrichment function based on ecosystem.""" eco_lower = (ecosystem or "").lower() @@ -382,6 +465,8 @@ def _enrich_package(name: str, ecosystem: str) -> dict[str, Any]: return _enrich_pypi(name) elif eco_lower in ("maven", "java", "gradle"): return _enrich_maven(name) + elif eco_lower in ("rubygems", "ruby", "gem"): + return _enrich_rubygems(name) # Unknown ecosystem — return minimal record return {"ecosystem": ecosystem, "package_name": name} @@ -398,6 +483,12 @@ def _blast_score(stats: dict[str, Any]) -> str: Returns one of: critical / high / medium / low / unknown """ weekly = stats.get("weekly_downloads") or 0 + # RubyGems exposes all-time total_downloads instead of weekly; use it as fallback + if not weekly and stats.get("total_downloads"): + # Rough weekly proxy: total / (age_years * 52) floored at 1 week + total = stats["total_downloads"] + age_years = stats.get("age_years") or 1.0 + weekly = int(total / max(age_years * 52, 1)) dependents = stats.get("dependent_packages_count") or 0 if weekly >= 5_000_000 or dependents >= 50_000: @@ -433,6 +524,7 @@ def get_dependency_blast_radius( # noqa: C901 - **npm**: dependent package count + weekly/monthly download stats - **PyPI**: package metadata + download stats (when pypistats is available) - **Maven**: artifact metadata from Maven Central + - **RubyGems**: gem metadata + all-time/current-version downloads + first-release date 3. Computes a qualitative blast-radius label: CRITICAL / HIGH / MEDIUM / LOW. Use after ``get_nvd_data`` to understand *how many projects are exposed* @@ -559,6 +651,23 @@ def get_dependency_blast_radius( # noqa: C901 if r.get("version_count"): lines.append(f" Version count: {r['version_count']}") + # RubyGems-specific stats + if eco.lower() in ("rubygems", "ruby", "gem"): + if r.get("latest_version"): + lines.append(f" Latest version: {r['latest_version']}") + if r.get("total_downloads") is not None: + lines.append(f" All-time DLs: {r['total_downloads']:,}") + if r.get("recent_downloads") is not None: + lines.append(f" Current ver DLs: {r['recent_downloads']:,}") + if r.get("first_release_date"): + lines.append( + f" First released: {r['first_release_date']} " + f"({r.get('age_years', '?')} yrs old)" + + (f" first version: {r['first_version']}" if r.get("first_version") else "") + ) + if r.get("description"): + lines.append(f" Description: {r['description'][:80]}") + if source: lines.append(f" Data sources: {source}") diff --git a/tests/test_dependency_blast_radius.py b/tests/test_dependency_blast_radius.py index 4e8d4b5..2be2f81 100644 --- a/tests/test_dependency_blast_radius.py +++ b/tests/test_dependency_blast_radius.py @@ -18,6 +18,8 @@ _enrich_npm, _enrich_package, _enrich_pypi, + _enrich_rubygems, + _extract_rubygems_first_release, _fetch_ghsa_affected, _fetch_nvd_affected, _fetch_osv_affected, @@ -933,3 +935,242 @@ def test_blast_radius_in_subcommands_set(self): from manus_agent.cli import _SUBCOMMANDS assert "blast-radius" in _SUBCOMMANDS + + +# =========================================================================== +# _extract_rubygems_first_release +# =========================================================================== + + +class TestExtractRubygemsFirstRelease: + def _make_version(self, number: str, created_at: str) -> dict: + return {"number": number, "created_at": created_at} + + def test_returns_oldest_version(self): + versions = [ + self._make_version("2.0.0", "2020-06-01T00:00:00.000Z"), + self._make_version("1.0.0", "2014-03-15T12:34:56.000Z"), + self._make_version("1.5.0", "2017-09-22T08:00:00.000Z"), + ] + result = _extract_rubygems_first_release(versions) + assert result["first_version"] == "1.0.0" + assert result["first_release_date"] == "2014-03-15" + assert isinstance(result["age_years"], float) + assert result["age_years"] > 5 + + def test_empty_list_returns_empty_dict(self): + assert _extract_rubygems_first_release([]) == {} + + def test_malformed_timestamps_skipped(self): + versions = [ + self._make_version("1.0.0", "not-a-date"), + self._make_version("2.0.0", "also-bad"), + ] + assert _extract_rubygems_first_release(versions) == {} + + def test_mixed_valid_and_invalid_timestamps(self): + versions = [ + self._make_version("1.0.0", "bad-date"), + self._make_version("2.0.0", "2019-05-10T00:00:00.000Z"), + ] + result = _extract_rubygems_first_release(versions) + assert result["first_version"] == "2.0.0" + assert result["first_release_date"] == "2019-05-10" + + def test_single_version(self): + versions = [self._make_version("0.1.0", "2012-01-01T00:00:00.000Z")] + result = _extract_rubygems_first_release(versions) + assert result["first_version"] == "0.1.0" + assert result["first_release_date"] == "2012-01-01" + + def test_handles_no_milliseconds(self): + """ISO 8601 without sub-second fraction.""" + versions = [self._make_version("1.0.0", "2015-07-04T10:30:00Z")] + result = _extract_rubygems_first_release(versions) + assert result["first_release_date"] == "2015-07-04" + + def test_handles_utc_offset(self): + """ISO 8601 with explicit +00:00 offset.""" + versions = [self._make_version("1.0.0", "2016-11-11T00:00:00.000+00:00")] + result = _extract_rubygems_first_release(versions) + assert result["first_release_date"] == "2016-11-11" + + def test_missing_created_at_skipped(self): + versions = [ + {"number": "1.0.0"}, # no created_at key + self._make_version("2.0.0", "2021-08-20T00:00:00.000Z"), + ] + result = _extract_rubygems_first_release(versions) + assert result["first_version"] == "2.0.0" + + +# =========================================================================== +# _enrich_rubygems +# =========================================================================== + + +class TestEnrichRubygems: + def _make_gem_response(self, name: str, version: str, downloads: int, version_downloads: int) -> dict: + return { + "name": name, + "version": version, + "downloads": downloads, + "version_downloads": version_downloads, + "info": f"{name} is a Ruby library for doing things", + "homepage_uri": f"https://github.com/example/{name}", + "project_uri": f"https://rubygems.org/gems/{name}", + } + + def _make_versions_response(self) -> list[dict]: + return [ + {"number": "2.0.0", "created_at": "2020-04-01T00:00:00.000Z"}, + {"number": "1.0.0", "created_at": "2010-09-15T00:00:00.000Z"}, + {"number": "1.5.0", "created_at": "2015-06-10T00:00:00.000Z"}, + ] + + def test_returns_gem_metadata_and_first_release(self): + gem_resp = MagicMock() + gem_resp.raise_for_status.return_value = None + gem_resp.json.return_value = self._make_gem_response("nokogiri", "1.16.0", 50_000_000, 3_000_000) + ver_resp = MagicMock() + ver_resp.raise_for_status.return_value = None + ver_resp.json.return_value = self._make_versions_response() + with patch("requests.get", side_effect=[gem_resp, ver_resp]): + result = _enrich_rubygems("nokogiri") + assert result["ecosystem"] == "RubyGems" + assert result["package_name"] == "nokogiri" + assert result["total_downloads"] == 50_000_000 + assert result["recent_downloads"] == 3_000_000 + assert result["latest_version"] == "1.16.0" + assert "nokogiri" in result["description"] + assert result["first_version"] == "1.0.0" + assert result["first_release_date"] == "2010-09-15" + assert result["age_years"] > 5 + + def test_versions_failure_does_not_break_main_metadata(self): + gem_resp = MagicMock() + gem_resp.raise_for_status.return_value = None + gem_resp.json.return_value = self._make_gem_response("rails", "7.1.0", 200_000_000, 1_000_000) + with patch("requests.get", side_effect=[gem_resp, Exception("versions endpoint 503")]): + result = _enrich_rubygems("rails") + assert result["total_downloads"] == 200_000_000 + assert result["latest_version"] == "7.1.0" + # first_release_date should be absent (versions call failed) + assert "first_release_date" not in result + + def test_graceful_degradation_on_gem_fetch_error(self): + with patch("requests.get", side_effect=Exception("connection refused")): + result = _enrich_rubygems("sinatra") + assert result["ecosystem"] == "RubyGems" + assert result["package_name"] == "sinatra" + assert "total_downloads" not in result + + def test_homepage_uri_preferred_over_project_uri(self): + gem_resp = MagicMock() + gem_resp.raise_for_status.return_value = None + gem = self._make_gem_response("devise", "4.9.0", 10_000_000, 500_000) + gem["homepage_uri"] = "https://github.com/heartcombo/devise" + gem_resp.json.return_value = gem + ver_resp = MagicMock() + ver_resp.raise_for_status.return_value = None + ver_resp.json.return_value = [] + with patch("requests.get", side_effect=[gem_resp, ver_resp]): + result = _enrich_rubygems("devise") + assert result["home_page"] == "https://github.com/heartcombo/devise" + + def test_description_truncated_to_120_chars(self): + gem_resp = MagicMock() + gem_resp.raise_for_status.return_value = None + gem = self._make_gem_response("capybara", "3.39.2", 80_000_000, 2_000_000) + gem["info"] = "x" * 200 + gem_resp.json.return_value = gem + ver_resp = MagicMock() + ver_resp.raise_for_status.return_value = None + ver_resp.json.return_value = [] + with patch("requests.get", side_effect=[gem_resp, ver_resp]): + result = _enrich_rubygems("capybara") + assert len(result["description"]) <= 120 + + +# =========================================================================== +# _enrich_package dispatch (RubyGems) +# =========================================================================== + + +class TestEnrichPackageDispatchRubyGems: + def test_rubygems_ecosystem(self): + with patch("manus_agent.tools.get_dependency_blast_radius._enrich_rubygems") as mock_rb: + mock_rb.return_value = {"ecosystem": "RubyGems", "package_name": "rack"} + _enrich_package("rack", "RubyGems") + mock_rb.assert_called_once_with("rack") + + def test_ruby_ecosystem_routes_to_rubygems(self): + with patch("manus_agent.tools.get_dependency_blast_radius._enrich_rubygems") as mock_rb: + mock_rb.return_value = {"ecosystem": "RubyGems", "package_name": "sinatra"} + _enrich_package("sinatra", "ruby") + mock_rb.assert_called_once_with("sinatra") + + def test_gem_ecosystem_routes_to_rubygems(self): + with patch("manus_agent.tools.get_dependency_blast_radius._enrich_rubygems") as mock_rb: + mock_rb.return_value = {"ecosystem": "RubyGems", "package_name": "puma"} + _enrich_package("puma", "gem") + mock_rb.assert_called_once_with("puma") + + +# =========================================================================== +# _blast_score with RubyGems total_downloads fallback +# =========================================================================== + + +class TestBlastScoreRubyGemsFallback: + def test_critical_from_total_downloads_large_gem(self): + # rails has hundreds of millions of downloads; age ~14 years + # avg weekly = 200_000_000 / (14 * 52) ≈ 274_725 -> MEDIUM/HIGH + stats = {"total_downloads": 200_000_000, "age_years": 14.0, "weekly_downloads": None} + score = _blast_score(stats) + assert score in ("MEDIUM", "HIGH", "CRITICAL") # large gem should rank above LOW + + def test_low_from_small_gem_total_downloads(self): + # tiny gem, 5000 total downloads, 3 years old -> avg weekly ≈ 32 -> LOW + stats = {"total_downloads": 5_000, "age_years": 3.0, "weekly_downloads": None} + score = _blast_score(stats) + assert score in ("LOW", "MEDIUM", "UNKNOWN") + + def test_explicit_weekly_takes_precedence(self): + # If weekly_downloads is set it should not be overridden + stats = {"weekly_downloads": 6_000_000, "total_downloads": 1_000, "age_years": 0.1} + score = _blast_score(stats) + assert score == "CRITICAL" + + +# =========================================================================== +# Integration: full blast-radius output includes RubyGems block +# =========================================================================== + + +class TestBlastRadiusRubyGemsOutput: + def test_rubygems_block_in_output(self): + """Direct package spec: rubygems:rack@2.2.6 should emit RubyGems block.""" + gem_resp = MagicMock() + gem_resp.raise_for_status.return_value = None + gem_resp.json.return_value = { + "name": "rack", + "version": "3.0.8", + "downloads": 500_000_000, + "version_downloads": 10_000_000, + "info": "A modular Ruby web server interface", + "homepage_uri": "https://rack.github.io", + } + ver_resp = MagicMock() + ver_resp.raise_for_status.return_value = None + ver_resp.json.return_value = [ + {"number": "0.1.0", "created_at": "2007-05-16T00:00:00.000Z"}, + {"number": "3.0.8", "created_at": "2023-11-01T00:00:00.000Z"}, + ] + with patch("requests.get", side_effect=[gem_resp, ver_resp]): + output = get_dependency_blast_radius("rubygems:rack@2.2.6") + assert "RubyGems" in output + assert "All-time DLs" in output + assert "500,000,000" in output + assert "First released" in output + assert "2007-05-16" in output