Skip to content

Commit 5fe3bc8

Browse files
authored
CRAVEX GitHub workflow integration (#362)
Signed-off-by: tdruez <[email protected]>
1 parent ce3d935 commit 5fe3bc8

File tree

17 files changed

+695
-27
lines changed

17 files changed

+695
-27
lines changed

dejacode_toolkit/__init__.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from os import getenv
1111

1212
from django.conf import settings
13-
from django.core.exceptions import ObjectDoesNotExist
1413

1514
import requests
1615

@@ -53,11 +52,7 @@ def __init__(self, dataspace):
5352
self.basic_auth_user = None
5453
self.basic_auth_password = None
5554

56-
try:
57-
dataspace_configuration = dataspace.configuration
58-
except ObjectDoesNotExist:
59-
dataspace_configuration = None
60-
55+
dataspace_configuration = dataspace.get_configuration()
6156
# Take the integration settings from the Dataspace when defined
6257
if dataspace_configuration:
6358
self.service_url = getattr(dataspace_configuration, self.url_field_name)

dje/admin.py

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1056,6 +1056,7 @@ class DataspaceConfigurationForm(forms.ModelForm):
10561056
"scancodeio_api_key",
10571057
"vulnerablecode_api_key",
10581058
"purldb_api_key",
1059+
"github_token",
10591060
]
10601061

10611062
def __init__(self, *args, **kwargs):
@@ -1077,15 +1078,33 @@ class DataspaceConfigurationInline(DataspacedFKMixin, admin.StackedInline):
10771078
form = DataspaceConfigurationForm
10781079
verbose_name_plural = _("Configuration")
10791080
verbose_name = _("Dataspace configuration")
1080-
fields = [
1081-
"homepage_layout",
1082-
"scancodeio_url",
1083-
"scancodeio_api_key",
1084-
"vulnerablecode_url",
1085-
"vulnerablecode_api_key",
1086-
"vulnerabilities_risk_threshold",
1087-
"purldb_url",
1088-
"purldb_api_key",
1081+
fieldsets = [
1082+
(
1083+
"",
1084+
{"fields": ("homepage_layout",)},
1085+
),
1086+
(
1087+
"AboutCode Integrations",
1088+
{
1089+
"fields": (
1090+
"scancodeio_url",
1091+
"scancodeio_api_key",
1092+
"vulnerablecode_url",
1093+
"vulnerablecode_api_key",
1094+
"vulnerabilities_risk_threshold",
1095+
"purldb_url",
1096+
"purldb_api_key",
1097+
)
1098+
},
1099+
),
1100+
(
1101+
"GitHub Integration",
1102+
{
1103+
"fields": [
1104+
"github_token",
1105+
]
1106+
},
1107+
),
10891108
]
10901109
can_delete = False
10911110

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-28 13:12
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('dje', '0007_dejacodeuser_timezone'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='dataspaceconfiguration',
15+
name='github_token',
16+
field=models.CharField(blank=True, help_text='Personal access token (PAT) or GitHub App token used to authenticate API requests for this integration. Keep this token secure.', max_length=255, verbose_name='GitHub token'),
17+
),
18+
]

dje/models.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,16 @@ class DataspaceConfiguration(models.Model):
518518
),
519519
)
520520

521+
github_token = models.CharField(
522+
_("GitHub token"),
523+
max_length=255,
524+
blank=True,
525+
help_text=_(
526+
"Personal access token (PAT) or GitHub App token used to authenticate "
527+
"API requests for this integration. Keep this token secure."
528+
),
529+
)
530+
521531
def __str__(self):
522532
return f"Configuration for {self.dataspace}"
523533

