Skip to content
Merged
44 changes: 25 additions & 19 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,32 +1,36 @@
module github.com/mattermost/mattermost-plugin-gitlab

go 1.25
go 1.26.2

require (
github.com/gorilla/mux v1.8.1
github.com/hashicorp/go-multierror v1.1.1
github.com/mattermost/mattermost/server/public v0.1.21
github.com/mattermost/mattermost-plugin-agents v1.14.1-0.20260508173910-8219eb13bd4e
github.com/mattermost/mattermost/server/public v0.3.1-0.20260402155910-d9d71af83e3f
github.com/microcosm-cc/bluemonday v1.0.27
github.com/modelcontextprotocol/go-sdk v1.4.1
github.com/pkg/errors v0.9.1
github.com/stretchr/testify v1.11.1
github.com/xanzy/go-gitlab v0.97.0
go.uber.org/mock v0.4.0
golang.org/x/oauth2 v0.34.0
golang.org/x/sync v0.19.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.20.0
gopkg.in/yaml.v3 v3.0.1
)

require (
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beevik/etree v1.6.0 // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/francoispqt/gojay v1.2.13 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
Expand All @@ -37,37 +41,39 @@ require (
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/lib/pq v1.12.0 // indirect
github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 // indirect
github.com/mattermost/gosaml2 v0.10.0 // indirect
github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 // indirect
github.com/mattermost/logr/v2 v2.0.22 // indirect
github.com/mattermost/mattermost/server/v8 v8.0.0-20251014075701-833e0125320d // indirect
github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/oklog/run v1.2.0 // indirect
github.com/pborman/uuid v1.2.1 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/russellhaering/goxmldsig v1.5.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/russellhaering/goxmldsig v1.6.0 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/tinylib/msgp v1.4.0 // indirect
github.com/tinylib/msgp v1.6.3 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/wiggin77/merror v1.0.5 // indirect
github.com/wiggin77/srslog v1.0.1 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/time v0.3.0 // indirect
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.10 // indirect
google.golang.org/grpc v1.81.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
119 changes: 66 additions & 53 deletions go.sum

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion server/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ func (p *Plugin) initializeAPI() {
p.router = mux.NewRouter()
p.router.Use(p.withRecovery)

p.router.PathPrefix("/mcp").HandlerFunc(p.serveMCPHTTP)

oauthRouter := p.router.PathPrefix("/oauth").Subrouter()
apiRouter := p.router.PathPrefix("/api/v1").Subrouter()
apiRouter.Use(p.checkConfigured)
Expand Down Expand Up @@ -285,7 +287,7 @@ func (p *Plugin) connectUserToGitlab(c *Context, w http.ResponseWriter, r *http.
}

if errorMsg != "" {
_, err := p.poster.DMWithAttachments(userID, &model.SlackAttachment{
_, err := p.poster.DMWithAttachments(userID, &model.MessageAttachment{
Text: fmt.Sprintf("There was an error connecting to your GitLab: `%s` Please double check your configuration.", errorMsg),
Color: string(flow.ColorDanger),
})
Expand Down
2 changes: 1 addition & 1 deletion server/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,7 @@ func TestAddWebhookCommand(t *testing.T) {
p.GitlabClient = mockedClient

conf := &model.Config{}
conf.ServiceSettings.SiteURL = model.NewPointer(test.siteURL)
conf.ServiceSettings.SiteURL = new(test.siteURL)

encryptedToken, _ := encrypt([]byte(testEncryptionKey), testGitlabToken)

Expand Down
6 changes: 3 additions & 3 deletions server/gitlab/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -710,8 +710,8 @@ func (g *gitlab) GetYourProjects(ctx context.Context, user *UserInfo, token *oau
if g.gitlabGroup == "" {
// ─── “No Group” branch: list all projects you belong to
opts := &internGitlab.ListProjectsOptions{
Membership: model.NewPointer(true),
WithIssuesEnabled: model.NewPointer(true),
Membership: new(true),
WithIssuesEnabled: new(true),
MinAccessLevel: model.NewPointer(guestLevel),
ListOptions: internGitlab.ListOptions{
Page: 1,
Expand All @@ -729,7 +729,7 @@ func (g *gitlab) GetYourProjects(ctx context.Context, user *UserInfo, token *oau
}
// ─── “With Group” branch: list all projects in that group you have access to
opts := &internGitlab.ListGroupProjectsOptions{
WithIssuesEnabled: model.NewPointer(true),
WithIssuesEnabled: new(true),
MinAccessLevel: model.NewPointer(guestLevel),
ListOptions: internGitlab.ListOptions{
Page: 1,
Expand Down
7 changes: 7 additions & 0 deletions server/gitlab/gitlab.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ type Gitlab interface {
fullPath string,
allowPrivate bool,
) (namespace string, project string, err error)

UpdateIssue(ctx context.Context, user *UserInfo, token *oauth2.Token, projectID string, issueIID int, opts *UpdateIssueOptions) (*internGitlab.Issue, error)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I'd split out a GitlabMCP interface for the new ones.

AddIssueNote(ctx context.Context, user *UserInfo, token *oauth2.Token, projectID string, issueIID int, body string) (*internGitlab.Note, error)
SearchMergeRequests(ctx context.Context, user *UserInfo, token *oauth2.Token, search string) ([]*internGitlab.MergeRequest, error)
CreateMergeRequest(ctx context.Context, user *UserInfo, token *oauth2.Token, projectID string, opts *CreateMergeRequestOptions) (*internGitlab.MergeRequest, error)
AddMergeRequestNote(ctx context.Context, user *UserInfo, token *oauth2.Token, projectID string, mrIID int, body string) (*internGitlab.Note, error)
ListProjectPipelines(ctx context.Context, user *UserInfo, token *oauth2.Token, projectID string, ref string, status string, page int, perPage int) ([]*internGitlab.PipelineInfo, error)
}

type gitlab struct {
Expand Down
217 changes: 217 additions & 0 deletions server/gitlab/mcp_api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

package gitlab

import (
"context"
"fmt"

internGitlab "github.com/xanzy/go-gitlab"
"golang.org/x/oauth2"
)

// UpdateIssueOptions contains the fields that can be updated on an existing issue.
type UpdateIssueOptions struct {
Title *string
Description *string
// StateEvent is "close" or "reopen".
StateEvent *string
AssigneeIDs *[]int
Labels *internGitlab.LabelOptions
MilestoneID *int
}

// CreateMergeRequestOptions contains the required and optional fields for creating
// a new merge request.
type CreateMergeRequestOptions struct {
Title string
Description string
SourceBranch string
TargetBranch string
AssigneeIDs []int
ReviewerIDs []int
Labels internGitlab.LabelOptions
MilestoneID *int
}

func (g *gitlab) UpdateIssue(ctx context.Context, user *UserInfo, token *oauth2.Token, projectID string, issueIID int, opts *UpdateIssueOptions) (*internGitlab.Issue, error) {
client, err := g.GitlabConnect(*token)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if err != nil {
return nil, err
}
if err = g.ensureProjectInAllowedGroup(ctx, client, projectID); err != nil {
return nil, err
}

updateOpts := &internGitlab.UpdateIssueOptions{
Title: opts.Title,
Description: opts.Description,
StateEvent: opts.StateEvent,
AssigneeIDs: opts.AssigneeIDs,
Labels: opts.Labels,
MilestoneID: opts.MilestoneID,
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

issue, resp, err := client.Issues.UpdateIssue(projectID, issueIID, updateOpts, internGitlab.WithContext(ctx))
if respErr := checkResponse(resp); respErr != nil {
return nil, respErr
}
if err != nil {
return nil, fmt.Errorf("failed to update issue: %w", err)
}

return issue, nil
}

func (g *gitlab) AddIssueNote(ctx context.Context, user *UserInfo, token *oauth2.Token, projectID string, issueIID int, body string) (*internGitlab.Note, error) {
client, err := g.GitlabConnect(*token)
if err != nil {
return nil, err
}
if err = g.ensureProjectInAllowedGroup(ctx, client, projectID); err != nil {
return nil, err
}

note, resp, err := client.Notes.CreateIssueNote(
projectID,
issueIID,
&internGitlab.CreateIssueNoteOptions{Body: &body},
internGitlab.WithContext(ctx),
)
if respErr := checkResponse(resp); respErr != nil {
return nil, respErr
}
if err != nil {
return nil, fmt.Errorf("failed to add issue comment: %w", err)
}

return note, nil
}

func (g *gitlab) SearchMergeRequests(ctx context.Context, user *UserInfo, token *oauth2.Token, search string) ([]*internGitlab.MergeRequest, error) {
client, err := g.GitlabConnect(*token)
if err != nil {
return nil, err
}

var (
result []*internGitlab.MergeRequest
resp *internGitlab.Response
)
if g.gitlabGroup == "" {
result, resp, err = client.Search.MergeRequests(search, &internGitlab.SearchOptions{}, internGitlab.WithContext(ctx))
} else {
result, resp, err = client.Search.MergeRequestsByGroup(g.gitlabGroup, search, &internGitlab.SearchOptions{}, internGitlab.WithContext(ctx))
}
if respErr := checkResponse(resp); respErr != nil {
return nil, respErr
}
if err != nil {
return nil, fmt.Errorf("failed to search merge requests: %w", err)
}

return result, nil
}

func (g *gitlab) CreateMergeRequest(ctx context.Context, user *UserInfo, token *oauth2.Token, projectID string, opts *CreateMergeRequestOptions) (*internGitlab.MergeRequest, error) {
client, err := g.GitlabConnect(*token)
if err != nil {
return nil, err
}
if err = g.ensureProjectInAllowedGroup(ctx, client, projectID); err != nil {
return nil, err
}

createOpts := &internGitlab.CreateMergeRequestOptions{
Title: &opts.Title,
Description: &opts.Description,
SourceBranch: &opts.SourceBranch,
TargetBranch: &opts.TargetBranch,
}
if len(opts.AssigneeIDs) > 0 {
createOpts.AssigneeIDs = &opts.AssigneeIDs
}
if len(opts.ReviewerIDs) > 0 {
createOpts.ReviewerIDs = &opts.ReviewerIDs
}
if len(opts.Labels) > 0 {
createOpts.Labels = &opts.Labels
}
if opts.MilestoneID != nil {
createOpts.MilestoneID = opts.MilestoneID
}

mr, resp, err := client.MergeRequests.CreateMergeRequest(projectID, createOpts, internGitlab.WithContext(ctx))
if respErr := checkResponse(resp); respErr != nil {
return nil, respErr
}
if err != nil {
return nil, fmt.Errorf("failed to create merge request: %w", err)
}

return mr, nil
}

func (g *gitlab) AddMergeRequestNote(ctx context.Context, user *UserInfo, token *oauth2.Token, projectID string, mrIID int, body string) (*internGitlab.Note, error) {
client, err := g.GitlabConnect(*token)
if err != nil {
return nil, err
}
if err = g.ensureProjectInAllowedGroup(ctx, client, projectID); err != nil {
return nil, err
}

note, resp, err := client.Notes.CreateMergeRequestNote(
projectID,
mrIID,
&internGitlab.CreateMergeRequestNoteOptions{Body: &body},
internGitlab.WithContext(ctx),
)
if respErr := checkResponse(resp); respErr != nil {
return nil, respErr
}
if err != nil {
return nil, fmt.Errorf("failed to add merge request comment: %w", err)
}

return note, nil
}

func (g *gitlab) ListProjectPipelines(ctx context.Context, user *UserInfo, token *oauth2.Token, projectID string, ref string, status string, page int, perPage int) ([]*internGitlab.PipelineInfo, error) {
client, err := g.GitlabConnect(*token)
if err != nil {
return nil, err
}
if err = g.ensureProjectInAllowedGroup(ctx, client, projectID); err != nil {
return nil, err
}

if perPage <= 0 {
perPage = 20
}
if page <= 0 {
page = 1
}

opts := &internGitlab.ListProjectPipelinesOptions{
ListOptions: internGitlab.ListOptions{Page: page, PerPage: perPage},
}
if ref != "" {
opts.Ref = &ref
}
if status != "" {
bs := internGitlab.BuildStateValue(status)
opts.Status = &bs
}

pipelines, resp, err := client.Pipelines.ListProjectPipelines(projectID, opts, internGitlab.WithContext(ctx))
if respErr := checkResponse(resp); respErr != nil {
return nil, respErr
}
if err != nil {
return nil, fmt.Errorf("failed to list pipelines: %w", err)
}

return pipelines, nil
}
Loading
Loading