Skip to content

Commit 1f3bd88

Browse files
committed
Add new models to support worklfow integrations #349
Signed-off-by: tdruez <tdruez@nexb.com>
1 parent e999471 commit 1f3bd88

8 files changed

Lines changed: 235 additions & 0 deletions

File tree

dejacode/settings.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,9 @@ def gettext_noop(s):
458458
daily_at_3am = "0 3 * * *"
459459
DEJACODE_VULNERABILITIES_CRON = env.str("DEJACODE_VULNERABILITIES_CRON", default=daily_at_3am)
460460

461+
# Workflow integrations
462+
DEJACODE_GITHUB_INTEGRATION_TOKEN = env.str("DEJACODE_GITHUB_INTEGRATION_TOKEN", default="")
463+
461464

462465
def enable_rq_eager_mode():
463466
"""

workflow/integrations/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# DejaCode is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: AGPL-3.0-only
5+
# See https://github.com/aboutcode-org/dejacode for support or download.
6+
# See https://aboutcode.org for more information about AboutCode FOSS projects.
7+
#

workflow/integrations/github.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# DejaCode is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: AGPL-3.0-only
5+
# See https://github.com/aboutcode-org/dejacode for support or download.
6+
# See https://aboutcode.org for more information about AboutCode FOSS projects.
7+
#
8+
9+
10+
from urllib.parse import urlparse
11+
12+
from django.conf import settings
13+
14+
from github import Github
15+
16+
17+
def handle_integration(request):
18+
github_token = settings.DEJACODE_GITHUB_INTEGRATION_TOKEN
19+
if not github_token:
20+
raise Exception("DEJACODE_GITHUB_INTEGRATION_TOKEN is not set.")
21+
22+
github_api = Github(github_token)
23+
repo_id = extract_github_repo_path(request.request_template.issue_tracker_id)
24+
repo = github_api.get_repo(repo_id)
25+
26+
labels = []
27+
if request.priority:
28+
labels.append(str(request.priority))
29+
30+
external_issue = request.external_issue
31+
32+
if external_issue: # Update existing issue on GitHib
33+
issue = repo.get_issue(number=int(external_issue.issue_id))
34+
state = "closed" if request.is_closed else "open"
35+
issue.edit(
36+
title=make_issue_title(request),
37+
body=make_issue_body(request),
38+
state=state,
39+
)
40+
else:
41+
issue = repo.create_issue(
42+
title=make_issue_title(request),
43+
body=make_issue_body(request),
44+
labels=labels,
45+
)
46+
# Create an ExternalIssueLink instance and assign to the Request.
47+
request.link_external_issue(platform="github", repo=repo_id, issue_id=issue.number)
48+
49+
50+
def make_issue_title(request):
51+
return f"[DEJACODE] {request.title}"
52+
53+
54+
def make_issue_body(request):
55+
body = f"""
56+
- Request template: {request.request_template}
57+
- Product context: {request.product_context}
58+
- Applies to: {request.content_object}
59+
- Submitted by: {request.requester}
60+
- Assigned to: {request.assignee}
61+
- Priority: {request.priority}
62+
"""
63+
return body
64+
65+
66+
def extract_github_repo_path(url):
67+
"""Extract 'username/repo-name' from a GitHub URL."""
68+
parsed = urlparse(url)
69+
if "github.com" not in parsed.netloc:
70+
raise ValueError("Not a GitHub URL")
71+
72+
path_parts = [part for part in parsed.path.split("/") if part]
73+
if len(path_parts) < 2:
74+
raise ValueError("Invalid GitHub repository URL")
75+
76+
return f"{path_parts[0]}/{path_parts[1]}"
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Generated by Django 5.2.4 on 2025-07-16 14:34
2+
3+
import django.db.models.deletion
4+
import uuid
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('dje', '0007_dejacodeuser_timezone'),
12+
('workflow', '0001_initial'),
13+
]
14+
15+
operations = [
16+
migrations.AddField(
17+
model_name='requesttemplate',
18+
name='issue_tracker_id',
19+
field=models.CharField(blank=True, help_text='Link to associated issue in a tracking application, provided by the integration when the issue is created.', max_length=1000, verbose_name='Issue Tracker ID'),
20+
),
21+
migrations.CreateModel(
22+
name='ExternalIssueLink',
23+
fields=[
24+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
25+
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')),
26+
('platform', models.CharField(choices=[('github', 'GitHub'), ('gitlab', 'GitLab'), ('jira', 'Jira')], help_text='External issue tracking platform.', max_length=20)),
27+
('repo', models.CharField(help_text="Repository or project identifier (e.g., 'user/repo-name').", max_length=100)),
28+
('issue_id', models.CharField(help_text='ID or key of the issue on the external platform.', max_length=100)),
29+
('dataspace', models.ForeignKey(editable=False, help_text='A Dataspace is an independent, exclusive set of DejaCode data, which can be either nexB master reference data or installation-specific data.', on_delete=django.db.models.deletion.PROTECT, to='dje.dataspace')),
30+
],
31+
options={
32+
'unique_together': {('dataspace', 'platform', 'repo', 'issue_id'), ('dataspace', 'uuid')},
33+
},
34+
),
35+
migrations.AddField(
36+
model_name='request',
37+
name='external_issue',
38+
field=models.ForeignKey(blank=True, help_text='Link to external issue (GitHub, GitLab, Jira, etc.)', null=True, on_delete=django.db.models.deletion.SET_NULL, to='workflow.externalissuelink'),
39+
),
40+
]

workflow/models.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from dje.models import HistoryDateFieldsMixin
4040
from dje.models import HistoryFieldsMixin
4141
from dje.models import get_unsecured_manager
42+
from workflow.integrations import github
4243
from workflow.notification import request_comment_slack_payload
4344
from workflow.notification import request_slack_payload
4445

@@ -88,13 +89,51 @@ def __str__(self):
8889
return self.label
8990

9091

92+
class ExternalIssueLink(DataspacedModel):
93+
class Platform(models.TextChoices):
94+
GITHUB = "github", _("GitHub")
95+
GITLAB = "gitlab", _("GitLab")
96+
JIRA = "jira", _("Jira")
97+
98+
platform = models.CharField(
99+
max_length=20, choices=Platform.choices, help_text="External issue tracking platform."
100+
)
101+
102+
repo = models.CharField(
103+
max_length=100, help_text="Repository or project identifier (e.g., 'user/repo-name')."
104+
)
105+
106+
issue_id = models.CharField(
107+
max_length=100, help_text="ID or key of the issue on the external platform."
108+
)
109+
110+
class Meta:
111+
unique_together = (
112+
("dataspace", "platform", "repo", "issue_id"),
113+
("dataspace", "uuid"),
114+
)
115+
116+
def __str__(self):
117+
return f"{self.get_platform_display()}:{self.repo}#{self.issue_id}"
118+
119+
@property
120+
def issue_url(self):
121+
if self.platform == self.Platform.GITHUB:
122+
return f"https://github.com/{self.repo}/issues/{self.issue_id}"
123+
elif self.platform == self.Platform.GITLAB:
124+
return f"https://gitlab.com/{self.repo}/-/issues/{self.issue_id}"
125+
elif self.platform == self.Platform.JIRA:
126+
return f"https://{self.repo}/browse/{self.issue_id}"
127+
128+
91129
class RequestQuerySet(DataspacedQuerySet):
92130
BASE_SELECT_RELATED = [
93131
"request_template",
94132
"requester",
95133
"assignee",
96134
"priority",
97135
"product_context",
136+
"external_issue",
98137
"last_modified_by",
99138
]
100139

@@ -348,6 +387,14 @@ class Status(models.TextChoices):
348387
),
349388
)
350389

390+
external_issue = models.ForeignKey(
391+
to="workflow.ExternalIssueLink",
392+
on_delete=models.SET_NULL,
393+
null=True,
394+
blank=True,
395+
help_text=_("Link to external issue (GitHub, GitLab, Jira, etc.)"),
396+
)
397+
351398
last_modified_by = LastModifiedByField()
352399

353400
objects = DataspacedManager.from_queryset(RequestQuerySet)()
@@ -397,6 +444,8 @@ def save(self, *args, **kwargs):
397444
return
398445
previous_object.update_request_count()
399446

447+
self.handle_integrations()
448+
400449
def get_absolute_url(self):
401450
return reverse("workflow:request_details", args=[self.uuid])
402451

@@ -520,6 +569,29 @@ def serialize_hook(self, hook):
520569
"data": serializer.data,
521570
}
522571

572+
def link_external_issue(self, platform, repo, issue_id):
573+
"""Create or return an ExternalIssueLink associated with this Request."""
574+
if self.external_issue:
575+
return self.external_issue
576+
577+
external_issue = ExternalIssueLink.objects.create(
578+
dataspace=self.dataspace,
579+
platform=platform,
580+
repo=repo,
581+
issue_id=str(issue_id),
582+
)
583+
self.update(external_issue=external_issue)
584+
585+
return external_issue
586+
587+
def handle_integrations(self):
588+
issue_tracker_id = self.request_template.issue_tracker_id
589+
if not issue_tracker_id:
590+
return
591+
592+
if "github.com" in issue_tracker_id:
593+
github.handle_integration(request=self)
594+
523595

524596
@receiver(models.signals.post_delete, sender=Request)
525597
def update_request_count_on_delete(sender, instance=None, **kwargs):
@@ -723,6 +795,16 @@ class RequestTemplate(HistoryFieldsMixin, DataspacedModel):
723795
),
724796
)
725797

798+
issue_tracker_id = models.CharField(
799+
verbose_name=_("Issue Tracker ID"),
800+
max_length=1000,
801+
blank=True,
802+
help_text=_(
803+
"Link to associated issue in a tracking application, "
804+
"provided by the integration when the issue is created."
805+
),
806+
)
807+
726808
objects = DataspacedManager.from_queryset(RequestTemplateQuerySet)()
727809

728810
class Meta:

workflow/templates/workflow/includes/request_list_table.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@
4646
<strong style="font-size: 115%;">
4747
<a href="{% inject_preserved_filters req.get_absolute_url %}">#{{ req.id }} {{ req.title }}</a>
4848
</strong>
49+
{% if req.external_issue %}
50+
<div>
51+
<a href="{{ req.external_issue.issue_url }}" target="_blank">
52+
<i class="fa-brands fa-github"></i>
53+
{{ req.external_issue }}
54+
<i class="fa-solid fa-up-right-from-square mini"></i>
55+
</a>
56+
</div>
57+
{% endif %}
4958
<div>
5059
{% if filter.form.request_template %}
5160
<a href="?{{ filter.form.request_template.name }}={{ req.request_template.uuid }}" class="text-muted-light" title="Form: {{ req.request_template.name }}">{{ req.request_template.name }}</a>

workflow/templates/workflow/request_details.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@
2222
<h1 class="header-title">
2323
{{ request_instance }} {{ request_instance.title }}
2424
</h1>
25+
{% if request_instance.external_issue %}
26+
<div class="h4 mb-0">
27+
<a href="{{ request_instance.external_issue.issue_url }}" target="_blank">
28+
<i class="fa-brands fa-github"></i>
29+
{{ request_instance.external_issue }}
30+
<i class="fa-solid fa-up-right-from-square mini"></i>
31+
</a>
32+
</div>
33+
{% endif %}
2534
</div>
2635
<div class="col-auto">
2736
<a href="{{ request_instance.request_template.get_absolute_url }}" class="btn btn-success" data-bs-toggle="tooltip" title="Add a new Request of the same type">

workflow/templates/workflow/request_form.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@
2121
<h1 class="header-title">
2222
{% if request_instance %}{# Edition #}
2323
Edit: <a href="{{ request_instance.get_absolute_url }}">{{ request_instance }} {{ request_instance.title }}</a>
24+
{% if request_instance.external_issue %}
25+
<span>&bull;
26+
<a href="{{ request_instance.external_issue.issue_url }}" target="_blank">
27+
<i class="fa-brands fa-github"></i>
28+
{{ request_instance.external_issue }}
29+
<i class="fa-solid fa-up-right-from-square mini"></i>
30+
</a>
31+
</span>
32+
{% endif %}
2433
{% else %}{# Addition #}
2534
New Request
2635
{% endif %}

0 commit comments

Comments
 (0)