Skip to content
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
7 changes: 1 addition & 6 deletions dejacode_toolkit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
from os import getenv

from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist

import requests

Expand Down Expand Up @@ -53,11 +52,7 @@ def __init__(self, dataspace):
self.basic_auth_user = None
self.basic_auth_password = None

try:
dataspace_configuration = dataspace.configuration
except ObjectDoesNotExist:
dataspace_configuration = None

dataspace_configuration = dataspace.get_configuration()
# Take the integration settings from the Dataspace when defined
if dataspace_configuration:
self.service_url = getattr(dataspace_configuration, self.url_field_name)
Expand Down
37 changes: 28 additions & 9 deletions dje/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -1056,6 +1056,7 @@ class DataspaceConfigurationForm(forms.ModelForm):
"scancodeio_api_key",
"vulnerablecode_api_key",
"purldb_api_key",
"github_token",
]

def __init__(self, *args, **kwargs):
Expand All @@ -1077,15 +1078,33 @@ class DataspaceConfigurationInline(DataspacedFKMixin, admin.StackedInline):
form = DataspaceConfigurationForm
verbose_name_plural = _("Configuration")
verbose_name = _("Dataspace configuration")
fields = [
"homepage_layout",
"scancodeio_url",
"scancodeio_api_key",
"vulnerablecode_url",
"vulnerablecode_api_key",
"vulnerabilities_risk_threshold",
"purldb_url",
"purldb_api_key",
fieldsets = [
(
"",
{"fields": ("homepage_layout",)},
),
(
"AboutCode Integrations",
{
"fields": (
"scancodeio_url",
"scancodeio_api_key",
"vulnerablecode_url",
"vulnerablecode_api_key",
"vulnerabilities_risk_threshold",
"purldb_url",
"purldb_api_key",
)
},
),
(
"GitHub Integration",
{
"fields": [
"github_token",
]
},
),
]
can_delete = False

Expand Down
18 changes: 18 additions & 0 deletions dje/migrations/0008_dataspaceconfiguration_github_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-07-28 13:12

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('dje', '0007_dejacodeuser_timezone'),
]

operations = [
migrations.AddField(
model_name='dataspaceconfiguration',
name='github_token',
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'),
),
]
10 changes: 10 additions & 0 deletions dje/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,16 @@ class DataspaceConfiguration(models.Model):
),
)

github_token = models.CharField(
_("GitHub token"),
max_length=255,
blank=True,
help_text=_(
"Personal access token (PAT) or GitHub App token used to authenticate "
"API requests for this integration. Keep this token secure."
),
)

def __str__(self):
return f"Configuration for {self.dataspace}"

