Skip to content

Commit 768c609

Browse files
authored
Add Forgejo workflow Request integration #350 (#376)
Signed-off-by: tdruez <[email protected]>
1 parent 65b4f55 commit 768c609

16 files changed

+418
-12
lines changed

dje/admin.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1060,6 +1060,7 @@ class DataspaceConfigurationForm(forms.ModelForm):
10601060
"github_token",
10611061
"gitlab_token",
10621062
"jira_token",
1063+
"forgejo_token",
10631064
]
10641065

10651066
def __init__(self, *args, **kwargs):
@@ -1125,6 +1126,14 @@ class DataspaceConfigurationInline(DataspacedFKMixin, admin.StackedInline):
11251126
]
11261127
},
11271128
),
1129+
(
1130+
"Forgejo Integration",
1131+
{
1132+
"fields": [
1133+
"forgejo_token",
1134+
]
1135+
},
1136+
),
11281137
]
11291138
can_delete = False
11301139

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Generated by Django 5.2.4 on 2025-08-07 09:43
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('dje', '0010_dataspaceconfiguration_jira_token_and_more'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='dataspaceconfiguration',
15+
name='forgejo_token',
16+
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'),
17+
),
18+
migrations.AddField(
19+
model_name='dataspaceconfiguration',
20+
name='sourcehut_token',
21+
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'),
22+
),
23+
]

dje/models.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -559,8 +559,30 @@ class DataspaceConfiguration(models.Model):
559559
),
560560
)
561561

562+
forgejo_token = models.CharField(
563+
_("Forgejo token"),
564+
max_length=255,
565+
blank=True,
566+
help_text=_(
567+
"Personal access token (PAT) used to authenticate API requests for the "
568+
"Forgejo integration. This token must have sufficient permissions to create "
569+
"and update issues. Keep this token secure."
570+
),
571+
)
572+
573+
sourcehut_token = models.CharField(
574+
_("SourceHut token"),
575+
max_length=255,
576+
blank=True,
577+
help_text=_(
578+
"Access token used to authenticate API requests for the SourceHut integration. "
579+
"This token must have permissions to create and update tickets. "
580+
"Keep this token secure."
581+
),
582+
)
583+
562584
def __str__(self):
563-
return f"Configuration for {self.dataspace}"
585+
return f"{self.dataspace}"
564586

565587

566588
class DataspacedQuerySet(models.QuerySet):

docs/index.rst

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

51+
integrations-forgejo
5152
integrations-github
5253
integrations-gitlab
5354
integrations-jira

docs/integrations-forgejo.rst

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
.. _integrations_forgejo:
2+
3+
Forgejo Integration
4+
===================
5+
6+
DejaCode's integration with Forgejo allows you to automatically forward
7+
**Workflow Requests** to Forgejo repository **Issues**.
8+
This behavior can be selectively applied to any **Request Template** of your choice.
9+
10+
Prerequisites
11+
-------------
12+
13+
- A **Forgejo repository** that you want to integrate with DejaCode.
14+
- A **Forgejo user account** with sufficient permissions (at least write access) to
15+
create and manage issues in that repository.
16+
17+
Forgejo Access Token
18+
--------------------
19+
20+
To enable integration, you need a **personal access token** from Forgejo.
21+
22+
1. **Generate a Token**:
23+
24+
- Log into your Forgejo instance
25+
- Go to your **User settings** → **Applications** → **Generate New Token**
26+
- Set a clear name like ``DejaCode Integration``
27+
- Select **permissions**:
28+
29+
- ``issue: Read and write``: Create and update issues
30+
31+
- Generate the token and copy it securely
32+
33+
.. note::
34+
35+
It is recommended to **create a dedicated Forgejo user** such as
36+
``dejacode-integration`` to manage automated activity for better traceability.
37+
38+
DejaCode Dataspace Configuration
39+
--------------------------------
40+
41+
To use your Forgejo token in DejaCode:
42+
43+
1. Go to the **Administration dashboard**
44+
2. Navigate to **Dataspaces**, and select your Dataspace
45+
3. Scroll to the **Forgejo Integration** section under **Configuration**
46+
4. Paste your Forgejo token in the **Forgejo token** field
47+
5. Save the form
48+
49+
Activate Forgejo Integration on Request Templates
50+
-------------------------------------------------
51+
52+
1. Go to the **Administration dashboard**
53+
2. Navigate to **Workflow** > **Request templates**
54+
3. Create or edit a Request Template in your Dataspace
55+
4. Set the **Issue Tracker ID** field to your Forgejo repository URL, e.g.::
56+
57+
https://forgejo.example.org/org/repo_name
58+
59+
Once the integration is configured:
60+
61+
- New **Requests** using this template will be automatically pushed to Forgejo
62+
- Field updates (like title or priority) and **status changes** (e.g. closed) will be
63+
synced
64+
- New **Comments** on a DejaCode Request will be propagated to the Forgejo Issue.

