Skip to content

Commit 1ed282c

Browse files
committed
CRAVEX GitLab workflow integration #346
Signed-off-by: tdruez <tdruez@nexb.com>
1 parent cea140d commit 1ed282c

10 files changed

Lines changed: 256 additions & 4 deletions

File tree

dje/admin.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from django.contrib.admin.utils import unquote
2525
from django.contrib.admin.views.main import ChangeList
2626
from django.contrib.admin.widgets import AdminDateWidget
27+
from django.contrib.admin.widgets import AdminTextInputWidget
2728
from django.contrib.auth.admin import GroupAdmin
2829
from django.contrib.auth.admin import UserAdmin
2930
from django.contrib.auth.models import Group
@@ -1034,7 +1035,7 @@ def has_activity_log(self):
10341035
return True
10351036

10361037

1037-
class HiddenValueWidget(forms.TextInput):
1038+
class HiddenValueWidget(AdminTextInputWidget):
10381039
"""Render a hidden value in the UI."""
10391040

10401041
HIDDEN_VALUE = "*******"
@@ -1057,6 +1058,7 @@ class DataspaceConfigurationForm(forms.ModelForm):
10571058
"vulnerablecode_api_key",
10581059
"purldb_api_key",
10591060
"github_token",
1061+
"gitlab_token",
10601062
]
10611063

10621064
def __init__(self, *args, **kwargs):
@@ -1105,6 +1107,14 @@ class DataspaceConfigurationInline(DataspacedFKMixin, admin.StackedInline):
11051107
]
11061108
},
11071109
),
1110+
(
1111+
"GitLab Integration",
1112+
{
1113+
"fields": [
1114+
"gitlab_token",
1115+
]
1116+
},
1117+
),
11081118
]
11091119
can_delete = False
11101120

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.2.4 on 2025-07-31 10:43
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('dje', '0008_dataspaceconfiguration_github_token'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='dataspaceconfiguration',
15+
name='gitlab_token',
16+
field=models.CharField(blank=True, help_text="Personal access token (PAT) used to authenticate API requests for the GitLab integration. This token must have 'api' scope. Keep this token secure.", max_length=255, verbose_name='GitLab token'),
17+
),
18+
]

dje/models.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,17 @@ class DataspaceConfiguration(models.Model):
528528
),
529529
)
530530

531+
gitlab_token = models.CharField(
532+
_("GitLab token"),
533+
max_length=255,
534+
blank=True,
535+
help_text=_(
536+
"Personal access token (PAT) used to authenticate API requests for the "
537+
"GitLab integration. This token must have 'api' scope. "
538+
"Keep this token secure."
539+
),
540+
)
541+
531542
def __str__(self):
532543
return f"Configuration for {self.dataspace}"
533544

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ Welcome to the very start of your DejaCode journey!
4949
:caption: Integrations
5050

5151
integrations-github
52+
integrations-gitlab
5253

5354
.. toctree::
5455
:maxdepth: 1

docs/integrations-gitlab.rst

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
.. _integrations_gitlab:
2+
3+
GitLab Integration
4+
==================
5+
6+
DejaCode's integration with GitLab allows you to automatically forward
7+
**Workflow Requests** to GitLab project **Issues**.
8+
This behavior can be selectively applied to any **Request Template** of your choice.
9+
10+
GitLab Account and Personal Access Token
11+
----------------------------------------
12+
13+
To enable integration, you need a GitLab **personal access token (PAT)** with
14+
appropriate permissions.
15+
16+
1. **Access GitLab Personal Access Tokens**:
17+
18+
- Go to: https://gitlab.com/-/user_settings/personal_access_tokens
19+
- Click the **"Add new token"** blue button on the right side
20+
- Provide a **name** (e.g., ``DejaCode Integration``) and **expiration date**
21+
(recommended)
22+
23+
2. **Permissions**:
24+
25+
Under **Scopes**, select:
26+
27+
- ``api`` — Full access to create, update, and comment on issues
28+
29+
.. note::
30+
31+
It is recommended to **create a dedicated GitLab user** with a clear, descriptive
32+
name such as ``dejacode-integration``. This ensures that all GitLab issues managed by
33+
the integration are clearly attributed to that user, improving traceability and
34+
auditability.
35+
36+
3. **Generate the Token**:
37+
38+
- Click **Create token**
39+
- Copy the token and store it securely — you’ll need it for the next step
40+
41+
DejaCode Dataspace Configuration
42+
--------------------------------
43+
44+
To use your GitLab token in DejaCode:
45+
46+
1. Go to the **Administration dashboard**
47+
2. Navigate to **Dataspaces**, and select your Dataspace
48+
3. Scroll to the **GitLab Integration** section under **Configuration**
49+
4. Paste your GitLab token in the **GitLab token** field
50+
5. Save the form
51+
52+
Activate GitLab Integration on Request Templates
53+
------------------------------------------------
54+
55+
1. Go to the **Administration dashboard**
56+
2. Navigate to **Workflow** > **Request templates**
57+
3. Create or edit a Request Template in your Dataspace
58+
4. Set the **Issue Tracker ID** field to your GitLab project URL, e.g.::
59+
60+
https://gitlab.com/group/project_name
61+
62+
Once the integration is configured:
63+
64+
- New **Requests** using this template will be automatically pushed to GitLab
65+
- Field updates (like title or priority) and **status changes** (e.g. closed) will be
66+
synced
67+
- New **Comments** on a DejaCode Request will be propagated to the GitLab Issue.

