Source code for tests.test_forge

# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

"""Tests for the multi-forge metrics reader."""

from __future__ import annotations

from unittest.mock import Mock

import pytest

from repomatic.metrics.forge import (
    FORGE_APIS,
    GITHUB_HOST,
    GITHUB_METRICS_QUERY,
    REST_ENDPOINTS,
    Forge,
    ForgeMetrics,
    canonical_url,
    forge_of,
    newest_dated,
    repo_metrics,
    split_repo_url,
)
from repomatic.metrics.store import METRICS_BY_ID

GITLAB_PROJECT = "https://gitlab.example.org/api/v4/projects/fruits%2Fpapaya"
"""The GitLab endpoint of `fruits/papaya`, its path encoded as one segment."""

FORGEJO_REPO = "https://codeberg.example.org/api/v1/repos/fruits/papaya"
"""The Forgejo endpoint of `fruits/papaya`, its path left as is."""

REST_ANSWERS = {
    Forge.GITLAB: {
        GITLAB_PROJECT: {"star_count": 7, "created_at": "2020-05-01T10:00:00Z"},
        f"{GITLAB_PROJECT}/releases?per_page=1": [
            {"released_at": "2026-08-01T09:00:00Z"}
        ],
        f"{GITLAB_PROJECT}/repository/tags?per_page=1&order_by=updated&sort=desc": [
            {"commit": {"created_at": "2026-09-01T09:00:00Z"}}
        ],
        f"{GITLAB_PROJECT}/repository/commits?per_page=1": [
            {"committed_date": "2026-09-02T09:00:00Z"}
        ],
    },
    Forge.FORGEJO: {
        FORGEJO_REPO: {"stars_count": 7, "created_at": "2020-05-01T10:00:00Z"},
        f"{FORGEJO_REPO}/releases?limit=1": [{"published_at": "2026-08-01T09:00:00Z"}],
        f"{FORGEJO_REPO}/tags?limit=1": [
            {"commit": {"created": "2026-09-01T09:00:00Z"}}
        ],
        f"{FORGEJO_REPO}/commits?limit=1": [
            {"commit": {"committer": {"date": "2026-09-02T09:00:00Z"}}}
        ],
    },
}
"""What each REST forge answers about one repository, keyed by endpoint."""

HOSTS = {Forge.GITLAB: "gitlab.example.org", Forge.FORGEJO: "codeberg.example.org"}
"""The instance each REST forge is read on."""


