2020-03-09 10:11:07 +01:00
|
|
|
#!/usr/bin/python
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
|
|
# (c) 2014, Steve Smith <ssmith@atlassian.com>
|
|
|
|
# Atlassian open-source approval reference OSR-76.
|
|
|
|
#
|
2020-04-09 09:29:26 +02:00
|
|
|
# (c) 2020, Per Abildgaard Toft <per@minfejl.dk> Search and update function
|
|
|
|
#
|
2020-03-09 10:11:07 +01:00
|
|
|
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
|
|
|
|
|
|
|
from __future__ import absolute_import, division, print_function
|
|
|
|
__metaclass__ = type
|
|
|
|
|
|
|
|
|
2020-04-09 09:29:26 +02:00
|
|
|
DOCUMENTATION = """
|
2020-03-09 10:11:07 +01:00
|
|
|
module: jira
|
|
|
|
short_description: create and modify issues in a JIRA instance
|
|
|
|
description:
|
|
|
|
- Create and modify issues in a JIRA instance.
|
|
|
|
|
|
|
|
options:
|
|
|
|
uri:
|
|
|
|
required: true
|
|
|
|
description:
|
|
|
|
- Base URI for the JIRA instance.
|
|
|
|
|
|
|
|
operation:
|
|
|
|
required: true
|
|
|
|
aliases: [ command ]
|
2020-04-09 09:29:26 +02:00
|
|
|
choices: [ comment, create, edit, fetch, link, search, transition, update ]
|
2020-03-09 10:11:07 +01:00
|
|
|
description:
|
|
|
|
- The operation to perform.
|
|
|
|
|
|
|
|
username:
|
|
|
|
required: true
|
|
|
|
description:
|
|
|
|
- The username to log-in with.
|
|
|
|
|
|
|
|
password:
|
|
|
|
required: true
|
|
|
|
description:
|
|
|
|
- The password to log-in with.
|
|
|
|
|
|
|
|
project:
|
|
|
|
required: false
|
|
|
|
description:
|
|
|
|
- The project for this operation. Required for issue creation.
|
|
|
|
|
|
|
|
summary:
|
|
|
|
required: false
|
|
|
|
description:
|
|
|
|
- The issue summary, where appropriate.
|
|
|
|
|
|
|
|
description:
|
|
|
|
required: false
|
|
|
|
description:
|
|
|
|
- The issue description, where appropriate.
|
|
|
|
|
|
|
|
issuetype:
|
|
|
|
required: false
|
|
|
|
description:
|
|
|
|
- The issue type, for issue creation.
|
|
|
|
|
|
|
|
issue:
|
|
|
|
required: false
|
|
|
|
description:
|
|
|
|
- An existing issue key to operate on.
|
|
|
|
|
|
|
|
comment:
|
|
|
|
required: false
|
|
|
|
description:
|
|
|
|
- The comment text to add.
|
|
|
|
|
|
|
|
status:
|
|
|
|
required: false
|
|
|
|
description:
|
|
|
|
- The desired status; only relevant for the transition operation.
|
|
|
|
|
|
|
|
assignee:
|
|
|
|
required: false
|
|
|
|
description:
|
|
|
|
- Sets the assignee on create or transition operations. Note not all transitions will allow this.
|
|
|
|
|
|
|
|
linktype:
|
|
|
|
required: false
|
|
|
|
description:
|
|
|
|
- Set type of link, when action 'link' selected.
|
|
|
|
|
|
|
|
inwardissue:
|
|
|
|
required: false
|
|
|
|
description:
|
|
|
|
- Set issue from which link will be created.
|
|
|
|
|
|
|
|
outwardissue:
|
|
|
|
required: false
|
|
|
|
description:
|
|
|
|
- Set issue to which link will be created.
|
|
|
|
|
|
|
|
fields:
|
|
|
|
required: false
|
|
|
|
description:
|
|
|
|
- This is a free-form data structure that can contain arbitrary data. This is passed directly to the JIRA REST API
|
|
|
|
(possibly after merging with other required data, as when passed to create). See examples for more information,
|
|
|
|
and the JIRA REST API for the structure required for various fields.
|
|
|
|
|
2020-04-09 09:29:26 +02:00
|
|
|
jql:
|
|
|
|
required: false
|
|
|
|
description:
|
|
|
|
- Query JIRA in JQL Syntax, e.g. 'CMDB Hostname'='test.example.com'.
|
|
|
|
type: str
|
|
|
|
|
|
|
|
maxresults:
|
|
|
|
required: false
|
|
|
|
description:
|
|
|
|
- Limit the result of I(operation=search). If no value is specified, the default jira limit will be used.
|
|
|
|
- Used when I(operation=search) only, ignored otherwise.
|
|
|
|
type: int
|
|
|
|
|
2020-03-09 10:11:07 +01:00
|
|
|
timeout:
|
|
|
|
required: false
|
|
|
|
description:
|
|
|
|
- Set timeout, in seconds, on requests to JIRA API.
|
|
|
|
default: 10
|
|
|
|
|
|
|
|
validate_certs:
|
|
|
|
required: false
|
|
|
|
description:
|
|
|
|
- Require valid SSL certificates (set to `false` if you'd like to use self-signed certificates)
|
|
|
|
default: true
|
|
|
|
type: bool
|
|
|
|
|
|
|
|
notes:
|
|
|
|
- "Currently this only works with basic-auth."
|
|
|
|
|
2020-04-09 09:29:26 +02:00
|
|
|
author:
|
|
|
|
- "Steve Smith (@tarka)"
|
|
|
|
- "Per Abildgaard Toft (@pertoft)"
|
|
|
|
"""
|
2020-03-09 10:11:07 +01:00
|
|
|
|
|
|
|
EXAMPLES = """
|
|
|
|
# Create a new issue and add a comment to it:
|
|
|
|
- name: Create an issue
|
|
|
|
jira:
|
|
|
|
uri: '{{ server }}'
|
|
|
|
username: '{{ user }}'
|
|
|
|
password: '{{ pass }}'
|
|
|
|
project: ANS
|
|
|
|
operation: create
|
|
|
|
summary: Example Issue
|
|
|
|
description: Created using Ansible
|
|
|
|
issuetype: Task
|
2020-04-09 09:29:26 +02:00
|
|
|
args:
|
|
|
|
fields:
|
|
|
|
customfield_13225: "test"
|
|
|
|
customfield_12931: '{"value": "Test"}'
|
2020-03-09 10:11:07 +01:00
|
|
|
register: issue
|
|
|
|
|
|
|
|
- name: Comment on issue
|
|
|
|
jira:
|
|
|
|
uri: '{{ server }}'
|
|
|
|
username: '{{ user }}'
|
|
|
|
password: '{{ pass }}'
|
|
|
|
issue: '{{ issue.meta.key }}'
|
|
|
|
operation: comment
|
|
|
|
comment: A comment added by Ansible
|
|
|
|
|
|
|
|
# Assign an existing issue using edit
|
|
|
|
- name: Assign an issue using free-form fields
|
|
|
|
jira:
|
|
|
|
uri: '{{ server }}'
|
|
|
|
username: '{{ user }}'
|
|
|
|
password: '{{ pass }}'
|
|
|
|
issue: '{{ issue.meta.key}}'
|
|
|
|
operation: edit
|
|
|
|
assignee: ssmith
|
|
|
|
|
|
|
|
# Create an issue with an existing assignee
|
|
|
|
- name: Create an assigned issue
|
|
|
|
jira:
|
|
|
|
uri: '{{ server }}'
|
|
|
|
username: '{{ user }}'
|
|
|
|
password: '{{ pass }}'
|
|
|
|
project: ANS
|
|
|
|
operation: create
|
|
|
|
summary: Assigned issue
|
|
|
|
description: Created and assigned using Ansible
|
|
|
|
issuetype: Task
|
|
|
|
assignee: ssmith
|
|
|
|
|
|
|
|
# Edit an issue
|
|
|
|
- name: Set the labels on an issue using free-form fields
|
|
|
|
jira:
|
|
|
|
uri: '{{ server }}'
|
|
|
|
username: '{{ user }}'
|
|
|
|
password: '{{ pass }}'
|
|
|
|
issue: '{{ issue.meta.key }}'
|
|
|
|
operation: edit
|
|
|
|
args:
|
|
|
|
fields:
|
|
|
|
labels:
|
|
|
|
- autocreated
|
|
|
|
- ansible
|
|
|
|
|
2020-04-09 09:29:26 +02:00
|
|
|
# Updating a field using operations: add, set & remove
|
|
|
|
- name: Change the value of a Select dropdown
|
|
|
|
jira:
|
|
|
|
uri: '{{ server }}'
|
|
|
|
username: '{{ user }}'
|
|
|
|
password: '{{ pass }}'
|
|
|
|
issue: '{{ issue.meta.key }}'
|
|
|
|
operation: update
|
|
|
|
args:
|
|
|
|
fields:
|
|
|
|
customfield_12931: [ {'set': {'value': 'Virtual'}} ]
|
|
|
|
customfield_13820: [ {'set': {'value':'Manually'}} ]
|
|
|
|
register: cmdb_issue
|
|
|
|
delegate_to: localhost
|
|
|
|
|
|
|
|
|
2020-03-09 10:11:07 +01:00
|
|
|
# Retrieve metadata for an issue and use it to create an account
|
|
|
|
- name: Get an issue
|
|
|
|
jira:
|
|
|
|
uri: '{{ server }}'
|
|
|
|
username: '{{ user }}'
|
|
|
|
password: '{{ pass }}'
|
|
|
|
project: ANS
|
|
|
|
operation: fetch
|
|
|
|
issue: ANS-63
|
|
|
|
register: issue
|
|
|
|
|
2020-04-09 09:29:26 +02:00
|
|
|
# Search for an issue
|
|
|
|
# You can limit the search for specific fields by adding optional args. Note! It must be a dict, hence, lastViewed: null
|
|
|
|
- name: Search for an issue
|
|
|
|
jira:
|
|
|
|
uri: '{{ server }}'
|
|
|
|
username: '{{ user }}'
|
|
|
|
password: '{{ pass }}'
|
|
|
|
project: ANS
|
|
|
|
operation: search
|
|
|
|
maxresults: 10
|
|
|
|
jql: project=cmdb AND cf[13225]="test"
|
|
|
|
args:
|
|
|
|
fields:
|
|
|
|
lastViewed: null
|
|
|
|
register: issue
|
|
|
|
|
2020-03-09 10:11:07 +01:00
|
|
|
- name: Create a unix account for the reporter
|
|
|
|
become: true
|
|
|
|
user:
|
|
|
|
name: '{{ issue.meta.fields.creator.name }}'
|
|
|
|
comment: '{{ issue.meta.fields.creator.displayName }}'
|
|
|
|
|
|
|
|
# You can get list of valid linktypes at /rest/api/2/issueLinkType
|
|
|
|
# url of your jira installation.
|
|
|
|
- name: Create link from HSP-1 to MKY-1
|
|
|
|
jira:
|
|
|
|
uri: '{{ server }}'
|
|
|
|
username: '{{ user }}'
|
|
|
|
password: '{{ pass }}'
|
|
|
|
operation: link
|
|
|
|
linktype: Relates
|
|
|
|
inwardissue: HSP-1
|
|
|
|
outwardissue: MKY-1
|
|
|
|
|
|
|
|
# Transition an issue by target status
|
|
|
|
- name: Close the issue
|
|
|
|
jira:
|
|
|
|
uri: '{{ server }}'
|
|
|
|
username: '{{ user }}'
|
|
|
|
password: '{{ pass }}'
|
|
|
|
issue: '{{ issue.meta.key }}'
|
|
|
|
operation: transition
|
|
|
|
status: Done
|
2020-04-09 09:29:26 +02:00
|
|
|
args:
|
|
|
|
fields:
|
|
|
|
customfield_14321: [ {'set': {'value': 'Value of Select' }} ]
|
|
|
|
comment: [ { 'add': { 'body' : 'Test' } }]
|
|
|
|
|
2020-03-09 10:11:07 +01:00
|
|
|
"""
|
|
|
|
|
|
|
|
import base64
|
|
|
|
import json
|
|
|
|
import sys
|
2020-04-20 13:05:57 +02:00
|
|
|
|
|
|
|
from ansible.module_utils.six.moves.urllib.request import pathname2url
|
|
|
|
|
2020-03-09 10:11:07 +01:00
|
|
|
from ansible.module_utils._text import to_text, to_bytes
|
|
|
|
|
|
|
|
from ansible.module_utils.basic import AnsibleModule
|
|
|
|
from ansible.module_utils.urls import fetch_url
|
|
|
|
|
|
|
|
|
|
|
|
def request(url, user, passwd, timeout, data=None, method=None):
|
|
|
|
if data:
|
|
|
|
data = json.dumps(data)
|
|
|
|
|
|
|
|
# NOTE: fetch_url uses a password manager, which follows the
|
|
|
|
# standard request-then-challenge basic-auth semantics. However as
|
|
|
|
# JIRA allows some unauthorised operations it doesn't necessarily
|
|
|
|
# send the challenge, so the request occurs as the anonymous user,
|
|
|
|
# resulting in unexpected results. To work around this we manually
|
|
|
|
# inject the basic-auth header up-front to ensure that JIRA treats
|
|
|
|
# the requests as authorized for this user.
|
|
|
|
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',
|
|
|
|
'Authorization': "Basic %s" % auth})
|
|
|
|
|
|
|
|
if info['status'] not in (200, 201, 204):
|
2020-04-09 09:29:26 +02:00
|
|
|
error = json.loads(info['body'])
|
|
|
|
if error:
|
|
|
|
module.fail_json(msg=error['errorMessages'])
|
|
|
|
else:
|
|
|
|
# Fallback print body, if it cant be decoded
|
|
|
|
module.fail_json(msg=info['body'])
|
2020-03-09 10:11:07 +01:00
|
|
|
|
|
|
|
body = response.read()
|
|
|
|
|
|
|
|
if body:
|
|
|
|
return json.loads(to_text(body, errors='surrogate_or_strict'))
|
|
|
|
else:
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
def post(url, user, passwd, timeout, data):
|
|
|
|
return request(url, user, passwd, timeout, data=data, method='POST')
|
|
|
|
|
|
|
|
|
|
|
|
def put(url, user, passwd, timeout, data):
|
|
|
|
return request(url, user, passwd, timeout, data=data, method='PUT')
|
|
|
|
|
|
|
|
|
|
|
|
def get(url, user, passwd, timeout):
|
|
|
|
return request(url, user, passwd, timeout)
|
|
|
|
|
|
|
|
|
|
|
|
def create(restbase, user, passwd, params):
|
|
|
|
createfields = {
|
|
|
|
'project': {'key': params['project']},
|
|
|
|
'summary': params['summary'],
|
|
|
|
'issuetype': {'name': params['issuetype']}}
|
|
|
|
|
|
|
|
if params['description']:
|
|
|
|
createfields['description'] = params['description']
|
|
|
|
|
|
|
|
# Merge in any additional or overridden fields
|
|
|
|
if params['fields']:
|
|
|
|
createfields.update(params['fields'])
|
|
|
|
|
|
|
|
data = {'fields': createfields}
|
|
|
|
|
|
|
|
url = restbase + '/issue/'
|
|
|
|
|
|
|
|
ret = post(url, user, passwd, params['timeout'], data)
|
|
|
|
|
|
|
|
return ret
|
|
|
|
|
|
|
|
|
|
|
|
def comment(restbase, user, passwd, params):
|
|
|
|
data = {
|
|
|
|
'body': params['comment']
|
|
|
|
}
|
|
|
|
|
|
|
|
url = restbase + '/issue/' + params['issue'] + '/comment'
|
|
|
|
|
|
|
|
ret = post(url, user, passwd, params['timeout'], data)
|
|
|
|
|
|
|
|
return ret
|
|
|
|
|
|
|
|
|
|
|
|
def edit(restbase, user, passwd, params):
|
|
|
|
data = {
|
|
|
|
'fields': params['fields']
|
|
|
|
}
|
|
|
|
|
|
|
|
url = restbase + '/issue/' + params['issue']
|
|
|
|
|
|
|
|
ret = put(url, user, passwd, params['timeout'], data)
|
|
|
|
|
|
|
|
return ret
|
|
|
|
|
|
|
|
|
2020-04-09 09:29:26 +02:00
|
|
|
def update(restbase, user, passwd, params):
|
|
|
|
data = {
|
|
|
|
"update": params['fields'],
|
|
|
|
}
|
|
|
|
url = restbase + '/issue/' + params['issue']
|
|
|
|
|
|
|
|
ret = put(url, user, passwd, params['timeout'], data)
|
|
|
|
|
|
|
|
return ret
|
|
|
|
|
|
|
|
|
2020-03-09 10:11:07 +01:00
|
|
|
def fetch(restbase, user, passwd, params):
|
|
|
|
url = restbase + '/issue/' + params['issue']
|
|
|
|
ret = get(url, user, passwd, params['timeout'])
|
|
|
|
return ret
|
|
|
|
|
|
|
|
|
2020-04-09 09:29:26 +02:00
|
|
|
def search(restbase, user, passwd, params):
|
2020-04-20 13:05:57 +02:00
|
|
|
url = restbase + '/search?jql=' + pathname2url(params['jql'])
|
2020-04-09 09:29:26 +02:00
|
|
|
if params['fields']:
|
|
|
|
fields = params['fields'].keys()
|
2020-04-20 13:05:57 +02:00
|
|
|
url = url + '&fields=' + '&fields='.join([pathname2url(f) for f in fields])
|
2020-04-09 09:29:26 +02:00
|
|
|
if params['maxresults']:
|
|
|
|
url = url + '&maxResults=' + str(params['maxresults'])
|
|
|
|
ret = get(url, user, passwd, params['timeout'])
|
|
|
|
return ret
|
|
|
|
|
|
|
|
|
2020-03-09 10:11:07 +01:00
|
|
|
def transition(restbase, user, passwd, params):
|
|
|
|
# Find the transition id
|
|
|
|
turl = restbase + '/issue/' + params['issue'] + "/transitions"
|
|
|
|
tmeta = get(turl, user, passwd, params['timeout'])
|
|
|
|
|
|
|
|
target = params['status']
|
|
|
|
tid = None
|
|
|
|
for t in tmeta['transitions']:
|
|
|
|
if t['name'] == target:
|
|
|
|
tid = t['id']
|
|
|
|
break
|
|
|
|
|
|
|
|
if not tid:
|
|
|
|
raise ValueError("Failed find valid transition for '%s'" % target)
|
|
|
|
|
|
|
|
# Perform it
|
|
|
|
url = restbase + '/issue/' + params['issue'] + "/transitions"
|
|
|
|
data = {'transition': {"id": tid},
|
2020-04-09 09:29:26 +02:00
|
|
|
'update': params['fields']}
|
2020-03-09 10:11:07 +01:00
|
|
|
|
|
|
|
ret = post(url, user, passwd, params['timeout'], data)
|
|
|
|
|
|
|
|
return ret
|
|
|
|
|
|
|
|
|
|
|
|
def link(restbase, user, passwd, params):
|
|
|
|
data = {
|
|
|
|
'type': {'name': params['linktype']},
|
|
|
|
'inwardIssue': {'key': params['inwardissue']},
|
|
|
|
'outwardIssue': {'key': params['outwardissue']},
|
|
|
|
}
|
|
|
|
|
|
|
|
url = restbase + '/issueLink/'
|
|
|
|
|
|
|
|
ret = post(url, user, passwd, params['timeout'], data)
|
|
|
|
|
|
|
|
return ret
|
|
|
|
|
|
|
|
|
|
|
|
# Some parameters are required depending on the operation:
|
|
|
|
OP_REQUIRED = dict(create=['project', 'issuetype', 'summary'],
|
|
|
|
comment=['issue', 'comment'],
|
|
|
|
edit=[],
|
2020-04-09 09:29:26 +02:00
|
|
|
update=[],
|
2020-03-09 10:11:07 +01:00
|
|
|
fetch=['issue'],
|
|
|
|
transition=['status'],
|
2020-04-09 09:29:26 +02:00
|
|
|
link=['linktype', 'inwardissue', 'outwardissue'],
|
|
|
|
search=['jql'])
|
2020-03-09 10:11:07 +01:00
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
|
|
|
global module
|
|
|
|
module = AnsibleModule(
|
|
|
|
argument_spec=dict(
|
|
|
|
uri=dict(required=True),
|
2020-04-09 09:29:26 +02:00
|
|
|
operation=dict(choices=['create', 'comment', 'edit', 'update', 'fetch', 'transition', 'link', 'search'],
|
2020-03-09 10:11:07 +01:00
|
|
|
aliases=['command'], required=True),
|
|
|
|
username=dict(required=True),
|
|
|
|
password=dict(required=True, no_log=True),
|
|
|
|
project=dict(),
|
|
|
|
summary=dict(),
|
|
|
|
description=dict(),
|
|
|
|
issuetype=dict(),
|
|
|
|
issue=dict(aliases=['ticket']),
|
|
|
|
comment=dict(),
|
|
|
|
status=dict(),
|
|
|
|
assignee=dict(),
|
|
|
|
fields=dict(default={}, type='dict'),
|
|
|
|
linktype=dict(),
|
|
|
|
inwardissue=dict(),
|
|
|
|
outwardissue=dict(),
|
2020-04-09 09:29:26 +02:00
|
|
|
jql=dict(),
|
|
|
|
maxresults=dict(type='int'),
|
2020-03-09 10:11:07 +01:00
|
|
|
timeout=dict(type='float', default=10),
|
|
|
|
validate_certs=dict(default=True, type='bool'),
|
|
|
|
),
|
|
|
|
supports_check_mode=False
|
|
|
|
)
|
|
|
|
|
|
|
|
op = module.params['operation']
|
|
|
|
|
|
|
|
# Check we have the necessary per-operation parameters
|
|
|
|
missing = []
|
|
|
|
for parm in OP_REQUIRED[op]:
|
|
|
|
if not module.params[parm]:
|
|
|
|
missing.append(parm)
|
|
|
|
if missing:
|
|
|
|
module.fail_json(msg="Operation %s require the following missing parameters: %s" % (op, ",".join(missing)))
|
|
|
|
|
|
|
|
# Handle rest of parameters
|
|
|
|
uri = module.params['uri']
|
|
|
|
user = module.params['username']
|
|
|
|
passwd = module.params['password']
|
|
|
|
if module.params['assignee']:
|
|
|
|
module.params['fields']['assignee'] = {'name': module.params['assignee']}
|
|
|
|
|
|
|
|
if not uri.endswith('/'):
|
|
|
|
uri = uri + '/'
|
|
|
|
restbase = uri + 'rest/api/2'
|
|
|
|
|
|
|
|
# Dispatch
|
|
|
|
try:
|
|
|
|
|
|
|
|
# Lookup the corresponding method for this operation. This is
|
|
|
|
# safe as the AnsibleModule should remove any unknown operations.
|
|
|
|
thismod = sys.modules[__name__]
|
|
|
|
method = getattr(thismod, op)
|
|
|
|
|
|
|
|
ret = method(restbase, user, passwd, module.params)
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
return module.fail_json(msg=e.message)
|
|
|
|
|
|
|
|
module.exit_json(changed=True, meta=ret)
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
main()
|