workflow/integrations/gitlab.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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+
from urllib.parse import urlparse
10+
11+
import requests
12+
13+
from workflow.integrations import BaseIntegration
14+
15+
GITLAB_API_URL = "https://gitlab.com/api/v4"
16+
17+
18+
class GitLabIntegration(BaseIntegration):
19+
"""
20+
A class for managing GitLab issue creation, updates, and comments
21+
from DejaCode requests.
22+
"""
23+
24+
api_url = GITLAB_API_URL
25+
26+
def get_headers(self):
27+
gitlab_token = self.dataspace.get_configuration(field_name="gitlab_token")
28+
if not gitlab_token:
29+
raise ValueError("The gitlab_token is not set on the Dataspace.")
30+
return {"PRIVATE-TOKEN": gitlab_token}
31+
32+
def sync(self, request):
33+
"""Sync the given request with GitLab by creating or updating an issue."""
34+
try:
35+
project_path = self.extract_gitlab_project_path(
36+
request.request_template.issue_tracker_id
37+
)
38+
except ValueError as error:
39+
raise ValueError(f"Invalid GitLab project URL: {error}")
40+
41+
external_issue = request.external_issue
42+
if external_issue:
43+
self.update_issue(
44+
project_path=project_path,
45+
issue_id=external_issue.issue_id,
46+
title=self.make_issue_title(request),
47+
description=self.make_issue_body(request),
48+
state_event="close" if request.is_closed else "reopen",
49+
)
50+
else:
51+
issue = self.create_issue(
52+
project_path=project_path,
53+
title=self.make_issue_title(request),
54+
description=self.make_issue_body(request),
55+
)
56+
request.link_external_issue(
57+
platform="gitlab",
58+
repo=project_path,
59+
issue_id=issue["iid"],
60+
)
61+
62+
def create_issue(self, project_path, title, description=""):
63+
"""Create a new GitLab issue."""
64+
project_path = requests.utils.quote(project_path, safe="")
65+
url = f"{self.api_url}/projects/{project_path}/issues"
66+
data = {"title": title, "description": description}
67+
68+
response = self.session.post(
69+
url,
70+
json=data,
71+
timeout=self.default_timeout,
72+
)
73+
response.raise_for_status()
74+
return response.json()
75+
76+
def update_issue(self, project_path, issue_id, title=None, description=None, state_event=None):
77+
"""Update an existing GitLab issue."""
78+
project_path = requests.utils.quote(project_path, safe="")
79+
url = f"{self.api_url}/projects/{project_path}/issues/{issue_id}"
80+
data = {}
81+
if title:
82+
data["title"] = title
83+
if description:
84+
data["description"] = description
85+
if state_event:
86+
data["state_event"] = state_event
87+
88+
response = self.session.put(
89+
url,
90+
json=data,
91+
timeout=self.default_timeout,
92+
)
93+
response.raise_for_status()
94+
return response.json()
95+
96+
def post_comment(self, project_path, issue_id, comment_body):
97+
"""Post a comment on an existing GitLab issue."""
98+
project_path = requests.utils.quote(project_path, safe="")
99+
url = f"{self.api_url}/projects/{project_path}/issues/{issue_id}/notes"
100+
data = {"body": comment_body}
101+
102+
response = self.session.post(
103+
url,
104+
json=data,
105+
timeout=self.default_timeout,
106+
)
107+
response.raise_for_status()
108+
return response.json()
109+
110+
@staticmethod
111+
def extract_gitlab_project_path(url):
112+
"""Extract 'namespace/project' from a GitLab URL."""
113+
parsed = urlparse(url)
114+
if "gitlab.com" not in parsed.netloc:
115+
raise ValueError("URL does not point to GitLab.")
116+
117+
path_parts = [part for part in parsed.path.split("/") if part]
118+
if len(path_parts) < 2:
119+
raise ValueError("Incomplete GitLab project path.")
120+
121+
return "/".join(path_parts[:2])

