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.title:
Jira mapper
; 2.标识符 :jira_mapper
; 3.Description :将 Jira 项目和问题映射到 Port
的 webhook 配置; 4.图标 :Jira
; - 集成配置选项卡 - 填写以下 JQ 映射:
::注意 注意并复制此选项卡中提供的 Webhook URL ::: 3.点击页面底部的保存。
[
{
"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)"
}
}
}
]
在 Jira 中创建 webhook
- 以具有管理全局权限的用户身份登录 Jira;
- 单击右上角的齿轮图标;
- 选择 系统;
- 在左侧边栏底部的高级下,选择Webhooks;
- 点击创建 Webhook
- 输入以下详细信息:
- 名称"- 被用于一个有意义的名称,如 Port Webhook;
- 状态"--请确保网络钩子已启用;
- Webhook URL
- 输入在 Port 中创建 Webhook 配置后收到的
url` 键的值; 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 客户端 IDPORT_CLIENT_SECRET
- 您的 Port 客户端secretJIRA_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 将根据映射解析问题,并相应地更新目录实体。