diff --git a/go.mod b/go.mod index 40365ba8f..aaadb57f6 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,15 @@ module github.com/mattermost/mattermost-plugin-gitlab -go 1.25.8 +go 1.26.2 require ( + github.com/Masterminds/semver/v3 v3.4.0 github.com/gorilla/mux v1.8.1 github.com/hashicorp/go-multierror v1.1.1 - github.com/mattermost/mattermost/server/public v0.3.0 + 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 @@ -17,7 +20,6 @@ require ( ) 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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -28,6 +30,7 @@ require ( 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 @@ -45,13 +48,15 @@ require ( github.com/mattermost/logr/v2 v2.0.22 // 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.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.6.3 // indirect @@ -59,6 +64,7 @@ require ( 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 + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/crypto v0.52.0 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.55.0 // indirect @@ -66,7 +72,7 @@ require ( golang.org/x/text v0.37.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/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 diff --git a/go.sum b/go.sum index 351eff069..24e92c18c 100644 --- a/go.sum +++ b/go.sum @@ -52,6 +52,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= @@ -71,6 +73,8 @@ github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+u github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -133,8 +137,10 @@ github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 h1:Y1Tu/swM31pVwwb github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956/go.mod h1:SRl30Lb7/QoYyohYeVBuqYvvmXSZJxZgiV3Zf6VbxjI= github.com/mattermost/logr/v2 v2.0.22 h1:npFkXlkAWR9J8payh8ftPcCZvLbHSI125mAM5/r/lP4= github.com/mattermost/logr/v2 v2.0.22/go.mod h1:0sUKpO+XNMZApeumaid7PYaUZPBIydfuWZ0dqixXo+s= -github.com/mattermost/mattermost/server/public v0.3.0 h1:AtzCjypbLcvSVQZMg0vKWL57vVfLSCC46j1nsOof2Ko= -github.com/mattermost/mattermost/server/public v0.3.0/go.mod h1:QnF/1Evlh7e3G8ifwut7Q5Joy/t4oHYNcDoyBTYuXho= +github.com/mattermost/mattermost-plugin-agents v1.14.1-0.20260508173910-8219eb13bd4e h1:FWqqXy4T6kULQ7zt3IJAJ+AKZ+bHPMJCMUhrIOO9jUQ= +github.com/mattermost/mattermost-plugin-agents v1.14.1-0.20260508173910-8219eb13bd4e/go.mod h1:Ca1M+q6C0EwPEbDBZyPyqKlRdwv3NXbo+vd19B6MOgU= +github.com/mattermost/mattermost/server/public v0.3.1-0.20260402155910-d9d71af83e3f h1:FXDfzbDTk86bKEgBATCTAb3AWsQVzJMn9ruLY72nmQk= +github.com/mattermost/mattermost/server/public v0.3.1-0.20260402155910-d9d71af83e3f/go.mod h1:QnF/1Evlh7e3G8ifwut7Q5Joy/t4oHYNcDoyBTYuXho= github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -143,12 +149,14 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= +github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= @@ -182,6 +190,10 @@ github.com/russellhaering/goxmldsig v1.2.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWy github.com/russellhaering/goxmldsig v1.6.0 h1:8fdWXEPh2k/NZNQBPFNoVfS3JmzS4ZprY/sAOpKQLks= github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBeYAqaHEyUeWPxZlBM= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY= github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM= @@ -232,19 +244,21 @@ github.com/wiggin77/srslog v1.0.1 h1:gA2XjSMy3DrRdX9UqLuDtuVAAshb8bE1NhX1YK0Qe+8 github.com/wiggin77/srslog v1.0.1/go.mod h1:fehkyYDq1QfuYn60TDPu9YdY2bB85VUW2mvN1WynEls= github.com/xanzy/go-gitlab v0.97.0 h1:StMqJ1Kvt00X43pYIBBjj52dFlghwSeBhRDRfzaZ7xY= github.com/xanzy/go-gitlab v0.97.0/go.mod h1:ETg8tcj4OhrB84UEgeE8dSuV/0h4BBL1uOV/qK0vlyI= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= @@ -296,7 +310,6 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -314,9 +327,11 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= @@ -335,8 +350,8 @@ google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmE google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= +google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/server/api.go b/server/api.go index 6e1f14adc..20527eb03 100644 --- a/server/api.go +++ b/server/api.go @@ -51,6 +51,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) diff --git a/server/command_test.go b/server/command_test.go index 38d8ef51c..4a79e6b71 100644 --- a/server/command_test.go +++ b/server/command_test.go @@ -510,7 +510,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) @@ -541,7 +541,7 @@ func TestAddWebhookCommandNamespaceNotAllowed(t *testing.T) { p.GitlabClient = mockedClient conf := &model.Config{} - conf.ServiceSettings.SiteURL = model.NewPointer("https://example.com") + conf.ServiceSettings.SiteURL = new("https://example.com") encryptedToken, _ := encrypt([]byte(testEncryptionKey), testGitlabToken) @@ -573,7 +573,7 @@ func TestAddWebhookCommandForbidden(t *testing.T) { p.GitlabClient = mockedClient conf := &model.Config{} - conf.ServiceSettings.SiteURL = model.NewPointer("https://example.com") + conf.ServiceSettings.SiteURL = new("https://example.com") encryptedToken, _ := encrypt([]byte(testEncryptionKey), testGitlabToken) diff --git a/server/gitlab/api.go b/server/gitlab/api.go index 58657c0b9..562c9728e 100644 --- a/server/gitlab/api.go +++ b/server/gitlab/api.go @@ -713,8 +713,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, @@ -732,7 +732,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, diff --git a/server/gitlab/gitlab.go b/server/gitlab/gitlab.go index 04229dd47..053bf4161 100644 --- a/server/gitlab/gitlab.go +++ b/server/gitlab/gitlab.go @@ -63,6 +63,21 @@ type Gitlab interface { fullPath string, allowPrivate bool, ) (namespace string, project string, err error) + + GitlabMCP +} + +// GitlabMCP groups the token-scoped operations consumed by the MCP tool +// handlers. Every method takes an OAuth token and constructs the GitLab client +// internally so the raw *internGitlab.Client never leaks out of this package. +type GitlabMCP interface { + UpdateIssue(ctx context.Context, user *UserInfo, token *oauth2.Token, projectID string, issueIID int, opts *UpdateIssueOptions) (*internGitlab.Issue, error) + 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) + AddMergeRequestNote(ctx context.Context, user *UserInfo, token *oauth2.Token, projectID string, mrIID int, body string) (*internGitlab.Note, error) + ListAssignedIssues(ctx context.Context, user *UserInfo, token *oauth2.Token) ([]*internGitlab.Issue, error) + ListAssignedMergeRequests(ctx context.Context, user *UserInfo, token *oauth2.Token) ([]*internGitlab.MergeRequest, error) + ListReviewRequests(ctx context.Context, user *UserInfo, token *oauth2.Token) ([]*internGitlab.MergeRequest, error) } type gitlab struct { diff --git a/server/gitlab/mcp_api.go b/server/gitlab/mcp_api.go new file mode 100644 index 000000000..be5283627 --- /dev/null +++ b/server/gitlab/mcp_api.go @@ -0,0 +1,170 @@ +// 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 +} + +// mcpConnect validates the OAuth token and returns a connected GitLab client. +// Centralising the nil/empty-token guard keeps every MCP method from panicking +// on a missing token. +func (g *gitlab) mcpConnect(token *oauth2.Token) (*internGitlab.Client, error) { + if token == nil || token.AccessToken == "" { + return nil, fmt.Errorf("missing OAuth token") + } + return g.GitlabConnect(*token) +} + +func (g *gitlab) UpdateIssue(ctx context.Context, user *UserInfo, token *oauth2.Token, projectID string, issueIID int, opts *UpdateIssueOptions) (*internGitlab.Issue, error) { + if opts == nil { + return nil, fmt.Errorf("update issue options are required") + } + client, err := g.mcpConnect(token) + 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, + } + + 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.mcpConnect(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.mcpConnect(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) AddMergeRequestNote(ctx context.Context, user *UserInfo, token *oauth2.Token, projectID string, mrIID int, body string) (*internGitlab.Note, error) { + client, err := g.mcpConnect(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 +} + +// ListAssignedIssues returns the open issues assigned to the calling user. It +// wraps the client-based helper so MCP callers never hold a raw client. +func (g *gitlab) ListAssignedIssues(ctx context.Context, user *UserInfo, token *oauth2.Token) ([]*internGitlab.Issue, error) { + client, err := g.mcpConnect(token) + if err != nil { + return nil, err + } + return g.GetYourAssignedIssues(ctx, user, client) +} + +// ListAssignedMergeRequests returns the open merge requests assigned to the +// calling user. +func (g *gitlab) ListAssignedMergeRequests(ctx context.Context, user *UserInfo, token *oauth2.Token) ([]*internGitlab.MergeRequest, error) { + client, err := g.mcpConnect(token) + if err != nil { + return nil, err + } + return g.GetYourAssignedPrs(ctx, user, client) +} + +// ListReviewRequests returns the open merge requests awaiting the calling +// user's review. +func (g *gitlab) ListReviewRequests(ctx context.Context, user *UserInfo, token *oauth2.Token) ([]*internGitlab.MergeRequest, error) { + client, err := g.mcpConnect(token) + if err != nil { + return nil, err + } + return g.GetReviews(ctx, user, client) +} diff --git a/server/gitlab/mocks/mock_gitlab.go b/server/gitlab/mocks/mock_gitlab.go index 11c502954..7f230953e 100644 --- a/server/gitlab/mocks/mock_gitlab.go +++ b/server/gitlab/mocks/mock_gitlab.go @@ -43,6 +43,36 @@ func (m *MockGitlab) EXPECT() *MockGitlabMockRecorder { return m.recorder } +// AddIssueNote mocks base method. +func (m *MockGitlab) AddIssueNote(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *oauth2.Token, arg3 string, arg4 int, arg5 string) (*gitlab0.Note, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddIssueNote", arg0, arg1, arg2, arg3, arg4, arg5) + ret0, _ := ret[0].(*gitlab0.Note) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddIssueNote indicates an expected call of AddIssueNote. +func (mr *MockGitlabMockRecorder) AddIssueNote(arg0, arg1, arg2, arg3, arg4, arg5 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddIssueNote", reflect.TypeOf((*MockGitlab)(nil).AddIssueNote), arg0, arg1, arg2, arg3, arg4, arg5) +} + +// AddMergeRequestNote mocks base method. +func (m *MockGitlab) AddMergeRequestNote(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *oauth2.Token, arg3 string, arg4 int, arg5 string) (*gitlab0.Note, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddMergeRequestNote", arg0, arg1, arg2, arg3, arg4, arg5) + ret0, _ := ret[0].(*gitlab0.Note) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddMergeRequestNote indicates an expected call of AddMergeRequestNote. +func (mr *MockGitlabMockRecorder) AddMergeRequestNote(arg0, arg1, arg2, arg3, arg4, arg5 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddMergeRequestNote", reflect.TypeOf((*MockGitlab)(nil).AddMergeRequestNote), arg0, arg1, arg2, arg3, arg4, arg5) +} + // AttachCommentToIssue mocks base method. func (m *MockGitlab) AttachCommentToIssue(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *gitlab.IssueRequest, arg3, arg4 string, arg5 *oauth2.Token) (*gitlab0.Note, error) { m.ctrl.T.Helper() @@ -53,7 +83,7 @@ func (m *MockGitlab) AttachCommentToIssue(arg0 context.Context, arg1 *gitlab.Use } // AttachCommentToIssue indicates an expected call of AttachCommentToIssue. -func (mr *MockGitlabMockRecorder) AttachCommentToIssue(arg0, arg1, arg2, arg3, arg4, arg5 interface{}) *gomock.Call { +func (mr *MockGitlabMockRecorder) AttachCommentToIssue(arg0, arg1, arg2, arg3, arg4, arg5 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AttachCommentToIssue", reflect.TypeOf((*MockGitlab)(nil).AttachCommentToIssue), arg0, arg1, arg2, arg3, arg4, arg5) } @@ -68,7 +98,7 @@ func (m *MockGitlab) CreateIssue(arg0 context.Context, arg1 *gitlab.UserInfo, ar } // CreateIssue indicates an expected call of CreateIssue. -func (mr *MockGitlabMockRecorder) CreateIssue(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { +func (mr *MockGitlabMockRecorder) CreateIssue(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateIssue", reflect.TypeOf((*MockGitlab)(nil).CreateIssue), arg0, arg1, arg2, arg3) } @@ -88,6 +118,21 @@ func (mr *MockGitlabMockRecorder) GetCurrentUser(arg0, arg1, arg2 any) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCurrentUser", reflect.TypeOf((*MockGitlab)(nil).GetCurrentUser), arg0, arg1, arg2) } +// GetGroup mocks base method. +func (m *MockGitlab) GetGroup(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *oauth2.Token, arg3, arg4 string) (*gitlab0.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGroup", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].(*gitlab0.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGroup indicates an expected call of GetGroup. +func (mr *MockGitlabMockRecorder) GetGroup(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroup", reflect.TypeOf((*MockGitlab)(nil).GetGroup), arg0, arg1, arg2, arg3, arg4) +} + // GetGroupHooks mocks base method. func (m *MockGitlab) GetGroupHooks(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *oauth2.Token, arg3 string) ([]*gitlab.WebhookInfo, error) { m.ctrl.T.Helper() @@ -143,7 +188,7 @@ func (m *MockGitlab) GetLabels(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 } // GetLabels indicates an expected call of GetLabels. -func (mr *MockGitlabMockRecorder) GetLabels(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { +func (mr *MockGitlabMockRecorder) GetLabels(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLabels", reflect.TypeOf((*MockGitlab)(nil).GetLabels), arg0, arg1, arg2, arg3) } @@ -173,7 +218,7 @@ func (m *MockGitlab) GetMilestones(arg0 context.Context, arg1 *gitlab.UserInfo, } // GetMilestones indicates an expected call of GetMilestones. -func (mr *MockGitlabMockRecorder) GetMilestones(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { +func (mr *MockGitlabMockRecorder) GetMilestones(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMilestones", reflect.TypeOf((*MockGitlab)(nil).GetMilestones), arg0, arg1, arg2, arg3) } @@ -187,15 +232,6 @@ func (m *MockGitlab) GetProject(arg0 context.Context, arg1 *gitlab.UserInfo, arg return ret0, ret1 } -// GetGroup mocks base method. -func (m *MockGitlab) GetGroup(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *oauth2.Token, arg3, arg4 string) (*gitlab0.Group, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetGroup", arg0, arg1, arg2, arg3, arg4) - ret0, _ := ret[0].(*gitlab0.Group) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - // GetProject indicates an expected call of GetProject. func (mr *MockGitlabMockRecorder) GetProject(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { mr.mock.ctrl.T.Helper() @@ -227,7 +263,7 @@ func (m *MockGitlab) GetProjectMembers(arg0 context.Context, arg1 *gitlab.UserIn } // GetProjectMembers indicates an expected call of GetProjectMembers. -func (mr *MockGitlabMockRecorder) GetProjectMembers(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { +func (mr *MockGitlabMockRecorder) GetProjectMembers(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProjectMembers", reflect.TypeOf((*MockGitlab)(nil).GetProjectMembers), arg0, arg1, arg2, arg3) } @@ -332,7 +368,7 @@ func (m *MockGitlab) GetYourProjects(arg0 context.Context, arg1 *gitlab.UserInfo } // GetYourProjects indicates an expected call of GetYourProjects. -func (mr *MockGitlabMockRecorder) GetYourProjects(arg0, arg1, arg2 interface{}) *gomock.Call { +func (mr *MockGitlabMockRecorder) GetYourProjects(arg0, arg1, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetYourProjects", reflect.TypeOf((*MockGitlab)(nil).GetYourProjects), arg0, arg1, arg2) } @@ -352,6 +388,51 @@ func (mr *MockGitlabMockRecorder) GitlabConnect(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GitlabConnect", reflect.TypeOf((*MockGitlab)(nil).GitlabConnect), arg0) } +// ListAssignedIssues mocks base method. +func (m *MockGitlab) ListAssignedIssues(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *oauth2.Token) ([]*gitlab0.Issue, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAssignedIssues", arg0, arg1, arg2) + ret0, _ := ret[0].([]*gitlab0.Issue) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListAssignedIssues indicates an expected call of ListAssignedIssues. +func (mr *MockGitlabMockRecorder) ListAssignedIssues(arg0, arg1, arg2 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAssignedIssues", reflect.TypeOf((*MockGitlab)(nil).ListAssignedIssues), arg0, arg1, arg2) +} + +// ListAssignedMergeRequests mocks base method. +func (m *MockGitlab) ListAssignedMergeRequests(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *oauth2.Token) ([]*gitlab0.MergeRequest, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAssignedMergeRequests", arg0, arg1, arg2) + ret0, _ := ret[0].([]*gitlab0.MergeRequest) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListAssignedMergeRequests indicates an expected call of ListAssignedMergeRequests. +func (mr *MockGitlabMockRecorder) ListAssignedMergeRequests(arg0, arg1, arg2 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAssignedMergeRequests", reflect.TypeOf((*MockGitlab)(nil).ListAssignedMergeRequests), arg0, arg1, arg2) +} + +// ListReviewRequests mocks base method. +func (m *MockGitlab) ListReviewRequests(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *oauth2.Token) ([]*gitlab0.MergeRequest, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListReviewRequests", arg0, arg1, arg2) + ret0, _ := ret[0].([]*gitlab0.MergeRequest) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListReviewRequests indicates an expected call of ListReviewRequests. +func (mr *MockGitlabMockRecorder) ListReviewRequests(arg0, arg1, arg2 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListReviewRequests", reflect.TypeOf((*MockGitlab)(nil).ListReviewRequests), arg0, arg1, arg2) +} + // NewGroupHook mocks base method. func (m *MockGitlab) NewGroupHook(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *oauth2.Token, arg3 string, arg4 *gitlab.AddWebhookOptions) (*gitlab.WebhookInfo, error) { m.ctrl.T.Helper() @@ -408,11 +489,26 @@ func (m *MockGitlab) SearchIssues(arg0 context.Context, arg1 *gitlab.UserInfo, a } // SearchIssues indicates an expected call of SearchIssues. -func (mr *MockGitlabMockRecorder) SearchIssues(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { +func (mr *MockGitlabMockRecorder) SearchIssues(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchIssues", reflect.TypeOf((*MockGitlab)(nil).SearchIssues), arg0, arg1, arg2, arg3) } +// SearchMergeRequests mocks base method. +func (m *MockGitlab) SearchMergeRequests(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *oauth2.Token, arg3 string) ([]*gitlab0.MergeRequest, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SearchMergeRequests", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].([]*gitlab0.MergeRequest) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SearchMergeRequests indicates an expected call of SearchMergeRequests. +func (mr *MockGitlabMockRecorder) SearchMergeRequests(arg0, arg1, arg2, arg3 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchMergeRequests", reflect.TypeOf((*MockGitlab)(nil).SearchMergeRequests), arg0, arg1, arg2, arg3) +} + // TriggerProjectPipeline mocks base method. func (m *MockGitlab) TriggerProjectPipeline(arg0 *gitlab.UserInfo, arg1 *oauth2.Token, arg2, arg3 string) (*gitlab.PipelineInfo, error) { m.ctrl.T.Helper() @@ -427,3 +523,18 @@ func (mr *MockGitlabMockRecorder) TriggerProjectPipeline(arg0, arg1, arg2, arg3 mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TriggerProjectPipeline", reflect.TypeOf((*MockGitlab)(nil).TriggerProjectPipeline), arg0, arg1, arg2, arg3) } + +// UpdateIssue mocks base method. +func (m *MockGitlab) UpdateIssue(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *oauth2.Token, arg3 string, arg4 int, arg5 *gitlab.UpdateIssueOptions) (*gitlab0.Issue, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateIssue", arg0, arg1, arg2, arg3, arg4, arg5) + ret0, _ := ret[0].(*gitlab0.Issue) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateIssue indicates an expected call of UpdateIssue. +func (mr *MockGitlabMockRecorder) UpdateIssue(arg0, arg1, arg2, arg3, arg4, arg5 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateIssue", reflect.TypeOf((*MockGitlab)(nil).UpdateIssue), arg0, arg1, arg2, arg3, arg4, arg5) +} diff --git a/server/mcp.go b/server/mcp.go new file mode 100644 index 000000000..d53db2bf6 --- /dev/null +++ b/server/mcp.go @@ -0,0 +1,143 @@ +// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package main + +import ( + "context" + "fmt" + "net/http" + + "github.com/Masterminds/semver/v3" + "github.com/mattermost/mattermost-plugin-agents/external/pluginmcp" + "golang.org/x/oauth2" + + "github.com/mattermost/mattermost-plugin-gitlab/server/gitlab" +) + +const ( + mcpBasePath = "/mcp" + mcpServerName = "GitLab" + + // minServerVersionForMCP is the minimum Mattermost server version that + // stamps the trusted Mattermost-Plugin-ID header on inter-plugin RPC and + // supports PluginHTTPStream, both required by the Agents MCP bridge. On + // older servers registration is rejected with a 401, so we skip it. + minServerVersionForMCP = "11.3.0" +) + +// mcpServer is an interface over *pluginmcp.Server so we can swap it with a +// nil-safe stub in tests without importing the real package. +type mcpServer interface { + ServeHTTP(w http.ResponseWriter, r *http.Request) + Register() error + Unregister() error +} + +// startMCP initialises the pluginmcp server and registers it with the Agents +// plugin. Panics from pluginmcp are caught so the GitLab plugin continues to +// start normally even when the Agents plugin is absent. +func (p *Plugin) startMCP() { + defer func() { + if rec := recover(); rec != nil { + p.API.LogWarn("MCP server initialization panicked; GitLab plugin continues without MCP", + "panic", fmt.Sprintf("%v", rec)) + } + }() + + p.mcpMu.Lock() + defer p.mcpMu.Unlock() + if p.mcpServer != nil { + return + } + + if serverVersion := p.API.GetServerVersion(); !serverSupportsMCP(serverVersion) { + p.API.LogWarn("Skipping GitLab MCP registration: Mattermost server is older than the version required by the Agents MCP bridge. Upgrade the server to expose GitLab tools to Agents.", + "server_version", serverVersion, + "required_version", minServerVersionForMCP, + ) + return + } + + s := pluginmcp.NewServer(p.API, pluginmcp.Config{ + PluginID: manifest.Id, + Name: mcpServerName, + Path: mcpBasePath, + Version: manifest.Version, + }) + + p.registerTools(s) + p.mcpServer = s + + // Register returns nil immediately and retries with the Agents plugin in a + // background goroutine, so there is no synchronous error to handle here. + // pluginmcp emits its own (stdlib) logs on terminal failure; log the attempt + // here so the plugin logs show that our side ran and what it pushed. + p.API.LogInfo("Registering GitLab MCP server with the Agents plugin", + "plugin_id", manifest.Id, + "path", mcpBasePath, + ) + _ = s.Register() +} + +// serverSupportsMCP reports whether the running Mattermost server is new enough +// for the Agents MCP bridge. An unparseable/empty version is treated as +// supported so a version-string quirk never silently disables the feature. +func serverSupportsMCP(serverVersion string) bool { + current, err := semver.NewVersion(serverVersion) + if err != nil { + return true + } + return !current.LessThan(semver.MustParse(minServerVersionForMCP)) +} + +func (p *Plugin) stopMCP() { + p.mcpMu.Lock() + s := p.mcpServer + p.mcpServer = nil + p.mcpMu.Unlock() + + if s == nil { + return + } + if err := s.Unregister(); err != nil { + p.API.LogWarn("MCP unregister failed", "err", err.Error()) + } +} + +func (p *Plugin) serveMCPHTTP(w http.ResponseWriter, r *http.Request) { + p.mcpMu.Lock() + s := p.mcpServer + p.mcpMu.Unlock() + + if s == nil { + http.Error(w, "MCP server not initialized", http.StatusServiceUnavailable) + return + } + s.ServeHTTP(w, r) +} + +// resolveCaller extracts the Mattermost user ID injected by the Agents plugin, +// then retrieves the user's GitLab UserInfo and a valid (possibly refreshed) +// OAuth token. It returns an error if the user has not connected their GitLab +// account. +func (p *Plugin) resolveCaller(ctx context.Context) (*gitlab.UserInfo, *oauth2.Token, error) { + userID := pluginmcp.GetUserID(ctx) + if userID == "" { + return nil, nil, fmt.Errorf("no Mattermost user ID in context (request did not arrive through the Agents plugin)") + } + + info, apiErr := p.getGitlabUserInfoByMattermostID(userID) + if apiErr != nil { + return nil, nil, fmt.Errorf("GitLab account not connected: %s", apiErr.Message) + } + + // Don't DM the user on a revoked token here: an LLM may retry a failed tool + // call several times, and we'd spam the same disconnect notice each time. + token, err := p.getOrRefreshToken(info, false) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitLab token: %w", err) + } + + return info, token, nil +} diff --git a/server/mcp_handlers.go b/server/mcp_handlers.go new file mode 100644 index 000000000..a04bc39ef --- /dev/null +++ b/server/mcp_handlers.go @@ -0,0 +1,542 @@ +// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package main + +import ( + "context" + "fmt" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + internGitlab "github.com/xanzy/go-gitlab" + + "github.com/mattermost/mattermost-plugin-gitlab/server/gitlab" +) + +// ============================================================================ +// Issues +// ============================================================================ + +func (p *Plugin) handleGetIssue(ctx context.Context, _ *mcp.CallToolRequest, in GetIssueInput) (*mcp.CallToolResult, GetIssueOutput, error) { + if in.ProjectPath == "" { + return nil, GetIssueOutput{}, fmt.Errorf("project_path is required") + } + if in.IssueIID <= 0 { + return nil, GetIssueOutput{}, fmt.Errorf("issue_iid must be a positive integer") + } + + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, GetIssueOutput{}, err + } + + owner, repo, err := splitProjectPath(in.ProjectPath) + if err != nil { + return nil, GetIssueOutput{}, err + } + + issue, err := p.GitlabClient.GetIssueByID(ctx, info, owner, repo, in.IssueIID, token) + if err != nil { + return nil, GetIssueOutput{}, fmt.Errorf("failed to get issue: %w", err) + } + + return nil, GetIssueOutput{Issue: issueToSummary(issue.Issue)}, nil +} + +// handleListIssues returns either the caller's assigned issues or the results +// of a keyword search. assigned_to_me (or an empty search) selects the assigned +// list; otherwise the search term is used. +func (p *Plugin) handleListIssues(ctx context.Context, _ *mcp.CallToolRequest, in ListIssuesInput) (*mcp.CallToolResult, ListIssuesOutput, error) { + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, ListIssuesOutput{}, err + } + + var issues []*internGitlab.Issue + if in.AssignedToMe || in.Search == "" { + issues, err = p.GitlabClient.ListAssignedIssues(ctx, info, token) + } else { + issues, err = p.GitlabClient.SearchIssues(ctx, info, in.Search, token) + } + if err != nil { + return nil, ListIssuesOutput{}, fmt.Errorf("failed to list issues: %w", err) + } + + return nil, ListIssuesOutput{Issues: issuesToSummaries(issues)}, nil +} + +func (p *Plugin) handleCreateIssue(ctx context.Context, _ *mcp.CallToolRequest, in CreateIssueInput) (*mcp.CallToolResult, CreateIssueOutput, error) { + if in.ProjectPath == "" { + return nil, CreateIssueOutput{}, fmt.Errorf("project_path is required") + } + if in.Title == "" { + return nil, CreateIssueOutput{}, fmt.Errorf("title is required") + } + + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, CreateIssueOutput{}, err + } + + owner, repo, err := splitProjectPath(in.ProjectPath) + if err != nil { + return nil, CreateIssueOutput{}, err + } + + req := &gitlab.IssueRequest{ + Title: in.Title, + Description: in.Description, + Assignees: in.AssigneeIDs, + Milestone: in.MilestoneID, + Labels: internGitlab.LabelOptions(in.Labels), + } + + project, err := p.GitlabClient.GetProject(ctx, info, token, owner, repo) + if err != nil { + return nil, CreateIssueOutput{}, fmt.Errorf("failed to resolve project %q: %w", in.ProjectPath, err) + } + req.ProjectID = project.ID + + issue, err := p.GitlabClient.CreateIssue(ctx, info, req, token) + if err != nil { + return nil, CreateIssueOutput{}, fmt.Errorf("failed to create issue: %w", err) + } + + return nil, CreateIssueOutput{Issue: issueToSummary(issue)}, nil +} + +func (p *Plugin) handleUpdateIssue(ctx context.Context, _ *mcp.CallToolRequest, in UpdateIssueInput) (*mcp.CallToolResult, UpdateIssueOutput, error) { + if in.ProjectPath == "" { + return nil, UpdateIssueOutput{}, fmt.Errorf("project_path is required") + } + if in.IssueIID <= 0 { + return nil, UpdateIssueOutput{}, fmt.Errorf("issue_iid must be a positive integer") + } + + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, UpdateIssueOutput{}, err + } + + opts := &gitlab.UpdateIssueOptions{ + Title: in.Title, + Description: in.Description, + StateEvent: in.StateEvent, + MilestoneID: in.MilestoneID, + } + if in.Labels != nil { + labels := internGitlab.LabelOptions(in.Labels) + opts.Labels = &labels + } + if in.AssigneeIDs != nil { + opts.AssigneeIDs = &in.AssigneeIDs + } + + issue, err := p.GitlabClient.UpdateIssue(ctx, info, token, in.ProjectPath, in.IssueIID, opts) + if err != nil { + return nil, UpdateIssueOutput{}, fmt.Errorf("failed to update issue: %w", err) + } + + return nil, UpdateIssueOutput{Issue: issueToSummary(issue)}, nil +} + +// ============================================================================ +// Comments +// ============================================================================ + +// handleAddComment posts a note to an issue or merge request, routing on +// target_type so a single tool covers both surfaces. +func (p *Plugin) handleAddComment(ctx context.Context, _ *mcp.CallToolRequest, in AddCommentInput) (*mcp.CallToolResult, AddCommentOutput, error) { + if in.ProjectPath == "" { + return nil, AddCommentOutput{}, fmt.Errorf("project_path is required") + } + if in.TargetIID <= 0 { + return nil, AddCommentOutput{}, fmt.Errorf("target_iid must be a positive integer") + } + if in.Body == "" { + return nil, AddCommentOutput{}, fmt.Errorf("body is required") + } + + var urlKind string + switch in.TargetType { + case "issue": + urlKind = "issues" + case "merge_request": + urlKind = "merge_requests" + default: + return nil, AddCommentOutput{}, fmt.Errorf("target_type must be 'issue' or 'merge_request'") + } + + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, AddCommentOutput{}, err + } + + var note *internGitlab.Note + if in.TargetType == "issue" { + note, err = p.GitlabClient.AddIssueNote(ctx, info, token, in.ProjectPath, in.TargetIID, in.Body) + } else { + note, err = p.GitlabClient.AddMergeRequestNote(ctx, info, token, in.ProjectPath, in.TargetIID, in.Body) + } + if err != nil { + return nil, AddCommentOutput{}, fmt.Errorf("failed to add comment: %w", err) + } + + return nil, AddCommentOutput{ + NoteID: note.ID, + Body: note.Body, + WebURL: noteWebURL(p.getConfiguration().GitlabURL, in.ProjectPath, urlKind, in.TargetIID, note.ID), + }, nil +} + +// ============================================================================ +// Merge Requests +// ============================================================================ + +func (p *Plugin) handleGetMergeRequest(ctx context.Context, _ *mcp.CallToolRequest, in GetMergeRequestInput) (*mcp.CallToolResult, GetMergeRequestOutput, error) { + if in.ProjectPath == "" { + return nil, GetMergeRequestOutput{}, fmt.Errorf("project_path is required") + } + if in.MergeRequestID <= 0 { + return nil, GetMergeRequestOutput{}, fmt.Errorf("merge_request_iid must be a positive integer") + } + + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, GetMergeRequestOutput{}, err + } + + owner, repo, err := splitProjectPath(in.ProjectPath) + if err != nil { + return nil, GetMergeRequestOutput{}, err + } + + mr, err := p.GitlabClient.GetMergeRequestByID(ctx, info, owner, repo, in.MergeRequestID, token) + if err != nil { + return nil, GetMergeRequestOutput{}, fmt.Errorf("failed to get merge request: %w", err) + } + + return nil, GetMergeRequestOutput{MergeRequest: mrToSummary(mr.MergeRequest)}, nil +} + +// handleListMergeRequests returns the caller's assigned MRs (default), the MRs +// awaiting their review, or keyword search results. +func (p *Plugin) handleListMergeRequests(ctx context.Context, _ *mcp.CallToolRequest, in ListMergeRequestsInput) (*mcp.CallToolResult, ListMergeRequestsOutput, error) { + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, ListMergeRequestsOutput{}, err + } + + selectors := 0 + if in.Search != "" { + selectors++ + } + if in.AssignedToMe { + selectors++ + } + if in.ReviewRequested { + selectors++ + } + if selectors > 1 { + return nil, ListMergeRequestsOutput{}, fmt.Errorf("only one of search, assigned_to_me, or review_requested may be set") + } + + var mrs []*internGitlab.MergeRequest + switch { + case in.ReviewRequested: + mrs, err = p.GitlabClient.ListReviewRequests(ctx, info, token) + case in.Search != "": + mrs, err = p.GitlabClient.SearchMergeRequests(ctx, info, token, in.Search) + default: + mrs, err = p.GitlabClient.ListAssignedMergeRequests(ctx, info, token) + } + if err != nil { + return nil, ListMergeRequestsOutput{}, fmt.Errorf("failed to list merge requests: %w", err) + } + + return nil, ListMergeRequestsOutput{MergeRequests: mrsToSummaries(mrs)}, nil +} + +// ============================================================================ +// Projects +// ============================================================================ + +// handleGetProjects lists the caller's accessible projects, or returns a single +// project when project_path is supplied. +func (p *Plugin) handleGetProjects(ctx context.Context, _ *mcp.CallToolRequest, in GetProjectsInput) (*mcp.CallToolResult, GetProjectsOutput, error) { + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, GetProjectsOutput{}, err + } + + if in.ProjectPath != "" { + owner, repo, splitErr := splitProjectPath(in.ProjectPath) + if splitErr != nil { + return nil, GetProjectsOutput{}, splitErr + } + project, projErr := p.GitlabClient.GetProject(ctx, info, token, owner, repo) + if projErr != nil { + return nil, GetProjectsOutput{}, fmt.Errorf("failed to get project: %w", projErr) + } + return nil, GetProjectsOutput{Projects: []ProjectSummary{projectToSummary(project)}}, nil + } + + projects, err := p.GitlabClient.GetYourProjects(ctx, info, token) + if err != nil { + return nil, GetProjectsOutput{}, fmt.Errorf("failed to list projects: %w", err) + } + + summaries := make([]ProjectSummary, 0, len(projects)) + for _, project := range projects { + summaries = append(summaries, projectToSummary(project)) + } + + return nil, GetProjectsOutput{Projects: summaries}, nil +} + +// handleGetProjectMetadata returns a project's labels, milestones, or members +// depending on the requested kind. +func (p *Plugin) handleGetProjectMetadata(ctx context.Context, _ *mcp.CallToolRequest, in GetProjectMetadataInput) (*mcp.CallToolResult, GetProjectMetadataOutput, error) { + if in.ProjectPath == "" { + return nil, GetProjectMetadataOutput{}, fmt.Errorf("project_path is required") + } + + switch in.Kind { + case "labels", "milestones", "members": + default: + return nil, GetProjectMetadataOutput{}, fmt.Errorf("kind must be 'labels', 'milestones', or 'members'") + } + + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, GetProjectMetadataOutput{}, err + } + + var out GetProjectMetadataOutput + switch in.Kind { + case "labels": + labels, lErr := p.GitlabClient.GetLabels(ctx, info, in.ProjectPath, token) + if lErr != nil { + return nil, GetProjectMetadataOutput{}, fmt.Errorf("failed to list labels: %w", lErr) + } + out.Labels = make([]LabelSummary, 0, len(labels)) + for _, l := range labels { + out.Labels = append(out.Labels, LabelSummary{ + ID: l.ID, + Name: l.Name, + Color: l.Color, + Description: l.Description, + }) + } + case "milestones": + milestones, mErr := p.GitlabClient.GetMilestones(ctx, info, in.ProjectPath, token) + if mErr != nil { + return nil, GetProjectMetadataOutput{}, fmt.Errorf("failed to list milestones: %w", mErr) + } + out.Milestones = make([]MilestoneSummary, 0, len(milestones)) + for _, m := range milestones { + ms := MilestoneSummary{ + ID: m.ID, + IID: m.IID, + Title: m.Title, + Description: m.Description, + State: m.State, + } + if m.DueDate != nil { + ms.DueDate = m.DueDate.String() + } + if m.StartDate != nil { + ms.StartDate = m.StartDate.String() + } + out.Milestones = append(out.Milestones, ms) + } + case "members": + members, memErr := p.GitlabClient.GetProjectMembers(ctx, info, in.ProjectPath, token) + if memErr != nil { + return nil, GetProjectMetadataOutput{}, fmt.Errorf("failed to list project members: %w", memErr) + } + out.Members = make([]ProjectMemberSummary, 0, len(members)) + for _, m := range members { + out.Members = append(out.Members, ProjectMemberSummary{ + ID: m.ID, + Username: m.Username, + Name: m.Name, + AccessLevel: int(m.AccessLevel), + }) + } + } + + return nil, out, nil +} + +// ============================================================================ +// User +// ============================================================================ + +func (p *Plugin) handleGetMyGitLabUser(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, GetMyGitLabUserOutput, error) { + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, GetMyGitLabUserOutput{}, err + } + + user, err := p.GitlabClient.GetUserDetails(ctx, info, token) + if err != nil { + return nil, GetMyGitLabUserOutput{}, fmt.Errorf("failed to get GitLab user: %w", err) + } + + return nil, GetMyGitLabUserOutput{ + ID: user.ID, + Username: user.Username, + Name: user.Name, + Email: user.Email, + AvatarURL: user.AvatarURL, + WebURL: user.WebURL, + }, nil +} + +// ============================================================================ +// Conversion helpers +// ============================================================================ + +func issueToSummary(issue *internGitlab.Issue) IssueSummary { + if issue == nil { + return IssueSummary{} + } + s := IssueSummary{ + ID: issue.ID, + IID: issue.IID, + ProjectID: issue.ProjectID, + Title: issue.Title, + State: issue.State, + Description: issue.Description, + WebURL: issue.WebURL, + Labels: issue.Labels, + } + for _, a := range issue.Assignees { + if a != nil { + s.Assignees = append(s.Assignees, a.Username) + } + } + if issue.Milestone != nil { + s.Milestone = issue.Milestone.Title + } + if issue.CreatedAt != nil { + s.CreatedAt = issue.CreatedAt.String() + } + if issue.UpdatedAt != nil { + s.UpdatedAt = issue.UpdatedAt.String() + } + return s +} + +func issuesToSummaries(issues []*internGitlab.Issue) []IssueSummary { + out := make([]IssueSummary, 0, len(issues)) + for _, i := range issues { + if i != nil { + out = append(out, issueToSummary(i)) + } + } + return out +} + +func mrToSummary(mr *internGitlab.MergeRequest) MergeRequestSummary { + if mr == nil { + return MergeRequestSummary{} + } + s := MergeRequestSummary{ + ID: mr.ID, + IID: mr.IID, + ProjectID: mr.ProjectID, + Title: mr.Title, + State: mr.State, + Description: mr.Description, + SourceBranch: mr.SourceBranch, + TargetBranch: mr.TargetBranch, + WebURL: mr.WebURL, + Labels: mr.Labels, + } + if mr.Author != nil { + s.Author = mr.Author.Username + } + for _, a := range mr.Assignees { + if a != nil { + s.Assignees = append(s.Assignees, a.Username) + } + } + for _, r := range mr.Reviewers { + if r != nil { + s.Reviewers = append(s.Reviewers, r.Username) + } + } + if mr.Milestone != nil { + s.Milestone = mr.Milestone.Title + } + if mr.CreatedAt != nil { + s.CreatedAt = mr.CreatedAt.String() + } + if mr.UpdatedAt != nil { + s.UpdatedAt = mr.UpdatedAt.String() + } + return s +} + +func mrsToSummaries(mrs []*internGitlab.MergeRequest) []MergeRequestSummary { + out := make([]MergeRequestSummary, 0, len(mrs)) + for _, mr := range mrs { + if mr != nil { + out = append(out, mrToSummary(mr)) + } + } + return out +} + +func projectToSummary(p *internGitlab.Project) ProjectSummary { + if p == nil { + return ProjectSummary{} + } + s := ProjectSummary{ + ID: p.ID, + Name: p.Name, + PathWithNamespace: p.PathWithNamespace, + Description: p.Description, + WebURL: p.WebURL, + Visibility: string(p.Visibility), + DefaultBranch: p.DefaultBranch, + } + return s +} + +// noteWebURL builds a GitLab note permalink. Returns "" when the base URL or +// project path is missing so we don't emit a half-formed link to the agent. +func noteWebURL(baseURL, projectPath, kind string, parentIID, noteID int) string { + if baseURL == "" || projectPath == "" { + return "" + } + return fmt.Sprintf("%s/%s/-/%s/%d#note_%d", strings.TrimRight(baseURL, "/"), projectPath, kind, parentIID, noteID) +} + +// splitProjectPath splits "namespace/project" into owner and repo. +// It also handles nested groups like "group/subgroup/project". +func splitProjectPath(projectPath string) (owner, repo string, err error) { + if projectPath == "" { + return "", "", fmt.Errorf("project_path must be in namespace/project format") + } + owner, repo = splitProjectPathParts(projectPath) + if owner == "" || repo == "" { + return "", "", fmt.Errorf("project_path %q must be in namespace/project format (e.g. mygroup/myproject)", projectPath) + } + return owner, repo, nil +} + +// splitProjectPathParts splits the last segment off the path as the repo name, +// with everything before it as the owner/namespace. Returns ("", path) when no +// slash is present. +func splitProjectPathParts(projectPath string) (owner, repo string) { + for i := len(projectPath) - 1; i >= 0; i-- { + if projectPath[i] == '/' { + return projectPath[:i], projectPath[i+1:] + } + } + return "", projectPath +} diff --git a/server/mcp_test.go b/server/mcp_test.go new file mode 100644 index 000000000..78739177a --- /dev/null +++ b/server/mcp_test.go @@ -0,0 +1,559 @@ +// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/mattermost/mattermost-plugin-agents/external/pluginmcp" + "github.com/mattermost/mattermost/server/public/plugin/plugintest" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + internGitlab "github.com/xanzy/go-gitlab" + gomock "go.uber.org/mock/gomock" + + mockgitlab "github.com/mattermost/mattermost-plugin-gitlab/server/mocks" +) + +// --- MCP lifecycle tests ---------------------------------------------------- + +func TestServeMCPHTTP_NilServer(t *testing.T) { + p := &Plugin{} + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/mcp", nil) + + p.serveMCPHTTP(w, r) + + assert.Equal(t, http.StatusServiceUnavailable, w.Code) + assert.Contains(t, w.Body.String(), "MCP server not initialized") +} + +func TestStartMCP_Idempotent(t *testing.T) { + api := &plugintest.API{} + api.On("GetServerVersion").Return("11.3.0").Maybe() + api.On("LogInfo", mock.AnythingOfType("string"), mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe() + api.On("LogWarn", mock.AnythingOfType("string"), mock.Anything, mock.Anything).Maybe() + api.On("LogWarn", mock.AnythingOfType("string"), mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe() + api.On("LogError", mock.AnythingOfType("string"), mock.Anything, mock.Anything).Maybe() + api.On("LogDebug", mock.AnythingOfType("string"), mock.Anything, mock.Anything).Maybe() + // pluginmcp.Register calls PluginHTTP to reach the Agents plugin. + // Return a nil response so that registration fails gracefully and the + // plugin continues to operate. + api.On("PluginHTTP", mock.Anything).Return((*http.Response)(nil)).Maybe() + + p := &Plugin{} + p.SetAPI(api) + + // Concurrent calls should produce at most one mcpServer instance and must + // not data-race. + var wg sync.WaitGroup + for range 10 { + wg.Go(func() { + p.startMCP() + }) + } + wg.Wait() + + p.mcpMu.Lock() + s := p.mcpServer + p.mcpMu.Unlock() + require.NotNil(t, s, "mcpServer should be set after startMCP") + + // Clean up the background registration goroutine. + _ = s.Unregister() +} + +func TestServerSupportsMCP(t *testing.T) { + cases := []struct { + version string + want bool + }{ + {"11.3.0", true}, + {"11.4.1", true}, + {"12.0.0", true}, + {"11.2.0", false}, + {"10.7.0", false}, + {"9.11.0", false}, + {"", true}, // unparseable: don't disable on a version quirk + {"garbage", true}, // unparseable + {"11.3.0-rc1", false}, // prerelease of 11.3 sorts below 11.3.0 + } + for _, tc := range cases { + t.Run(tc.version, func(t *testing.T) { + assert.Equal(t, tc.want, serverSupportsMCP(tc.version)) + }) + } +} + +func TestStartMCP_SkipsOnOldServer(t *testing.T) { + api := &plugintest.API{} + api.On("GetServerVersion").Return("11.2.0") + api.On("LogWarn", mock.AnythingOfType("string"), mock.Anything, mock.Anything, mock.Anything, mock.Anything).Once() + + p := &Plugin{} + p.SetAPI(api) + p.startMCP() + + p.mcpMu.Lock() + defer p.mcpMu.Unlock() + assert.Nil(t, p.mcpServer, "MCP server should not be created on an unsupported server") + api.AssertExpectations(t) +} + +func TestStopMCP_NilSafe(t *testing.T) { + p := &Plugin{} + require.NotPanics(t, func() { + p.stopMCP() + }) +} + +func TestStopMCP_ClearsServer(t *testing.T) { + api := &plugintest.API{} + api.On("LogWarn", mock.AnythingOfType("string"), mock.Anything, mock.Anything).Maybe() + api.On("LogWarn", mock.AnythingOfType("string"), mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe() + + stub := &mcpStub{unregisterErr: nil} + p := &Plugin{} + p.SetAPI(api) + p.mcpServer = stub + + p.stopMCP() + + p.mcpMu.Lock() + defer p.mcpMu.Unlock() + assert.Nil(t, p.mcpServer) + assert.True(t, stub.unregistered) +} + +// mcpStub is a minimal mcpServer implementation for unit tests. +type mcpStub struct { + mu sync.Mutex + unregistered bool + unregisterErr error +} + +func (s *mcpStub) ServeHTTP(_ http.ResponseWriter, _ *http.Request) {} + +func (s *mcpStub) Register() error { return nil } + +func (s *mcpStub) Unregister() error { + s.mu.Lock() + defer s.mu.Unlock() + s.unregistered = true + return s.unregisterErr +} + +// --- End-to-end ServeHTTP tools/list test ----------------------------------- + +// expectedToolNamePrefix mirrors pluginmcp's sanitization: the plugin ID with +// any character outside [A-Za-z0-9_-] replaced by '_', plus the "__" separator. +func expectedToolNamePrefix() string { + var b strings.Builder + for _, r := range manifest.Id { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-': + b.WriteRune(r) + default: + b.WriteRune('_') + } + } + return b.String() + "__" +} + +// TestMCP_ToolsListOverServeHTTP drives a real pluginmcp.Server through +// ServeHTTP and a streamable MCP client to verify the namespaced tool names, +// generated schemas, and annotations actually appear on the wire. +func TestMCP_ToolsListOverServeHTTP(t *testing.T) { + ctx := context.Background() + + p := &Plugin{} + s := pluginmcp.NewServer(nil, pluginmcp.Config{ + PluginID: manifest.Id, + Name: mcpServerName, + Path: mcpBasePath, + Version: "0.0.1", + }) + p.registerTools(s) + + // Inject the trusted inter-plugin header the Agents plugin would add. + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Header.Set("Mattermost-Plugin-ID", "mattermost-ai") + s.ServeHTTP(w, r) + })) + t.Cleanup(ts.Close) + + client := mcp.NewClient(&mcp.Implementation{Name: "gitlab-test-client", Version: "0.0.1"}, nil) + session, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: ts.URL}, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + + res, err := session.ListTools(ctx, &mcp.ListToolsParams{}) + require.NoError(t, err) + require.NotEmpty(t, res.Tools) + + prefix := expectedToolNamePrefix() + byShortName := map[string]*mcp.Tool{} + for _, tool := range res.Tools { + require.Truef(t, strings.HasPrefix(tool.Name, prefix), "tool %q missing namespace prefix %q", tool.Name, prefix) + require.NotNilf(t, tool.InputSchema, "tool %q should expose a generated input schema", tool.Name) + byShortName[strings.TrimPrefix(tool.Name, prefix)] = tool + } + + // Keep the surface small: every tool's schema is sent on each LLM call. + assert.LessOrEqual(t, len(res.Tools), 10, "MCP tool count should stay within the pluginmcp budget") + + t.Run("read tool is annotated read-only", func(t *testing.T) { + getIssue := byShortName["get_issue"] + require.NotNil(t, getIssue) + require.NotNil(t, getIssue.Annotations) + assert.True(t, getIssue.Annotations.ReadOnlyHint) + }) + + t.Run("create_issue is a non-destructive write", func(t *testing.T) { + createIssue := byShortName["create_issue"] + require.NotNil(t, createIssue) + require.NotNil(t, createIssue.Annotations) + require.NotNil(t, createIssue.Annotations.DestructiveHint) + assert.False(t, *createIssue.Annotations.DestructiveHint) + }) +} + +// --- resolveCaller tests ---------------------------------------------------- + +func TestResolveCaller_NoUserID(t *testing.T) { + p := &Plugin{} + _, _, err := p.resolveCaller(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "no Mattermost user ID") +} + +// --- splitProjectPath tests ------------------------------------------------- + +func TestSplitProjectPath(t *testing.T) { + tests := []struct { + name string + input string + wantOwner string + wantRepo string + wantErrSub string + }{ + { + name: "simple namespace/project", + input: "mygroup/myproject", + wantOwner: "mygroup", + wantRepo: "myproject", + }, + { + name: "nested group", + input: "top/sub/myproject", + wantOwner: "top/sub", + wantRepo: "myproject", + }, + { + name: "empty string", + input: "", + wantErrSub: "project_path must be in namespace/project format", + }, + { + name: "no slash — no owner", + input: "repoonly", + wantErrSub: "namespace/project format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + owner, repo, err := splitProjectPath(tt.input) + if tt.wantErrSub != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErrSub) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantOwner, owner) + assert.Equal(t, tt.wantRepo, repo) + }) + } +} + +// --- Handler validation tests (mocked GitlabClient) ------------------------ + +func newPluginWithMockGitlab(t *testing.T) (*Plugin, *mockgitlab.MockGitlab) { + t.Helper() + ctrl := gomock.NewController(t) + mockGL := mockgitlab.NewMockGitlab(ctrl) + + api := &plugintest.API{} + api.On("LogWarn", mock.AnythingOfType("string"), mock.Anything, mock.Anything).Maybe() + + p := &Plugin{ + GitlabClient: mockGL, + } + p.SetAPI(api) + return p, mockGL +} + +func TestHandleGetIssue_Validation(t *testing.T) { + t.Run("empty project_path", func(t *testing.T) { + p, _ := newPluginWithMockGitlab(t) + _, _, err := p.handleGetIssue(context.Background(), nil, GetIssueInput{IssueIID: 1}) + require.Error(t, err) + assert.Contains(t, err.Error(), "project_path is required") + }) + + t.Run("zero iid", func(t *testing.T) { + p, _ := newPluginWithMockGitlab(t) + _, _, err := p.handleGetIssue(context.Background(), nil, GetIssueInput{ProjectPath: "g/p"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "issue_iid must be a positive integer") + }) + + t.Run("no caller in context", func(t *testing.T) { + p, _ := newPluginWithMockGitlab(t) + _, _, err := p.handleGetIssue(context.Background(), nil, GetIssueInput{ProjectPath: "g/p", IssueIID: 1}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no Mattermost user ID") + }) +} + +func TestHandleGetMergeRequest_Validation(t *testing.T) { + t.Run("empty project_path", func(t *testing.T) { + p, _ := newPluginWithMockGitlab(t) + _, _, err := p.handleGetMergeRequest(context.Background(), nil, GetMergeRequestInput{MergeRequestID: 1}) + require.Error(t, err) + assert.Contains(t, err.Error(), "project_path is required") + }) + + t.Run("zero merge_request_iid", func(t *testing.T) { + p, _ := newPluginWithMockGitlab(t) + _, _, err := p.handleGetMergeRequest(context.Background(), nil, GetMergeRequestInput{ProjectPath: "g/p"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "merge_request_iid must be a positive integer") + }) +} + +func TestHandleCreateIssue_Validation(t *testing.T) { + t.Run("empty project_path", func(t *testing.T) { + p, _ := newPluginWithMockGitlab(t) + _, _, err := p.handleCreateIssue(context.Background(), nil, CreateIssueInput{Title: "T"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "project_path is required") + }) + + t.Run("empty title", func(t *testing.T) { + p, _ := newPluginWithMockGitlab(t) + _, _, err := p.handleCreateIssue(context.Background(), nil, CreateIssueInput{ProjectPath: "g/p"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "title is required") + }) +} + +func TestHandleGetProjectMetadata_Validation(t *testing.T) { + t.Run("empty project_path", func(t *testing.T) { + p, _ := newPluginWithMockGitlab(t) + _, _, err := p.handleGetProjectMetadata(context.Background(), nil, GetProjectMetadataInput{Kind: "labels"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "project_path is required") + }) + + t.Run("invalid kind", func(t *testing.T) { + p, _ := newPluginWithMockGitlab(t) + _, _, err := p.handleGetProjectMetadata(context.Background(), nil, GetProjectMetadataInput{ProjectPath: "g/p", Kind: "bogus"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "kind must be") + }) +} + +func TestHandleAddComment_Validation(t *testing.T) { + cases := []struct { + name string + input AddCommentInput + errSub string + }{ + {"empty project_path", AddCommentInput{TargetType: "issue", TargetIID: 1, Body: "hi"}, "project_path is required"}, + {"zero target_iid", AddCommentInput{TargetType: "issue", ProjectPath: "g/p", Body: "hi"}, "target_iid must be a positive integer"}, + {"empty body", AddCommentInput{TargetType: "issue", ProjectPath: "g/p", TargetIID: 1}, "body is required"}, + {"invalid target_type", AddCommentInput{TargetType: "epic", ProjectPath: "g/p", TargetIID: 1, Body: "hi"}, "target_type must be"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p, _ := newPluginWithMockGitlab(t) + _, _, err := p.handleAddComment(context.Background(), nil, tc.input) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.errSub) + }) + } +} + +// --- Conversion helper tests ------------------------------------------------ + +func TestIssueToSummary(t *testing.T) { + t.Run("full issue", func(t *testing.T) { + ts := time.Date(2026, 5, 8, 9, 0, 0, 0, time.UTC) + issue := &internGitlab.Issue{ + ID: 100, + IID: 42, + ProjectID: 7, + Title: "Fix the bug", + State: "opened", + Description: "A nasty bug", + Labels: internGitlab.Labels{"bug", "priority::high"}, + Assignees: []*internGitlab.IssueAssignee{{Username: "alice"}, {Username: "bob"}}, + Milestone: &internGitlab.Milestone{Title: "v2.0"}, + WebURL: "https://gitlab.com/g/p/-/issues/42", + CreatedAt: &ts, + UpdatedAt: &ts, + } + + s := issueToSummary(issue) + + assert.Equal(t, 100, s.ID) + assert.Equal(t, 42, s.IID) + assert.Equal(t, "Fix the bug", s.Title) + assert.Equal(t, "opened", s.State) + assert.Equal(t, []string{"bug", "priority::high"}, s.Labels) + assert.Equal(t, []string{"alice", "bob"}, s.Assignees) + assert.Equal(t, "v2.0", s.Milestone) + assert.NotEmpty(t, s.CreatedAt) + }) + + t.Run("nil issue returns zero value", func(t *testing.T) { + s := issueToSummary(nil) + assert.Empty(t, s.Title) + }) + + t.Run("nil optional fields", func(t *testing.T) { + s := issueToSummary(&internGitlab.Issue{ID: 1, Title: "Min"}) + assert.Empty(t, s.Assignees) + assert.Empty(t, s.Milestone) + assert.Empty(t, s.CreatedAt) + }) +} + +func TestMrToSummary(t *testing.T) { + t.Run("full MR", func(t *testing.T) { + ts := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC) + mr := &internGitlab.MergeRequest{ + ID: 200, + IID: 15, + ProjectID: 7, + Title: "Add feature X", + State: "opened", + SourceBranch: "feature/x", + TargetBranch: "main", + Author: &internGitlab.BasicUser{Username: "carol"}, + Assignees: []*internGitlab.BasicUser{{Username: "dave"}}, + Reviewers: []*internGitlab.BasicUser{{Username: "eve"}}, + Labels: internGitlab.Labels{"feature"}, + Milestone: &internGitlab.Milestone{Title: "v3.0"}, + WebURL: "https://gitlab.com/g/p/-/merge_requests/15", + CreatedAt: &ts, + UpdatedAt: &ts, + } + + s := mrToSummary(mr) + + assert.Equal(t, 200, s.ID) + assert.Equal(t, 15, s.IID) + assert.Equal(t, "carol", s.Author) + assert.Equal(t, []string{"dave"}, s.Assignees) + assert.Equal(t, []string{"eve"}, s.Reviewers) + assert.Equal(t, "v3.0", s.Milestone) + assert.Equal(t, "feature/x", s.SourceBranch) + }) + + t.Run("nil MR returns zero value", func(t *testing.T) { + s := mrToSummary(nil) + assert.Empty(t, s.Title) + }) +} + +func TestIssuesToSummaries_Order(t *testing.T) { + issues := []*internGitlab.Issue{ + {ID: 1, Title: "First"}, + {ID: 2, Title: "Second"}, + nil, // should be skipped + } + out := issuesToSummaries(issues) + require.Len(t, out, 2) + assert.Equal(t, 1, out[0].ID) + assert.Equal(t, 2, out[1].ID) +} + +func TestMrsToSummaries_SkipsNil(t *testing.T) { + mrs := []*internGitlab.MergeRequest{nil, {ID: 5, Title: "OK"}, nil} + out := mrsToSummaries(mrs) + require.Len(t, out, 1) + assert.Equal(t, 5, out[0].ID) +} + +func TestNoteWebURL(t *testing.T) { + t.Run("issue note", func(t *testing.T) { + got := noteWebURL("https://gitlab.com", "g/p", "issues", 42, 7) + assert.Equal(t, "https://gitlab.com/g/p/-/issues/42#note_7", got) + }) + + t.Run("merge request note", func(t *testing.T) { + got := noteWebURL("https://gitlab.example.com", "g/sub/p", "merge_requests", 15, 99) + assert.Equal(t, "https://gitlab.example.com/g/sub/p/-/merge_requests/15#note_99", got) + }) + + t.Run("trims trailing slash on base URL", func(t *testing.T) { + got := noteWebURL("https://gitlab.com/", "g/p", "issues", 42, 7) + assert.Equal(t, "https://gitlab.com/g/p/-/issues/42#note_7", got) + }) + + t.Run("missing base URL returns empty", func(t *testing.T) { + assert.Empty(t, noteWebURL("", "g/p", "issues", 1, 1)) + }) + + t.Run("missing project path returns empty", func(t *testing.T) { + assert.Empty(t, noteWebURL("https://gitlab.com", "", "issues", 1, 1)) + }) +} + +func TestSplitProjectPathParts(t *testing.T) { + owner, repo := splitProjectPathParts("group/sub/project") + assert.Equal(t, "group/sub", owner) + assert.Equal(t, "project", repo) + + owner2, repo2 := splitProjectPathParts("simple/repo") + assert.Equal(t, "simple", owner2) + assert.Equal(t, "repo", repo2) + + owner3, repo3 := splitProjectPathParts("noslash") + assert.Equal(t, "", owner3) + assert.Equal(t, "noslash", repo3) +} + +func TestProjectToSummary(t *testing.T) { + t.Run("nil project returns zero value", func(t *testing.T) { + assert.Empty(t, projectToSummary(nil).Name) + }) + + t.Run("populates fields", func(t *testing.T) { + s := projectToSummary(&internGitlab.Project{ + ID: 7, + Name: "my-project", + PathWithNamespace: "g/my-project", + Description: "Test", + WebURL: "https://gitlab.com/g/my-project", + Visibility: internGitlab.PublicVisibility, + DefaultBranch: "main", + }) + assert.Equal(t, 7, s.ID) + assert.Equal(t, "my-project", s.Name) + assert.Equal(t, "g/my-project", s.PathWithNamespace) + assert.Equal(t, "main", s.DefaultBranch) + assert.Equal(t, "public", s.Visibility) + }) +} diff --git a/server/mcp_tools.go b/server/mcp_tools.go new file mode 100644 index 000000000..8653cc40c --- /dev/null +++ b/server/mcp_tools.go @@ -0,0 +1,274 @@ +// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package main + +import ( + "github.com/mattermost/mattermost-plugin-agents/external/pluginmcp" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// --- Issue types ------------------------------------------------------------ + +type GetIssueInput struct { + ProjectPath string `json:"project_path" jsonschema:"Full project path in namespace/project format (e.g. mygroup/myproject)"` + IssueIID int `json:"issue_iid" jsonschema:"Internal issue number (IID) shown in the GitLab UI, e.g. 42"` +} + +type IssueSummary struct { + ID int `json:"id" jsonschema:"GitLab issue database ID"` + IID int `json:"iid" jsonschema:"Issue number within the project (shown in the UI)"` + ProjectID int `json:"project_id"` + Title string `json:"title"` + State string `json:"state" jsonschema:"open or closed"` + Description string `json:"description,omitempty"` + Labels []string `json:"labels,omitempty"` + Assignees []string `json:"assignees,omitempty" jsonschema:"GitLab usernames of assignees"` + Milestone string `json:"milestone,omitempty" jsonschema:"Milestone title if set"` + WebURL string `json:"web_url"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type GetIssueOutput struct { + Issue IssueSummary `json:"issue"` +} + +type ListIssuesInput struct { + Search string `json:"search,omitempty" jsonschema:"Keyword to search issue titles and descriptions. When omitted, the issues assigned to you are returned instead."` + AssignedToMe bool `json:"assigned_to_me,omitempty" jsonschema:"Force the assigned-to-me list even when a search term is given"` +} + +type ListIssuesOutput struct { + Issues []IssueSummary `json:"issues"` +} + +type CreateIssueInput struct { + ProjectPath string `json:"project_path" jsonschema:"Full project path in namespace/project format (e.g. mygroup/myproject)"` + Title string `json:"title" jsonschema:"Issue title (required)"` + Description string `json:"description,omitempty" jsonschema:"Optional issue description (Markdown supported)"` + Labels []string `json:"labels,omitempty" jsonschema:"Optional list of label names to apply"` + AssigneeIDs []int `json:"assignee_ids,omitempty" jsonschema:"Optional list of GitLab user IDs to assign"` + MilestoneID int `json:"milestone_id,omitempty" jsonschema:"Optional milestone ID"` +} + +type CreateIssueOutput struct { + Issue IssueSummary `json:"issue"` +} + +type UpdateIssueInput struct { + ProjectPath string `json:"project_path" jsonschema:"Full project path in namespace/project format"` + IssueIID int `json:"issue_iid" jsonschema:"Internal issue number (IID)"` + Title *string `json:"title,omitempty" jsonschema:"New title (omit to leave unchanged)"` + Description *string `json:"description,omitempty" jsonschema:"New description (omit to leave unchanged)"` + StateEvent *string `json:"state_event,omitempty" jsonschema:"close or reopen (omit to leave state unchanged)"` + Labels []string `json:"labels,omitempty" jsonschema:"Replacement label set. Omit to leave unchanged, send an empty array to clear."` + AssigneeIDs []int `json:"assignee_ids,omitempty" jsonschema:"Replacement assignee list (user IDs). Omit to leave unchanged, send an empty array to clear."` + MilestoneID *int `json:"milestone_id,omitempty" jsonschema:"New milestone ID, or 0 to remove the milestone (omit to leave unchanged)"` +} + +type UpdateIssueOutput struct { + Issue IssueSummary `json:"issue"` +} + +// --- Comment types ---------------------------------------------------------- + +type AddCommentInput struct { + TargetType string `json:"target_type" jsonschema:"What to comment on: 'issue' or 'merge_request'"` + ProjectPath string `json:"project_path" jsonschema:"Full project path in namespace/project format"` + TargetIID int `json:"target_iid" jsonschema:"Internal number (IID) of the issue or merge request"` + Body string `json:"body" jsonschema:"Comment text (Markdown supported)"` +} + +type AddCommentOutput struct { + NoteID int `json:"note_id" jsonschema:"ID of the newly created note/comment"` + Body string `json:"body"` + WebURL string `json:"web_url,omitempty"` +} + +// --- Merge request types ---------------------------------------------------- + +type GetMergeRequestInput struct { + ProjectPath string `json:"project_path" jsonschema:"Full project path in namespace/project format (e.g. mygroup/myproject)"` + MergeRequestID int `json:"merge_request_iid" jsonschema:"Internal merge request number (IID) shown in the GitLab UI"` +} + +type MergeRequestSummary struct { + ID int `json:"id" jsonschema:"GitLab merge request database ID"` + IID int `json:"iid" jsonschema:"Merge request number within the project (shown in the UI)"` + ProjectID int `json:"project_id"` + Title string `json:"title"` + State string `json:"state" jsonschema:"opened, closed, locked, or merged"` + Description string `json:"description,omitempty"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + Author string `json:"author" jsonschema:"GitLab username of the MR author"` + Assignees []string `json:"assignees,omitempty" jsonschema:"GitLab usernames of assignees"` + Reviewers []string `json:"reviewers,omitempty" jsonschema:"GitLab usernames of reviewers"` + Labels []string `json:"labels,omitempty"` + Milestone string `json:"milestone,omitempty"` + WebURL string `json:"web_url"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type GetMergeRequestOutput struct { + MergeRequest MergeRequestSummary `json:"merge_request"` +} + +type ListMergeRequestsInput struct { + Search string `json:"search,omitempty" jsonschema:"Keyword to search MR titles and descriptions"` + AssignedToMe bool `json:"assigned_to_me,omitempty" jsonschema:"List MRs assigned to you (the default when no other filter is set)"` + ReviewRequested bool `json:"review_requested,omitempty" jsonschema:"List MRs awaiting your review"` +} + +type ListMergeRequestsOutput struct { + MergeRequests []MergeRequestSummary `json:"merge_requests"` +} + +// --- Project types ---------------------------------------------------------- + +type GetProjectsInput struct { + ProjectPath string `json:"project_path,omitempty" jsonschema:"Full project path (namespace/project) to fetch a single project. Omit to list the projects you can access."` +} + +type ProjectSummary struct { + ID int `json:"id" jsonschema:"GitLab project database ID"` + Name string `json:"name"` + PathWithNamespace string `json:"path_with_namespace" jsonschema:"Full path including group/subgroup"` + Description string `json:"description,omitempty"` + WebURL string `json:"web_url"` + Visibility string `json:"visibility" jsonschema:"public, internal, or private"` + DefaultBranch string `json:"default_branch,omitempty"` +} + +type GetProjectsOutput struct { + Projects []ProjectSummary `json:"projects"` +} + +// --- Project metadata types ------------------------------------------------- + +type GetProjectMetadataInput struct { + ProjectPath string `json:"project_path" jsonschema:"Full project path in namespace/project format"` + Kind string `json:"kind" jsonschema:"Which metadata to return: 'labels', 'milestones', or 'members'"` +} + +type LabelSummary struct { + ID int `json:"id"` + Name string `json:"name"` + Color string `json:"color,omitempty" jsonschema:"Hex color code (e.g. #428BCA)"` + Description string `json:"description,omitempty"` +} + +type MilestoneSummary struct { + ID int `json:"id"` + IID int `json:"iid"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + State string `json:"state" jsonschema:"active or closed"` + DueDate string `json:"due_date,omitempty"` + StartDate string `json:"start_date,omitempty"` +} + +type ProjectMemberSummary struct { + ID int `json:"id" jsonschema:"GitLab user ID — use this value for assignee_ids"` + Username string `json:"username"` + Name string `json:"name"` + AccessLevel int `json:"access_level" jsonschema:"Access level: 10=Guest, 20=Reporter, 30=Developer, 40=Maintainer, 50=Owner"` +} + +type GetProjectMetadataOutput struct { + Labels []LabelSummary `json:"labels,omitempty"` + Milestones []MilestoneSummary `json:"milestones,omitempty"` + Members []ProjectMemberSummary `json:"members,omitempty"` +} + +// --- User types ------------------------------------------------------------- + +type GetMyGitLabUserOutput struct { + ID int `json:"id" jsonschema:"GitLab user database ID"` + Username string `json:"username"` + Name string `json:"name"` + Email string `json:"email,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + WebURL string `json:"web_url"` +} + +// --- Tool registration ------------------------------------------------------ + +// registerTools registers the GitLab MCP tools. The set is intentionally kept +// small (every tool's schema is injected into each LLM call, see the pluginmcp +// budget of ~10 tools): related read/search/list operations are merged into +// single tools with mode flags rather than exposed as separate tools. +func (p *Plugin) registerTools(s *pluginmcp.Server) { + readOnly := &mcp.ToolAnnotations{ReadOnlyHint: true} + additive := &mcp.ToolAnnotations{DestructiveHint: new(false)} + // update_issue overwrites existing fields and can close issues, so it is + // classified as destructive rather than reusing the additive annotation. + destructive := &mcp.ToolAnnotations{DestructiveHint: new(true)} + + // Issues + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "get_issue", + Description: "Fetch one issue's full details by project path and IID. For a keyword search or your assigned issues use list_issues.", + Annotations: readOnly, + }, p.handleGetIssue) + + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "list_issues", + Description: "List the issues assigned to you, or search issues by keyword. Returns issue summaries (capped to GitLab's default page size). For a single issue use get_issue.", + Annotations: readOnly, + }, p.handleListIssues) + + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "create_issue", + Description: "Create an issue in a project and return it. Resolve label names and assignee IDs first with get_project_metadata.", + Annotations: additive, + }, p.handleCreateIssue) + + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "update_issue", + Description: "Change an existing issue's fields or open/close state and return it; omitted fields are left untouched. To only add a comment use add_comment.", + Annotations: destructive, + }, p.handleUpdateIssue) + + // Comments (issues and merge requests) + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "add_comment", + Description: "Post a comment on an issue or merge request, selected via target_type, and return the created note.", + Annotations: additive, + }, p.handleAddComment) + + // Merge requests + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "get_merge_request", + Description: "Fetch one merge request's full details by project path and IID. For lists or keyword search use list_merge_requests.", + Annotations: readOnly, + }, p.handleGetMergeRequest) + + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "list_merge_requests", + Description: "List the merge requests assigned to you (default), awaiting your review, or matching a keyword search (capped to GitLab's default page size). For a single MR use get_merge_request.", + Annotations: readOnly, + }, p.handleListMergeRequests) + + // Projects + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "get_projects", + Description: "List the projects you can access, or fetch a single project when project_path is set (results capped to GitLab's default page size).", + Annotations: readOnly, + }, p.handleGetProjects) + + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "get_project_metadata", + Description: "Fetch a project's labels, milestones, or members (choose with kind). Use it to resolve label names and user IDs before create_issue or update_issue.", + Annotations: readOnly, + }, p.handleGetProjectMetadata) + + // User + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "get_my_gitlab_user", + Description: "Return your own GitLab identity (id, username, name). Use the id for assignee_ids when creating or updating issues.", + Annotations: readOnly, + }, p.handleGetMyGitLabUser) +} diff --git a/server/mocks/mock_gitlab.go b/server/mocks/mock_gitlab.go index 7f6ceeca2..b93c12de1 100644 --- a/server/mocks/mock_gitlab.go +++ b/server/mocks/mock_gitlab.go @@ -43,6 +43,36 @@ func (m *MockGitlab) EXPECT() *MockGitlabMockRecorder { return m.recorder } +// AddIssueNote mocks base method. +func (m *MockGitlab) AddIssueNote(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *oauth2.Token, arg3 string, arg4 int, arg5 string) (*gitlab0.Note, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddIssueNote", arg0, arg1, arg2, arg3, arg4, arg5) + ret0, _ := ret[0].(*gitlab0.Note) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddIssueNote indicates an expected call of AddIssueNote. +func (mr *MockGitlabMockRecorder) AddIssueNote(arg0, arg1, arg2, arg3, arg4, arg5 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddIssueNote", reflect.TypeOf((*MockGitlab)(nil).AddIssueNote), arg0, arg1, arg2, arg3, arg4, arg5) +} + +// AddMergeRequestNote mocks base method. +func (m *MockGitlab) AddMergeRequestNote(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *oauth2.Token, arg3 string, arg4 int, arg5 string) (*gitlab0.Note, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddMergeRequestNote", arg0, arg1, arg2, arg3, arg4, arg5) + ret0, _ := ret[0].(*gitlab0.Note) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddMergeRequestNote indicates an expected call of AddMergeRequestNote. +func (mr *MockGitlabMockRecorder) AddMergeRequestNote(arg0, arg1, arg2, arg3, arg4, arg5 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddMergeRequestNote", reflect.TypeOf((*MockGitlab)(nil).AddMergeRequestNote), arg0, arg1, arg2, arg3, arg4, arg5) +} + // AttachCommentToIssue mocks base method. func (m *MockGitlab) AttachCommentToIssue(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *gitlab.IssueRequest, arg3, arg4 string, arg5 *oauth2.Token) (*gitlab0.Note, error) { m.ctrl.T.Helper() @@ -53,7 +83,7 @@ func (m *MockGitlab) AttachCommentToIssue(arg0 context.Context, arg1 *gitlab.Use } // AttachCommentToIssue indicates an expected call of AttachCommentToIssue. -func (mr *MockGitlabMockRecorder) AttachCommentToIssue(arg0, arg1, arg2, arg3, arg4, arg5 interface{}) *gomock.Call { +func (mr *MockGitlabMockRecorder) AttachCommentToIssue(arg0, arg1, arg2, arg3, arg4, arg5 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AttachCommentToIssue", reflect.TypeOf((*MockGitlab)(nil).AttachCommentToIssue), arg0, arg1, arg2, arg3, arg4, arg5) } @@ -68,7 +98,7 @@ func (m *MockGitlab) CreateIssue(arg0 context.Context, arg1 *gitlab.UserInfo, ar } // CreateIssue indicates an expected call of CreateIssue. -func (mr *MockGitlabMockRecorder) CreateIssue(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { +func (mr *MockGitlabMockRecorder) CreateIssue(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateIssue", reflect.TypeOf((*MockGitlab)(nil).CreateIssue), arg0, arg1, arg2, arg3) } @@ -88,6 +118,21 @@ func (mr *MockGitlabMockRecorder) GetCurrentUser(arg0, arg1, arg2 any) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCurrentUser", reflect.TypeOf((*MockGitlab)(nil).GetCurrentUser), arg0, arg1, arg2) } +// GetGroup mocks base method. +func (m *MockGitlab) GetGroup(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *oauth2.Token, arg3, arg4 string) (*gitlab0.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGroup", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].(*gitlab0.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGroup indicates an expected call of GetGroup. +func (mr *MockGitlabMockRecorder) GetGroup(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroup", reflect.TypeOf((*MockGitlab)(nil).GetGroup), arg0, arg1, arg2, arg3, arg4) +} + // GetGroupHooks mocks base method. func (m *MockGitlab) GetGroupHooks(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *oauth2.Token, arg3 string) ([]*gitlab.WebhookInfo, error) { m.ctrl.T.Helper() @@ -143,7 +188,7 @@ func (m *MockGitlab) GetLabels(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 } // GetLabels indicates an expected call of GetLabels. -func (mr *MockGitlabMockRecorder) GetLabels(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { +func (mr *MockGitlabMockRecorder) GetLabels(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLabels", reflect.TypeOf((*MockGitlab)(nil).GetLabels), arg0, arg1, arg2, arg3) } @@ -173,7 +218,7 @@ func (m *MockGitlab) GetMilestones(arg0 context.Context, arg1 *gitlab.UserInfo, } // GetMilestones indicates an expected call of GetMilestones. -func (mr *MockGitlabMockRecorder) GetMilestones(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { +func (mr *MockGitlabMockRecorder) GetMilestones(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMilestones", reflect.TypeOf((*MockGitlab)(nil).GetMilestones), arg0, arg1, arg2, arg3) } @@ -218,7 +263,7 @@ func (m *MockGitlab) GetProjectMembers(arg0 context.Context, arg1 *gitlab.UserIn } // GetProjectMembers indicates an expected call of GetProjectMembers. -func (mr *MockGitlabMockRecorder) GetProjectMembers(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { +func (mr *MockGitlabMockRecorder) GetProjectMembers(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProjectMembers", reflect.TypeOf((*MockGitlab)(nil).GetProjectMembers), arg0, arg1, arg2, arg3) } @@ -323,7 +368,7 @@ func (m *MockGitlab) GetYourProjects(arg0 context.Context, arg1 *gitlab.UserInfo } // GetYourProjects indicates an expected call of GetYourProjects. -func (mr *MockGitlabMockRecorder) GetYourProjects(arg0, arg1, arg2 interface{}) *gomock.Call { +func (mr *MockGitlabMockRecorder) GetYourProjects(arg0, arg1, arg2 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetYourProjects", reflect.TypeOf((*MockGitlab)(nil).GetYourProjects), arg0, arg1, arg2) } @@ -343,6 +388,51 @@ func (mr *MockGitlabMockRecorder) GitlabConnect(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GitlabConnect", reflect.TypeOf((*MockGitlab)(nil).GitlabConnect), arg0) } +// ListAssignedIssues mocks base method. +func (m *MockGitlab) ListAssignedIssues(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *oauth2.Token) ([]*gitlab0.Issue, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAssignedIssues", arg0, arg1, arg2) + ret0, _ := ret[0].([]*gitlab0.Issue) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListAssignedIssues indicates an expected call of ListAssignedIssues. +func (mr *MockGitlabMockRecorder) ListAssignedIssues(arg0, arg1, arg2 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAssignedIssues", reflect.TypeOf((*MockGitlab)(nil).ListAssignedIssues), arg0, arg1, arg2) +} + +// ListAssignedMergeRequests mocks base method. +func (m *MockGitlab) ListAssignedMergeRequests(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *oauth2.Token) ([]*gitlab0.MergeRequest, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAssignedMergeRequests", arg0, arg1, arg2) + ret0, _ := ret[0].([]*gitlab0.MergeRequest) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListAssignedMergeRequests indicates an expected call of ListAssignedMergeRequests. +func (mr *MockGitlabMockRecorder) ListAssignedMergeRequests(arg0, arg1, arg2 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAssignedMergeRequests", reflect.TypeOf((*MockGitlab)(nil).ListAssignedMergeRequests), arg0, arg1, arg2) +} + +// ListReviewRequests mocks base method. +func (m *MockGitlab) ListReviewRequests(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *oauth2.Token) ([]*gitlab0.MergeRequest, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListReviewRequests", arg0, arg1, arg2) + ret0, _ := ret[0].([]*gitlab0.MergeRequest) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListReviewRequests indicates an expected call of ListReviewRequests. +func (mr *MockGitlabMockRecorder) ListReviewRequests(arg0, arg1, arg2 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListReviewRequests", reflect.TypeOf((*MockGitlab)(nil).ListReviewRequests), arg0, arg1, arg2) +} + // NewGroupHook mocks base method. func (m *MockGitlab) NewGroupHook(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *oauth2.Token, arg3 string, arg4 *gitlab.AddWebhookOptions) (*gitlab.WebhookInfo, error) { m.ctrl.T.Helper() @@ -399,11 +489,26 @@ func (m *MockGitlab) SearchIssues(arg0 context.Context, arg1 *gitlab.UserInfo, a } // SearchIssues indicates an expected call of SearchIssues. -func (mr *MockGitlabMockRecorder) SearchIssues(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { +func (mr *MockGitlabMockRecorder) SearchIssues(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchIssues", reflect.TypeOf((*MockGitlab)(nil).SearchIssues), arg0, arg1, arg2, arg3) } +// SearchMergeRequests mocks base method. +func (m *MockGitlab) SearchMergeRequests(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *oauth2.Token, arg3 string) ([]*gitlab0.MergeRequest, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SearchMergeRequests", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].([]*gitlab0.MergeRequest) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SearchMergeRequests indicates an expected call of SearchMergeRequests. +func (mr *MockGitlabMockRecorder) SearchMergeRequests(arg0, arg1, arg2, arg3 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchMergeRequests", reflect.TypeOf((*MockGitlab)(nil).SearchMergeRequests), arg0, arg1, arg2, arg3) +} + // TriggerProjectPipeline mocks base method. func (m *MockGitlab) TriggerProjectPipeline(arg0 *gitlab.UserInfo, arg1 *oauth2.Token, arg2, arg3 string) (*gitlab.PipelineInfo, error) { m.ctrl.T.Helper() @@ -418,3 +523,18 @@ func (mr *MockGitlabMockRecorder) TriggerProjectPipeline(arg0, arg1, arg2, arg3 mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TriggerProjectPipeline", reflect.TypeOf((*MockGitlab)(nil).TriggerProjectPipeline), arg0, arg1, arg2, arg3) } + +// UpdateIssue mocks base method. +func (m *MockGitlab) UpdateIssue(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *oauth2.Token, arg3 string, arg4 int, arg5 *gitlab.UpdateIssueOptions) (*gitlab0.Issue, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateIssue", arg0, arg1, arg2, arg3, arg4, arg5) + ret0, _ := ret[0].(*gitlab0.Issue) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateIssue indicates an expected call of UpdateIssue. +func (mr *MockGitlabMockRecorder) UpdateIssue(arg0, arg1, arg2, arg3, arg4, arg5 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateIssue", reflect.TypeOf((*MockGitlab)(nil).UpdateIssue), arg0, arg1, arg2, arg3, arg4, arg5) +} diff --git a/server/plugin.go b/server/plugin.go index 387422d1f..1723b92c4 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -82,6 +82,9 @@ type Plugin struct { WebhookHandler webhook.Webhook GitlabClient gitlab.Gitlab + + mcpMu sync.Mutex + mcpServer mcpServer } // gitlabPermalinkRegex is used to parse gitlab permalinks in post messages. @@ -139,10 +142,13 @@ func (p *Plugin) OnActivate() error { } p.flowManager = flowManager + p.startMCP() + return nil } func (p *Plugin) OnDeactivate() error { + p.stopMCP() p.oauthBroker.Close() return nil @@ -979,7 +985,7 @@ func (p *Plugin) getUsername(userID string) (string, *APIErrorResponse) { return info.GitlabUsername, nil } -func (p *Plugin) refreshToken(userInfo *gitlab.UserInfo, token *oauth2.Token) (*oauth2.Token, error) { +func (p *Plugin) refreshToken(userInfo *gitlab.UserInfo, token *oauth2.Token, notifyOnRevoke bool) (*oauth2.Token, error) { conf, err := p.getOAuthConfig() if err != nil { return nil, errors.Wrap(err, "unable to get OAuth config for token refresh") @@ -994,7 +1000,7 @@ func (p *Plugin) refreshToken(userInfo *gitlab.UserInfo, token *oauth2.Token) (* if err != nil { if strings.Contains(err.Error(), "\"error\":\"invalid_grant\"") { p.client.Log.Warn("Failed to refresh OAuth token as the existing one has an invalid grant. Revoking the token.", "userInfo", userInfo, "error", err.Error()) - p.handleRevokedToken(userInfo) + p.handleRevokedToken(userInfo, notifyOnRevoke) } return nil, errors.Wrap(err, "unable to get the new refreshed token") } @@ -1012,8 +1018,11 @@ func (p *Plugin) refreshToken(userInfo *gitlab.UserInfo, token *oauth2.Token) (* return token, nil } -func (p *Plugin) handleRevokedToken(info *gitlab.UserInfo) { +func (p *Plugin) handleRevokedToken(info *gitlab.UserInfo, notify bool) { p.disconnectGitlabAccount(info.UserID) + if !notify { + return + } err := p.CreateBotDMPost(info.UserID, "Your GitLab account was disconnected due to an invalid or revoked authorization token. Reconnect your account using the `/gitlab connect` command.", "custom_git_revoked_token") if err != nil { p.client.Log.Warn("Error sending revoked token DM post", "err", err.Error()) @@ -1207,7 +1216,11 @@ func (p *Plugin) forceDisconnectUser(userID string) { } } -func (p *Plugin) getOrRefreshTokenWithMutex(info *gitlab.UserInfo) (*oauth2.Token, error) { +// getOrRefreshToken returns a valid token for the user, refreshing it when it +// is close to expiry. notifyOnRevoke controls whether the user is DM'd if the +// token turns out to be revoked; pass false on automated paths (e.g. MCP) that +// may retry and would otherwise notify repeatedly. +func (p *Plugin) getOrRefreshToken(info *gitlab.UserInfo, notifyOnRevoke bool) (*oauth2.Token, error) { token, apiErr := p.getGitlabUserTokenByMattermostID(info.UserID) if apiErr != nil { @@ -1238,7 +1251,7 @@ func (p *Plugin) getOrRefreshTokenWithMutex(info *gitlab.UserInfo) (*oauth2.Toke return lockedToken, nil } - newToken, err := p.refreshToken(info, lockedToken) + newToken, err := p.refreshToken(info, lockedToken, notifyOnRevoke) if err != nil { return nil, err } @@ -1247,7 +1260,7 @@ func (p *Plugin) getOrRefreshTokenWithMutex(info *gitlab.UserInfo) (*oauth2.Toke } func (p *Plugin) useGitlabClient(info *gitlab.UserInfo, toRun func(info *gitlab.UserInfo, token *oauth2.Token) error) error { - token, err := p.getOrRefreshTokenWithMutex(info) + token, err := p.getOrRefreshToken(info, true) if err != nil { return err } @@ -1256,7 +1269,7 @@ func (p *Plugin) useGitlabClient(info *gitlab.UserInfo, toRun func(info *gitlab. if err != nil && strings.Contains(err.Error(), invalidTokenError) { p.client.Log.Warn("Revoking OAuth token while using Gitlab client as it is invalid", "userInfo", info, "error", err.Error()) - p.handleRevokedToken(info) + p.handleRevokedToken(info, true) } return err diff --git a/server/plugin_test.go b/server/plugin_test.go index ca21089ef..4bb96c221 100644 --- a/server/plugin_test.go +++ b/server/plugin_test.go @@ -429,8 +429,40 @@ func TestRefreshTokenReturnsErrorWhenOAuthConfigFails(t *testing.T) { Expiry: time.Now().Add(-1 * time.Hour), } - newToken, err := p.refreshToken(userInfo, token) + newToken, err := p.refreshToken(userInfo, token, true) assert.Nil(t, newToken) assert.Error(t, err) assert.Contains(t, err.Error(), "unable to get OAuth config for token refresh") } + +func TestHandleRevokedTokenWithoutNotifyDisconnectsSilently(t *testing.T) { + const userID = "test-user" + info := &gitlab.UserInfo{ + UserID: userID, + GitlabUsername: "gitlab-user", + GitlabUserID: 42, + } + infoJSON, err := json.Marshal(info) + require.NoError(t, err) + + p := &Plugin{} + + api := &plugintest.API{} + api.On("KVGet", userID+GitlabUserInfoKey).Return(infoJSON, nil) + // pluginapi KV.Delete is implemented via KVSetWithOptions with a nil value. + api.On("KVSetWithOptions", mock.AnythingOfType("string"), []byte(nil), mock.Anything).Return(true, nil) + api.On("GetUser", userID).Return(nil, &model.AppError{Message: "not found"}) + api.On("PublishWebSocketEvent", WsEventDisconnect, mock.Anything, mock.Anything).Return() + p.SetAPI(api) + p.client = pluginapi.NewClient(api, p.Driver) + + p.handleRevokedToken(info, false) + + // The revoke/disconnect still happens: stored user info and token are removed. + api.AssertCalled(t, "KVSetWithOptions", userID+GitlabUserInfoKey, []byte(nil), mock.Anything) + api.AssertCalled(t, "KVSetWithOptions", userID+GitlabUserTokenKey, []byte(nil), mock.Anything) + + // No DM/notification is sent when notifyOnRevoke is false. + api.AssertNotCalled(t, "GetDirectChannel", mock.Anything, mock.Anything) + api.AssertNotCalled(t, "CreatePost", mock.Anything) +}