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

Oauth Jira Integration #265

Open
wants to merge 10 commits into
base: master
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 package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@types/twilio": "^0.0.9",
"@types/uuid": "^3.4.3",
"@types/winston": "^2.3.6",
"adf-builder": "^3.3.0",
"airtable": "^0.7.2",
"analytics-node": "^3.3.0",
"aws-sdk": "^2.362.0",
Expand All @@ -65,7 +66,6 @@
"express": "^4.16.2",
"express-winston": "^2.4.0",
"hipchatter": "^1.0.0",
"jira-client": "^6.4.1",
"mailchimp": "^1.2.0",
"node-marketo-rest": "^0.7.5",
"nodemailer": "^5.1.1",
Expand Down
26 changes: 14 additions & 12 deletions src/actions/dropbox/test_dropbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,18 @@ function expectDropboxMatch(request: Hub.ActionRequest, optionsMatch: any) {
}

describe(`${action.constructor.name} unit tests`, () => {
sinon.stub(ActionCrypto.prototype, "encrypt").callsFake( async (s: string) => b64.encode(s) )
sinon.stub(ActionCrypto.prototype, "decrypt").callsFake( async (s: string) => b64.decode(s) )
let encryptStub: any
let decryptStub: any

beforeEach(() => {
encryptStub = sinon.stub(ActionCrypto.prototype, "encrypt").callsFake( async (s: string) => b64.encode(s) )
decryptStub = sinon.stub(ActionCrypto.prototype, "decrypt").callsFake( async (s: string) => b64.decode(s) )
})

afterEach(() => {
encryptStub.restore()
decryptStub.restore()
})

describe("action", () => {

Expand All @@ -43,10 +53,8 @@ describe(`${action.constructor.name} unit tests`, () => {
request.attachment = {dataBuffer: Buffer.from("Hello"), fileExtension: "csv"}
request.formParams = {filename: stubFileName, directory: stubDirectory}
request.params = {
appKey: "mykey",
secretKey: "mySecret",
stateUrl: "https://looker.state.url.com/action_hub_state/asdfasdfasdfasdf",
stateJson: `{"access_token": "token"}`,
state_url: "https://looker.state.url.com/action_hub_state/asdfasdfasdfasdf",
state_json: `{"access_token": "token"}`,
}
return expectDropboxMatch(request,
{path: `/${stubDirectory}/${stubFileName}.csv`, contents: Buffer.from("Hello")})
Expand All @@ -58,8 +66,6 @@ describe(`${action.constructor.name} unit tests`, () => {
request.formParams = {filename: stubFileName, directory: stubDirectory}
request.type = Hub.ActionType.Query
request.params = {
appKey: "mykey",
secretKey: "mySecret",
state_url: "https://looker.state.url.com/action_hub_state/asdfasdfasdfasdf",
state_json: `{"access_token": "token"}`,
}
Expand Down Expand Up @@ -89,8 +95,6 @@ describe(`${action.constructor.name} unit tests`, () => {
}))
const request = new Hub.ActionRequest()
request.params = {
appKey: "mykey",
secretKey: "mySecret",
state_url: "https://looker.state.url.com/action_hub_state/asdfasdfasdfasdf",
state_json: `{"access_token": "token"}`,
}
Expand All @@ -116,8 +120,6 @@ describe(`${action.constructor.name} unit tests`, () => {
}))
const request = new Hub.ActionRequest()
request.params = {
appKey: "mykey",
secretKey: "mySecret",
state_url: "https://looker.state.url.com/action_hub_state/asdfasdfasdfasdf",
state_json: `{"access_token": "token"}`,
}
Expand Down
2 changes: 1 addition & 1 deletion src/actions/jira/jira.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
283 changes: 181 additions & 102 deletions src/actions/jira/jira.ts
Original file line number Diff line number Diff line change
@@ -1,133 +1,212 @@
import * as Hub from "../../hub"

import * as URL from "url"
import * as https from "request-promise-native"
import * as winston from "winston"

const jiraApi = require("jira-client")

const apiVersion = "2"

export class JiraAction extends Hub.Action {
import {Credentials, JiraClient} from "./jira_client"

export class JiraAction extends Hub.OAuthAction {
name = "jira_create_issue"
label = "JIRA"
iconName = "jira/jira.svg"
description = "Create a JIRA issue referencing data."
params = [
{
description: "The address of your JIRA server ex. https://myjira.atlassian.net.",
label: "Address",
name: "address",
required: true,
sensitive: false,
}, {
description: "The JIRA username assigned to create issues for Looker.",
label: "Username",
name: "username",
required: true,
sensitive: false,
}, {
description: "The password for the JIRA user assigned to Looker.",
label: "Password",
name: "password",
required: true,
sensitive: true,
},
]
supportedActionTypes = [Hub.ActionType.Query]
params = []
supportedActionTypes = [Hub.ActionType.Query, Hub.ActionType.Dashboard]
requiredFields = []
usesStreaming = false
minimumSupportedLookerVersion = "6.8.0"

async execute(request: Hub.ActionRequest) {

if (!request.attachment || !request.attachment.dataBuffer) {
throw "Couldn't get data from attachment"
throw "Couldn't get data from attachment."
}
const buffer = request.attachment.dataBuffer
const filename = request.formParams.filename || request.suggestedFilename()!

const resp = new Hub.ActionResponse()

const jira = this.jiraClientFromRequest(request)
if (!request.params.state_json) {
resp.success = false
resp.state = new Hub.ActionState()
resp.state.data = "reset"
return resp
}

let url
if (request.scheduledPlan) {
if (request.scheduledPlan.url) {
url = request.scheduledPlan.url
}
}
const issue = {
fields: {
project: {
id: request.formParams.project,
},
summary: request.formParams.summary,
description: `${request.formParams.description}` +
`\nLooker URL: ${request.scheduledPlan && request.scheduledPlan.url}`,
issuetype: {
id: request.formParams.issueType,
},
project: {
id: request.formParams.project!,
},
summary: request.formParams.summary,
description: request.formParams.description,
url,
issuetype: {
id: request.formParams.issueType!,
},
}
let response
try {
await jira.addNewIssue(issue)
} catch (e) {
response = {success: false, message: e.message}

const stateJson = JSON.parse(request.params.state_json)
if (stateJson.tokens && stateJson.redirect) {
try {
const client = await this.jiraClient(stateJson.redirect, stateJson.tokens)
const newIssue = await client.newIssue(issue)
await client.addAttachmentToIssue(newIssue.key, buffer, filename, request.attachment.mime)
resp.success = true
} catch (e) {
resp.success = false
resp.message = e.message
}
} else {
resp.success = false
resp.state = new Hub.ActionState()
resp.state.data = "reset"
}
return new Hub.ActionResponse(response)
return new Hub.ActionResponse(resp)
}

async form(request: Hub.ActionRequest) {

const form = new Hub.ActionForm()
try {
const jira = this.jiraClientFromRequest(request)

const [projects, issueTypes] = await Promise.all([
jira.listProjects(),
jira.listIssueTypes(),
])

form.fields = [{
default: projects[0].id,
label: "Project",
name: "project",
options: projects.map((p: any) => {
return {name: p.id, label: p.name}
}),
type: "select",
required: true,
}, {
label: "Summary",
name: "summary",
type: "string",
required: true,
}, {
label: "Description",
name: "description",
type: "textarea",
required: true,
}, {
default: issueTypes[0].id,
label: "Issue Type",
name: "issueType",
type: "select",
options: issueTypes
.filter((i: any) => i.description)
.map((p: any) => {
if (request.params.state_json) {
try {
const stateJson = JSON.parse(request.params.state_json)
if (stateJson.tokens && stateJson.redirect) {
const client = await this.jiraClient(stateJson.redirect, stateJson.tokens)
const projects = await client.getProjects()
const projectOptions: {name: string, label: string}[] = projects.map((p: any) => {
return {name: p.id, label: p.name}
}),
required: true,
}]
} catch (e) {
form.error = e
})

const issueTypesOptions = [{
name: "10000",
label: "Epic",
}, {
name: "10001",
label: "Story",
}, {
name: "10002",
label: "Task",
}, {
name: "10003",
label: "Sub-task",
}, {
name: "10004",
label: "Bug",
}]
projectOptions.sort((a, b) => ((a.label < b.label) ? -1 : 1 ))
issueTypesOptions.sort((a, b) => ((a.name > b.name) ? -1 : 1 ))

const form = new Hub.ActionForm()
form.fields = [{
default: projectOptions[0].name,
label: "Project",
name: "project",
options: projectOptions,
type: "select",
required: true,
}, {
default: issueTypesOptions[0].name,
label: "Issue Type",
name: "issueType",
type: "select",
options: issueTypesOptions,
required: true,
}, {
label: "Summary",
name: "summary",
type: "string",
required: true,
}, {
label: "Description",
name: "description",
type: "textarea",
required: false,
}, {
label: "Filename",
name: "filename",
type: "string",
required: false,
}]
return form
}
} catch (e) { winston.warn(`Log in fail ${JSON.stringify(e)}`) }
}
return form
return this.loginForm(request)
}

private jiraClientFromRequest(request: Hub.ActionRequest) {
const parsedUrl = URL.parse(request.params.address!)
if (!parsedUrl.host) {
throw "Invalid JIRA server address."
}
return new jiraApi({
protocol: parsedUrl.protocol ? parsedUrl.protocol : "https",
host: parsedUrl.host,
port: parsedUrl.port ? parsedUrl.port : "443",
username: request.params.username,
password: request.params.password,
apiVersion,
async oauthUrl(redirectUri: string, encryptedState: string) {
const client = await this.jiraClient(redirectUri)
const scope = "read:jira-user read:jira-work write:jira-work offline_access"
return client.generateAuthUrl(encryptedState, scope)
}

async oauthFetchInfo(urlParams: { [key: string]: string }, redirectUri: string) {
const actionCrypto = new Hub.ActionCrypto()
const plaintext = await actionCrypto.decrypt(urlParams.state).catch((err: string) => {
winston.error("Encryption not correctly configured" + err)
throw err
})

const client = await this.jiraClient(redirectUri)
const tokens = await client.getToken(urlParams.code)

winston.info(`oauthFetchInfo tokens: ${JSON.stringify(tokens)}`)
const payload = JSON.parse(plaintext)
await https.post({
url: payload.stateurl,
body: JSON.stringify({tokens, redirect: redirectUri}),
}).promise().catch((_err) => { winston.error(_err.toString()) })
}

async oauthCheck(request: Hub.ActionRequest) {
if (request.params.state_json) {
try {
const stateJson = JSON.parse(request.params.state_json)
if (stateJson.tokens && stateJson.redirect) {
const client = await this.jiraClient(stateJson.redirect, stateJson.tokens)
await client.getCloudIdFromTokens()
}
return true
} catch (err) {
winston.error(`Error in oauthCheck ${JSON.stringify(err)}`)
return false
}
}
return false
}

protected async jiraClient(redirect: string, tokens?: Credentials) {
const jiraClient = new JiraClient(redirect, tokens)
if (tokens) {
await jiraClient.setCloudIdFromTokens()
}
return jiraClient
}

private async loginForm(request: Hub.ActionRequest) {
const form = new Hub.ActionForm()
const actionCrypto = new Hub.ActionCrypto()
const jsonString = JSON.stringify({stateurl: request.params.state_url})
const ciphertextBlob = await actionCrypto.encrypt(jsonString).catch((err: string) => {
winston.error("Encryption not correctly configured")
throw err
})
form.fields = [{
name: "login",
type: "oauth_link",
label: "Log in",
description: "In order to create an Issue, you will need to log in" +
" to your Jira account.",
oauth_url: `${process.env.ACTION_HUB_BASE_URL}/actions/${this.name}/oauth?state=${ciphertextBlob}`,
}]
return form
}
}

Hub.addAction(new JiraAction())
if (process.env.JIRA_CLIENT_ID && process.env.JIRA_CLIENT_SECRET) {
Hub.addAction(new JiraAction())
}
Loading