Skip to content

Commit 62af982

Browse files
committed
Add Forgejo workflow Request integration #350
Signed-off-by: tdruez <[email protected]>
1 parent 65b4f55 commit 62af982

File tree

8 files changed

+186
-8
lines changed

8 files changed

+186
-8
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: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -559,8 +559,31 @@ class DataspaceConfiguration(models.Model):
559559
),
560560
)
561561

562-
def __str__(self):
563-
return f"Configuration for {self.dataspace}"
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+
584+
585+
def __str__(self):
586+
return f"Configuration for {self.dataspace}"
564587

565588

566589
class DataspacedQuerySet(models.QuerySet):

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/forgejo.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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_URL = ""
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+
api_url = FORGEJO_API_URL
23+
24+
def get_headers(self):
25+
forgejo_token = self.dataspace.get_configuration(field_name="forgejo_token")
26+
if not forgejo_token:
27+
raise ValueError("The forgejo_token is not set on the Dataspace.")
28+
return {"Authorization": f"token {forgejo_token}"}
29+
30+
def sync(self, request):
31+
"""Sync the given request with Forgejo by creating or updating an issue."""
32+
try:
33+
repo_id = self.extract_forgejo_repo_path(request.request_template.issue_tracker_id)
34+
except ValueError as error:
35+
raise ValueError(f"Invalid Forgejo repository URL: {error}")
36+
37+
external_issue = request.external_issue
38+
if external_issue:
39+
self.update_issue(
40+
repo_id=repo_id,
41+
issue_id=external_issue.issue_id,
42+
title=self.make_issue_title(request),
43+
body=self.make_issue_body(request),
44+
state="closed" if request.is_closed else "open",
45+
)
46+
else:
47+
issue = self.create_issue(
48+
repo_id=repo_id,
49+
title=self.make_issue_title(request),
50+
body=self.make_issue_body(request),
51+
)
52+
request.link_external_issue(
53+
platform="forgejo",
54+
repo=repo_id,
55+
issue_id=issue["number"],
56+
)
57+
58+
def create_issue(self, repo_id, title, body=""):
59+
"""Create a new Forgejo issue."""
60+
url = f"{self.api_url}/repos/{repo_id}/issues"
61+
data = {
62+
"title": title,
63+
"body": body,
64+
}
65+
66+
return self.post(url, json=data)
67+
68+
def update_issue(self, repo_id, issue_id, title=None, body=None, state=None):
69+
"""Update an existing Forgejo issue."""
70+
url = f"{self.api_url}/repos/{repo_id}/issues/{issue_id}"
71+
data = {}
72+
if title:
73+
data["title"] = title
74+
if body:
75+
data["body"] = body
76+
if state:
77+
data["state"] = state
78+
79+
return self.patch(url, json=data)
80+
81+
def post_comment(self, repo_id, issue_id, comment_body):
82+
"""Post a comment on an existing Forgejo issue."""
83+
url = f"{self.api_url}/repos/{repo_id}/issues/{issue_id}/comments"
84+
data = {"body": comment_body}
85+
86+
return self.post(url, json=data)
87+
88+
@staticmethod
89+
def extract_forgejo_repo_path(url):
90+
"""Extract 'owner/repo-name' from a Forgejo URL."""
91+
parsed = urlparse(url)
92+
if not parsed.netloc:
93+
raise ValueError("URL must include a hostname.")
94+
95+
path_parts = [part for part in parsed.path.split("/") if part]
96+
if len(path_parts) < 2:
97+
raise ValueError("Incomplete Forgejo repository path.")
98+
99+
return f"{path_parts[0]}/{path_parts[1]}"

workflow/migrations/0002_requesttemplate_issue_tracker_id_externalissuelink_and_more.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class Migration(migrations.Migration):
2323
fields=[
2424
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
2525
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')),
26-
('platform', models.CharField(choices=[('github', 'GitHub'), ('gitlab', 'GitLab'), ('jira', 'Jira'), ('sourcehut', 'SourceHut')], help_text='External issue tracking platform.', max_length=20)),
26+
('platform', models.CharField(choices=[('github', 'GitHub'), ('gitlab', 'GitLab'), ('jira', 'Jira'), ('sourcehut', 'SourceHut'), ('forgejo', 'Forgejo')], help_text='External issue tracking platform.', max_length=20)),
2727
('repo', models.CharField(help_text="Repository or project identifier (e.g., 'user/repo-name').", max_length=100)),
2828
('issue_id', models.CharField(help_text='ID or key of the issue on the external platform.', max_length=100)),
2929
('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')),

