-
Notifications
You must be signed in to change notification settings - Fork 0
/
app.py
312 lines (268 loc) · 11.8 KB
/
app.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
#!/usr/bin/python3
"""
This can be used as a template to start developing a webhook service for
use with Jira Service Desk.
When testing, if not running on the same server as Service Desk, remember
to use:
flask run --host=0.0.0.0
Note that the CSRF protection is not supported or used because there isn't
a mechanism available to get Jira/Service Desk to generate the necessary
headers.
"""
import importlib
import os
import sys
import traceback
import sentry_sdk
from flask import Flask, request
from sentry_sdk.integrations.flask import FlaskIntegration
import shared.globals
import shared.sentry_config
import shared.shared_sd as shared_sd
UNEXPECTED = "An unexpected error occurred in the automation:\n%s"
# This must stay before the Flask initialisation.
if shared.sentry_config.SENTRY_DSN is not None:
sentry_sdk.init(
dsn=shared.sentry_config.SENTRY_DSN,
integrations=[FlaskIntegration()],
release="sd-webhook-framework@1.0.0"
)
APP = Flask(__name__)
@APP.route('/', methods=['GET'])
def hello_world():
""" A simple test to confirm that the code is running properly. """
return "Hello, world!"
@APP.route('/test-sentry', methods=['GET'])
def test_sentry():
""" A simple test to provoke reporting back to Sentry. """
_ = 1/0
@APP.route('/create', methods=['POST'])
def create():
""" Triggered when a ticket is created. """
handler = initialise(False)
if handler is None:
print(f"{shared.globals.TICKET} /create: no handler")
else:
print(f"{shared.globals.TICKET} /create: {handler.CAPABILITIES}")
if handler is not None and "CREATE" in handler.CAPABILITIES:
try:
print(f"{shared.globals.TICKET} calling create handler", file=sys.stderr)
save_ticket_data(handler)
handler.create(shared.globals.TICKET_DATA)
except Exception: # pylint: disable=broad-except
shared_sd.post_comment(UNEXPECTED % traceback.format_exc(), False)
return ""
@APP.route('/comment', methods=['POST'])
def comment():
""" Triggered when a non-automation comment is added to a ticket. """
handler = initialise(True)
if handler is None:
print(f"{shared.globals.TICKET} /comment: no handler")
else:
print(f"{shared.globals.TICKET} /comment: {handler.CAPABILITIES}")
if (handler is not None and
"COMMENT" in handler.CAPABILITIES and
not shared_sd.automation_triggered_comment(shared.globals.TICKET_DATA)):
try:
print(f"{shared.globals.TICKET} calling comment handler", file=sys.stderr)
save_ticket_data(handler)
handler.comment(shared.globals.TICKET_DATA)
except Exception: # pylint: disable=broad-except
shared_sd.post_comment(UNEXPECTED % traceback.format_exc(), False)
return ""
@APP.route('/org-change', methods=['POST'])
def org_change():
""" Triggered when the organizations change for a ticket. """
handler = initialise(False)
if handler is None:
print(f"{shared.globals.TICKET} /org-change: no handler")
else:
print(f"{shared.globals.TICKET} /org-change: {handler.CAPABILITIES}")
if handler is not None and "ORGCHANGE" in handler.CAPABILITIES:
try:
print(f"{shared.globals.TICKET} calling org change handler", file=sys.stderr)
save_ticket_data(handler)
handler.org_change(shared.globals.TICKET_DATA)
except Exception: # pylint: disable=broad-except
shared_sd.post_comment(UNEXPECTED % traceback.format_exc(), False)
return ""
@APP.route('/transition', methods=['POST'])
def ticket_transition():
""" Triggered by SD Automation on transition. """
handler = initialise(False)
if handler is None:
print(f"{shared.globals.TICKET} /transition: no handler")
else:
print(f"{shared.globals.TICKET} /transition: {handler.CAPABILITIES}")
if handler is not None and "TRANSITION" in handler.CAPABILITIES:
try:
print(f"{shared.globals.TICKET} calling transition handler", file=sys.stderr)
save_ticket_data(handler)
new_status = shared.globals.TICKET_DATA["fields"]["status"]["name"]
handler.transition(new_status, shared.globals.TICKET_DATA)
except Exception: # pylint: disable=broad-except
shared_sd.post_comment(UNEXPECTED % traceback.format_exc(), False)
return ""
@APP.route('/jira-hook', methods=['POST'])
def jira_hook():
""" Triggered when Jira itself (not Service Desk) fires a webhook event. """
handler = initialise(False)
if handler is None:
print(f"{shared.globals.TICKET} /jira-hook: no handler")
else:
print(f"{shared.globals.TICKET} /jira-hook: {handler.CAPABILITIES}")
if handler is not None:
# Jira hook can be triggered for any sort of update to a ticket
# so we need to look at what has changed. In *theory*, it is
# possible for both assignee and status to change so we need
# to check and call for both.
#
# Note that we pass request.json and not TICKET_DATA because the
# latter is literally just the ticket data but trigger_is_X needs
# the original body in order to decide what triggered the webhook.
assignee_result, assignee_to = shared_sd.\
trigger_is_assignment(request.json)
status_result, status_to = shared_sd.\
trigger_is_transition(request.json)
try:
if got_handled_jira_event(handler.CAPABILITIES, status_result, assignee_result):
save_ticket_data(handler)
if is_transition(handler.CAPABILITIES, status_result):
print(f"Calling transition handler for {shared.globals.TICKET}", file=sys.stderr)
handler.transition(status_to, shared.globals.TICKET_DATA)
if is_assignment(handler.CAPABILITIES, assignee_result):
print(f"Calling assignment handler for {shared.globals.TICKET}", file=sys.stderr)
handler.assignment(assignee_to, shared.globals.TICKET_DATA)
if is_generic_jira(handler.CAPABILITIES, status_result, assignee_result):
print(f"Calling Jira hook handler for {shared.globals.TICKET}", file=sys.stderr)
# A generic handler might need to know what has changed so extract the change log
# if there is one.
changelog = request.json["changelog"] if "changelog" in request.json else None
handler.jira_hook(shared.globals.TICKET_DATA, changelog)
except Exception: # pylint: disable=broad-except
shared_sd.post_comment(UNEXPECTED % traceback.format_exc(), False)
return ""
def got_handled_jira_event(capabilities, status_result, assignee_result):
""" Central checker for Jira webhook handling """
return is_transition(capabilities, status_result) or \
is_assignment(capabilities, assignee_result) or \
is_generic_jira(capabilities, status_result, assignee_result)
def is_transition(capabilities, status_result):
""" Check that we're handling a transition """
return "TRANSITION" in capabilities and status_result
def is_assignment(capabilities, assignee_result):
""" Check that we're handling an assignment """
return "ASSIGNMENT" in capabilities and assignee_result
def is_generic_jira(capabilities, status_result, assignee_result):
""" Check that we're handling a generic Jira trigger """
return "JIRAHOOK" in capabilities and not status_result and not assignee_result
def save_ticket_data(handler):
""" Save the ticket data to the ticket. """
save_data = False
try:
save_data = handler.SAVE_TICKET_DATA
except Exception: # pylint: disable=broad-except
pass
if save_data:
shared_sd.save_ticket_data_as_attachment(shared.globals.TICKET_DATA)
def handler_filename(dir_path, reqtype):
""" Determine the handler filename for this request type. """
if reqtype is not None:
#
# Is there a handler in the configuration for this request type?
if ("handlers" in shared.globals.CONFIGURATION and
reqtype in shared.globals.CONFIGURATION["handlers"]):
return shared.globals.CONFIGURATION["handlers"][reqtype]
#
# Is there a file with the right format name?
filename = f"rt{reqtype}"
if os.path.exists(f"{dir_path}/{filename}.py"):
return filename
#
# Is there a wildcard?
if ("handlers" in shared.globals.CONFIGURATION and
"*" in shared.globals.CONFIGURATION["handlers"]):
return shared.globals.CONFIGURATION["handlers"]["*"]
#
# Nothing doing.
return None
def initialise_handler():
""" Load the Python code handling this request type if possible. """
print("initialise_handler")
#
# Check that the handler directory exists.
dir_path = os.path.dirname(os.path.abspath(__file__)) + "/rt_handlers"
if not os.path.isdir(dir_path):
print("ERROR! Missing rt_handlers directory", file=sys.stderr)
return None
#
# Work out what the request type number is.
try:
reqtype = shared_sd.ticket_request_type(shared.globals.TICKET_DATA)
except shared_sd.CustomFieldLookupFailure as caught_error:
shared_sd.post_comment(
f"{str(caught_error)}. Please check the configuration and logs.",
False
)
return None
if reqtype is None:
print("Unable to determine request type")
return None
#
# Work out which handler to use, if there is one.
filename = handler_filename(dir_path, reqtype)
if filename is not None:
if dir_path not in sys.path:
sys.path.insert(0, dir_path)
if os.path.exists(f"{dir_path}/{filename}.py"):
return import_handler(dir_path, filename)
print(
f"ERROR! Cannot find '{dir_path}/{filename}.py'",
file=sys.stderr)
return None
print(
f"Called to handle {reqtype} but no handler found.",
file=sys.stderr)
return None
def import_handler(dir_path, filename):
""" Load the desired handler. """
print(f"Loading '{dir_path}/{filename}.py' as handler for {shared.globals.TICKET}",
file=sys.stderr)
handler = importlib.import_module(filename)
# Make sure that the handler has a CAPABILITIES block
try:
_ = handler.CAPABILITIES
except Exception: # pylint: disable=broad-except
print(
"Handler is missing CAPABILITIES definition",
file=sys.stderr)
handler = None
return handler
def initialise(action_is_comment: bool):
""" Initialise code and variables for this event. """
try:
shared.globals.initialise_config()
shared.globals.initialise_ticket_data(request.json)
shared.globals.initialise_sd_auth()
shared.globals.initialise_shared_sd()
print("shared.globals initialisation complete")
if shared.globals.REPORTER is None:
# Need to ensure that we don't react to ourselves posting the comment below.
if action_is_comment:
latest_comment = shared_sd.get_latest_comment()
if shared_sd.user_is_bot(latest_comment["author"]):
print("Anonymously submitted ticket but ignoring automation-posted comment")
return None
print("Anonymously submitted ticket - aborting")
shared_sd.post_comment(
"It is not possible to action this request. It has been submitted anonymously. "
"Please sign in to Service Desk and try again.",
True
)
shared_sd.resolve_ticket("Declined")
return None
except Exception as exc: # pylint: disable=broad-except
print("An exception has occurred during the initialisation")
print(exc)
return None
return initialise_handler()