Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PR adds Jira as an option #61

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
repos:
- repo: https://github.com/ambv/black
rev: stable
rev: 21.9b0
hooks:
- id: black
37 changes: 28 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ Here is an example of a conversation you can have with this bot:
- [Rasa Helpdesk Assistant Example](#rasa-helpdesk-assistant-example)
- [Setup](#setup)
- [Install the dependencies](#install-the-dependencies)
- [Configure endpoints](#configure-endpoints)
- [Optional: Connect to a ServiceNow instance](#optional-connect-to-a-servicenow-instance)
- [Optional: Connect to a Jira Instance](#optional-connect-to-a-jira-instance)
- [Running the bot](#running-the-bot)
- [Things you can ask the bot](#things-you-can-ask-the-bot)
- [Example conversations](#example-conversations)
Expand Down Expand Up @@ -49,13 +51,15 @@ pre-commit install
> With pre-commit installed, the `black` and `doctoc` hooks will run on every `git commit`.
> If any changes are made by the hooks, you will need to re-add changed files and re-commit your changes.

### Optional: Connect to a ServiceNow instance
### Configure endpoints

You can run this bot without connecting to a ServiceNow instance, in which case it will
You can run this bot without connecting to a ServiceNow or Jira instance, in which case it will
send responses without creating an incident or checking the actual status of one.
To run the bot without connecting ServiceNow,
you don't need to change anything in `actions/snow_credentials.yml`; `localmode` should already be set
to `true`
To run the bot without connecting to ServiceNow or Jira you don't need to change anything
in `endpoints.yml`; `helpdesk: mode:` should already be set to `local`.

When set to `local` (default in the code), it will just take all the data in and message out the information that would normally be sent.
### Optional: Connect to a ServiceNow instance

If you do want to connect to ServiceNow, you can get your own free Developer instance
to test this with [here](https://developer.servicenow.com/app.do#!/home)
Expand All @@ -68,7 +72,22 @@ To connect to your ServiceNow developer instance, configure the following in `ac

- `snow_pw` - The password of the service account for the ServiceNow developer instance

- `localmode` - Whether the action server should **not** try to reach out to a `snow_instance` based on the credentials in `actions/snow_credentials.yml`. When set to `True` (default in the code), it will just take all the data in and message out the information that would normally be sent.
In `endpoints.yml` configure `helpdesk: mode:` to `snow`.

### Optional: Connect to a Jira Instance

If you want to connect to Jira Service Management, you can setup a free limited instance [here](https://www.atlassian.com/software/jira/service-management/free)

To connect to your Jira instance, configure the following in `actions/jira_credentials.yml`:
- `jira_user` - This is the email of the admin account for the Jira instance

- `jira_token` - This is the API Token for your Jira instance. Get it [here](https://id.atlassian.com/manage-profile/security/api-tokens)

- `jira_url` - The url for the Jira instance. Should be in the form https://your-domain.atlassian.net

- `project: id` - The id for your project. Can be found using the API https://your-domain.atlassian.net/rest/api/3/project

In `endpoints.yml` configure `helpdesk: mode:` to `jira`.

## Running the bot

Expand All @@ -80,7 +99,7 @@ Then, to run, first set up your action server in one terminal window:
rasa run actions
```

In another window, run the duckling server (for entity extraction):
In another window, run the duckling server (for netentity extraction):

```bash
docker run -p 8000:8000 rasa/duckling
Expand Down Expand Up @@ -122,7 +141,7 @@ If configured, the bot can also hand off to another bot in response to the user

## Example conversations

With `localmode=true`:
With `helpdesk: mode: local`:

```
Bot loaded. Type a message and press enter (use '/stop' to exit):
Expand Down Expand Up @@ -158,7 +177,7 @@ Your input -> Yes please
The most recent incident for [email protected] is currently awaiting triage
```

With `localmode=false`:
With `helpdesk: mode: snow`:

With a Service Now instance connected, it will check if the email address is in the instance database and provide an incident number for the final response:

Expand Down
101 changes: 83 additions & 18 deletions actions/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,29 @@
from rasa_sdk.forms import FormValidationAction
from rasa_sdk.events import AllSlotsReset, SlotSet
from actions.snow import SnowAPI
from actions.jira_actions import JiraAPI
import random

import ruamel.yaml
import pathlib

logger = logging.getLogger(__name__)
vers = "vers: 0.1.0, date: Apr 2, 2020"
logger.debug(vers)

snow = SnowAPI()
localmode = snow.localmode
logger.debug(f"Local mode: {snow.localmode}")
here = pathlib.Path(__file__).parent.absolute()
endpoint_config = (
ruamel.yaml.safe_load(open(f"{here.parent}/endpoints.yml", "r")) or {}
)
mode = endpoint_config.get("helpdesk").get("mode")
if mode == "jira":
jira = JiraAPI()
print(mode)

if mode == "snow":
snow = SnowAPI()
print(mode)

logger.debug(f"mode: {mode}")


class ActionAskEmail(Action):
Expand All @@ -28,7 +41,9 @@ def run(
domain: Dict[Text, Any],
) -> List[Dict]:
if tracker.get_slot("previous_email"):
dispatcher.utter_message(template=f"utter_ask_use_previous_email",)
dispatcher.utter_message(
template=f"utter_ask_use_previous_email",
)
else:
dispatcher.utter_message(template=f"utter_ask_email")
return []
Expand All @@ -46,11 +61,16 @@ def _validate_email(
elif isinstance(value, bool):
value = tracker.get_slot("previous_email")

if localmode:
return {"email": value}
if mode == "snow":
results = snow.email_to_sysid(value)
caller_id = results.get("caller_id")

elif mode == "jira":
results = jira.email_to_sysid(value)
caller_id = results.get("account_id")

results = snow.email_to_sysid(value)
caller_id = results.get("caller_id")
else: # localmode
return {"email": value}

if caller_id:
return {"email": value, "caller_id": caller_id}
Expand Down Expand Up @@ -85,11 +105,22 @@ def validate_priority(
) -> Dict[Text, Any]:
"""Validate priority is a valid value."""

if value.lower() in snow.priority_db():
return {"priority": value}
if mode == "jira":
if value.lower() in jira.priority_db():
return {"priority": value}
else:
dispatcher.utter_message(template="utter_no_priority")
return {"priority": None}

elif mode == "snow": # handles local mode and snow
if value.lower() in snow.priority_db():
return {"priority": value}
else:
dispatcher.utter_message(template="utter_no_priority")
return {"priority": None}

else:
dispatcher.utter_message(template="utter_no_priority")
return {"priority": None}
return {"priority": value}


class ActionOpenIncident(Action):
Expand Down Expand Up @@ -118,15 +149,15 @@ def run(
)
return [AllSlotsReset(), SlotSet("previous_email", email)]

if localmode:
if mode == "local":
message = (
f"An incident with the following details would be opened "
f"if ServiceNow was connected:\n"
f"email: {email}\n"
f"problem description: {problem_description}\n"
f"title: {incident_title}\npriority: {priority}"
)
else:
elif mode == "snow":
snow_priority = snow.priority_db().get(priority)
response = snow.create_incident(
description=problem_description,
Expand All @@ -147,6 +178,28 @@ def run(
f"Something went wrong while opening an incident for you. "
f"{response.get('error')}"
)
elif mode == "jira":
jira_priority = jira.priority_db().get(priority)
response = jira.create_incident(
description=problem_description,
short_description=incident_title,
priority=jira_priority,
email=email,
)
# TODO Need to test. Might need a try catch around this. If the email returns an error, the response won't be an object with id function.
incident_number = response.id
print(incident_number)
if incident_number:
message = (
f"Successfully opened up incident {incident_number} "
f"for you. Someone will reach out soon."
)
else:
message = (
f"Something went wrong while opening an incident for you. "
f"{response.get('error')}"
)

dispatcher.utter_message(message)
return [AllSlotsReset(), SlotSet("previous_email", email)]

Expand Down Expand Up @@ -177,7 +230,7 @@ def run(
domain: Dict[Text, Any],
) -> List[Dict]:
"""Look up all incidents associated with email address
and return status of each"""
and return status of each"""

email = tracker.get_slot("email")

Expand All @@ -187,13 +240,13 @@ def run(
"On Hold": "has been put on hold",
"Closed": "has been closed",
}
if localmode:
if mode == "local":
status = random.choice(list(incident_states.values()))
message = (
f"Since ServiceNow isn't connected, I'm making this up!\n"
f"The most recent incident for {email} {status}"
)
else:
elif mode == "snow":
incidents_result = snow.retrieve_incidents(email)
incidents = incidents_result.get("incidents")
if incidents:
Expand All @@ -209,6 +262,18 @@ def run(

else:
message = f"{incidents_result.get('error')}"
elif mode == "jira":
incidents_result = jira.retrieve_incidents(email)
if incidents_result:
message = "\n".join(
[
f"Incident {i}: "
f'{incidents_result["incidents"][i]["summary"]} '
f'opened on {incidents_result["incidents"][i]["created_on"]} '
f'Status: {incidents_result["incidents"][i]["status"]} '
for i in incidents_result["incidents"]
]
)

dispatcher.utter_message(message)
return [AllSlotsReset(), SlotSet("previous_email", email)]
110 changes: 110 additions & 0 deletions actions/jira_actions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import logging
import pathlib
import ruamel.yaml
from typing import Dict, Text, Any
from jira import (
JIRA,
)
from jira.resources import Customer, Priority, User

logger = logging.getLogger(__name__)

cred_path = str(pathlib.Path(__file__).parent.absolute())


class JiraAPI(object):
def __init__(self):
jira_config = (
ruamel.yaml.safe_load(
open(f"{cred_path}/jira_credentials.yml", "r")
)
or {}
)

self.jira_user = jira_config.get("jira_user")
self.jira_token = jira_config.get("jira_token")
self.jira_url = jira_config.get("jira_url")
self.project = jira_config.get("project")
self.jira_obj = JIRA(
self.jira_url, basic_auth=(self.jira_user, self.jira_token)
)

def email_to_sysid(self, email) -> Dict[Text, Any]:
result = {}
email_result = self.jira_obj.search_users(
None, 0, 10, True, False, email
)
num_records = len(email_result)
if num_records == 1:
result["account_id"] = vars(email_result[0]).get("accountId")
else:
result["account_id"] = []
result["error"] = (
f"Could not retreive account id; "
f"{num_records} records found for email {email}"
)

return result

def retrieve_incidents(self, email) -> Dict[Text, Any]:
result = {}
incidents = {}
email_result = self.email_to_sysid(email)
account_id = email_result.get("account_id")
issues = self.jira_obj.search_issues(
f"reporter in ({account_id}) order by created DESC"
)
if account_id:
for issue in issues:
incidents[issue.key] = {
"summary": issue.fields.summary,
"created_on": issue.fields.created,
"status": issue.fields.status.name,
}
elif isinstance(issues, list):
result["error"] = f"No incidents on record for {email}"

result["account_id"] = account_id
result["incidents"] = incidents
return result

def create_incident(
self, description, short_description, priority, email
) -> Dict[Text, Any]:
project = self.project
email_result = self.email_to_sysid(email)
account_id = email_result.get("account_id")
if account_id:
result = self.jira_obj.create_issue(
project=project,
summary=short_description,
description=description,
issuetype={"id": "10002"},
priority={"id": priority},
reporter={"accountId": account_id},
)
else:
result = email_result.get("error")

return result

def delete_issue(self, issue_id):
issue = self.jira_obj.issue(issue_id)
issue.delete()
return

def change_priority(self, issue_id, priority):
issue = self.jira_obj.issue(issue_id)
issue.update(priority={"id": priority})

def assigned_issues(self, account_id):
issues = self.jira_obj.search_issues(
f"assignee = {account_id} ORDER BY priority"
)
return issues

@staticmethod
def priority_db() -> Dict[str, int]:
"""Database of supported priorities"""
priorities = {"low": "4", "medium": "3", "high": "2"}
return priorities
6 changes: 6 additions & 0 deletions actions/jira_credentials.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#jira_user: YOUR_EMAIL
#jira_token: YOUR_API_TOKEN
#jira_url: YOUR_URL
#project:
# id : 'PROJECT_ID'

1 change: 0 additions & 1 deletion actions/snow_credentials.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# snow_instance: "dev97377.service-now.com"
# snow_user: "admin"
# snow_pw: "mySnowinstance1"
# localmode: false
Loading