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
9 changes: 9 additions & 0 deletions dje/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -1060,6 +1060,7 @@ class DataspaceConfigurationForm(forms.ModelForm):
"github_token",
"gitlab_token",
"jira_token",
"forgejo_token",
]

def __init__(self, *args, **kwargs):
Expand Down Expand Up @@ -1125,6 +1126,14 @@ class DataspaceConfigurationInline(DataspacedFKMixin, admin.StackedInline):
]
},
),
(
"Forgejo Integration",
{
"fields": [
"forgejo_token",
]
},
),
]
can_delete = False

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-08-07 09:43

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('dje', '0010_dataspaceconfiguration_jira_token_and_more'),
]

operations = [
migrations.AddField(
model_name='dataspaceconfiguration',
name='forgejo_token',
field=models.CharField(blank=True, help_text='Personal access token (PAT) used to authenticate API requests for the Forgejo integration. This token must have sufficient permissions to create and update issues. Keep this token secure.', max_length=255, verbose_name='Forgejo token'),
),
migrations.AddField(
model_name='dataspaceconfiguration',
name='sourcehut_token',
field=models.CharField(blank=True, help_text='Access token used to authenticate API requests for the SourceHut integration. This token must have permissions to create and update tickets. Keep this token secure.', max_length=255, verbose_name='SourceHut token'),
),
]
24 changes: 23 additions & 1 deletion dje/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -559,8 +559,30 @@ class DataspaceConfiguration(models.Model):
),
)

forgejo_token = models.CharField(
_("Forgejo token"),
max_length=255,
blank=True,
help_text=_(
"Personal access token (PAT) used to authenticate API requests for the "
"Forgejo integration. This token must have sufficient permissions to create "
"and update issues. Keep this token secure."
),
)

sourcehut_token = models.CharField(
_("SourceHut token"),
max_length=255,
blank=True,
help_text=_(
"Access token used to authenticate API requests for the SourceHut integration. "
"This token must have permissions to create and update tickets. "
"Keep this token secure."
),
)

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


class DataspacedQuerySet(models.QuerySet):
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Welcome to the very start of your DejaCode journey!
:maxdepth: 1
:caption: Integrations

integrations-forgejo
integrations-github
integrations-gitlab
integrations-jira
Expand Down
64 changes: 64 additions & 0 deletions docs/integrations-forgejo.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
.. _integrations_forgejo:

Forgejo Integration
===================

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

Prerequisites
-------------

- A **Forgejo repository** that you want to integrate with DejaCode.
- A **Forgejo user account** with sufficient permissions (at least write access) to
create and manage issues in that repository.

Forgejo Access Token
--------------------

To enable integration, you need a **personal access token** from Forgejo.

1. **Generate a Token**:

- Log into your Forgejo instance
- Go to your **User settings** → **Applications** → **Generate New Token**
- Set a clear name like ``DejaCode Integration``
- Select **permissions**:

- ``issue: Read and write``: Create and update issues

- Generate the token and copy it securely

.. note::

It is recommended to **create a dedicated Forgejo user** such as
``dejacode-integration`` to manage automated activity for better traceability.

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

To use your Forgejo token in DejaCode:

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

Activate Forgejo 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 Forgejo repository URL, e.g.::

https://forgejo.example.org/org/repo_name

Once the integration is configured:

