From b4ea430dc5ab962a8ed33dc17fea61c44a011f76 Mon Sep 17 00:00:00 2001 From: avasconcelos114 Date: Fri, 15 May 2026 18:37:09 +0300 Subject: [PATCH 1/4] feat: Adding agents MCP tools for GitLab --- go.mod | 44 +- go.sum | 119 +++-- server/api.go | 2 + server/gitlab/gitlab.go | 7 + server/gitlab/mcp_api.go | 220 ++++++++ server/gitlab/mocks/mock_gitlab.go | 128 ++++- server/mcp.go | 110 ++++ server/mcp_handlers.go | 776 +++++++++++++++++++++++++++++ server/mcp_test.go | 528 ++++++++++++++++++++ server/mcp_tools.go | 431 ++++++++++++++++ server/mocks/mock_gitlab.go | 119 ++++- server/plugin.go | 6 + 12 files changed, 2395 insertions(+), 95 deletions(-) create mode 100644 server/gitlab/mcp_api.go create mode 100644 server/mcp.go create mode 100644 server/mcp_handlers.go create mode 100644 server/mcp_test.go create mode 100644 server/mcp_tools.go diff --git a/go.mod b/go.mod index 4087f8694..46b31f3a6 100644 --- a/go.mod +++ b/go.mod @@ -1,32 +1,35 @@ module github.com/mattermost/mattermost-plugin-gitlab -go 1.25 +go 1.26.2 require ( github.com/gorilla/mux v1.8.1 github.com/hashicorp/go-multierror v1.1.1 - github.com/mattermost/mattermost/server/public v0.1.21 + github.com/mattermost/mattermost-plugin-agents v1.14.1-0.20260508173910-8219eb13bd4e + github.com/mattermost/mattermost/server/public v0.3.1-0.20260402155910-d9d71af83e3f github.com/microcosm-cc/bluemonday v1.0.27 github.com/pkg/errors v0.9.1 github.com/stretchr/testify v1.11.1 github.com/xanzy/go-gitlab v0.97.0 go.uber.org/mock v0.4.0 - golang.org/x/oauth2 v0.34.0 - golang.org/x/sync v0.19.0 + golang.org/x/oauth2 v0.36.0 + golang.org/x/sync v0.20.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beevik/etree v1.6.0 // indirect - github.com/blang/semver/v4 v4.0.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a // indirect - github.com/fatih/color v1.18.0 // indirect + github.com/fatih/color v1.19.0 // indirect github.com/francoispqt/gojay v1.2.13 // indirect github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/websocket v1.5.3 // indirect @@ -37,37 +40,40 @@ require ( github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect - github.com/lib/pq v1.10.9 // indirect + github.com/lib/pq v1.12.0 // indirect github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 // indirect github.com/mattermost/gosaml2 v0.10.0 // indirect github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 // indirect github.com/mattermost/logr/v2 v2.0.22 // indirect - github.com/mattermost/mattermost/server/v8 v8.0.0-20251014075701-833e0125320d // indirect github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/modelcontextprotocol/go-sdk v1.4.1 // indirect github.com/oklog/run v1.2.0 // indirect github.com/pborman/uuid v1.2.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/russellhaering/goxmldsig v1.5.0 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect + github.com/russellhaering/goxmldsig v1.6.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/stretchr/objx v0.5.3 // indirect - github.com/tinylib/msgp v1.4.0 // indirect + github.com/tinylib/msgp v1.6.3 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/wiggin77/merror v1.0.5 // indirect github.com/wiggin77/srslog v1.0.1 // indirect - golang.org/x/crypto v0.46.0 // indirect - golang.org/x/mod v0.30.0 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.32.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.3.0 // indirect google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect - google.golang.org/grpc v1.79.3 // indirect - google.golang.org/protobuf v1.36.10 // indirect + google.golang.org/grpc v1.81.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 879ebea12..1caae935c 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1 dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU= git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= @@ -15,8 +17,6 @@ github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62 github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE= github.com/beevik/etree v1.6.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= -github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= @@ -34,8 +34,8 @@ github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25Kn github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a h1:etIrTD8BQqzColk9nKRusM9um5+1q0iOEJLqfBMIK64= github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a/go.mod h1:emQhSYTXqB0xxjLITTw4EaWZ+8IIQYw+kx9GqNUKdLg= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= @@ -49,7 +49,11 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +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= @@ -69,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= @@ -119,8 +125,8 @@ github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.12.0 h1:mC1zeiNamwKBecjHarAr26c/+d8V5w/u4J0I/yASbJo= +github.com/lib/pq v1.12.0/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 h1:Khvh6waxG1cHc4Cz5ef9n3XVCxRWpAKUtqg9PJl5+y8= @@ -131,10 +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.1.21 h1:LyynB6G1CVTy1am6WCf8JwG+B+V0+up5DM2iYg5JGvg= -github.com/mattermost/mattermost/server/public v0.1.21/go.mod h1:X0RG3lk0XK0SFSH67JS/xporlz3TxItHEPlFIrsQIa8= -github.com/mattermost/mattermost/server/v8 v8.0.0-20251014075701-833e0125320d h1:etRyN6FNd6fc7BGZ8X+XB2u/5Hb2HNz5/K53YZNvfrs= -github.com/mattermost/mattermost/server/v8 v8.0.0-20251014075701-833e0125320d/go.mod h1:HILhsra+xY4SNEFhuPbobH3I8a0aeXJcTJ6RWPX85nI= +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= @@ -176,12 +184,16 @@ github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7q github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russellhaering/goxmldsig v1.2.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= -github.com/russellhaering/goxmldsig v1.5.0 h1:AU2UkkYIUOTyZRbe08XMThaOCelArgvNfYapcmSjBNw= -github.com/russellhaering/goxmldsig v1.5.0/go.mod h1:x98CjQNFJcWfMxeOrMnMKg70lvDP6tE0nTaeUnjXDmk= +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= @@ -205,8 +217,8 @@ github.com/shurcooL/reactions v0.0.0-20181006231557-f2e0b4ca5b82/go.mod h1:TCR1l github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4= github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -214,13 +226,12 @@ github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= -github.com/tinylib/msgp v1.4.0 h1:SYOeDRiydzOw9kSiwdYp9UcBgPFtLU2WDHaJXyHruf8= -github.com/tinylib/msgp v1.4.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o= +github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= +github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU= github.com/viant/toolbox v0.24.0/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMILuUhzM= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= @@ -233,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= @@ -253,14 +266,14 @@ golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+ golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -270,21 +283,21 @@ golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -297,16 +310,14 @@ 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.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= @@ -316,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.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= 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= @@ -337,10 +350,10 @@ 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/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/server/api.go b/server/api.go index 973fb6e20..d4e412a2a 100644 --- a/server/api.go +++ b/server/api.go @@ -50,6 +50,8 @@ func (p *Plugin) initializeAPI() { p.router = mux.NewRouter() p.router.Use(p.withRecovery) + p.router.PathPrefix("/mcp").HandlerFunc(p.serveMCPHTTP) + oauthRouter := p.router.PathPrefix("/oauth").Subrouter() apiRouter := p.router.PathPrefix("/api/v1").Subrouter() apiRouter.Use(p.checkConfigured) diff --git a/server/gitlab/gitlab.go b/server/gitlab/gitlab.go index 04229dd47..4768cb32a 100644 --- a/server/gitlab/gitlab.go +++ b/server/gitlab/gitlab.go @@ -63,6 +63,13 @@ type Gitlab interface { fullPath string, allowPrivate bool, ) (namespace string, project string, err error) + + UpdateIssue(ctx context.Context, user *UserInfo, token *oauth2.Token, projectID string, issueIID int, opts *UpdateIssueOptions) (*internGitlab.Issue, error) + AddIssueNote(ctx context.Context, user *UserInfo, token *oauth2.Token, projectID string, issueIID int, body string) (*internGitlab.Note, error) + SearchMergeRequests(ctx context.Context, user *UserInfo, token *oauth2.Token, search string) ([]*internGitlab.MergeRequest, error) + CreateMergeRequest(ctx context.Context, user *UserInfo, token *oauth2.Token, projectID string, opts *CreateMergeRequestOptions) (*internGitlab.MergeRequest, error) + AddMergeRequestNote(ctx context.Context, user *UserInfo, token *oauth2.Token, projectID string, mrIID int, body string) (*internGitlab.Note, error) + ListProjectPipelines(ctx context.Context, user *UserInfo, token *oauth2.Token, projectID string, ref string, status string, page int, perPage int) ([]*internGitlab.PipelineInfo, error) } type gitlab struct { diff --git a/server/gitlab/mcp_api.go b/server/gitlab/mcp_api.go new file mode 100644 index 000000000..ac4e502e2 --- /dev/null +++ b/server/gitlab/mcp_api.go @@ -0,0 +1,220 @@ +// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package gitlab + +import ( + "context" + "fmt" + + internGitlab "github.com/xanzy/go-gitlab" + "golang.org/x/oauth2" +) + +// UpdateIssueOptions contains the fields that can be updated on an existing issue. +type UpdateIssueOptions struct { + Title *string + Description *string + // StateEvent is "close" or "reopen". + StateEvent *string + AssigneeIDs *[]int + Labels *internGitlab.LabelOptions + MilestoneID *int +} + +// CreateMergeRequestOptions contains the required and optional fields for creating +// a new merge request. +type CreateMergeRequestOptions struct { + Title string + Description string + SourceBranch string + TargetBranch string + AssigneeIDs []int + ReviewerIDs []int + Labels internGitlab.LabelOptions + MilestoneID *int +} + +func (g *gitlab) UpdateIssue(ctx context.Context, user *UserInfo, token *oauth2.Token, projectID string, issueIID int, opts *UpdateIssueOptions) (*internGitlab.Issue, error) { + client, err := g.GitlabConnect(*token) + 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.GitlabConnect(*token) + if err != nil { + return nil, err + } + if err = g.ensureProjectInAllowedGroup(ctx, client, projectID); err != nil { + return nil, err + } + + note, resp, err := client.Notes.CreateIssueNote( + projectID, + issueIID, + &internGitlab.CreateIssueNoteOptions{Body: &body}, + internGitlab.WithContext(ctx), + ) + if respErr := checkResponse(resp); respErr != nil { + return nil, respErr + } + if err != nil { + return nil, fmt.Errorf("failed to add issue comment: %w", err) + } + + return note, nil +} + +func (g *gitlab) SearchMergeRequests(ctx context.Context, user *UserInfo, token *oauth2.Token, search string) ([]*internGitlab.MergeRequest, error) { + client, err := g.GitlabConnect(*token) + if err != nil { + return nil, err + } + + if g.gitlabGroup == "" { + result, resp, err := client.Search.MergeRequests(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 + } + + result, resp, err := client.Search.MergeRequestsByGroup(g.gitlabGroup, search, &internGitlab.SearchOptions{}, internGitlab.WithContext(ctx)) + if respErr := checkResponse(resp); respErr != nil { + return nil, respErr + } + if err != nil { + return nil, fmt.Errorf("failed to search merge requests: %w", err) + } + + return result, nil +} + +func (g *gitlab) CreateMergeRequest(ctx context.Context, user *UserInfo, token *oauth2.Token, projectID string, opts *CreateMergeRequestOptions) (*internGitlab.MergeRequest, error) { + client, err := g.GitlabConnect(*token) + if err != nil { + return nil, err + } + if err = g.ensureProjectInAllowedGroup(ctx, client, projectID); err != nil { + return nil, err + } + + createOpts := &internGitlab.CreateMergeRequestOptions{ + Title: &opts.Title, + Description: &opts.Description, + SourceBranch: &opts.SourceBranch, + TargetBranch: &opts.TargetBranch, + } + if len(opts.AssigneeIDs) > 0 { + createOpts.AssigneeIDs = &opts.AssigneeIDs + } + if len(opts.ReviewerIDs) > 0 { + createOpts.ReviewerIDs = &opts.ReviewerIDs + } + if len(opts.Labels) > 0 { + createOpts.Labels = &opts.Labels + } + if opts.MilestoneID != nil { + createOpts.MilestoneID = opts.MilestoneID + } + + mr, resp, err := client.MergeRequests.CreateMergeRequest(projectID, createOpts, internGitlab.WithContext(ctx)) + if respErr := checkResponse(resp); respErr != nil { + return nil, respErr + } + if err != nil { + return nil, fmt.Errorf("failed to create merge request: %w", err) + } + + return mr, nil +} + +func (g *gitlab) AddMergeRequestNote(ctx context.Context, user *UserInfo, token *oauth2.Token, projectID string, mrIID int, body string) (*internGitlab.Note, error) { + client, err := g.GitlabConnect(*token) + if err != nil { + return nil, err + } + if err = g.ensureProjectInAllowedGroup(ctx, client, projectID); err != nil { + return nil, err + } + + note, resp, err := client.Notes.CreateMergeRequestNote( + projectID, + mrIID, + &internGitlab.CreateMergeRequestNoteOptions{Body: &body}, + internGitlab.WithContext(ctx), + ) + if respErr := checkResponse(resp); respErr != nil { + return nil, respErr + } + if err != nil { + return nil, fmt.Errorf("failed to add merge request comment: %w", err) + } + + return note, nil +} + +func (g *gitlab) ListProjectPipelines(ctx context.Context, user *UserInfo, token *oauth2.Token, projectID string, ref string, status string, page int, perPage int) ([]*internGitlab.PipelineInfo, error) { + client, err := g.GitlabConnect(*token) + if err != nil { + return nil, err + } + if err = g.ensureProjectInAllowedGroup(ctx, client, projectID); err != nil { + return nil, err + } + + if perPage <= 0 { + perPage = 20 + } + if page <= 0 { + page = 1 + } + + opts := &internGitlab.ListProjectPipelinesOptions{ + ListOptions: internGitlab.ListOptions{Page: page, PerPage: perPage}, + } + if ref != "" { + opts.Ref = &ref + } + if status != "" { + bs := internGitlab.BuildStateValue(status) + opts.Status = &bs + } + + pipelines, resp, err := client.Pipelines.ListProjectPipelines(projectID, opts, internGitlab.WithContext(ctx)) + if respErr := checkResponse(resp); respErr != nil { + return nil, respErr + } + if err != nil { + return nil, fmt.Errorf("failed to list pipelines: %w", err) + } + + return pipelines, nil +} diff --git a/server/gitlab/mocks/mock_gitlab.go b/server/gitlab/mocks/mock_gitlab.go index 11c502954..31377e2e8 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,11 +98,26 @@ 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) } +// CreateMergeRequest mocks base method. +func (m *MockGitlab) CreateMergeRequest(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *oauth2.Token, arg3 string, arg4 *gitlab.CreateMergeRequestOptions) (*gitlab0.MergeRequest, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateMergeRequest", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].(*gitlab0.MergeRequest) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateMergeRequest indicates an expected call of CreateMergeRequest. +func (mr *MockGitlabMockRecorder) CreateMergeRequest(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateMergeRequest", reflect.TypeOf((*MockGitlab)(nil).CreateMergeRequest), arg0, arg1, arg2, arg3, arg4) +} + // GetCurrentUser mocks base method. func (m *MockGitlab) GetCurrentUser(arg0 context.Context, arg1 string, arg2 oauth2.Token) (*gitlab.UserInfo, error) { m.ctrl.T.Helper() @@ -88,6 +133,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 +203,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 +233,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 +247,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 +278,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 +383,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 +403,21 @@ func (mr *MockGitlabMockRecorder) GitlabConnect(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GitlabConnect", reflect.TypeOf((*MockGitlab)(nil).GitlabConnect), arg0) } +// ListProjectPipelines mocks base method. +func (m *MockGitlab) ListProjectPipelines(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *oauth2.Token, arg3, arg4, arg5 string, arg6, arg7 int) ([]*gitlab0.PipelineInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListProjectPipelines", arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7) + ret0, _ := ret[0].([]*gitlab0.PipelineInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListProjectPipelines indicates an expected call of ListProjectPipelines. +func (mr *MockGitlabMockRecorder) ListProjectPipelines(arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListProjectPipelines", reflect.TypeOf((*MockGitlab)(nil).ListProjectPipelines), arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7) +} + // 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 +474,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 +508,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..70f7a23fd --- /dev/null +++ b/server/mcp.go @@ -0,0 +1,110 @@ +// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package main + +import ( + "context" + "fmt" + "net/http" + + "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" +) + +// 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 + } + + s := pluginmcp.NewServer(p.API, pluginmcp.Config{ + PluginID: manifest.Id, + Name: mcpServerName, + Path: mcpBasePath, + Version: manifest.Version, + }) + + p.registerTools(s) + p.mcpServer = s + + if err := s.Register(); err != nil { + p.API.LogWarn("MCP register call returned error; tools will not be exposed to Agents", + "err", err.Error()) + } +} + +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) + } + + token, err := p.getOrRefreshTokenWithMutex(info) + 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..706c08910 --- /dev/null +++ b/server/mcp_handlers.go @@ -0,0 +1,776 @@ +// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package main + +import ( + "context" + "fmt" + + "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 +} + +func (p *Plugin) handleListMyAssignedIssues(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, ListMyAssignedIssuesOutput, error) { + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, ListMyAssignedIssuesOutput{}, err + } + + client, err := p.GitlabClient.GitlabConnect(*token) + if err != nil { + return nil, ListMyAssignedIssuesOutput{}, fmt.Errorf("failed to connect to GitLab: %w", err) + } + + issues, err := p.GitlabClient.GetYourAssignedIssues(ctx, info, client) + if err != nil { + return nil, ListMyAssignedIssuesOutput{}, fmt.Errorf("failed to list assigned issues: %w", err) + } + + return nil, ListMyAssignedIssuesOutput{Issues: issuesToSummaries(issues)}, nil +} + +func (p *Plugin) handleSearchIssues(ctx context.Context, _ *mcp.CallToolRequest, in SearchIssuesInput) (*mcp.CallToolResult, SearchIssuesOutput, error) { + if in.Search == "" { + return nil, SearchIssuesOutput{}, fmt.Errorf("search term is required") + } + + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, SearchIssuesOutput{}, err + } + + issues, err := p.GitlabClient.SearchIssues(ctx, info, in.Search, token) + if err != nil { + return nil, SearchIssuesOutput{}, fmt.Errorf("failed to search issues: %w", err) + } + + return nil, SearchIssuesOutput{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 + } + + req := &gitlab.IssueRequest{ + Title: in.Title, + Description: in.Description, + Assignees: in.AssigneeIDs, + Milestone: in.MilestoneID, + Labels: internGitlab.LabelOptions(in.Labels), + } + + owner, repo := splitProjectPathParts(in.ProjectPath) + 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 +} + +func (p *Plugin) handleAddIssueComment(ctx context.Context, _ *mcp.CallToolRequest, in AddIssueCommentInput) (*mcp.CallToolResult, AddIssueCommentOutput, error) { + if in.ProjectPath == "" { + return nil, AddIssueCommentOutput{}, fmt.Errorf("project_path is required") + } + if in.IssueIID <= 0 { + return nil, AddIssueCommentOutput{}, fmt.Errorf("issue_iid must be a positive integer") + } + if in.Body == "" { + return nil, AddIssueCommentOutput{}, fmt.Errorf("body is required") + } + + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, AddIssueCommentOutput{}, err + } + + note, err := p.GitlabClient.AddIssueNote(ctx, info, token, in.ProjectPath, in.IssueIID, in.Body) + if err != nil { + return nil, AddIssueCommentOutput{}, fmt.Errorf("failed to add issue comment: %w", err) + } + + return nil, AddIssueCommentOutput{NoteID: note.ID, Body: note.Body}, 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 +} + +func (p *Plugin) handleListMyAssignedMRs(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, ListMyAssignedMergeRequestsOutput, error) { + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, ListMyAssignedMergeRequestsOutput{}, err + } + + client, err := p.GitlabClient.GitlabConnect(*token) + if err != nil { + return nil, ListMyAssignedMergeRequestsOutput{}, fmt.Errorf("failed to connect to GitLab: %w", err) + } + + mrs, err := p.GitlabClient.GetYourAssignedPrs(ctx, info, client) + if err != nil { + return nil, ListMyAssignedMergeRequestsOutput{}, fmt.Errorf("failed to list assigned merge requests: %w", err) + } + + return nil, ListMyAssignedMergeRequestsOutput{MergeRequests: mrsToSummaries(mrs)}, nil +} + +func (p *Plugin) handleListMyReviewRequests(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, ListMyReviewRequestsOutput, error) { + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, ListMyReviewRequestsOutput{}, err + } + + client, err := p.GitlabClient.GitlabConnect(*token) + if err != nil { + return nil, ListMyReviewRequestsOutput{}, fmt.Errorf("failed to connect to GitLab: %w", err) + } + + mrs, err := p.GitlabClient.GetReviews(ctx, info, client) + if err != nil { + return nil, ListMyReviewRequestsOutput{}, fmt.Errorf("failed to list review requests: %w", err) + } + + return nil, ListMyReviewRequestsOutput{MergeRequests: mrsToSummaries(mrs)}, nil +} + +func (p *Plugin) handleSearchMergeRequests(ctx context.Context, _ *mcp.CallToolRequest, in SearchMergeRequestsInput) (*mcp.CallToolResult, SearchMergeRequestsOutput, error) { + if in.Search == "" { + return nil, SearchMergeRequestsOutput{}, fmt.Errorf("search term is required") + } + + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, SearchMergeRequestsOutput{}, err + } + + mrs, err := p.GitlabClient.SearchMergeRequests(ctx, info, token, in.Search) + if err != nil { + return nil, SearchMergeRequestsOutput{}, fmt.Errorf("failed to search merge requests: %w", err) + } + + return nil, SearchMergeRequestsOutput{MergeRequests: mrsToSummaries(mrs)}, nil +} + +func (p *Plugin) handleCreateMergeRequest(ctx context.Context, _ *mcp.CallToolRequest, in CreateMergeRequestInput) (*mcp.CallToolResult, CreateMergeRequestOutput, error) { + if in.ProjectPath == "" { + return nil, CreateMergeRequestOutput{}, fmt.Errorf("project_path is required") + } + if in.Title == "" { + return nil, CreateMergeRequestOutput{}, fmt.Errorf("title is required") + } + if in.SourceBranch == "" { + return nil, CreateMergeRequestOutput{}, fmt.Errorf("source_branch is required") + } + if in.TargetBranch == "" { + return nil, CreateMergeRequestOutput{}, fmt.Errorf("target_branch is required") + } + + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, CreateMergeRequestOutput{}, err + } + + opts := &gitlab.CreateMergeRequestOptions{ + Title: in.Title, + Description: in.Description, + SourceBranch: in.SourceBranch, + TargetBranch: in.TargetBranch, + AssigneeIDs: in.AssigneeIDs, + ReviewerIDs: in.ReviewerIDs, + Labels: internGitlab.LabelOptions(in.Labels), + MilestoneID: in.MilestoneID, + } + + mr, err := p.GitlabClient.CreateMergeRequest(ctx, info, token, in.ProjectPath, opts) + if err != nil { + return nil, CreateMergeRequestOutput{}, fmt.Errorf("failed to create merge request: %w", err) + } + + return nil, CreateMergeRequestOutput{MergeRequest: mrToSummary(mr)}, nil +} + +func (p *Plugin) handleAddMergeRequestComment(ctx context.Context, _ *mcp.CallToolRequest, in AddMergeRequestCommentInput) (*mcp.CallToolResult, AddMergeRequestCommentOutput, error) { + if in.ProjectPath == "" { + return nil, AddMergeRequestCommentOutput{}, fmt.Errorf("project_path is required") + } + if in.MergeRequestID <= 0 { + return nil, AddMergeRequestCommentOutput{}, fmt.Errorf("merge_request_iid must be a positive integer") + } + if in.Body == "" { + return nil, AddMergeRequestCommentOutput{}, fmt.Errorf("body is required") + } + + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, AddMergeRequestCommentOutput{}, err + } + + note, err := p.GitlabClient.AddMergeRequestNote(ctx, info, token, in.ProjectPath, in.MergeRequestID, in.Body) + if err != nil { + return nil, AddMergeRequestCommentOutput{}, fmt.Errorf("failed to add merge request comment: %w", err) + } + + return nil, AddMergeRequestCommentOutput{NoteID: note.ID, Body: note.Body}, nil +} + +// ============================================================================ +// Projects +// ============================================================================ + +func (p *Plugin) handleListMyProjects(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, ListMyProjectsOutput, error) { + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, ListMyProjectsOutput{}, err + } + + projects, err := p.GitlabClient.GetYourProjects(ctx, info, token) + if err != nil { + return nil, ListMyProjectsOutput{}, 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, ListMyProjectsOutput{Projects: summaries}, nil +} + +func (p *Plugin) handleGetProject(ctx context.Context, _ *mcp.CallToolRequest, in GetProjectInput) (*mcp.CallToolResult, GetProjectOutput, error) { + if in.ProjectPath == "" { + return nil, GetProjectOutput{}, fmt.Errorf("project_path is required") + } + + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, GetProjectOutput{}, err + } + + owner, repo := splitProjectPathParts(in.ProjectPath) + project, err := p.GitlabClient.GetProject(ctx, info, token, owner, repo) + if err != nil { + return nil, GetProjectOutput{}, fmt.Errorf("failed to get project: %w", err) + } + + return nil, GetProjectOutput{Project: projectToSummary(project)}, nil +} + +// ============================================================================ +// Pipelines +// ============================================================================ + +func (p *Plugin) handleRunPipeline(ctx context.Context, _ *mcp.CallToolRequest, in RunPipelineInput) (*mcp.CallToolResult, RunPipelineOutput, error) { + if in.ProjectPath == "" { + return nil, RunPipelineOutput{}, fmt.Errorf("project_path is required") + } + if in.Ref == "" { + return nil, RunPipelineOutput{}, fmt.Errorf("ref is required") + } + + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, RunPipelineOutput{}, err + } + + pipeline, err := p.GitlabClient.TriggerProjectPipeline(info, token, in.ProjectPath, in.Ref) + if err != nil { + return nil, RunPipelineOutput{}, fmt.Errorf("failed to run pipeline: %w", err) + } + + return nil, RunPipelineOutput{Pipeline: PipelineSummary{ + ID: pipeline.PipelineID, + Status: pipeline.Status, + Ref: pipeline.Ref, + SHA: pipeline.SHA, + WebURL: pipeline.WebURL, + }}, nil +} + +func (p *Plugin) handleListProjectPipelines(ctx context.Context, _ *mcp.CallToolRequest, in ListProjectPipelinesInput) (*mcp.CallToolResult, ListProjectPipelinesOutput, error) { + if in.ProjectPath == "" { + return nil, ListProjectPipelinesOutput{}, fmt.Errorf("project_path is required") + } + + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, ListProjectPipelinesOutput{}, err + } + + pipelines, err := p.GitlabClient.ListProjectPipelines(ctx, info, token, in.ProjectPath, in.Ref, in.Status, in.Page, in.PerPage) + if err != nil { + return nil, ListProjectPipelinesOutput{}, fmt.Errorf("failed to list pipelines: %w", err) + } + + summaries := make([]PipelineSummary, 0, len(pipelines)) + for _, pl := range pipelines { + summaries = append(summaries, pipelineInfoToSummary(pl)) + } + + return nil, ListProjectPipelinesOutput{Pipelines: summaries}, nil +} + +// ============================================================================ +// Todos +// ============================================================================ + +func (p *Plugin) handleGetMyTodos(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, GetMyTodosOutput, error) { + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, GetMyTodosOutput{}, err + } + + client, err := p.GitlabClient.GitlabConnect(*token) + if err != nil { + return nil, GetMyTodosOutput{}, fmt.Errorf("failed to connect to GitLab: %w", err) + } + + todos, err := p.GitlabClient.GetToDoList(ctx, info, client) + if err != nil { + return nil, GetMyTodosOutput{}, fmt.Errorf("failed to get todos: %w", err) + } + + return nil, GetMyTodosOutput{Todos: todosToSummaries(todos)}, nil +} + +// ============================================================================ +// Dashboard +// ============================================================================ + +func (p *Plugin) handleGetGitLabDashboard(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, LHSDataOutput, error) { + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, LHSDataOutput{}, err + } + + lhs, err := p.GitlabClient.GetLHSData(ctx, info, token) + if err != nil { + return nil, LHSDataOutput{}, fmt.Errorf("failed to get GitLab dashboard: %w", err) + } + + return nil, LHSDataOutput{ + AssignedMergeRequests: mrsToSummaries(lhs.AssignedPRs), + ReviewRequests: mrsToSummaries(lhs.Reviews), + AssignedIssues: issuesToSummaries(lhs.AssignedIssues), + Todos: todosToSummaries(lhs.Todos), + }, nil +} + +// ============================================================================ +// Labels and Milestones +// ============================================================================ + +func (p *Plugin) handleListProjectLabels(ctx context.Context, _ *mcp.CallToolRequest, in ListProjectLabelsInput) (*mcp.CallToolResult, ListProjectLabelsOutput, error) { + if in.ProjectPath == "" { + return nil, ListProjectLabelsOutput{}, fmt.Errorf("project_path is required") + } + + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, ListProjectLabelsOutput{}, err + } + + labels, err := p.GitlabClient.GetLabels(ctx, info, in.ProjectPath, token) + if err != nil { + return nil, ListProjectLabelsOutput{}, fmt.Errorf("failed to list labels: %w", err) + } + + summaries := make([]LabelSummary, 0, len(labels)) + for _, l := range labels { + summaries = append(summaries, LabelSummary{ + ID: l.ID, + Name: l.Name, + Color: l.Color, + Description: l.Description, + }) + } + + return nil, ListProjectLabelsOutput{Labels: summaries}, nil +} + +func (p *Plugin) handleListProjectMilestones(ctx context.Context, _ *mcp.CallToolRequest, in ListProjectMilestonesInput) (*mcp.CallToolResult, ListProjectMilestonesOutput, error) { + if in.ProjectPath == "" { + return nil, ListProjectMilestonesOutput{}, fmt.Errorf("project_path is required") + } + + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, ListProjectMilestonesOutput{}, err + } + + milestones, err := p.GitlabClient.GetMilestones(ctx, info, in.ProjectPath, token) + if err != nil { + return nil, ListProjectMilestonesOutput{}, fmt.Errorf("failed to list milestones: %w", err) + } + + summaries := 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() + } + summaries = append(summaries, ms) + } + + return nil, ListProjectMilestonesOutput{Milestones: summaries}, nil +} + +// ============================================================================ +// User / Metadata +// ============================================================================ + +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 +} + +func (p *Plugin) handleListProjectMembers(ctx context.Context, _ *mcp.CallToolRequest, in ListProjectMembersInput) (*mcp.CallToolResult, ListProjectMembersOutput, error) { + if in.ProjectPath == "" { + return nil, ListProjectMembersOutput{}, fmt.Errorf("project_path is required") + } + + info, token, err := p.resolveCaller(ctx) + if err != nil { + return nil, ListProjectMembersOutput{}, err + } + + members, err := p.GitlabClient.GetProjectMembers(ctx, info, in.ProjectPath, token) + if err != nil { + return nil, ListProjectMembersOutput{}, fmt.Errorf("failed to list project members: %w", err) + } + + summaries := make([]ProjectMemberSummary, 0, len(members)) + for _, m := range members { + summaries = append(summaries, ProjectMemberSummary{ + ID: m.ID, + Username: m.Username, + Name: m.Name, + AccessLevel: int(m.AccessLevel), + }) + } + + return nil, ListProjectMembersOutput{Members: summaries}, 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 +} + +func pipelineInfoToSummary(pl *internGitlab.PipelineInfo) PipelineSummary { + if pl == nil { + return PipelineSummary{} + } + s := PipelineSummary{ + ID: pl.ID, + Status: pl.Status, + Ref: pl.Ref, + SHA: pl.SHA, + WebURL: pl.WebURL, + } + if pl.CreatedAt != nil { + s.CreatedAt = pl.CreatedAt.String() + } + if pl.UpdatedAt != nil { + s.UpdatedAt = pl.UpdatedAt.String() + } + return s +} + +func todoToSummary(todo *internGitlab.Todo) TodoSummary { + if todo == nil { + return TodoSummary{} + } + s := TodoSummary{ + ID: todo.ID, + ActionName: string(todo.ActionName), + } + if todo.Target != nil { + s.TargetType = string(todo.TargetType) + s.TargetTitle = todo.Target.Title + s.WebURL = todo.Target.WebURL + } + if todo.Project != nil { + s.ProjectPath = todo.Project.PathWithNamespace + } + if todo.CreatedAt != nil { + s.CreatedAt = todo.CreatedAt.String() + } + return s +} + +func todosToSummaries(todos []*internGitlab.Todo) []TodoSummary { + out := make([]TodoSummary, 0, len(todos)) + for _, t := range todos { + if t != nil { + out = append(out, todoToSummary(t)) + } + } + return out +} + +// 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..52b0d4d59 --- /dev/null +++ b/server/mcp_test.go @@ -0,0 +1,528 @@ +// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/mattermost/mattermost/server/public/plugin/plugintest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + gomock "go.uber.org/mock/gomock" + internGitlab "github.com/xanzy/go-gitlab" + + 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("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 i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + 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 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 +} + +// --- 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 TestHandleCreateMergeRequest_Validation(t *testing.T) { + cases := []struct { + name string + input CreateMergeRequestInput + errSub string + }{ + {"empty project_path", CreateMergeRequestInput{Title: "T", SourceBranch: "feat", TargetBranch: "main"}, "project_path is required"}, + {"empty title", CreateMergeRequestInput{ProjectPath: "g/p", SourceBranch: "feat", TargetBranch: "main"}, "title is required"}, + {"empty source_branch", CreateMergeRequestInput{ProjectPath: "g/p", Title: "T", TargetBranch: "main"}, "source_branch is required"}, + {"empty target_branch", CreateMergeRequestInput{ProjectPath: "g/p", Title: "T", SourceBranch: "feat"}, "target_branch is required"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p, _ := newPluginWithMockGitlab(t) + _, _, err := p.handleCreateMergeRequest(context.Background(), nil, tc.input) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.errSub) + }) + } +} + +func TestHandleSearchIssues_Validation(t *testing.T) { + p, _ := newPluginWithMockGitlab(t) + _, _, err := p.handleSearchIssues(context.Background(), nil, SearchIssuesInput{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "search term is required") +} + +func TestHandleSearchMergeRequests_Validation(t *testing.T) { + p, _ := newPluginWithMockGitlab(t) + _, _, err := p.handleSearchMergeRequests(context.Background(), nil, SearchMergeRequestsInput{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "search term is required") +} + +func TestHandleRunPipeline_Validation(t *testing.T) { + t.Run("empty project_path", func(t *testing.T) { + p, _ := newPluginWithMockGitlab(t) + _, _, err := p.handleRunPipeline(context.Background(), nil, RunPipelineInput{Ref: "main"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "project_path is required") + }) + + t.Run("empty ref", func(t *testing.T) { + p, _ := newPluginWithMockGitlab(t) + _, _, err := p.handleRunPipeline(context.Background(), nil, RunPipelineInput{ProjectPath: "g/p"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "ref is required") + }) +} + +func TestHandleAddIssueComment_Validation(t *testing.T) { + cases := []struct { + name string + input AddIssueCommentInput + errSub string + }{ + {"empty project_path", AddIssueCommentInput{IssueIID: 1, Body: "hi"}, "project_path is required"}, + {"zero issue_iid", AddIssueCommentInput{ProjectPath: "g/p", Body: "hi"}, "issue_iid must be a positive integer"}, + {"empty body", AddIssueCommentInput{ProjectPath: "g/p", IssueIID: 1}, "body is required"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p, _ := newPluginWithMockGitlab(t) + _, _, err := p.handleAddIssueComment(context.Background(), nil, tc.input) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.errSub) + }) + } +} + +func TestHandleAddMergeRequestComment_Validation(t *testing.T) { + cases := []struct { + name string + input AddMergeRequestCommentInput + errSub string + }{ + {"empty project_path", AddMergeRequestCommentInput{MergeRequestID: 1, Body: "hi"}, "project_path is required"}, + {"zero merge_request_iid", AddMergeRequestCommentInput{ProjectPath: "g/p", Body: "hi"}, "merge_request_iid must be a positive integer"}, + {"empty body", AddMergeRequestCommentInput{ProjectPath: "g/p", MergeRequestID: 1}, "body is required"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p, _ := newPluginWithMockGitlab(t) + _, _, err := p.handleAddMergeRequestComment(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 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) + }) +} + +func TestPipelineInfoToSummary(t *testing.T) { + t.Run("nil returns zero value", func(t *testing.T) { + assert.Equal(t, 0, pipelineInfoToSummary(nil).ID) + }) + + t.Run("populates fields", func(t *testing.T) { + ts := time.Date(2026, 5, 8, 9, 0, 0, 0, time.UTC) + s := pipelineInfoToSummary(&internGitlab.PipelineInfo{ + ID: 123, + Status: "success", + Ref: "main", + SHA: "abc123", + WebURL: "https://gitlab.com/g/p/-/pipelines/123", + CreatedAt: &ts, + UpdatedAt: &ts, + }) + assert.Equal(t, 123, s.ID) + assert.Equal(t, "success", s.Status) + assert.Equal(t, "main", s.Ref) + assert.NotEmpty(t, s.CreatedAt) + }) +} + +func TestTodoToSummary(t *testing.T) { + t.Run("nil returns zero value", func(t *testing.T) { + assert.Equal(t, 0, todoToSummary(nil).ID) + }) + + t.Run("populates fields", func(t *testing.T) { + s := todoToSummary(&internGitlab.Todo{ + ID: 99, + ActionName: internGitlab.TodoAction("assigned"), + TargetType: internGitlab.TodoTargetType("Issue"), + Target: &internGitlab.TodoTarget{ + Title: "Fix the thing", + WebURL: "https://gitlab.com/g/p/-/issues/1", + }, + Project: &internGitlab.BasicProject{PathWithNamespace: "g/p"}, + }) + assert.Equal(t, 99, s.ID) + assert.Equal(t, "assigned", s.ActionName) + assert.Equal(t, "Issue", s.TargetType) + assert.Equal(t, "Fix the thing", s.TargetTitle) + assert.Equal(t, "g/p", s.ProjectPath) + assert.Equal(t, "https://gitlab.com/g/p/-/issues/1", s.WebURL) + }) + + t.Run("nil target and project safe", func(t *testing.T) { + s := todoToSummary(&internGitlab.Todo{ID: 1}) + assert.Empty(t, s.TargetTitle) + assert.Empty(t, s.ProjectPath) + }) +} + diff --git a/server/mcp_tools.go b/server/mcp_tools.go new file mode 100644 index 000000000..4cd2a89cf --- /dev/null +++ b/server/mcp_tools.go @@ -0,0 +1,431 @@ +// 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" +) + +// --- Common types ----------------------------------------------------------- + +type PaginationInput struct { + Page int `json:"page,omitempty" jsonschema:"Page number (1-based, default 1)"` + PerPage int `json:"per_page,omitempty" jsonschema:"Number of results per page (default 20, max 100)"` +} + +// --- 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 ListMyAssignedIssuesOutput struct { + Issues []IssueSummary `json:"issues"` +} + +type SearchIssuesInput struct { + Search string `json:"search" jsonschema:"Full-text search term to find issues by title or description"` +} + +type SearchIssuesOutput 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. Use list_project_members to look up IDs."` + MilestoneID int `json:"milestone_id,omitempty" jsonschema:"Optional milestone ID. Use list_project_milestones to look up IDs."` +} + +type CreateIssueOutput struct { + Issue IssueSummary `json:"issue"` +} + +type UpdateIssueInput 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)"` + 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' to close the issue, 'reopen' to reopen it (omit to leave state unchanged)"` + Labels []string `json:"labels,omitempty" jsonschema:"Replacement label set. Omit to leave labels unchanged. Send an empty array to clear all labels."` + AssigneeIDs []int `json:"assignee_ids,omitempty" jsonschema:"Replacement assignee list (GitLab user IDs). Omit to leave unchanged. Send an empty array to clear all assignees."` + 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"` +} + +type AddIssueCommentInput struct { + ProjectPath string `json:"project_path" jsonschema:"Full project path in namespace/project format"` + IssueIID int `json:"issue_iid" jsonschema:"Internal issue number (IID)"` + Body string `json:"body" jsonschema:"Comment text (Markdown supported)"` +} + +type AddIssueCommentOutput 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 ListMyAssignedMergeRequestsOutput struct { + MergeRequests []MergeRequestSummary `json:"merge_requests"` +} + +type ListMyReviewRequestsOutput struct { + MergeRequests []MergeRequestSummary `json:"merge_requests"` +} + +type SearchMergeRequestsInput struct { + Search string `json:"search" jsonschema:"Full-text search term to find merge requests by title or description"` +} + +type SearchMergeRequestsOutput struct { + MergeRequests []MergeRequestSummary `json:"merge_requests"` +} + +type CreateMergeRequestInput struct { + ProjectPath string `json:"project_path" jsonschema:"Full project path of the source project in namespace/project format"` + Title string `json:"title" jsonschema:"Merge request title (required)"` + Description string `json:"description,omitempty" jsonschema:"Optional MR description (Markdown supported)"` + SourceBranch string `json:"source_branch" jsonschema:"Branch to merge from (required)"` + TargetBranch string `json:"target_branch" jsonschema:"Branch to merge into (required, e.g. main or master)"` + AssigneeIDs []int `json:"assignee_ids,omitempty" jsonschema:"Optional list of GitLab user IDs to assign. Use list_project_members to look up IDs."` + ReviewerIDs []int `json:"reviewer_ids,omitempty" jsonschema:"Optional list of GitLab user IDs to request review from"` + Labels []string `json:"labels,omitempty" jsonschema:"Optional list of label names to apply"` + MilestoneID *int `json:"milestone_id,omitempty" jsonschema:"Optional milestone ID"` +} + +type CreateMergeRequestOutput struct { + MergeRequest MergeRequestSummary `json:"merge_request"` +} + +type AddMergeRequestCommentInput struct { + ProjectPath string `json:"project_path" jsonschema:"Full project path in namespace/project format"` + MergeRequestID int `json:"merge_request_iid" jsonschema:"Internal merge request number (IID)"` + Body string `json:"body" jsonschema:"Comment text (Markdown supported)"` +} + +type AddMergeRequestCommentOutput struct { + NoteID int `json:"note_id" jsonschema:"ID of the newly created note/comment"` + Body string `json:"body"` +} + +// --- Project types ---------------------------------------------------------- + +type ListMyProjectsOutput struct { + Projects []ProjectSummary `json:"projects"` +} + +type GetProjectInput struct { + ProjectPath string `json:"project_path" jsonschema:"Full project path in namespace/project format (e.g. mygroup/myproject)"` +} + +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 GetProjectOutput struct { + Project ProjectSummary `json:"project"` +} + +// --- Pipeline types --------------------------------------------------------- + +type RunPipelineInput struct { + ProjectPath string `json:"project_path" jsonschema:"Full project path in namespace/project format"` + Ref string `json:"ref" jsonschema:"Branch name or tag to run the pipeline on (e.g. main)"` +} + +type PipelineSummary struct { + ID int `json:"id"` + Status string `json:"status" jsonschema:"Pipeline status: pending, running, passed, failed, canceled, skipped"` + Ref string `json:"ref" jsonschema:"Branch or tag name"` + SHA string `json:"sha,omitempty" jsonschema:"Commit SHA"` + WebURL string `json:"web_url"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type RunPipelineOutput struct { + Pipeline PipelineSummary `json:"pipeline"` +} + +type ListProjectPipelinesInput struct { + ProjectPath string `json:"project_path" jsonschema:"Full project path in namespace/project format"` + Ref string `json:"ref,omitempty" jsonschema:"Optional branch or tag to filter pipelines"` + Status string `json:"status,omitempty" jsonschema:"Optional status filter: pending, running, passed, failed, canceled, skipped"` + PaginationInput +} + +type ListProjectPipelinesOutput struct { + Pipelines []PipelineSummary `json:"pipelines"` +} + +// --- Todo types ------------------------------------------------------------- + +type TodoSummary struct { + ID int `json:"id"` + ActionName string `json:"action_name" jsonschema:"What triggered this todo, e.g. assigned, mentioned, review_requested"` + TargetType string `json:"target_type" jsonschema:"Type of the target object, e.g. Issue or MergeRequest"` + TargetTitle string `json:"target_title"` + ProjectPath string `json:"project_path,omitempty"` + WebURL string `json:"web_url,omitempty"` + CreatedAt string `json:"created_at,omitempty"` +} + +type GetMyTodosOutput struct { + Todos []TodoSummary `json:"todos"` +} + +// --- LHS dashboard types ---------------------------------------------------- + +type LHSDataOutput struct { + AssignedMergeRequests []MergeRequestSummary `json:"assigned_merge_requests"` + ReviewRequests []MergeRequestSummary `json:"review_requests"` + AssignedIssues []IssueSummary `json:"assigned_issues"` + Todos []TodoSummary `json:"todos"` +} + +// --- Label / milestone types ------------------------------------------------ + +type ListProjectLabelsInput struct { + ProjectPath string `json:"project_path" jsonschema:"Full project path in namespace/project format"` +} + +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 ListProjectLabelsOutput struct { + Labels []LabelSummary `json:"labels"` +} + +type ListProjectMilestonesInput struct { + ProjectPath string `json:"project_path" jsonschema:"Full project path in namespace/project format"` +} + +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 ListProjectMilestonesOutput struct { + Milestones []MilestoneSummary `json:"milestones"` +} + +// --- User / member 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"` +} + +type ListProjectMembersInput struct { + ProjectPath string `json:"project_path" jsonschema:"Full project path in namespace/project format"` +} + +type ProjectMemberSummary struct { + ID int `json:"id" jsonschema:"GitLab user ID — use this value for assignee_ids and reviewer_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 ListProjectMembersOutput struct { + Members []ProjectMemberSummary `json:"members"` +} + +// --- Tool registration ------------------------------------------------------ + +func (p *Plugin) registerTools(s *pluginmcp.Server) { + // Issues + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "get_issue", + Description: "Retrieve details of a single GitLab issue by project path and issue IID (the number shown in the GitLab UI). Returns title, state, description, labels, assignees, milestone, and web URL.", + }, p.handleGetIssue) + + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "list_my_assigned_issues", + Description: "List all open GitLab issues currently assigned to the calling user. Respects the plugin's configured namespace restriction.", + }, p.handleListMyAssignedIssues) + + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "search_issues", + Description: "Full-text search for GitLab issues by title or description. Searches within the configured namespace (group or whole instance).", + }, p.handleSearchIssues) + + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "create_issue", + Description: "Create a new GitLab issue in a project. Use list_project_labels to find valid label names, list_project_milestones for milestone IDs, and list_project_members for assignee user IDs.", + }, p.handleCreateIssue) + + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "update_issue", + Description: "Update an existing GitLab issue. Only fields explicitly provided are changed; omitted fields remain as-is. Use state_event 'close' or 'reopen' to change issue state.", + }, p.handleUpdateIssue) + + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "add_issue_comment", + Description: "Add a comment (note) to an existing GitLab issue. Markdown is supported in the body.", + }, p.handleAddIssueComment) + + // Merge Requests + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "get_merge_request", + Description: "Retrieve details of a single GitLab merge request by project path and MR IID. Returns title, state, branches, author, assignees, reviewers, labels, and web URL.", + }, p.handleGetMergeRequest) + + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "list_my_assigned_merge_requests", + Description: "List all open GitLab merge requests currently assigned to the calling user.", + }, p.handleListMyAssignedMRs) + + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "list_my_review_requests", + Description: "List all open GitLab merge requests where the calling user has been requested as a reviewer.", + }, p.handleListMyReviewRequests) + + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "search_merge_requests", + Description: "Full-text search for GitLab merge requests by title or description.", + }, p.handleSearchMergeRequests) + + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "create_merge_request", + Description: "Create a new GitLab merge request. source_branch and target_branch are required. Use list_project_members to look up assignee and reviewer user IDs.", + }, p.handleCreateMergeRequest) + + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "add_merge_request_comment", + Description: "Add a comment (note) to an existing GitLab merge request. Markdown is supported in the body.", + }, p.handleAddMergeRequestComment) + + // Projects + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "list_my_projects", + Description: "List GitLab projects the calling user has access to. If the plugin is configured with a group restriction, only projects within that group are returned.", + }, p.handleListMyProjects) + + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "get_project", + Description: "Get details for a specific GitLab project by its full path (namespace/project).", + }, p.handleGetProject) + + // Pipelines + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "run_pipeline", + Description: "Trigger a new CI/CD pipeline for a given project and branch or tag ref.", + }, p.handleRunPipeline) + + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "list_project_pipelines", + Description: "List recent CI/CD pipelines for a project. Optionally filter by ref (branch/tag) and status (pending, running, passed, failed, canceled, skipped).", + }, p.handleListProjectPipelines) + + // Todos + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "get_my_todos", + Description: "List the calling user's GitLab to-do items — issues and merge requests that require their attention (assigned, mentioned, review requested, etc.).", + }, p.handleGetMyTodos) + + // Dashboard + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "get_gitlab_dashboard", + Description: "Return a combined overview of the calling user's GitLab workload in a single call: assigned merge requests, review requests, assigned issues, and todos. Useful for a quick situational awareness check.", + }, p.handleGetGitLabDashboard) + + // Labels and milestones + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "list_project_labels", + Description: "List all labels defined for a GitLab project. Use this to find valid label names before creating or updating issues and merge requests.", + }, p.handleListProjectLabels) + + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "list_project_milestones", + Description: "List active milestones for a GitLab project. Includes group-level milestones when the project belongs to a group.", + }, p.handleListProjectMilestones) + + // User / metadata + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "get_my_gitlab_user", + Description: "Return the calling user's GitLab profile: username, name, email, and web URL. Useful for agents to identify who they are acting as.", + }, p.handleGetMyGitLabUser) + + pluginmcp.AddTool(s, &mcp.Tool{ + Name: "list_project_members", + Description: "List all members of a GitLab project with their user IDs and access levels. Use the returned id values as assignee_ids or reviewer_ids when creating issues or merge requests.", + }, p.handleListProjectMembers) +} diff --git a/server/mocks/mock_gitlab.go b/server/mocks/mock_gitlab.go index 7f6ceeca2..143c288cf 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,11 +98,26 @@ 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) } +// CreateMergeRequest mocks base method. +func (m *MockGitlab) CreateMergeRequest(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *oauth2.Token, arg3 string, arg4 *gitlab.CreateMergeRequestOptions) (*gitlab0.MergeRequest, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateMergeRequest", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].(*gitlab0.MergeRequest) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateMergeRequest indicates an expected call of CreateMergeRequest. +func (mr *MockGitlabMockRecorder) CreateMergeRequest(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateMergeRequest", reflect.TypeOf((*MockGitlab)(nil).CreateMergeRequest), arg0, arg1, arg2, arg3, arg4) +} + // GetCurrentUser mocks base method. func (m *MockGitlab) GetCurrentUser(arg0 context.Context, arg1 string, arg2 oauth2.Token) (*gitlab.UserInfo, error) { m.ctrl.T.Helper() @@ -88,6 +133,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 +203,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 +233,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 +278,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 +383,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 +403,21 @@ func (mr *MockGitlabMockRecorder) GitlabConnect(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GitlabConnect", reflect.TypeOf((*MockGitlab)(nil).GitlabConnect), arg0) } +// ListProjectPipelines mocks base method. +func (m *MockGitlab) ListProjectPipelines(arg0 context.Context, arg1 *gitlab.UserInfo, arg2 *oauth2.Token, arg3, arg4, arg5 string, arg6, arg7 int) ([]*gitlab0.PipelineInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListProjectPipelines", arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7) + ret0, _ := ret[0].([]*gitlab0.PipelineInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListProjectPipelines indicates an expected call of ListProjectPipelines. +func (mr *MockGitlabMockRecorder) ListProjectPipelines(arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListProjectPipelines", reflect.TypeOf((*MockGitlab)(nil).ListProjectPipelines), arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7) +} + // 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 +474,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 +508,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..789b3ee62 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 From 4cb800b72316e7b0c4a18040d8db65174de978a1 Mon Sep 17 00:00:00 2001 From: avasconcelos114 Date: Fri, 15 May 2026 19:27:48 +0300 Subject: [PATCH 2/4] Making fixes for coderabbit review --- server/api.go | 2 +- server/gitlab/api.go | 6 +++--- server/gitlab/mcp_api.go | 17 +++++++-------- server/mcp_handlers.go | 21 +++++++++++++++++-- server/mcp_test.go | 45 +++++++++++++++++++++++++++------------- server/mcp_tools.go | 25 +++++++++++----------- 6 files changed, 74 insertions(+), 42 deletions(-) diff --git a/server/api.go b/server/api.go index d4e412a2a..af4f33fc1 100644 --- a/server/api.go +++ b/server/api.go @@ -287,7 +287,7 @@ func (p *Plugin) connectUserToGitlab(c *Context, w http.ResponseWriter, r *http. } if errorMsg != "" { - _, err := p.poster.DMWithAttachments(userID, &model.SlackAttachment{ + _, err := p.poster.DMWithAttachments(userID, &model.MessageAttachment{ Text: fmt.Sprintf("There was an error connecting to your GitLab: `%s` Please double check your configuration.", errorMsg), Color: string(flow.ColorDanger), }) diff --git a/server/gitlab/api.go b/server/gitlab/api.go index 6e7c3c945..4ecb0b637 100644 --- a/server/gitlab/api.go +++ b/server/gitlab/api.go @@ -710,8 +710,8 @@ func (g *gitlab) GetYourProjects(ctx context.Context, user *UserInfo, token *oau if g.gitlabGroup == "" { // ─── “No Group” branch: list all projects you belong to opts := &internGitlab.ListProjectsOptions{ - Membership: model.NewPointer(true), - WithIssuesEnabled: model.NewPointer(true), + Membership: new(true), + WithIssuesEnabled: new(true), MinAccessLevel: model.NewPointer(guestLevel), ListOptions: internGitlab.ListOptions{ Page: 1, @@ -729,7 +729,7 @@ func (g *gitlab) GetYourProjects(ctx context.Context, user *UserInfo, token *oau } // ─── “With Group” branch: list all projects in that group you have access to opts := &internGitlab.ListGroupProjectsOptions{ - WithIssuesEnabled: model.NewPointer(true), + WithIssuesEnabled: new(true), MinAccessLevel: model.NewPointer(guestLevel), ListOptions: internGitlab.ListOptions{ Page: 1, diff --git a/server/gitlab/mcp_api.go b/server/gitlab/mcp_api.go index ac4e502e2..13c1ec01b 100644 --- a/server/gitlab/mcp_api.go +++ b/server/gitlab/mcp_api.go @@ -95,18 +95,15 @@ func (g *gitlab) SearchMergeRequests(ctx context.Context, user *UserInfo, token 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)) - 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 + 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)) } - - result, resp, err := client.Search.MergeRequestsByGroup(g.gitlabGroup, search, &internGitlab.SearchOptions{}, internGitlab.WithContext(ctx)) if respErr := checkResponse(resp); respErr != nil { return nil, respErr } diff --git a/server/mcp_handlers.go b/server/mcp_handlers.go index 706c08910..824558101 100644 --- a/server/mcp_handlers.go +++ b/server/mcp_handlers.go @@ -172,7 +172,11 @@ func (p *Plugin) handleAddIssueComment(ctx context.Context, _ *mcp.CallToolReque return nil, AddIssueCommentOutput{}, fmt.Errorf("failed to add issue comment: %w", err) } - return nil, AddIssueCommentOutput{NoteID: note.ID, Body: note.Body}, nil + return nil, AddIssueCommentOutput{ + NoteID: note.ID, + Body: note.Body, + WebURL: noteWebURL(p.getConfiguration().GitlabURL, in.ProjectPath, "issues", in.IssueIID, note.ID), + }, nil } // ============================================================================ @@ -320,7 +324,11 @@ func (p *Plugin) handleAddMergeRequestComment(ctx context.Context, _ *mcp.CallTo return nil, AddMergeRequestCommentOutput{}, fmt.Errorf("failed to add merge request comment: %w", err) } - return nil, AddMergeRequestCommentOutput{NoteID: note.ID, Body: note.Body}, nil + return nil, AddMergeRequestCommentOutput{ + NoteID: note.ID, + Body: note.Body, + WebURL: noteWebURL(p.getConfiguration().GitlabURL, in.ProjectPath, "merge_requests", in.MergeRequestID, note.ID), + }, nil } // ============================================================================ @@ -750,6 +758,15 @@ func todosToSummaries(todos []*internGitlab.Todo) []TodoSummary { return out } +// 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", 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) { diff --git a/server/mcp_test.go b/server/mcp_test.go index 52b0d4d59..2d6a4f109 100644 --- a/server/mcp_test.go +++ b/server/mcp_test.go @@ -15,8 +15,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - gomock "go.uber.org/mock/gomock" internGitlab "github.com/xanzy/go-gitlab" + gomock "go.uber.org/mock/gomock" mockgitlab "github.com/mattermost/mattermost-plugin-gitlab/server/mocks" ) @@ -51,12 +51,10 @@ func TestStartMCP_Idempotent(t *testing.T) { // Concurrent calls should produce at most one mcpServer instance and must // not data-race. var wg sync.WaitGroup - for i := 0; i < 10; i++ { - wg.Add(1) - go func() { - defer wg.Done() + for range 10 { + wg.Go(func() { p.startMCP() - }() + }) } wg.Wait() @@ -125,11 +123,11 @@ func TestResolveCaller_NoUserID(t *testing.T) { func TestSplitProjectPath(t *testing.T) { tests := []struct { - name string - input string - wantOwner string - wantRepo string - wantErrSub string + name string + input string + wantOwner string + wantRepo string + wantErrSub string }{ { name: "simple namespace/project", @@ -244,8 +242,8 @@ func TestHandleCreateIssue_Validation(t *testing.T) { func TestHandleCreateMergeRequest_Validation(t *testing.T) { cases := []struct { - name string - input CreateMergeRequestInput + name string + input CreateMergeRequestInput errSub string }{ {"empty project_path", CreateMergeRequestInput{Title: "T", SourceBranch: "feat", TargetBranch: "main"}, "project_path is required"}, @@ -435,6 +433,26 @@ func TestMrsToSummaries_SkipsNil(t *testing.T) { 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("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) @@ -525,4 +543,3 @@ func TestTodoToSummary(t *testing.T) { assert.Empty(t, s.ProjectPath) }) } - diff --git a/server/mcp_tools.go b/server/mcp_tools.go index 4cd2a89cf..9b107ea4a 100644 --- a/server/mcp_tools.go +++ b/server/mcp_tools.go @@ -23,18 +23,18 @@ type GetIssueInput struct { } 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"` + 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 { @@ -164,6 +164,7 @@ type AddMergeRequestCommentInput struct { type AddMergeRequestCommentOutput struct { NoteID int `json:"note_id" jsonschema:"ID of the newly created note/comment"` Body string `json:"body"` + WebURL string `json:"web_url,omitempty"` } // --- Project types ---------------------------------------------------------- From 476dbd41f819dd9c500f31c1bbe8c461732d0886 Mon Sep 17 00:00:00 2001 From: avasconcelos114 Date: Fri, 15 May 2026 19:34:33 +0300 Subject: [PATCH 3/4] Replacing last NewPointer with new() --- server/command_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/command_test.go b/server/command_test.go index 6b28becc2..279a72715 100644 --- a/server/command_test.go +++ b/server/command_test.go @@ -485,7 +485,7 @@ func TestAddWebhookCommand(t *testing.T) { p.GitlabClient = mockedClient conf := &model.Config{} - conf.ServiceSettings.SiteURL = model.NewPointer(test.siteURL) + conf.ServiceSettings.SiteURL = new(test.siteURL) encryptedToken, _ := encrypt([]byte(testEncryptionKey), testGitlabToken) From 2e8303073d839670e43e470720e2286f791ddcab Mon Sep 17 00:00:00 2001 From: avasconcelos114 Date: Fri, 15 May 2026 19:45:02 +0300 Subject: [PATCH 4/4] Running go mod tidy --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 46b31f3a6..f38aaf5f2 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( 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 @@ -48,7 +49,6 @@ require ( 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.22 // indirect - github.com/modelcontextprotocol/go-sdk v1.4.1 // 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