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

[MI-1974] Add modal to link a project #11

Merged
merged 15 commits into from
Aug 10, 2022
Merged
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
1 change: 1 addition & 0 deletions server/constants/constants.go
Original file line number Diff line number Diff line change
@@ -21,6 +21,7 @@ const (
// Azure API Routes
CreateTask = "/%s/%s/_apis/wit/workitems/$%s?api-version=7.1-preview.3"
GetTask = "%s/_apis/wit/workitems/%s?api-version=7.1-preview.3"
GetProject = "/%s/_apis/projects/%s?api-version=7.1-preview.4"

// Get task link preview constants
HTTPS = "https:"
3 changes: 3 additions & 0 deletions server/constants/messages.go
Original file line number Diff line number Diff line change
@@ -22,4 +22,7 @@ const (
CreatedTask = "Link for newly created task: %s"
TaskTitle = "[%s #%d: %s](%s)"
TaskPreviewMessage = "**State:** %s\n**Assigned To:** %s\n**Description:** %s"
AlreadyLinkedProject = "This project is already linked."
GetProjectListError = "Error getting Project List"
ErrorFetchProjectList = "Error in fetching project list"
)
5 changes: 5 additions & 0 deletions server/constants/store.go
Original file line number Diff line number Diff line change
@@ -6,4 +6,9 @@ const (
AtomicRetryLimit = 5
AtomicRetryWait = 30 * time.Millisecond
TTLSecondsForOAuthState int64 = 60

// KV store prefix keys
OAuthPrefix = "oAuth_%s"
ProjectKey = "%s_%s"
ProjectPrefix = "project_list"
)
56 changes: 52 additions & 4 deletions server/plugin/api.go
Original file line number Diff line number Diff line change
@@ -35,6 +35,7 @@ func (p *Plugin) InitRoutes() {

// Plugin APIs
s.HandleFunc("/tasks", p.handleAuthRequired(p.handleCreateTask)).Methods(http.MethodPost)
s.HandleFunc("/link", p.handleAuthRequired(p.handleLink)).Methods(http.MethodPost)

// TODO: for testing purpose, remove later
s.HandleFunc("/test", p.testAPI).Methods(http.MethodGet)
@@ -61,22 +62,69 @@ func (p *Plugin) handleCreateTask(w http.ResponseWriter, r *http.Request) {
p.handleError(w, r, &serializers.Error{Code: statusCode, Message: err.Error()})
return
}

response, err := json.Marshal(task)
if err != nil {
p.handleError(w, r, &serializers.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}

w.Header().Add("Content-Type", "application/json")
if _, err := w.Write(response); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}

message := fmt.Sprintf(constants.CreatedTask, task.Link.Html.Href)

// Send message to DM.
p.DM(mattermostUserID, message)
}

w.Header().Add("Content-Type", "application/json")
if _, err := w.Write(response); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
// API to link a project and an organization to a user.
func (p *Plugin) handleLink(w http.ResponseWriter, r *http.Request) {
mattermostUserID := r.Header.Get(constants.HeaderMattermostUserIDAPI)
var body *serializers.LinkRequestPayload
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&body); err != nil {
p.API.LogError("Error in decoding body", "Error", err.Error())
p.handleError(w, r, &serializers.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}

if err := body.IsLinkPayloadValid(); err != "" {
error := serializers.Error{Code: http.StatusBadRequest, Message: err}
ayusht2810 marked this conversation as resolved.
Show resolved Hide resolved
p.handleError(w, r, &error)
return
}

projectList, err := p.Store.GetAllProjects(mattermostUserID)
if err != nil {
p.API.LogError(constants.ErrorFetchProjectList, "Error", err.Error())
p.handleError(w, r, &serializers.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}

if p.IsProjectLinked(projectList, serializers.ProjectDetails{OrganizationName: body.Organization, ProjectName: body.Project}) {
p.DM(mattermostUserID, constants.AlreadyLinkedProject)
return
}

response, err := p.Client.Link(body, mattermostUserID)
if err != nil {
p.handleError(w, r, &serializers.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}

project := serializers.ProjectDetails{
MattermostUserID: mattermostUserID,
ProjectID: response.ID,
ProjectName: response.Name,
OrganizationName: body.Organization,
}

p.Store.StoreProject(&project)

w.WriteHeader(http.StatusOK)
w.Header().Add("Content-Type", "application/json")
}

// handleAuthRequired verifies if the provided request is performed by an authorized source.
20 changes: 16 additions & 4 deletions server/plugin/client.go
Original file line number Diff line number Diff line change
@@ -20,6 +20,7 @@ type Client interface {
GenerateOAuthToken(encodedFormValues url.Values) (*serializers.OAuthSuccessResponse, int, error)
CreateTask(body *serializers.CreateTaskRequestPayload, mattermostUserID string) (*serializers.TaskValue, int, error)
GetTask(organization, taskID, mattermostUserID string) (*serializers.TaskValue, int, error)
Link(body *serializers.LinkRequestPayload, mattermostUserID string) (*serializers.Project, error)
}

type client struct {
@@ -80,10 +81,15 @@ func (c *client) CreateTask(body *serializers.CreateTaskRequestPayload, mattermo
return task, statusCode, nil
}

// Wrapper to make REST API requests with "application/x-www-form-urlencoded" type content
func (c *client) callFormURLEncoded(url, path, method string, out interface{}, formValues url.Values) (responseData []byte, statusCode int, err error) {
contentType := "application/x-www-form-urlencoded"
return c.call(url, method, path, contentType, "", nil, out, formValues)
// Function to link a project and an organization.
func (c *client) Link(body *serializers.LinkRequestPayload, mattermostUserID string) (*serializers.Project, error) {
projectURL := fmt.Sprintf(constants.GetProject, body.Organization, body.Project)
var project *serializers.Project
if _, _, err := c.callJSON(c.plugin.getConfiguration().AzureDevopsAPIBaseURL, projectURL, http.MethodGet, mattermostUserID, nil, &project, nil); err != nil {
return nil, errors.Wrap(err, "failed to link Project")
}

return project, nil
}

// Function to get the task.
@@ -99,6 +105,12 @@ func (c *client) GetTask(organization, taskID, mattermostUserID string) (*serial
return task, statusCode, nil
}

// Wrapper to make REST API requests with "application/x-www-form-urlencoded" type content
func (c *client) callFormURLEncoded(url, path, method string, out interface{}, formValues url.Values) (responseData []byte, statusCode int, err error) {
contentType := "application/x-www-form-urlencoded"
return c.call(url, method, path, contentType, "", nil, out, formValues)
}

// Wrapper to make REST API requests with "application/json-patch+json" type content
func (c *client) callPatchJSON(url, path, method, mattermostUserID string, in, out interface{}, formValues url.Values) (responseData []byte, statusCode int, err error) {
contentType := "application/json-patch+json"
5 changes: 4 additions & 1 deletion server/plugin/command.go
Original file line number Diff line number Diff line change
@@ -41,7 +41,7 @@ func (ch *Handler) Handle(p *Plugin, c *plugin.Context, commandArgs *model.Comma
}

func (p *Plugin) getAutoCompleteData() *model.AutocompleteData {
azureDevops := model.NewAutocompleteData(constants.CommandTriggerName, "[command]", "Available commands: help, connect, disconnect, create")
azureDevops := model.NewAutocompleteData(constants.CommandTriggerName, "[command]", "Available commands: help, connect, disconnect, create, link")

help := model.NewAutocompleteData("help", "", fmt.Sprintf("Show %s slash command help", constants.CommandTriggerName))
azureDevops.AddCommand(help)
@@ -55,6 +55,9 @@ func (p *Plugin) getAutoCompleteData() *model.AutocompleteData {
create := model.NewAutocompleteData("boards create", "", "create a new task")
azureDevops.AddCommand(create)

link := model.NewAutocompleteData("link", "[link]", "link a project")
azureDevops.AddCommand(link)

return azureDevops
}

21 changes: 9 additions & 12 deletions server/plugin/utils.go
Original file line number Diff line number Diff line change
@@ -11,6 +11,7 @@ import (
"strings"

"github.com/Brightscout/mattermost-plugin-azure-devops/server/constants"
"github.com/Brightscout/mattermost-plugin-azure-devops/server/serializers"
"github.com/mattermost/mattermost-server/v5/model"
"github.com/pkg/errors"
)
@@ -164,15 +165,11 @@ func (p *Plugin) AddAuthorization(r *http.Request, mattermostUserID string) erro
return nil
}

// TODO: WIP.
// StringToInt function to convert string to int.
// func StringToInt(str string) int {
// if str == "" {
// return 0
// }
// val, err := strconv.ParseInt(str, 10, 64)
// if err != nil {
// return 0
// }
// return int(val)
// }
func (p *Plugin) IsProjectLinked(projectList []serializers.ProjectDetails, project serializers.ProjectDetails) bool {
for _, a := range projectList {
if a.ProjectName == project.ProjectName && a.OrganizationName == project.OrganizationName {
return true
}
}
return false
}
31 changes: 31 additions & 0 deletions server/serializers/link.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package serializers

import (
"github.com/Brightscout/mattermost-plugin-azure-devops/server/constants"
)

type LinkRequestPayload struct {
Organization string `json:"organization"`
Project string `json:"project"`
}

type Project struct {
ID string `json:"id"`
Name string `json:"name"`
Link ProjectLink `json:"_links"`
}

type ProjectLink struct {
Web Href `json:"web"`
}

// IsLinkPayloadValid function to validate request payload.
func (t *LinkRequestPayload) IsLinkPayloadValid() string {
if t.Organization == "" {
return constants.OrganizationRequired
}
if t.Project == "" {
return constants.ProjectRequired
}
return ""
}
7 changes: 7 additions & 0 deletions server/serializers/project.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
package serializers

type ProjectDetails struct {
MattermostUserID string
ProjectID string
ProjectName string
OrganizationName string
}

// TODO: Remove later if not needed.
// import (
// "time"
122 changes: 122 additions & 0 deletions server/store/link.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package store

import (
"encoding/json"

"github.com/Brightscout/mattermost-plugin-azure-devops/server/constants"
"github.com/Brightscout/mattermost-plugin-azure-devops/server/serializers"
"github.com/pkg/errors"
)

type ProjectListMap map[string]serializers.ProjectDetails

type ProjectList struct {
ByMattermostUserID map[string]ProjectListMap
}

func NewProjectList() *ProjectList {
return &ProjectList{
ByMattermostUserID: map[string]ProjectListMap{},
}
}

func (s *Store) StoreProject(project *serializers.ProjectDetails) error {
key := GetProjectListMapKey()
if err := s.AtomicModify(key, func(initialBytes []byte) ([]byte, error) {
projectList, err := ProjectListFromJSON(initialBytes)
if err != nil {
return nil, err
}
projectList.AddProject(project.MattermostUserID, project)
modifiedBytes, marshalErr := json.Marshal(projectList)
if marshalErr != nil {
return nil, marshalErr
}
return modifiedBytes, nil
}); err != nil {
return err
}

return nil
}

func (projectList *ProjectList) AddProject(userID string, project *serializers.ProjectDetails) {
if _, valid := projectList.ByMattermostUserID[userID]; !valid {
projectList.ByMattermostUserID[userID] = make(ProjectListMap)
}
projectKey := GetProjectKey(project.ProjectID, userID)
projectListValue := serializers.ProjectDetails{
MattermostUserID: userID,
ProjectID: project.ProjectID,
ProjectName: project.ProjectName,
OrganizationName: project.OrganizationName,
}
projectList.ByMattermostUserID[userID][projectKey] = projectListValue
}

func (s *Store) GetProject() (*ProjectList, error) {
key := GetProjectListMapKey()
initialBytes, appErr := s.Load(key)
if appErr != nil {
return nil, errors.New(constants.GetProjectListError)
}
projects, err := ProjectListFromJSON(initialBytes)
if err != nil {
return nil, errors.New(constants.GetProjectListError)
}
return projects, nil
}

func (s *Store) GetAllProjects(userID string) ([]serializers.ProjectDetails, error) {
projects, err := s.GetProject()
if err != nil {
return nil, err
}
var projectList []serializers.ProjectDetails
for _, project := range projects.ByMattermostUserID[userID] {
projectList = append(projectList, project)
}
return projectList, nil
}

func (s *Store) DeleteProject(project *serializers.ProjectDetails) error {
key := GetProjectListMapKey()
if err := s.AtomicModify(key, func(initialBytes []byte) ([]byte, error) {
projectList, err := ProjectListFromJSON(initialBytes)
if err != nil {
return nil, err
}
projectKey := GetProjectKey(project.ProjectID, project.MattermostUserID)
projectList.DeleteProjectByKey(project.MattermostUserID, projectKey)
modifiedBytes, marshalErr := json.Marshal(projectList)
if marshalErr != nil {
return nil, marshalErr
}
return modifiedBytes, nil
}); err != nil {
return err
}

return nil
}

func (projectList *ProjectList) DeleteProjectByKey(userID, projectKey string) {
for key := range projectList.ByMattermostUserID[userID] {
if key == projectKey {
delete(projectList.ByMattermostUserID[userID], key)
}
}
}

func ProjectListFromJSON(bytes []byte) (*ProjectList, error) {
var projectList *ProjectList
if len(bytes) != 0 {
unmarshalErr := json.Unmarshal(bytes, &projectList)
if unmarshalErr != nil {
return nil, unmarshalErr
}
} else {
projectList = NewProjectList()
}
return projectList, nil
}
6 changes: 4 additions & 2 deletions server/store/oAuth.go
Original file line number Diff line number Diff line change
@@ -7,11 +7,13 @@ import (
)

func (s *Store) StoreOAuthState(mattermostUserID, state string) error {
return s.StoreTTL(mattermostUserID, []byte(state), constants.TTLSecondsForOAuthState)
oAuthKey := GetOAuthKey(mattermostUserID)
return s.StoreTTL(oAuthKey, []byte(state), constants.TTLSecondsForOAuthState)
}

func (s *Store) VerifyOAuthState(mattermostUserID, state string) error {
storedState, err := s.Load(mattermostUserID)
oAuthKey := GetOAuthKey(mattermostUserID)
storedState, err := s.Load(oAuthKey)
if err != nil {
if err == ErrNotFound {
return errors.New(constants.AuthAttemptExpired)
Loading