- New **Requests** using this template will be automatically pushed to Forgejo
- 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 Forgejo Issue.
1 change: 1 addition & 0 deletions workflow/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ def clean_issue_tracker_id(self):
raise ValidationError(
[
"Invalid issue tracker URL format. Supported formats include:",
"• Forgejo: https://forgejo.DOMAIN.org/OR/REPO_NAME",
"• GitHub: https://github.com/ORG/REPO_NAME",
"• GitLab: https://gitlab.com/GROUP/PROJECT_NAME",
"• Jira: https://YOUR_DOMAIN.atlassian.net/projects/PROJECTKEY",
Expand Down
7 changes: 7 additions & 0 deletions workflow/integrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@
import re

from workflow.integrations.base import BaseIntegration
from workflow.integrations.forgejo import ForgejoIntegration
from workflow.integrations.github import GitHubIntegration
from workflow.integrations.gitlab import GitLabIntegration
from workflow.integrations.jira import JiraIntegration

__all__ = [
"BaseIntegration",
"ForgejoIntegration",
"GitHubIntegration",
"GitLabIntegration",
"JiraIntegration",
Expand All @@ -23,6 +25,7 @@
"get_class_for_platform",
]

FORGEJO_PATTERN = re.compile(r"^https://(?:[a-zA-Z0-9.-]*forgejo[a-zA-Z0-9.-]*)/[^/]+/[^/]+/?$")

GITHUB_PATTERN = re.compile(r"^https://github\.com/[^/]+/[^/]+/?$")

Expand All @@ -34,6 +37,7 @@
)

ISSUE_TRACKER_PATTERNS = [
FORGEJO_PATTERN,
GITHUB_PATTERN,
GITLAB_PATTERN,
JIRA_PATTERN,
Expand All @@ -51,10 +55,13 @@ def get_class_for_tracker(issue_tracker_id):
return GitLabIntegration
elif "atlassian.net" in issue_tracker_id:
return JiraIntegration
elif "forgejo" in issue_tracker_id:
return ForgejoIntegration


def get_class_for_platform(platform):
return {
"forgejo": ForgejoIntegration,
"github": GitHubIntegration,
"gitlab": GitLabIntegration,
"jira": JiraIntegration,
Expand Down
3 changes: 3 additions & 0 deletions workflow/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ def patch(self, url, json=None):
"""Send a PATCH request."""
return self.request("PATCH", url, json=json)

def post_comment(self, repo_id, issue_id, comment_body, base_url=None):
raise NotImplementedError

@staticmethod
def make_issue_title(request):
return f"[DEJACODE] {request.title}"
Expand Down
111 changes: 111 additions & 0 deletions workflow/integrations/forgejo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
#
# 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 workflow.integrations.base import BaseIntegration

FORGEJO_API_PATH = "/api/v1"


class ForgejoIntegration(BaseIntegration):
"""
A class for managing Forgejo issue creation, updates, and comments
from DejaCode requests.
"""

open_status = "open"
closed_status = "closed"

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

def sync(self, request):
"""Sync the given request with Forgejo by creating or updating an issue."""
try:
base_url, repo_path = self.extract_forgejo_info(
request.request_template.issue_tracker_id
)
except ValueError as error:
raise ValueError(f"Invalid Forgejo tracker URL: {error}")

self.api_url = base_url.rstrip("/") + FORGEJO_API_PATH

external_issue = request.external_issue
if external_issue:
self.update_issue(
repo_id=repo_path,
issue_id=external_issue.issue_id,
title=self.make_issue_title(request),
body=self.make_issue_body(request),
state=self.closed_status if request.is_closed else self.open_status,
)
else:
issue = self.create_issue(
repo_id=repo_path,
title=self.make_issue_title(request),
body=self.make_issue_body(request),
)
request.link_external_issue(
platform="forgejo",
repo=repo_path,
issue_id=issue["number"],
base_url=base_url,
)

def create_issue(self, repo_id, title, body=""):
"""Create a new Forgejo issue."""
url = f"{self.api_url}/repos/{repo_id}/issues"
data = {
"title": title,
"body": body,
}

return self.post(url, json=data)

def update_issue(self, repo_id, issue_id, title=None, body=None, state=None):
"""Update an existing Forgejo 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

return self.patch(url, json=data)

def post_comment(self, repo_id, issue_id, comment_body, base_url=None):
"""Post a comment on an existing Forgejo issue."""
url = f"{base_url}{FORGEJO_API_PATH}/repos/{repo_id}/issues/{issue_id}/comments"
return self.post(url, json={"body": comment_body})

@staticmethod
def extract_forgejo_info(url):
"""
Extract the Forgejo base domain and repo path (org/repo) from a repo URL.

Example:
- https://forgejo.example.org/org/repo → ("https://forgejo.example.org", "org/repo")

"""
parsed = urlparse(url)
if not parsed.netloc:
raise ValueError("Missing hostname in Forgejo URL.")

base_url = f"{parsed.scheme}://{parsed.netloc}"
path_parts = [p for p in parsed.path.split("/") if p]
if len(path_parts) < 2:
raise ValueError("Incomplete Forgejo repository path.")

repo_path = f"{path_parts[0]}/{path_parts[1]}"
return base_url, repo_path
6 changes: 4 additions & 2 deletions workflow/integrations/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ class GitHubIntegration(BaseIntegration):
"""

api_url = GITHUB_API_URL
open_status = "open"
closed_status = "closed"

def get_headers(self):
github_token = self.dataspace.get_configuration(field_name="github_token")
Expand All @@ -45,7 +47,7 @@ def sync(self, request):
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",
state=self.closed_status if request.is_closed else self.open_status,
labels=labels,
)
else:
Expand Down Expand Up @@ -88,7 +90,7 @@ def update_issue(self, repo_id, issue_id, title=None, body=None, state=None, lab

return self.patch(url, json=data)

def post_comment(self, repo_id, issue_id, comment_body):
def post_comment(self, repo_id, issue_id, comment_body, base_url=None):
"""Post a comment on an existing GitHub issue."""
url = f"{self.api_url}/repos/{repo_id}/issues/{issue_id}/comments"
data = {"body": comment_body}
Expand Down
2 changes: 1 addition & 1 deletion workflow/integrations/gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ def update_issue(self, repo_id, issue_id, title=None, body=None, state_event=Non

return self.put(url, json=data)

def post_comment(self, repo_id, issue_id, comment_body):
def post_comment(self, repo_id, issue_id, comment_body, base_url=None):
"""Post a comment on an existing GitLab issue."""
project_path = quote(repo_id, safe="")
url = f"{self.api_url}/projects/{project_path}/issues/{issue_id}/notes"
Expand Down
2 changes: 1 addition & 1 deletion workflow/integrations/jira.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def update_issue(self, issue_id, title=None, body=None, status=None):

return {"id": issue_id}

def post_comment(self, repo_id, issue_id, comment_body):
def post_comment(self, repo_id, issue_id, comment_body, base_url=None):
"""Post a comment on an existing Jira issue."""
api_url = repo_id.rstrip("/") + JIRA_API_PATH
url = f"{api_url}/issue/{issue_id}/comment"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')),
('platform', models.CharField(choices=[('github', 'GitHub'), ('gitlab', 'GitLab'), ('jira', 'Jira'), ('sourcehut', 'SourceHut')], help_text='External issue tracking platform.', max_length=20)),
('platform', models.CharField(choices=[('github', 'GitHub'), ('gitlab', 'GitLab'), ('jira', 'Jira'), ('sourcehut', 'SourceHut'), ('forgejo', 'Forgejo')], help_text='External issue tracking platform.', max_length=20)),
('repo', models.CharField(help_text="Repository or project identifier (e.g., 'user/repo-name').", max_length=100)),
('issue_id', models.CharField(help_text='ID or key of the issue on the external platform.', max_length=100)),
('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')),
Expand Down
18 changes: 18 additions & 0 deletions workflow/migrations/0003_externalissuelink_base_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-08-07 14:07

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('workflow', '0002_requesttemplate_issue_tracker_id_externalissuelink_and_more'),
]

operations = [
migrations.AddField(
model_name='externalissuelink',
name='base_url',
field=models.URLField(blank=True, help_text='Base URL of the external issue tracker platform (e.g., https://forgejo.example.org). Used to construct API endpoints for integrations like Forgejo or Jira.', max_length=255, null=True),
),
]
Loading