Skip to content

Commit

Permalink
Add Jira labels (#2268)
Browse files Browse the repository at this point in the history
* feat: add custom labels

* graphql

* feat: working Jira labels

TODO: tests

* mage gen fmt

* fix: tests

* fix: remove comments

* fix: tests

* fix: pr feedback

Co-authored-by: panther-bot <[email protected]>
  • Loading branch information
s0l0ist and panther-bot authored Dec 14, 2020
1 parent e8a3f4e commit 1506ccd
Show file tree
Hide file tree
Showing 18 changed files with 104 additions and 23 deletions.
2 changes: 2 additions & 0 deletions api/graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,7 @@ type JiraConfig {
apiKey: String!
assigneeId: String
issueType: String!
labels: [String!]!
}

type AsanaConfig {
Expand Down Expand Up @@ -763,6 +764,7 @@ input JiraConfigInput {
apiKey: String!
assigneeId: String
issueType: String!
labels: [String!]
}

input AsanaConfigInput {
Expand Down
13 changes: 7 additions & 6 deletions api/lambda/outputs/models/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,12 +219,13 @@ type GithubConfig struct {

// JiraConfig defines options for each Jira output
type JiraConfig struct {
OrgDomain string `json:"orgDomain"`
ProjectKey string `json:"projectKey"`
UserName string `json:"userName"`
APIKey string `json:"apiKey"`
AssigneeID string `json:"assigneeId"`
Type string `json:"issueType"`
OrgDomain string `json:"orgDomain" validate:"url"`
ProjectKey string `json:"projectKey" validate:"required"`
UserName string `json:"userName" validate:"required"`
APIKey string `json:"apiKey"`
AssigneeID string `json:"assigneeId"`
Type string `json:"issueType"`
Labels []string `json:"labels" validate:"required,dive,min=1"`
}

// OpsgenieConfig defines options for each Opsgenie output
Expand Down
3 changes: 2 additions & 1 deletion internal/core/alert_delivery/outputs/jira.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func (client *OutputClient) Jira(
alert *alertModels.Alert, config *outputModels.JiraConfig) *AlertDeliveryResponse {

description := "*Description:* " + alert.AnalysisDescription
link := "\n [Click here to view in the Panther UI](" + generateURL(alert) + ")"
link := "\n [Click here to view in the Panther UI|" + generateURL(alert) + "]"
runBook := "\n *Runbook:* " + alert.Runbook
severity := "\n *Severity:* " + alert.Severity
tags := "\n *Tags:* " + strings.Join(alert.Tags, ", ")
Expand All @@ -55,6 +55,7 @@ func (client *OutputClient) Jira(
"issuetype": map[string]*string{
"name": aws.String(config.Type),
},
"labels": aws.StringSlice(config.Labels),
}

if config.AssigneeID != "" {
Expand Down
5 changes: 3 additions & 2 deletions internal/core/alert_delivery/outputs/jira_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ var jiraConfig = &outputModels.JiraConfig{
ProjectKey: "QR",
Type: "Task",
UserName: "username",
Labels: []string{"panther", "test-label"},
}

func TestJiraAlert(t *testing.T) {
Expand All @@ -53,12 +54,11 @@ func TestJiraAlert(t *testing.T) {
Severity: "INFO",
Context: map[string]interface{}{"key": "value"},
}

jiraPayload := map[string]interface{}{
"fields": map[string]interface{}{
"summary": "Policy Failure: policyId",
"description": "*Description:* policyDescription\n " +
"[Click here to view in the Panther UI](https://panther.io/policies/policyId)\n" +
"[Click here to view in the Panther UI|https://panther.io/policies/policyId]\n" +
" *Runbook:* \n *Severity:* INFO\n *Tags:* \n *AlertContext:* {\"key\":\"value\"}",
"project": map[string]*string{
"key": aws.String(jiraConfig.ProjectKey),
Expand All @@ -69,6 +69,7 @@ func TestJiraAlert(t *testing.T) {
"assignee": map[string]*string{
"id": aws.String(jiraConfig.AssigneeID),
},
"labels": aws.StringSlice(jiraConfig.Labels),
},
}
auth := jiraConfig.UserName + ":" + jiraConfig.APIKey
Expand Down
4 changes: 2 additions & 2 deletions internal/core/outputs_api/api/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ func mergeConfigs(oldConfig, newConfig *models.OutputConfig) (*models.OutputConf
}
}
// Turn the bytes into a map so we can work with it more easily
var oldMap map[string]map[string]string
var oldMap map[string]map[string]interface{}
err = jsoniter.Unmarshal(oldBytes, &oldMap)
if err != nil {
return nil, &genericapi.InternalError{
Expand All @@ -176,7 +176,7 @@ func mergeConfigs(oldConfig, newConfig *models.OutputConfig) (*models.OutputConf
Message: "Unable to extract the new configuration",
}
}
var newMap map[string]map[string]string
var newMap map[string]map[string]interface{}
err = jsoniter.Unmarshal(newBytes, &newMap)
if err != nil {
return nil, &genericapi.InternalError{
Expand Down
3 changes: 3 additions & 0 deletions web/__generated__/schema.tsx

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions web/__tests__/__mocks__/builders.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,7 @@ export const buildJiraConfig = (overrides: Partial<JiraConfig> = {}): JiraConfig
apiKey: 'apiKey' in overrides ? overrides.apiKey : 'bluetooth',
assigneeId: 'assigneeId' in overrides ? overrides.assigneeId : 'bleeding-edge',
issueType: 'issueType' in overrides ? overrides.issueType : 'Iowa',
labels: 'labels' in overrides ? overrides.labels : ['Rhode Island'],
};
};

Expand All @@ -870,6 +871,7 @@ export const buildJiraConfigInput = (overrides: Partial<JiraConfigInput> = {}):
apiKey: 'apiKey' in overrides ? overrides.apiKey : 'Sleek Cotton Car',
assigneeId: 'assigneeId' in overrides ? overrides.assigneeId : 'Virgin Islands, British',
issueType: 'issueType' in overrides ? overrides.issueType : 'strategic',
labels: 'labels' in overrides ? overrides.labels : ['magenta'],
};
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ const DestinationFormSwitcher: React.FC<DestinationFormSwitcherProps> = ({
'jira.apiKey',
'jira.assigneeId',
'jira.issueType',
'jira.labels',
]),
}}
onSubmit={onSubmit}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@ const emptyInitialValues = {
projectKey: '',
issueType: '',
userName: '',
labels: [],
},
},
};

const displayName = 'Jira';
const severity = SeverityEnum.Critical;
const labels = ['panther', 'label'];

const initialValues = {
outputId: '123',
Expand All @@ -51,14 +53,15 @@ const initialValues = {
projectKey: 'key',
issueType: 'Bug',
userName: faker.internet.email(),
labels: ['panther', 'label'],
},
},
defaultForSeverity: [severity],
};

describe('JiraDestinationForm', () => {
it('renders the correct fields', () => {
const { getByLabelText, getByText } = render(
const { getByLabelText, getAllByLabelText, getByText } = render(
<JiraDestinationForm onSubmit={() => {}} initialValues={emptyInitialValues} />
);
const displayNameField = getByLabelText('* Display Name');
Expand All @@ -68,6 +71,7 @@ describe('JiraDestinationForm', () => {
const apiKeyField = getByLabelText('* Jira API Key');
const assigneeIdField = getByLabelText('Assignee ID');
const issueTypeField = getByLabelText('* Issue Type');
const labelsField = getAllByLabelText('Labels')[0];
const submitButton = getByText('Add Destination');
expect(displayNameField).toBeInTheDocument();
expect(orgDomainField).toBeInTheDocument();
Expand All @@ -76,6 +80,7 @@ describe('JiraDestinationForm', () => {
expect(apiKeyField).toBeInTheDocument();
expect(assigneeIdField).toBeInTheDocument();
expect(issueTypeField).toBeInTheDocument();
expect(labelsField).toBeInTheDocument();
Object.values(SeverityEnum).forEach(sev => {
expect(getByText(sev)).toBeInTheDocument();
});
Expand All @@ -84,7 +89,7 @@ describe('JiraDestinationForm', () => {
});

it('has proper validation', async () => {
const { getByLabelText, getByText } = render(
const { getByLabelText, getAllByLabelText, getByText } = render(
<JiraDestinationForm onSubmit={() => {}} initialValues={emptyInitialValues} />
);
const displayNameField = getByLabelText('* Display Name');
Expand All @@ -94,6 +99,7 @@ describe('JiraDestinationForm', () => {
const apiKeyField = getByLabelText('* Jira API Key');
const assigneeIdField = getByLabelText('Assignee ID');
const issueTypeField = getByLabelText('* Issue Type');
const labelsField = getAllByLabelText('Labels')[0];
const submitButton = getByText('Add Destination');
const criticalSeverityCheckBox = document.getElementById(severity);
expect(criticalSeverityCheckBox).not.toBeNull();
Expand All @@ -117,6 +123,17 @@ describe('JiraDestinationForm', () => {
expect(submitButton).toHaveAttribute('disabled');
fireEvent.change(issueTypeField, { target: { value: 'Bug' } });
await waitMs(50);
expect(submitButton).not.toHaveAttribute('disabled');
// Labels is not required
labels.forEach(label => {
fireEvent.change(labelsField, {
target: {
value: label,
},
});
fireEvent.blur(labelsField);
});
await waitMs(50);
// Assignee ID is not required
expect(submitButton).not.toHaveAttribute('disabled');
fireEvent.change(assigneeIdField, { target: { value: 'key' } });
Expand All @@ -126,7 +143,7 @@ describe('JiraDestinationForm', () => {

it('should trigger submit successfully', async () => {
const submitMockFunc = jest.fn();
const { getByLabelText, getByText } = render(
const { getByLabelText, getAllByLabelText, getByText } = render(
<JiraDestinationForm onSubmit={submitMockFunc} initialValues={emptyInitialValues} />
);
const jiraInput = buildJiraConfigInput({
Expand All @@ -139,6 +156,7 @@ describe('JiraDestinationForm', () => {
const apiKeyField = getByLabelText('* Jira API Key');
const assigneeIdField = getByLabelText('Assignee ID');
const issueTypeField = getByLabelText('* Issue Type');
const labelsField = getAllByLabelText('Labels')[0];
const submitButton = getByText('Add Destination');
const criticalSeverityCheckBox = document.getElementById(severity);
expect(criticalSeverityCheckBox).not.toBeNull();
Expand All @@ -152,6 +170,14 @@ describe('JiraDestinationForm', () => {
fireEvent.change(apiKeyField, { target: { value: jiraInput.apiKey } });
fireEvent.change(assigneeIdField, { target: { value: jiraInput.assigneeId } });
fireEvent.change(issueTypeField, { target: { value: jiraInput.issueType } });
jiraInput.labels.forEach(label => {
fireEvent.change(labelsField, {
target: {
value: label,
},
});
fireEvent.blur(labelsField);
});
await waitMs(50);
expect(submitButton).not.toHaveAttribute('disabled');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import BaseDestinationForm, {
defaultValidationSchema,
} from 'Components/forms/BaseDestinationForm';
import { Box, FormHelperText, SimpleGrid } from 'pouncejs';
import FormikMultiCombobox from 'Components/fields/MultiComboBox';
import { hasNoWhitespaces } from 'Helpers/utils';

type JiraFieldValues = Pick<DestinationConfigInput, 'jira'>;

Expand All @@ -46,6 +48,7 @@ const JiraDestinationForm: React.FC<JiraDestinationFormProps> = ({ onSubmit, ini
projectKey: Yup.string().required(),
assigneeId: Yup.string(),
issueType: Yup.string().required(),
labels: Yup.array().of(Yup.string()),
apiKey: existing ? Yup.string() : Yup.string().required(),
}),
}),
Expand Down Expand Up @@ -83,7 +86,7 @@ const JiraDestinationForm: React.FC<JiraDestinationFormProps> = ({ onSubmit, ini
autoComplete="new-password"
/>
</SimpleGrid>
<SimpleGrid gap={5} columns={2}>
<SimpleGrid gap={5} columns={2} mb={5}>
<Field
as={FormikTextInput}
name="outputConfig.jira.userName"
Expand All @@ -99,13 +102,8 @@ const JiraDestinationForm: React.FC<JiraDestinationFormProps> = ({ onSubmit, ini
required={!existing}
autoComplete="new-password"
/>

<Field
as={FormikTextInput}
name="outputConfig.jira.assigneeId"
label="Assignee ID"
placeholder="Who should we assign this to?"
/>
</SimpleGrid>
<SimpleGrid gap={5} columns={3}>
<Box as="fieldset">
<Field
as={FormikTextInput}
Expand All @@ -118,6 +116,28 @@ const JiraDestinationForm: React.FC<JiraDestinationFormProps> = ({ onSubmit, ini
Can be Bug, Story, Task or any custom type
</FormHelperText>
</Box>
<Field
as={FormikTextInput}
name="outputConfig.jira.assigneeId"
label="Assignee ID"
placeholder="Who should we assign this to?"
/>
<Box as="fieldset">
<Field
name="outputConfig.jira.labels"
as={FormikMultiCombobox}
label="Labels"
aria-describedby="labels-helper"
allowAdditions
validateAddition={hasNoWhitespaces}
searchable
items={[]}
placeholder="Add custom labels"
/>
<FormHelperText id="labels-helper" mt={2}>
Add by pressing the {'<'}Enter{'>'} key
</FormHelperText>
</Box>
</SimpleGrid>
</BaseDestinationForm>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const initialValues: Omit<DestinationInput, 'outputType'> = {
apiKey: '',
assigneeId: '',
issueType: '',
labels: [],
},
opsgenie: { apiKey: '', serviceRegion: OpsgenieServiceRegionEnum.Us },
slack: { webhookURL: '' },
Expand Down
3 changes: 2 additions & 1 deletion web/src/graphql/fragments/DestinationFull.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export type DestinationFull = { __typename: 'Destination' } & Pick<
jira?: Types.Maybe<
Pick<
Types.JiraConfig,
'orgDomain' | 'projectKey' | 'userName' | 'apiKey' | 'assigneeId' | 'issueType'
'orgDomain' | 'projectKey' | 'userName' | 'apiKey' | 'assigneeId' | 'issueType' | 'labels'
>
>;
opsgenie?: Types.Maybe<Pick<Types.OpsgenieConfig, 'apiKey' | 'serviceRegion'>>;
Expand Down Expand Up @@ -82,6 +82,7 @@ export const DestinationFull = gql`
apiKey
assigneeId
issueType
labels
}
opsgenie {
apiKey
Expand Down
1 change: 1 addition & 0 deletions web/src/graphql/fragments/DestinationFull.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ fragment DestinationFull on Destination {
apiKey
assigneeId
issueType
labels
}
opsgenie {
apiKey
Expand Down
6 changes: 6 additions & 0 deletions web/src/helpers/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,12 @@ export function slugify(text: string) {

export const isNumber = (value: string) => /^-{0,1}\d+$/.test(value);

/**
* A function that returns true if the string consists of only non-whitespace characters
* @param {string} value A string to test
*/
export const hasNoWhitespaces = (value: string) => /^\S+$/.test(value);

export const toStackNameFormat = (val: string) => val.replace(/ /g, '-').toLowerCase();

/*
Expand Down
Loading

0 comments on commit 1506ccd

Please sign in to comment.