Skip to content
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ Here are some links to the documentation that could be helpful when contributing
- for details also see [official GitLab API docs](https://docs.gitlab.com/ee/api/)
- Pagure (through `requests`) - API is dependent on deployed version of Pagure service;
`ogr` is majorly used on (links lead directly to API docs)
- [src.fedoraproject.org](https://src.fedoraproject.org/api/0/)
- [pagure.io](https://pagure.io/api/0/)
- [git.stg.centos.org](https://git.stg.centos.org/api/0/)
- Forgejo
- [src.fedoraproject.org](https://src.fedoraproject.org/api/v1/)

## Making raw HTTP requests

Expand Down
7 changes: 7 additions & 0 deletions ogr/constant.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,10 @@

CLONE_TIMEOUT = 60
DEFAULT_RO_PREFIX_STRING = "READ ONLY: "

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] naming-convention

DGIT abbreviation is non-standard in the codebase. Consider DISTGIT_URLS for clarity.

DGIT_URLS = (
"src.fedoraproject.org",
"src.stg.fedoraproject.org",
"pkgs.fedoraproject.org",
"pkgs.stg.fedoraproject.org",
)
69 changes: 65 additions & 4 deletions ogr/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,26 @@
# SPDX-License-Identifier: MIT

import functools
import logging
import time
from collections.abc import Iterable
from typing import Optional

from requests.exceptions import ConnectionError, ReadTimeout

from ogr.abstract import GitProject, GitService
from ogr.exceptions import OgrException
from ogr.constant import DGIT_URLS
from ogr.exceptions import OgrException, OgrNetworkError
from ogr.parsing import parse_git_repo

_SERVICE_MAPPING: dict[str, type[GitService]] = {}

# cache dist-git forge service class for two minutes
_DGIT_FORGE_CACHE: dict[str, tuple[float, type[GitService]]] = {}
_DGIT_FORGE_CACHE_TTL = 120

logger = logging.getLogger(__name__)


def use_for_service(service: str, _func=None):
"""
Expand All @@ -29,7 +40,7 @@ class GithubService(BaseGitService):
pass
Comment thread
betulependule marked this conversation as resolved.

@use_for_service("pagure.io")
@use_for_service("src.fedoraproject.org")
@use_for_service("git.centos.org")
class PagureService(BaseGitService):
pass
```
Expand Down Expand Up @@ -116,6 +127,10 @@ def get_service_class_or_none(
) -> Optional[type[GitService]]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[critical] behavioral-contract-change

get_service_class_or_none() now raises OgrNetworkError for dist-git URLs when the Pagure probe request fails with ConnectionError or ReadTimeout. Previously this function returned Optional[type[GitService]] and never raised exceptions. Downstream callers (get_service_class, get_instances_from_dict, get_project, and packit's Config.load_authentication()) do not handle this new exception type.

Suggested fix: Either catch the error inside get_service_class_or_none and return a fallback (e.g., ForgejoService from the static mapping) or None, or update all callers to handle OgrNetworkError.

"""
Get the matching service class from the URL.
When attempting to get the matching service class for dist-git, probing
is used to determine whether `PagureService` or `ForgejoService`
should be returned. This information is cached for two minutes. The
probing request is set to timeout after 5 seconds.

Args:
url: URL of the project, e.g. `"https://github.com/packit/ogr"`.
Expand All @@ -126,13 +141,59 @@ def get_service_class_or_none(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[high] behavioral-contract-break

get_service_class_or_none() changes from a pure in-memory dict lookup to a function that makes HTTP requests for dist-git URLs and can now raise OgrNetworkError. This is a new failure mode for all callers including get_service_class() and get_instances_from_dict().

Suggested fix: Consider catching OgrNetworkError internally and falling back to ForgejoService, preserving the function's no-raise contract.

Returns:
Matched class (subclass of `GitService`) or `None`.

Raises:
OgrNetworkError, in case a ConnectionError or ReadTimeout error
is encountered when attempting to probe Pagure dist-git.
"""
mapping = {}
mapping.update(_SERVICE_MAPPING)
non_overridden_dgit_urls: Iterable[str] = DGIT_URLS

if service_mapping_update:
mapping.update(service_mapping_update)
non_overridden_dgit_urls = (
set(non_overridden_dgit_urls) - service_mapping_update.keys()
)

parsed_url = parse_git_repo(url)

# [XXX] remove once the migration of dist-git is finished

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just an FYI:

  • you need to keep in mind that this will probably get also released, so even if we remove it after the migration is finished, it might live somewhere for some time…
  • also, it would be ideal to have this in some release before the migration

for dgit_url in non_overridden_dgit_urls:

# if dealing with dist-git, we need to check whether we need to use

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[high] logic-error

The dist-git URL check uses dgit_url in parsed_url.hostname (substring match) instead of exact equality. Since DGIT_URLS iterates 'src.fedoraproject.org' before 'src.stg.fedoraproject.org', a staging hostname matches the production entry first, causing the probe to hit the production API endpoint for staging URLs.

Suggested fix: Use if dgit_url == parsed_url.hostname: for exact match.

# `PagureService` or `ForgejoService`
if dgit_url in parsed_url.hostname:

from ogr.services.forgejo import ForgejoService
from ogr.services.pagure import PagureService

now = time.monotonic()
if cache := _DGIT_FORGE_CACHE.get(dgit_url):
timestamp, service_type = cache
if now - timestamp < _DGIT_FORGE_CACHE_TTL:
return service_type
Comment thread
betulependule marked this conversation as resolved.
# API call to the Pagure backend
api_endpoint = "https://src.fedoraproject.org/api/0/version"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] trust-boundary

Forge detection relies on HTTP response from Fedora infrastructure. Document the trust assumption.

api_endpoint_stg = "https://src.stg.fedoraproject.org/api/0/version"
Comment on lines +177 to +178

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was going to say that it doesn’t work for pagure.io, so it’s some dist-git specific patch, but… now that I checked the forge itself, I cannot seem to get there altogether…

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure? I can get to "https://src.stg.fedoraproject.org/api/0/version" just fine.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pagure.io, not staging dist-git

request_url = api_endpoint_stg if ".stg." in dgit_url else api_endpoint

try:
pagure_service = PagureService()
response = pagure_service.get_raw_request(url=request_url, timeout=5)

# if not found, then dist-git is no longer hosted on Pagure
dgit_service_kls = (
PagureService if response.status_code != 404 else ForgejoService
)

_DGIT_FORGE_CACHE[dgit_url] = (now, dgit_service_kls)
return dgit_service_kls

except (ConnectionError, ReadTimeout) as er:
logger.error(er)
raise OgrNetworkError(f"Cannot connect to url: '{url}'.") from er

for service, service_kls in mapping.items():
if parse_git_repo(service).hostname in parsed_url.hostname:
return service_kls
Expand Down Expand Up @@ -178,13 +239,13 @@ def get_instances_from_dict(instances: dict) -> set[GitService]:
```py
get_instances_from_dict({
"github.com": {"token": "abcd"},
"pagure": {
"forgejo": {
"token": "abcd",
"instance_url": "https://src.fedoraproject.org",
},
}) == {
GithubService(token="abcd"),
PagureService(token="abcd", instance_url="https://src.fedoraproject.org")
ForgejoService(token="abcd", instance_url="https://src.fedoraproject.org")
}
```

Expand Down
4 changes: 4 additions & 0 deletions ogr/services/forgejo/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@

@use_for_service("forgejo")
@use_for_service("codeberg.org")
@use_for_service("src.fedoraproject.org")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] intent-contradiction

PR registers dist-git hostnames to ForgejoService via @use_for_service AND adds runtime probing that can return PagureService. The two mechanisms contradict each other.

Suggested fix: Choose one mechanism: decorators (merge when migration complete) or runtime probing (remove dist-git decorators from ForgejoService).

@betulependule betulependule Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it matters whether the decorators are added to ForgejoService. If dealing with dist-git URLs, probing is always used to determine whether ForgejoService or PagureService should be used. The decorators should have no effect until the probing is removed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] service-mapping-break

Both static @use_for_service decorators and runtime probing can return different service classes for the same dist-git URL. The dual registration creates confusing redundancy.

Suggested fix: Document the interaction or remove the static registration since probing handles the decision dynamically.

@use_for_service("src.stg.fedoraproject.org")
@use_for_service("pkgs.fedoraproject.org")
@use_for_service("pkgs.stg.fedoraproject.org")
class ForgejoService(BaseGitService):
version = "/api/v1"

Expand Down
2 changes: 0 additions & 2 deletions ogr/services/pagure/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,8 +382,6 @@ def is_private(self) -> bool:
"git.centos.org",
"git.stg.centos.org",
"pagure.io",
"src.fedoraproject.org",
"src.stg.fedoraproject.org",
]:
# private repositories are not allowed on generally used pagure instances
return False
Expand Down
8 changes: 3 additions & 5 deletions ogr/services/pagure/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,13 @@


@use_for_service("pagure")
@use_for_service("src.fedoraproject.org")
@use_for_service("src.stg.fedoraproject.org")
@use_for_service("pkgs.fedoraproject.org")
@use_for_service("pkgs.stg.fedoraproject.org")
@use_for_service("git.centos.org")
@use_for_service("git.stg.centos.org")
class PagureService(BaseGitService):
def __init__(
self,
token: Optional[str] = None,
instance_url: str = "https://src.fedoraproject.org",
instance_url: str = "https://pagure.io",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[high] default-parameter-break

PagureService.init default instance_url changed from 'https://src.fedoraproject.org' to 'https://pagure.io'. Any code constructing PagureService() without specifying instance_url will silently connect to a different server.

Suggested fix: Document as a breaking change in the PR description and changelog.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is going to die too tbf, not sure if before or after dist-git

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, as you pointed out, "pagure.io" is read-only now. I could change instance_url to "git.centos.org", but if that's going to die as well, then I might as well keep it set to "src.fedoraproject.org".

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(best would be no default, but… that is breaking change… idk, the pagure.io is probably the most sane default)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, changing instance_url from "https://src.fedoraproject.org" to anything else is a breaking change regardless. I'm thinking that once this is released before the migration, whoever is using PagureService without explicitly setting instance_url to "https://src.fedoraproject.org" will run into an error, despite dist-git still being on Pagure at that point. Maybe it's safer to keep the default value set to "https://src.fedoraproject.org" and only change it in a follow-up PR after the migration to make sure no users are affected before the migration.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As fullsend pointed out, another breaking change similar to this one is in pagure/project.py‎ where removing the hardcoded dist-git URLs would break the is_private method whenever called pre-migration.

I think it would be best to drop 0506e3d7c9a45c16a2f9ef2fcce5097bd5b2bf1e and d044e7c6932f22179a4e2bf08ef599ac1f7d0276 from this PR, then create a follow-up PR with these two commits and merge it after migration, not before.

read_only: bool = False,
insecure: bool = False,
max_retries: Union[int, urllib3.util.Retry] = 3,
Expand Down Expand Up @@ -250,6 +246,7 @@ def get_raw_request(
params=None,
data=None,
header=None,
timeout: Optional[float] = None,
) -> RequestResponse:
"""
Call API endpoint and wrap the response in `RequestResponse` type.
Expand Down Expand Up @@ -279,6 +276,7 @@ def get_raw_request(
headers=headers,
data=data,
verify=not self.insecure,
timeout=timeout,
)
logger.debug(
f"Ogr sent request with following headers: {headers | {'Authorization': '<redacted>'}}",
Expand Down
5 changes: 4 additions & 1 deletion tests/integration/factory/test_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ def github_service(self):
@property
def pagure_service(self):
if not self._pagure_service:
self._pagure_service = PagureService(token=self.pagure_token)
self._pagure_service = PagureService(
token=self.pagure_token,
instance_url="https://src.fedoraproject.org",
)
return self._pagure_service

@property
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion tests/integration/pagure/test_project_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ def test_pr_status(self):
)

def test_is_private(self):
self.service.instance_url = "https://src.fedoraproject.org"
self.service.instance_url = "https://pagure.io"
assert not self.ogr_project.is_private()

def test_token_is_none_then_set(self):
Expand Down
29 changes: 29 additions & 0 deletions tests/unit/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Copyright Contributors to the Packit project.
# SPDX-License-Identifier: MIT

import pytest
from flexmock import flexmock

from ogr import PagureService


# mocks API calls to Pagure dist-git made to determine whether dist-git
# is still hosted on Pagure and returns the status code expected after
# the migration of dist-git to Forgejo
@pytest.fixture(autouse=True)
def setup_api_request_mock():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] test-isolation

_DGIT_FORGE_CACHE is module-level state never cleared between tests. First test populates cache; subsequent tests within TTL use cached values instead of the mock.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, but I don't think it matters.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] missing-test-coverage

The autouse fixture always returns a 404 response (post-migration state). No test verifies the pre-migration path where Pagure returns a non-404 status and PagureService should be returned.

response = flexmock(status_code=404)
flexmock(PagureService).should_receive("get_raw_request").with_args(
url="https://src.fedoraproject.org/api/0/version",
timeout=5,
).and_return(
response,
)
flexmock(PagureService).should_receive("get_raw_request").with_args(
url="https://src.stg.fedoraproject.org/api/0/version",
timeout=5,
).and_return(
response,
)

return
32 changes: 24 additions & 8 deletions tests/unit/test_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
from flexmock import Mock, flexmock
from urllib3.util import Retry

from ogr import GithubService, GitlabService, PagureService
from ogr import ForgejoService, GithubService, GitlabService, PagureService
from ogr.exceptions import OgrException
from ogr.factory import get_instances_from_dict, get_project, get_service_class
from ogr.services.forgejo import ForgejoProject
from ogr.services.github import GithubProject
from ogr.services.gitlab import GitlabProject
from ogr.services.pagure import PagureProject
Expand All @@ -35,7 +36,7 @@
{"github.com": PagureService},
PagureService,
),
("https://src.fedoraproject.org/rpms/python-ogr", None, PagureService),
("https://src.fedoraproject.org/rpms/python-ogr", None, ForgejoService),
("https://pagure.io/ogr", None, PagureService),
("https://pagure.something.com/ogr", None, PagureService),
("https://gitlab.com/someone/project", None, GitlabService),
Expand All @@ -55,18 +56,18 @@
(
"https://src.fedoraproject.org/rpms/golang-gitlab-flimzy-testy",
None,
PagureService,
ForgejoService,
),
(
"https://src.stg.fedoraproject.org/rpms/golang-gitlab-flimzy-testy",
None,
PagureService,
ForgejoService,
),
("https://src.fedoraproject.org/rpms/python-gitlab", None, PagureService),
("https://src.fedoraproject.org/rpms/python-gitlab", None, ForgejoService),
(
"https://src.fedoraproject.org/rpms/golang-gitlab-yawning-utls",
None,
PagureService,
ForgejoService,
),
],
)
Expand Down Expand Up @@ -165,10 +166,10 @@ def test_get_service_class_not_found(url, mapping):
None,
None,
True,
PagureProject(
ForgejoProject(
namespace="rpms",
repo="python-ogr",
service=PagureService(instance_url="https://src.fedoraproject.org"),
service=ForgejoService(instance_url="https://src.fedoraproject.org"),
),
),
(
Expand Down Expand Up @@ -350,6 +351,7 @@ def test_get_project_not_found(url, mapping, instances, exc_str):
({"github.com": {"token": "abcd"}}, {GithubService(token="abcd")}),
({"gitlab": {"token": "abcd"}}, {GitlabService(token="abcd")}),
({"pagure": {"token": "abcd"}}, {PagureService(token="abcd")}),
({"forgejo": {"token": "abcd"}}, {ForgejoService(token="abcd")}),
(
{
"pagure": {
Expand All @@ -359,6 +361,20 @@ def test_get_project_not_found(url, mapping, instances, exc_str):
},
{PagureService(token="abcd", instance_url="https://src.fedoraproject.org")},
),
(
{
"forgejo": {
"token": "abcd",
"instance_url": "https://src.fedoraproject.org",
},
},
{
ForgejoService(
token="abcd",
instance_url="https://src.fedoraproject.org",
),
},
),
(
{"github.com": {"token": "abcd"}, "gitlab": {"token": "abcd"}},
{GithubService(token="abcd"), GitlabService(token="abcd")},
Expand Down
7 changes: 5 additions & 2 deletions tests/unit/test_pagure.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,8 @@

class TestPagureService(TestCase):
def test_hostname(self):
assert PagureService().hostname == "src.fedoraproject.org"
assert PagureService(instance_url="https://pagure.io").hostname == "pagure.io"
assert PagureService().hostname == "pagure.io"
assert (
PagureService(instance_url="https://git.centos.org").hostname
Comment thread
mfocko marked this conversation as resolved.
== "git.centos.org"
)
Loading