# 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")