Jira (Self-Hosted)
In this example you are going to create a webhook integration between your Jira Server and Port. The integration will facilitate the ingestion of Jira project and issue entities into Port.
Port configuration
Create the following blueprint definitions:
Jira project blueprint
{
"identifier": "jiraProject",
"title": "Jira Project",
"icon": "Jira",
"description": "A Jira project",
"schema": {
"properties": {
"url": {
"title": "Project URL",
"type": "string",
"format": "url",
"description": "URL to the project in Jira"
},
"projectType": {
"title": "Type",
"type": "string",
"description": "The type of the project"
}
}
},
"mirrorProperties": {},
"calculationProperties": {},
"relations": {}
}
Jira issue blueprint
{
"identifier": "jiraIssue",
"title": "Jira Issue",
"icon": "Jira",
"description": "A Jira issue blueprint",
"schema": {
"properties": {
"url": {
"title": "Issue URL",
"type": "string",
"format": "url",
"description": "URL to the issue in Jira"
},
"status": {
"title": "Status",
"type": "string",
"description": "The status of the issue"
},
"issueType": {
"title": "Type",
"type": "string",
"description": "The type of the issue",
"enum": ["Story", "Bug", "Task", "New Feature", "Epic", "Improvement"],
"enumColors": {
"Story": "green",
"Bug": "red",
"Task": "blue",
"New Feature": "turquoise",
"Epic": "purple",
"Improvement": "yellow"
}
},
"components": {
"title": "Components",
"type": "array",
"description": "The components related to this issue"
},
"assignee": {
"title": "Assignee",
"type": "string",
"format": "user",
"description": "The user assigned to the issue"
},
"reporter": {
"title": "Reporter",
"type": "string",
"description": "The user that reported to the issue",
"format": "user"
},
"priority": {
"title": "Priority",
"type": "string",
"description": "The priority of the issue",
"format": "user"
},
"creator": {
"title": "Creator",
"type": "string",
"description": "The user that created to the issue",
"format": "user"
}
}
},
"mirrorProperties": {},
"calculationProperties": {},
"relations": {
"project": {
"target": "jiraProject",
"title": "Project",
"description": "The Jira project that contains this issue",
"required": false,
"many": false
},
"parentIssue": {
"target": "jiraIssue",
"title": "Parent Issue",
"required": false,
"many": false
},
"subtasks": {
"target": "jiraIssue",
"title": "Subtasks",
"required": false,
"many": true
}
}
}
You may modify the properties in your blueprints depending on what you want to track in your Jira projects and issues.
Create the following webhook configuration using Port's UI
Jira webhook configuration
-
Basic details tab - fill the following details:
- Title :
Jira mapper
; - Identifier :
jira_mapper
; - Description :
A webhook configuration to map Jira projects and issues to Port
; - Icon :
Jira
;
- Title :
-
Integration configuration tab - fill the following JQ mapping:
[
{
"blueprint": "jiraProject",
"filter": ".body.webhookEvent | IN(\"project_created\", \"project_updated\")",
"entity": {
"identifier": ".body.project.key",
"title": ".body.project.name",
"properties": {
"url": ".body.project.self"
}
}
},
{
"blueprint": "jiraIssue",
"filter": ".body.webhookEvent | IN(\"jira:issue_updated\", \"jira:issue_created\")",
"entity": {
"identifier": ".body.issue.key",
"title": ".body.issue.fields.summary",
"properties": {
"url": ".body.issue.self",
"status": ".body.issue.fields.status.name",
"assignee": ".body.issue.fields.assignee.name",
"issueType": ".body.issue.fields.issuetype.name",
"reporter": ".body.issue.fields.reporter.name",
"priority": ".body.issue.fields.priority.name",
"creator": ".body.issue.fields.creator.name"
},
"relations": {
"project": ".body.issue.fields.project.key",
"parentIssue": ".body.issue.fields.parent.key",
"subtasks": ".body.issue.fields.subtasks | map(.key)"
}
}
}
]noteTake note of, and copy the Webhook URL that is provided in this tab
-
Click Save at the bottom of the page.
Create a webhook in Jira
- Log in to Jira as a user with the Administer global permission;
- Click the gear icon at the top right corner;
- Choose System;
- At the bottom of the sidebar on the left, under Advanced, choose WebHooks;
- Click on Create a WebHook
- Input the following details:
Name
- use a meaningful name such as Port Webhook;Status
- be sure to keep the webhook Enabled;Webhook URL
- enter the value of theurl
key you received after creating the webhook configuration in Port;Description
- enter a description for the webhook;Issue related events
- enter a JQL query in this section to filter the issues that get sent to the webhook (if you leave this field empty, all issues will trigger a webhook event);- Under
Issue
- mark created, updated and delete; - Under the
Project related events
section, go toProjects
and mark created, updated and deleted;
- Click Create at the bottom of the page.
In order to view the different payloads and events available in Jira webhooks, look here
Done! any change you make to a project or an issue (open, close, edit, etc.) will trigger a webhook event that Jira will send to the webhook URL provided by Port. Port will parse the events according to the mapping and update the catalog entities accordingly.
Let's Test It
This section includes a sample webhook event sent from Jira when an issue is created or updated. In addition, it includes the entity created from the event based on the webhook configuration provided in the previous section.
Payload
Here is an example of the payload structure sent to the webhook URL when a Jira issue is created:
Webhook event payload
{
"timestamp": 1702992455854,
"webhookEvent": "jira:issue_updated",
"issue_event_type_name": "issue_updated",
"user": {
"self": "https://jira.yourdomain.com/rest/api/2/user?username=youruser",
"name": "youruser",
"key": "JIRAUSER10000",
"emailAddress": "youruser@email.com",
"avatarUrls": {
"48x48": "https://www.gravatar.com/avatar/73b83eb1f16580bfe2bfccf81fcb1870?d=mm&s=48",
"24x24": "https://www.gravatar.com/avatar/73b83eb1f16580bfe2bfccf81fcb1870?d=mm&s=24",
"16x16": "https://www.gravatar.com/avatar/73b83eb1f16580bfe2bfccf81fcb1870?d=mm&s=16",
"32x32": "https://www.gravatar.com/avatar/73b83eb1f16580bfe2bfccf81fcb1870?d=mm&s=32"
},
"displayName": "My User",
"active": true,
"timeZone": "Etc/UTC"
},
"issue": {
"id": "10303",
"self": "https://jira.yourdomain.com/rest/api/2/issue/10303",
"key": "BSD-6",
"fields": {
"issuetype": {
"self": "https://jira.yourdomain.com/rest/api/2/issuetype/10002",
"id": "10002",
"description": "Created by Jira Software - do not edit or delete. Issue type for a user story.",
"iconUrl": "https://jira.yourdomain.com/images/icons/issuetypes/story.svg",
"name": "Story",
"subtask": false
},
"timespent": null,
"project": {
"self": "https://jira.yourdomain.com/rest/api/2/project/10001",
"id": "10001",
"key": "BSD",
"name": "Basic Soft Dev",
"projectTypeKey": "software",
"avatarUrls": {
"48x48": "https://jira.yourdomain.com/secure/projectavatar?avatarId=10324",
"24x24": "https://jira.yourdomain.com/secure/projectavatar?size=small&avatarId=10324",
"16x16": "https://jira.yourdomain.com/secure/projectavatar?size=xsmall&avatarId=10324",
"32x32": "https://jira.yourdomain.com/secure/projectavatar?size=medium&avatarId=10324"
}
},
"fixVersions": [],
"customfield_10110": null,
"customfield_10111": null,
"aggregatetimespent": null,
"resolution": null,
"customfield_10106": null,
"customfield_10107": null,
"customfield_10108": null,
"customfield_10109": null,
"resolutiondate": null,
"workratio": -1,
"lastViewed": "2023-12-19T13:27:14.538+0000",
"watches": {
"self": "https://jira.yourdomain.com/rest/api/2/issue/BSD-6/watchers",
"watchCount": 1,
"isWatching": true
},
"created": "2023-12-19T12:14:34.524+0000",
"priority": {
"self": "https://jira.yourdomain.com/rest/api/2/priority/3",
"iconUrl": "https://jira.yourdomain.com/images/icons/priorities/medium.svg",
"name": "Medium",
"id": "3"
},
"customfield_10100": "0|i001av:",
"customfield_10101": null,
"customfield_10102": null,
"labels": [],
"timeestimate": null,
"aggregatetimeoriginalestimate": null,
"versions": [],
"issuelinks": [],
"assignee": {
"self": "https://jira.yourdomain.com/rest/api/2/user?username=janedoe",
"name": "janedoe",
"key": "JIRAUSER10001",
"emailAddress": "noreplay@example.com",
"avatarUrls": {
"48x48": "https://www.gravatar.com/avatar/c567e3a76e53c7bd7d2dda08af2122e3?d=mm&s=48",
"24x24": "https://www.gravatar.com/avatar/c567e3a76e53c7bd7d2dda08af2122e3?d=mm&s=24",
"16x16": "https://www.gravatar.com/avatar/c567e3a76e53c7bd7d2dda08af2122e3?d=mm&s=16",
"32x32": "https://www.gravatar.com/avatar/c567e3a76e53c7bd7d2dda08af2122e3?d=mm&s=32"
},
"displayName": "Jane Doe",
"active": true,
"timeZone": "Etc/UTC"
},
"updated": "2023-12-19T13:27:35.853+0000",
"status": {
"self": "https://jira.yourdomain.com/rest/api/2/status/10003",
"description": "",
"iconUrl": "https://jira.yourdomain.com/",
"name": "To Do",
"id": "10003",
"statusCategory": {
"self": "https://jira.yourdomain.com/rest/api/2/statuscategory/2",
"id": 2,
"key": "new",
"colorName": "default",
"name": "To Do"
}
},
"components": [],
"timeoriginalestimate": null,
"description": "Be able to login on the app",
"timetracking": {},
"archiveddate": null,
"attachment": [],
"aggregatetimeestimate": null,
"summary": "As a user, I want to login",
"creator": {
"self": "https://jira.yourdomain.com/rest/api/2/user?username=youruser",
"name": "youruser",
"key": "JIRAUSER10000",
"emailAddress": "youruser@email.com",
"avatarUrls": {
"48x48": "https://www.gravatar.com/avatar/73b83eb1f16580bfe2bfccf81fcb1870?d=mm&s=48",
"24x24": "https://www.gravatar.com/avatar/73b83eb1f16580bfe2bfccf81fcb1870?d=mm&s=24",
"16x16": "https://www.gravatar.com/avatar/73b83eb1f16580bfe2bfccf81fcb1870?d=mm&s=16",
"32x32": "https://www.gravatar.com/avatar/73b83eb1f16580bfe2bfccf81fcb1870?d=mm&s=32"
},
"displayName": "My User",
"active": true,
"timeZone": "Etc/UTC"
},
"subtasks": [],
"reporter": {
"self": "https://jira.yourdomain.com/rest/api/2/user?username=johndoe",
"name": "johndoe",
"key": "JIRAUSER10002",
"emailAddress": "noreplay@example.com",
"avatarUrls": {
"48x48": "https://www.gravatar.com/avatar/c567e3a76e53c7bd7d2dda08af2122e3?d=mm&s=48",
"24x24": "https://www.gravatar.com/avatar/c567e3a76e53c7bd7d2dda08af2122e3?d=mm&s=24",
"16x16": "https://www.gravatar.com/avatar/c567e3a76e53c7bd7d2dda08af2122e3?d=mm&s=16",
"32x32": "https://www.gravatar.com/avatar/c567e3a76e53c7bd7d2dda08af2122e3?d=mm&s=32"
},
"displayName": "John Doe",
"active": true,
"timeZone": "Etc/UTC"
},
"customfield_10000": "{summaryBean=com.atlassian.jira.plugin.devstatus.rest.SummaryBean@5bedd466[summary={pullrequest=com.atlassian.jira.plugin.devstatus.rest.SummaryItemBean@49d7365c[overall=PullRequestOverallBean{stateCount=0, state='OPEN', details=PullRequestOverallDetails{openCount=0, mergedCount=0, declinedCount=0}},byInstanceType={}], build=com.atlassian.jira.plugin.devstatus.rest.SummaryItemBean@fb742a[overall=com.atlassian.jira.plugin.devstatus.summary.beans.BuildOverallBean@706ec7bf[failedBuildCount=0,successfulBuildCount=0,unknownBuildCount=0,count=0,lastUpdated=<null>,lastUpdatedTimestamp=<null>],byInstanceType={}], review=com.atlassian.jira.plugin.devstatus.rest.SummaryItemBean@1bf5dc7[overall=com.atlassian.jira.plugin.devstatus.summary.beans.ReviewsOverallBean@324c9570[stateCount=0,state=<null>,dueDate=<null>,overDue=false,count=0,lastUpdated=<null>,lastUpdatedTimestamp=<null>],byInstanceType={}], deployment-environment=com.atlassian.jira.plugin.devstatus.rest.SummaryItemBean@69cdfd37[overall=com.atlassian.jira.plugin.devstatus.summary.beans.DeploymentOverallBean@6f189c8e[topEnvironments=[],showProjects=false,successfulCount=0,count=0,lastUpdated=<null>,lastUpdatedTimestamp=<null>],byInstanceType={}], repository=com.atlassian.jira.plugin.devstatus.rest.SummaryItemBean@14b2b5cf[overall=com.atlassian.jira.plugin.devstatus.summary.beans.CommitOverallBean@4283453c[count=0,lastUpdated=<null>,lastUpdatedTimestamp=<null>],byInstanceType={}], branch=com.atlassian.jira.plugin.devstatus.rest.SummaryItemBean@44a13c1e[overall=com.atlassian.jira.plugin.devstatus.summary.beans.BranchOverallBean@6f7634e8[count=0,lastUpdated=<null>,lastUpdatedTimestamp=<null>],byInstanceType={}]},errors=[],configErrors=[]], devSummaryJson={\"cachedValue\":{\"errors\":[],\"configErrors\":[],\"summary\":{\"pullrequest\":{\"overall\":{\"count\":0,\"lastUpdated\":null,\"stateCount\":0,\"state\":\"OPEN\",\"details\":{\"openCount\":0,\"mergedCount\":0,\"declinedCount\":0,\"total\":0},\"open\":true},\"byInstanceType\":{}},\"build\":{\"overall\":{\"count\":0,\"lastUpdated\":null,\"failedBuildCount\":0,\"successfulBuildCount\":0,\"unknownBuildCount\":0},\"byInstanceType\":{}},\"review\":{\"overall\":{\"count\":0,\"lastUpdated\":null,\"stateCount\":0,\"state\":null,\"dueDate\":null,\"overDue\":false,\"completed\":false},\"byInstanceType\":{}},\"deployment-environment\":{\"overall\":{\"count\":0,\"lastUpdated\":null,\"topEnvironments\":[],\"showProjects\":false,\"successfulCount\":0},\"byInstanceType\":{}},\"repository\":{\"overall\":{\"count\":0,\"lastUpdated\":null},\"byInstanceType\":{}},\"branch\":{\"overall\":{\"count\":0,\"lastUpdated\":null},\"byInstanceType\":{}}}},\"isStale\":false}}",
"aggregateprogress": {
"progress": 0,
"total": 0
},
"environment": null,
"duedate": null,
"progress": {
"progress": 0,
"total": 0
},
"comment": {
"comments": [],
"maxResults": 0,
"total": 0,
"startAt": 0
},
"votes": {
"self": "https://jira.yourdomain.com/rest/api/2/issue/BSD-6/votes",
"votes": 0,
"hasVoted": false
},
"worklog": {
"startAt": 0,
"maxResults": 20,
"total": 0,
"worklogs": []
},
"archivedby": null
}
},
"changelog": {
"id": "10407",
"items": [
{
"field": "description",
"fieldtype": "jira",
"from": null,
"fromString": null,
"to": null,
"toString": "Be able to login on the app"
}
]
}
}
Mapping Result
{
"identifier": "BSD-6",
"title": "As a user, I want to login",
"blueprint": "jiraIssue",
"properties": {
"url": "https://jira.yourdomain.com/rest/api/2/issue/10303",
"status": "To Do",
"assignee": "janedoe",
"issueType": "Story",
"reporter": "johndoe",
"priority": "Medium",
"creator": "youruser"
},
"relations": {
"project": "BSD",
"parentIssue": null,
"subtasks": []
},
"filter": true
}
Import Jira Historical Issues
In this example you are going to use the provided Python script to fetch data from the Jira Server API and ingest it to Port.
Prerequisites
This example utilizes the same blueprint and webhook definition from the previous section.
In addition, you require the following environment variables:
PORT_CLIENT_ID
- Your Port client idPORT_CLIENT_SECRET
- Your Port client secretJIRA_API_URL
- Your Jira server host such ashttps://jira.yourdomain.com
JIRA_USERNAME
- Your Jira username to use when accessing the Jira Software (Server) resourcesJIRA_PASSWORD
- Your Jira account password or token to use when accessing the Jira resources
Find your Port credentials using this guide
Use the following Python script to ingest historical Jira issues into port:
Jira Python script for historical issues
# Dependencies to Install
# pip install loguru
# pip install requests
# pip install decouple
# pip install httpx
import asyncio
from typing import Any, AsyncGenerator
import httpx
import requests
from requests.auth import HTTPBasicAuth
from loguru import logger
from decouple import config
PAGE_SIZE = 50
# Get environment variables using the config object or os.environ["KEY"]
# These are the credentials passed by the variables of your pipeline to your tasks and in to your env
PORT_API_URL = "https://api.getport.io/v1"
PORT_CLIENT_ID = config("PORT_CLIENT_ID")
PORT_CLIENT_SECRET = config("PORT_CLIENT_SECRET")
JIRA_USERNAME = config("JIRA_USERNAME")
JIRA_PASSWORD = config("JIRA_PASSWORD")
JIRA_API_URL = config("JIRA_API_URL")
api_url = f"{JIRA_API_URL}/rest/api/2"
## Get Port Access Token
credentials = {"clientId": PORT_CLIENT_ID, "clientSecret": PORT_CLIENT_SECRET}
token_response = requests.post(f"{PORT_API_URL}/auth/access_token", json=credentials)
access_token = token_response.json()["accessToken"]
# You can now use the value in access_token when making further requests
port_headers = {"Authorization": f"Bearer {access_token}"}
# https://developer.atlassian.com/cloud/jira/platform/basic-auth-for-rest-apis/
jira_auth = HTTPBasicAuth(username=JIRA_USERNAME, password=JIRA_PASSWORD)
# Add a resource to Port
async def add_resource_to_port(blueprint: str, resource: dict[str, Any]) -> None:
logger.info(f"Adding {blueprint} resource to Port: {resource}")
if blueprint == "jiraProject":
entity = {
"identifier": resource["key"],
"title": resource["name"],
"properties": {
"url": jq_filter(resource),
"projectType": resource["projectTypeKey"],
},
"relations": {},
}
elif blueprint == "jiraIssue":
entity = {
"identifier": resource["key"],
"title": resource["fields"]["summary"],
"properties": {
"url": resource["self"],
"status": resource["fields"]["status"]["name"],
"issueType": resource["fields"]["issuetype"]["name"],
"assignee": get_field_value(resource, "assignee", "name"),
"reporter": resource["fields"].get("reporter", {}).get("name"),
"priority": resource["fields"].get("priority", {}).get("name"),
"creator": resource["fields"].get("creator", {}).get("name"),
},
"relations": {
"project": resource["fields"]["project"]["key"],
"parentIssue": get_field_value(resource, "parent", "key"),
"subtasks": [
issue.get("key")
for issue in resource["fields"]["subtasks"]
if issue
],
},
}
else:
raise ValueError(f"Blueprint {blueprint} is not supported")
response = requests.post(
f"{PORT_API_URL}/blueprints/{blueprint}/entities?upsert=true&merge=true",
json=entity,
headers=port_headers,
)
logger.info(response.json())
def get_field_value(resource, field_name, subfield_name=None):
"""
Get the value of a field from a resource dictionary.
Parameters:
- resource (dict): The dictionary representing the resource.
- field_name (str): The name of the field to retrieve.
- subfield_name (str): Optional. If the field has a subfield, provide its name.
Returns:
- The value of the specified field or subfield, or None if not found.
"""
field = resource["fields"].get(field_name)
if subfield_name:
return field.get(subfield_name) if field else None
else:
return field
def jq_filter(data):
if isinstance(data, dict):
# Split the data string by '/'
split_data = data["self"].split("/")
# Extract the first three elements of the split data list
filtered_data = split_data[:3]
# Join the filtered data list back into a string using '/' delimiter
joined_data = "/".join(filtered_data)
# Add the '/projects/' prefix and the data's key as suffix to the joined data
output = joined_data + "/projects/" + data["key"]
else:
# Return an empty string if the data is not a dictionary
output = ""
return output
class JiraClient:
def __init__(self, jira_auth: HTTPBasicAuth) -> None:
self.client = httpx.AsyncClient()
self.client.auth = jira_auth
async def _get_paginated_projects(self, params: dict[str, Any]) -> dict[str, Any]:
# https://community.atlassian.com/t5/Jira-Core-Server-questions/Jira-API-Get-projects-paginated/qaq-p/925683
# this GET /rest/api/2/project/search api is available for the jira cloud, not the server.
project_response = await self.client.get(f"{api_url}/project", params=params)
project_response.raise_for_status()
return project_response.json()
async def _get_paginated_issues(self, params: dict[str, Any]) -> dict[str, Any]:
issue_response = await self.client.get(f"{api_url}/search", params=params)
issue_response.raise_for_status()
return issue_response.json()
async def get_projects(
self,
) -> AsyncGenerator[list[dict[str, Any]], None]:
logger.info("Getting projects from Jira")
project_response = await self.client.get(f"{api_url}/project")
project_response.raise_for_status()
yield project_response.json()
async def get_paginated_issues(self) -> AsyncGenerator[list[dict[str, Any]], None]:
logger.info("Getting issues from Jira")
params: dict[str, Any] = {
"maxResults": 0,
"startAt": 0,
}
params["jql"] = "status != Done"
total_issues = (await self._get_paginated_issues(params))["total"]
params["maxResults"] = PAGE_SIZE
while params["startAt"] <= total_issues:
logger.info(f"Current query position: {params['startAt']}/{total_issues}")
issue_response_list = (await self._get_paginated_issues(params))["issues"]
yield issue_response_list
params["startAt"] += PAGE_SIZE
if __name__ == "__main__":
logger.debug("Starting the app")
jira_client = JiraClient(jira_auth)
async def main():
async for projects in jira_client.get_projects():
for project in projects:
await add_resource_to_port("jiraProject", project)
async for issues in jira_client.get_paginated_issues():
for issue in issues:
await add_resource_to_port("jiraIssue", issue)
asyncio.run(main())
logger.debug("Finished the app")
Done! you can now import historical issues from Jira into Port. Port will parse the issues according to the mapping and update the catalog entities accordingly.