workflow/models.py

Lines changed: 24 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.gitlab import GitLabIntegration
4243
from workflow.integrations.github import GitHubIntegration
4344
from workflow.notification import request_comment_slack_payload
4445
from workflow.notification import request_slack_payload
@@ -126,6 +127,15 @@ def issue_url(self):
126127
elif self.platform == self.Platform.JIRA:
127128
return f"https://{self.repo}/browse/{self.issue_id}"
128129

130+
@property
131+
def icon_css_class(self):
132+
platform_icons = {
133+
self.Platform.GITHUB: "fa-brands fa-github",
134+
self.Platform.GITLAB: "fa-brands fa-gitlab",
135+
self.Platform.JIRA: "fa-brands fa-jira",
136+
}
137+
return platform_icons.get(self.platform, "fa-solid fa-square-up-right")
138+
129139

130140
class RequestQuerySet(DataspacedQuerySet):
131141
BASE_SELECT_RELATED = [
@@ -608,6 +618,8 @@ def handle_integrations(self):
608618

609619
if "github.com" in issue_tracker_id:
610620
GitHubIntegration(dataspace=self.dataspace).sync(request=self)
621+
if "gitlab.com" in issue_tracker_id:
622+
GitLabIntegration(dataspace=self.dataspace).sync(request=self)
611623

612624

613625
@receiver(models.signals.post_delete, sender=Request)
@@ -687,6 +699,12 @@ def handle_integrations(self):
687699
issue_id=external_issue.issue_id,
688700
comment_body=self.text,
689701
)
702+
elif external_issue.platform == ExternalIssueLink.Platform.GITLAB:
703+
GitLabIntegration(dataspace=self.dataspace).post_comment(
704+
project_path=external_issue.repo,
705+
issue_id=external_issue.issue_id,
706+
comment_body=self.text,
707+
)
690708

691709

692710
class RequestComment(AbstractRequestEvent):
@@ -769,6 +787,12 @@ def handle_integrations(self):
769787
issue_id=external_issue.issue_id,
770788
comment_body=self.text,
771789
)
790+
elif external_issue.platform == ExternalIssueLink.Platform.GITLAB:
791+
GitLabIntegration(dataspace=self.dataspace).post_comment(
792+
project_path=external_issue.repo,
793+
issue_id=external_issue.issue_id,
794+
comment_body=self.text,
795+
)
772796

773797

774798
class RequestTemplateQuerySet(DataspacedQuerySet):

workflow/templates/workflow/includes/request_list_table.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
{% if req.external_issue %}
5050
<div>
5151
<a href="{{ req.external_issue.issue_url }}" target="_blank">
52-
<i class="fa-brands fa-github"></i>
52+
<i class="{{ req.external_issue.icon_css_class }}"></i>
5353
{{ req.external_issue }}
5454
<i class="fa-solid fa-up-right-from-square mini"></i>
5555
</a>

workflow/templates/workflow/request_details.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ <h1 class="header-title">
2525
{% if request_instance.external_issue %}
2626
<div class="h4 mb-0">
2727
<a href="{{ request_instance.external_issue.issue_url }}" target="_blank">
28-
<i class="fa-brands fa-github"></i>
28+
<i class="{{ request_instance.external_issue.icon_css_class }}"></i>
2929
{{ request_instance.external_issue }}
3030
<i class="fa-solid fa-up-right-from-square mini"></i>
3131
</a>

workflow/templates/workflow/request_form.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ <h1 class="header-title">
2424
{% if request_instance.external_issue %}
2525
<span>&bull;
2626
<a href="{{ request_instance.external_issue.issue_url }}" target="_blank">
27-
<i class="fa-brands fa-github"></i>
27+
<i class="{{ request_instance.external_issue.icon_css_class }}"></i>
2828
{{ request_instance.external_issue }}
2929
<i class="fa-solid fa-up-right-from-square mini"></i>
3030
</a>

0 commit comments

Comments
 (0)