Skip to main content

Jira(自行托管)

在本示例中,您将在 Jira 服务器和 Port 之间创建一个 webhook 集成,该集成将有助于将 Jira 项目和问题实体导入 Port。

Port 配置​

创建以下蓝图定义:

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
}
}
}
蓝图属性 您可以根据希望在 Jira 项目和问题中跟踪的内容修改蓝图中的属性。

创建以下 webhook 配置using Port's UI

Jira webhook configuration
  1. 基本信息 选项卡 - 填写以下详细信息: 1.title: Jira mapper; 2.标识符 : jira_mapper; 3.Description : 将 Jira 项目和问题映射到 Port 的 webhook 配置; 4.图标 : Jira;
  2. 集成配置选项卡 - 填写以下 JQ 映射:
    [
    {
    "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)"
    }
    }
    }
    ]
    ::注意 注意并复制此选项卡中提供的 Webhook URL ::: 3.点击页面底部的保存。

在 Jira 中创建 webhook​

  1. 以具有管理全局权限的用户身份登录 Jira;
  2. 单击右上角的齿轮图标;
  3. 选择 系统;
  4. 在左侧边栏底部的高级下,选择Webhooks;
  5. 点击创建 Webhook
  6. 输入以下详细信息:
    1. 名称"- 被用于一个有意义的名称,如 Port Webhook;
    2. 状态"--请确保网络钩子已启用;
    3. Webhook URL- 输入在 Port 中创建 Webhook 配置后收到的url` 键的值;
    4. Description - 输入 webhook 的描述; 5.问题相关事件"- 在此部分输入 JQL 查询,以筛选发送到 webhook 的问题(如果此字段为空,则所有问题都将触发 webhook 事件); 6.问题 "下 - 标记创建、更新和删除; 7.在 "项目相关事件 "部分下,转到 "项目 "并标记创建、更新和删除; 7.单击页面底部的创建。
为了查看 Jira webhooks 中可用的不同有效载荷和事件、look here

完成!您对项目或问题所做的任何更改(打开、关闭、编辑等)都会触发 webhook 事件,Jira 会将该事件发送到 Port 提供的 webhook URL。 Port 会根据映射解析事件,并相应地更新目录实体。

让我们来测试一下​

本节包括创建或更新问题时从 Jira 发送的 webhook 事件示例。 此外,还包括根据上一节提供的 webhook 配置从事件中创建的实体。

有效载荷​

下面是创建 Jira 问题时发送到 webhook URL 的有效载荷结构示例:

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": "[email protected]",
"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": "[email protected]",
"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": "[email protected]",
"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": "[email protected]",
"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"
}
]
}
}

映射结果​

{
"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
}

导入 Jira 历史问题​

在本例中,您将使用 Provider 提供的 Python 脚本从 Jira Server API 获取数据并将其引用到 Port。

先决条件​

本示例使用的是上一节中的blueprint and webhook 定义。

此外,您还需要以下环境变量:

  • PORT_CLIENT_ID - 您的 Port 客户端 ID
  • PORT_CLIENT_SECRET - 您的 Port 客户端secret
  • JIRA_API_URL - 您的 Jira 服务器主机,例如https://jira.yourdomain.com。
  • JIRA_USERNAME - 访问 Jira 软件(服务器)资源时被用于的 Jira 用户名
  • JIRA_PASSWORD - 访问 Jira 资源时要使用的 Jira 帐户密码或令牌
使用以下命令查找您的 Port 凭据guide

使用以下 Python 脚本将历史 Jira 问题引用到 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")

完成!现在您可以将历史问题从 Jira 导入 Port。 Port 将根据映射解析问题,并相应地更新目录实体。