Expand Down
3 changes: 2 additions & 1 deletion dje/tests/testfiles/test_dataset_workflow.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
],
"is_active": true,
"include_applies_to": true,
"include_product": true
"include_product": true,
"issue_tracker_id": ""
}
},
{
Expand Down
6 changes: 6 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ Welcome to the very start of your DejaCode journey!
reference-2
reference-3-cravex

.. toctree::
:maxdepth: 1
:caption: Integrations

integrations-github

.. toctree::
:maxdepth: 1
:caption: Miscellaneous
Expand Down
75 changes: 75 additions & 0 deletions docs/integrations-github.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
.. _integrations_github:

GitHub Integration
==================

DejaCode's integration with GitHub allows you to automatically forward
**Workflow Requests** to GitHub repository **Issues**.
This behavior can be selectively applied to any **Request Template** of your choice.

GitHub Account and Personal Access Token
----------------------------------------

To enable integration, you need a GitHub **fine-grained personal access token (PAT)**.

1. **Access GitHub Developer Settings**:

- Go to: https://github.com/settings/personal-access-tokens
- Click **"Generate new token"** under *Fine-grained personal access tokens*

2. **Configure the Token**:

- **Name**: Give it a clear name (e.g., ``DejaCode Integration``)
- **Expiration**: Set an expiration date (recommended)
- **Resource owner**: Choose your personal GitHub account or organization

.. note::

It is recommended to **create a dedicated GitHub user** with a clear, descriptive
name such as ``dejacode-integration``. This ensures that all GitHub issues managed by
the integration are clearly attributed to that user, improving traceability and
auditability.

3. **Repository Access**:

- Under *Repository access*, select **Only select repositories**
- Choose the repository where you want issues to be created and updated

4. **Permissions**:

- Under *Repository permissions*, enable::

Issues: Read and write

5. **Save and Copy the Token**:

- Click **Generate token**
- Copy the token and store it securely — you’ll need it for the next step

DejaCode Dataspace Configuration
--------------------------------

To use your GitHub token in DejaCode:

1. Go to the **Administration dashboard**
2. Navigate to **Dataspaces**, and select your Dataspace
3. Scroll to the **GitHub Integration** section under **Configuration**
4. Paste your GitHub token in the **GitHub token** field
5. Save the form

Activate GitHub Integration on Request Templates
------------------------------------------------

1. Go to the **Administration dashboard**
2. Navigate to **Workflow** > **Request templates**
3. Create or edit a Request Template in your Dataspace
4. Set the **Issue Tracker ID** field to your GitHub repository URL, e.g.::

https://github.com/org/repo_name

Once the integration is configured:

- New **Requests** using this template will be automatically pushed to GitHub
- Field updates (like title or priority) and **status changes** (e.g. closed) will be
synced
- New **Comments** on a DejaCode Request will be propagated to the GitHub Issue.
7 changes: 7 additions & 0 deletions workflow/integrations/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#
# Copyright (c) nexB Inc. and others. All rights reserved.
# DejaCode is a trademark of nexB Inc.
# SPDX-License-Identifier: AGPL-3.0-only
# See https://github.com/aboutcode-org/dejacode for support or download.
# See https://aboutcode.org for more information about AboutCode FOSS projects.
#
176 changes: 176 additions & 0 deletions workflow/integrations/github.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
#
# Copyright (c) nexB Inc. and others. All rights reserved.
# DejaCode is a trademark of nexB Inc.
# SPDX-License-Identifier: AGPL-3.0-only
# See https://github.com/aboutcode-org/dejacode for support or download.
# See https://aboutcode.org for more information about AboutCode FOSS projects.
#

from urllib.parse import urlparse

from django.conf import settings

import requests

GITHUB_API_URL = "https://api.github.com"
DEJACODE_SITE_URL = settings.SITE_URL.rstrip("/")


class GitHubIntegration:
"""
A class for managing GitHub issue creation, updates, and comments
from DejaCode requests.
"""

api_url = GITHUB_API_URL
default_timeout = 10

def __init__(self, dataspace):
if not dataspace:
raise ValueError("Dataspace must be provided.")
self.dataspace = dataspace
self.session = self.get_session()

def get_session(self):
session = requests.Session()
session.headers.update(self.get_headers())
return session

def get_headers(self):
github_token = self.dataspace.get_configuration(field_name="github_token")
if not github_token:
raise ValueError("The github_token is not set on the Dataspace.")
return {"Authorization": f"token {github_token}"}

def sync(self, request):
"""Sync the given request with GitHub by creating or updating an issue."""
try:
repo_id = self.extract_github_repo_path(request.request_template.issue_tracker_id)
except ValueError as error:
raise ValueError(f"Invalid GitHub repository URL: {error}")

labels = []
if request.priority:
labels.append(str(request.priority))

external_issue = request.external_issue
if external_issue:
self.update_issue(
repo_id=repo_id,
issue_id=external_issue.issue_id,
title=self.make_issue_title(request),
body=self.make_issue_body(request),
state="closed" if request.is_closed else "open",
)
else:
issue = self.create_issue(
repo_id=repo_id,
title=self.make_issue_title(request),
body=self.make_issue_body(request),
labels=labels,
)
request.link_external_issue(
platform="github",
repo=repo_id,
issue_id=issue["number"],
)

def create_issue(self, repo_id, title, body="", labels=None):
"""Create a new GitHub issue."""
url = f"{self.api_url}/repos/{repo_id}/issues"
data = {
"title": title,
"body": body,
}
if labels:
data["labels"] = labels

response = self.session.post(
url,
json=data,
timeout=self.default_timeout,
)
response.raise_for_status()
return response.json()

def update_issue(self, repo_id, issue_id, title=None, body=None, state=None):
"""Update an existing GitHub issue."""
url = f"{self.api_url}/repos/{repo_id}/issues/{issue_id}"
data = {}
if title:
data["title"] = title
if body:
data["body"] = body
if state:
data["state"] = state

response = self.session.patch(
url,
json=data,
timeout=self.default_timeout,
)
response.raise_for_status()
return response.json()

def post_comment(self, repo_id, issue_id, comment_body):
"""Post a comment on an existing GitHub issue."""
url = f"{self.api_url}/repos/{repo_id}/issues/{issue_id}/comments"
data = {"body": comment_body}

response = self.session.post(
url,
json=data,
timeout=self.default_timeout,
)
response.raise_for_status()
return response.json()

@staticmethod
def extract_github_repo_path(url):
"""Extract 'username/repo-name' from a GitHub URL."""
parsed = urlparse(url)
if "github.com" not in parsed.netloc:
raise ValueError("URL does not point to GitHub.")

path_parts = [part for part in parsed.path.split("/") if part]
if len(path_parts) < 2:
raise ValueError("Incomplete GitHub repository path.")

return f"{path_parts[0]}/{path_parts[1]}"

@staticmethod
def make_issue_title(request):
return f"[DEJACODE] {request.title}"

@staticmethod
def make_issue_body(request):
request_url = f"{DEJACODE_SITE_URL}{request.get_absolute_url()}"
label_fields = [
("📝 Request Template", request.request_template),
("📦 Product Context", request.product_context),
("📌 Applies To", request.content_object),
("🙋 Submitted By", request.requester),
("👤 Assigned To", request.assignee),
("🚨 Priority", request.priority),
("🗒️ Notes", request.notes),
("🔗️ DejaCode URL", request_url),
]

lines = []
for label, value in label_fields:
if value:
lines.append(f"### {label}\n{value}")

lines.append("----")

for question in request.get_serialized_data_as_list():
label = question.get("label")
value = question.get("value")
input_type = question.get("input_type")

if input_type == "BooleanField":
value = "Yes" if str(value).lower() in ("1", "true", "yes") else "No"

lines.append(f"### {label}\n{value}")

return "\n\n".join(lines)
Loading