[docs] @pytest.mark.parametrize( ("url", "expected"), ( ( "https://github.com/apricot-org/apricot", ("github.com", "apricot-org/apricot"), ), ( "http://github.com/apricot-org/apricot", ("github.com", "apricot-org/apricot"), ), ( "https://github.com/apricot-org/apricot/", ("github.com", "apricot-org/apricot"), ), ( "https://github.com/apricot-org/apricot.git", ("github.com", "apricot-org/apricot"), ), ( "https://gitlab.example.org/fruits/papaya", ("gitlab.example.org", "fruits/papaya"), ), ( "https://gitlab.example.org/fruits/tropical/papaya", ("gitlab.example.org", "fruits/tropical/papaya"), ), ), ) def test_split_repo_url(url, expected): """Check a repository URL splits into its host and its namespace path.""" assert split_repo_url(url) == expected
[docs] @pytest.mark.parametrize( "url", ( "https://github.com", "https://github.com/", "https://github.com/apricot-org", "not-a-url", ), ) def test_split_repo_url_rejects_incomplete(url): """Check a URL without an owner and a name is refused rather than guessed.""" with pytest.raises(ValueError, match="repository URL"): split_repo_url(url)
[docs] @pytest.mark.parametrize( ("subject", "expected"), ( # A bare slug is GitHub, which is what a repository declaring peers writes. ("fruits/papaya", "https://github.com/fruits/papaya"), ("/fruits/papaya/", "https://github.com/fruits/papaya"), ("https://github.com/fruits/papaya", "https://github.com/fruits/papaya"), ("https://github.com/fruits/papaya.git", "https://github.com/fruits/papaya"), ("http://github.com/fruits/papaya", "https://github.com/fruits/papaya"), ("https://gitlab.com/fruits/papaya", "https://gitlab.com/fruits/papaya"), ( "https://codeberg.org/fruits/papaya/", "https://codeberg.org/fruits/papaya", ), ), ) def test_canonical_url(subject, expected): """Check every way of naming a subject lands on one spelling. The store keys on this, so two configurations naming the same repository differently must not accrue two disjoint histories. """ assert canonical_url(subject) == expected # Idempotent, since the sampler canonicalizes what it may already have. assert canonical_url(expected) == expected
[docs] @pytest.mark.parametrize("subject", ("", "papaya", "https://github.com/")) def test_canonical_url_rejects_what_it_cannot_resolve(subject): """Check a half-written subject raises rather than becoming a wrong URL.""" with pytest.raises(ValueError, match="repository URL"): canonical_url(subject)
[docs] def test_forge_of_never_guesses_an_unknown_host(): """Check an undeclared host raises instead of sampling nothing. A silently unsampled subject is worse than a loud one: it renders as a project nobody follows rather than as a project nobody could read. """ with pytest.raises(ValueError, match="Unknown forge host"): forge_of("https://git.example.org/fruits/papaya") resolved = forge_of( "https://git.example.org/fruits/papaya", {"git.example.org": "gitlab"} ) assert resolved is Forge.GITLAB
[docs] def test_forge_of_rejects_an_unimplemented_forge(): """Check a declared host naming software nothing reads is refused.""" with pytest.raises(ValueError, match="Unsupported forge"): forge_of("https://git.example.org/fruits/papaya", {"git.example.org": "svn"})
[docs] def test_known_forges_are_implemented(): """Check every bundled host names a forge the reader actually speaks.""" assert set(FORGE_APIS.values()) <= set(Forge) assert FORGE_APIS == dict(sorted(FORGE_APIS.items())) assert FORGE_APIS[GITHUB_HOST] is Forge.GITHUB
[docs] @pytest.mark.parametrize( ("release", "tag", "expected"), ( # A tag newer than the latest release object is the case that matters: # always preferring the release would report a live project as idle. ("2020-01-01", "2021-01-01", ("2021-01-01", "tag")), ("2021-01-01", "2020-01-01", ("2021-01-01", "release")), ("2021-01-01", "2021-01-01", ("2021-01-01", "release")), ("2021-01-01", None, ("2021-01-01", "release")), (None, "2021-01-01", ("2021-01-01", "tag")), (None, None, (None, None)), ), ) def test_newest_dated(release, tag, expected): """Check the more recent of a release and a tag wins, and says which it is.""" assert newest_dated(release, tag) == expected
[docs] def test_readings_yield_only_registered_metrics(): """Check the reader and the metric registry cannot drift apart. A reading yielded under an ID the registry does not know raises inside `upsert`, mid-collection, on the one code path nobody watches. """ full = ForgeMetrics( stars=42, created="2020-01-01", release="2026-08-01", release_source="tag", commit="2026-08-15", ) readings = dict(full.readings()) assert set(readings) <= set(METRICS_BY_ID) assert readings == { "stars": "42", "commit": "2026-08-15", "release": "2026-08-01", "release_source": "tag", }
[docs] def test_readings_omit_what_a_forge_did_not_answer(): """Check a project with no release yields no row rather than a blank one.""" assert dict(ForgeMetrics(stars=0).readings()) == {"stars": "0"}
[docs] def test_readings_leave_the_creation_date_to_the_sampler(): """Check `created` is not yielded as a metric of its own. It is not a reading but the origin of one, recorded by the sampler as a zero-star reading on that date. """ metrics = ForgeMetrics(stars=1, created="2020-01-01") assert "created" not in dict(metrics.readings()) assert "created" not in METRICS_BY_ID
[docs] def test_readings_default_a_missing_release_source(): """Check a release with no stated kind still records one. The store's consumers gate release-only rendering on this value, so an empty cell beside a real date would read as a project that never released. """ metrics = ForgeMetrics(stars=1, release="2026-08-01") assert dict(metrics.readings())["release_source"] == "release"
[docs] def test_every_forge_but_github_has_rest_endpoints(): """Check the dispatch finds a table for every forge it does not query by GraphQL.""" assert set(REST_ENDPOINTS) == set(Forge) - {Forge.GITHUB}
[docs] @pytest.mark.parametrize("forge", (Forge.FORGEJO, Forge.GITLAB)) def test_rest_metrics_read_each_forge_through_its_table(monkeypatch, forge): """Check one reader serves both REST forges, each through its own endpoints.""" monkeypatch.setattr("repomatic.metrics.forge.forge_json", REST_ANSWERS[forge].get) assert forge.metrics(HOSTS[forge], "fruits/papaya") == ForgeMetrics( stars=7, created="2020-05-01", release="2026-09-01", release_source="tag", commit="2026-09-02", )
[docs] @pytest.mark.parametrize("forge", (Forge.FORGEJO, Forge.GITLAB)) def test_rest_metrics_leave_out_what_a_listing_lacks(monkeypatch, forge): """Check an empty release, tag or commit listing is a missing reading.""" repository = next(iter(REST_ANSWERS[forge])) answers = {repository: REST_ANSWERS[forge][repository]} monkeypatch.setattr("repomatic.metrics.forge.forge_json", answers.get) assert forge.metrics(HOSTS[forge], "fruits/papaya") == ForgeMetrics( stars=7, created="2020-05-01" )
[docs] def test_rest_metrics_answer_none_for_an_unreadable_repository(monkeypatch): """Check a repository the forge does not serve is unreadable, not empty.""" monkeypatch.setattr("repomatic.metrics.forge.forge_json", lambda url: None) assert Forge.GITLAB.metrics("gitlab.example.org", "fruits/papaya") is None
RELEASED = {"publishedAt": "2026-08-01T09:00:00Z"} """The `latestRelease` object of a repository that cut a release.""" BRANCH = {"target": {"committedDate": "2026-09-02T09:00:00Z"}} """The `defaultBranchRef` object, its head commit dated."""
[docs] @pytest.mark.parametrize( ("release", "tag_node", "branch", "expected"), ( pytest.param( RELEASED, # A lightweight tag points straight at its commit. {"target": {"committedDate": "2026-09-01T09:00:00Z"}}, BRANCH, ForgeMetrics( stars=7, created="2020-05-01", release="2026-09-01", release_source="tag", commit="2026-09-02", ), id="tag-newer-than-release", ), pytest.param( RELEASED, # An annotated tag points at a tag object wrapping the commit. {"target": {"target": {"committedDate": "2026-07-01T09:00:00Z"}}}, BRANCH, ForgeMetrics( stars=7, created="2020-05-01", release="2026-08-01", release_source="release", commit="2026-09-02", ), id="older-annotated-tag", ), pytest.param( None, {"target": {"target": {"committedDate": "2026-07-01T09:00:00Z"}}}, BRANCH, ForgeMetrics( stars=7, created="2020-05-01", release="2026-07-01", release_source="tag", commit="2026-09-02", ), id="no-release", ), pytest.param( None, # A tag on something that is no commit carries no date. {"target": {}}, None, ForgeMetrics(stars=7, created="2020-05-01"), id="nothing-dated", ), ), ) def test_github_metrics_read_the_whole_set_in_one_query( monkeypatch, release, tag_node, branch, expected ): """Check one GraphQL answer yields the stars, the release or tag, and the commit.""" answer = { "repository": { "createdAt": "2020-05-01T10:00:00Z", "stargazerCount": 7, "latestRelease": release, "defaultBranchRef": branch, "refs": {"nodes": [tag_node]}, } } query = Mock(return_value=answer) monkeypatch.setattr("repomatic.metrics.forge.gh_graphql", query) assert repo_metrics("https://github.com/fruits/papaya") == expected query.assert_called_once_with(GITHUB_METRICS_QUERY, owner="fruits", name="papaya")