Skip to content

Commit

Permalink
[MI-1948] Add API to create task (#5)
Browse files Browse the repository at this point in the history
* [MI-1846]: Added nvmrc file

* Modular folder structure

* Modular folder structure

* [MI-1846]: Base setup

* [MI-1846]: Refactored server base setup

* [MI-1854]: Implement OAuth to access Azure DevOps services

* [MI-1931] Add API to get projects and get tasks

* [MI-1931] Self review fixes

* [MI-1931] Review fixes 1

* [MI-1931] Self review fix

* [MI-1945] Add function to make protected routes

* [MI-1846]: Review fixes

* [MI-1931] Review fixes 2

* [MI-1948] Add API to create task

* [MI-1931] Add json error handling

* [MI-1948] Add error in json format

* [MI-1931] Add comment to the code

* [MI-1931] Add error check

* [MI-1945] Review fix

* [MI-1948] Review fixes 1

* [MI-1846]: Review fixes

* [MI-1854]: Review fixes

* [MI-1854]: Removed unused config

* [MI-1854]: Added logic to verify state

* [MI-1931] Run fmt

* [MI-1945] Review fix 2

* [MI-1948] Self review fix

* [MI-1854]: Removed unused configs

* [MI-1854]: Review fixes

* [MI-1948] Return status code from client

* [MI-1931] Remove unused code

* [MI-1931] Correct spelling error

* [MI-1948] Add wrapper for application/json-patch+json content type

* [MI-1948] Correct error handling

* [MI-1948] Correct serializer name and add a method

* [MI-1948] Correct constants

Co-authored-by: Abhishek Verma <abhishek.verma@joshtechnologygroup.com>
Co-authored-by: Abhishek Verma <abhishek.verma@brightscout.com>
  • Loading branch information
3 people authored Aug 9, 2022
1 parent 42f9cf1 commit d22b29b
Show file tree
Hide file tree
Showing 6 changed files with 196 additions and 25 deletions.
10 changes: 9 additions & 1 deletion server/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,22 @@ const (

// Plugin configs
PluginID = "mattermost-plugin-azure-devops"
HeaderMattermostUserID = "Mattermost-User-ID"
ChannelID = "channel_id"
HeaderMattermostUserID = "Mattermost-User-ID"
// TODO: Change later according to the needs.
HeaderMattermostUserIDAPI = "User-ID"

// Command configs
CommandTriggerName = "azuredevops"
HelpText = "###### Mattermost Azure Devops Plugin - Slash Command Help\n"
InvalidCommand = "Invalid command parameters. Please use `/azuredevops help` for more information."

// Azure API Routes
CreateTask = "/%s/%s/_apis/wit/workitems/$%s?api-version=" + CreateTaskAPIVersion

// Azure API Versions
CreateTaskAPIVersion = "7.1-preview.3"

// Authorization constants
Bearer = "Bearer"
Authorization = "Authorization"
Expand Down
4 changes: 4 additions & 0 deletions server/constants/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ const (
GenericErrorMessage = "Something went wrong, please try again later"
Error = "error"
NotAuthorized = "not authorized"
OrganizationRequired = "organization is required"
ProjectRequired = "project is required"
TaskTypeRequired = "task type is required"
TaskTitleRequired = "task title is required"
ConnectAccount = "[Click here to link your Azure DevOps account](%s%s)"
ConnectAccountFirst = "You do not have any Azure Devops account connected. Kindly link the account first"
UserConnected = "Your Azure Devops account is succesfully connected!"
Expand Down
35 changes: 35 additions & 0 deletions server/plugin/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,41 @@ func (p *Plugin) InitRoutes() {
s.HandleFunc(constants.PathOAuthCallback, p.OAuthComplete).Methods(http.MethodGet)
// TODO: for testing purpose, remove later
s.HandleFunc("/test", p.testAPI).Methods(http.MethodGet)
s.HandleFunc("/tasks", p.handleAuthRequired(p.handleCreateTask)).Methods(http.MethodPost)
}

// API to create task of a project in an organization.
func (p *Plugin) handleCreateTask(w http.ResponseWriter, r *http.Request) {
mattermostUserID := r.Header.Get(constants.HeaderMattermostUserIDAPI)

body, err := serializers.CreateTaskRequestPayloadFromJSON(r.Body)
if err != nil {
p.API.LogError("Error in decoding the body for creating a task", "Error", err.Error())
p.handleError(w, r, &serializers.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}

if err := body.IsValid(); err != nil {
p.handleError(w, r, &serializers.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}

task, statusCode, err := p.Client.CreateTask(body, mattermostUserID)
if err != nil {
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)
}
}

// handleAuthRequired verifies if the provided request is performed by an authorized source.
Expand Down
91 changes: 68 additions & 23 deletions server/plugin/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import (

type Client interface {
TestApi() (string, error) // TODO: remove later
GenerateOAuthToken(encodedFormValues string) (*serializers.OAuthSuccessResponse, error)
GenerateOAuthToken(encodedFormValues string) (*serializers.OAuthSuccessResponse, int, error)
CreateTask(body *serializers.CreateTaskRequestPayload, mattermostUserID string) (*serializers.TaskValue, int, error)
}

type client struct {
Expand All @@ -34,45 +35,89 @@ func (c *client) TestApi() (string, error) {
return "hello world", nil
}

func (c *client) GenerateOAuthToken(encodedFormValues string) (*serializers.OAuthSuccessResponse, error) {
// Function to create task for a project.
func (c *client) CreateTask(body *serializers.CreateTaskRequestPayload, mattermostUserID string) (*serializers.TaskValue, int, error) {
taskURL := fmt.Sprintf(constants.CreateTask, body.Organization, body.Project, body.Type)

// Create request body.
payload := []*serializers.CreateTaskBodyPayload{}
payload = append(payload,
&serializers.CreateTaskBodyPayload{
Operation: "add",
Path: "/fields/System.Title",
From: "",
Value: body.Fields.Title,
})

if body.Fields.Description != "" {
payload = append(payload,
&serializers.CreateTaskBodyPayload{
Operation: "add",
Path: "/fields/System.Description",
From: "",
Value: body.Fields.Description,
})
}

var task *serializers.TaskValue
_, statusCode, err := c.callPatchJSON(c.plugin.getConfiguration().AzureDevopsAPIBaseURL, taskURL, http.MethodPost, mattermostUserID, payload, &task)
if err != nil {
return nil, statusCode, errors.Wrap(err, "failed to create task")
}

return task, statusCode, nil
}

func (c *client) GenerateOAuthToken(encodedFormValues string) (*serializers.OAuthSuccessResponse, int, error) {
var oAuthSuccessResponse *serializers.OAuthSuccessResponse

if _, err := c.callFormURLEncoded(constants.BaseOauthURL, constants.PathToken, http.MethodPost, &oAuthSuccessResponse, encodedFormValues); err != nil {
return nil, err
_, statusCode, err := c.callFormURLEncoded(constants.BaseOauthURL, constants.PathToken, http.MethodPost, &oAuthSuccessResponse, encodedFormValues)
if err != nil {
return nil, statusCode, err
}

return oAuthSuccessResponse, nil
return oAuthSuccessResponse, statusCode, nil
}

// 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{}) (responseData []byte, statusCode int, err error) {
contentType := "application/json-patch+json"
buf := &bytes.Buffer{}
if err = json.NewEncoder(buf).Encode(in); err != nil {
return nil, http.StatusInternalServerError, err
}
return c.call(url, method, path, contentType, mattermostUserID, buf, out, "")
}

// Wrapper to make REST API requests with "application/json" type content
func (c *client) callJSON(url, path, method string, mattermostUserID string, in, out interface{}) (responseData []byte, err error) {
func (c *client) callJSON(url, path, method string, mattermostUserID string, in, out interface{}) (responseData []byte, statusCode int, err error) {
contentType := "application/json"
buf := &bytes.Buffer{}
if err = json.NewEncoder(buf).Encode(in); err != nil {
return nil, err
return nil, http.StatusInternalServerError, err
}
return c.call(url, method, path, contentType, mattermostUserID, buf, out, "")
}

// 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 string) (responseData []byte, err error) {
func (c *client) callFormURLEncoded(url, path, method string, out interface{}, formValues string) (responseData []byte, statusCode int, err error) {
contentType := "application/x-www-form-urlencoded"
return c.call(url, method, path, contentType, "", nil, out, formValues)
}

// Makes HTTP request to REST APIs
func (c *client) call(basePath, method, path, contentType string, mattermostUserID string, inBody io.Reader, out interface{}, formValues string) (responseData []byte, err error) {
func (c *client) call(basePath, method, path, contentType string, mattermostUserID string, inBody io.Reader, out interface{}, formValues string) (responseData []byte, statusCode int, err error) {
errContext := fmt.Sprintf("Azure Devops: Call failed: method:%s, path:%s", method, path)
pathURL, err := url.Parse(path)
if err != nil {
return nil, errors.WithMessage(err, errContext)
return nil, http.StatusInternalServerError, errors.WithMessage(err, errContext)
}

if pathURL.Scheme == "" || pathURL.Host == "" {
var baseURL *url.URL
baseURL, err = url.Parse(basePath)
if err != nil {
return nil, errors.WithMessage(err, errContext)
return nil, http.StatusInternalServerError, errors.WithMessage(err, errContext)
}
if path[0] != '/' {
path = "/" + path
Expand All @@ -84,12 +129,12 @@ func (c *client) call(basePath, method, path, contentType string, mattermostUser
if formValues != "" {
req, err = http.NewRequest(method, path, strings.NewReader(formValues))
if err != nil {
return nil, err
return nil, http.StatusInternalServerError, err
}
} else {
req, err = http.NewRequest(method, path, inBody)
if err != nil {
return nil, err
return nil, http.StatusInternalServerError, err
}
}

Expand All @@ -99,46 +144,46 @@ func (c *client) call(basePath, method, path, contentType string, mattermostUser

if mattermostUserID != "" {
if err = c.plugin.AddAuthorization(req, mattermostUserID); err != nil {
return nil, err
return nil, http.StatusInternalServerError, err
}
}

resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
return nil, resp.StatusCode, err
}

if resp.Body == nil {
return nil, nil
return nil, resp.StatusCode, nil
}
defer resp.Body.Close()

responseData, err = ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
return nil, resp.StatusCode, err
}

switch resp.StatusCode {
case http.StatusOK, http.StatusCreated:
if out != nil {
if err = json.Unmarshal(responseData, out); err != nil {
return responseData, err
return responseData, http.StatusInternalServerError, err
}
}
return responseData, nil
return responseData, resp.StatusCode, nil

case http.StatusNoContent:
return nil, nil
return nil, resp.StatusCode, nil

case http.StatusNotFound:
return nil, ErrNotFound
return nil, resp.StatusCode, ErrNotFound
}

errResp := ErrorResponse{}
if err = json.Unmarshal(responseData, &errResp); err != nil {
return responseData, errors.WithMessagef(err, "status: %s", resp.Status)
return responseData, http.StatusInternalServerError, errors.WithMessagef(err, "status: %s", resp.Status)
}
return responseData, fmt.Errorf("errorMessage %s", errResp.Message)
return responseData, resp.StatusCode, fmt.Errorf("errorMessage %s", errResp.Message)
}

func InitClient(p *Plugin) Client {
Expand Down
2 changes: 1 addition & 1 deletion server/plugin/oAuth.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ func (p *Plugin) GenerateOAuthToken(code, state string) error {
"redirect_uri": {fmt.Sprintf("%s%s%s", p.GetSiteURL(), p.GetPluginURLPath(), constants.PathOAuthCallback)},
}

successResponse, err := p.Client.GenerateOAuthToken(oauthTokenFormValues.Encode())
successResponse, _, err := p.Client.GenerateOAuthToken(oauthTokenFormValues.Encode())
if err != nil {
p.DM(mattermostUserID, constants.GenericErrorMessage)
return errors.Wrap(err, err.Error())
Expand Down
79 changes: 79 additions & 0 deletions server/serializers/task.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package serializers

import (
"encoding/json"
"errors"
"io"
"time"

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

type TaskValue struct {
ID int `json:"id"`
Fields TaskFieldValue `json:"fields"`
}

type TaskFieldValue struct {
Title string `json:"System.Title"`
Project string `json:"System.TeamProject"`
Type string `json:"System.WorkItemType"`
State string `json:"System.State"`
Reason string `json:"System.Reason"`
AssignedTo TaskUserDetails `json:"System.AssignedTo"`
CreatedAt time.Time `json:"System.CreatedDate"`
CreatedBy TaskUserDetails `json:"System.CreatedBy"`
UpdatedAt time.Time `json:"System.ChangedDate"`
UpdatedBy TaskUserDetails `json:"System.ChangedBy"`
Description string `json:"System.Description"`
}

type TaskUserDetails struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
UniqueName string `json:"uniqueName"`
}

type CreateTaskRequestPayload struct {
Organization string `json:"organization"`
Project string `json:"project"`
Type string `json:"type"`
Fields CreateTaskFieldValue `json:"fields"`
}

type CreateTaskFieldValue struct {
Title string `json:"title"`
Description string `json:"description"`
}

type CreateTaskBodyPayload struct {
Operation string `json:"op"`
Path string `json:"path"`
From string `json:"from"`
Value string `json:"value"`
}

// IsValid function to validate request payload.
func (t *CreateTaskRequestPayload) IsValid() error {
if t.Organization == "" {
return errors.New(constants.OrganizationRequired)
}
if t.Project == "" {
return errors.New(constants.ProjectRequired)
}
if t.Type == "" {
return errors.New(constants.TaskTypeRequired)
}
if t.Fields.Title == "" {
return errors.New(constants.TaskTitleRequired)
}
return nil
}

func CreateTaskRequestPayloadFromJSON(data io.Reader) (*CreateTaskRequestPayload, error) {
var body *CreateTaskRequestPayload
if err := json.NewDecoder(data).Decode(&body); err != nil {
return nil, err
}
return body, nil
}

0 comments on commit d22b29b

Please sign in to comment.