workflow/models.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,17 +95,22 @@ class Platform(models.TextChoices):
9595
GITLAB = "gitlab", _("GitLab")
9696
JIRA = "jira", _("Jira")
9797
SOURCEHUT = "sourcehut", _("SourceHut")
98+
FORGEJO = "forgejo", _("Forgejo")
9899

99100
platform = models.CharField(
100-
max_length=20, choices=Platform.choices, help_text="External issue tracking platform."
101+
max_length=20,
102+
choices=Platform.choices,
103+
help_text=_("External issue tracking platform."),
101104
)
102105

103106
repo = models.CharField(
104-
max_length=100, help_text="Repository or project identifier (e.g., 'user/repo-name')."
107+
max_length=100,
108+
help_text=_("Repository or project identifier (e.g., 'user/repo-name')."),
105109
)
106110

107111
issue_id = models.CharField(
108-
max_length=100, help_text="ID or key of the issue on the external platform."
112+
max_length=100,
113+
help_text=_("ID or key of the issue on the external platform."),
109114
)
110115

111116
class Meta:
@@ -125,6 +130,8 @@ def issue_url(self):
125130
return f"https://gitlab.com/{self.repo}/-/issues/{self.issue_id}"
126131
elif self.platform == self.Platform.JIRA:
127132
return f"{self.repo}/browse/{self.issue_id}"
133+
elif self.platform == self.Platform.FORGEJO:
134+
return f"{self.repo}/issues/{self.issue_id}"
128135

129136
@property
130137
def icon_css_class(self):

workflow/tests/test_integrations.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@
1515

1616
from dje.models import Dataspace
1717
from dje.tests import create_superuser
18+
from workflow.integrations import ForgejoIntegration
19+
from workflow.integrations import GitHubIntegration
20+
from workflow.integrations import GitLabIntegration
1821
from workflow.integrations import JiraIntegration
1922
from workflow.integrations import get_class_for_platform
2023
from workflow.integrations import get_class_for_tracker
2124
from workflow.integrations import is_valid_issue_tracker_id
22-
from workflow.integrations.github import GitHubIntegration
23-
from workflow.integrations.gitlab import GitLabIntegration
2425
from workflow.models import Question
2526
from workflow.models import RequestTemplate
2627

@@ -41,6 +42,10 @@ def test_integrations_is_valid_issue_tracker_id(self):
4142
"https://example.atlassian.net/jira/software/projects/PROJ/",
4243
"https://example.atlassian.net/jira/software/projects/PROJ/summary",
4344
"https://example.atlassian.net/jira/servicedesk/projects/PROJ",
45+
# Forgejo
46+
"https://code.forgejo.org/user/repo",
47+
"https://git.forgejo.dev/org/project/",
48+
"https://forgejo.example.org/team/repo",
4449
]
4550
for url in valid_urls:
4651
self.assertTrue(is_valid_issue_tracker_id(url), msg=url)
@@ -51,6 +56,7 @@ def test_integrations_is_valid_issue_tracker_id(self):
5156
"https://gitlab.com/",
5257
"https://atlassian.net/projects/",
5358
"https://example.com",
59+
"https://example.org/user/repo",
5460
]
5561
for url in invalid_urls:
5662
self.assertFalse(is_valid_issue_tracker_id(url), msg=url)
@@ -61,12 +67,16 @@ def test_integrations_get_class_for_tracker(self):
6167
self.assertIs(
6268
get_class_for_tracker("https://example.atlassian.net/projects/PROJ"), JiraIntegration
6369
)
70+
self.assertIs(
71+
get_class_for_tracker("https://code.forgejo.org/user/repo"), ForgejoIntegration
72+
)
6473
self.assertIsNone(get_class_for_tracker("https://example.com"))
6574

6675
def test_integrations_get_class_for_platform(self):
6776
self.assertIs(get_class_for_platform("github"), GitHubIntegration)
6877
self.assertIs(get_class_for_platform("gitlab"), GitLabIntegration)
6978
self.assertIs(get_class_for_platform("jira"), JiraIntegration)
79+
self.assertIs(get_class_for_platform("forgejo"), ForgejoIntegration)
7080
self.assertIsNone(get_class_for_platform("example"))
7181

7282

0 commit comments

Comments
 (0)