Skip to content

Commit

Permalink
Merge pull request #816 from rdmorganiser/openproject_provider
Browse files Browse the repository at this point in the history
Openproject provider
  • Loading branch information
jochenklar authored Nov 24, 2023
2 parents b1f36d1 + ff08f12 commit 88879b8
Show file tree
Hide file tree
Showing 12 changed files with 143 additions and 304 deletions.
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

0 comments on commit 88879b8

Please sign in to comment.