Skip to content

Commit

Permalink
[MM 15099] direct message notifications on off (#62)
Browse files Browse the repository at this point in the history
* JIRAv2 initial PR (#36)

* Refactored the JIRA plugin to support more events

* Removed Enabled config setting

* PR feedback

* PR feedback from @hanzei

* Added back webhook tests, updated for MD

* coverage

* Added back formatting as Slack attachments

* Add atlassian connect functionality

* Updated plugin.json

* merged the minimal webapp from hackathon

* Updated .gitignore

* wip Merged plugin.go from hackathon

* wip oauth failures

* wip oauth2 with a static clientid seems to work

* wip /jira connect seems to work

* wip trying JWT webhook setup

* wiop

* wip

* wip connect auth and the beginning of issue

* Added webapp from hackathon

* Create JIRA Issue is showing up

* wip create issue with semi-fakeuser mapping

* JWT verification in user-config page

* moved JWT verification into auth.go

* WIP WIP WIP in the middle of something

* wip flow works, need to add encryption

* User mapping works

* added /jira-disconnect

* Fix websocket event handling for connect and add disconnect

* WIP fixed websocket initialization

* WIP - TODO encrypt Atl account ID in config

* Secured the auth flow, passing mm_token and jwt to the final endpoint

* Restored legacy webhook support

* Process mentions in Webhooks, rearrange files

* Point Gopkg.toml at mattermost-server/master for now

* Adding comments for posts mentioning JIRA issues

* Some error logging

* Cleanup

* 5.8 compatibility: replace GetBundlePath with old-style config hacking

* go test cleanup

* Removed unused files

* Fixed MM-15004, no lnger requires admin to connect user

* wip prefixing and instances - appears to work with connect

* MM-15003 Prefix KV keys with the JIRA instance ID

- Reworked the kv store functions to use the JIRA BaseURL as a part of the
key.
- Other cleanup

* Added JIRA Server and OAuth1

- Added back OAuth1 functionality
- Added `type JIRAInstance` and moved a lot of instance-specific data
  there
- Added "cloud" and "server" instance types, for now with switches
- Added `/jira add server url` command to add JIRA Server instances

* wip style/naming

* JIRA Server auth appears to work

* Cosmetic PR feedback, fixed atlassian-connect.json

* Fixed a crash if no current JIRAInsttance exists

* GetJIRAClient refactor

* GetJIRACLient appears to work

* CreateIssue works in server and cloud

* Added (encoded) URL to Atlassian-connect.json `key` value (e.g. `"mattermost-https-e1ba36fb-ngrok-io"`), making it unique per Mattermost instance. Now can add multiple Mattermost instances to the same JIRA Cloud instance
* Removed caching of the project keys in anticipation of the new cache
* Webapp CreateIssue: use f.schema.system rather than f.key to identify field keys, this works with JIRA Server and Cloud versions.
* Webapp: CreateIssue renderFields: initialize `description` to an empty value to avoid JS errors

* PR feedback: ephf -> responsef

* Cleanup, /jira command improvements

- added `/jira help`
- added instance numbers to `/jira instance list`, ability to do
 `/jira instance select {number}`
- style: refactored command.go
- style: http naming, and using constants for all routes
- style: updated webapp to match
- style: eliminated unused oauth2.code

* Fixed 2 typos in 1 URL

* WIP prep to having a revoke button on the confirm page

- fixed a typo in `/jira instance listt` output
- removed unused routeOAuth1Connect and related code
- renamed StoreOAuth1RequestToken to StoreOneTimeSecret and such

* PR feedback (style), expire OTS

* PR feedback: Do not change original posts

- Do not change the original posts when creating issues
- require MM server 5.6 to build
- Style, error handling

* Fixed "Connect to JIRA" URL in post menu item

* PR feedback: added explicit Gopkg.toml deps

* PR feedback: style and error handling

* PR feedback: more style

* Fix tests.

* Fix linting.

* Removed notify() from webhooks for now, to pass the tests

* Uncommented the tests @cristopher took out

* Uncommented the test I missed

* Minor fixes

* Update README.md

* Revert accidental commit

* Update README.md (#38)

* Update README.md

* Update README.md

* Update README.md

* Update README.md

* quick fix of url

* correct the menu item used in Jira

* Proposed text updates for /jira slash commands (#41)

* Proposed text updates for /jira slash commands

Feedback welcome

* Address review feedback on command.go

* Some cleanup for Jira plugin (#42)

* Update instance_server.go

* JIRA -> Jira in error text

* JIRA > Jira, text update for Jira Server URL help text

* Update kv.go

* Update message_posted.go

* JIRA > Jira

* Update user_oauth1.go

* Update utils.go

* JIRA > Jira

* JIRA > Jira

* Update create_issue.jsx

* Update http.go

* JIRA > Jira

* MM-15244 Jira Cloud user connect security (#39)

* Adjusted the cloud auth flow as per spec

- changed cloud user mapping flow:
  1. serve a page that retrieves mm_token and auto-resubmits
     (`/ac/user_redirect.html`)
  2. (skipped/not implemented) a confirm page that would displat both
     usernames, and have a submit button
  3. connect the accounts, and serve a summary page with a "Disconnect"
     button (`/ac/user_connected.html`)
  4. disconnect the accounts if the above button is clicked,
     (`/ac/user_disconnected.html`)
- refactored http to use withXxxInstance
- refactored use of templates

* Added OTS to Jira Cloud user connect flow

- Added `Secret` field to `type AuthToken`
- Populate `Secret` with a random md5(256bytes) secret
- Verify and remove OTS
- Added a missing ./server/templates/ac/user_connected.html

* Added a confirm page in tthe flow

* Cleanup of Jira server connect flow

* PR feedback: style

* Careful with that lock, Eugene

* Style, govet, added a template I missed

* CAREFUL with that lock, Eugene

* resolved merge conflicts

* PR feedback: clarified respondWithTemplate

* PR feedback: md5->sha256

* PR feedback: separated Store[Current]JIRAInstance functions

* PR feedback: Like _really_ careful with that lock, Eugene

* Doc updates for roadmap and Jira Server install docs (#40)

* WIP: Doc updates for roadmap and Jira Server install docs

* Update README.md

* Update README.md

* Update based on feedback

* MM-15101 Adding /jira transition (#46)

* Adding /jira transition

* Moving transitionJiraIssue to a better home.

* MM-15253 Refactor of create-issue modal. (#44)

* Refactor of create-issue modal.

* Names feeback.

* Fixed /jira subcommand parsing consistency (#45)

* Fixed creating server/dist/templates/templates on a 2nd make (#50)

* Fixed creating server/dist/templates/templates on 2nd make

* make the cp switch portable

ref: https://unix.stackexchange.com/questions/18712/difference-between-cp-r-and-cp-r-copy-command

* small fixes to /jira instance delete (#52)

* Some improvments to cloud connect flow. (#55)

* MM-15096 UI fixes for template pages and menu item (#54)

* UI fixes for template and menu item

* Updating flexparent

* Updating templates

* Update Jira application link for Cloud (#48)

* (no ticket?) Fixed a bug in unescaping webhook secret (#53)

Also added `/jira webhook` command to see a URL custom-fit to the
current channel

* MM-15400 Added some protections to /installed link (#47)

* Added some protections to /installed link

* Forgot to reset the timeout back to 15min

* PR feedback: Installed flag

* Fixed s in URLs

* PR feedback: use  more consistently

* PR feedback: return a 403, not a 401 if already installed

* Update README.md

* Update README.md

* Add 'subject to change' for timeline in README

* Added response logging for Jira API errors (#59)

* * notifications working, added issue_created

* wip

* * new make lifecycle commands: debug, webapp-debug, reset, stop

* [MM-14773] [MM-15440] - simplify settings and setup (#57)

* [MM-14773] [MM-15440] - simplify settings and setup

* * restrict `/jira webhook` to system admins; some cleanup

* * remove public webhook instructions, remove generate webhook secret

* fixups

* Update server/command.go

Co-Authored-By: Jason Blais <13119842+jasonblais@users.noreply.github.com>

* Update server/command.go

Co-Authored-By: Jason Blais <13119842+jasonblais@users.noreply.github.com>

* PR comments

* * notification setting persisted, command line updated

* * disable notifications working

* Revert "* new make lifecycle commands: debug, webapp-debug, reset, stop"

This reverts commit 92b691d

* Fix govet

* Adding new CI

* Updating Makefile and moving to go modules

* Temporarily use jira2 as CI branch.

* fixing tests

* PR comments

* fixes needed after The Great Merge

* updated for firehose webhook (#61) now on master

* PR comments
  • Loading branch information
cpoile authored and crspeller committed May 24, 2019
1 parent e9e58a6 commit 1b8644d
Show file tree
Hide file tree
Showing 25 changed files with 3,146 additions and 648 deletions.
35 changes: 34 additions & 1 deletion server/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,19 @@ const helpText = "###### Mattermost Jira Plugin - Slash Command Help\n" +
"* `/jira disconnect` - Disonnect your Mattermost account from your Jira account\n" +
"* `/jira create <text (optional)>` - Create a new Issue with 'text' inserted into the description field.\n" +
"* `/jira transition <issue-key> <state>` - Changes the state of a Jira issue.\n" +
"* `/jira settings [setting] [value]` - Update your user settings\n" +
" * [setting] can be `notifications`\n" +
" * [value] can be `on` or `off`\n" +
"\nFor system administrators:\n" +
"* `/jira install cloud <URL>` - connect Mattermost to a cloud Jira instance located at <URL>\n" +
"* `/jira install server <URL>` - connect Mattermost to a server Jira instance located at <URL>\n" +
""

// Available settings
const (
settingsNotifications = "notifications"
)

type CommandHandlerFunc func(p *Plugin, c *plugin.Context, header *model.CommandArgs, args ...string) *model.CommandResponse

type CommandHandler struct {
Expand All @@ -33,6 +41,7 @@ var jiraCommandHandler = CommandHandler{
"transition": executeTransition,
"connect": executeConnect,
"disconnect": executeDisconnect,
"settings": executeSettings,
//"webhook": executeWebhookURL,
//"webhook/url": executeWebhookURL,
//"list": executeList,
Expand Down Expand Up @@ -84,6 +93,30 @@ func executeDisconnect(p *Plugin, c *plugin.Context, header *model.CommandArgs,
p.GetPluginURL(), routeUserDisconnect)
}

func executeSettings(p *Plugin, c *plugin.Context, header *model.CommandArgs, args ...string) *model.CommandResponse {
if len(args) < 1 {
return help()
}

ji, err := p.LoadCurrentJIRAInstance()
if err != nil {
return responsef("Failed to load current Jira instance: %v. Please contact your system administrator.", err)
}

mattermostUserId := header.UserId
jiraUser, err := p.LoadJIRAUser(ji, mattermostUserId)
if err != nil {
return responsef("Your username is not connected to Jira. Please type `jira connect`. %v", err)
}

switch args[0] {
case settingsNotifications:
return p.settingsNotifications(ji, mattermostUserId, jiraUser, args)
default:
return responsef("Unknown setting.")
}
}

func executeList(p *Plugin, c *plugin.Context, header *model.CommandArgs, args ...string) *model.CommandResponse {
authorized, err := authorizedSysAdmin(p, header.UserId)
if err != nil {
Expand Down Expand Up @@ -280,7 +313,7 @@ func getCommand() *model.Command {
DisplayName: "Jira",
Description: "Integration with Jira.",
AutoComplete: true,
AutoCompleteDesc: "Available commands: connect, disconnect, create, transition, install cloud, install server, help",
AutoCompleteDesc: "Available commands: connect, disconnect, create, transition, settings, install cloud, install server, help",
AutoCompleteHint: "[command]",
}
}
Expand Down
47 changes: 47 additions & 0 deletions server/settings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package main

import "github.com/mattermost/mattermost-server/model"

const (
settingOn = "on"
settingOff = "off"
)

func (p *Plugin) settingsNotifications(ji Instance, mattermostUserId string, jiraUser JIRAUser, args []string) *model.CommandResponse {
const helpText = "`/jira settings notifications [value]`\n* Invalid value. Accepted values are: `on` or `off`."

if len(args) != 2 {
return responsef(helpText)
}

var value bool
switch args[1] {
case settingOn:
value = true
case settingOff:
value = false
default:
return responsef(helpText)
}

if jiraUser.Settings == nil {
jiraUser.Settings = &UserSettings{}
}
jiraUser.Settings.Notifications = value
if err := p.StoreUserInfo(ji, mattermostUserId, jiraUser); err != nil {
p.errorf("settingsNotifications, err: %v", err)
responsef("Could not store new settings. Please contact your system administrator. error: %v", err)
}

// send back the actual value
updatedJiraUser, err := p.LoadJIRAUser(ji, mattermostUserId)
if err != nil {
return responsef("Your username is not connected to Jira. Please type `jira connect`. %v", err)
}
notifications := "off"
if updatedJiraUser.Settings.Notifications {
notifications = "on"
}

return responsef("Settings updated. Notifications %s.", notifications)
}
7 changes: 7 additions & 0 deletions server/subscribe.go
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,13 @@ func httpSubscribeWebhook(p *Plugin, w http.ResponseWriter, r *http.Request) (in
}
}

// Notify any affected users using a direct channel
err = p.handleNotifications(parsed)
if err != nil {
p.errorf("httpSubscribeWebhook, handleNotifications: %v", err)
return http.StatusBadRequest, err
}

return http.StatusOK, nil
}

Expand Down
94 changes: 93 additions & 1 deletion server/testdata/webhook-comment-created.json
Original file line number Diff line number Diff line change
@@ -1 +1,93 @@
{"timestamp":1550286678321,"webhookEvent":"comment_created","comment":{"self":"https://some-instance-test.atlassian.net/rest/api/2/issue/10040/comment/10019","id":"10019","author":{"self":"https://some-instance-test.atlassian.net/rest/api/2/user?accountId=5c5f880629be9642ba529340","name":"admin","key":"admin","accountId":"5c5f880629be9642ba529340","avatarUrls":{"48x48":"https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=48&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D48%26noRedirect%3Dtrue","24x24":"https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=24&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D24%26noRedirect%3Dtrue","16x16":"https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=16&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D16%26noRedirect%3Dtrue","32x32":"https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=32&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D32%26noRedirect%3Dtrue"},"displayName":"Test User","active":true,"timeZone":"America/Los_Angeles"},"body":"Added a comment","updateAuthor":{"self":"https://some-instance-test.atlassian.net/rest/api/2/user?accountId=5c5f880629be9642ba529340","name":"admin","key":"admin","accountId":"5c5f880629be9642ba529340","avatarUrls":{"48x48":"https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=48&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D48%26noRedirect%3Dtrue","24x24":"https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=24&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D24%26noRedirect%3Dtrue","16x16":"https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=16&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D16%26noRedirect%3Dtrue","32x32":"https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=32&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D32%26noRedirect%3Dtrue"},"displayName":"Test User","active":true,"timeZone":"America/Los_Angeles"},"created":"2019-02-15T19:11:18.321-0800","updated":"2019-02-15T19:11:18.321-0800","jsdPublic":true},"issue":{"id":"10040","self":"https://some-instance-test.atlassian.net/rest/api/2/issue/10040","key":"TES-41","fields":{"summary":"Unit test summary 1","issuetype":{"self":"https://some-instance-test.atlassian.net/rest/api/2/issuetype/10001","id":"10001","description":"Stories track functionality or features expressed as user goals.","iconUrl":"https://some-instance-test.atlassian.net/secure/viewavatar?size=xsmall&avatarId=10315&avatarType=issuetype","name":"Story","subtask":false,"avatarId":10315},"project":{"self":"https://some-instance-test.atlassian.net/rest/api/2/project/10000","id":"10000","key":"TES","name":"test1","projectTypeKey":"software","avatarUrls":{"48x48":"https://some-instance-test.atlassian.net/secure/projectavatar?avatarId=10324","24x24":"https://some-instance-test.atlassian.net/secure/projectavatar?size=small&avatarId=10324","16x16":"https://some-instance-test.atlassian.net/secure/projectavatar?size=xsmall&avatarId=10324","32x32":"https://some-instance-test.atlassian.net/secure/projectavatar?size=medium&avatarId=10324"}},"assignee":null,"priority":{"self":"https://some-instance-test.atlassian.net/rest/api/2/priority/2","iconUrl":"https://some-instance-test.atlassian.net/images/icons/priorities/high.svg","name":"High","id":"2"},"status":{"self":"https://some-instance-test.atlassian.net/rest/api/2/status/10001","description":"","iconUrl":"https://some-instance-test.atlassian.net/","name":"To Do","id":"10001","statusCategory":{"self":"https://some-instance-test.atlassian.net/rest/api/2/statuscategory/2","id":2,"key":"new","colorName":"blue-gray","name":"To Do"}}}}}
{
"timestamp": 1550286678321,
"webhookEvent": "comment_created",
"comment": {
"self": "https://some-instance-test.atlassian.net/rest/api/2/issue/10040/comment/10019",
"id": "10019",
"author": {
"self": "https://some-instance-test.atlassian.net/rest/api/2/user?accountId=5c5f880629be9642ba529340",
"name": "admin",
"key": "admin",
"accountId": "5c5f880629be9642ba529340",
"avatarUrls": {
"48x48": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=48&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D48%26noRedirect%3Dtrue",
"24x24": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=24&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D24%26noRedirect%3Dtrue",
"16x16": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=16&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D16%26noRedirect%3Dtrue",
"32x32": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=32&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D32%26noRedirect%3Dtrue"
},
"displayName": "Test User",
"active": true,
"timeZone": "America/Los_Angeles"
},
"body": "Added a comment",
"updateAuthor": {
"self": "https://some-instance-test.atlassian.net/rest/api/2/user?accountId=5c5f880629be9642ba529340",
"name": "admin",
"key": "admin",
"accountId": "5c5f880629be9642ba529340",
"avatarUrls": {
"48x48": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=48&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D48%26noRedirect%3Dtrue",
"24x24": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=24&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D24%26noRedirect%3Dtrue",
"16x16": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=16&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D16%26noRedirect%3Dtrue",
"32x32": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=32&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D32%26noRedirect%3Dtrue"
},
"displayName": "Test User",
"active": true,
"timeZone": "America/Los_Angeles"
},
"created": "2019-02-15T19:11:18.321-0800",
"updated": "2019-02-15T19:11:18.321-0800",
"jsdPublic": true
},
"issue": {
"id": "10040",
"self": "https://some-instance-test.atlassian.net/rest/api/2/issue/10040",
"key": "TES-41",
"fields": {
"summary": "Unit test summary 1",
"issuetype": {
"self": "https://some-instance-test.atlassian.net/rest/api/2/issuetype/10001",
"id": "10001",
"description": "Stories track functionality or features expressed as user goals.",
"iconUrl": "https://some-instance-test.atlassian.net/secure/viewavatar?size=xsmall&avatarId=10315&avatarType=issuetype",
"name": "Story",
"subtask": false,
"avatarId": 10315
},
"project": {
"self": "https://some-instance-test.atlassian.net/rest/api/2/project/10000",
"id": "10000",
"key": "TES",
"name": "test1",
"projectTypeKey": "software",
"avatarUrls": {
"48x48": "https://some-instance-test.atlassian.net/secure/projectavatar?avatarId=10324",
"24x24": "https://some-instance-test.atlassian.net/secure/projectavatar?size=small&avatarId=10324",
"16x16": "https://some-instance-test.atlassian.net/secure/projectavatar?size=xsmall&avatarId=10324",
"32x32": "https://some-instance-test.atlassian.net/secure/projectavatar?size=medium&avatarId=10324"
}
},
"assignee": null,
"priority": {
"self": "https://some-instance-test.atlassian.net/rest/api/2/priority/2",
"iconUrl": "https://some-instance-test.atlassian.net/images/icons/priorities/high.svg",
"name": "High",
"id": "2"
},
"status": {
"self": "https://some-instance-test.atlassian.net/rest/api/2/status/10001",
"description": "",
"iconUrl": "https://some-instance-test.atlassian.net/",
"name": "To Do",
"id": "10001",
"statusCategory": {
"self": "https://some-instance-test.atlassian.net/rest/api/2/statuscategory/2",
"id": 2,
"key": "new",
"colorName": "blue-gray",
"name": "To Do"
}
}
}
}
}
Loading

0 comments on commit 1b8644d

Please sign in to comment.