Skip to content

Commit 426ae7a

Browse files
committed
WIP: feat(vcs): service layer
* Created a new VCSService class to act as the 'glue' between the new generic provider API methods and the view handlers, with mostly higher-level methods that combine multiple API calls and reads/writes to the DB. * Also included the modified VCSRelease class in the same file. This has been kept to maintain good compatibility with InvenioRDM and anyone else who might have overriden it. However, it has been updated to support the new generic structure. There have also been some small changes such as the variable naming (`release_object` and `release_payload` to `db_release` and `generic_release` to make more clear where the data comes from). * The OAuth handlers, tasks, and webhook receivers are also updated as part of this PR, again with small changes to make them compatible with the generic structure.
1 parent a841b14 commit 426ae7a

File tree

5 files changed

+1080
-0
lines changed

5 files changed

+1080
-0
lines changed

invenio_vcs/config.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# This file is part of Invenio.
4+
# Copyright (C) 2023 CERN.
5+
#
6+
# Invenio is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU General Public License as published by
8+
# the Free Software Foundation, either version 3 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# Invenio is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU General Public License
17+
# along with Invenio. If not, see <http://www.gnu.org/licenses/>.
18+
#
19+
# In applying this licence, CERN does not waive the privileges and immunities
20+
# granted to it by virtue of its status as an Intergovernmental Organization
21+
# or submit itself to any jurisdiction.
22+
23+
"""Configuration for GitHub module."""
24+
25+
from typing import TYPE_CHECKING
26+
27+
from flask import current_app
28+
29+
if TYPE_CHECKING:
30+
from invenio_vcs.providers import RepositoryServiceProviderFactory
31+
32+
VCS_PROVIDERS = []
33+
34+
VCS_RELEASE_CLASS = "invenio_vcs.service:VCSRelease"
35+
"""GitHubRelease class to be used for release handling."""
36+
37+
VCS_TEMPLATE_INDEX = "invenio_vcs/settings/index.html"
38+
"""Repositories list template."""
39+
40+
VCS_TEMPLATE_VIEW = "invenio_vcs/settings/view.html"
41+
"""Repository detail view template."""
42+
43+
VCS_ERROR_HANDLERS = None
44+
"""Definition of the way specific exceptions are handled."""
45+
46+
VCS_MAX_CONTRIBUTORS_NUMBER = 30
47+
"""Max number of contributors of a release to be retrieved from Github."""
48+
49+
VCS_CITATION_FILE = None
50+
"""Citation file name."""
51+
52+
VCS_CITATION_METADATA_SCHEMA = None
53+
"""Citation metadata schema."""
54+
55+
VCS_ZIPBALL_TIMEOUT = 300
56+
"""Timeout for the zipball download, in seconds."""
57+
58+
59+
def get_provider_list(app=current_app) -> list["RepositoryServiceProviderFactory"]:
60+
return app.config["VCS_PROVIDERS"]
61+
62+
63+
def get_provider_by_id(id: str) -> "RepositoryServiceProviderFactory":
64+
providers = get_provider_list()
65+
for provider in providers:
66+
if id == provider.id:
67+
return provider
68+
raise Exception(f"VCS provider with ID {id} not registered")

invenio_vcs/oauth/handlers.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# -*- coding: utf-8 -*-
2+
# This file is part of Invenio.
3+
# Copyright (C) 2025 CERN.
4+
#
5+
# Invenio is free software; you can redistribute it and/or modify it
6+
# under the terms of the MIT License; see LICENSE file for more details.
7+
8+
"""Implement OAuth client handler."""
9+
10+
import typing
11+
12+
from flask import current_app, redirect, url_for
13+
from flask_login import current_user
14+
from invenio_db import db
15+
from invenio_oauth2server.models import Token as ProviderToken
16+
from invenio_oauthclient import oauth_unlink_external_id
17+
18+
from invenio_vcs.service import VCSService
19+
from invenio_vcs.tasks import disconnect_provider
20+
21+
if typing.TYPE_CHECKING:
22+
from invenio_vcs.providers import RepositoryServiceProviderFactory
23+
24+
25+
class OAuthHandlers:
26+
def __init__(self, provider_factory: "RepositoryServiceProviderFactory") -> None:
27+
self.provider_factory = provider_factory
28+
29+
def account_setup_handler(self, remote, token, resp):
30+
"""Perform post initialization."""
31+
try:
32+
svc = VCSService(
33+
self.provider_factory.for_user(token.remote_account.user_id)
34+
)
35+
svc.init_account()
36+
svc.sync()
37+
db.session.commit()
38+
except Exception as e:
39+
current_app.logger.warning(str(e), exc_info=True)
40+
41+
def disconnect_handler(self, remote):
42+
"""Disconnect callback handler for GitHub."""
43+
# User must be authenticated
44+
if not current_user.is_authenticated:
45+
return current_app.login_manager.unauthorized()
46+
47+
external_method = self.provider_factory.id
48+
external_ids = [
49+
i.id
50+
for i in current_user.external_identifiers
51+
if i.method == external_method
52+
]
53+
if external_ids:
54+
oauth_unlink_external_id(dict(id=external_ids[0], method=external_method))
55+
56+
svc = VCSService(self.provider_factory.for_user(current_user.id))
57+
token = svc.provider.session_token
58+
59+
if token:
60+
extra_data = token.remote_account.extra_data
61+
62+
# Delete the token that we issued for GitHub to deliver webhooks
63+
webhook_token_id = extra_data.get("tokens", {}).get("webhook")
64+
ProviderToken.query.filter_by(id=webhook_token_id).delete()
65+
66+
# Disable every GitHub webhooks from our side
67+
repos = svc.user_enabled_repositories.all()
68+
repos_with_hooks = []
69+
for repo in repos:
70+
if repo.hook:
71+
repos_with_hooks.append((repo.provider_id, repo.hook))
72+
svc.disable_repository(repo.provider_id)
73+
74+
# Commit any changes before running the ascynhronous task
75+
db.session.commit()
76+
77+
# Send Celery task for webhooks removal and token revocation
78+
disconnect_provider.delay(
79+
self.provider_factory.id,
80+
current_user.id,
81+
token.access_token,
82+
repos_with_hooks,
83+
)
84+
85+
# Delete the RemoteAccount (along with the associated RemoteToken)
86+
token.remote_account.delete()
87+
db.session.commit()
88+
89+
return redirect(url_for("invenio_oauthclient_settings.index"))

