Skip to content

Commit 833713a

Browse files
committed
Add Jira workflow Request integration #350
Signed-off-by: tdruez <[email protected]>
1 parent 4851d28 commit 833713a

File tree

8 files changed

+342
-0
lines changed

8 files changed

+342
-0
lines changed

CHANGELOG.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ Release notes
5050
Documentation: https://dejacode.readthedocs.io/en/latest/integrations-github.html
5151
https://github.com/aboutcode-org/dejacode/issues/346
5252

53+
- Add Jira workflow Request integration.
54+
https://github.com/aboutcode-org/dejacode/issues/350
55+
5356
### Version 5.3.0
5457

5558
- Rename ProductDependency is_resolved to is_pinned.

dje/admin.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1059,6 +1059,7 @@ class DataspaceConfigurationForm(forms.ModelForm):
10591059
"purldb_api_key",
10601060
"github_token",
10611061
"gitlab_token",
1062+
"jira_token",
10621063
]
10631064

10641065
def __init__(self, *args, **kwargs):
@@ -1115,6 +1116,15 @@ class DataspaceConfigurationInline(DataspacedFKMixin, admin.StackedInline):
11151116
]
11161117
},
11171118
),
1119+
(
1120+
"Jira Integration",
1121+
{
1122+
"fields": [
1123+
"jira_user",
1124+
"jira_token",
1125+
]
1126+
},
1127+
),
11181128
]
11191129
can_delete = False
11201130

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-01 12:33
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('dje', '0009_dataspaceconfiguration_gitlab_token'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='dataspaceconfiguration',
15+
name='jira_token',
16+
field=models.CharField(blank=True, help_text='API token generated from your Atlassian account, used to authenticate API requests to Jira Cloud. Keep this token secure.', max_length=255, verbose_name='Jira API token'),
17+
),
18+
migrations.AddField(
19+
model_name='dataspaceconfiguration',
20+
name='jira_user',
21+
field=models.CharField(blank=True, help_text='The email address associated with your Jira account. Used together with the API token to authenticate API requests.', max_length=255, verbose_name='Jira user email'),
22+
),
23+
]

dje/models.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,26 @@ class DataspaceConfiguration(models.Model):
539539
),
540540
)
541541

542+
jira_user = models.CharField(
543+
_("Jira user email"),
544+
max_length=255,
545+
blank=True,
546+
help_text=_(
547+
"The email address associated with your Jira account. "
548+
"Used together with the API token to authenticate API requests."
549+
),
550+
)
551+
552+
jira_token = models.CharField(
553+
_("Jira API token"),
554+
max_length=255,
555+
blank=True,
556+
help_text=_(
557+
"API token generated from your Atlassian account, used to authenticate "
558+
"API requests to Jira Cloud. Keep this token secure."
559+
),
560+
)
561+
542562
def __str__(self):
543563
return f"Configuration for {self.dataspace}"
544564

docs/index.rst

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

5151
integrations-github
5252
integrations-gitlab
53+
integrations-jira
5354

5455
.. toctree::
5556
:maxdepth: 1

