Skip to content

Commit

Permalink
Merge branch 'develop' into release/1.2
Browse files Browse the repository at this point in the history
  • Loading branch information
b3rnhard committed Aug 28, 2021
2 parents ce2cd6b + b1c602a commit d61ac9c
Show file tree
Hide file tree
Showing 8 changed files with 322 additions and 95 deletions.
29 changes: 25 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
# YouTrack plugin for Keypirinha

## Changelog

### Version 1.2

#### YouTrack API
This plugin has been using the YouTrack Legacy API since its first version. This API recently was disabled on current versions of YouTrack. The default API is now `/api/...` instead of `/rest/...`. A `legacy_api` switch is available to go back to the old api in case the affected server is on an older version and does *not* have the newer api yet. The default for `legacy_api` is `False`.

#### max_results support

It is now possible to restrict the amount of results per YouTrack server. If not supplied the `max_results` configuration of Keypirinha is used. An information is displayed how many results came back from the YouTrack API. If the result exceeded the amount set by `max_results`, this is also shown.

## Installation
* Copy YouTrack.keypirinha-package to
```
<Keypirinha root>\portable\Profile\Packages
```

* Recommended: use [PackageControl](https://ue.spdns.de/packagecontrol/) to install
* Alternative: Copy YouTrack.keypirinha-package to
```
<Keypirinha root>\portable\Profile\Packages
```

## Configuration
* Edit `youtrack.ini` by issuing `Keypirinha: Configure Package: youtrack`
* In youtrack.ini you can add as many YouTrack-servers as you like by adding sections:

Expand Down Expand Up @@ -36,6 +51,9 @@
# disables the automatic whitespace added after the prefix filter, defaults to False
#filter_dont_append_whitespace=False

# legacy api (available in versions before July 2021), defaults to False
#legacy_api = False

[server/my-server2]

# youtrack base url
Expand All @@ -62,6 +80,9 @@

# disables the automatic whitespace added after the prefix filter, defaults to False
#filter_dont_append_whitespace=False

# legacy api (available in versions before July 2021), defaults to False
#legacy_api = False
```

* You can add the same server more than once but use different `filter` values that are prefixed to all queries.
Expand Down
136 changes: 72 additions & 64 deletions lib/api.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
from typing import Sequence, Callable, Union
import urllib
from typing import Sequence, Union
from urllib import parse, request
from xml.dom import minidom
from xml.dom.minidom import Element
import json

from .util import get_as_xml, get_value, get_child_att_value

class IntellisenseResult(object):
def __init__(self, full_option: str, prefix: Union[str, None], suffix: Union[str,None], option: str, start: int, end: int, description: str):
class SuggestionResult(object):
def __init__(self, full_option: str, prefix: Union[str, None], suffix: Union[str, None], option: str, start: int,
end: int, description: str):
self.description = description
self.end = end
self.start = start
Expand All @@ -15,118 +15,126 @@ def __init__(self, full_option: str, prefix: Union[str, None], suffix: Union[str
self.prefix = prefix
self.full_option = full_option


class Issue(object):
def __init__(self, id: str, summary: str, description: str, url: str):
self.url = url
self.id = id
self.summary = summary
self.description = description

class Api():

class Api:
AUTH_HEADER: str = 'Authorization'
TOKEN_PREFIX: str = 'Bearer '
YOUTRACK_INTELLISENSE_ISSUE_API: str = '{base_url}/rest/issue/intellisense/?'
YOUTRACK_LIST_OF_ISSUES_API: str = '{base_url}/rest/issue?'
YOUTRACK_INTELLISENSE_ISSUE_API: str = '{base_url}/api/search/assist?'
YOUTRACK_LIST_OF_ISSUES_API: str = '{base_url}/api/issues?'
YOUTRACK_ISSUE: str = '{base_url}/issue/{id}'
YOUTRACK_ISSUES: str = '{base_url}/issues/?'

def __init__(self, api_token: str, youtrack_url: str, dbg):
def __init__(self, api_token: str, youtrack_url: str, dbg, max_results: int):
super().__init__()
self.dbg = dbg
self.api_token = api_token
self.youtrack_url = youtrack_url
self.max_results = max_results

def open_url(self, http_url) -> str:
req = request.Request(http_url)
req.add_header(self.AUTH_HEADER, self.TOKEN_PREFIX + self.api_token)

with request.urlopen(req) as resp:
content = resp.read()
return content

def print(self, **kwargs):
toPrint = str.join(",", [key + " = \"" + str(value) + "\"" for key, value in kwargs.items()])
self.dbg("[" + toPrint + "]")
to_print = str.join(",", [key + " = \"" + str(value) + "\"" for key, value in kwargs.items()])
self.dbg("[" + to_print + "]")

def create_issues_url(self, filter):
return self.YOUTRACK_ISSUES.format(base_url=self.youtrack_url) + parse.urlencode({'q': filter})

def create_issue_url(self, id):
return self.YOUTRACK_ISSUE.format(base_url=self.youtrack_url, id=id)

def get_intellisense_suggestions(self, actual_user_input: str) -> Sequence[IntellisenseResult]:
"""
There is no non-legacy yet (YouTrack 2019.2) but already announced that it will be discontinued
once everything has been published under the new api.
"""
requestUrl = self.YOUTRACK_INTELLISENSE_ISSUE_API.format(base_url=self.youtrack_url)
filter = parse.urlencode({'filter': actual_user_input})
requestUrl = requestUrl + filter
self.print(requesturl=requestUrl)
content: bytes = self.open_url(requestUrl)
api_result_suggestions = self.parse_intellisense_suggestions(content)
def get_filters_url(self):
return self.YOUTRACK_INTELLISENSE_ISSUE_API.format(base_url=self.youtrack_url) + parse.urlencode({
'fields': 'suggestions(completionEnd,completionStart,description,option,prefix,suffix)'
})

def get_suggestions(self, actual_user_input: str) -> Sequence[SuggestionResult]:
request_url = self.get_filters_url()
self.print(requesturl=request_url)
json_data = json.dumps({
'caret': len(actual_user_input),
'query': actual_user_input
})
post_data = json_data.encode('utf-8')
suggestions_request = urllib.request.Request(request_url, data=post_data)
suggestions_request.method = 'POST'
self.add_common_headers(suggestions_request)
response = self.read_response(suggestions_request)

api_result_suggestions = self.parse_suggestions_response(response)
return api_result_suggestions

def parse_intellisense_suggestions(self, response: bytes) -> Sequence[IntellisenseResult]:
dom = get_as_xml(response)
if (dom.documentElement.nodeName != 'IntelliSense'): return []
items = [itemOrRecentItem
for suggestOrRecent in dom.documentElement.childNodes
for itemOrRecentItem in suggestOrRecent.childNodes
if isinstance(itemOrRecentItem, Element) and itemOrRecentItem.nodeName in ['item', 'recentItem']]
def add_common_headers(self, the_request):
the_request.add_header(self.AUTH_HEADER, self.TOKEN_PREFIX + self.api_token)
the_request.add_header('Content-Type', 'application/json')

@staticmethod
def read_response(request_to_read):
with urllib.request.urlopen(request_to_read) as resp:
response = json.loads(resp.read().decode('utf-8'))
return response

@staticmethod
def parse_suggestions_response(response: dict) -> Sequence[SuggestionResult]:
items = response['suggestions']

result = []

for item in items:
prefix: str = get_value(item, 'prefix')
suffix: str = get_value(item, 'suffix')
option: str = get_value(item, 'option')
description: str = get_value(item, 'description')
start: int = int(get_child_att_value(item, 'completion', 'start'))
end: int = int(get_child_att_value(item, 'completion', 'end'))
prefix: str = item['prefix']
suffix: str = item['suffix']
option: str = item['option']
description: str = item['description']
start: int = int(item['completionStart'])
end: int = int(item['completionEnd'])
if option is None: continue
res = str.join('', (item for item in [prefix, option, suffix] if item is not None))
intelliRes = IntellisenseResult(
intelli_res = SuggestionResult(
full_option=res,
prefix=prefix,
suffix=suffix,
option=option,
start=start,
end=end,
description=description)
result.append(intelliRes)
result.append(intelli_res)
return result

def parse_list_of_issues_result(self, response: str) -> Sequence[Issue]:
dom = get_as_xml(response)
if (dom.documentElement.nodeName != 'issueCompacts'): return []
items = [issue for issue in dom.documentElement.childNodes
if isinstance(issue, minidom.Element) and issue.nodeName == 'issue']
issues: Sequence[Issue] = []
for item in items:
for item in response:
self.print(item=str(item))
id = item.getAttribute('id')
description = self.extract_field_value('description', "", item)
summary: str = self.extract_field_value('summary', "--no summary--", item)
issue = Issue(id=id, summary=summary, description=description, url=self.create_issue_url(id))
id_readable = item['idReadable']
description = item['description']
summary: str = item['summary'] if item['summary'] is not None else "--no summary--"
issue = Issue(id=id_readable, summary=summary, description=description,
url=self.create_issue_url(id_readable))
issues.append(issue)
self.print(id=id, summary=summary,url=issue.url)
self.print(id=id_readable, summary=summary, url=issue.url)
return issues


def extract_field_value(self, field_name: str, fallback: str, item) -> str:
return next((get_value(fieldNode, "value")
for fieldNode in item.childNodes
if isinstance(fieldNode, minidom.Element) and fieldNode.nodeName == 'field' and fieldNode.getAttribute(
'name') == field_name),
fallback)

def get_issues_matching_filter(self, actual_user_input: str) -> Sequence[Issue]:
requestUrl: str = self.YOUTRACK_LIST_OF_ISSUES_API.format(base_url=self.youtrack_url)
filter: str = parse.urlencode({'filter': actual_user_input})
requestUrl = requestUrl + filter
self.print(requesturl=requestUrl)
content: str = self.open_url(requestUrl)
request_url: str = self.YOUTRACK_LIST_OF_ISSUES_API.format(base_url=self.youtrack_url)
query_part: str = parse.urlencode({'query': actual_user_input, '$top': self.max_results + 1, 'fields': 'description,summary,idReadable'})
request_url = request_url + query_part
issues_request = urllib.request.Request(request_url)
self.add_common_headers(issues_request)
self.print(requesturl=request_url)
json_response = self.read_response(issues_request)
self.dbg("parsing issues result")
issues = self.parse_list_of_issues_result(content)
issues = self.parse_list_of_issues_result(json_response)
return issues

139 changes: 139 additions & 0 deletions lib/legacy_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
from typing import Sequence, Callable, Union
from urllib import parse, request
from xml.dom import minidom
from xml.dom.minidom import Element

from .util import get_as_xml, get_value, get_child_att_value


class IntellisenseResult(object):
def __init__(self, full_option: str, prefix: Union[str, None], suffix: Union[str, None], option: str, start: int,
end: int, description: str):
self.description = description
self.end = end
self.start = start
self.option = option
self.suffix = suffix
self.prefix = prefix
self.full_option = full_option


class Issue(object):
def __init__(self, id: str, summary: str, description: str, url: str):
self.url = url
self.id = id
self.summary = summary
self.description = description


class Api():
AUTH_HEADER: str = 'Authorization'
TOKEN_PREFIX: str = 'Bearer '
YOUTRACK_INTELLISENSE_ISSUE_API: str = '{base_url}/rest/issue/intellisense/?'
YOUTRACK_LIST_OF_ISSUES_API: str = '{base_url}/rest/issue?'
YOUTRACK_ISSUE: str = '{base_url}/issue/{id}'
YOUTRACK_ISSUES: str = '{base_url}/issues/?'

def __init__(self, api_token: str, youtrack_url: str, dbg, max_results):
super().__init__()
self.dbg = dbg
self.api_token = api_token
self.youtrack_url = youtrack_url
self.max_results = max_results

def open_url(self, http_url) -> str:
req = request.Request(http_url)
req.add_header(self.AUTH_HEADER, self.TOKEN_PREFIX + self.api_token)

with request.urlopen(req) as resp:
content = resp.read()
return content

def print(self, **kwargs):
to_print = str.join(",", [key + " = \"" + str(value) + "\"" for key, value in kwargs.items()])
self.dbg("legacy_api [" + to_print + "]")

def create_issues_url(self, filter):
return self.YOUTRACK_ISSUES.format(base_url=self.youtrack_url) + parse.urlencode({'q': filter})

def create_issue_url(self, id):
return self.YOUTRACK_ISSUE.format(base_url=self.youtrack_url, id=id)

def get_intellisense_suggestions(self, actual_user_input: str) -> Sequence[IntellisenseResult]:
"""
There is no non-legacy yet (YouTrack 2019.2) but already announced that it will be discontinued
once everything has been published under the new api.
"""
request_url = self.YOUTRACK_INTELLISENSE_ISSUE_API.format(base_url=self.youtrack_url)
filter_part = parse.urlencode({'filter': actual_user_input})
request_url = request_url + filter_part
self.print(requesturl=request_url)
content: bytes = self.open_url(request_url)
api_result_suggestions = self.parse_intellisense_suggestions(content)
return api_result_suggestions

@staticmethod
def parse_intellisense_suggestions(response: bytes) -> Sequence[IntellisenseResult]:
dom = get_as_xml(response)
if (dom.documentElement.nodeName != 'IntelliSense'): return []
items = [itemOrRecentItem
for suggestOrRecent in dom.documentElement.childNodes
for itemOrRecentItem in suggestOrRecent.childNodes
if isinstance(itemOrRecentItem, Element) and itemOrRecentItem.nodeName in ['item', 'recentItem']]
result = []

for item in items:
prefix: str = get_value(item, 'prefix')
suffix: str = get_value(item, 'suffix')
option: str = get_value(item, 'option')
description: str = get_value(item, 'description')
start: int = int(get_child_att_value(item, 'completion', 'start'))
end: int = int(get_child_att_value(item, 'completion', 'end'))
if option is None: continue
res = str.join('', (item for item in [prefix, option, suffix] if item is not None))
intelliRes = IntellisenseResult(
full_option=res,
prefix=prefix,
suffix=suffix,
option=option,
start=start,
end=end,
description=description)
result.append(intelliRes)
return result

def parse_list_of_issues_result(self, response: bytes) -> Sequence[Issue]:
dom = get_as_xml(response)
if dom.documentElement.nodeName != 'issueCompacts': return []
items = [issue for issue in dom.documentElement.childNodes
if isinstance(issue, minidom.Element) and issue.nodeName == 'issue']
issues: Sequence[Issue] = []
for item in items:
self.print(item=str(item))
id = item.getAttribute('id')
description = self.extract_field_value('description', "", item)
summary: str = self.extract_field_value('summary', "--no summary--", item)
issue = Issue(id=id, summary=summary, description=description, url=self.create_issue_url(id))
issues.append(issue)
self.print(id=id, summary=summary, url=issue.url)
return issues

@staticmethod
def extract_field_value(field_name: str, fallback: str, item) -> str:
return next((get_value(fieldNode, "value")
for fieldNode in item.childNodes
if isinstance(fieldNode,
minidom.Element) and fieldNode.nodeName == 'field' and fieldNode.getAttribute(
'name') == field_name),
fallback)

def get_issues_matching_filter(self, actual_user_input: str) -> Sequence[Issue]:
request_url: str = self.YOUTRACK_LIST_OF_ISSUES_API.format(base_url=self.youtrack_url)
filter_part: str = parse.urlencode({'filter': actual_user_input})
request_url = request_url + filter_part
self.print(requesturl=request_url)
content: str = self.open_url(request_url)
self.dbg("parsing issues result")
issues = self.parse_list_of_issues_result(content)
return issues

0 comments on commit d61ac9c

Please sign in to comment.