invenio_vcs/receivers.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# This file is part of Invenio.
4+
# Copyright (C) 2023 CERN.
5+
#
6+
# Invenio is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU General Public License as published by
8+
# the Free Software Foundation, either version 3 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# Invenio is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU General Public License
17+
# along with Invenio. If not, see <http://www.gnu.org/licenses/>.
18+
#
19+
# In applying this licence, CERN does not waive the privileges and immunities
20+
# granted to it by virtue of its status as an Intergovernmental Organization
21+
# or submit itself to any jurisdiction.
22+
23+
"""Task for managing GitHub integration."""
24+
25+
from invenio_db import db
26+
from invenio_webhooks.models import Receiver
27+
28+
from invenio_vcs.config import get_provider_by_id
29+
from invenio_vcs.models import Release, ReleaseStatus, Repository
30+
from invenio_vcs.tasks import process_release
31+
32+
from .errors import (
33+
InvalidSenderError,
34+
ReleaseAlreadyReceivedError,
35+
RepositoryAccessError,
36+
RepositoryDisabledError,
37+
RepositoryNotFoundError,
38+
)
39+
40+
41+
class VCSReceiver(Receiver):
42+
"""Handle incoming notification from GitHub on a new release."""
43+
44+
def __init__(self, receiver_id):
45+
super().__init__(receiver_id)
46+
self.provider_factory = get_provider_by_id(receiver_id)
47+
48+
def run(self, event):
49+
"""Process an event.
50+
51+
.. note::
52+
53+
We should only do basic server side operation here, since we send
54+
the rest of the processing to a Celery task which will be mainly
55+
accessing the GitHub API.
56+
"""
57+
self._handle_event(event)
58+
59+
def _handle_event(self, event):
60+
"""Handles an incoming github event."""
61+
is_create_release_event = self.provider_factory.webhook_is_create_release_event(
62+
event.payload
63+
)
64+
65+
if is_create_release_event:
66+
self._handle_create_release(event)
67+
68+
def _handle_create_release(self, event):
69+
"""Creates a release in invenio."""
70+
try:
71+
generic_release, generic_repo = (
72+
self.provider_factory.webhook_event_to_generic(event.payload)
73+
)
74+
75+
# Check if the release already exists
76+
existing_release = Release.query.filter_by(
77+
provider_id=generic_release.id,
78+
).first()
79+
80+
if existing_release:
81+
raise ReleaseAlreadyReceivedError(release=existing_release)
82+
83+
# Create the Release
84+
repo = Repository.get(
85+
self.provider_factory.id,
86+
provider_id=generic_repo.id,
87+
full_name=generic_repo.full_name,
88+
)
89+
if not repo:
90+
raise RepositoryNotFoundError(generic_repo.full_name)
91+
92+
if repo.enabled:
93+
release = Release(
94+
provider_id=generic_release.id,
95+
provider=self.provider_factory.id,
96+
tag=generic_release.tag_name,
97+
repository=repo,
98+
event=event,
99+
status=ReleaseStatus.RECEIVED,
100+
)
101+
db.session.add(release)
102+
else:
103+
raise RepositoryDisabledError(repo=repo)
104+
105+
# Process the release
106+
# Since 'process_release' is executed asynchronously, we commit the current state of session
107+
db.session.commit()
108+
process_release.delay(self.provider_factory.id, release.provider_id)
109+
110+
except (ReleaseAlreadyReceivedError, RepositoryDisabledError) as e:
111+
event.response_code = 409
112+
event.response = dict(message=str(e), status=409)
113+
except (RepositoryAccessError, InvalidSenderError) as e:
114+
event.response_code = 403
115+
event.response = dict(message=str(e), status=403)
116+
except RepositoryNotFoundError as e:
117+
event.response_code = 404
118+
event.response = dict(message=str(e), status=404)

0 commit comments

Comments
 (0)