docs/integrations-jira.rst

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
.. _integrations_jira:
2+
3+
Jira Cloud Integration
4+
======================
5+
6+
DejaCode's integration with Jira allows you to automatically forward
7+
**Workflow Requests** to Jira **Issues**.
8+
This behavior can be selectively applied to any **Request Template** of your choice.
9+
10+
Prerequisites
11+
-------------
12+
13+
- A **Jira Cloud project** that you want to integrate with DejaCode.
14+
- A **Jira user account** with sufficient permissions
15+
(at least *Create Issues* and *Edit Issues*) in that project.
16+
17+
Jira API Token
18+
--------------
19+
20+
To enable integration, you need a Jira Cloud **API token** and the associated
21+
**user email**.
22+
23+
1. **Generate a Jira API Token**:
24+
25+
- Go to: https://id.atlassian.com/manage-profile/security/api-tokens
26+
- Click **"Create API token"**
27+
- Enter a descriptive label (e.g., ``DejaCode Integration``)
28+
- Click **Create** and then **Copy** the token
29+
30+
2. **Store Your Credentials Securely**:
31+
32+
- You will need both:
33+
34+
- Your **Jira user email** (the one used to log into Jira)
35+
- The **API token** you just generated
36+
37+
.. note::
38+
39+
The API token is required for authenticating to the Jira Cloud REST API.
40+
If your Jira instance is hosted on-prem (Jira Server/Data Center), the integration
41+
may not be supported without further customization.
42+
43+
DejaCode Dataspace Configuration
44+
--------------------------------
45+
46+
To use your Jira credentials in DejaCode:
47+
48+
1. Go to the **Administration dashboard**
49+
2. Navigate to **Dataspaces**, and select your Dataspace
50+
3. Scroll to the **Jira Integration** section under **Configuration**
51+
4. Enter:
52+
53+
- Your **Jira user email**
54+
- The **API token** you generated
55+
56+
5. Save the form
57+
58+
Activate Jira Integration on Request Templates
59+
----------------------------------------------
60+
61+
1. Go to the **Administration dashboard**
62+
2. Navigate to **Workflow** > **Request templates**
63+
3. Create or edit a Request Template in your Dataspace
64+
4. Set the **Issue Tracker ID** field to your Jira base URL with project key, e.g.::
65+
66+
https://YOUR-DOMAIN.atlassian.net/projects/PROJECTKEY
67+
https://YOUR-DOMAIN.atlassian.net/jira/software/projects/PROJECTKEY/summary
68+
69+
- This URL must point to your Jira Cloud instance
70+
71+
Once the integration is configured:
72+
73+
- New **Requests** using this template will be automatically pushed to Jira
74+
- Field updates (like title or priority) and **status changes** (e.g. closed) will be
75+
synced
76+
- New **Comments** on a DejaCode Request will be propagated to the Jira Issue