workflow/admin.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def clean_issue_tracker_id(self):
6565
raise ValidationError(
6666
[
6767
"Invalid issue tracker URL format. Supported formats include:",
68+
"• Forgejo: https://forgejo.DOMAIN.org/OR/REPO_NAME",
6869
"• GitHub: https://github.com/ORG/REPO_NAME",
6970
"• GitLab: https://gitlab.com/GROUP/PROJECT_NAME",
7071
"• Jira: https://YOUR_DOMAIN.atlassian.net/projects/PROJECTKEY",

workflow/integrations/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@
99
import re
1010

1111
from workflow.integrations.base import BaseIntegration
12+
from workflow.integrations.forgejo import ForgejoIntegration
1213
from workflow.integrations.github import GitHubIntegration
1314
from workflow.integrations.gitlab import GitLabIntegration
1415
from workflow.integrations.jira import JiraIntegration
1516

1617
__all__ = [
1718
"BaseIntegration",
19+
"ForgejoIntegration",
1820
"GitHubIntegration",
1921
"GitLabIntegration",
2022
"JiraIntegration",
@@ -23,6 +25,7 @@
2325
"get_class_for_platform",
2426
]
2527

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

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

@@ -34,6 +37,7 @@
3437
)
3538

3639
ISSUE_TRACKER_PATTERNS = [
40+
FORGEJO_PATTERN,
3741
GITHUB_PATTERN,
3842
GITLAB_PATTERN,
3943
JIRA_PATTERN,
@@ -51,10 +55,13 @@ def get_class_for_tracker(issue_tracker_id):
5155
return GitLabIntegration
5256
elif "atlassian.net" in issue_tracker_id:
5357
return JiraIntegration
58+
elif "forgejo" in issue_tracker_id:
59+
return ForgejoIntegration
5460

5561

5662
def get_class_for_platform(platform):
5763
return {
64+
"forgejo": ForgejoIntegration,
5865
"github": GitHubIntegration,
5966
"gitlab": GitLabIntegration,
6067
"jira": JiraIntegration,

workflow/integrations/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ def patch(self, url, json=None):
9292
"""Send a PATCH request."""
9393
return self.request("PATCH", url, json=json)
9494

95+
def post_comment(self, repo_id, issue_id, comment_body, base_url=None):
96+
raise NotImplementedError
97+
9598
@staticmethod
9699
def make_issue_title(request):
97100
return f"[DEJACODE] {request.title}"

workflow/integrations/forgejo.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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 workflow.integrations.base import BaseIntegration
12+
13+
FORGEJO_API_PATH = "/api/v1"
14+
15+
16+
class ForgejoIntegration(BaseIntegration):
17+
"""
18+
A class for managing Forgejo issue creation, updates, and comments
19+
from DejaCode requests.
20+
"""
21+
22+
open_status = "open"
23+
closed_status = "closed"
24+
25+
def get_headers(self):
26+
forgejo_token = self.dataspace.get_configuration("forgejo_token")
27+
if not forgejo_token:
28+
raise ValueError("The forgejo_token is not set on the Dataspace.")
29+
return {"Authorization": f"token {forgejo_token}"}
30+
31+
def sync(self, request):
32+
"""Sync the given request with Forgejo by creating or updating an issue."""
33+
try:
34+
base_url, repo_path = self.extract_forgejo_info(
35+
request.request_template.issue_tracker_id
36+
)
37+
except ValueError as error:
38+
raise ValueError(f"Invalid Forgejo tracker URL: {error}")
39+
40+
self.api_url = base_url.rstrip("/") + FORGEJO_API_PATH
41+
42+
external_issue = request.external_issue
43+
if external_issue:
44+
self.update_issue(
45+
repo_id=repo_path,
46+
issue_id=external_issue.issue_id,
47+
title=self.make_issue_title(request),
48+
body=self.make_issue_body(request),
49+
state=self.closed_status if request.is_closed else self.open_status,
50+
)
51+
else:
52+
issue = self.create_issue(
53+
repo_id=repo_path,
54+
title=self.make_issue_title(request),
55+
body=self.make_issue_body(request),
56+
)
57+
request.link_external_issue(
58+
platform="forgejo",
59+
repo=repo_path,
60+
issue_id=issue["number"],
61+
base_url=base_url,
62+
)
63+
64+
def create_issue(self, repo_id, title, body=""):
65+
"""Create a new Forgejo issue."""
66+
url = f"{self.api_url}/repos/{repo_id}/issues"
67+
data = {
68+
"title": title,
69+
"body": body,
70+
}
71+
72+
return self.post(url, json=data)
73+
74+
def update_issue(self, repo_id, issue_id, title=None, body=None, state=None):
75+
"""Update an existing Forgejo issue."""
76+
url = f"{self.api_url}/repos/{repo_id}/issues/{issue_id}"
77+
data = {}
78+
if title:
79+
data["title"] = title
80+
if body:
81+
data["body"] = body
82+
if state:
83+
data["state"] = state
84+
85+
return self.patch(url, json=data)
86+
87+
def post_comment(self, repo_id, issue_id, comment_body, base_url=None):
88+
"""Post a comment on an existing Forgejo issue."""
89+
url = f"{base_url}{FORGEJO_API_PATH}/repos/{repo_id}/issues/{issue_id}/comments"
90+
return self.post(url, json={"body": comment_body})
91+
92+
@staticmethod
93+
def extract_forgejo_info(url):
94+
"""
95+
Extract the Forgejo base domain and repo path (org/repo) from a repo URL.
96+
97+
Example:
98+
- https://forgejo.example.org/org/repo → ("https://forgejo.example.org", "org/repo")
99+
100+
"""
101+
parsed = urlparse(url)
102+
if not parsed.netloc:
103+
raise ValueError("Missing hostname in Forgejo URL.")
104+
105+
base_url = f"{parsed.scheme}://{parsed.netloc}"
106+
path_parts = [p for p in parsed.path.split("/") if p]
107+
if len(path_parts) < 2:
108+
raise ValueError("Incomplete Forgejo repository path.")
109+
110+
repo_path = f"{path_parts[0]}/{path_parts[1]}"
111+
return base_url, repo_path

workflow/integrations/github.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ class GitHubIntegration(BaseIntegration):
2020
"""
2121

2222
api_url = GITHUB_API_URL
23+
open_status = "open"
24+
closed_status = "closed"
2325

2426
def get_headers(self):
2527
github_token = self.dataspace.get_configuration(field_name="github_token")
@@ -45,7 +47,7 @@ def sync(self, request):
4547
issue_id=external_issue.issue_id,
4648
title=self.make_issue_title(request),
4749
body=self.make_issue_body(request),
48-
state="closed" if request.is_closed else "open",
50+
state=self.closed_status if request.is_closed else self.open_status,
4951
labels=labels,
5052
)
5153
else:
@@ -88,7 +90,7 @@ def update_issue(self, repo_id, issue_id, title=None, body=None, state=None, lab
8890

8991
return self.patch(url, json=data)
9092

91-
def post_comment(self, repo_id, issue_id, comment_body):
93+
def post_comment(self, repo_id, issue_id, comment_body, base_url=None):
9294
"""Post a comment on an existing GitHub issue."""
9395
url = f"{self.api_url}/repos/{repo_id}/issues/{issue_id}/comments"
9496
data = {"body": comment_body}

0 commit comments

Comments
 (0)