Page Menu
Home
Software Heritage
Search
Configure Global Search
Log In
Files
F7122852
D4907.id17482.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
47 KB
Subscribers
None
D4907.id17482.diff
View Options
diff --git a/swh/lister/gitea/lister.py b/swh/lister/gitea/lister.py
--- a/swh/lister/gitea/lister.py
+++ b/swh/lister/gitea/lister.py
@@ -1,89 +1,135 @@
-# Copyright (C) 2018-2020 The Software Heritage developers
+# Copyright (C) 2018-2021 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU General Public License version 3, or any later version
# See top-level LICENSE file for more information
-import re
-from typing import Any, Dict, List, MutableMapping, Optional, Tuple
+import logging
+from typing import Any, Dict, Iterator, List, Optional
+from urllib.parse import urljoin
-from requests import Response
+import iso8601
+import requests
+from tenacity.before_sleep import before_sleep_log
from urllib3.util import parse_url
-from ..core.page_by_page_lister import PageByPageHttpLister
-from .models import GiteaModel
+from swh.lister.utils import throttling_retry
+from swh.scheduler.interface import SchedulerInterface
+from swh.scheduler.model import ListedOrigin
+from .. import USER_AGENT
+from ..pattern import CredentialsType, StatelessLister
+
+logger = logging.getLogger(__name__)
+
+RepoListPage = List[Dict[str, Any]]
+
+
+class GiteaLister(StatelessLister[RepoListPage]):
+ """List origins from Gitea.
+
+ Test instance URL: https://try.gitea.io/api/v1/"""
-class GiteaLister(PageByPageHttpLister):
- # Template path expecting an integer that represents the page id
- PATH_TEMPLATE = "repos/search?page=%d&sort=id"
- DEFAULT_URL = "https://try.gitea.io/api/v1/"
- MODEL = GiteaModel
LISTER_NAME = "gitea"
+ REPO_LIST_PATH = "repos/search"
+
def __init__(
- self, url=None, instance=None, override_config=None, order="asc", limit=3
+ self,
+ scheduler: SchedulerInterface,
+ url: str,
+ instance: Optional[str] = None,
+ api_token: Optional[str] = None,
+ page_size: int = 50,
+ credentials: CredentialsType = None,
):
- super().__init__(url=url, override_config=override_config)
if instance is None:
- instance = parse_url(self.url).host
- self.instance = instance
- self.PATH_TEMPLATE = "%s&order=%s&limit=%s" % (
- self.PATH_TEMPLATE,
- order,
- limit,
+ instance = parse_url(url).host
+
+ super().__init__(
+ scheduler=scheduler, credentials=credentials, url=url, instance=instance,
)
- def get_model_from_repo(self, repo: Dict[str, Any]) -> Dict[str, Any]:
- return {
- "instance": self.instance,
- "uid": f'{self.instance}/{repo["id"]}',
- "name": repo["name"],
- "full_name": repo["full_name"],
- "html_url": repo["html_url"],
- "origin_url": repo["clone_url"],
- "origin_type": "git",
+ self.query_params = {
+ "sort": "id",
+ "order": "asc",
+ "limit": page_size,
+ "page": 1,
}
- def get_next_target_from_response(self, response: Response) -> Optional[int]:
- """Determine the next page identifier.
+ self.session = requests.Session()
+ self.session.headers.update(
+ {"Accept": "application/json", "User-Agent": USER_AGENT,}
+ )
- """
- if "next" in response.links:
- next_url = response.links["next"]["url"]
- return self.get_page_from_url(next_url)
- return None
+ if api_token is None and len(self.credentials) > 0:
+ if len(self.credentials) > 1:
+ logger.warning(
+ "Gitea lister support only API token authentication "
+ " as of now. Will use the first password as token."
+ )
+ api_token = self.credentials[0]["password"]
+ self.set_auth_token(api_token)
- def get_page_from_url(self, url: str) -> int:
- page_re = re.compile(r"^.*/search\?.*page=(\d+)")
- return int(page_re.match(url).group(1)) # type: ignore
+ def set_auth_token(self, auth_token: Optional[str]) -> None:
+ """Set authentication headers with given authentication token."""
+ if auth_token is not None:
+ self.session.headers["Authorization"] = "Token %s" % auth_token
- def transport_response_simplified(self, response: Response) -> List[Dict[str, Any]]:
- repos = response.json()["data"]
- return [self.get_model_from_repo(repo) for repo in repos]
+ @throttling_retry(before_sleep=before_sleep_log(logger, logging.WARNING))
+ def page_request(self, url: str, params: Dict[str, Any]) -> requests.Response:
- def get_pages_information(
- self,
- ) -> Tuple[Optional[int], Optional[int], Optional[int]]:
- """Determine pages information.
+ logger.info("Fetching URL %s with params %s", url, params)
- """
- response = self.transport_head(identifier=1) # type: ignore
- if not response.ok:
- raise ValueError(
- "Problem during information fetch: %s" % response.status_code
+ response = self.session.get(url, params=params)
+
+ if response.status_code != 200:
+ logger.warning(
+ "Unexpected HTTP status code %s on %s: %s",
+ response.status_code,
+ response.url,
+ response.content,
)
- h = response.headers
- return (
- self._get_int(h, "x-total-count"),
- int(self.get_page_from_url(response.links["last"]["url"])),
- self._get_int(h, "x-per-page"),
- )
+ response.raise_for_status()
+
+ return response
+
+ @classmethod
+ def results_simplified(cls, body: Dict[str, RepoListPage]) -> RepoListPage:
+ fields_filter = ["id", "clone_url", "updated_at"]
+ return [{k: r[k] for k in fields_filter} for r in body["data"]]
+
+ def get_pages(self) -> Iterator[RepoListPage]:
+ # base with trailing slash, path without leading slash for urljoin
+ url: str = urljoin(self.url, self.REPO_LIST_PATH)
+
+ response = self.page_request(url, self.query_params)
+
+ while True:
+ page_results = self.results_simplified(response.json())
+
+ yield page_results
- def _get_int(self, headers: MutableMapping[str, Any], key: str) -> Optional[int]:
- _val = headers.get(key)
- if _val:
- return int(_val)
- return None
+ assert len(response.links) > 0, "API changed: no Link header found"
+ if "next" in response.links:
+ url = response.links["next"]["url"]
+ else:
+ # last page
+ break
- def run(self, min_bound=1, max_bound=None, check_existence=False):
- return super().run(min_bound, max_bound, check_existence)
+ response = self.page_request(url, {})
+
+ def get_origins_from_page(self, page: RepoListPage) -> Iterator[ListedOrigin]:
+ """Convert a page of Gitea repositories into a list of ListedOrigins.
+
+ """
+ assert self.lister_obj.id is not None
+
+ for repo in page:
+ last_update = iso8601.parse_date(repo["updated_at"])
+
+ yield ListedOrigin(
+ lister_id=self.lister_obj.id,
+ url=repo["clone_url"],
+ visit_type="git",
+ last_update=last_update,
+ )
diff --git a/swh/lister/gitea/tasks.py b/swh/lister/gitea/tasks.py
--- a/swh/lister/gitea/tasks.py
+++ b/swh/lister/gitea/tasks.py
@@ -2,51 +2,27 @@
# License: GNU General Public License version 3, or any later version
# See top-level LICENSE file for more information
-import random
+from typing import Dict, Optional
-from celery import group, shared_task
+from celery import shared_task
-from .. import utils
from .lister import GiteaLister
-NBPAGES = 10
-
-@shared_task(name=__name__ + ".IncrementalGiteaLister")
-def list_gitea_incremental(**lister_args):
- """Incremental update of a Gitea instance"""
- lister_args["order"] = "desc"
- lister = GiteaLister(**lister_args)
- total_pages = lister.get_pages_information()[1]
- # stopping as soon as existing origins for that instance are detected
- return lister.run(min_bound=1, max_bound=total_pages, check_existence=True)
-
-
-@shared_task(name=__name__ + ".RangeGiteaLister")
-def _range_gitea_lister(start, end, **lister_args):
- lister = GiteaLister(**lister_args)
- return lister.run(min_bound=start, max_bound=end)
-
-
-@shared_task(name=__name__ + ".FullGiteaRelister", bind=True)
-def list_gitea_full(self, **lister_args):
+@shared_task(name=__name__ + ".FullGiteaRelister")
+def list_gitea_full(
+ url: str,
+ instance: Optional[str] = None,
+ api_token: Optional[str] = None,
+ page_size: Optional[int] = None,
+) -> Dict[str, int]:
"""Full update of a Gitea instance"""
- lister = GiteaLister(**lister_args)
- _, total_pages, _ = lister.get_pages_information()
- ranges = list(utils.split_range(total_pages, NBPAGES))
- random.shuffle(ranges)
- promise = group(
- _range_gitea_lister.s(minv, maxv, **lister_args) for minv, maxv in ranges
- )()
- self.log.debug("%s OK (spawned %s subtasks)" % (self.name, len(ranges)))
- try:
- promise.save()
- except (NotImplementedError, AttributeError):
- self.log.info("Unable to call save_group with current result backend.")
- # FIXME: what to do in terms of return here?
- return promise.id
+ lister = GiteaLister.from_configfile(
+ url=url, instance=instance, api_token=api_token, page_size=page_size
+ )
+ return lister.run().dict()
@shared_task(name=__name__ + ".ping")
-def _ping():
+def _ping() -> str:
return "OK"
diff --git a/swh/lister/gitea/tests/data/https_try.gitea.io/api_empty_response.json b/swh/lister/gitea/tests/data/https_try.gitea.io/api_empty_response.json
deleted file mode 100644
--- a/swh/lister/gitea/tests/data/https_try.gitea.io/api_empty_response.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "ok": true,
- "data": []
-}
\ No newline at end of file
diff --git a/swh/lister/gitea/tests/data/https_try.gitea.io/api_response.json b/swh/lister/gitea/tests/data/https_try.gitea.io/api_response.json
deleted file mode 100644
--- a/swh/lister/gitea/tests/data/https_try.gitea.io/api_response.json
+++ /dev/null
@@ -1,182 +0,0 @@
-{
- "ok": true,
- "data": [
- {
- "id": 5017,
- "owner": {
- "id": 1609,
- "login": "JonasFranzDEV",
- "full_name": "",
- "email": "info@jonasfranz.software",
- "avatar_url": "https://try.gitea.io/user/avatar/JonasFranzDEV/-1",
- "language": "de-DE",
- "is_admin": false,
- "last_login": "2019-10-19T10:58:29Z",
- "created": "2017-06-25T17:43:19Z",
- "username": "JonasFranzDEV"
- },
- "name": "drone-gitea-release",
- "full_name": "JonasFranzDEV/drone-gitea-release",
- "description": "",
- "empty": false,
- "private": false,
- "fork": false,
- "template": false,
- "parent": null,
- "mirror": false,
- "size": 380,
- "html_url": "https://try.gitea.io/JonasFranzDEV/drone-gitea-release",
- "ssh_url": "git@try.gitea.io:JonasFranzDEV/drone-gitea-release.git",
- "clone_url": "https://try.gitea.io/JonasFranzDEV/drone-gitea-release.git",
- "original_url": "",
- "website": "",
- "stars_count": 0,
- "forks_count": 0,
- "watchers_count": 1,
- "open_issues_count": 1,
- "open_pr_counter": 0,
- "release_counter": 2,
- "default_branch": "master",
- "archived": false,
- "created_at": "2018-03-30T19:34:44Z",
- "updated_at": "2018-05-29T20:09:40Z",
- "permissions": {
- "admin": false,
- "push": false,
- "pull": true
- },
- "has_issues": true,
- "internal_tracker": {
- "enable_time_tracker": true,
- "allow_only_contributors_to_track_time": true,
- "enable_issue_dependencies": true
- },
- "has_wiki": true,
- "has_pull_requests": true,
- "ignore_whitespace_conflicts": false,
- "allow_merge_commits": false,
- "allow_rebase": false,
- "allow_rebase_explicit": true,
- "allow_squash_merge": false,
- "avatar_url": ""
- },
- {
- "id": 5018,
- "owner": {
- "id": 4495,
- "login": "nick.korsakov",
- "full_name": "",
- "email": "nick@korsakov.email",
- "avatar_url": "https://try.gitea.io/user/avatar/nick.korsakov/-1",
- "language": "ru-RU",
- "is_admin": false,
- "last_login": "2020-02-15T10:29:10Z",
- "created": "2018-03-31T15:00:07Z",
- "username": "nick.korsakov"
- },
- "name": "one",
- "full_name": "nick.korsakov/one",
- "description": "",
- "empty": true,
- "private": false,
- "fork": false,
- "template": false,
- "parent": null,
- "mirror": false,
- "size": 0,
- "html_url": "https://try.gitea.io/nick.korsakov/one",
- "ssh_url": "git@try.gitea.io:nick.korsakov/one.git",
- "clone_url": "https://try.gitea.io/nick.korsakov/one.git",
- "original_url": "",
- "website": "",
- "stars_count": 0,
- "forks_count": 0,
- "watchers_count": 1,
- "open_issues_count": 0,
- "open_pr_counter": 0,
- "release_counter": 0,
- "default_branch": "master",
- "archived": false,
- "created_at": "2018-03-31T15:00:33Z",
- "updated_at": "2018-03-31T15:00:33Z",
- "permissions": {
- "admin": false,
- "push": false,
- "pull": true
- },
- "has_issues": true,
- "internal_tracker": {
- "enable_time_tracker": true,
- "allow_only_contributors_to_track_time": true,
- "enable_issue_dependencies": true
- },
- "has_wiki": true,
- "has_pull_requests": true,
- "ignore_whitespace_conflicts": false,
- "allow_merge_commits": false,
- "allow_rebase": false,
- "allow_rebase_explicit": true,
- "allow_squash_merge": false,
- "avatar_url": ""
- },
- {
- "id": 5030,
- "owner": {
- "id": 1623,
- "login": "xingshijun",
- "full_name": "",
- "email": "934302794@qq.com",
- "avatar_url": "https://try.gitea.io/user/avatar/xingshijun/-1",
- "language": "zh-CN",
- "is_admin": false,
- "last_login": "2019-06-15T12:28:43Z",
- "created": "2017-06-28T02:19:23Z",
- "username": "xingshijun"
- },
- "name": "lfzl",
- "full_name": "xingshijun/lfzl",
- "description": "",
- "empty": false,
- "private": false,
- "fork": false,
- "template": false,
- "parent": null,
- "mirror": false,
- "size": 10990,
- "html_url": "https://try.gitea.io/xingshijun/lfzl",
- "ssh_url": "git@try.gitea.io:xingshijun/lfzl.git",
- "clone_url": "https://try.gitea.io/xingshijun/lfzl.git",
- "original_url": "",
- "website": "",
- "stars_count": 0,
- "forks_count": 0,
- "watchers_count": 1,
- "open_issues_count": 0,
- "open_pr_counter": 0,
- "release_counter": 0,
- "default_branch": "master",
- "archived": false,
- "created_at": "2018-04-02T08:34:08Z",
- "updated_at": "2019-11-21T10:23:36Z",
- "permissions": {
- "admin": false,
- "push": false,
- "pull": true
- },
- "has_issues": true,
- "internal_tracker": {
- "enable_time_tracker": true,
- "allow_only_contributors_to_track_time": true,
- "enable_issue_dependencies": true
- },
- "has_wiki": true,
- "has_pull_requests": true,
- "ignore_whitespace_conflicts": false,
- "allow_merge_commits": false,
- "allow_rebase": false,
- "allow_rebase_explicit": true,
- "allow_squash_merge": false,
- "avatar_url": ""
- }
- ]
-}
\ No newline at end of file
diff --git a/swh/lister/gitea/tests/data/https_try.gitea.io/api_v1_repos_search,page=1,sort=id,order=asc,limit=3 b/swh/lister/gitea/tests/data/https_try.gitea.io/api_v1_repos_search,sort=id,order=asc,limit=3,page=1.json
rename from swh/lister/gitea/tests/data/https_try.gitea.io/api_v1_repos_search,page=1,sort=id,order=asc,limit=3
rename to swh/lister/gitea/tests/data/https_try.gitea.io/api_v1_repos_search,sort=id,order=asc,limit=3,page=1.json
--- a/swh/lister/gitea/tests/data/https_try.gitea.io/api_v1_repos_search,page=1,sort=id,order=asc,limit=3
+++ b/swh/lister/gitea/tests/data/https_try.gitea.io/api_v1_repos_search,sort=id,order=asc,limit=3,page=1.json
@@ -1,182 +1,195 @@
{
- "ok": true,
"data": [
{
+ "allow_merge_commits": false,
+ "allow_rebase": false,
+ "allow_rebase_explicit": true,
+ "allow_squash_merge": false,
+ "archived": false,
+ "avatar_url": "",
+ "clone_url": "https://try.gitea.io/JonasFranzDEV/drone-gitea-release.git",
+ "created_at": "2018-03-30T19:34:44Z",
+ "default_branch": "master",
+ "description": "",
+ "empty": false,
+ "fork": false,
+ "forks_count": 1,
+ "full_name": "JonasFranzDEV/drone-gitea-release",
+ "has_issues": true,
+ "has_projects": false,
+ "has_pull_requests": true,
+ "has_wiki": true,
+ "html_url": "https://try.gitea.io/JonasFranzDEV/drone-gitea-release",
"id": 5017,
+ "ignore_whitespace_conflicts": false,
+ "internal": false,
+ "internal_tracker": {
+ "allow_only_contributors_to_track_time": true,
+ "enable_issue_dependencies": true,
+ "enable_time_tracker": true
+ },
+ "mirror": false,
+ "mirror_interval": "",
+ "name": "drone-gitea-release",
+ "open_issues_count": 1,
+ "open_pr_counter": 0,
+ "original_url": "",
"owner": {
- "id": 1609,
- "login": "JonasFranzDEV",
- "full_name": "",
- "email": "info@jonasfranz.software",
"avatar_url": "https://try.gitea.io/user/avatar/JonasFranzDEV/-1",
- "language": "de-DE",
- "is_admin": false,
- "last_login": "2019-10-19T10:58:29Z",
"created": "2017-06-25T17:43:19Z",
+ "email": "info@jonasfranz.software",
+ "full_name": "",
+ "id": 1609,
+ "is_admin": false,
+ "language": "",
+ "last_login": "0001-01-01T00:00:00Z",
+ "login": "JonasFranzDEV",
"username": "JonasFranzDEV"
},
- "name": "drone-gitea-release",
- "full_name": "JonasFranzDEV/drone-gitea-release",
- "description": "",
- "empty": false,
- "private": false,
- "fork": false,
- "template": false,
"parent": null,
- "mirror": false,
+ "permissions": {
+ "admin": false,
+ "pull": true,
+ "push": false
+ },
+ "private": false,
+ "release_counter": 2,
"size": 380,
- "html_url": "https://try.gitea.io/JonasFranzDEV/drone-gitea-release",
"ssh_url": "git@try.gitea.io:JonasFranzDEV/drone-gitea-release.git",
- "clone_url": "https://try.gitea.io/JonasFranzDEV/drone-gitea-release.git",
- "original_url": "",
- "website": "",
"stars_count": 0,
- "forks_count": 0,
- "watchers_count": 1,
- "open_issues_count": 1,
- "open_pr_counter": 0,
- "release_counter": 2,
- "default_branch": "master",
- "archived": false,
- "created_at": "2018-03-30T19:34:44Z",
+ "template": false,
"updated_at": "2018-05-29T20:09:40Z",
- "permissions": {
- "admin": false,
- "push": false,
- "pull": true
- },
- "has_issues": true,
- "internal_tracker": {
- "enable_time_tracker": true,
- "allow_only_contributors_to_track_time": true,
- "enable_issue_dependencies": true
- },
- "has_wiki": true,
- "has_pull_requests": true,
- "ignore_whitespace_conflicts": false,
+ "watchers_count": 1,
+ "website": ""
+ },
+ {
"allow_merge_commits": false,
"allow_rebase": false,
"allow_rebase_explicit": true,
"allow_squash_merge": false,
- "avatar_url": ""
- },
- {
- "id": 5018,
- "owner": {
- "id": 4495,
- "login": "nick.korsakov",
- "full_name": "",
- "email": "nick@korsakov.email",
- "avatar_url": "https://try.gitea.io/user/avatar/nick.korsakov/-1",
- "language": "ru-RU",
- "is_admin": false,
- "last_login": "2020-02-15T10:29:10Z",
- "created": "2018-03-31T15:00:07Z",
- "username": "nick.korsakov"
- },
- "name": "one",
- "full_name": "nick.korsakov/one",
+ "archived": false,
+ "avatar_url": "",
+ "clone_url": "https://try.gitea.io/xingshijun/lfzl.git",
+ "created_at": "2018-04-02T08:34:08Z",
+ "default_branch": "master",
"description": "",
- "empty": true,
- "private": false,
+ "empty": false,
"fork": false,
- "template": false,
- "parent": null,
- "mirror": false,
- "size": 0,
- "html_url": "https://try.gitea.io/nick.korsakov/one",
- "ssh_url": "git@try.gitea.io:nick.korsakov/one.git",
- "clone_url": "https://try.gitea.io/nick.korsakov/one.git",
- "original_url": "",
- "website": "",
- "stars_count": 0,
"forks_count": 0,
- "watchers_count": 1,
- "open_issues_count": 0,
- "open_pr_counter": 0,
- "release_counter": 0,
- "default_branch": "master",
- "archived": false,
- "created_at": "2018-03-31T15:00:33Z",
- "updated_at": "2018-03-31T15:00:33Z",
- "permissions": {
- "admin": false,
- "push": false,
- "pull": true
- },
+ "full_name": "xingshijun/lfzl",
"has_issues": true,
+ "has_projects": false,
+ "has_pull_requests": true,
+ "has_wiki": true,
+ "html_url": "https://try.gitea.io/xingshijun/lfzl",
+ "id": 5030,
+ "ignore_whitespace_conflicts": false,
+ "internal": false,
"internal_tracker": {
- "enable_time_tracker": true,
"allow_only_contributors_to_track_time": true,
- "enable_issue_dependencies": true
+ "enable_issue_dependencies": true,
+ "enable_time_tracker": true
},
- "has_wiki": true,
- "has_pull_requests": true,
- "ignore_whitespace_conflicts": false,
- "allow_merge_commits": false,
- "allow_rebase": false,
- "allow_rebase_explicit": true,
- "allow_squash_merge": false,
- "avatar_url": ""
- },
- {
- "id": 5030,
+ "mirror": false,
+ "mirror_interval": "",
+ "name": "lfzl",
+ "open_issues_count": 0,
+ "open_pr_counter": 0,
+ "original_url": "",
"owner": {
- "id": 1623,
- "login": "xingshijun",
- "full_name": "",
- "email": "934302794@qq.com",
"avatar_url": "https://try.gitea.io/user/avatar/xingshijun/-1",
- "language": "zh-CN",
- "is_admin": false,
- "last_login": "2019-06-15T12:28:43Z",
"created": "2017-06-28T02:19:23Z",
+ "email": "934302794@qq.com",
+ "full_name": "",
+ "id": 1623,
+ "is_admin": false,
+ "language": "",
+ "last_login": "0001-01-01T00:00:00Z",
+ "login": "xingshijun",
"username": "xingshijun"
},
- "name": "lfzl",
- "full_name": "xingshijun/lfzl",
- "description": "",
- "empty": false,
- "private": false,
- "fork": false,
- "template": false,
"parent": null,
- "mirror": false,
- "size": 10990,
- "html_url": "https://try.gitea.io/xingshijun/lfzl",
+ "permissions": {
+ "admin": false,
+ "pull": true,
+ "push": false
+ },
+ "private": false,
+ "release_counter": 0,
+ "size": 10997,
"ssh_url": "git@try.gitea.io:xingshijun/lfzl.git",
- "clone_url": "https://try.gitea.io/xingshijun/lfzl.git",
- "original_url": "",
- "website": "",
"stars_count": 0,
- "forks_count": 0,
+ "template": false,
+ "updated_at": "2020-04-16T08:39:18Z",
"watchers_count": 1,
- "open_issues_count": 0,
- "open_pr_counter": 0,
- "release_counter": 0,
- "default_branch": "master",
+ "website": ""
+ },
+ {
+ "allow_merge_commits": false,
+ "allow_rebase": false,
+ "allow_rebase_explicit": true,
+ "allow_squash_merge": false,
"archived": false,
- "created_at": "2018-04-02T08:34:08Z",
- "updated_at": "2019-11-21T10:23:36Z",
- "permissions": {
- "admin": false,
- "push": false,
- "pull": true
- },
+ "avatar_url": "",
+ "clone_url": "https://try.gitea.io/ulm0/negroni.git",
+ "created_at": "2018-04-02T17:30:26Z",
+ "default_branch": "master",
+ "description": "Idiomatic HTTP Middleware for Golang",
+ "empty": false,
+ "fork": false,
+ "forks_count": 1,
+ "full_name": "ulm0/negroni",
"has_issues": true,
+ "has_projects": false,
+ "has_pull_requests": true,
+ "has_wiki": true,
+ "html_url": "https://try.gitea.io/ulm0/negroni",
+ "id": 5034,
+ "ignore_whitespace_conflicts": false,
+ "internal": false,
"internal_tracker": {
- "enable_time_tracker": true,
"allow_only_contributors_to_track_time": true,
- "enable_issue_dependencies": true
+ "enable_issue_dependencies": true,
+ "enable_time_tracker": true
},
- "has_wiki": true,
- "has_pull_requests": true,
- "ignore_whitespace_conflicts": false,
- "allow_merge_commits": false,
- "allow_rebase": false,
- "allow_rebase_explicit": true,
- "allow_squash_merge": false,
- "avatar_url": ""
+ "mirror": true,
+ "mirror_interval": "8h0m0s",
+ "name": "negroni",
+ "open_issues_count": 0,
+ "open_pr_counter": 0,
+ "original_url": "",
+ "owner": {
+ "avatar_url": "https://try.gitea.io/user/avatar/ulm0/-1",
+ "created": "2017-07-09T18:58:34Z",
+ "email": "ulm0@innersea.xyz",
+ "full_name": "Mauricio Ugaz",
+ "id": 1706,
+ "is_admin": false,
+ "language": "",
+ "last_login": "0001-01-01T00:00:00Z",
+ "login": "ulm0",
+ "username": "ulm0"
+ },
+ "parent": null,
+ "permissions": {
+ "admin": false,
+ "pull": true,
+ "push": false
+ },
+ "private": false,
+ "release_counter": 7,
+ "size": 17739,
+ "ssh_url": "git@try.gitea.io:ulm0/negroni.git",
+ "stars_count": 0,
+ "template": false,
+ "updated_at": "2020-11-14T17:50:56Z",
+ "watchers_count": 1,
+ "website": ""
}
- ]
+ ],
+ "ok": true,
+ "links": {
+ "next": "https://try.gitea.io/api/v1/repos/search?limit=3&order=asc&page=2&sort=id",
+ "last": "https://try.gitea.io/api/v1/repos/search?limit=3&order=asc&page=2282&sort=id"
+ }
}
\ No newline at end of file
diff --git a/swh/lister/gitea/tests/data/https_try.gitea.io/api_v1_repos_search,sort=id,order=asc,limit=3,page=2.json b/swh/lister/gitea/tests/data/https_try.gitea.io/api_v1_repos_search,sort=id,order=asc,limit=3,page=2.json
new file mode 100644
--- /dev/null
+++ b/swh/lister/gitea/tests/data/https_try.gitea.io/api_v1_repos_search,sort=id,order=asc,limit=3,page=2.json
@@ -0,0 +1,252 @@
+{
+ "data": [
+ {
+ "allow_merge_commits": false,
+ "allow_rebase": false,
+ "allow_rebase_explicit": true,
+ "allow_squash_merge": false,
+ "archived": false,
+ "avatar_url": "",
+ "clone_url": "https://try.gitea.io/ulm0/mux.git",
+ "created_at": "2018-04-02T17:35:13Z",
+ "default_branch": "master",
+ "description": "A powerful URL router and dispatcher for golang.",
+ "empty": false,
+ "fork": false,
+ "forks_count": 1,
+ "full_name": "ulm0/mux",
+ "has_issues": true,
+ "has_projects": false,
+ "has_pull_requests": true,
+ "has_wiki": true,
+ "html_url": "https://try.gitea.io/ulm0/mux",
+ "id": 5035,
+ "ignore_whitespace_conflicts": false,
+ "internal": false,
+ "internal_tracker": {
+ "allow_only_contributors_to_track_time": true,
+ "enable_issue_dependencies": true,
+ "enable_time_tracker": true
+ },
+ "mirror": true,
+ "mirror_interval": "8h0m0s",
+ "name": "mux",
+ "open_issues_count": 0,
+ "open_pr_counter": 0,
+ "original_url": "",
+ "owner": {
+ "avatar_url": "https://try.gitea.io/user/avatar/ulm0/-1",
+ "created": "2017-07-09T18:58:34Z",
+ "email": "ulm0@innersea.xyz",
+ "full_name": "Mauricio Ugaz",
+ "id": 1706,
+ "is_admin": false,
+ "language": "",
+ "last_login": "0001-01-01T00:00:00Z",
+ "login": "ulm0",
+ "username": "ulm0"
+ },
+ "parent": null,
+ "permissions": {
+ "admin": false,
+ "pull": true,
+ "push": false
+ },
+ "private": false,
+ "release_counter": 14,
+ "size": 2512,
+ "ssh_url": "git@try.gitea.io:ulm0/mux.git",
+ "stars_count": 0,
+ "template": false,
+ "updated_at": "2020-09-12T19:20:56Z",
+ "watchers_count": 1,
+ "website": "http://www.gorillatoolkit.org/pkg/mux"
+ },
+ {
+ "allow_merge_commits": false,
+ "allow_rebase": false,
+ "allow_rebase_explicit": true,
+ "allow_squash_merge": false,
+ "archived": false,
+ "avatar_url": "",
+ "clone_url": "https://try.gitea.io/ligh0721/negroni.git",
+ "created_at": "2018-04-03T10:41:41Z",
+ "default_branch": "master",
+ "description": "Idiomatic HTTP Middleware for Golang",
+ "empty": false,
+ "fork": true,
+ "forks_count": 0,
+ "full_name": "ligh0721/negroni",
+ "has_issues": true,
+ "has_projects": false,
+ "has_pull_requests": true,
+ "has_wiki": true,
+ "html_url": "https://try.gitea.io/ligh0721/negroni",
+ "id": 5045,
+ "ignore_whitespace_conflicts": false,
+ "internal": false,
+ "internal_tracker": {
+ "allow_only_contributors_to_track_time": true,
+ "enable_issue_dependencies": true,
+ "enable_time_tracker": true
+ },
+ "mirror": false,
+ "mirror_interval": "",
+ "name": "negroni",
+ "open_issues_count": 0,
+ "open_pr_counter": 0,
+ "original_url": "",
+ "owner": {
+ "avatar_url": "https://try.gitea.io/user/avatar/ligh0721/-1",
+ "created": "2018-04-03T10:37:01Z",
+ "email": "lightning_0721@163.com",
+ "full_name": "",
+ "id": 4534,
+ "is_admin": false,
+ "language": "",
+ "last_login": "0001-01-01T00:00:00Z",
+ "login": "ligh0721",
+ "username": "ligh0721"
+ },
+ "parent": {
+ "allow_merge_commits": false,
+ "allow_rebase": false,
+ "allow_rebase_explicit": true,
+ "allow_squash_merge": false,
+ "archived": false,
+ "avatar_url": "",
+ "clone_url": "https://try.gitea.io/ulm0/negroni.git",
+ "created_at": "2018-04-02T17:30:26Z",
+ "default_branch": "master",
+ "description": "Idiomatic HTTP Middleware for Golang",
+ "empty": false,
+ "fork": false,
+ "forks_count": 1,
+ "full_name": "ulm0/negroni",
+ "has_issues": true,
+ "has_projects": false,
+ "has_pull_requests": true,
+ "has_wiki": true,
+ "html_url": "https://try.gitea.io/ulm0/negroni",
+ "id": 5034,
+ "ignore_whitespace_conflicts": false,
+ "internal": false,
+ "internal_tracker": {
+ "allow_only_contributors_to_track_time": true,
+ "enable_issue_dependencies": true,
+ "enable_time_tracker": true
+ },
+ "mirror": true,
+ "mirror_interval": "8h0m0s",
+ "name": "negroni",
+ "open_issues_count": 0,
+ "open_pr_counter": 0,
+ "original_url": "",
+ "owner": {
+ "avatar_url": "https://try.gitea.io/user/avatar/ulm0/-1",
+ "created": "2017-07-09T18:58:34Z",
+ "email": "ulm0@innersea.xyz",
+ "full_name": "Mauricio Ugaz",
+ "id": 1706,
+ "is_admin": false,
+ "language": "",
+ "last_login": "0001-01-01T00:00:00Z",
+ "login": "ulm0",
+ "username": "ulm0"
+ },
+ "parent": null,
+ "permissions": {
+ "admin": false,
+ "pull": true,
+ "push": false
+ },
+ "private": false,
+ "release_counter": 7,
+ "size": 17739,
+ "ssh_url": "git@try.gitea.io:ulm0/negroni.git",
+ "stars_count": 0,
+ "template": false,
+ "updated_at": "2020-11-14T17:50:56Z",
+ "watchers_count": 1,
+ "website": ""
+ },
+ "permissions": {
+ "admin": false,
+ "pull": true,
+ "push": false
+ },
+ "private": false,
+ "release_counter": 3,
+ "size": 344,
+ "ssh_url": "git@try.gitea.io:ligh0721/negroni.git",
+ "stars_count": 0,
+ "template": false,
+ "updated_at": "2018-04-03T10:41:41Z",
+ "watchers_count": 1,
+ "website": ""
+ },
+ {
+ "allow_merge_commits": false,
+ "allow_rebase": false,
+ "allow_rebase_explicit": true,
+ "allow_squash_merge": false,
+ "archived": false,
+ "avatar_url": "",
+ "clone_url": "https://try.gitea.io/user12312341324124/Tiny.git",
+ "created_at": "2018-04-03T13:08:29Z",
+ "default_branch": "master",
+ "description": "",
+ "empty": false,
+ "fork": false,
+ "forks_count": 1,
+ "full_name": "user12312341324124/Tiny",
+ "has_issues": true,
+ "has_projects": false,
+ "has_pull_requests": true,
+ "has_wiki": true,
+ "html_url": "https://try.gitea.io/user12312341324124/Tiny",
+ "id": 5046,
+ "ignore_whitespace_conflicts": false,
+ "internal": false,
+ "internal_tracker": {
+ "allow_only_contributors_to_track_time": true,
+ "enable_issue_dependencies": true,
+ "enable_time_tracker": true
+ },
+ "mirror": false,
+ "mirror_interval": "",
+ "name": "Tiny",
+ "open_issues_count": 1,
+ "open_pr_counter": 0,
+ "original_url": "",
+ "owner": {
+ "avatar_url": "https://try.gitea.io/user/avatar/user12312341324124/-1",
+ "created": "2018-04-03T13:07:45Z",
+ "email": "z333676@mvrht.net",
+ "full_name": "",
+ "id": 4536,
+ "is_admin": false,
+ "language": "",
+ "last_login": "0001-01-01T00:00:00Z",
+ "login": "user12312341324124",
+ "username": "user12312341324124"
+ },
+ "parent": null,
+ "permissions": {
+ "admin": false,
+ "pull": true,
+ "push": false
+ },
+ "private": false,
+ "release_counter": 0,
+ "size": 110,
+ "ssh_url": "git@try.gitea.io:user12312341324124/Tiny.git",
+ "stars_count": 0,
+ "template": false,
+ "updated_at": "2018-04-03T13:08:29Z",
+ "watchers_count": 1,
+ "website": ""
+ }
+ ],
+ "ok": true
+}
\ No newline at end of file
diff --git a/swh/lister/gitea/tests/test_lister.py b/swh/lister/gitea/tests/test_lister.py
--- a/swh/lister/gitea/tests/test_lister.py
+++ b/swh/lister/gitea/tests/test_lister.py
@@ -3,56 +3,132 @@
# License: GNU General Public License version 3, or any later version
# See top-level LICENSE file for more information
-import logging
-import re
-import unittest
-
-from swh.lister.core.tests.test_lister import HttpListerTesterBase
-from swh.lister.gitea.lister import GiteaLister
-
-logger = logging.getLogger(__name__)
-
-
-class GiteaListerTester(HttpListerTesterBase, unittest.TestCase):
- Lister = GiteaLister
- test_re = re.compile(r"^.*/projects.*page=(\d+).*")
- lister_subdir = "gitea"
- good_api_response_file = "data/https_try.gitea.io/api_response.json"
- bad_api_response_file = "data/https_try.gitea.io/api_empty_response.json"
- first_index = 1
- last_index = 2
- entries_per_page = 3
- convert_type = int
-
- def response_headers(self, request):
- headers = {}
- if self.request_index(request) == self.first_index:
- headers.update(
- {
- "Link": "<https://try.gitea.io/api/v1\
- /repos/search?&page=%s&sort=id>;"
- ' rel="next"' % self.last_index
- }
- )
-
- return headers
-
-
-def test_lister_gitea(lister_gitea, requests_mock_datadir):
- lister_gitea.run()
- r = lister_gitea.scheduler.search_tasks(task_type="load-git")
- assert len(r) == 3
-
- for row in r:
- assert row["type"] == "load-git"
- # arguments check
- args = row["arguments"]["args"]
- assert len(args) == 0
-
- # kwargs
- kwargs = row["arguments"]["kwargs"]
- url = kwargs["url"]
- assert url.startswith("https://try.gitea.io")
-
- assert row["policy"] == "recurring"
- assert row["priority"] is None
+import json
+from pathlib import Path
+from typing import Dict, List, Tuple
+
+import pytest
+import requests
+
+from swh.lister.gitea.lister import GiteaLister, RepoListPage
+from swh.scheduler.model import ListedOrigin
+
+
+@pytest.fixture
+def trygitea_p1(datadir) -> Tuple[str, str, Dict[str, str], RepoListPage, List[str]]:
+ url = "https://try.gitea.io/api/v1/repos/search?sort=id&order=asc&limit=3&page=1"
+ text = Path(
+ datadir,
+ "https_try.gitea.io",
+ "api_v1_repos_search,sort=id,order=asc,limit=3,page=1.json",
+ ).read_text()
+ headers: Dict[str, str] = {
+ "Link": '<https://try.gitea.io/api/v1/repos/search?limit=3&order=asc&page=2&sort=id>; rel="next",' # noqa
+ '<https://try.gitea.io/api/v1/repos/search?limit=3&order=asc&page=2282&sort=id>; rel="last"' # noqa
+ }
+ page_result = GiteaLister.results_simplified(json.loads(text))
+ origin_urls = [r["clone_url"] for r in page_result]
+ return url, text, headers, page_result, origin_urls
+
+
+@pytest.fixture
+def trygitea_p2(datadir) -> Tuple[str, str, Dict[str, str], RepoListPage, List[str]]:
+ url = "https://try.gitea.io/api/v1/repos/search?sort=id&order=asc&limit=3&page=2"
+ text = Path(
+ datadir,
+ "https_try.gitea.io",
+ "api_v1_repos_search,sort=id,order=asc,limit=3,page=2.json",
+ ).read_text()
+ headers: Dict[str, str] = {
+ "Link": '<https://try.gitea.io/api/v1/repos/search?limit=3&order=asc&page=1&sort=id>; rel="prev",' # noqa
+ '<https://try.gitea.io/api/v1/repos/search?limit=3&order=asc&page=1&sort=id>; rel="first"' # noqa
+ }
+ page_result = GiteaLister.results_simplified(json.loads(text))
+ origin_urls = [r["clone_url"] for r in page_result]
+ return url, text, headers, page_result, origin_urls
+
+
+def check_listed_origins(lister_urls: List[str], scheduler_origins: List[ListedOrigin]):
+ """Asserts that the two collections have the same origin URLs.
+
+ Does not test last_update."""
+
+ sorted_lister_urls = list(sorted(lister_urls))
+ sorted_scheduler_origins = list(sorted(scheduler_origins))
+
+ assert len(sorted_lister_urls) == len(sorted_scheduler_origins)
+
+ for l_url, s_origin in zip(sorted_lister_urls, sorted_scheduler_origins):
+ assert l_url == s_origin.url
+
+
+def test_gitea_full_listing(
+ swh_scheduler, requests_mock, mocker, trygitea_p1, trygitea_p2
+):
+ """Covers full listing of multiple pages, instance inference from URL,
+ rate-limit, token authentication, token from credentials,
+ page size (required for test), checking page results and listed origins,
+ statelessness."""
+
+ baseurl = "https://try.gitea.io/api/v1/"
+ p1_url, p1_text, p1_headers, p1_result, p1_origin_urls = trygitea_p1
+ p2_url, p2_text, p2_headers, p2_result, p2_origin_urls = trygitea_p2
+
+ requests_mock.get(p1_url, text=p1_text, headers=p1_headers)
+ requests_mock.get(
+ p2_url,
+ [
+ {"status_code": requests.codes.too_many_requests},
+ {"text": p2_text, "headers": p2_headers},
+ ],
+ )
+
+ instance = "try.gitea.io"
+ api_token = "p"
+ creds = {"gitea": {instance: [{"username": "u", "password": api_token}]}}
+ kwargs = dict(url=baseurl, page_size=3, credentials=creds)
+
+ lister = GiteaLister(scheduler=swh_scheduler, **kwargs)
+
+ assert lister.instance == instance
+ assert (
+ "Authorization" in lister.session.headers
+ and lister.session.headers["Authorization"] == "token %s" % api_token
+ )
+
+ lister.get_origins_from_page = mocker.spy(lister, "get_origins_from_page")
+
+ # end test setup
+
+ stats = lister.run()
+
+ # start test checks
+
+ assert stats.pages == 2
+ assert stats.origins == 6
+
+ calls = [mocker.call(p1_result), mocker.call(p2_result)]
+ lister.get_origins_from_page.assert_has_calls(calls)
+
+ scheduler_origins = swh_scheduler.get_listed_origins(lister.lister_obj.id).origins
+
+ assert lister.get_state_from_scheduler() is None
+ check_listed_origins(p1_origin_urls + p2_origin_urls, scheduler_origins)
+
+
+@pytest.mark.parametrize("http_code", [400, 500, 502])
+def test_gitea_list_http_error(swh_scheduler, requests_mock, trygitea_p1, http_code):
+ """Test handling of some HTTP errors commonly encountered"""
+
+ baseurl = "https://try.gitea.io/api/v1/"
+ p1_url, _, _, _, _ = trygitea_p1
+
+ requests_mock.get(p1_url, status_code=http_code)
+
+ lister = GiteaLister(scheduler=swh_scheduler, url=baseurl, page_size=3)
+
+ with pytest.raises(requests.HTTPError):
+ lister.run()
+
+ scheduler_origins = swh_scheduler.get_listed_origins(lister.lister_obj.id).origins
+ assert len(scheduler_origins) == 0
diff --git a/swh/lister/gitea/tests/test_tasks.py b/swh/lister/gitea/tests/test_tasks.py
--- a/swh/lister/gitea/tests/test_tasks.py
+++ b/swh/lister/gitea/tests/test_tasks.py
@@ -3,13 +3,9 @@
# License: GNU General Public License version 3, or any later version
# See top-level LICENSE file for more information
-from time import sleep
-from unittest.mock import call, patch
+from unittest.mock import patch
-from celery.result import GroupResult
-
-from swh.lister.gitea.tasks import NBPAGES
-from swh.lister.utils import split_range
+from swh.lister.pattern import ListerStats
def test_ping(swh_scheduler_celery_app, swh_scheduler_celery_worker):
@@ -21,125 +17,43 @@
@patch("swh.lister.gitea.tasks.GiteaLister")
-def test_incremental(lister, swh_scheduler_celery_app, swh_scheduler_celery_worker):
- # setup the mocked GiteaLister
- lister.return_value = lister
- lister.run.return_value = None
- lister.get_pages_information.return_value = (None, 10, None)
+def test_full_listing(lister, swh_scheduler_celery_app, swh_scheduler_celery_worker):
+ lister.from_configfile.return_value = lister
+ lister.run.return_value = ListerStats(pages=10, origins=500)
+ kwargs = dict(url="https://try.gitea.io/api/v1")
res = swh_scheduler_celery_app.send_task(
- "swh.lister.gitea.tasks.IncrementalGiteaLister"
+ "swh.lister.gitea.tasks.FullGiteaRelister", kwargs=kwargs,
)
assert res
res.wait()
assert res.successful()
- lister.assert_called_once_with(order="desc")
- lister.db_last_index.assert_not_called()
- lister.get_pages_information.assert_called_once_with()
- lister.run.assert_called_once_with(min_bound=1, max_bound=10, check_existence=True)
-
-
-@patch("swh.lister.gitea.tasks.GiteaLister")
-def test_range(lister, swh_scheduler_celery_app, swh_scheduler_celery_worker):
- # setup the mocked GiteaLister
- lister.return_value = lister
- lister.run.return_value = None
-
- res = swh_scheduler_celery_app.send_task(
- "swh.lister.gitea.tasks.RangeGiteaLister", kwargs=dict(start=12, end=42)
- )
- assert res
- res.wait()
- assert res.successful()
-
- lister.assert_called_once_with()
- lister.db_last_index.assert_not_called()
- lister.run.assert_called_once_with(min_bound=12, max_bound=42)
-
-
-@patch("swh.lister.gitea.tasks.GiteaLister")
-def test_relister(lister, swh_scheduler_celery_app, swh_scheduler_celery_worker):
- total_pages = 85
- # setup the mocked GiteaLister
- lister.return_value = lister
- lister.run.return_value = None
- lister.get_pages_information.return_value = (None, total_pages, None)
-
- res = swh_scheduler_celery_app.send_task("swh.lister.gitea.tasks.FullGiteaRelister")
- assert res
-
- res.wait()
- assert res.successful()
-
- # retrieve the GroupResult for this task and wait for all the subtasks
- # to complete
- promise_id = res.result
- assert promise_id
- promise = GroupResult.restore(promise_id, app=swh_scheduler_celery_app)
- for i in range(5):
- if promise.ready():
- break
- sleep(1)
-
- lister.assert_called_with()
-
- # one by the FullGiteaRelister task
- # + 9 for the RangeGiteaLister subtasks
- assert lister.call_count == 10
-
- lister.db_last_index.assert_not_called()
- lister.db_partition_indices.assert_not_called()
- lister.get_pages_information.assert_called_once_with()
+ actual_kwargs = dict(**kwargs, instance=None, api_token=None, page_size=None)
- # lister.run should have been called once per partition interval
- for min_bound, max_bound in split_range(total_pages, NBPAGES):
- assert (
- call(min_bound=min_bound, max_bound=max_bound) in lister.run.call_args_list
- )
+ lister.from_configfile.assert_called_once_with(**actual_kwargs)
+ lister.run.assert_called_once_with()
@patch("swh.lister.gitea.tasks.GiteaLister")
-def test_relister_instance(
+def test_full_listing_params(
lister, swh_scheduler_celery_app, swh_scheduler_celery_worker
):
- total_pages = 85
- # setup the mocked GiteaLister
- lister.return_value = lister
- lister.run.return_value = None
- lister.get_pages_information.return_value = (None, total_pages, None)
-
+ lister.from_configfile.return_value = lister
+ lister.run.return_value = ListerStats(pages=10, origins=500)
+
+ kwargs = dict(
+ url="https://0xacab.org/api/v4",
+ instance="0xacab",
+ api_token="test",
+ page_size=50,
+ )
res = swh_scheduler_celery_app.send_task(
- "swh.lister.gitea.tasks.FullGiteaRelister",
- kwargs=dict(url="https://0xacab.org/api/v4"),
+ "swh.lister.gitea.tasks.FullGiteaRelister", kwargs=kwargs,
)
assert res
-
res.wait()
assert res.successful()
- # retrieve the GroupResult for this task and wait for all the subtasks
- # to complete
- promise_id = res.result
- assert promise_id
- promise = GroupResult.restore(promise_id, app=swh_scheduler_celery_app)
- for i in range(5):
- if promise.ready():
- break
- sleep(1)
-
- lister.assert_called_with(url="https://0xacab.org/api/v4")
-
- # one by the FullGiteaRelister task
- # + 9 for the RangeGiteaLister subtasks
- assert lister.call_count == 10
-
- lister.db_last_index.assert_not_called()
- lister.db_partition_indices.assert_not_called()
- lister.get_pages_information.assert_called_once_with()
-
- # lister.run should have been called once per partition interval
- for min_bound, max_bound in split_range(total_pages, NBPAGES):
- assert (
- call(min_bound=min_bound, max_bound=max_bound) in lister.run.call_args_list
- )
+ lister.from_configfile.assert_called_once_with(**kwargs)
+ lister.run.assert_called_once_with()
diff --git a/swh/lister/tests/test_cli.py b/swh/lister/tests/test_cli.py
--- a/swh/lister/tests/test_cli.py
+++ b/swh/lister/tests/test_cli.py
@@ -15,6 +15,7 @@
"url": "https://forge.softwareheritage.org/api/diffusion.repository.search",
"api_token": "bogus",
},
+ "gitea": {"url": "https://try.gitea.io/api/v1/",},
}
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Dec 17 2024, 7:45 AM (14 w, 16 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3224087
Attached To
D4907: Reimplement Gitea lister using new Lister API
Event Timeline
Log In to Comment