Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Openproject provider #816

Merged
merged 4 commits into from
Nov 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ dependencies = [
"iso8601~=2.0",
"markdown~=3.4",
"pypandoc~=1.11",
"requests-toolbelt~=1.0",
"rules~=3.3",
]

Expand Down
4 changes: 3 additions & 1 deletion rdmo/projects/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,8 +278,10 @@ def __init__(self, *args, **kwargs):

if field.get('placeholder'):
attrs = {'placeholder': field.get('placeholder')}

self.fields[field.get('key')] = forms.CharField(widget=forms.TextInput(attrs=attrs),
initial=initial, required=field.get('required', True))
initial=initial, required=field.get('required', True),
help_text=field.get('help'))

def save(self):
# the the project and the provider_key
Expand Down
10 changes: 10 additions & 0 deletions rdmo/projects/models/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ def provider(self):
def get_absolute_url(self):
return reverse('project', kwargs={'pk': self.project.pk})

def get_option_value(self, key):
try:
return self.options.get(key=key).value
except IntegrationOption.DoesNotExist:
return None

def save_options(self, options):
for field in self.provider.fields:
try:
Expand Down Expand Up @@ -77,3 +83,7 @@ class Meta:

def __str__(self):
return f'{self.integration.project.title} / {self.integration.provider_key} / {self.key} = {self.value}'

@property
def title(self):
return self.key.title().replace('_', ' ')
187 changes: 41 additions & 146 deletions rdmo/projects/providers.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
import hmac
import json
from urllib.parse import quote

from django.core.exceptions import ObjectDoesNotExist
from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.shortcuts import render
from django.utils.translation import gettext_lazy as _

from rdmo.core.plugins import Plugin
from rdmo.services.providers import GitHubProviderMixin, GitLabProviderMixin, OauthProviderMixin
from rdmo.services.providers import OauthProviderMixin


class IssueProvider(Plugin):

def send_issue(self, request, issue, integration, subject, message, attachments):
raise NotImplementedError

def webhook(self, request, options, payload):
def webhook(self, request, integration):
raise NotImplementedError


Expand All @@ -32,22 +30,18 @@ def send_issue(self, request, issue, integration, subject, message, attachments)
if url is None or data is None:
return render(request, 'core/error.html', {
'title': _('Integration error'),
'errors': [_('The Integration is not configured correctly.') % message]
'errors': [_('The Integration is not configured correctly.')]
}, status=200)

return self.post(request, url, data)

def post_success(self, request, response):
def update_issue(self, request, remote_url):
from rdmo.projects.models import Integration, Issue, IssueResource

# get the upstream url of the issue
remote_url = self.get_issue_url(response)

# get the issue_id and integration_id from the session
issue_id = self.pop_from_session(request, 'issue_id')
integration_id = self.pop_from_session(request, 'integration_id')

# update the issue in rdmo
try:
issue = Issue.objects.get(pk=issue_id)
issue.status = Issue.ISSUE_STATUS_IN_PROGRESS
Expand All @@ -60,6 +54,13 @@ def post_success(self, request, response):
except ObjectDoesNotExist:
pass

def post_success(self, request, response):
# get the upstream url of the issue
remote_url = self.get_issue_url(response)

# update the issue in rdmo
self.update_issue(request, remote_url)

return HttpResponseRedirect(remote_url)

def get_post_url(self, request, issue, integration, subject, message, attachments):
Expand All @@ -72,165 +73,59 @@ def get_issue_url(self, response):
raise NotImplementedError


class GitHubIssueProvider(GitHubProviderMixin, OauthIssueProvider):
add_label = _('Add GitHub integration')
send_label = _('Send to GitHub')
description = _('This integration allow the creation of issues in arbitrary GitHub repositories. '
'The upload of attachments is not supported by GitHub.')

def get_repo(self, integration):
try:
return integration.options.get(key='repo').value
except ObjectDoesNotExist:
return None

def get_secret(self, integration):
try:
return integration.options.get(key='secret').value
except ObjectDoesNotExist:
return None

def get_post_url(self, request, issue, integration, subject, message, attachments):
repo = self.get_repo(integration)
if repo:
return f'https://api.github.com/repos/{repo}/issues'

def get_post_data(self, request, issue, integration, subject, message, attachments):
return {
'title': subject,
'body': message
}

def get_issue_url(self, response):
return response.json().get('html_url')

def webhook(self, request, integration):
secret = self.get_secret(integration)
header_signature = request.headers.get('X-Hub-Signature')
class SimpleIssueProvider(OauthIssueProvider):

if (secret is not None) and (header_signature is not None):
body_signature = 'sha1=' + hmac.new(secret.encode(), request.body, 'sha1').hexdigest()

if hmac.compare_digest(header_signature, body_signature):
try:
payload = json.loads(request.body.decode())
action = payload.get('action')
issue_url = payload.get('issue', {}).get('html_url')