dje/tests/testfiles/test_dataset_workflow.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
],
1717
"is_active": true,
1818
"include_applies_to": true,
19-
"include_product": true
19+
"include_product": true,
20+
"issue_tracker_id": ""
2021
}
2122
},
2223
{

docs/index.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ Welcome to the very start of your DejaCode journey!
4444
reference-2
4545
reference-3-cravex
4646

47+
.. toctree::
48+
:maxdepth: 1
49+
:caption: Integrations
50+
51+
integrations-github
52+
4753
.. toctree::
4854
:maxdepth: 1
4955
:caption: Miscellaneous

docs/integrations-github.rst

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
.. _integrations_github:
2+
3+
GitHub Integration
4+
==================
5+
6+
DejaCode's integration with GitHub allows you to automatically forward
7+
**Workflow Requests** to GitHub repository **Issues**.
8+
This behavior can be selectively applied to any **Request Template** of your choice.
9+
10+
GitHub Account and Personal Access Token
11+
----------------------------------------
12+
13+
To enable integration, you need a GitHub **fine-grained personal access token (PAT)**.
14+
15+
1. **Access GitHub Developer Settings**:
16+
17+
- Go to: https://github.com/settings/personal-access-tokens
18+
- Click **"Generate new token"** under *Fine-grained personal access tokens*
19+
20+
2. **Configure the Token**:
21+
22+
- **Name**: Give it a clear name (e.g., ``DejaCode Integration``)
23+
- **Expiration**: Set an expiration date (recommended)
24+
- **Resource owner**: Choose your personal GitHub account or organization
25+
26+
.. note::
27+
28+
It is recommended to **create a dedicated GitHub user** with a clear, descriptive
29+
name such as ``dejacode-integration``. This ensures that all GitHub issues managed by
30+
the integration are clearly attributed to that user, improving traceability and
31+
auditability.
32+
33+
3. **Repository Access**:
34+
35+
- Under *Repository access*, select **Only select repositories**
36+
- Choose the repository where you want issues to be created and updated
37+
38+
4. **Permissions**:
39+
40+
- Under *Repository permissions*, enable::
41+
42+
Issues: Read and write
43+
44+
5. **Save and Copy the Token**:
45+
46+
- Click **Generate token**
47+
- Copy the token and store it securely — you’ll need it for the next step
48+
49+
DejaCode Dataspace Configuration
50+
--------------------------------
51+
52+
To use your GitHub token in DejaCode:
53+
54+
1. Go to the **Administration dashboard**
55+
2. Navigate to **Dataspaces**, and select your Dataspace
56+
3. Scroll to the **GitHub Integration** section under **Configuration**
57+
4. Paste your GitHub token in the **GitHub token** field
58+
5. Save the form
59+
60+
Activate GitHub Integration on Request Templates
61+
------------------------------------------------
62+
63+
1. Go to the **Administration dashboard**
64+
2. Navigate to **Workflow** > **Request templates**
65+
3. Create or edit a Request Template in your Dataspace
66+
4. Set the **Issue Tracker ID** field to your GitHub repository URL, e.g.::
67+
68+
https://github.com/org/repo_name
69+
70+
Once the integration is configured:
71+
72+
- New **Requests** using this template will be automatically pushed to GitHub
73+
- Field updates (like title or priority) and **status changes** (e.g. closed) will be
74+
synced
75+
- New **Comments** on a DejaCode Request will be propagated to the GitHub Issue.

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: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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+
from django.conf import settings
12+
13+
import requests
14+
15+
GITHUB_API_URL = "https://api.github.com"
16+
DEJACODE_SITE_URL = settings.SITE_URL.rstrip("/")
17+
18+
19+
class GitHubIntegration:
20+
"""
21+
A class for managing GitHub issue creation, updates, and comments
22+
from DejaCode requests.
23+
"""
24+
25+
api_url = GITHUB_API_URL
26+
default_timeout = 10
27+
28+
def __init__(self, dataspace):
29+
if not dataspace:
30+
raise ValueError("Dataspace must be provided.")
31+
self.dataspace = dataspace
32+
self.session = self.get_session()
33+
34+
def get_session(self):
35+
session = requests.Session()
36+
session.headers.update(self.get_headers())
37+
return session
38+
39+
def get_headers(self):
40+
github_token = self.dataspace.get_configuration(field_name="github_token")
41+
if not github_token:
42+
raise ValueError("The github_token is not set on the Dataspace.")
43+
return {"Authorization": f"token {github_token}"}
44+
45+
def sync(self, request):
46+
"""Sync the given request with GitHub by creating or updating an issue."""
47+
try:
48+
repo_id = self.extract_github_repo_path(request.request_template.issue_tracker_id)
49+
except ValueError as error:
50+
raise ValueError(f"Invalid GitHub repository URL: {error}")
51+
52+
labels = []
53+
if request.priority:
54+
labels.append(str(request.priority))
55+
56+
external_issue = request.external_issue
57+
if external_issue:
58+
self.update_issue(
59+
repo_id=repo_id,
60+
issue_id=external_issue.issue_id,
61+
title=self.make_issue_title(request),
62+
body=self.make_issue_body(request),
63+
state="closed" if request.is_closed else "open",
64+
)
65+
else:
66+
issue = self.create_issue(
67+
repo_id=repo_id,
68+
title=self.make_issue_title(request),
69+
body=self.make_issue_body(request),
70+
labels=labels,
71+
)
72+
request.link_external_issue(
73+
platform="github",
74+
repo=repo_id,
75+
issue_id=issue["number"],
76+
)
77+
78+
def create_issue(self, repo_id, title, body="", labels=None):
79+
"""Create a new GitHub issue."""
80+
url = f"{self.api_url}/repos/{repo_id}/issues"
81+
data = {
82+
"title": title,
83+
"body": body,
84+
}
85+
if labels:
86+
data["labels"] = labels
87+
88+
response = self.session.post(
89+
url,
90+
json=data,
91+
timeout=self.default_timeout,
92+
)
93+
response.raise_for_status()
94+
return response.json()
95+
96+
def update_issue(self, repo_id, issue_id, title=None, body=None, state=None):
97+
"""Update an existing GitHub issue."""
98+
url = f"{self.api_url}/repos/{repo_id}/issues/{issue_id}"
99+
data = {}
100+
if title:
101+
data["title"] = title
102+
if body:
103+
data["body"] = body
104+
if state:
105+
data["state"] = state
106+
107+
response = self.session.patch(
108+
url,
109+
json=data,
110+
timeout=self.default_timeout,
111+
)
112+
response.raise_for_status()
113+
return response.json()
114+
115+
def post_comment(self, repo_id, issue_id, comment_body):
116+
"""Post a comment on an existing GitHub issue."""
117+
url = f"{self.api_url}/repos/{repo_id}/issues/{issue_id}/comments"
118+
data = {"body": comment_body}
119+
120+
response = self.session.post(
121+
url,
122+
json=data,
123+
timeout=self.default_timeout,
124+
)
125+
response.raise_for_status()
126+
return response.json()
127+
128+
@staticmethod
129+
def extract_github_repo_path(url):
130+
"""Extract 'username/repo-name' from a GitHub URL."""
131+
parsed = urlparse(url)
132+
if "github.com" not in parsed.netloc:
133+
raise ValueError("URL does not point to GitHub.")
134+
135+
path_parts = [part for part in parsed.path.split("/") if part]
136+
if len(path_parts) < 2:
137+
raise ValueError("Incomplete GitHub repository path.")
138+
139+
return f"{path_parts[0]}/{path_parts[1]}"
140+
141+
@staticmethod
142+
def make_issue_title(request):
143+
return f"[DEJACODE] {request.title}"
144+
145+
@staticmethod
146+
def make_issue_body(request):
147+
request_url = f"{DEJACODE_SITE_URL}{request.get_absolute_url()}"
148+
label_fields = [
149+
("📝 Request Template", request.request_template),
150+
("📦 Product Context", request.product_context),
151+
("📌 Applies To", request.content_object),
152+
("🙋 Submitted By", request.requester),
153+
("👤 Assigned To", request.assignee),
154+
("🚨 Priority", request.priority),
155+
("🗒️ Notes", request.notes),
156+
("🔗️ DejaCode URL", request_url),
157+
]
158+
159+
lines = []
160+
for label, value in label_fields:
161+
if value:
162+
lines.append(f"### {label}\n{value}")
163+
164+
lines.append("----")
165+
166+
for question in request.get_serialized_data_as_list():
167+
label = question.get("label")
168+
value = question.get("value")
169+
input_type = question.get("input_type")
170+
171+
if input_type == "BooleanField":
172+
value = "Yes" if str(value).lower() in ("1", "true", "yes") else "No"
173+
174+
lines.append(f"### {label}\n{value}")
175+
176+
return "\n\n".join(lines)

0 commit comments

Comments
 (0)