workflow/integrations/jira.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
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+
import base64
10+
import re
11+
from urllib.parse import urlparse
12+
13+
from workflow.integrations import BaseIntegration
14+
15+
JIRA_API_PATH = "/rest/api/3"
16+
17+
18+
class JiraIntegration(BaseIntegration):
19+
"""
20+
A class for managing Jira issue creation, updates, and comments
21+
from DejaCode requests.
22+
"""
23+
24+
def get_headers(self):
25+
jira_user = self.dataspace.get_configuration("jira_user")
26+
jira_token = self.dataspace.get_configuration("jira_token")
27+
if not jira_user or not jira_token:
28+
raise ValueError("The jira_user or jira_token is not set on the Dataspace.")
29+
30+
auth = f"{jira_user}:{jira_token}"
31+
encoded_auth = base64.b64encode(auth.encode()).decode()
32+
return {
33+
"Authorization": f"Basic {encoded_auth}",
34+
"Accept": "application/json",
35+
"Content-Type": "application/json",
36+
}
37+
38+
def sync(self, request):
39+
"""Sync the given request with Jira by creating or updating an issue."""
40+
try:
41+
base_url, project_key = self.extract_jira_info(
42+
request.request_template.issue_tracker_id
43+
)
44+
except ValueError as error:
45+
raise ValueError(f"Invalid Jira tracker URL: {error}")
46+
47+
self.api_url = base_url.rstrip("/") + JIRA_API_PATH
48+
49+
external_issue = request.external_issue
50+
if external_issue:
51+
self.update_issue(
52+
issue_id=external_issue.issue_id,
53+
title=self.make_issue_title(request),
54+
body=self.make_issue_body(request),
55+
status="Done" if request.is_closed else None,
56+
)
57+
else:
58+
issue = self.create_issue(
59+
project_key=project_key,
60+
title=self.make_issue_title(request),
61+
body=self.make_issue_body(request),
62+
)
63+
request.link_external_issue(
64+
platform="jira",
65+
repo=base_url,
66+
issue_id=issue["key"],
67+
)
68+
69+
def create_issue(self, project_key, title, body=""):
70+
"""Create a new Jira issue."""
71+
url = f"{self.api_url}/issue"
72+
data = {
73+
"fields": {
74+
"project": {"key": project_key},
75+
"summary": title,
76+
"description": markdown_to_adf(body),
77+
"issuetype": {"name": "Request"},
78+
}
79+
}
80+
81+
response = self.session.post(
82+
url,
83+
json=data,
84+
timeout=self.default_timeout,
85+
)
86+
response.raise_for_status()
87+
return response.json()
88+
89+
def update_issue(self, issue_id, title=None, body=None, status=None):
90+
"""Update an existing Jira issue."""
91+
url = f"{self.api_url}/issue/{issue_id}"
92+
fields = {}
93+
if title:
94+
fields["summary"] = title
95+
if body:
96+
fields["description"] = markdown_to_adf(body)
97+
98+
if fields:
99+
response = self.session.put(
100+
url,
101+
json={"fields": fields},
102+
timeout=self.default_timeout,
103+
)
104+
response.raise_for_status()
105+
106+
# Transition (e.g., close) if status is specified
107+
if status:
108+
self.transition_issue(issue_id, status)
109+
110+
return {"id": issue_id}
111+
112+
def post_comment(self, repo_id, issue_id, comment_body):
113+
"""Post a comment on an existing Jira issue."""
114+
api_url = repo_id.rstrip("/") + JIRA_API_PATH
115+
116+
url = f"{api_url}/issue/{issue_id}/comment"
117+
data = {"body": markdown_to_adf(comment_body)}
118+
119+
response = self.session.post(
120+
url,
121+
json=data,
122+
timeout=self.default_timeout,
123+
)
124+
response.raise_for_status()
125+
return response.json()
126+
127+
def transition_issue(self, issue_id, target_status_name):
128+
"""Transition a Jira issue to a new status by name."""
129+
transitions_url = f"{self.api_url}/issue/{issue_id}/transitions"
130+
response = self.session.get(transitions_url, timeout=self.default_timeout)
131+
response.raise_for_status()
132+
transitions = response.json().get("transitions", [])
133+
134+
for transition in transitions:
135+
if transition["to"]["name"].lower() == target_status_name.lower():
136+
transition_id = transition["id"]
137+
break
138+
else:
139+
raise ValueError(f"No transition found for status '{target_status_name}'")
140+
141+
response = self.session.post(
142+
transitions_url,
143+
json={"transition": {"id": transition_id}},
144+
timeout=self.default_timeout,
145+
)
146+
response.raise_for_status()
147+
148+
@staticmethod
149+
def extract_jira_info(url):
150+
"""
151+
Extract the base Jira URL and project key from a Jira Cloud URL.
152+
Supports:
153+
- https://<domain>.atlassian.net/projects/PROJECTKEY
154+
- https://<domain>.atlassian.net/browse/PROJECTKEY
155+
- https://<domain>.atlassian.net/jira/software/projects/PROJECTKEY/...
156+
"""
157+
parsed = urlparse(url)
158+
if not parsed.netloc.endswith("atlassian.net"):
159+
raise ValueError("Invalid Jira Cloud domain.")
160+
161+
base_url = f"{parsed.scheme}://{parsed.netloc}"
162+
path = parsed.path
163+
164+
project_key_pattern = r"/(?:projects|browse|jira/software/projects)/([A-Z][A-Z0-9]+)"
165+
match = re.search(project_key_pattern, path)
166+
if match:
167+
return base_url, match.group(1)
168+
169+
raise ValueError("Unable to extract Jira project key from URL.")
170+
171+
172+
def markdown_to_adf(markdown_text):
173+
"""
174+
Convert minimal Markdown to Atlassian Document Format (ADF).
175+
Converts:
176+
- '### ' headings into ADF heading blocks (level 3)
177+
- All other non-empty lines into paragraphs
178+
"""
179+
lines = markdown_text.splitlines()
180+
content = []
181+
182+
for line in lines:
183+
stripped = line.strip()
184+
if stripped.startswith("### "):
185+
content.append(
186+
{
187+
"type": "heading",
188+
"attrs": {"level": 3},
189+
"content": [{"type": "text", "text": stripped[4:].strip()}],
190+
}
191+
)
192+
elif stripped:
193+
content.append(
194+
{
195+
"type": "paragraph",
196+
"content": [{"type": "text", "text": stripped}],
197+
}
198+
)
199+
200+
return {
201+
"version": 1,
202+
"type": "doc",
203+
"content": content,
204+
}

0 commit comments

Comments
 (0)