├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── conftest.py ├── docs ├── Makefile └── source │ ├── conf.py │ └── index.rst ├── sentry_jira ├── __init__.py ├── forms.py ├── jira.py ├── models.py ├── plugin.py └── templates │ └── sentry_jira │ ├── create_jira_issue.html │ ├── plugin_misconfigured.html │ └── project_conf_form.html ├── setup.cfg ├── setup.py └── tests └── sentry_jira └── test_plugin.py /.gitignore: -------------------------------------------------------------------------------- 1 | sentry.conf.py 2 | *.pyc 3 | *.egg-info 4 | .idea/ 5 | env/ 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | services: 4 | - memcached 5 | - postgresql 6 | - redis-server 7 | python: 8 | - '2.7' 9 | cache: 10 | directories: 11 | - node_modules 12 | - "$HOME/.cache/pip" 13 | deploy: 14 | provider: pypi 15 | user: getsentry 16 | password: 17 | secure: Yom5X3EjXAAjc9zT1dpH1ly1g1pEmmkY75BOfZ1Xv1iU9SEFs9VVCxrwXBCYtCcH3r3FoCFhGeMZN2q2T8eTsqespBprVYKzLg09YFH99pZUdAETPktrfBXMzPvY2mQgCQNP0pvY2fSR/pPBg+y1CTuuhvn0tYlmiPstlf+82G4= 18 | on: 19 | tags: true 20 | distributions: sdist bdist_wheel 21 | env: 22 | global: 23 | - PIP_DOWNLOAD_CACHE=".pip_download_cache" 24 | install: 25 | - make develop 26 | script: 27 | - flake8 28 | - py.test 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Functional Software, Inc 2 | Copyright (c) 2013, Adam Thurlow 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | * Neither the name of sentry-jira nor the names of its contributors may be used 14 | to endorse or promote products derived from this software without 15 | specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL ADAM THURLOW BE LIABLE FOR ANY DIRECT, INDIRECT, 21 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 22 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 23 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 24 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 25 | OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 26 | ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include setup.py README.rst MANIFEST.in LICENSE 2 | recursive-include sentry_jira * 3 | global-exclude *~ -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean develop install-tests lint publish test 2 | 3 | develop: 4 | pip install "pip>=7" 5 | pip install -e . 6 | make install-tests 7 | 8 | install-tests: 9 | pip install .[tests] 10 | 11 | lint: 12 | @echo "--> Linting python" 13 | flake8 14 | @echo "" 15 | 16 | test: 17 | @echo "--> Running Python tests" 18 | py.test tests || exit 1 19 | @echo "" 20 | 21 | publish: 22 | python setup.py sdist bdist_wheel upload 23 | 24 | clean: 25 | rm -rf *.egg-info src/*.egg-info 26 | rm -rf dist build 27 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | sentry-jira 2 | =========== 3 | 4 | **DEPRECATED:** This project now lives in `sentry-plugins `_ 5 | 6 | A flexible extension for Sentry which allows you to create issues in JIRA based on sentry events. 7 | It is capable of rendering and saving many custom fields, and will display the proper fields depending on 8 | which issue type you are trying to create. 9 | 10 | **Requires Sentry 8+** 11 | 12 | Installation 13 | ------------ 14 | 15 | Install the package via ``pip``: 16 | 17 | :: 18 | 19 | pip install sentry-jira 20 | 21 | Configuration 22 | ------------- 23 | 24 | Go to your project's configuration page (Dashboard -> [Project] -> Settings), select the 25 | Issue Tracking tab, and then click the JIRA button under available integrations. 26 | 27 | Enter the JIRA credentials and Project configuration and save changes. Filling out the form is 28 | a two step process (one to fill in data, one to enter additional options). 29 | 30 | More Documentation 31 | ------------------ 32 | 33 | Have a look at the readthedocs page for more detailed configuration steps and a 34 | changelog: https://sentry-jira.readthedocs.io/en/latest/ 35 | 36 | License 37 | ------- 38 | 39 | sentry-jira is licensed under the terms of the 3-clause BSD license. 40 | 41 | Contributing 42 | ------------ 43 | 44 | All contributions are welcome, including but not limited to: 45 | 46 | - Documentation fixes / updates 47 | - New features (requests as well as implementations) 48 | - Bug fixes (see issues list) 49 | - Update supported JIRA types as you come across them 50 | 51 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import os 4 | os.environ.setdefault('DB', 'sqlite') 5 | 6 | pytest_plugins = [ 7 | 'sentry.utils.pytest' 8 | ] 9 | 10 | 11 | def pytest_configure(config): 12 | from django.conf import settings 13 | settings.INSTALLED_APPS += ('sentry_jira',) 14 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/sentry-jira.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/sentry-jira.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/sentry-jira" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/sentry-jira" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # sentry-jira documentation build configuration file, created by 4 | # sphinx-quickstart on Mon Sep 10 15:06:37 2012. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | #sys.path.insert(0, os.path.abspath('.')) 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # If your documentation needs a minimal Sphinx version, state it here. 24 | #needs_sphinx = '1.0' 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be extensions 27 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 28 | extensions = [] 29 | 30 | # Add any paths that contain templates here, relative to this directory. 31 | templates_path = ['_templates'] 32 | 33 | # The suffix of source filenames. 34 | source_suffix = '.rst' 35 | 36 | # The encoding of source files. 37 | #source_encoding = 'utf-8-sig' 38 | 39 | # The master toctree document. 40 | master_doc = 'index' 41 | 42 | # General information about the project. 43 | project = u'sentry-jira' 44 | copyright = u'2012, Adam Thurlow (thurloat)' 45 | 46 | # The version info for the project you're documenting, acts as replacement for 47 | # |version| and |release|, also used in various other places throughout the 48 | # built documents. 49 | # 50 | # The short X.Y version. 51 | version = __import__('pkg_resources').get_distribution('sentry-jira').version 52 | # The full version, including alpha/beta/rc tags. 53 | release = version 54 | 55 | # The language for content autogenerated by Sphinx. Refer to documentation 56 | # for a list of supported languages. 57 | #language = None 58 | 59 | # There are two options for replacing |today|: either, you set today to some 60 | # non-false value, then it is used: 61 | #today = '' 62 | # Else, today_fmt is used as the format for a strftime call. 63 | #today_fmt = '%B %d, %Y' 64 | 65 | # List of patterns, relative to source directory, that match files and 66 | # directories to ignore when looking for source files. 67 | exclude_patterns = [] 68 | 69 | # The reST default role (used for this markup: `text`) to use for all documents. 70 | #default_role = None 71 | 72 | # If true, '()' will be appended to :func: etc. cross-reference text. 73 | #add_function_parentheses = True 74 | 75 | # If true, the current module name will be prepended to all description 76 | # unit titles (such as .. function::). 77 | #add_module_names = True 78 | 79 | # If true, sectionauthor and moduleauthor directives will be shown in the 80 | # output. They are ignored by default. 81 | #show_authors = False 82 | 83 | # The name of the Pygments (syntax highlighting) style to use. 84 | pygments_style = 'sphinx' 85 | 86 | # A list of ignored prefixes for module index sorting. 87 | #modindex_common_prefix = [] 88 | 89 | 90 | # -- Options for HTML output --------------------------------------------------- 91 | 92 | # The theme to use for HTML and HTML Help pages. See the documentation for 93 | # a list of builtin themes. 94 | html_theme = 'default' 95 | 96 | # Theme options are theme-specific and customize the look and feel of a theme 97 | # further. For a list of options available for each theme, see the 98 | # documentation. 99 | #html_theme_options = {} 100 | 101 | # Add any paths that contain custom themes here, relative to this directory. 102 | #html_theme_path = [] 103 | 104 | # The name for this set of Sphinx documents. If None, it defaults to 105 | # " v documentation". 106 | #html_title = None 107 | 108 | # A shorter title for the navigation bar. Default is the same as html_title. 109 | #html_short_title = None 110 | 111 | # The name of an image file (relative to this directory) to place at the top 112 | # of the sidebar. 113 | #html_logo = None 114 | 115 | # The name of an image file (within the static path) to use as favicon of the 116 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 117 | # pixels large. 118 | #html_favicon = None 119 | 120 | # Add any paths that contain custom static files (such as style sheets) here, 121 | # relative to this directory. They are copied after the builtin static files, 122 | # so a file named "default.css" will overwrite the builtin "default.css". 123 | html_static_path = ['_static'] 124 | 125 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 126 | # using the given strftime format. 127 | #html_last_updated_fmt = '%b %d, %Y' 128 | 129 | # If true, SmartyPants will be used to convert quotes and dashes to 130 | # typographically correct entities. 131 | #html_use_smartypants = True 132 | 133 | # Custom sidebar templates, maps document names to template names. 134 | #html_sidebars = {} 135 | 136 | # Additional templates that should be rendered to pages, maps page names to 137 | # template names. 138 | #html_additional_pages = {} 139 | 140 | # If false, no module index is generated. 141 | #html_domain_indices = True 142 | 143 | # If false, no index is generated. 144 | #html_use_index = True 145 | 146 | # If true, the index is split into individual pages for each letter. 147 | #html_split_index = False 148 | 149 | # If true, links to the reST sources are added to the pages. 150 | #html_show_sourcelink = True 151 | 152 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 153 | #html_show_sphinx = True 154 | 155 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 156 | #html_show_copyright = True 157 | 158 | # If true, an OpenSearch description file will be output, and all pages will 159 | # contain a tag referring to it. The value of this option must be the 160 | # base URL from which the finished HTML is served. 161 | #html_use_opensearch = '' 162 | 163 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 164 | #html_file_suffix = None 165 | 166 | # Output file base name for HTML help builder. 167 | htmlhelp_basename = 'sentry-jiradoc' 168 | 169 | 170 | # -- Options for LaTeX output -------------------------------------------------- 171 | 172 | latex_elements = { 173 | # The paper size ('letterpaper' or 'a4paper'). 174 | #'papersize': 'letterpaper', 175 | 176 | # The font size ('10pt', '11pt' or '12pt'). 177 | #'pointsize': '10pt', 178 | 179 | # Additional stuff for the LaTeX preamble. 180 | #'preamble': '', 181 | } 182 | 183 | # Grouping the document tree into LaTeX files. List of tuples 184 | # (source start file, target name, title, author, documentclass [howto/manual]). 185 | latex_documents = [ 186 | ('index', 'sentry-jira.tex', u'sentry-jira Documentation', 187 | u'Adam Thurlow (thurloat)', 'manual'), 188 | ] 189 | 190 | # The name of an image file (relative to this directory) to place at the top of 191 | # the title page. 192 | #latex_logo = None 193 | 194 | # For "manual" documents, if this is true, then toplevel headings are parts, 195 | # not chapters. 196 | #latex_use_parts = False 197 | 198 | # If true, show page references after internal links. 199 | #latex_show_pagerefs = False 200 | 201 | # If true, show URL addresses after external links. 202 | #latex_show_urls = False 203 | 204 | # Documents to append as an appendix to all manuals. 205 | #latex_appendices = [] 206 | 207 | # If false, no module index is generated. 208 | #latex_domain_indices = True 209 | 210 | 211 | # -- Options for manual page output -------------------------------------------- 212 | 213 | # One entry per manual page. List of tuples 214 | # (source start file, name, description, authors, manual section). 215 | man_pages = [ 216 | ('index', 'sentry-jira', u'sentry-jira Documentation', 217 | [u'Adam Thurlow (thurloat)'], 1) 218 | ] 219 | 220 | # If true, show URL addresses after external links. 221 | #man_show_urls = False 222 | 223 | 224 | # -- Options for Texinfo output ------------------------------------------------ 225 | 226 | # Grouping the document tree into Texinfo files. List of tuples 227 | # (source start file, target name, title, author, 228 | # dir menu entry, description, category) 229 | texinfo_documents = [ 230 | ('index', 'sentry-jira', u'sentry-jira Documentation', 231 | u'Adam Thurlow (thurloat)', 'sentry-jira', 'One line description of project.', 232 | 'Miscellaneous'), 233 | ] 234 | 235 | # Documents to append as an appendix to all manuals. 236 | #texinfo_appendices = [] 237 | 238 | # If false, no module index is generated. 239 | #texinfo_domain_indices = True 240 | 241 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 242 | #texinfo_show_urls = 'footnote' 243 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. sentry-jira documentation master file, created by 2 | sphinx-quickstart on Mon Sep 10 15:06:37 2012. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to sentry-jira's documentation 7 | ====================================== 8 | 9 | 10 | Configuration Overview 11 | ---------------------- 12 | 13 | Go to your project's configuration page (Projects -> [Project]) and select the 14 | JIRA tab. Enter the JIRA credentials and Project configuration and save changes. 15 | Filling out the form is a two step process (one to fill in data, one to select 16 | project). 17 | 18 | Once the configuration is saved, you have the option of filling in raw field 19 | names (comma separated) that you don't want displayed on the create form. This 20 | is useful if you have plugins installed which use custom fields to store 21 | additional data on Issues. 22 | 23 | Configuration Tips 24 | ------------------ 25 | 26 | - JIRA >= 5.0 is required. 27 | 28 | - You should use `https://` for the configuration since the plugin 29 | uses basic auth with the JIRA API to authenticate requests. 30 | 31 | - Ensure that the account you're using has a few key permissions: 32 | 1. CREATE_ISSUE 33 | 2. ASSIGN_ISSUE 34 | 3. USER_PICKER 35 | 36 | - You cannot link to a JIRA server behind a firewall, unless sentry is also 37 | behind that firewall. 38 | 39 | - You need to configure the plugin for each Sentry project, and you have the 40 | ability to assign a default JIRA project for each sentry project. 41 | 42 | - JIRA servers with self-signed SSL Certs are supported. 43 | 44 | 45 | Change Log 46 | ---------- 47 | 48 | There have been a few changes recently that depend on the version of sentry 49 | that is installed alongside the plugin, so I'm keeping track of changes for 50 | versions of the plugins (along with which version of sentry they actually 51 | support). 52 | 53 | 0.7.1 54 | ##### 55 | 56 | - Add support for self-signed SSL Certs for JIRA instances 57 | 58 | - Removes self-included select2 library in favour of the one built into sentry 59 | 60 | - Now requires at least Sentry v. 5.3.3. If upgrading from a version older 61 | than 5.1, check out the sentry upgrade guide http://sentry.readthedocs.org/en/latest/upgrading/index.html#upgrading-to-5-1 62 | 63 | 0.6.12 64 | ###### 65 | 66 | - Add support for the `select` custom field type in JIRA 67 | 68 | - Fixed form -> API type conversions 69 | 70 | - Add more specific cache keys for hosted sentry support 71 | 72 | - Supports Sentry v 5.0.x - 5.2.x 73 | 74 | 0.6.x 75 | ##### 76 | 77 | - Old versions, don't use them. 78 | 79 | .. toctree:: 80 | :maxdepth: 2 81 | 82 | 83 | 84 | Indices and tables 85 | ================== 86 | 87 | * :ref:`genindex` 88 | * :ref:`modindex` 89 | * :ref:`search` 90 | 91 | -------------------------------------------------------------------------------- /sentry_jira/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | VERSION = __import__('pkg_resources').get_distribution('sentry-jira').version 3 | except Exception, e: 4 | VERSION = "over 9000" 5 | -------------------------------------------------------------------------------- /sentry_jira/forms.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import logging 4 | 5 | from django.core.exceptions import ValidationError 6 | from django.utils.translation import ugettext_lazy as _ 7 | from django import forms 8 | from .jira import JIRAClient, JIRAError 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | 13 | class JIRAFormUtils(object): 14 | @staticmethod 15 | def make_choices(x): 16 | return [(y["id"], y["name"] if "name" in y else y["value"]) for y in x] if x else [] 17 | 18 | 19 | class JIRAOptionsForm(forms.Form): 20 | instance_url = forms.CharField( 21 | label=_("JIRA Instance URL"), 22 | widget=forms.TextInput(attrs={'class': 'span6', 'placeholder': 'e.g. "https://jira.atlassian.com"'}), 23 | help_text=_("It must be visible to the Sentry server"), 24 | required=True 25 | ) 26 | username = forms.CharField( 27 | label=_("Username"), 28 | widget=forms.TextInput(attrs={'class': 'span6'}), 29 | help_text=_("Ensure the JIRA user has admin perm. on the project"), 30 | required=True 31 | ) 32 | password = forms.CharField( 33 | label=_("Password"), 34 | widget=forms.PasswordInput(attrs={'class': 'span6'}), 35 | required=False 36 | ) 37 | default_project = forms.ChoiceField( 38 | label=_("Linked Project"), 39 | ) 40 | ignored_fields = forms.CharField( 41 | label=_("Ignored Fields"), 42 | widget=forms.Textarea(attrs={'class': 'span11', 'placeholder': 'e.g. "components, security, customfield_10006"'}), 43 | help_text=_("Comma-separated list of properties that you don't want to show in the form"), 44 | required=False 45 | ) 46 | default_priority = forms.ChoiceField( 47 | label=_("Default Priority"), 48 | required=False 49 | ) 50 | default_issue_type = forms.ChoiceField( 51 | label=_("Default Issue Type"), 52 | required=False, 53 | ) 54 | auto_create = forms.BooleanField( 55 | label=_("Auto create JIRA tickets"), 56 | help_text=_("Automatically create a JIRA ticket for EVERY new issue"), 57 | required=False 58 | ) 59 | 60 | def __init__(self, data=None, *args, **kwargs): 61 | 62 | super(JIRAOptionsForm, self).__init__(data=data, *args, **kwargs) 63 | 64 | initial = kwargs.get("initial") or {} 65 | for key, value in self.data.items(): 66 | initial[key.lstrip(self.prefix or '')] = value 67 | 68 | has_credentials = all(initial.get(k) for k in ('instance_url', 'username', 'password')) 69 | project_safe = False 70 | can_auto_create = False 71 | 72 | # auto_create is not available on new configurations 73 | has_auto_create = 'auto_create' in initial 74 | 75 | if has_credentials: 76 | jira = JIRAClient(initial['instance_url'], initial['username'], initial['password']) 77 | 78 | try: 79 | projects_response = jira.get_projects_list() 80 | except JIRAError as e: 81 | if e.status_code == 401: 82 | has_credentials = False 83 | else: 84 | projects = projects_response.json 85 | if projects: 86 | project_choices = [(p.get('key'), "%s (%s)" % (p.get('name'), p.get('key'))) for p in projects] 87 | project_safe = True 88 | can_auto_create = True 89 | self.fields["default_project"].choices = project_choices 90 | 91 | if project_safe and has_auto_create: 92 | try: 93 | priorities_response = jira.get_priorities() 94 | except JIRAError as e: 95 | if e.status_code == 401: 96 | has_credentials = False 97 | else: 98 | priorities = priorities_response.json 99 | if priorities: 100 | priority_choices = [(p.get('id'), "%s" % (p.get('name'))) for p in priorities] 101 | self.fields["default_priority"].choices = priority_choices 102 | 103 | default_project = initial.get('default_project') 104 | if default_project: 105 | try: 106 | meta = jira.get_create_meta_for_project(default_project) 107 | except JIRAError as e: 108 | if e.status_code == 401: 109 | has_credentials = False 110 | can_auto_create = False 111 | else: 112 | if meta: 113 | self.fields["default_issue_type"].choices = JIRAFormUtils.make_choices(meta["issuetypes"]) 114 | else: 115 | can_auto_create = False 116 | 117 | if not has_credentials: 118 | self.fields['password'].required = True 119 | else: 120 | self.fields['password'].help_text = _("Only enter a new password if you wish to update the stored value") 121 | 122 | if not project_safe: 123 | del self.fields["default_project"] 124 | del self.fields["default_issue_type"] 125 | del self.fields["default_priority"] 126 | del self.fields["ignored_fields"] 127 | 128 | if not can_auto_create: 129 | del self.fields["auto_create"] 130 | 131 | def clean_password(self): 132 | """ 133 | Don't complain if the field is empty and a password is already stored, 134 | no one wants to type a pw in each time they want to change it. 135 | """ 136 | pw = self.cleaned_data.get("password") 137 | if pw: 138 | return pw 139 | else: 140 | old_pw = self.initial.get("password") 141 | if not old_pw: 142 | raise ValidationError("A Password is Required") 143 | return old_pw 144 | 145 | def clean_instance_url(self): 146 | """ 147 | Strip forward slashes off any url passed through the form. 148 | """ 149 | url = self.cleaned_data.get("instance_url") 150 | if url and url[-1:] == "/": 151 | return url[:-1] 152 | else: 153 | return url 154 | 155 | def clean_auto_create(self): 156 | cd = self.cleaned_data 157 | if not cd.get('auto_create'): 158 | return False 159 | if not (cd.get('default_priority') and cd.get('default_issue_type')): 160 | raise ValidationError("Default priority and issue type must be configured.") 161 | return cd['auto_create'] 162 | 163 | def clean(self): 164 | """ 165 | try and build a JIRAClient and make a random call to make sure the 166 | configuration is right. 167 | """ 168 | cd = self.cleaned_data 169 | 170 | missing_fields = False 171 | if not cd.get("instance_url"): 172 | self.errors["instance_url"] = ["Instance URL is required"] 173 | missing_fields = True 174 | if not cd.get("username"): 175 | self.errors["username"] = ["Username is required"] 176 | missing_fields = True 177 | if missing_fields: 178 | raise ValidationError("Missing Fields") 179 | 180 | if cd.get("password"): 181 | jira = JIRAClient(cd["instance_url"], cd["username"], cd["password"]) 182 | try: 183 | sut_response = jira.get_priorities() 184 | except JIRAError as e: 185 | if e.status_code == 403 or e.status_code == 401: 186 | self.errors["username"] = ["Username might be incorrect"] 187 | self.errors["password"] = ["Password might be incorrect"] 188 | raise ValidationError("Unable to connect to JIRA: %s, if you have " 189 | "tried and failed multiple times you may have" 190 | " to enter a CAPTCHA in JIRA to re-enable API" 191 | " logins." % e.status_code) 192 | else: 193 | logging.exception(e) 194 | raise ValidationError("Unable to connect to JIRA: the remote " 195 | "server returned an unhandled %s status " 196 | " code" % e.status_code) 197 | if not sut_response.json: 198 | raise ValidationError("Unable to connect to JIRA: " 199 | "the response did not contain valid JSON, did " 200 | "you enter the correct instance URL?") 201 | 202 | return cd 203 | 204 | # A list of common builtin custom field types for JIRA for easy reference. 205 | CUSTOM_FIELD_TYPES = { 206 | "select": "com.atlassian.jira.plugin.system.customfieldtypes:select", 207 | "textarea": "com.atlassian.jira.plugin.system.customfieldtypes:textarea", 208 | "multiuserpicker": "com.atlassian.jira.plugin.system.customfieldtypes:multiuserpicker" 209 | } 210 | 211 | 212 | class JIRAIssueForm(forms.Form): 213 | project = forms.CharField(widget=forms.HiddenInput()) 214 | issuetype = forms.ChoiceField( 215 | label="Issue Type", 216 | help_text="Changing the issue type will refresh the page with the required form fields.", 217 | required=True 218 | ) 219 | 220 | summary = forms.CharField( 221 | label=_("Issue Summary"), 222 | widget=forms.TextInput(attrs={'class': 'span6'}) 223 | ) 224 | description = forms.CharField( 225 | widget=forms.Textarea(attrs={"class": 'span6'}) 226 | ) 227 | 228 | def __init__(self, *args, **kwargs): 229 | self.ignored_fields = set((kwargs.pop("ignored_fields") or '').split(",")) 230 | initial = kwargs.get("initial") 231 | jira_client = kwargs.pop("jira_client") 232 | project_key = kwargs.pop("project_key") 233 | 234 | priorities = jira_client.get_priorities().json 235 | versions = jira_client.get_versions(project_key).json 236 | 237 | # Returns the metadata the configured JIRA instance requires for 238 | # creating issues for a given project. 239 | # https://developer.atlassian.com/static/rest/jira/5.0.html#id200251 240 | meta = jira_client.get_create_meta(project_key).json 241 | 242 | # Early exit, somehow made it here without properly configuring the 243 | # plugin. 244 | if not meta or not priorities: 245 | super(JIRAIssueForm, self).__init__(*args, **kwargs) 246 | self.errors["__all__"] = [ 247 | "Error communicating with JIRA, Please check your configuration."] 248 | return 249 | 250 | # Early exit #2, no projects available. 251 | if len(meta["projects"]) == 0: 252 | super(JIRAIssueForm, self).__init__(*args, **kwargs) 253 | self.errors["__all__"] = [ 254 | "Error in JIRA configuration, no projects found for user {}.".format(jira_client.username) 255 | ] 256 | return 257 | 258 | # Looking up the project meta by exact key, so it's always the first 259 | # one. 260 | project = meta["projects"][0] 261 | issue_types = project["issuetypes"] 262 | 263 | # check if the issuetype was passed as a GET parameter 264 | self.issue_type = initial.get("issuetype") 265 | if self.issue_type: 266 | matching_type = [t for t in issue_types if t["id"] == self.issue_type] 267 | self.issue_type = matching_type[0] if len(matching_type) > 0 else None 268 | 269 | # still no issue type? just use the first one. 270 | if not self.issue_type: 271 | self.issue_type = issue_types[0] 272 | 273 | # set back after we've played with the inital data 274 | kwargs["initial"] = initial 275 | 276 | # call the super to bind self.fields from the defaults. 277 | super(JIRAIssueForm, self).__init__(*args, **kwargs) 278 | 279 | self.fields["project"].initial = project["id"] 280 | self.fields["issuetype"].choices = JIRAFormUtils.make_choices(issue_types) 281 | 282 | # apply ordering to fields based on some known built-in JIRA fields. 283 | # otherwise weird ordering occurs. 284 | anti_gravity = {"priority": -150, 285 | "fixVersions": -125, 286 | "components": -100, 287 | "security": -50} 288 | 289 | dynamic_fields = self.issue_type.get("fields").keys() 290 | dynamic_fields.sort(key=lambda f: anti_gravity.get(f) or 0) 291 | # build up some dynamic fields based on required shit. 292 | for field in dynamic_fields: 293 | if field in self.fields.keys() or field in [x.strip() for x in self.ignored_fields]: 294 | # don't overwrite the fixed fields for the form. 295 | continue 296 | mb_field = self.build_dynamic_field(self.issue_type["fields"][field]) 297 | if mb_field: 298 | # apply field to form 299 | self.fields[field] = mb_field 300 | 301 | if "priority" in self.fields.keys(): 302 | # whenever priorities are available, put the available ones in the list. 303 | # allowedValues for some reason doesn't pass enough info. 304 | self.fields["priority"].choices = JIRAFormUtils.make_choices(priorities) 305 | 306 | if "fixVersions" in self.fields.keys(): 307 | self.fields["fixVersions"].choices = JIRAFormUtils.make_choices(versions) 308 | 309 | def clean_description(self): 310 | """ 311 | Turn code blocks that are in the stack trace into JIRA code blocks. 312 | """ 313 | desc = self.cleaned_data["description"] 314 | return desc.replace("```", "{code}") 315 | 316 | def clean(self): 317 | """ 318 | The form clean method needs to take advantage of the loaded issue type 319 | fields and meta info so it can determine the format that the datatypes 320 | should render as. 321 | """ 322 | very_clean = self.cleaned_data 323 | 324 | # protect against mis-configured plugin submitting a form without an 325 | # issuetype assigned. 326 | if not very_clean.get("issuetype"): 327 | raise ValidationError("Issue Type is required. Check your plugin configuration.") 328 | 329 | fs = self.issue_type["fields"] 330 | for field in fs.keys(): 331 | f = fs[field] 332 | if field in ["description", "summary"]: 333 | continue 334 | if field in very_clean.keys(): 335 | v = very_clean.get(field) 336 | if v: 337 | schema = f["schema"] 338 | if schema.get("type") == "string" and not schema.get("custom") == CUSTOM_FIELD_TYPES["select"]: 339 | continue # noop 340 | if schema["type"] == "user" or schema.get('items') == "user": 341 | v = {"name": v} 342 | elif schema.get("custom") == CUSTOM_FIELD_TYPES.get("multiuserpicker"): 343 | # custom multi-picker 344 | v = [{"name": v}] 345 | elif schema["type"] == "array" and schema.get('items') != "string": 346 | v = [{"id": vx} for vx in v] 347 | elif schema["type"] == "array" and schema.get('items') == "string": 348 | v = [v] 349 | elif schema.get("custom") == CUSTOM_FIELD_TYPES.get("textarea"): 350 | v = v 351 | elif (schema.get("type") != "string" 352 | or schema.get('items') != "string" 353 | or schema.get("custom") == CUSTOM_FIELD_TYPES.get("select")): 354 | v = {"id": v} 355 | very_clean[field] = v 356 | else: 357 | # We don't want to pass blank data back to the API, so kill 358 | # None values 359 | very_clean.pop(field, None) 360 | 361 | if not (isinstance(very_clean["issuetype"], dict) 362 | and "id" in very_clean["issuetype"]): 363 | # something fishy is going on with this field, working on some JIRA 364 | # instances, and some not. 365 | # testing against 5.1.5 and 5.1.4 does not convert (perhaps is no longer included 366 | # in the projectmeta API call, and would normally be converted in the 367 | # above clean method.) 368 | very_clean["issuetype"] = {"id": very_clean["issuetype"]} 369 | 370 | return very_clean 371 | 372 | def build_dynamic_field(self, field_meta): 373 | """ 374 | Builds a field based on JIRA's meta field information 375 | """ 376 | schema = field_meta["schema"] 377 | # set up some defaults for form fields 378 | fieldtype = forms.CharField 379 | fkwargs = { 380 | 'label': field_meta["name"], 381 | 'required': field_meta["required"], 382 | 'widget': forms.TextInput(attrs={'class': 'span6'}) 383 | } 384 | # override defaults based on field configuration 385 | if (schema["type"] in ["securitylevel", "priority"] 386 | or schema.get("custom") == CUSTOM_FIELD_TYPES.get("select")): 387 | fieldtype = forms.ChoiceField 388 | fkwargs["choices"] = JIRAFormUtils.make_choices(field_meta.get('allowedValues')) 389 | fkwargs["widget"] = forms.Select() 390 | elif schema.get("items") == "user" or schema["type"] == "user": 391 | fkwargs["widget"] = forms.TextInput(attrs={ 392 | 'class': 'user-selector', 393 | 'data-autocomplete': field_meta.get("autoCompleteUrl") 394 | }) 395 | elif schema["type"] in ["timetracking"]: 396 | # TODO: Implement timetracking (currently unsupported alltogether) 397 | return None 398 | elif schema.get("items") in ["worklog", "attachment"]: 399 | # TODO: Implement worklogs and attachments someday 400 | return None 401 | elif schema["type"] == "array" and schema["items"] != "string": 402 | fieldtype = forms.MultipleChoiceField 403 | fkwargs["choices"] = JIRAFormUtils.make_choices(field_meta.get("allowedValues")) 404 | fkwargs["widget"] = forms.SelectMultiple() 405 | 406 | # break this out, since multiple field types could additionally 407 | # be configured to use a custom property instead of a default. 408 | if schema.get("custom"): 409 | if schema["custom"] == CUSTOM_FIELD_TYPES.get("textarea"): 410 | fkwargs["widget"] = forms.Textarea(attrs={'class': 'span6'}) 411 | 412 | return fieldtype(**fkwargs) 413 | -------------------------------------------------------------------------------- /sentry_jira/jira.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import logging 4 | 5 | from requests.exceptions import ConnectionError, RequestException 6 | from sentry.http import build_session 7 | from sentry.utils import json 8 | from sentry.utils.cache import cache 9 | from simplejson.decoder import JSONDecodeError 10 | from BeautifulSoup import BeautifulStoneSoup 11 | from django.utils.datastructures import SortedDict 12 | 13 | log = logging.getLogger(__name__) 14 | 15 | CACHE_KEY = "SENTRY-JIRA-%s-%s" 16 | 17 | 18 | class JIRAError(Exception): 19 | status_code = None 20 | 21 | def __init__(self, response_text, status_code=None): 22 | if status_code is not None: 23 | self.status_code = status_code 24 | self.text = response_text 25 | self.xml = None 26 | if response_text: 27 | try: 28 | self.json = json.loads(response_text, object_pairs_hook=SortedDict) 29 | except (JSONDecodeError, ValueError): 30 | if self.text[:5] == " %s>" % (self.status_code, self.text[:120]) 72 | 73 | @classmethod 74 | def from_response(cls, response): 75 | return cls(response.text, response.status_code) 76 | 77 | 78 | class JIRAClient(object): 79 | """ 80 | The JIRA API Client, so you don't have to. 81 | """ 82 | 83 | PROJECT_URL = '/rest/api/2/project' 84 | META_URL = '/rest/api/2/issue/createmeta' 85 | CREATE_URL = '/rest/api/2/issue' 86 | PRIORITIES_URL = '/rest/api/2/priority' 87 | VERSIONS_URL = '/rest/api/2/project/%s/versions' 88 | USERS_URL = '/rest/api/2/user/assignable/search' 89 | ISSUE_URL = '/rest/api/2/issue/%s' 90 | HTTP_TIMEOUT = 5 91 | 92 | def __init__(self, instance_uri, username, password): 93 | self.instance_url = instance_uri.rstrip('/') 94 | self.username = username 95 | self.password = password 96 | 97 | def get_projects_list(self): 98 | return self.get_cached(self.PROJECT_URL) 99 | 100 | def get_create_meta(self, project): 101 | return self.make_request('get', self.META_URL, {'projectKeys': project, 'expand': 'projects.issuetypes.fields'}) 102 | 103 | def get_create_meta_for_project(self, project): 104 | response = self.get_create_meta(project) 105 | metas = response.json 106 | 107 | # We saw an empty JSON response come back from the API :( 108 | if not metas: 109 | return None 110 | 111 | # XXX(dcramer): document how this is possible, if it even is 112 | if len(metas["projects"]) > 1: 113 | raise JIRAError("More than one project found.") 114 | 115 | try: 116 | return metas["projects"][0] 117 | except IndexError: 118 | return None 119 | 120 | def get_versions(self, project): 121 | return self.get_cached(self.VERSIONS_URL % project) 122 | 123 | def get_priorities(self): 124 | return self.get_cached(self.PRIORITIES_URL) 125 | 126 | def get_users_for_project(self, project): 127 | return self.make_request('get', self.USERS_URL, {'project': project}) 128 | 129 | def create_issue(self, raw_form_data): 130 | data = {'fields': raw_form_data} 131 | return self.make_request('post', self.CREATE_URL, payload=data) 132 | 133 | def get_issue(self, key): 134 | return self.make_request('get', self.ISSUE_URL % key) 135 | 136 | def make_request(self, method, url, payload=None): 137 | if url[:4] != "http": 138 | url = self.instance_url + url 139 | auth = self.username, self.password 140 | session = build_session() 141 | try: 142 | if method == 'get': 143 | r = session.get( 144 | url, params=payload, auth=auth, 145 | verify=False, timeout=self.HTTP_TIMEOUT) 146 | else: 147 | r = session.post( 148 | url, json=payload, auth=auth, 149 | verify=False, timeout=self.HTTP_TIMEOUT) 150 | except ConnectionError as e: 151 | raise JIRAError(unicode(e)) 152 | except RequestException as e: 153 | resp = e.response 154 | if not resp: 155 | raise JIRAError('Internal Error') 156 | if resp.status_code == 401: 157 | raise JIRAUnauthorized.from_response(resp) 158 | raise JIRAError.from_response(resp) 159 | except Exception as e: 160 | logging.error('Error in request to %s: %s', url, e.message[:128], 161 | exc_info=True) 162 | raise JIRAError('Internal error', 500) 163 | 164 | if r.status_code == 401: 165 | raise JIRAUnauthorized.from_response(r) 166 | elif r.status_code < 200 or r.status_code >= 300: 167 | raise JIRAError.from_response(r) 168 | return JIRAResponse.from_response(r) 169 | 170 | def get_cached(self, full_url): 171 | """ 172 | Basic Caching mechanism for requests and responses. It only caches responses 173 | based on URL 174 | TODO: Implement GET attr in cache as well. (see self.create_meta for example) 175 | """ 176 | key = CACHE_KEY % (full_url, self.instance_url) 177 | cached_result = cache.get(key) 178 | if not cached_result: 179 | cached_result = self.make_request('get', full_url) 180 | cache.set(key, cached_result, 60) 181 | return cached_result 182 | -------------------------------------------------------------------------------- /sentry_jira/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsentry/sentry-jira/bca35f402720eac5b8c949b299a9bd7982d0c9be/sentry_jira/models.py -------------------------------------------------------------------------------- /sentry_jira/plugin.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import urllib 3 | import urlparse 4 | 5 | from django.conf import settings 6 | from django.core.urlresolvers import reverse 7 | from django.utils.translation import ugettext_lazy as _ 8 | from sentry.models import GroupMeta, Event 9 | from sentry.plugins.base import JSONResponse 10 | from sentry.plugins.bases.issue import IssuePlugin 11 | from sentry.utils.http import absolute_uri 12 | 13 | from sentry_jira import VERSION as PLUGINVERSION 14 | from sentry_jira.forms import JIRAOptionsForm, JIRAIssueForm 15 | from sentry_jira.jira import JIRAClient, JIRAError 16 | 17 | 18 | class JIRAPlugin(IssuePlugin): 19 | author = "Sentry Team" 20 | author_url = "https://github.com/getsentry/sentry-jira" 21 | version = PLUGINVERSION 22 | 23 | slug = "jira" 24 | title = _("JIRA") 25 | conf_title = title 26 | conf_key = slug 27 | project_conf_form = JIRAOptionsForm 28 | project_conf_template = "sentry_jira/project_conf_form.html" 29 | new_issue_form = JIRAIssueForm 30 | create_issue_template = 'sentry_jira/create_jira_issue.html' 31 | plugin_misconfigured_template = 'sentry_jira/plugin_misconfigured.html' 32 | 33 | # Adding resource links for forward compatibility, still need to integrate 34 | # into existing `project_conf.html` template. 35 | resource_links = [ 36 | ("Documentation", "https://sentry-jira.readthedocs.io/en/latest/"), 37 | ("README", "https://raw.github.com/thurloat/sentry-jira/master/README.rst"), 38 | ("Bug Tracker", "https://github.com/thurloat/sentry-jira/issues"), 39 | ("Source", "http://github.com/thurloat/sentry-jira"), 40 | ] 41 | 42 | def _get_group_description(self, request, group, event): 43 | # XXX: Mostly yanked from bases/issue:IssueTrackingPlugin, 44 | # except change ``` code formatting to {code} 45 | output = [ 46 | absolute_uri(group.get_absolute_url()), 47 | ] 48 | body = self._get_group_body(request, group, event) 49 | if body: 50 | output.extend([ 51 | '', 52 | '{code}', 53 | body, 54 | '{code}', 55 | ]) 56 | return '\n'.join(output) 57 | 58 | def is_configured(self, request, project, **kwargs): 59 | if not self.get_option('default_project', project): 60 | return False 61 | return True 62 | 63 | def get_jira_client(self, project): 64 | instance = self.get_option('instance_url', project) 65 | username = self.get_option('username', project) 66 | pw = self.get_option('password', project) 67 | return JIRAClient(instance, username, pw) 68 | 69 | def get_initial_form_data(self, request, group, event, **kwargs): 70 | initial = { 71 | 'summary': self._get_group_title(request, group, event), 72 | 'description': self._get_group_description(request, group, event), 73 | } 74 | 75 | default_priority = self.get_option('default_priority', group.project) 76 | if default_priority: 77 | initial['priority'] = default_priority 78 | 79 | default_issue_type = self.get_option('default_issue_type', group.project) 80 | 81 | if default_issue_type: 82 | initial['issuetype'] = default_issue_type 83 | 84 | return initial 85 | 86 | def get_new_issue_title(self): 87 | return "Create JIRA Issue" 88 | 89 | def get_issue_label(self, group, issue_id, **kwargs): 90 | return issue_id 91 | 92 | def create_issue(self, request, group, form_data, **kwargs): 93 | """ 94 | Form validation errors recognized server-side raise ValidationErrors, 95 | but when validation errors occur in JIRA they are simply attached to 96 | the form. 97 | """ 98 | jira_client = self.get_jira_client(group.project) 99 | try: 100 | issue_response = jira_client.create_issue(form_data) 101 | except JIRAError as e: 102 | # return some sort of error. 103 | errdict = {"__all__": None} 104 | if e.status_code == 500: 105 | errdict["__all__"] = ["JIRA Internal Server Error."] 106 | elif e.status_code == 400: 107 | for k in e.json["errors"].keys(): 108 | errdict[k] = [e.json["errors"][k]] 109 | errdict["__all__"] = [e.json["errorMessages"]] 110 | else: 111 | errdict["__all__"] = ["Something went wrong, Sounds like a configuration issue: code %s" % e.status_code] 112 | return None, errdict 113 | else: 114 | return issue_response.json.get("key"), None 115 | 116 | def get_issue_url(self, group, issue_id, **kwargs): 117 | instance = self.get_option('instance_url', group.project) 118 | return "%s/browse/%s" % (instance, issue_id) 119 | 120 | def actions(self, request, group, action_list, **kwargs): 121 | issue_key = GroupMeta.objects.get_value(group, '%s:tid' % self.get_conf_key(), None) 122 | if not issue_key: 123 | action_list.append((self.get_new_issue_title(), self.get_url(group))) 124 | else: 125 | action_list.append(('View JIRA: %s' % issue_key, self.get_issue_url(group, issue_key))) 126 | action_list.append(('Update Issue Key', self.get_url(group))) 127 | return action_list 128 | 129 | def view(self, request, group, **kwargs): 130 | """ 131 | Overriding the super to alter the error checking functionality. Method 132 | source had to be copied in an altered, see huge comment below for changes. 133 | """ 134 | has_auth_configured = self.has_auth_configured() 135 | if not (has_auth_configured and self.is_configured(project=group.project, request=request)): 136 | if self.auth_provider: 137 | providers = settings.AUTH_PROVIDERS if hasattr( 138 | settings, 'AUTH_PROVIDERS') else settings.SENTRY_AUTH_PROVIDERS 139 | required_auth_settings = providers[self.auth_provider] 140 | else: 141 | required_auth_settings = None 142 | 143 | return self.render(self.not_configured_template, { 144 | 'title': self.get_title(), 145 | 'project': group.project, 146 | 'has_auth_configured': has_auth_configured, 147 | 'required_auth_settings': required_auth_settings, 148 | }) 149 | 150 | if self.needs_auth(project=group.project, request=request): 151 | return self.render(self.needs_auth_template, { 152 | 'title': self.get_title(), 153 | 'project': group.project, 154 | }) 155 | 156 | issue_key = GroupMeta.objects.get_value(group, '%s:tid' % self.get_conf_key(), None) 157 | if issue_key: 158 | self.update_issue_key(group) 159 | return self.redirect(reverse('sentry-group', args=[ 160 | group.organization.slug, group.project.slug, group.id 161 | ])) 162 | 163 | ####################################################################### 164 | # Auto-complete handler 165 | if request.GET.get("user_autocomplete"): 166 | return self.handle_user_autocomplete(request, group, **kwargs) 167 | ####################################################################### 168 | 169 | prefix = self.get_conf_key() 170 | event = group.get_latest_event() 171 | Event.objects.bind_nodes([event], 'data') 172 | 173 | # Added the ignored_fields to the new_issue_form call 174 | try: 175 | form = self.new_issue_form( 176 | request.POST or None, 177 | initial=self.get_initial_form_data(request, group, event), 178 | jira_client=self.get_jira_client(group.project), 179 | project_key=self.get_option('default_project', group.project), 180 | ignored_fields=self.get_option("ignored_fields", group.project) 181 | ) 182 | except JIRAError as e: 183 | context = { 184 | 'errorMessages': e.json.get('errorMessages', []) if e.json else [], 185 | 'title': self.get_new_issue_title(), 186 | } 187 | return self.render(self.plugin_misconfigured_template, context) 188 | 189 | # to allow the form to be submitted, but ignored so that dynamic fields 190 | # can change if the issuetype is different 191 | if request.POST and request.POST.get("changing_issuetype") == "0": 192 | if form.is_valid(): 193 | issue_id, error = self.create_issue( 194 | group=group, 195 | form_data=form.cleaned_data, 196 | request=request, 197 | ) 198 | if error: 199 | form.errors.update(error) 200 | 201 | # Register field errors which were returned from the JIRA 202 | # API, but were marked as ignored fields in the 203 | # configuration with the global error reporter for the form 204 | ignored_errors = [v for k, v in error.items() 205 | if k in form.ignored_fields] 206 | if len(ignored_errors) > 0: 207 | errs = form.errors['__all__'] 208 | errs.append("Validation Error on ignored field, check" 209 | " your plugin settings.") 210 | errs.extend(ignored_errors) 211 | form.errors['__all__'] = errs 212 | 213 | if form.is_valid(): 214 | GroupMeta.objects.set_value(group, '%s:tid' % prefix, issue_id) 215 | 216 | return self.redirect(group.get_absolute_url()) 217 | else: 218 | for name, field in form.fields.items(): 219 | form.errors[name] = form.error_class() 220 | 221 | context = { 222 | 'form': form, 223 | 'title': self.get_new_issue_title(), 224 | } 225 | 226 | return self.render(self.create_issue_template, context) 227 | 228 | def handle_user_autocomplete(self, request, group, **kwargs): 229 | """ 230 | Auto-complete JSON handler, Tries to handle multiple different types of 231 | response from JIRA as only some of their backend is moved over to use 232 | the JSON REST API, some of the responses come back in XML format and 233 | pre-rendered HTML. 234 | """ 235 | 236 | url = urllib.unquote_plus(request.GET.get("user_autocomplete")) 237 | parsed = list(urlparse.urlsplit(url)) 238 | query = urlparse.parse_qs(parsed[3]) 239 | q = request.GET.get('q') 240 | 241 | jira_client = self.get_jira_client(group.project) 242 | 243 | project = self.get_option('default_project', group.project) 244 | # shortcut case for no input since JIRA's API doesn't return all users 245 | if q == '': 246 | return self._get_all_users_for_project(jira_client, project) 247 | 248 | if "/rest/api/latest/user/" in url: # its the JSON version of the autocompleter 249 | isXML = False 250 | query["username"] = q.encode('utf8') 251 | query.pop('issueKey', False) # some reason JIRA complains if this key is in the URL. 252 | query["project"] = project.encode('utf8') 253 | else: # its the stupid XML version of the API. 254 | isXML = True 255 | query["query"] = q.encode('utf8') 256 | if query.get('fieldName'): 257 | query["fieldName"] = query["fieldName"][0] # for some reason its a list. 258 | 259 | parsed[3] = urllib.urlencode(query) 260 | final_url = urlparse.urlunsplit(parsed) 261 | 262 | autocomplete_response = jira_client.get_cached(final_url) 263 | users = [] 264 | 265 | if isXML: 266 | for userxml in autocomplete_response.xml.findAll("users"): 267 | users.append({ 268 | 'value': userxml.find("name").text, 269 | 'display': userxml.find("html").text, 270 | 'needsRender': False, 271 | 'q': q, 272 | }) 273 | else: 274 | for user in autocomplete_response.json: 275 | users.append({ 276 | 'value': user["name"], 277 | 'display': "%s - %s (%s)" % (user["displayName"], user["emailAddress"], user["name"]), 278 | 'needsRender': True, 279 | 'q': q, 280 | }) 281 | 282 | return JSONResponse({'users': users}) 283 | 284 | def _get_all_users_for_project(self, client, project): 285 | users = [] 286 | for user in client.get_users_for_project(project).json: 287 | users.append({ 288 | 'value': user['name'], 289 | 'display': '%s - %s (%s)' % (user['displayName'], user['emailAddress'], user['name']), 290 | 'needsRender': True, 291 | 'q': '', 292 | }) 293 | return JSONResponse({'users': users}) 294 | 295 | def handle_issue_type_autocomplete(self, request, group): 296 | project = request.GET("project") 297 | jira_client = self.get_jira_client(group.project) 298 | meta = jira_client.get_meta_for_project(project) 299 | 300 | issue_types = [] 301 | for issue_type in meta.json: 302 | issue_types.append({ 303 | 'value': issue_type["name"], 304 | 'display': issue_type["name"], 305 | 'needsRender': True, 306 | 'q': request.GET.get('q') 307 | }) 308 | 309 | return issue_types 310 | 311 | def should_create(self, group, event, is_new): 312 | if not is_new: 313 | return False 314 | 315 | if not self.get_option('auto_create', group.project): 316 | return False 317 | 318 | # XXX(dcramer): Sentry doesn't expect GroupMeta referenced here so we 319 | # need to populate the cache 320 | GroupMeta.objects.populate_cache([group]) 321 | if GroupMeta.objects.get_value(group, '%s:tid' % self.get_conf_key(), None): 322 | return False 323 | 324 | return True 325 | 326 | def post_process(self, group, event, is_new, is_sample, **kwargs): 327 | if not self.should_create(group, event, is_new): 328 | return 329 | 330 | initial = self.get_initial_form_data({}, group, event) 331 | default_priority = initial.get('priority') 332 | default_issue_type = initial.get('issuetype') 333 | default_project = self.get_option('default_project', group.project) 334 | 335 | if not (default_priority and default_issue_type and default_project): 336 | return 337 | 338 | jira_client = self.get_jira_client(group.project) 339 | meta = jira_client.get_create_meta(default_project).json 340 | if not meta or len(meta["projects"]) == 0: 341 | return 342 | project = meta["projects"][0] 343 | 344 | post_data = { 345 | 'project': {'id': project['id']}, 346 | 'summary': initial['summary'], 347 | 'description': initial['description'], 348 | } 349 | 350 | interface = event.interfaces.get('sentry.interfaces.Exception') 351 | 352 | if interface: 353 | post_data['description'] += "\n{code}%s{code}" % interface.get_stacktrace(event, system_frames=False, max_frames=settings.SENTRY_MAX_STACKTRACE_FRAMES) 354 | 355 | post_data['priority'] = {'id': default_priority} 356 | post_data['issuetype'] = {'id': default_issue_type} 357 | 358 | issue_id, error = self.create_issue( 359 | request={}, 360 | group=group, 361 | form_data=post_data) 362 | 363 | if issue_id and not error: 364 | prefix = self.get_conf_key() 365 | GroupMeta.objects.set_value(group, '%s:tid' % prefix, issue_id) 366 | 367 | elif error: 368 | logging.exception("Error creating JIRA ticket: %s" % error) 369 | 370 | def update_issue_key(self, group): 371 | gm = GroupMeta.objects.get(group=group, key='%s:tid' % self.get_conf_key()) 372 | client = self.get_jira_client(group.project) 373 | resp = client.get_issue(gm.value) 374 | if resp.json['key'] != gm.value: 375 | gm.update(value=resp.json['key']) 376 | 377 | def update_issue_keys(self, project): 378 | groupmetas = GroupMeta.objects.filter(key='%s:tid' % self.get_conf_key()) 379 | client = self.get_jira_client(project) 380 | for gm in groupmetas: 381 | issue_key = gm.value 382 | resp = client.get_issue(issue_key) 383 | gm.update(value=resp.json['key']) 384 | -------------------------------------------------------------------------------- /sentry_jira/templates/sentry_jira/create_jira_issue.html: -------------------------------------------------------------------------------- 1 | {% extends "sentry/plugins/bases/issue/create_issue.html" %} 2 | 3 | {% block main %} 4 |
5 | {{ block.super }} 6 |
7 | {% endblock %} 8 | 9 | {% block meta %} 10 | {{ block.super }} 11 | 54 | {% endblock %} 55 | -------------------------------------------------------------------------------- /sentry_jira/templates/sentry_jira/plugin_misconfigured.html: -------------------------------------------------------------------------------- 1 | {% extends "sentry/plugins/bases/issue/create_issue.html" %} 2 | 3 | {% block main %} 4 |

There is an error with the Jira configuration stored for your project.

5 |
    6 | {% if errorMessages %} 7 | {% for message in errorMessages %} 8 |
  • {{ message }}
  • 9 | {% endfor %} 10 | {% else %} 11 |
  • An unknown error occurred while communicating with Jira.
  • 12 | {% endif %} 13 |
14 |

You may wish to double check your configuration.

15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /sentry_jira/templates/sentry_jira/project_conf_form.html: -------------------------------------------------------------------------------- 1 | {% load i18n crispy_forms_filters %} 2 | 3 |
4 | If you change login credentials or the default project you will need to save changes in order to populate new options. 5 |
6 | 7 |
8 | {% csrf_token %} 9 | 10 | 11 | {{ form|crispy }} 12 |
13 | 14 |
15 |
16 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | 4 | [pytest] 5 | python_files = test*.py 6 | addopts = --tb=native -p no:doctest 7 | norecursedirs = bin dist docs htmlcov script hooks node_modules .* {args} 8 | 9 | [flake8] 10 | ignore = F999,E501,E128,E124,E402,W503,E731,C901 11 | max-line-length = 100 12 | exclude = .tox,.git,*/migrations/*,node_modules/*,docs/* 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup, find_packages 3 | 4 | install_requires = [ 5 | 'BeautifulSoup>=3.2.1' 6 | ] 7 | 8 | tests_require = [ 9 | 'exam', 10 | 'flake8>=2.0,<2.1', 11 | 'responses', 12 | 'sentry<8.7', 13 | ] 14 | 15 | setup( 16 | name='sentry-jira', 17 | version='0.12.0.dev0', 18 | author='Adam Thurlow', 19 | author_email='thurloat@gmail.com', 20 | url='http://github.com/thurloat/sentry-jira', 21 | description='A Sentry extension which creates JIRA issues from sentry events.', 22 | long_description=open('README.rst').read(), 23 | license='BSD', 24 | packages=find_packages(), 25 | install_requires=install_requires, 26 | extras_require={ 27 | 'tests': tests_require, 28 | }, 29 | entry_points={ 30 | 'sentry.apps': [ 31 | 'sentry_jira = sentry_jira', 32 | ], 33 | 'sentry.plugins': [ 34 | 'sentry_jira = sentry_jira.plugin:JIRAPlugin' 35 | ], 36 | }, 37 | include_package_data=True, 38 | zip_safe=False, 39 | classifiers=[ 40 | 'Intended Audience :: Developers', 41 | 'Intended Audience :: System Administrators', 42 | 'Operating System :: OS Independent', 43 | 'License :: OSI Approved :: BSD License', 44 | 'Programming Language :: Python', 45 | 'Framework :: Django', 46 | 'Topic :: Software Development' 47 | ], 48 | ) 49 | -------------------------------------------------------------------------------- /tests/sentry_jira/test_plugin.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import responses 4 | 5 | from django.core.urlresolvers import reverse 6 | from exam import fixture 7 | from sentry.models import GroupMeta 8 | from sentry.plugins import register, unregister 9 | from sentry.testutils import TestCase 10 | from sentry.utils import json 11 | 12 | from sentry_jira.plugin import JIRAPlugin 13 | 14 | 15 | def jira_mock(): 16 | # TODO(dcramer): we cannot currently assert on auth, which is pretty damned 17 | # important 18 | 19 | priority_response = [ 20 | { 21 | 'self': 'https://getsentry.atlassian.net/rest/api/2/priority/1', 22 | 'statusColor': '#d04437', 23 | 'description': 'This problem will block progress.', 24 | 'iconUrl': 'https://getsentry.atlassian.net/images/icons/priorities/highest.svg', 25 | 'name': 'Highest', 26 | 'id': '1' 27 | }, 28 | ] 29 | 30 | project_response = [ 31 | { 32 | 'expand': 'description,lead,url,projectKeys', 33 | 'self': 'https://getsentry.atlassian.net/rest/api/2/project/10000', 34 | 'id': '10000', 35 | 'key': 'SEN', 36 | 'name': 'Sentry', 37 | 'avatarUrls': { 38 | '48x48': 'https://getsentry.atlassian.net/secure/projectavatar?avatarId=10324', 39 | '24x24': 'https://getsentry.atlassian.net/secure/projectavatar?size=small&avatarId=10324', 40 | '16x16': 'https://getsentry.atlassian.net/secure/projectavatar?size=xsmall&avatarId=10324', 41 | '32x32': 'https://getsentry.atlassian.net/secure/projectavatar?size=medium&avatarId=10324' 42 | }, 43 | 'projectTypeKey': 'software' 44 | }, 45 | ] 46 | 47 | # TODO(dcramer): find one of these 48 | versions_response = [] 49 | 50 | create_meta_response = { 51 | 'expand': 'projects', 52 | 'projects': [ 53 | { 54 | 'expand': 'issuetypes', 55 | 'self': 'https://getsentry.atlassian.net/rest/api/2/project/10000', 56 | 'id': '10000', 57 | 'key': 'SEN', 58 | 'name': 'Sentry', 59 | 'avatarUrls': { 60 | '48x48': 'https://getsentry.atlassian.net/secure/projectavatar?avatarId=10324', 61 | '24x24': 'https://getsentry.atlassian.net/secure/projectavatar?size=small&avatarId=10324', 62 | '16x16': 'https://getsentry.atlassian.net/secure/projectavatar?size=xsmall&avatarId=10324', 63 | '32x32': 'https://getsentry.atlassian.net/secure/projectavatar?size=medium&avatarId=10324' 64 | }, 65 | 'issuetypes': [ 66 | { 67 | 'self': 'https://getsentry.atlassian.net/rest/api/2/issuetype/10002', 68 | 'id': '10002', 69 | 'description': 'A task that needs to be done.', 70 | 'iconUrl': 'https://getsentry.atlassian.net/secure/viewavatar?size=xsmall&avatarId=10318&avatarType=issuetype', 71 | 'name': 'Task', 72 | 'subtask': False, 73 | 'expand': 'fields', 74 | 'fields': { 75 | 'summary': { 76 | 'required': True, 77 | 'schema': { 78 | 'type': 'string', 79 | 'system': 'summary', 80 | }, 81 | 'name': 'Summary', 82 | 'hasDefaultValue': False, 83 | 'operations': ['set'] 84 | }, 85 | 'issuetype': { 86 | 'required': True, 87 | 'schema': { 88 | 'type': 'issuetype', 89 | 'system': 'issuetype' 90 | }, 91 | 'name': 'Issue Type', 92 | 'hasDefaultValue': False, 93 | 'operations': [], 94 | 'allowedValues': [ 95 | { 96 | 'self': 'https://getsentry.atlassian.net/rest/api/2/issuetype/10002', 97 | 'id': '10002', 98 | 'description': 'A task that needs to be done.', 99 | 'iconUrl': 'https://getsentry.atlassian.net/secure/viewavatar?size=xsmall&avatarId=10318&avatarType=issuetype', 100 | 'name': 'Task', 101 | 'subtask': False, 102 | 'avatarId': 10318, 103 | } 104 | ] 105 | }, 106 | 'components': { 107 | 'required': False, 108 | 'schema': { 109 | 'type': 'array', 110 | 'items': 'component', 111 | 'system': 'components', 112 | }, 113 | 'name': 'Component/s', 114 | 'hasDefaultValue': False, 115 | 'operations': ['add', 'set', 'remove'], 116 | 'allowedValues': [], 117 | }, 118 | 'description': { 119 | 'required': False, 120 | 'schema': { 121 | 'type': 'string', 122 | 'system': 'description', 123 | }, 124 | 'name': 'Description', 125 | 'hasDefaultValue': False, 126 | 'operations': ['set'] 127 | }, 128 | 'project': { 129 | 'required': True, 130 | 'schema': { 131 | 'type': 'project', 132 | 'system': 'project' 133 | }, 134 | 'name': 'Project', 135 | 'hasDefaultValue': False, 136 | 'operations': ['set'], 137 | 'allowedValues': [ 138 | { 139 | 'self': 'https://getsentry.atlassian.net/rest/api/2/project/10000', 140 | 'id': '10000', 141 | 'key': 'SEN', 142 | 'name': 'Sentry', 143 | 'avatarUrls': { 144 | '48x48': 'https://getsentry.atlassian.net/secure/projectavatar?avatarId=10324', 145 | '24x24': 'https://getsentry.atlassian.net/secure/projectavatar?size=small&avatarId=10324', 146 | '16x16': 'https://getsentry.atlassian.net/secure/projectavatar?size=xsmall&avatarId=10324', 147 | '32x32': 'https://getsentry.atlassian.net/secure/projectavatar?size=medium&avatarId=10324', 148 | } 149 | } 150 | ] 151 | }, 152 | 'reporter': { 153 | 'required': True, 154 | 'schema': { 155 | 'type': 'user', 156 | 'system': 'reporter', 157 | }, 158 | 'name': 'Reporter', 159 | 'autoCompleteUrl': 'https://getsentry.atlassian.net/rest/api/latest/user/search?username=', 160 | 'hasDefaultValue': False, 161 | 'operations': ['set'], 162 | }, 163 | 'fixVersions': { 164 | 'required': False, 165 | 'schema': { 166 | 'type': 'array', 167 | 'items': 'version', 168 | 'system': 'fixVersions', 169 | }, 170 | 'name': 'Fix Version/s', 171 | 'hasDefaultValue': False, 172 | 'operations': ['set', 'add', 'remove'], 173 | 'allowedValues': [], 174 | }, 175 | 'priority': { 176 | 'required': False, 177 | 'schema': { 178 | 'type': 'priority', 179 | 'system': 'priority', 180 | }, 181 | 'name': 'Priority', 182 | 'hasDefaultValue': True, 183 | 'operations': ['set'], 184 | 'allowedValues': [ 185 | { 186 | 'self': 'https://getsentry.atlassian.net/rest/api/2/priority/1', 187 | 'iconUrl': 'https://getsentry.atlassian.net/images/icons/priorities/highest.svg', 188 | 'name': 'Highest', 189 | 'id': '1' 190 | }, 191 | ] 192 | }, 193 | 'customfield_10003': { 194 | 'required': False, 195 | 'schema': { 196 | 'type': 'array', 197 | 'items': 'string', 198 | 'custom': 'com.pyxis.greenhopper.jira:gh-sprint', 199 | 'customId': 10003, 200 | }, 201 | 'name': 'Sprint', 202 | 'hasDefaultValue': False, 203 | 'operations': ['set'] 204 | }, 205 | 'labels': { 206 | 'required': False, 207 | 'schema': { 208 | 'type': 'array', 209 | 'items': 'string', 210 | 'system': 'labels', 211 | }, 212 | 'name': 'Labels', 213 | 'autoCompleteUrl': 'https://getsentry.atlassian.net/rest/api/1.0/labels/suggest?query=', 214 | 'hasDefaultValue': False, 215 | 'operations': ['add', 'set', 'remove'], 216 | }, 217 | 'attachment': { 218 | 'required': False, 219 | 'schema': { 220 | 'type': 'array', 221 | 'items': 'attachment', 222 | 'system': 'attachment', 223 | }, 224 | 'name': 'Attachment', 225 | 'hasDefaultValue': False, 226 | 'operations': [], 227 | }, 228 | 'assignee': { 229 | 'required': False, 230 | 'schema': { 231 | 'type': 'user', 232 | 'system': 'assignee', 233 | }, 234 | 'name': 'Assignee', 235 | 'autoCompleteUrl': 'https://getsentry.atlassian.net/rest/api/latest/user/assignable/search?issueKey=null&username=', 236 | 'hasDefaultValue': False, 237 | 'operations': ['set'], 238 | } 239 | } 240 | } 241 | ] 242 | } 243 | ] 244 | } 245 | 246 | mock = responses.RequestsMock(assert_all_requests_are_fired=False) 247 | mock.add(mock.GET, 'https://getsentry.atlassian.net/rest/api/2/priority', 248 | json=priority_response) 249 | mock.add(mock.GET, 'https://getsentry.atlassian.net/rest/api/2/project', 250 | json=project_response) 251 | mock.add(mock.GET, 'https://getsentry.atlassian.net/rest/api/2/project/SEN/versions', 252 | json=versions_response) 253 | # TODO(dcramer): validate input params 254 | # create_meta_params = { 255 | # 'projectKeys': 'SEN', 256 | # 'expand': 'projects.issuetypes.fields' 257 | # } 258 | mock.add(mock.GET, 'https://getsentry.atlassian.net/rest/api/2/issue/createmeta', 259 | json=create_meta_response) 260 | mock.add(mock.POST, 'https://getsentry.atlassian.net/rest/api/2/issue', 261 | json={'key': 'SEN-1234'}) 262 | return mock 263 | 264 | 265 | class JIRAPluginTest(TestCase): 266 | plugin_cls = JIRAPlugin 267 | 268 | def setUp(self): 269 | super(JIRAPluginTest, self).setUp() 270 | register(self.plugin_cls) 271 | self.group = self.create_group(message='Hello world', culprit='foo.bar') 272 | self.event = self.create_event(group=self.group, message='Hello world') 273 | 274 | def tearDown(self): 275 | unregister(self.plugin_cls) 276 | super(JIRAPluginTest, self).tearDown() 277 | 278 | @fixture 279 | def plugin(self): 280 | return self.plugin_cls() 281 | 282 | @fixture 283 | def action_path(self): 284 | project = self.project 285 | return reverse('sentry-group-plugin-action', args=[ 286 | project.organization.slug, project.slug, self.group.id, self.plugin.slug, 287 | ]) 288 | 289 | @fixture 290 | def configure_path(self): 291 | project = self.project 292 | return reverse('sentry-configure-project-plugin', args=[ 293 | project.organization.slug, project.slug, self.plugin.slug, 294 | ]) 295 | 296 | def test_create_issue_renders(self): 297 | project = self.project 298 | plugin = self.plugin 299 | 300 | plugin.set_option('username', 'foo', project) 301 | plugin.set_option('password', 'bar', project) 302 | plugin.set_option('instance_url', 'https://getsentry.atlassian.net', project) 303 | plugin.set_option('default_project', 'SEN', project) 304 | 305 | self.login_as(self.user) 306 | 307 | with jira_mock(), self.options({'system.url-prefix': 'http://example.com'}): 308 | response = self.client.get(self.action_path) 309 | 310 | assert response.status_code == 200, vars(response) 311 | self.assertTemplateUsed(response, 'sentry_jira/create_jira_issue.html') 312 | 313 | def test_create_issue_saves(self): 314 | project = self.project 315 | plugin = self.plugin 316 | 317 | plugin.set_option('username', 'foo', project) 318 | plugin.set_option('password', 'bar', project) 319 | plugin.set_option('instance_url', 'https://getsentry.atlassian.net', project) 320 | plugin.set_option('default_project', 'SEN', project) 321 | 322 | self.login_as(self.user) 323 | 324 | with jira_mock() as mock: 325 | response = self.client.post(self.action_path, { 326 | 'changing_issuetype': '0', 327 | 'issuetype': '10002', 328 | 'priority': '1', 329 | 'customfield_10003': '', 330 | 'project': '10000', 331 | 'description': 'A ticket description', 332 | 'summary': 'A ticket summary', 333 | 'assignee': 'assignee', 334 | 'reporter': 'reporter', 335 | }) 336 | 337 | assert response.status_code == 302, dict(response.context['form'].errors) 338 | assert GroupMeta.objects.get(group=self.group, key='jira:tid').value == 'SEN-1234' 339 | 340 | jira_request = mock.calls[-1].request 341 | assert jira_request.url == 'https://getsentry.atlassian.net/rest/api/2/issue' 342 | assert json.loads(jira_request.body) == { 343 | "fields": { 344 | "priority": {"id": "1"}, 345 | "description": "A ticket description", 346 | "reporter": {"name": "reporter"}, 347 | "summary": "A ticket summary", 348 | "project": {"id": "10000"}, 349 | "assignee": {"name": "assignee"}, 350 | "issuetype": {"id": "10002"}, 351 | }, 352 | } 353 | 354 | @responses.activate 355 | def test_create_issue_with_fetch_errors(self): 356 | project = self.project 357 | plugin = self.plugin 358 | 359 | plugin.set_option('username', 'foo', project) 360 | plugin.set_option('password', 'bar', project) 361 | plugin.set_option('instance_url', 'https://getsentry.atlassian.net', project) 362 | plugin.set_option('default_project', 'SEN', project) 363 | 364 | self.login_as(self.user) 365 | 366 | response = self.client.get(self.action_path) 367 | 368 | assert response.status_code == 200, vars(response) 369 | self.assertTemplateUsed(response, 'sentry_jira/plugin_misconfigured.html') 370 | 371 | def test_configure_renders(self): 372 | self.login_as(self.user) 373 | with jira_mock(): 374 | response = self.client.get(self.configure_path) 375 | assert response.status_code == 200 376 | self.assertTemplateUsed(response, 'sentry_jira/project_conf_form.html') 377 | assert '' in response.content 378 | assert 'default_project' not in response.content 379 | assert 'default_issue_type' not in response.content 380 | assert 'default_priority' not in response.content 381 | assert 'ignored_fields' not in response.content 382 | 383 | def test_configure_without_credentials(self): 384 | self.login_as(self.user) 385 | with jira_mock(): 386 | response = self.client.post(self.configure_path, { 387 | 'plugin': 'jira', 388 | 'jira-username': 'foo', 389 | 'jira-password': 'bar', 390 | 'jira-instance_url': 'https://getsentry.atlassian.net', 391 | }) 392 | assert response.status_code == 302 393 | 394 | project = self.project 395 | plugin = self.plugin 396 | 397 | assert plugin.get_option('username', project) == 'foo' 398 | assert plugin.get_option('password', project) == 'bar' 399 | assert plugin.get_option('instance_url', project) == 'https://getsentry.atlassian.net' 400 | 401 | def test_configure_renders_with_credentials(self): 402 | project = self.project 403 | plugin = self.plugin 404 | 405 | plugin.set_option('username', 'foo', project) 406 | plugin.set_option('password', 'bar', project) 407 | plugin.set_option('instance_url', 'https://getsentry.atlassian.net', project) 408 | 409 | self.login_as(self.user) 410 | 411 | with jira_mock(): 412 | response = self.client.get(self.configure_path, { 413 | 'plugin': 'jira', 414 | }) 415 | assert response.status_code == 200 416 | self.assertTemplateUsed(response, 'sentry_jira/project_conf_form.html') 417 | 418 | assert 'ignored_fields' in response.content 419 | --------------------------------------------------------------------------------