mirror of
https://github.com/ansible-collections/community.general.git
synced 2024-09-14 20:13:21 +02:00
* Add Jira attach operation
Adds the `attach` operation to the `web_infrastructure.jira` module,
which allows a user to attach a file to an issue. The user can supply
either the path to a file, which will be read from storage, or a file
name and content (as bytes).
* Apply suggestions from code review
* Update plugins/modules/web_infrastructure/jira.py
Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com>
Co-authored-by: Felix Fontein <felix@fontein.de>
Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com>
(cherry picked from commit 98af8161b2
)
Co-authored-by: Brandon McNama <brandonmcnama@outlook.com>
This commit is contained in:
parent
73e2c2eb85
commit
694584f907
2 changed files with 147 additions and 9 deletions
2
changelogs/fragments/2192-add-jira-attach.yml
Normal file
2
changelogs/fragments/2192-add-jira-attach.yml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
minor_changes:
|
||||||
|
- jira - added ``attach`` operation, which allows a user to attach a file to an issue (https://github.com/ansible-collections/community.general/pull/2192).
|
|
@ -5,6 +5,7 @@
|
||||||
# Atlassian open-source approval reference OSR-76.
|
# Atlassian open-source approval reference OSR-76.
|
||||||
#
|
#
|
||||||
# (c) 2020, Per Abildgaard Toft <per@minfejl.dk> Search and update function
|
# (c) 2020, Per Abildgaard Toft <per@minfejl.dk> Search and update function
|
||||||
|
# (c) 2021, Brandon McNama <brandonmcnama@outlook.com> Issue attachment functionality
|
||||||
#
|
#
|
||||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
|
|
||||||
|
@ -29,7 +30,7 @@ options:
|
||||||
type: str
|
type: str
|
||||||
required: true
|
required: true
|
||||||
aliases: [ command ]
|
aliases: [ command ]
|
||||||
choices: [ comment, create, edit, fetch, link, search, transition, update ]
|
choices: [ attach, comment, create, edit, fetch, link, search, transition, update ]
|
||||||
description:
|
description:
|
||||||
- The operation to perform.
|
- The operation to perform.
|
||||||
|
|
||||||
|
@ -162,6 +163,29 @@ options:
|
||||||
default: true
|
default: true
|
||||||
type: bool
|
type: bool
|
||||||
|
|
||||||
|
attachment:
|
||||||
|
type: dict
|
||||||
|
version_added: 2.5.0
|
||||||
|
description:
|
||||||
|
- Information about the attachment being uploaded.
|
||||||
|
suboptions:
|
||||||
|
filename:
|
||||||
|
required: true
|
||||||
|
type: path
|
||||||
|
description:
|
||||||
|
- The path to the file to upload (from the remote node) or, if I(content) is specified,
|
||||||
|
the filename to use for the attachment.
|
||||||
|
content:
|
||||||
|
type: str
|
||||||
|
description:
|
||||||
|
- The Base64 encoded contents of the file to attach. If not specified, the contents of I(filename) will be
|
||||||
|
used instead.
|
||||||
|
mimetype:
|
||||||
|
type: str
|
||||||
|
description:
|
||||||
|
- The MIME type to supply for the upload. If not specified, best-effort detection will be
|
||||||
|
done.
|
||||||
|
|
||||||
notes:
|
notes:
|
||||||
- "Currently this only works with basic-auth."
|
- "Currently this only works with basic-auth."
|
||||||
- "To use with JIRA Cloud, pass the login e-mail as the I(username) and the API token as I(password)."
|
- "To use with JIRA Cloud, pass the login e-mail as the I(username) and the API token as I(password)."
|
||||||
|
@ -169,6 +193,7 @@ notes:
|
||||||
author:
|
author:
|
||||||
- "Steve Smith (@tarka)"
|
- "Steve Smith (@tarka)"
|
||||||
- "Per Abildgaard Toft (@pertoft)"
|
- "Per Abildgaard Toft (@pertoft)"
|
||||||
|
- "Brandon McNama (@DWSR)"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
EXAMPLES = r"""
|
EXAMPLES = r"""
|
||||||
|
@ -310,10 +335,26 @@ EXAMPLES = r"""
|
||||||
resolution:
|
resolution:
|
||||||
name: Done
|
name: Done
|
||||||
description: I am done! This is the last description I will ever give you.
|
description: I am done! This is the last description I will ever give you.
|
||||||
|
|
||||||
|
# Attach a file to an issue
|
||||||
|
- name: Attach a file
|
||||||
|
community.general.jira:
|
||||||
|
uri: '{{ server }}'
|
||||||
|
username: '{{ user }}'
|
||||||
|
password: '{{ pass }}'
|
||||||
|
issue: HSP-1
|
||||||
|
operation: attach
|
||||||
|
attachment:
|
||||||
|
filename: topsecretreport.xlsx
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import binascii
|
||||||
import json
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import string
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
@ -325,8 +366,17 @@ from ansible.module_utils.basic import AnsibleModule
|
||||||
from ansible.module_utils.urls import fetch_url
|
from ansible.module_utils.urls import fetch_url
|
||||||
|
|
||||||
|
|
||||||
def request(url, user, passwd, timeout, data=None, method=None):
|
def request(
|
||||||
if data:
|
url,
|
||||||
|
user,
|
||||||
|
passwd,
|
||||||
|
timeout,
|
||||||
|
data=None,
|
||||||
|
method=None,
|
||||||
|
content_type='application/json',
|
||||||
|
additional_headers=None
|
||||||
|
):
|
||||||
|
if data and content_type == 'application/json':
|
||||||
data = json.dumps(data)
|
data = json.dumps(data)
|
||||||
|
|
||||||
# NOTE: fetch_url uses a password manager, which follows the
|
# NOTE: fetch_url uses a password manager, which follows the
|
||||||
|
@ -337,9 +387,18 @@ def request(url, user, passwd, timeout, data=None, method=None):
|
||||||
# inject the basic-auth header up-front to ensure that JIRA treats
|
# inject the basic-auth header up-front to ensure that JIRA treats
|
||||||
# the requests as authorized for this user.
|
# the requests as authorized for this user.
|
||||||
auth = to_text(base64.b64encode(to_bytes('{0}:{1}'.format(user, passwd), errors='surrogate_or_strict')))
|
auth = to_text(base64.b64encode(to_bytes('{0}:{1}'.format(user, passwd), errors='surrogate_or_strict')))
|
||||||
response, info = fetch_url(module, url, data=data, method=method, timeout=timeout,
|
|
||||||
headers={'Content-Type': 'application/json',
|
headers = {}
|
||||||
'Authorization': "Basic %s" % auth})
|
if isinstance(additional_headers) == dict:
|
||||||
|
headers = additional_headers.copy()
|
||||||
|
headers.update({
|
||||||
|
"Content-Type": content_type,
|
||||||
|
"Authorization": "Basic %s" % auth,
|
||||||
|
})
|
||||||
|
|
||||||
|
response, info = fetch_url(
|
||||||
|
module, url, data=data, method=method, timeout=timeout, headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
if info['status'] not in (200, 201, 204):
|
if info['status'] not in (200, 201, 204):
|
||||||
error = None
|
error = None
|
||||||
|
@ -365,8 +424,8 @@ def request(url, user, passwd, timeout, data=None, method=None):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def post(url, user, passwd, timeout, data):
|
def post(url, user, passwd, timeout, data, content_type='application/json', additional_headers=None):
|
||||||
return request(url, user, passwd, timeout, data=data, method='POST')
|
return request(url, user, passwd, timeout, data=data, method='POST', content_type=content_type, additional_headers=additional_headers)
|
||||||
|
|
||||||
|
|
||||||
def put(url, user, passwd, timeout, data):
|
def put(url, user, passwd, timeout, data):
|
||||||
|
@ -486,13 +545,89 @@ def link(restbase, user, passwd, params):
|
||||||
return True, post(url, user, passwd, params['timeout'], data)
|
return True, post(url, user, passwd, params['timeout'], data)
|
||||||
|
|
||||||
|
|
||||||
|
def attach(restbase, user, passwd, params):
|
||||||
|
filename = params['attachment'].get('filename')
|
||||||
|
content = params['attachment'].get('content')
|
||||||
|
|
||||||
|
if not any((filename, content)):
|
||||||
|
raise ValueError('at least one of filename or content must be provided')
|
||||||
|
mime = params['attachment'].get('mimetype')
|
||||||
|
|
||||||
|
if not os.path.isfile(filename):
|
||||||
|
raise ValueError('The provided filename does not exist: %s' % filename)
|
||||||
|
|
||||||
|
content_type, data = _prepare_attachment(filename, content, mime)
|
||||||
|
|
||||||
|
url = restbase + '/issue/' + params['issue'] + '/attachments'
|
||||||
|
return True, post(
|
||||||
|
url, user, passwd, params['timeout'], data, content_type=content_type,
|
||||||
|
additional_headers={"X-Atlassian-Token": "no-check"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Ideally we'd just use prepare_multipart from ansible.module_utils.urls, but
|
||||||
|
# unfortunately it does not support specifying the encoding and also defaults to
|
||||||
|
# base64. Jira doesn't support base64 encoded attachments (and is therefore not
|
||||||
|
# spec compliant. Go figure). I originally wrote this function as an almost
|
||||||
|
# exact copypasta of prepare_multipart, but ran into some encoding issues when
|
||||||
|
# using the noop encoder. Hand rolling the entire message body seemed to work
|
||||||
|
# out much better.
|
||||||
|
#
|
||||||
|
# https://community.atlassian.com/t5/Jira-questions/Jira-dosen-t-decode-base64-attachment-request-REST-API/qaq-p/916427
|
||||||
|
#
|
||||||
|
# content is expected to be a base64 encoded string since Ansible doesn't
|
||||||
|
# support passing raw bytes objects.
|
||||||
|
def _prepare_attachment(filename, content=None, mime_type=None):
|
||||||
|
def escape_quotes(s):
|
||||||
|
return s.replace('"', '\\"')
|
||||||
|
|
||||||
|
boundary = "".join(random.choice(string.digits + string.ascii_letters) for i in range(30))
|
||||||
|
name = to_native(os.path.basename(filename))
|
||||||
|
|
||||||
|
if not mime_type:
|
||||||
|
try:
|
||||||
|
mime_type = mimetypes.guess_type(filename or '', strict=False)[0] or 'application/octet-stream'
|
||||||
|
except Exception:
|
||||||
|
mime_type = 'application/octet-stream'
|
||||||
|
main_type, sep, sub_type = mime_type.partition('/')
|
||||||
|
|
||||||
|
if not content and filename:
|
||||||
|
with open(to_bytes(filename, errors='surrogate_or_strict'), 'rb') as f:
|
||||||
|
content = f.read()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
content = base64.decode(content)
|
||||||
|
except binascii.Error as e:
|
||||||
|
raise Exception("Unable to base64 decode file content: %s" % e)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"--{0}".format(boundary),
|
||||||
|
'Content-Disposition: form-data; name="file"; filename={0}'.format(escape_quotes(name)),
|
||||||
|
"Content-Type: {0}".format("{0}/{1}".format(main_type, sub_type)),
|
||||||
|
'',
|
||||||
|
to_text(content),
|
||||||
|
"--{0}--".format(boundary),
|
||||||
|
""
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
"multipart/form-data; boundary={0}".format(boundary),
|
||||||
|
"\r\n".join(lines)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
||||||
global module
|
global module
|
||||||
module = AnsibleModule(
|
module = AnsibleModule(
|
||||||
argument_spec=dict(
|
argument_spec=dict(
|
||||||
|
attachment=dict(type='dict', options=dict(
|
||||||
|
content=dict(type='str'),
|
||||||
|
filename=dict(type='path', required=True),
|
||||||
|
mimetype=dict(type='str')
|
||||||
|
)),
|
||||||
uri=dict(type='str', required=True),
|
uri=dict(type='str', required=True),
|
||||||
operation=dict(type='str', choices=['create', 'comment', 'edit', 'update', 'fetch', 'transition', 'link', 'search'],
|
operation=dict(type='str', choices=['attach', 'create', 'comment', 'edit', 'update', 'fetch', 'transition', 'link', 'search'],
|
||||||
aliases=['command'], required=True),
|
aliases=['command'], required=True),
|
||||||
username=dict(type='str', required=True),
|
username=dict(type='str', required=True),
|
||||||
password=dict(type='str', required=True, no_log=True),
|
password=dict(type='str', required=True, no_log=True),
|
||||||
|
@ -515,6 +650,7 @@ def main():
|
||||||
account_id=dict(type='str'),
|
account_id=dict(type='str'),
|
||||||
),
|
),
|
||||||
required_if=(
|
required_if=(
|
||||||
|
('operation', 'attach', ['issue', 'attachment']),
|
||||||
('operation', 'create', ['project', 'issuetype', 'summary']),
|
('operation', 'create', ['project', 'issuetype', 'summary']),
|
||||||
('operation', 'comment', ['issue', 'comment']),
|
('operation', 'comment', ['issue', 'comment']),
|
||||||
('operation', 'fetch', ['issue']),
|
('operation', 'fetch', ['issue']),
|
||||||
|
|
Loading…
Reference in a new issue