if action and issue_url:
try:
issue_resource = integration.resources.get(url=issue_url)
if action == 'closed':
issue_resource.issue.status = issue_resource.issue.ISSUE_STATUS_CLOSED
else:
issue_resource.issue.status = issue_resource.issue.ISSUE_STATUS_IN_PROGRESS

issue_resource.issue.save()
except ObjectDoesNotExist:
pass

return HttpResponse(status=200)

except json.decoder.JSONDecodeError as e:
return HttpResponse(e, status=400)

raise Http404
add_label = _('Add Simple integration')
send_label = _('Send to Simple')
description = _('This integration allow the creation of issues in arbitrary Simple repositories. '
'The upload of attachments is not supported.')

@property
def fields(self):
return [
{
'key': 'repo',
'placeholder': 'user_name/repo_name',
'help': _('The GitHub repository to send issues to.')
'key': 'project_url',
'placeholder': 'https://example.com/projects/<name>',
'help': _('The URL of the project to send tasks to.')
},
{
'key': 'secret',
'placeholder': 'Secret (random) string',
'help': _('The secret for a GitHub webhook to close a task.'),
'help': _('The secret for a webhook to close a task (optional).'),
'required': False,
'secret': True
}
]

def get(self, request, url):
raise NotImplementedError

class GitLabIssueProvider(GitLabProviderMixin, OauthIssueProvider):
add_label = _('Add GitLab integration')
send_label = _('Send to GitLab')

@property
def description(self):
return _(f'This integration allow the creation of issues in arbitrary repositories on {self.gitlab_url}. '
'The upload of attachments is not supported by GitLab.')

def get_repo(self, integration):
try:
return integration.options.get(key='repo').value
except ObjectDoesNotExist:
return None

def get_secret(self, integration):
try:
return integration.options.get(key='secret').value
except ObjectDoesNotExist:
return None

def get_post_url(self, request, issue, integration, subject, message, attachments):
repo = self.get_repo(integration)
if repo:
return '{}/api/v4/projects/{}/issues'.format(self.gitlab_url, quote(repo, safe=''))

def get_post_data(self, request, issue, integration, subject, message, attachments):
return {
'title': subject,
'description': message
}

def get_issue_url(self, response):
return response.json().get('web_url')
def post(self, request, url, json=None, files=None, multipart=None):
raise NotImplementedError

def webhook(self, request, integration):
secret = self.get_secret(integration)
header_token = request.headers.get('X-Gitlab-Token')

if (secret is not None) and (header_token is not None) and (header_token == secret):
secret = integration.get_option_value('secret')
header_signature = request.headers.get('X-Secret')
if secret == header_signature:
try:
payload = json.loads(request.body.decode())
state = payload.get('object_attributes', {}).get('state')
issue_url = payload.get('object_attributes', {}).get('url')
except json.decoder.JSONDecodeError as e:
return HttpResponse(e, status=400)

if state and issue_url:
try:
issue_resource = integration.resources.get(url=issue_url)
if state == 'closed':
issue_resource.issue.status = issue_resource.issue.ISSUE_STATUS_CLOSED
else:
issue_resource.issue.status = issue_resource.issue.ISSUE_STATUS_IN_PROGRESS
action = payload.get('action')
url = payload.get('url')

issue_resource.issue.save()
except ObjectDoesNotExist:
pass
try:
issue_resource = integration.resources.get(url=url)
if action == 'closed':
issue_resource.issue.status = issue_resource.issue.ISSUE_STATUS_CLOSED
else:
issue_resource.issue.status = issue_resource.issue.ISSUE_STATUS_IN_PROGRESS

return HttpResponse(status=200)
issue_resource.issue.save()
except ObjectDoesNotExist:
pass

except json.decoder.JSONDecodeError as e:
return HttpResponse(e, status=400)
return HttpResponse(status=200)

raise Http404

@property
def fields(self):
return [
{
'key': 'repo',
'placeholder': 'user_name/repo_name',
'help': _('The GitLab repository to send issues to.')
},
{
'key': 'secret',
'placeholder': 'Secret (random) string',
'help': _('The secret for a GitLab webhook to close a task.'),
'required': False,
'secret': True
}
]
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ <h2>{% trans 'Send via integration' %}</h2>
<td>
{% for option in integration.options.all %}
{% if not option.secret %}
<p>{{ option.key.title }}: {{ option.value }}</p>
<p>{{ option.title }}: {{ option.value }}</p>
{% endif %}
{% endfor %}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ <h2>{% trans 'Integrations' %}</h2>
<td>
{% for option in integration.options.all %}
{% if not option.secret %}
{{ option.key.title }}: {{ option.value }}<br />
{{ option.title }}: {{ option.value }}<br />
{% endif %}
{% endfor %}

Expand Down
Loading