diff --git a/README.md b/README.md index 28a16856..b0db0a7d 100644 --- a/README.md +++ b/README.md @@ -720,6 +720,36 @@ jira board list ``` +## MCP server + +`jira-cli` ships an embedded [Model Context Protocol](https://modelcontextprotocol.io) server so MCP-aware hosts (Cursor, Claude Desktop, etc.) can read and modify Jira issues during a coding session. The server reuses the same config, auth, and Jira API client as the rest of the CLI. + +Start it from your MCP host configuration: + +```json +{ + "mcpServers": { + "jira": { + "command": "jira", + "args": ["mcp", "serve"], + "env": { "JIRA_API_TOKEN": "..." } + } + } +} +``` + +The server speaks stdio and exposes the following tools: + +| Tool | Purpose | +| --- | --- | +| `search_issues` | Search by raw JQL or simple `status`/`assignee` filters. | +| `get_issue` | Full issue details including description and recent comments. | +| `create_issue` | Create a new issue in a project. | +| `add_comment` | Add a comment to an issue. | +| `transition_issue` | Move an issue to a new status by name. | + +Every tool that returns an issue also returns its browser URL so the LLM can cite or link to it directly. + ## Scripts Often times, you may want to use the output of the command to do something cool. However, the default interactive UI might not allow you to do that. The tool comes with the `--plain` flag that displays results in a simple layout that can then be manipulated from the shell script. diff --git a/go.mod b/go.mod index f17c8b04..c7c31150 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/mattn/go-isatty v0.0.20 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d github.com/mitchellh/go-homedir v1.1.0 + github.com/modelcontextprotocol/go-sdk v1.5.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/rivo/tview v0.0.0-20240406141410-79d4cc321256 github.com/russross/blackfriday/v2 v2.1.0 @@ -47,6 +48,7 @@ require ( github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gdamore/encoding v1.0.1 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 // indirect @@ -64,18 +66,22 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect golang.org/x/net v0.38.0 // indirect - golang.org/x/sys v0.31.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.23.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index ff292e4f..84474c11 100644 --- a/go.sum +++ b/go.sum @@ -63,8 +63,12 @@ github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAY github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -109,6 +113,8 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU= +github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= @@ -136,6 +142,10 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +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/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= @@ -165,6 +175,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +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= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= @@ -187,6 +199,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -200,8 +214,8 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -220,6 +234,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= diff --git a/internal/cmd/mcp/mcp.go b/internal/cmd/mcp/mcp.go new file mode 100644 index 00000000..74b194df --- /dev/null +++ b/internal/cmd/mcp/mcp.go @@ -0,0 +1,25 @@ +package mcp + +import ( + "github.com/spf13/cobra" + + "github.com/ankitpokhrel/jira-cli/internal/cmd/mcp/serve" +) + +const helpText = `Run jira-cli as a Model Context Protocol (MCP) server, exposing +Jira operations to MCP-aware hosts (e.g. Cursor, Claude Desktop).` + +// NewCmdMCP is the parent command for MCP-related subcommands. +func NewCmdMCP() *cobra.Command { + cmd := &cobra.Command{ + Use: "mcp", + Short: "Run jira-cli as an MCP server", + Long: helpText, + Annotations: map[string]string{"cmd:main": "true"}, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + cmd.AddCommand(serve.NewCmdServe()) + return cmd +} diff --git a/internal/cmd/mcp/serve/serve.go b/internal/cmd/mcp/serve/serve.go new file mode 100644 index 00000000..f900f693 --- /dev/null +++ b/internal/cmd/mcp/serve/serve.go @@ -0,0 +1,85 @@ +package serve + +import ( + "fmt" + "os" + "os/signal" + "syscall" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/ankitpokhrel/jira-cli/api" + jiramcp "github.com/ankitpokhrel/jira-cli/internal/mcp" + "github.com/ankitpokhrel/jira-cli/internal/mcp/tools" +) + +const helpText = `Start an MCP server over stdio. + +Configure your MCP host (Cursor, Claude Desktop, etc.) like this: + + { + "mcpServers": { + "jira": { + "command": "jira", + "args": ["mcp", "serve"], + "env": { "JIRA_API_TOKEN": "..." } + } + } + } + +The server inherits the same configuration as every other jira-cli command: +JIRA_CONFIG_FILE, ~/.config/.jira/.config.yml, .netrc, and keychain all work +unchanged. The server reads from stdin and writes JSON-RPC frames to stdout; +all logs go to stderr.` + +// NewCmdServe is the `jira mcp serve` command. +func NewCmdServe() *cobra.Command { + return &cobra.Command{ + Use: "serve", + Short: "Start an MCP server over stdio", + Long: helpText, + RunE: run, + } +} + +func run(cmd *cobra.Command, _ []string) error { + server := viper.GetString("server") + if server == "" { + return fmt.Errorf("no Jira server configured. Run 'jira init' to set up the tool") + } + + // Honor browse_server override the same way internal/cmdutil.GenerateServerBrowseURL does, + // so MCP-emitted issue URLs match what the rest of the CLI produces for users whose web + // client and API endpoints differ. + browseServer := server + if v := viper.GetString("browse_server"); v != "" { + browseServer = v + } + + // Stdout is reserved for JSON-RPC frames; pkg/jira's debug dump and + // root's "Using config file: ..." both write to stdout when debug is + // enabled, which would corrupt the MCP transport. Force-disable here + // and surface a stderr notice if the user had it on in config. + if viper.GetBool("debug") { + fmt.Fprintln(os.Stderr, "jira-cli MCP server: ignoring debug=true (would corrupt stdio transport)") + } + deps := &tools.Deps{ + Client: api.DefaultClient(false), + Server: browseServer, + DefaultProject: viper.GetString("project.key"), + Installation: viper.GetString("installation"), + } + + srv := jiramcp.NewServer(deps) + + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) + defer stop() + + fmt.Fprintln(os.Stderr, "jira-cli MCP server: listening on stdio") + if err := srv.Run(ctx, &mcpsdk.StdioTransport{}); err != nil { + return fmt.Errorf("mcp server: %w", err) + } + return nil +} diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index 7bd95e1b..ee6de1b2 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -14,6 +14,7 @@ import ( initCmd "github.com/ankitpokhrel/jira-cli/internal/cmd/init" "github.com/ankitpokhrel/jira-cli/internal/cmd/issue" "github.com/ankitpokhrel/jira-cli/internal/cmd/man" + "github.com/ankitpokhrel/jira-cli/internal/cmd/mcp" "github.com/ankitpokhrel/jira-cli/internal/cmd/me" "github.com/ankitpokhrel/jira-cli/internal/cmd/open" "github.com/ankitpokhrel/jira-cli/internal/cmd/project" @@ -64,7 +65,7 @@ func init() { viper.SetEnvPrefix("jira") if err := viper.ReadInConfig(); err == nil && debug { - fmt.Printf("Using config file: %s\n", viper.ConfigFileUsed()) + fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed()) } }) } @@ -140,6 +141,7 @@ func addChildCommands(cmd *cobra.Command) { version.NewCmdVersion(), release.NewCmdRelease(), man.NewCmdMan(), + mcp.NewCmdMCP(), ) } diff --git a/internal/mcp/doc.go b/internal/mcp/doc.go new file mode 100644 index 00000000..9e6940af --- /dev/null +++ b/internal/mcp/doc.go @@ -0,0 +1,4 @@ +// Package mcp implements a Model Context Protocol server that exposes +// a subset of jira-cli's capabilities to MCP-aware hosts (e.g. Cursor, +// Claude Desktop). Wiring lives in internal/cmd/mcp. +package mcp diff --git a/internal/mcp/server.go b/internal/mcp/server.go new file mode 100644 index 00000000..e75bbe05 --- /dev/null +++ b/internal/mcp/server.go @@ -0,0 +1,94 @@ +package mcp + +import ( + "context" + "fmt" + "os" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/ankitpokhrel/jira-cli/internal/mcp/tools" +) + +const ( + // ServerName is the implementation name advertised over MCP. + ServerName = "jira-cli" + // ServerVersion is the MCP server version advertised to clients. + // Bumped independently of the jira-cli release version when the MCP + // surface changes in a backward-incompatible way. + ServerVersion = "0.1.0" +) + +// NewServer constructs a configured *mcp.Server with all jira-cli tools +// registered. The caller is responsible for invoking server.Run with a +// transport. +func NewServer(d *tools.Deps) *mcpsdk.Server { + srv := mcpsdk.NewServer(&mcpsdk.Implementation{ + Name: ServerName, + Version: ServerVersion, + }, nil) + + registerTool(srv, "search_issues", + "Search Jira issues by JQL or simple filters. Defaults to the configured project.", + d, tools.SearchIssues) + + registerTool(srv, "get_issue", + "Get full details of a Jira issue including description and recent comments.", + d, tools.GetIssue) + + registerTool(srv, "create_issue", + "Create a new Jira issue in the given project.", + d, tools.CreateIssue) + + registerTool(srv, "add_comment", + "Add a comment to a Jira issue.", + d, tools.AddComment) + + registerTool(srv, "transition_issue", + "Transition a Jira issue to a new status by name (e.g. \"In Progress\", \"Done\").", + d, tools.TransitionIssue) + + return srv +} + +// registerTool adapts a tools.* handler (which takes Deps + Input and returns +// Output + error) onto the SDK's expected handler signature. It also recovers +// from panics in the handler body so a single bad call cannot kill the server +// mid-session, and converts both errors and panics into MCP tool errors that +// the LLM can read. +func registerTool[In, Out any]( + srv *mcpsdk.Server, + name, description string, + d *tools.Deps, + fn func(context.Context, *tools.Deps, In) (Out, error), +) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: name, Description: description}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in In) (result *mcpsdk.CallToolResult, out Out, err error) { + defer func() { + if r := recover(); r != nil { + var zero Out + fmt.Fprintf(os.Stderr, "mcp: panic in tool %q: %v\n", name, r) + result = &mcpsdk.CallToolResult{ + IsError: true, + Content: []mcpsdk.Content{&mcpsdk.TextContent{ + Text: fmt.Sprintf("internal error in tool %q: %v", name, r), + }}, + } + out = zero + err = nil + } + }() + + out, callErr := fn(ctx, d, in) + if callErr != nil { + var zero Out + return &mcpsdk.CallToolResult{ + IsError: true, + Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: callErr.Error()}}, + }, zero, nil + } + return nil, out, nil + }, + ) +} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go new file mode 100644 index 00000000..87497c68 --- /dev/null +++ b/internal/mcp/server_test.go @@ -0,0 +1,117 @@ +package mcp + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ankitpokhrel/jira-cli/internal/mcp/tools" + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +func TestServer_ListsAllTools(t *testing.T) { + prevInstall := viper.GetString("installation") + viper.Set("installation", jira.InstallationTypeCloud) + defer viper.Set("installation", prevInstall) + + jiraServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(200) + _, _ = w.Write([]byte(`{"issues": []}`)) + })) + defer jiraServer.Close() + + deps := &tools.Deps{ + Client: jira.NewClient(jira.Config{Server: jiraServer.URL}, jira.WithTimeout(3*time.Second)), + Server: jiraServer.URL, + DefaultProject: "TEST", + Installation: jira.InstallationTypeCloud, + } + + srv := NewServer(deps) + require.NotNil(t, srv) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + serverT, clientT := mcp.NewInMemoryTransports() + + serverDone := make(chan error, 1) + go func() { serverDone <- srv.Run(ctx, serverT) }() + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0"}, nil) + session, err := client.Connect(ctx, clientT, nil) + require.NoError(t, err) + defer func() { _ = session.Close() }() + + listed, err := session.ListTools(ctx, &mcp.ListToolsParams{}) + require.NoError(t, err) + + names := make(map[string]bool) + for _, tool := range listed.Tools { + names[tool.Name] = true + } + for _, expected := range []string{ + "search_issues", "get_issue", "create_issue", "add_comment", "transition_issue", + } { + assert.True(t, names[expected], "expected tool %q to be registered", expected) + } + + res, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "search_issues", + Arguments: map[string]any{"jql": "project = TEST"}, + }) + require.NoError(t, err) + assert.False(t, res.IsError, "search_issues should succeed against the fake server") + + // Validation errors must come back as IsError tool results, not transport errors. + res, err = session.CallTool(ctx, &mcp.CallToolParams{ + Name: "get_issue", + Arguments: map[string]any{}, // missing required "key" + }) + require.NoError(t, err) + assert.True(t, res.IsError, "missing required key should produce a tool error result") +} + +func TestRegisterTool_RecoversFromPanic(t *testing.T) { + srv := mcp.NewServer(&mcp.Implementation{Name: "panic-test", Version: "v0"}, nil) + + type panicIn struct{} + type panicOut struct{} + + registerTool(srv, "panic_tool", "always panics", + &tools.Deps{}, + func(context.Context, *tools.Deps, panicIn) (panicOut, error) { + panic("boom") + }, + ) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + serverT, clientT := mcp.NewInMemoryTransports() + go func() { _ = srv.Run(ctx, serverT) }() + + client := mcp.NewClient(&mcp.Implementation{Name: "panic-client", Version: "v0"}, nil) + session, err := client.Connect(ctx, clientT, nil) + require.NoError(t, err) + defer func() { _ = session.Close() }() + + res, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "panic_tool", + Arguments: map[string]any{}, + }) + require.NoError(t, err, "transport should survive a panicking handler") + assert.True(t, res.IsError, "panic should surface as a tool error, not a transport error") + require.NotEmpty(t, res.Content) + if tc, ok := res.Content[0].(*mcp.TextContent); ok { + assert.Contains(t, tc.Text, "panic_tool") + assert.Contains(t, tc.Text, "boom") + } +} diff --git a/internal/mcp/tools/add_comment.go b/internal/mcp/tools/add_comment.go new file mode 100644 index 00000000..cbb76a93 --- /dev/null +++ b/internal/mcp/tools/add_comment.go @@ -0,0 +1,33 @@ +package tools + +import ( + "context" + "errors" +) + +// AddCommentInput is the input schema for the add_comment tool. +type AddCommentInput struct { + Key string `json:"key" jsonschema:"issue key, e.g. \"PROJ-123\" (required)"` + Body string `json:"body" jsonschema:"comment body in markdown (required)"` + Internal bool `json:"internal,omitempty" jsonschema:"mark as an internal (service-desk) comment"` +} + +// AddCommentOutput is the structured result of the add_comment tool. +type AddCommentOutput struct { + Key string `json:"key"` + URL string `json:"url"` +} + +// AddComment runs the add_comment tool. +func AddComment(_ context.Context, d *Deps, in AddCommentInput) (AddCommentOutput, error) { + if in.Key == "" { + return AddCommentOutput{}, errors.New("key is required") + } + if in.Body == "" { + return AddCommentOutput{}, errors.New("body is required") + } + if err := d.Client.AddIssueComment(in.Key, in.Body, in.Internal); err != nil { + return AddCommentOutput{}, err + } + return AddCommentOutput{Key: in.Key, URL: d.IssueURL(in.Key)}, nil +} diff --git a/internal/mcp/tools/add_comment_test.go b/internal/mcp/tools/add_comment_test.go new file mode 100644 index 00000000..bb424452 --- /dev/null +++ b/internal/mcp/tools/add_comment_test.go @@ -0,0 +1,77 @@ +package tools + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +func newCommentTestDeps(t *testing.T, handler http.HandlerFunc) (*Deps, func()) { + t.Helper() + server := httptest.NewServer(handler) + client := jira.NewClient(jira.Config{Server: server.URL}, jira.WithTimeout(3*time.Second)) + + prevInstall := viper.GetString("installation") + viper.Set("installation", jira.InstallationTypeCloud) + + deps := &Deps{ + Client: client, + Server: server.URL, + DefaultProject: "TEST", + Installation: jira.InstallationTypeCloud, + } + return deps, func() { + server.Close() + viper.Set("installation", prevInstall) + } +} + +func TestAddComment_Success(t *testing.T) { + deps, cleanup := newCommentTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/2/issue/TEST-1/comment", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id": "999"}`)) + }) + defer cleanup() + + out, err := AddComment(context.Background(), deps, AddCommentInput{ + Key: "TEST-1", + Body: "Hello world", + }) + require.NoError(t, err) + assert.Equal(t, "TEST-1", out.Key) + assert.Equal(t, deps.IssueURL("TEST-1"), out.URL) +} + +func TestAddComment_RequiresKey(t *testing.T) { + deps, cleanup := newCommentTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("server should not be called") + w.WriteHeader(500) + }) + defer cleanup() + + _, err := AddComment(context.Background(), deps, AddCommentInput{Body: "x"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "key is required") +} + +func TestAddComment_RequiresBody(t *testing.T) { + deps, cleanup := newCommentTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("server should not be called") + w.WriteHeader(500) + }) + defer cleanup() + + _, err := AddComment(context.Background(), deps, AddCommentInput{Key: "TEST-1"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "body is required") +} diff --git a/internal/mcp/tools/create_issue.go b/internal/mcp/tools/create_issue.go new file mode 100644 index 00000000..f813211c --- /dev/null +++ b/internal/mcp/tools/create_issue.go @@ -0,0 +1,65 @@ +package tools + +import ( + "context" + "errors" + + "github.com/ankitpokhrel/jira-cli/api" + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +// CreateIssueInput is the input schema for the create_issue tool. +// +// v1 intentionally omits parent/epic linking: the underlying pkg/jira.CreateRequest +// routes that through project-type-aware fields (EpicField, SubtaskField) that the +// MCP layer doesn't currently resolve, so exposing it here would silently drop the +// link for classic projects. Add back when pkg/jira grows a first-class linker. +type CreateIssueInput struct { + Summary string `json:"summary" jsonschema:"issue summary (required)"` + Type string `json:"type" jsonschema:"issue type, e.g. \"Task\", \"Bug\", \"Story\" (required)"` + Project string `json:"project,omitempty" jsonschema:"project key (defaults to the configured project)"` + Description string `json:"description,omitempty" jsonschema:"issue description in markdown"` + Priority string `json:"priority,omitempty" jsonschema:"priority name, e.g. \"High\""` + Labels []string `json:"labels,omitempty"` + Components []string `json:"components,omitempty"` + Assignee string `json:"assignee,omitempty" jsonschema:"assignee account id (Cloud) or username (Local)"` +} + +// CreateIssueOutput is the structured result of the create_issue tool. +type CreateIssueOutput struct { + Key string `json:"key"` + URL string `json:"url"` +} + +// CreateIssue runs the create_issue tool. +func CreateIssue(_ context.Context, d *Deps, in CreateIssueInput) (CreateIssueOutput, error) { + if in.Summary == "" { + return CreateIssueOutput{}, errors.New("summary is required") + } + if in.Type == "" { + return CreateIssueOutput{}, errors.New("type is required") + } + + project := d.ResolveProject(in.Project) + if project == "" { + return CreateIssueOutput{}, errors.New("project is required (no default project configured)") + } + + req := &jira.CreateRequest{ + Project: project, + IssueType: in.Type, + Summary: in.Summary, + Body: in.Description, + Priority: in.Priority, + Labels: in.Labels, + Components: in.Components, + Assignee: in.Assignee, + } + req.ForInstallationType(d.Installation) + + resp, err := api.ProxyCreate(d.Client, req) + if err != nil { + return CreateIssueOutput{}, err + } + return CreateIssueOutput{Key: resp.Key, URL: d.IssueURL(resp.Key)}, nil +} diff --git a/internal/mcp/tools/create_issue_test.go b/internal/mcp/tools/create_issue_test.go new file mode 100644 index 00000000..1185892e --- /dev/null +++ b/internal/mcp/tools/create_issue_test.go @@ -0,0 +1,150 @@ +package tools + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +func newCreateTestDeps(t *testing.T, handler http.HandlerFunc) (*Deps, func()) { + t.Helper() + server := httptest.NewServer(handler) + client := jira.NewClient(jira.Config{Server: server.URL}, jira.WithTimeout(3*time.Second)) + + prevInstall := viper.GetString("installation") + viper.Set("installation", jira.InstallationTypeCloud) + + deps := &Deps{ + Client: client, + Server: server.URL, + DefaultProject: "TEST", + Installation: jira.InstallationTypeCloud, + } + return deps, func() { + server.Close() + viper.Set("installation", prevInstall) + } +} + +func TestCreateIssue_Success(t *testing.T) { + var capturedBody map[string]any + deps, cleanup := newCreateTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/3/issue", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &capturedBody) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id": "10001", "key": "TEST-42"}`)) + }) + defer cleanup() + + out, err := CreateIssue(context.Background(), deps, CreateIssueInput{ + Summary: "New thing", + Type: "Task", + }) + require.NoError(t, err) + assert.Equal(t, "TEST-42", out.Key) + assert.Equal(t, deps.IssueURL("TEST-42"), out.URL) + + fields, _ := capturedBody["fields"].(map[string]any) + require.NotNil(t, fields) + project, _ := fields["project"].(map[string]any) + assert.Equal(t, "TEST", project["key"]) + assert.Equal(t, "New thing", fields["summary"]) +} + +func TestCreateIssue_RequiresSummary(t *testing.T) { + deps, cleanup := newCreateTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("server should not be called") + w.WriteHeader(500) + }) + defer cleanup() + + _, err := CreateIssue(context.Background(), deps, CreateIssueInput{Type: "Task"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "summary is required") +} + +func TestCreateIssue_RequiresType(t *testing.T) { + deps, cleanup := newCreateTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("server should not be called") + w.WriteHeader(500) + }) + defer cleanup() + + _, err := CreateIssue(context.Background(), deps, CreateIssueInput{Summary: "x"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "type is required") +} + +func TestCreateIssue_OverridesProject(t *testing.T) { + var capturedProject string + deps, cleanup := newCreateTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + var body map[string]any + _ = json.Unmarshal(raw, &body) + fields, _ := body["fields"].(map[string]any) + project, _ := fields["project"].(map[string]any) + capturedProject, _ = project["key"].(string) + + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id": "10001", "key": "OTHER-1"}`)) + }) + defer cleanup() + + _, err := CreateIssue(context.Background(), deps, CreateIssueInput{ + Summary: "x", Type: "Task", Project: "OTHER", + }) + require.NoError(t, err) + assert.Equal(t, "OTHER", capturedProject) +} + +func TestCreateIssue_Local(t *testing.T) { + var ( + capturedPath string + capturedBody map[string]any + ) + deps, cleanup := newCreateTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &capturedBody) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id": "10001", "key": "TEST-42"}`)) + }) + defer cleanup() + + viper.Set("installation", jira.InstallationTypeLocal) + deps.Installation = jira.InstallationTypeLocal + + out, err := CreateIssue(context.Background(), deps, CreateIssueInput{ + Summary: "Local bug", + Type: "Bug", + Assignee: "alice", + }) + require.NoError(t, err) + + assert.Equal(t, "/rest/api/2/issue", capturedPath) + assert.Equal(t, "TEST-42", out.Key) + + fields, _ := capturedBody["fields"].(map[string]any) + require.NotNil(t, fields) + assignee, _ := fields["assignee"].(map[string]any) + require.NotNil(t, assignee) + assert.Equal(t, "alice", assignee["name"]) + _, hasAccountID := assignee["accountId"] + assert.False(t, hasAccountID, "v2 assignee should not use accountId") +} diff --git a/internal/mcp/tools/deps.go b/internal/mcp/tools/deps.go new file mode 100644 index 00000000..528a362c --- /dev/null +++ b/internal/mcp/tools/deps.go @@ -0,0 +1,36 @@ +package tools + +import ( + "strings" + + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +// Deps bundles the runtime dependencies every MCP tool handler needs. +// It is constructed once in internal/cmd/mcp/serve and shared (read-only) +// across all tool invocations. +type Deps struct { + Client *jira.Client + Server string + DefaultProject string + // Installation is the configured Jira installation type ("Cloud" or + // "Local"). Currently consumed only by CreateIssue (for + // CreateRequest.ForInstallationType). The v2/v3 dispatch of api.Proxy* + // helpers still reads viper directly, so tests must also + // viper.Set("installation", ...) when exercising non-Cloud paths. + Installation string +} + +// IssueURL returns the browser URL for a given issue key. +func (d *Deps) IssueURL(key string) string { + return strings.TrimRight(d.Server, "/") + "/browse/" + key +} + +// ResolveProject returns explicit if non-empty, otherwise the configured +// default project key. +func (d *Deps) ResolveProject(explicit string) string { + if explicit != "" { + return explicit + } + return d.DefaultProject +} diff --git a/internal/mcp/tools/deps_test.go b/internal/mcp/tools/deps_test.go new file mode 100644 index 00000000..df47e3bc --- /dev/null +++ b/internal/mcp/tools/deps_test.go @@ -0,0 +1,27 @@ +package tools + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDeps_IssueURL(t *testing.T) { + d := &Deps{Server: "https://example.atlassian.net"} + assert.Equal(t, "https://example.atlassian.net/browse/TEST-1", d.IssueURL("TEST-1")) +} + +func TestDeps_IssueURL_TrimsTrailingSlash(t *testing.T) { + d := &Deps{Server: "https://example.atlassian.net/"} + assert.Equal(t, "https://example.atlassian.net/browse/TEST-1", d.IssueURL("TEST-1")) +} + +func TestDeps_ResolveProject_UsesDefaultWhenEmpty(t *testing.T) { + d := &Deps{DefaultProject: "ABC"} + assert.Equal(t, "ABC", d.ResolveProject("")) +} + +func TestDeps_ResolveProject_PrefersExplicit(t *testing.T) { + d := &Deps{DefaultProject: "ABC"} + assert.Equal(t, "XYZ", d.ResolveProject("XYZ")) +} diff --git a/internal/mcp/tools/doc.go b/internal/mcp/tools/doc.go new file mode 100644 index 00000000..8bc4a8b3 --- /dev/null +++ b/internal/mcp/tools/doc.go @@ -0,0 +1,5 @@ +// Package tools holds the individual MCP tool handlers. Each handler is +// a small adapter from a typed input/output struct onto the existing +// pkg/jira client. Handlers must not depend on cobra, viper, survey, or +// tui; their dependencies are injected via the Deps struct. +package tools diff --git a/internal/mcp/tools/get_issue.go b/internal/mcp/tools/get_issue.go new file mode 100644 index 00000000..a65859da --- /dev/null +++ b/internal/mcp/tools/get_issue.go @@ -0,0 +1,109 @@ +package tools + +import ( + "context" + "errors" + + "github.com/ankitpokhrel/jira-cli/api" + issuefilter "github.com/ankitpokhrel/jira-cli/pkg/jira/filter/issue" +) + +// GetIssueInput is the input schema for the get_issue tool. +type GetIssueInput struct { + Key string `json:"key" jsonschema:"issue key, e.g. \"PROJ-123\" (required)"` + IncludeComments *bool `json:"include_comments,omitempty" jsonschema:"include recent comments in the response (default true)"` + CommentLimit int `json:"comment_limit,omitempty" jsonschema:"maximum number of recent comments to include (default 10)"` +} + +// GetIssueOutput is the structured result of the get_issue tool. +type GetIssueOutput struct { + Key string `json:"key"` + Summary string `json:"summary"` + Status string `json:"status"` + Type string `json:"type"` + Priority string `json:"priority"` + Assignee string `json:"assignee"` + Reporter string `json:"reporter"` + Labels []string `json:"labels"` + Components []string `json:"components"` + FixVersions []string `json:"fix_versions"` + Parent string `json:"parent,omitempty"` + Created string `json:"created"` + Updated string `json:"updated"` + Description string `json:"description"` + Comments []CommentBrief `json:"comments,omitempty"` + URL string `json:"url"` +} + +// CommentBrief is a lean projection of an issue comment. +type CommentBrief struct { + ID string `json:"id"` + Author string `json:"author"` + Body string `json:"body"` + Created string `json:"created"` +} + +// GetIssue runs the get_issue tool. +func GetIssue(_ context.Context, d *Deps, in GetIssueInput) (GetIssueOutput, error) { + if in.Key == "" { + return GetIssueOutput{}, errors.New("key is required") + } + + includeComments := true + if in.IncludeComments != nil { + includeComments = *in.IncludeComments + } + commentLimit := in.CommentLimit + if commentLimit <= 0 { + commentLimit = 10 + } + + iss, err := api.ProxyGetIssue(d.Client, in.Key, issuefilter.NewNumCommentsFilter(uint(commentLimit))) + if err != nil { + return GetIssueOutput{}, err + } + + out := GetIssueOutput{ + Key: iss.Key, + Summary: iss.Fields.Summary, + Status: iss.Fields.Status.Name, + Type: iss.Fields.IssueType.Name, + Priority: iss.Fields.Priority.Name, + Assignee: iss.Fields.Assignee.Name, + Reporter: iss.Fields.Reporter.Name, + Labels: iss.Fields.Labels, + Created: iss.Fields.Created, + Updated: iss.Fields.Updated, + Description: bodyToMarkdown(iss.Fields.Description), + URL: d.IssueURL(iss.Key), + } + if iss.Fields.Parent != nil { + out.Parent = iss.Fields.Parent.Key + } + for _, c := range iss.Fields.Components { + out.Components = append(out.Components, c.Name) + } + for _, v := range iss.Fields.FixVersions { + out.FixVersions = append(out.FixVersions, v.Name) + } + + if includeComments && iss.Fields.Comment.Total > 0 { + comments := iss.Fields.Comment.Comments + // Take the last commentLimit comments (newest), preserving chronological order. + start := 0 + if len(comments) > commentLimit { + start = len(comments) - commentLimit + } + for i := start; i < len(comments); i++ { + c := comments[i] + out.Comments = append(out.Comments, CommentBrief{ + ID: c.ID, + Author: c.Author.DisplayName, + Body: bodyToMarkdown(c.Body), + Created: c.Created, + }) + } + } + + return out, nil +} diff --git a/internal/mcp/tools/get_issue_test.go b/internal/mcp/tools/get_issue_test.go new file mode 100644 index 00000000..3badb2a5 --- /dev/null +++ b/internal/mcp/tools/get_issue_test.go @@ -0,0 +1,173 @@ +package tools + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +const getIssueResponseV3 = `{ + "key": "TEST-1", + "fields": { + "summary": "Sample bug", + "status": {"name": "In Progress"}, + "issueType": {"name": "Bug"}, + "priority": {"name": "High"}, + "assignee": {"displayName": "Alice"}, + "reporter": {"displayName": "Bob"}, + "labels": ["backend", "urgent"], + "components": [{"name": "API"}], + "fixVersions": [{"name": "v2.0"}], + "created": "2026-01-01T10:00:00.000+0000", + "updated": "2026-01-02T10:00:00.000+0000", + "description": { + "version": 1, + "type": "doc", + "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "Repro steps"}]} + ] + }, + "comment": { + "total": 1, + "comments": [ + { + "id": "100", + "author": {"displayName": "Carol", "emailAddress": "carol@example.com", "active": true}, + "body": { + "version": 1, + "type": "doc", + "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "Looking into it"}]} + ] + }, + "created": "2026-01-03T10:00:00.000+0000" + } + ] + } + } +}` + +func newIssueTestDeps(t *testing.T, handler http.HandlerFunc) (*Deps, func()) { + t.Helper() + server := httptest.NewServer(handler) + client := jira.NewClient(jira.Config{Server: server.URL}, jira.WithTimeout(3*time.Second)) + + prevInstall := viper.GetString("installation") + viper.Set("installation", jira.InstallationTypeCloud) + + deps := &Deps{ + Client: client, + Server: server.URL, + DefaultProject: "TEST", + Installation: jira.InstallationTypeCloud, + } + return deps, func() { + server.Close() + viper.Set("installation", prevInstall) + } +} + +func TestGetIssue_Cloud(t *testing.T) { + deps, cleanup := newIssueTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/3/issue/TEST-1", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(getIssueResponseV3)) + }) + defer cleanup() + + out, err := GetIssue(context.Background(), deps, GetIssueInput{Key: "TEST-1"}) + require.NoError(t, err) + + assert.Equal(t, "TEST-1", out.Key) + assert.Equal(t, "Sample bug", out.Summary) + assert.Equal(t, "In Progress", out.Status) + assert.Equal(t, "Bug", out.Type) + assert.Equal(t, "High", out.Priority) + assert.Equal(t, "Alice", out.Assignee) + assert.Equal(t, "Bob", out.Reporter) + assert.Equal(t, []string{"backend", "urgent"}, out.Labels) + assert.Equal(t, []string{"API"}, out.Components) + assert.Equal(t, []string{"v2.0"}, out.FixVersions) + assert.Contains(t, out.Description, "Repro steps") + require.Len(t, out.Comments, 1) + assert.Equal(t, "Carol", out.Comments[0].Author) + assert.Contains(t, out.Comments[0].Body, "Looking into it") + assert.Equal(t, deps.IssueURL("TEST-1"), out.URL) +} + +func TestGetIssue_RequiresKey(t *testing.T) { + deps, cleanup := newIssueTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("server should not be called when key is missing") + w.WriteHeader(500) + }) + defer cleanup() + + _, err := GetIssue(context.Background(), deps, GetIssueInput{Key: ""}) + require.Error(t, err) + assert.Contains(t, err.Error(), "key is required") +} + +func TestGetIssue_RespectsCommentLimit(t *testing.T) { + deps, cleanup := newIssueTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(getIssueResponseV3)) + }) + defer cleanup() + + out, err := GetIssue(context.Background(), deps, GetIssueInput{ + Key: "TEST-1", + IncludeComments: boolPtr(false), + }) + require.NoError(t, err) + assert.Empty(t, out.Comments) +} + +func boolPtr(b bool) *bool { return &b } + +const getIssueResponseV2 = `{ + "key": "TEST-1", + "fields": { + "summary": "Sample bug", + "status": {"name": "In Progress"}, + "issueType": {"name": "Bug"}, + "priority": {"name": "High"}, + "assignee": {"displayName": "Alice"}, + "reporter": {"displayName": "Bob"}, + "labels": [], + "components": [], + "fixVersions": [], + "created": "2026-01-01T10:00:00.000+0000", + "updated": "2026-01-02T10:00:00.000+0000", + "description": "Repro steps from wiki markup", + "comment": {"total": 0, "comments": []} + } +}` + +func TestGetIssue_Local(t *testing.T) { + var capturedPath string + deps, cleanup := newIssueTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(getIssueResponseV2)) + }) + defer cleanup() + + viper.Set("installation", jira.InstallationTypeLocal) + deps.Installation = jira.InstallationTypeLocal + + out, err := GetIssue(context.Background(), deps, GetIssueInput{Key: "TEST-1"}) + require.NoError(t, err) + + assert.Equal(t, "/rest/api/2/issue/TEST-1", capturedPath) + assert.Equal(t, "TEST-1", out.Key) + assert.Equal(t, "In Progress", out.Status) + assert.Equal(t, "Repro steps from wiki markup", out.Description) +} diff --git a/internal/mcp/tools/markdown.go b/internal/mcp/tools/markdown.go new file mode 100644 index 00000000..cc3a7c63 --- /dev/null +++ b/internal/mcp/tools/markdown.go @@ -0,0 +1,23 @@ +package tools + +import ( + "github.com/ankitpokhrel/jira-cli/pkg/adf" +) + +// bodyToMarkdown renders a Jira description-or-comment body field to markdown. +// The body is interface{} because v3 returns *adf.ADF and v2 returns string. +func bodyToMarkdown(body any) string { + switch v := body.(type) { + case nil: + return "" + case string: + return v + case *adf.ADF: + if v == nil { + return "" + } + return adf.NewTranslator(v, adf.NewMarkdownTranslator()).Translate() + default: + return "" + } +} diff --git a/internal/mcp/tools/markdown_test.go b/internal/mcp/tools/markdown_test.go new file mode 100644 index 00000000..b0adccd2 --- /dev/null +++ b/internal/mcp/tools/markdown_test.go @@ -0,0 +1,38 @@ +package tools + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/ankitpokhrel/jira-cli/pkg/adf" +) + +func TestBodyToMarkdown_String(t *testing.T) { + assert.Equal(t, "hello", bodyToMarkdown("hello")) +} + +func TestBodyToMarkdown_Nil(t *testing.T) { + assert.Equal(t, "", bodyToMarkdown(nil)) +} + +func TestBodyToMarkdown_ADF(t *testing.T) { + doc := &adf.ADF{ + Version: 1, + DocType: "doc", + Content: []*adf.Node{ + { + NodeType: "paragraph", + Content: []*adf.Node{ + {NodeType: "text", NodeValue: adf.NodeValue{Text: "Hello world"}}, + }, + }, + }, + } + got := bodyToMarkdown(doc) + assert.Contains(t, got, "Hello world") +} + +func TestBodyToMarkdown_UnknownType(t *testing.T) { + assert.Equal(t, "", bodyToMarkdown(123)) +} diff --git a/internal/mcp/tools/search_issues.go b/internal/mcp/tools/search_issues.go new file mode 100644 index 00000000..424c5a8b --- /dev/null +++ b/internal/mcp/tools/search_issues.go @@ -0,0 +1,115 @@ +package tools + +import ( + "context" + "fmt" + "strings" + + "github.com/ankitpokhrel/jira-cli/api" +) + +const ( + // defaultSearchLimit is used when the caller omits Limit (or passes <= 0). + defaultSearchLimit = 50 + // maxSearchLimit is the hard cap we enforce on Limit. The Jira v3 + // /search/jql endpoint accepts up to 100; we mirror that here. + maxSearchLimit = 100 +) + +// SearchIssuesInput is the input schema for the search_issues tool. +type SearchIssuesInput struct { + JQL string `json:"jql,omitempty" jsonschema:"raw JQL to execute. Passed through verbatim unless project is also set, in which case the JQL is wrapped as 'project = X AND (your JQL)'. If you set project alongside JQL, your JQL must not contain its own ORDER BY clause."` + Project string `json:"project,omitempty" jsonschema:"project key (defaults to the configured project when JQL is omitted; when JQL is provided, only set this if you want the JQL scoped to a specific project)"` + Status string `json:"status,omitempty" jsonschema:"filter by status name, e.g. \"To Do\""` + Assignee string `json:"assignee,omitempty" jsonschema:"filter by assignee. Use \"me\" for the configured user, \"none\" for unassigned, or a username/account id."` + Limit int `json:"limit,omitempty" jsonschema:"maximum number of issues to return (default 50, clamped to 100)"` +} + +// SearchIssuesOutput is the structured result of the search_issues tool. +type SearchIssuesOutput struct { + // Returned is the number of issues in this response page. The Jira v3 + // /search/jql endpoint does not return a total match count; callers that + // need to know whether more results exist should rerun with a larger Limit. + Returned int `json:"returned"` + Issues []IssueBrief `json:"issues"` +} + +// IssueBrief is a lean projection of jira.Issue used for list-style outputs. +type IssueBrief struct { + Key string `json:"key"` + Summary string `json:"summary"` + Status string `json:"status"` + Type string `json:"type"` + Priority string `json:"priority"` + Assignee string `json:"assignee"` + Reporter string `json:"reporter"` + Created string `json:"created"` + Updated string `json:"updated"` + URL string `json:"url"` +} + +// SearchIssues runs the search_issues tool. +func SearchIssues(_ context.Context, d *Deps, in SearchIssuesInput) (SearchIssuesOutput, error) { + limit := in.Limit + if limit <= 0 { + limit = defaultSearchLimit + } + if limit > maxSearchLimit { + limit = maxSearchLimit + } + + jql := strings.TrimSpace(in.JQL) + project := d.ResolveProject(in.Project) + + if jql == "" { + jql = composeJQL(project, in.Status, in.Assignee) + } else if in.Project != "" { + jql = fmt.Sprintf(`project = %q AND (%s)`, in.Project, jql) + } + + res, err := api.ProxySearch(d.Client, jql, 0, uint(limit)) + if err != nil { + return SearchIssuesOutput{}, err + } + + out := SearchIssuesOutput{Issues: make([]IssueBrief, 0, len(res.Issues))} + for _, iss := range res.Issues { + out.Issues = append(out.Issues, IssueBrief{ + Key: iss.Key, + Summary: iss.Fields.Summary, + Status: iss.Fields.Status.Name, + Type: iss.Fields.IssueType.Name, + Priority: iss.Fields.Priority.Name, + Assignee: iss.Fields.Assignee.Name, + Reporter: iss.Fields.Reporter.Name, + Created: iss.Fields.Created, + Updated: iss.Fields.Updated, + URL: d.IssueURL(iss.Key), + }) + } + out.Returned = len(out.Issues) + return out, nil +} + +func composeJQL(project, status, assignee string) string { + var clauses []string + if project != "" { + clauses = append(clauses, fmt.Sprintf(`project = %q`, project)) + } + if status != "" { + clauses = append(clauses, fmt.Sprintf(`status = %q`, status)) + } + switch strings.ToLower(assignee) { + case "": + case "me": + clauses = append(clauses, "assignee = currentUser()") + case "none", "x": + clauses = append(clauses, "assignee is EMPTY") + default: + clauses = append(clauses, fmt.Sprintf(`assignee = %q`, assignee)) + } + if len(clauses) == 0 { + return "" + } + return strings.Join(clauses, " AND ") + " ORDER BY created DESC" +} diff --git a/internal/mcp/tools/search_issues_test.go b/internal/mcp/tools/search_issues_test.go new file mode 100644 index 00000000..ae74a971 --- /dev/null +++ b/internal/mcp/tools/search_issues_test.go @@ -0,0 +1,172 @@ +package tools + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +const searchResponseBody = `{ + "issues": [ + { + "key": "TEST-1", + "fields": { + "summary": "First issue", + "status": {"name": "To Do"}, + "issueType": {"name": "Task"}, + "priority": {"name": "Medium"}, + "assignee": {"displayName": "Alice"}, + "reporter": {"displayName": "Bob"}, + "created": "2026-01-01T10:00:00.000+0000", + "updated": "2026-01-02T10:00:00.000+0000", + "labels": [] + } + } + ] +}` + +func newSearchTestDeps(t *testing.T, handler http.HandlerFunc) (*Deps, func()) { + t.Helper() + + server := httptest.NewServer(handler) + client := jira.NewClient(jira.Config{Server: server.URL}, jira.WithTimeout(3*time.Second)) + + prevInstall := viper.GetString("installation") + viper.Set("installation", jira.InstallationTypeCloud) + + deps := &Deps{ + Client: client, + Server: server.URL, + DefaultProject: "TEST", + Installation: jira.InstallationTypeCloud, + } + + cleanup := func() { + server.Close() + viper.Set("installation", prevInstall) + } + return deps, cleanup +} + +func TestSearchIssues_UsesProvidedJQL(t *testing.T) { + var capturedJQL string + deps, cleanup := newSearchTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/3/search/jql", r.URL.Path) + capturedJQL = r.URL.Query().Get("jql") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(searchResponseBody)) + }) + defer cleanup() + + out, err := SearchIssues(context.Background(), deps, SearchIssuesInput{ + JQL: "summary ~ first", + }) + require.NoError(t, err) + + assert.Equal(t, "summary ~ first", capturedJQL) + require.Len(t, out.Issues, 1) + assert.Equal(t, "TEST-1", out.Issues[0].Key) + assert.Equal(t, "First issue", out.Issues[0].Summary) + assert.Equal(t, "To Do", out.Issues[0].Status) + assert.Equal(t, "Alice", out.Issues[0].Assignee) + assert.True(t, strings.HasSuffix(out.Issues[0].URL, "/browse/TEST-1")) +} + +func TestSearchIssues_ComposesJQLFromFilters(t *testing.T) { + var capturedJQL string + deps, cleanup := newSearchTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + capturedJQL = r.URL.Query().Get("jql") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"issues": []}`)) + }) + defer cleanup() + + _, err := SearchIssues(context.Background(), deps, SearchIssuesInput{ + Status: "In Progress", + Assignee: "alice", + }) + require.NoError(t, err) + + assert.Contains(t, capturedJQL, `project = "TEST"`) + assert.Contains(t, capturedJQL, `status = "In Progress"`) + assert.Contains(t, capturedJQL, `assignee = "alice"`) +} + +func TestSearchIssues_AssigneeMe(t *testing.T) { + var capturedJQL string + deps, cleanup := newSearchTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + capturedJQL = r.URL.Query().Get("jql") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"issues": []}`)) + }) + defer cleanup() + + _, err := SearchIssues(context.Background(), deps, SearchIssuesInput{Assignee: "me"}) + require.NoError(t, err) + assert.Contains(t, capturedJQL, `assignee = currentUser()`) +} + +func TestSearchIssues_LimitClampedTo100(t *testing.T) { + var capturedLimit string + deps, cleanup := newSearchTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + capturedLimit = r.URL.Query().Get("maxResults") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"issues": []}`)) + }) + defer cleanup() + + _, err := SearchIssues(context.Background(), deps, SearchIssuesInput{Limit: 500}) + require.NoError(t, err) + assert.Equal(t, "100", capturedLimit) +} + +func TestSearchIssues_DefaultLimit(t *testing.T) { + var capturedLimit string + deps, cleanup := newSearchTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + capturedLimit = r.URL.Query().Get("maxResults") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"issues": []}`)) + }) + defer cleanup() + + _, err := SearchIssues(context.Background(), deps, SearchIssuesInput{}) + require.NoError(t, err) + assert.Equal(t, "50", capturedLimit) +} + +func TestSearchIssues_Local(t *testing.T) { + var ( + capturedPath string + capturedQuery url.Values + ) + deps, cleanup := newSearchTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + capturedQuery = r.URL.Query() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(searchResponseBody)) + }) + defer cleanup() + + viper.Set("installation", jira.InstallationTypeLocal) + deps.Installation = jira.InstallationTypeLocal + + out, err := SearchIssues(context.Background(), deps, SearchIssuesInput{JQL: "summary ~ first"}) + require.NoError(t, err) + + assert.Equal(t, "/rest/api/2/search", capturedPath) + assert.Equal(t, "0", capturedQuery.Get("startAt")) + assert.Equal(t, "50", capturedQuery.Get("maxResults")) + assert.Equal(t, 1, out.Returned) + require.Len(t, out.Issues, 1) + assert.Equal(t, "TEST-1", out.Issues[0].Key) +} diff --git a/internal/mcp/tools/transition_issue.go b/internal/mcp/tools/transition_issue.go new file mode 100644 index 00000000..52fe021b --- /dev/null +++ b/internal/mcp/tools/transition_issue.go @@ -0,0 +1,97 @@ +package tools + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/ankitpokhrel/jira-cli/api" + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +// TransitionIssueInput is the input schema for the transition_issue tool. +// +// v1 intentionally omits assignee: pkg/jira.TransitionRequestFields.Assignee only +// supports the v2-style {"name": "..."} shape, which Jira Cloud ignores for +// account-id-style users. Reassignment should go through a dedicated tool (or wait +// until pkg/jira grows accountId-aware transition field support). +type TransitionIssueInput struct { + Key string `json:"key" jsonschema:"issue key (required)"` + Transition string `json:"transition" jsonschema:"target transition name, e.g. \"In Progress\" (required, case-insensitive)"` + Comment string `json:"comment,omitempty" jsonschema:"optional comment to add as part of the transition (workflow must allow it)"` + Resolution string `json:"resolution,omitempty" jsonschema:"optional resolution name to set, e.g. \"Fixed\""` +} + +// TransitionIssueOutput is the structured result of the transition_issue tool. +type TransitionIssueOutput struct { + Key string `json:"key"` + ToStatus string `json:"to_status"` + URL string `json:"url"` +} + +// TransitionIssue runs the transition_issue tool. +func TransitionIssue(_ context.Context, d *Deps, in TransitionIssueInput) (TransitionIssueOutput, error) { + if in.Key == "" { + return TransitionIssueOutput{}, errors.New("key is required") + } + if in.Transition == "" { + return TransitionIssueOutput{}, errors.New("transition is required") + } + + transitions, err := api.ProxyTransitions(d.Client, in.Key) + if err != nil { + return TransitionIssueOutput{}, err + } + + var match *jira.Transition + target := strings.ToLower(strings.TrimSpace(in.Transition)) + available := make([]string, 0, len(transitions)) + for _, t := range transitions { + available = append(available, t.Name) + if strings.ToLower(t.Name) == target { + match = t + } + } + if match == nil { + return TransitionIssueOutput{}, fmt.Errorf( + "unknown transition %q for %s. Valid transitions: %s", + in.Transition, in.Key, strings.Join(available, ", "), + ) + } + + req := &jira.TransitionRequest{ + Transition: &jira.TransitionRequestData{ + ID: match.ID.String(), + Name: match.Name, + }, + } + if in.Comment != "" { + req.Update = &jira.TransitionRequestUpdate{} + req.Update.Comment = append(req.Update.Comment, struct { + Add struct { + Body string `json:"body"` + } `json:"add"` + }{ + Add: struct { + Body string `json:"body"` + }{Body: in.Comment}, + }) + } + if in.Resolution != "" { + req.Fields = &jira.TransitionRequestFields{ + Resolution: &struct { + Name string `json:"name"` + }{Name: in.Resolution}, + } + } + + if _, err := d.Client.Transition(in.Key, req); err != nil { + return TransitionIssueOutput{}, err + } + return TransitionIssueOutput{ + Key: in.Key, + ToStatus: match.Name, + URL: d.IssueURL(in.Key), + }, nil +} diff --git a/internal/mcp/tools/transition_issue_test.go b/internal/mcp/tools/transition_issue_test.go new file mode 100644 index 00000000..cce00df2 --- /dev/null +++ b/internal/mcp/tools/transition_issue_test.go @@ -0,0 +1,138 @@ +package tools + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +const transitionsResponse = `{ + "transitions": [ + {"id": "11", "name": "To Do", "isAvailable": true}, + {"id": "21", "name": "In Progress", "isAvailable": true}, + {"id": "31", "name": "Done", "isAvailable": true} + ] +}` + +func newTransitionTestDeps(t *testing.T, handler http.HandlerFunc) (*Deps, func()) { + t.Helper() + server := httptest.NewServer(handler) + client := jira.NewClient(jira.Config{Server: server.URL}, jira.WithTimeout(3*time.Second)) + + prevInstall := viper.GetString("installation") + viper.Set("installation", jira.InstallationTypeCloud) + + deps := &Deps{ + Client: client, + Server: server.URL, + DefaultProject: "TEST", + Installation: jira.InstallationTypeCloud, + } + return deps, func() { + server.Close() + viper.Set("installation", prevInstall) + } +} + +func TestTransitionIssue_Success(t *testing.T) { + var postedID string + deps, cleanup := newTransitionTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + assert.Equal(t, "/rest/api/3/issue/TEST-1/transitions", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(transitionsResponse)) + case http.MethodPost: + assert.Equal(t, "/rest/api/2/issue/TEST-1/transitions", r.URL.Path) + raw, _ := io.ReadAll(r.Body) + var body map[string]any + _ = json.Unmarshal(raw, &body) + tr, _ := body["transition"].(map[string]any) + postedID, _ = tr["id"].(string) + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected method %s", r.Method) + } + }) + defer cleanup() + + out, err := TransitionIssue(context.Background(), deps, TransitionIssueInput{ + Key: "TEST-1", + Transition: "In Progress", + }) + require.NoError(t, err) + assert.Equal(t, "21", postedID) + assert.Equal(t, "TEST-1", out.Key) + assert.Equal(t, "In Progress", out.ToStatus) + assert.Equal(t, deps.IssueURL("TEST-1"), out.URL) +} + +func TestTransitionIssue_CaseInsensitiveMatch(t *testing.T) { + var postedID string + deps, cleanup := newTransitionTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(transitionsResponse)) + return + } + raw, _ := io.ReadAll(r.Body) + var body map[string]any + _ = json.Unmarshal(raw, &body) + tr, _ := body["transition"].(map[string]any) + postedID, _ = tr["id"].(string) + w.WriteHeader(http.StatusNoContent) + }) + defer cleanup() + + _, err := TransitionIssue(context.Background(), deps, TransitionIssueInput{ + Key: "TEST-1", + Transition: "in progress", + }) + require.NoError(t, err) + assert.Equal(t, "21", postedID) +} + +func TestTransitionIssue_UnknownTransition(t *testing.T) { + deps, cleanup := newTransitionTestDeps(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "POST should not happen for unknown transition") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(transitionsResponse)) + }) + defer cleanup() + + _, err := TransitionIssue(context.Background(), deps, TransitionIssueInput{ + Key: "TEST-1", + Transition: "Doing", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown transition") + assert.Contains(t, err.Error(), "To Do") + assert.Contains(t, err.Error(), "In Progress") + assert.Contains(t, err.Error(), "Done") +} + +func TestTransitionIssue_RequiresKeyAndTransition(t *testing.T) { + deps, cleanup := newTransitionTestDeps(t, func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("server should not be called") + w.WriteHeader(500) + }) + defer cleanup() + + _, err := TransitionIssue(context.Background(), deps, TransitionIssueInput{Transition: "Done"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "key is required") + + _, err = TransitionIssue(context.Background(), deps, TransitionIssueInput{Key: "TEST-1"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "transition is required") +}