diff --git a/README.md b/README.md index 95344fe..bda36dd 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ Prerequisites * Go 1.24 or later * Git -* Github Token PAT +* Github Token PAT (required for GitHub integration and MCP server) +* Anthropic API Key (optional, required for AI-powered analysis in MCP client) * Kubernetes cluster (kind) ### Prerequisites @@ -58,6 +59,64 @@ export SIGNALHOUND_GITHUB_TOKEN= export GITHUB_TOKEN= ``` +## Model Context Protocol (MCP) Integration + +Signalhound includes a Model Context Protocol (MCP) integration that enables AI-powered analysis of failing tests and GitHub issues. The MCP system consists of two components: + +### MCP Server + +The MCP server runs automatically when you start the `abstract` command. It provides tools for querying GitHub project issues and is accessible at `http://localhost:8080/mcp` by default. + +**Required Environment Variables:** +- `GITHUB_TOKEN` or `SIGNALHOUND_GITHUB_TOKEN` - GitHub Personal Access Token with `read:project` scope (required for the MCP server to query GitHub project issues) + +**Features:** +- Lists all issues from the SIG Signal project board +- Filters issues by latest Kubernetes release version and FAILING status +- Returns issue details including number, title, body, state, and URL + +### MCP Client + +The MCP client is used by the TUI to analyze failing tests and compare them with existing GitHub issues using Anthropic's Claude AI. + +**Required Environment Variables:** +- `ANTHROPIC_API_KEY` or `SIGNALHOUND_ANTHROPIC_API_KEY` - Anthropic API key for Claude AI analysis (required for AI-powered issue comparison) +- `MCP_SERVER_ENDPOINT` - MCP server endpoint URL (optional, defaults to `http://localhost:8080/mcp`) + +**Features:** +- Compares currently failing tests with existing GitHub issues +- Identifies tests that don't have corresponding GitHub issues +- Provides AI-generated summaries and recommendations + +### Complete Setup + +To enable all MCP features, set the following environment variables: + +```bash +# GitHub token for MCP server (to query project issues) +export GITHUB_TOKEN= +# or +export SIGNALHOUND_GITHUB_TOKEN= + +# Anthropic API key for MCP client (for AI analysis) +export ANTHROPIC_API_KEY= +# or +export SIGNALHOUND_ANTHROPIC_API_KEY= + +# Optional: Custom MCP server endpoint (defaults to http://localhost:8080/mcp) +export MCP_SERVER_ENDPOINT=http://localhost:8080/mcp +``` + +### Getting an Anthropic API Key + +1. Visit [Anthropic's Console](https://console.anthropic.com/) +2. Sign up or log in to your account +3. Navigate to API Keys section +4. Create a new API key +5. Copy the key and set it as `ANTHROPIC_API_KEY` or `SIGNALHOUND_ANTHROPIC_API_KEY` + +**Note:** The Anthropic API key is only required if you want to use the AI-powered analysis feature in the TUI. The MCP server will still work for listing issues without it, but the comparison and analysis features will not be available. + ### Running at runtime ```bash diff --git a/cmd/abstract.go b/cmd/abstract.go index b2ec493..55487b5 100644 --- a/cmd/abstract.go +++ b/cmd/abstract.go @@ -3,17 +3,21 @@ package cmd import ( + "context" "fmt" + "log" + "net/http" "os" - "time" "github.com/spf13/cobra" - "sigs.k8s.io/signalhound/api/v1alpha1" + "sigs.k8s.io/signalhound/internal/mcp" "sigs.k8s.io/signalhound/internal/testgrid" "sigs.k8s.io/signalhound/internal/tui" ) +const MCP_SERVER = "localhost:8080" + // abstractCmd represents the abstract command var abstractCmd = &cobra.Command{ Use: "abstract", @@ -22,9 +26,7 @@ var abstractCmd = &cobra.Command{ } var ( - tg = testgrid.NewTestGrid(testgrid.URL) minFailure, minFlake int - refreshInterval int token string ) @@ -35,8 +37,6 @@ func init() { "minimum threshold for test failures, to disable use 0. Defaults to 0.") abstractCmd.PersistentFlags().IntVarP(&minFlake, "min-flake", "m", 0, "minimum threshold for test flakeness, to disable use 0. Defaults to 0.") - abstractCmd.PersistentFlags().IntVarP(&refreshInterval, "refresh-interval", "r", 0, - "refresh interval in seconds (0 to disable auto-refresh)") token = os.Getenv("SIGNALHOUND_GITHUB_TOKEN") if token == "" { @@ -44,41 +44,40 @@ func init() { } } -// FetchTabSummary fetches all dashboard tabs from TestGrid. -func FetchTabSummary() ([]*v1alpha1.DashboardTab, error) { - var dashboardTabs []*v1alpha1.DashboardTab - for _, dashboard := range []string{"sig-release-master-blocking", "sig-release-master-informing"} { - dashSummaries, err := tg.FetchTabSummary(dashboard, v1alpha1.ERROR_STATUSES) - if err != nil { - return nil, err - } - for _, dashSummary := range dashSummaries { - dashTab, err := tg.FetchTabTests(&dashSummary, minFailure, minFlake) - if err != nil { - fmt.Println(fmt.Errorf("error fetching table : %s", err)) - continue - } - if len(dashTab.TestRuns) > 0 { - dashboardTabs = append(dashboardTabs, dashTab) - } - } - } - return dashboardTabs, nil -} - // RunAbstract starts the main command to scrape TestGrid. func RunAbstract(cmd *cobra.Command, args []string) error { - dashboardTabs, err := FetchTabSummary() - if err != nil { - return err - } + var ( + tg = testgrid.NewTestGrid(testgrid.URL) + dashboardTabs []*v1alpha1.DashboardTab + dashboards = []string{"sig-release-master-blocking", "sig-release-master-informing"} + ) - var refreshFunc func() ([]*v1alpha1.DashboardTab, error) - if refreshInterval > 0 { - refreshFunc = func() ([]*v1alpha1.DashboardTab, error) { - return FetchTabSummary() - } + // start the MCP server in the background + go startMCPServer() + + fmt.Println("Scraping the testgrid dashboard, wait...") + tuiInstance := tui.NewMultiWindowTUI(dashboardTabs, token) + tuiInstance.SetRefreshConfig(tg, dashboards, minFailure, minFlake) + return tuiInstance.Run() +} + +// startMCPServer starts the MCP server in the background +func startMCPServer() { + ctx := context.Background() + mcpToken := os.Getenv("ANTHROPIC_API_KEY") + if mcpToken == "" { + log.Println("Warning: ANTHROPIC_API_KEY not set, MCP server will not start") + return + } + githubToken := os.Getenv("GITHUB_TOKEN") + if githubToken == "" { + log.Println("Warning: GITHUB_TOKEN not set, MCP server will not start") + return } - return tui.RenderVisual(dashboardTabs, token, time.Duration(refreshInterval)*time.Second, refreshFunc) + server := mcp.NewMCPServer(ctx, githubToken) + log.Printf("MCP handler listening at %s", MCP_SERVER) + if err := http.ListenAndServe(MCP_SERVER, server.NewHTTPHandler()); err != nil { + log.Printf("MCP server error: %v", err) + } } diff --git a/go.mod b/go.mod index 634b00a..89d053f 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,12 @@ module sigs.k8s.io/signalhound go 1.25.1 require ( + github.com/anthropics/anthropic-sdk-go v1.19.0 github.com/gdamore/tcell/v2 v2.9.0 github.com/go-logr/logr v1.4.3 - github.com/google/go-github/v65 v65.0.0 + github.com/modelcontextprotocol/go-sdk v1.1.0 github.com/onsi/ginkgo/v2 v2.22.0 github.com/onsi/gomega v1.36.1 - github.com/prometheus/client_golang v1.23.0 - github.com/prometheus/common v0.65.0 github.com/rivo/tview v0.42.0 github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 github.com/spf13/cobra v1.8.1 @@ -18,9 +17,9 @@ require ( go.opentelemetry.io/otel/exporters/prometheus v0.60.0 go.opentelemetry.io/otel/metric v1.38.0 go.opentelemetry.io/otel/sdk/metric v1.38.0 - golang.org/x/net v0.42.0 + golang.org/x/net v0.46.0 golang.org/x/oauth2 v0.30.0 - golang.org/x/text v0.28.0 + golang.org/x/text v0.31.0 k8s.io/apimachinery v0.32.1 k8s.io/client-go v0.32.1 sigs.k8s.io/controller-runtime v0.20.2 @@ -53,8 +52,8 @@ require ( github.com/google/cel-go v0.22.0 // indirect github.com/google/gnostic-models v0.6.8 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/go-querystring v1.1.0 // indirect github.com/google/gofuzz v1.2.0 // indirect + github.com/google/jsonschema-go v0.3.0 // indirect github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db // indirect github.com/google/uuid v1.6.0 // indirect github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect @@ -70,14 +69,21 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.65.0 // indirect github.com/prometheus/otlptranslator v0.0.2 // indirect github.com/prometheus/procfs v0.17.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect @@ -88,11 +94,11 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/term v0.34.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/term v0.36.0 // indirect golang.org/x/time v0.7.0 // indirect - golang.org/x/tools v0.35.0 // indirect + golang.org/x/tools v0.38.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 // indirect diff --git a/go.sum b/go.sum index a7d071b..a19ac57 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ cel.dev/expr v0.18.0 h1:CJ6drgk+Hf96lkLikr4rFf19WrU0BOWEihyZnI2TAzo= cel.dev/expr v0.18.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +github.com/anthropics/anthropic-sdk-go v1.18.0 h1:jfxRA7AqZoCm83nHO/OVQp8xuwjUKtBziEdMbfmofHU= +github.com/anthropics/anthropic-sdk-go v1.18.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= +github.com/anthropics/anthropic-sdk-go v1.19.0 h1:mO6E+ffSzLRvR/YUH9KJC0uGw0uV8GjISIuzem//3KE= +github.com/anthropics/anthropic-sdk-go v1.19.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA= @@ -61,17 +65,14 @@ github.com/google/cel-go v0.22.0 h1:b3FJZxpiv1vTMo2/5RDUqAHPxkT8mmMfJIrq1llbf7g= github.com/google/cel-go v0.22.0/go.mod h1:BuznPXXfQDpXKWQ9sPW3TzlAJN5zzFe+i9tIs0yC4s8= github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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/go-github/v65 v65.0.0 h1:pQ7BmO3DZivvFk92geC0jB0q2m3gyn8vnYPgV7GSLhQ= -github.com/google/go-github/v65 v65.0.0/go.mod h1:DvrqWo5hvsdhJvHd4WyVF9ttANN3BniqjP8uTFMNb60= -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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= +github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -84,8 +85,6 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= -github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -107,6 +106,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA= +github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -114,8 +115,6 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw= @@ -162,8 +161,20 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 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/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +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.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -214,6 +225,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.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -223,6 +236,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -233,11 +248,15 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.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= golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -245,6 +264,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -255,6 +276,8 @@ 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.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -276,8 +299,6 @@ gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSP gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/github/github.go b/internal/github/github.go index 3e8cff0..08d3182 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -20,6 +20,7 @@ const ( type ProjectManagerInterface interface { GetProjectFields() ([]ProjectFieldInfo, error) CreateDraftIssue(title, body, board string) error + GetProjectIssues(perPage int) ([]Issue, error) } // ProjectManager represents a GitHub organization with a global workflow file and reference @@ -44,6 +45,23 @@ type ProjectFieldInfo struct { Options map[string]interface{} // option name -> option ID } +// Project represents a GitHub project +type Project struct { + Name *string + Body *string + State *string + HTMLURL *string +} + +// Issue represents a GitHub issue on a project board +type Issue struct { + Number int + Title string + Body string + State string + HTMLURL string +} + // NewProjectManager creates a new ProjectManager func NewProjectManager(ctx context.Context, token string) ProjectManagerInterface { return &ProjectManager{ @@ -145,29 +163,18 @@ func (g *ProjectManager) CreateDraftIssue(title, body, board string) error { var k8sReleaseFieldID, viewFieldID, statusFieldID, boardFieldID g4.ID var k8sReleaseValueID, viewValueID, statusValueID, boardValueID g4.ID + // Use helper function to find k8s_release field and latest version + k8sReleaseFieldID, k8sReleaseValueID = findK8sReleaseFieldAndLatestVersion(fields) + + // Use helper function to find status field with "drafting" or "draft" option + statusFieldID, statusValueID = findStatusFieldAndOption(fields, func(optName string) bool { + optNameLower := strings.ToLower(optName) + return strings.Contains(optNameLower, "drafting") || strings.Contains(optNameLower, "draft") + }) + for _, field := range fields { fieldNameLower := strings.ToLower(string(field.Name)) - // find K8s Release field - look for fields containing "k8s", "release", or "version" - if strings.Contains(fieldNameLower, "k8s release") { - k8sReleaseFieldID = field.ID - // find the latest version option (highest version number) - latestVersion := "" - latestVersionID := g4.ID("") - for optName, optID := range field.Options { - // extract version number from option name (e.g., "v1.32" -> "1.32") - if version := extractVersion(optName); version != "" { - if latestVersion == "" || compareVersions(version, latestVersion) > 0 { - latestVersion = version - latestVersionID = optID - } - } - } - if latestVersionID != g4.ID("") { - k8sReleaseValueID = latestVersionID - } - } - // find view field - look for fields containing "view" if strings.Contains(fieldNameLower, "view") { viewFieldID = field.ID @@ -191,18 +198,6 @@ func (g *ProjectManager) CreateDraftIssue(title, body, board string) error { } } } - - // find Status field - if strings.Contains(fieldNameLower, "status") { - statusFieldID = field.ID - for optName, optID := range field.Options { - if strings.Contains(strings.ToLower(optName), "drafting") || - strings.Contains(strings.ToLower(optName), "draft") { - statusValueID = optID - break - } - } - } } // create the draft issue @@ -258,13 +253,210 @@ func (g *ProjectManager) CreateDraftIssue(title, body, board string) error { return nil } -// extractVersion extracts a version string from text (e.g., "v1.32" -> "1.32", "1.30" -> "1.30") -func extractVersion(text string) string { - versionPattern := regexp.MustCompile(`v?(\d+)\.(\d+)`) - if matches := versionPattern.FindStringSubmatch(text); len(matches) >= 3 { - return fmt.Sprintf("%s.%s", matches[1], matches[2]) +// GetProjectIssues retrieves all issues from the project board +func (g *ProjectManager) GetProjectIssues(perPage int) ([]Issue, error) { + if g.githubClient == nil { + return nil, errors.New("github GraphQL client is nil") } - return "" + + // Get project fields to find the k8s_release field ID + fields, err := g.GetProjectFields() + if err != nil { + return nil, fmt.Errorf("failed to get project fields: %w", err) + } + + // Use helper functions to find fields + k8sReleaseFieldID, k8sReleaseOptionID := findK8sReleaseFieldAndLatestVersion(fields) + statusFieldID, failingStatusOptionID := findStatusFieldAndOption(fields, func(optName string) bool { + return strings.Contains(strings.ToLower(optName), "failing") || + strings.Contains(strings.ToLower(optName), "flaky") + }) + + if k8sReleaseOptionID == "" { + return nil, fmt.Errorf("latest version option not found in k8s_release field") + } + + if failingStatusOptionID == "" { + return nil, fmt.Errorf("FAILING status option not found in status field") + } + + // Find the latest version string for comparison + var latestVersionStr string + for _, field := range fields { + fieldNameLower := strings.ToLower(string(field.Name)) + if strings.Contains(fieldNameLower, "k8s release") && field.ID == k8sReleaseFieldID { + for optName, optID := range field.Options { + if optID == k8sReleaseOptionID { + latestVersionStr = extractVersion(optName) + break + } + } + break + } + } + + issues := make([]Issue, 0) + var cursor *g4.String + hasNextPage := true + + for hasNextPage { + var query struct { + Node struct { + ProjectV2 struct { + Items struct { + Nodes []struct { + Content struct { + Typename string `graphql:"__typename"` + Issue struct { + Number g4.Int + Title g4.String + Body g4.String + State g4.IssueState + URL g4.URI + } `graphql:"... on Issue"` + } + FieldValues struct { + Nodes []struct { + Typename string `graphql:"__typename"` + ProjectV2ItemFieldSingleSelectValue struct { + Field struct { + ProjectV2FieldCommon struct { + ID g4.ID + Name g4.String + } `graphql:"... on ProjectV2FieldCommon"` + } `graphql:"field"` + Name g4.String + } `graphql:"... on ProjectV2ItemFieldSingleSelectValue"` + } + } `graphql:"fieldValues(first: 20)"` + } + PageInfo struct { + HasNextPage g4.Boolean + EndCursor g4.String + } + } `graphql:"items(first: $first, after: $after)"` + } `graphql:"... on ProjectV2"` + } `graphql:"node(id: $projectID)"` + } + + // Note: GitHub GraphQL API does NOT support filter parameter for ProjectV2 items + // We need to fetch all items and filter them in code by checking fieldValues + variables := map[string]interface{}{ + "projectID": g4.ID(g.projectID), + "first": g4.Int(perPage), + "after": cursor, + } + + if err := g.githubClient.Query(context.Background(), &query, variables); err != nil { + return nil, fmt.Errorf("failed to query project issues: %w", err) + } + + // Filter items by k8s_release field value in code + // Since GraphQL API doesn't support filter parameter, we fetch all and filter manually + for _, node := range query.Node.ProjectV2.Items.Nodes { + // Only process actual issues, not draft issues or pull requests + if node.Content.Typename != "Issue" { + continue + } + + // Check if this item has the matching k8s_release field value and FAILING status + matchesVersion := false + matchesStatus := false + + for _, fieldValue := range node.FieldValues.Nodes { + if fieldValue.Typename == "ProjectV2ItemFieldSingleSelectValue" { + fieldID := fmt.Sprintf("%v", fieldValue.ProjectV2ItemFieldSingleSelectValue.Field.ProjectV2FieldCommon.ID) + optionName := string(fieldValue.ProjectV2ItemFieldSingleSelectValue.Name) + + // Check if this is the k8s_release field with the latest version + if fieldID == fmt.Sprintf("%v", k8sReleaseFieldID) { + // Extract version and check if it matches the latest version we found + extractedVersion := extractVersion(optionName) + if extractedVersion == latestVersionStr { + matchesVersion = true + } + } + + // Check if this is the status field with FAILING status + if fieldID == fmt.Sprintf("%v", statusFieldID) { + optionNameLower := strings.ToLower(optionName) + if strings.Contains(optionNameLower, "failing") || strings.Contains(optionNameLower, "flaky") { + matchesStatus = true + } + } + } + } + + // Only include issues that match both the version filter and FAILING status + if matchesVersion && matchesStatus { + issue := Issue{ + Number: int(node.Content.Issue.Number), + Title: string(node.Content.Issue.Title), + Body: string(node.Content.Issue.Body), + State: string(node.Content.Issue.State), + HTMLURL: node.Content.Issue.URL.String(), + } + issues = append(issues, issue) + } + } + + hasNextPage = bool(query.Node.ProjectV2.Items.PageInfo.HasNextPage) + if hasNextPage { + cursor = &query.Node.ProjectV2.Items.PageInfo.EndCursor + } + } + + return issues, nil +} + +// findK8sReleaseFieldAndLatestVersion finds the k8s_release field and returns the field ID and latest version option ID +func findK8sReleaseFieldAndLatestVersion(fields []ProjectFieldInfo) (fieldID g4.ID, optionID g4.ID) { + for _, field := range fields { + fieldNameLower := strings.ToLower(string(field.Name)) + if strings.Contains(fieldNameLower, "k8s release") { + fieldID = field.ID + // find the latest version option (highest version number) + latestVersion := "" + latestVersionID := g4.ID("") + for optName, optID := range field.Options { + // extract version number from option name (e.g., "v1.32" -> "1.32") + if version := extractVersion(optName); version != "" { + if latestVersion == "" || compareVersions(version, latestVersion) > 0 { + latestVersion = version + if id, ok := optID.(g4.ID); ok { + latestVersionID = id + } + } + } + } + if latestVersionID != g4.ID("") { + optionID = latestVersionID + } + break + } + } + return +} + +// findStatusFieldAndOption finds the status field and returns the field ID and option ID matching the criteria +func findStatusFieldAndOption(fields []ProjectFieldInfo, optionMatcher func(string) bool) (fieldID g4.ID, optionID g4.ID) { + for _, field := range fields { + fieldNameLower := strings.ToLower(string(field.Name)) + if strings.Contains(fieldNameLower, "status") { + fieldID = field.ID + // Find the option that matches the criteria + for optName, optID := range field.Options { + if optionMatcher(optName) { + if id, ok := optID.(g4.ID); ok { + optionID = id + break + } + } + } + break + } + } + return } // compareVersions compares two version strings (e.g., "1.30", "1.31") @@ -297,3 +489,12 @@ func compareVersions(v1, v2 string) int { return 0 } + +// extractVersion extracts a version string from text (e.g., "v1.32" -> "1.32", "1.30" -> "1.30") +func extractVersion(text string) string { + versionPattern := regexp.MustCompile(`v?(\d+)\.(\d+)`) + if matches := versionPattern.FindStringSubmatch(text); len(matches) >= 3 { + return fmt.Sprintf("%s.%s", matches[1], matches[2]) + } + return "" +} diff --git a/internal/mcp/client.go b/internal/mcp/client.go new file mode 100644 index 0000000..676c458 --- /dev/null +++ b/internal/mcp/client.go @@ -0,0 +1,191 @@ +package mcp + +import ( + "context" + "fmt" + "strings" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/option" + "github.com/modelcontextprotocol/go-sdk/mcp" + "sigs.k8s.io/signalhound/api/v1alpha1" +) + +const templatePrompt = `You are analyzing a list of currently failing and flaky Kubernetes tests and comparing them with existing GitHub project issues. + +Your task is to: +1. Review the list of currently failing and flaky tests +2. Review the existing GitHub issues, paying special attention to: + - Issues from the SAME board/tab (BoardHash) as the failing test + - Issues that might cover the same test or related tests in the same category + - Issues that mention similar test names, SIGs, or error patterns +3. For each failing/flaky test, determine if it is: + a) ALREADY COVERED: An existing issue on the same board/tab likely covers this test (same test name, similar test pattern, same SIG, or broader category issue) + b) MISSING: No existing issue covers this test +4. Report both categories clearly + +Currently Failing and Flaky Tests: +%s + +Existing GitHub Issues: +%s + +Please provide your analysis in the following format, only showing the MISSING items: + +## Tests Missing Issues + +For each test that does NOT have a corresponding issue: +- Test name and board/tab +- Brief summary of what test is failing and why it needs an issue + +Sort by: Status then Test name + +IMPORTANT: When determining if a test is "COVERED", be liberal in your interpretation: + +1. **Board/Tab Matching**: + - Extract board/tab information from issue titles and bodies (look for patterns like "sig-release-master-blocking", "board#tab", or similar board names) + - If an issue mentions the same board/tab (BoardHash) as a failing test, it's likely related + - Board names can appear in various formats: "sig-release-master-blocking", "sig-release-master-informing", etc. + +2. **Test Name Matching**: + - Exact test name match = COVERED + - Similar test name (same prefix, same SIG) = COVERED + - Test mentioned in a broader category issue = COVERED + +3. **SIG Matching**: + - If an issue exists for the same board/tab with the same SIG, consider tests from that SIG potentially covered + - Look for SIG mentions in issue titles/bodies (e.g., "[sig-node]", "SIG Node", etc.) + +4. **Broader Coverage**: + - If an issue exists for the same board/tab and covers a broader category (e.g., "multiple tests failing", "job failing", "tab failing"), consider related tests covered + - Issues that mention the board/tab but not specific tests may still cover all tests in that board/tab + +5. **When to mark as MISSING**: + - Only mark as MISSING if you're confident no existing issue on the same board/tab could reasonably cover it + - If unsure, prefer marking as COVERED to avoid duplicate issues + +**Example**: If a test "TestA" is failing on board "sig-release-master-blocking#gce-cos-master-default" and there's an issue titled "[Failing Test] sig-release-master-blocking#gce-cos-master-default - multiple tests failing", then TestA should be marked as COVERED even if it's not explicitly mentioned.` + +type MCPClient struct { + ctx context.Context + + mcpEndpoint string + anthropicAPIKey string + clientSession *mcp.ClientSession +} + +func NewMCPClient(anthropicAPIKey, mcpEndpoint string) (*MCPClient, error) { + ctx := context.Background() + impl := &mcp.Implementation{ + Name: "signalhound-tui", + Version: "1.0.0", + } + client := mcp.NewClient(impl, nil) + transport := &mcp.StreamableClientTransport{ + Endpoint: mcpEndpoint, + } + clientSession, err := client.Connect(ctx, transport, nil) + if err != nil { + return nil, err + } + + return &MCPClient{ + ctx: ctx, + clientSession: clientSession, + anthropicAPIKey: anthropicAPIKey, + mcpEndpoint: mcpEndpoint, + }, nil +} + +// LoadGithubIssues loads the list of GitHub issues for the given tabs +func (m *MCPClient) LoadGithubIssues(tabs []*v1alpha1.DashboardTab) (string, error) { + // Filter for only FAILING_STATUS and FLAKY_STATUS tabs + failingTabs := make([]*v1alpha1.DashboardTab, 0) + for _, tab := range tabs { + if tab.TabState == v1alpha1.FAILING_STATUS || tab.TabState == v1alpha1.FLAKY_STATUS { + failingTabs = append(failingTabs, tab) + } + } + + // Directly call the MCP tool to get issues + params := &mcp.CallToolParams{ + Name: "list_project_issues", + Arguments: map[string]interface{}{ + "perPage": 100, + }, + } + + result, err := m.clientSession.CallTool(m.ctx, params) + if err != nil { + return "", err + } + + // Parse the issues from the MCP response + var issuesText string + if result.IsError { + issuesText = "Error: Tool returned an error\n" + } else { + for _, content := range result.Content { + if textContent, ok := content.(*mcp.TextContent); ok { + issuesText += textContent.Text + "\n" + } + } + } + + // Build a list of failing tests for comparison with clear board/tab grouping + var brokenTestsList strings.Builder + brokenTestsList.WriteString("=== Currently Failing or Flaking Tests ===\n\n") + for _, tab := range failingTabs { + // Extract board name and tab name from BoardHash (format: "board#tab") + boardParts := strings.Split(tab.BoardHash, "#") + boardName := boardParts[0] + tabName := "" + if len(boardParts) > 1 { + tabName = boardParts[1] + } + + brokenTestsList.WriteString(fmt.Sprintf("Board/Tab: %s (BoardHash: %s)\n", tab.BoardHash, tab.BoardHash)) + brokenTestsList.WriteString(fmt.Sprintf(" Board Name: %s\n", boardName)) + if tabName != "" { + brokenTestsList.WriteString(fmt.Sprintf(" Tab Name: %s\n", tabName)) + } + brokenTestsList.WriteString(fmt.Sprintf(" Status: %s\n", tab.TabState)) + brokenTestsList.WriteString(" Tests:\n") + for _, test := range tab.TestRuns { + brokenTestsList.WriteString(fmt.Sprintf(" - %s\n", test.TestName)) + } + brokenTestsList.WriteString("\n") + } + + // Use Anthropic to compare failing tests with existing issues and identify missing ones + anthropicClient := anthropic.NewClient( + option.WithAPIKey(m.anthropicAPIKey), + ) + + prompt := fmt.Sprintf(templatePrompt, brokenTestsList.String(), issuesText) + + message, err := anthropicClient.Messages.New(m.ctx, anthropic.MessageNewParams{ + Model: anthropic.ModelClaudeSonnet4_5_20250929, + MaxTokens: 4096, + Messages: []anthropic.MessageParam{ + anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)), + }, + }) + if err != nil { + return "", err + } + + var response string + if len(message.Content) > 0 { + for _, block := range message.Content { + if textBlock, ok := block.AsAny().(anthropic.TextBlock); ok { + response += textBlock.Text + } + } + } else { + // Fallback if Anthropic fails + _ = fmt.Sprintf("=== Analysis ===\n\nFlake || Failing Tests:\n%s\n\nExisting Issues:\n%s", brokenTestsList.String(), issuesText) + } + + return response, nil +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go new file mode 100755 index 0000000..39e4790 --- /dev/null +++ b/internal/mcp/server.go @@ -0,0 +1,115 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "sigs.k8s.io/signalhound/internal/github" +) + +// MCPServer represents an MCP server instance +type MCPServer struct { + githubClient github.ProjectManagerInterface + ctx context.Context + server *mcp.Server +} + +// NewMCPServer creates a new MCP server instance +func NewMCPServer(ctx context.Context, githubToken string) *MCPServer { + server := &MCPServer{ + githubClient: github.NewProjectManager(ctx, githubToken), + ctx: ctx, + } + + // create MCP server with implementation info + impl := &mcp.Implementation{ + Name: "signalhound", + Version: "1.0.0", + } + + server.server = mcp.NewServer(impl, nil) + + // Add tools + listIssuesSchema := json.RawMessage(`{ + "type": "object", + "properties": { + "perPage": { + "type": "number", + "description": "Number of issues per page (default: 100)" + } + } + }`) + + mcp.AddTool(server.server, &mcp.Tool{ + Name: "list_project_issues", + Description: "List all issues from the SIG Signal project board", + InputSchema: listIssuesSchema, + }, server.handleListProjectIssues) + + return server +} + +// NewHTTPHandler creates an HTTP handler for StreamableHTTP transport +func (s *MCPServer) NewHTTPHandler() *mcp.StreamableHTTPHandler { + return mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { + return s.server + }, nil) +} + +// ListProjectIssuesInput represents the input for list_project_issues tool +type ListProjectIssuesInput struct { + PerPage int `json:"perPage,omitempty"` +} + +// handleListProjectIssues handles the list_project_issues tool call +func (s *MCPServer) handleListProjectIssues(ctx context.Context, req *mcp.CallToolRequest, input ListProjectIssuesInput) ( + *mcp.CallToolResult, + any, + error, +) { + // Parse arguments + perPage := 100 + if input.PerPage > 0 { + perPage = input.PerPage + } + + issues, err := s.githubClient.GetProjectIssues(perPage) + if err != nil { + log.Printf("Error getting issues: %v", err) + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("Failed to get issues: %v", err)}, + }, + IsError: true, + }, nil, nil + } + + var resultText string + if len(issues) == 0 { + resultText = "No issues found on the project board" + } else { + resultText = fmt.Sprintf("Found %d issue(s) on the project board:\n\n", len(issues)) + for i, issue := range issues { + body := issue.Body + if len(body) > 200 { + body = body[:200] + "..." + } + resultText += fmt.Sprintf("%d. #%d: %s\n", i+1, issue.Number, issue.Title) + resultText += fmt.Sprintf(" State: %s\n", issue.State) + resultText += fmt.Sprintf(" URL: %s\n", issue.HTMLURL) + if body != "" { + resultText += fmt.Sprintf(" Body: %s\n", body) + } + resultText += "\n" + } + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: resultText}, + }, + }, nil, nil +} diff --git a/internal/tui/panel.go b/internal/tui/panel.go index d8cae14..48ee6fa 100644 --- a/internal/tui/panel.go +++ b/internal/tui/panel.go @@ -3,6 +3,7 @@ package tui import ( "context" "fmt" + "os" "os/exec" "runtime" "strings" @@ -14,25 +15,41 @@ import ( "golang.org/x/text/language" "sigs.k8s.io/signalhound/api/v1alpha1" "sigs.k8s.io/signalhound/internal/github" + intmcp "sigs.k8s.io/signalhound/internal/mcp" + "sigs.k8s.io/signalhound/internal/testgrid" ) -const defaultPositionText = "[green]Select a content Windows and press [blue]Ctrl-Space [green]to COPY or press [blue]Ctrl-C [green]to exit" - -var ( - pagesName = "SignalHound" - app *tview.Application // The tview application. - pages *tview.Pages // The application pages. - tabsPanel *tview.List // The tabs panel (needs to be accessible for updates) - brokenPanel = tview.NewList() - slackPanel = tview.NewTextArea() - githubPanel = tview.NewTextArea() - position = tview.NewTextView() - currentTabs []*v1alpha1.DashboardTab // Store current tabs for refresh - githubToken string // Store token for refresh - selectedBoardHash string // Store selected BoardHash for refresh preservation - selectedTestName string // Store selected test name for refresh preservation +const ( + defaultMCPEndpoint = "http://localhost:8080/mcp" + errorMsgFormat = "Error calling MCP tool: %v" + successMsg = "[green]✓ Analysis Completed" + defaultRefreshInterval = 10 * time.Minute ) +// MultiWindowTUI represents the multi-window TUI application +type MultiWindowTUI struct { + app *tview.Application + pages *tview.Pages + tabs []*v1alpha1.DashboardTab + githubToken string + brokenTestsPage *tview.Flex + mcpIssuesPage *tview.Flex + mcpPanelRef *tview.TextArea + statusPanelRef *tview.TextView + tabsPanelRef *tview.List + brokenPanelRef *tview.List + slackPanelRef *tview.TextArea + githubPanelRef *tview.TextArea + positionRef *tview.TextView + // Auto-refresh fields + refreshTicker *time.Ticker + testgridClient *testgrid.TestGrid + dashboards []string + minFailure int + minFlake int + refreshStopCh chan struct{} +} + func formatTitle(txt string) string { // var titleColor = "green" // return fmt.Sprintf(" [%s:bg:b]%s[-:-:-] ", titleColor, txt) @@ -56,203 +73,420 @@ func setPanelFocusStyle(p *tview.Box) { p.SetBorderColor(tcell.ColorBlue) p.SetTitleColor(tcell.ColorBlue) p.SetBackgroundColor(tcell.ColorDarkBlue) - app.SetFocus(p) } -// updateTabsPanel updates the tabs panel with new data while preserving selection if possible. -func updateTabsPanel(tabs []*v1alpha1.DashboardTab) { - if tabsPanel == nil { - return +// NewMultiWindowTUI creates a new MultiWindowTUI instance +func NewMultiWindowTUI(tabs []*v1alpha1.DashboardTab, githubToken string) *MultiWindowTUI { + return &MultiWindowTUI{ + app: tview.NewApplication(), + pages: tview.NewPages(), + tabs: tabs, + githubToken: githubToken, + refreshStopCh: make(chan struct{}), } +} - // Store current selection before clearing - if tabsPanel.GetItemCount() > 0 { - currentIndex := tabsPanel.GetCurrentItem() - if currentIndex >= 0 && currentIndex < len(currentTabs) { - selectedBoardHash = currentTabs[currentIndex].BoardHash - // Store selected test name if brokenPanel has items - if brokenPanel.GetItemCount() > 0 { - testIndex := brokenPanel.GetCurrentItem() - if testIndex >= 0 && testIndex < brokenPanel.GetItemCount() { - _, selectedTestName = brokenPanel.GetItemText(testIndex) - } - } - } - } +// SetRefreshConfig sets the configuration for auto-refresh +func (m *MultiWindowTUI) SetRefreshConfig(tg *testgrid.TestGrid, dashboards []string, minFailure, minFlake int) { + m.testgridClient = tg + m.dashboards = dashboards + m.minFailure = minFailure + m.minFlake = minFlake +} - // Clear and rebuild the tabs panel - tabsPanel.Clear() - // Map to store tab selection callbacks by BoardHash for restoration - tabCallbacks := make(map[string]func()) +// Run starts the TUI application +func (m *MultiWindowTUI) Run() error { + // Create all views + m.brokenTestsPage = m.createBrokenTestsView() + m.mcpIssuesPage = m.createMCPIssuesView() - for _, tab := range tabs { - icon := "🟣" - if tab.TabState == v1alpha1.FAILING_STATUS { - icon = "🔴" - } - tabText := fmt.Sprintf("[%s] %s", icon, strings.ReplaceAll(tab.BoardHash, "#", " - ")) - - // Create selection callback for this tab - tabCallback := func(tab *v1alpha1.DashboardTab) func() { - return func() { - // Store the selected BoardHash when user manually selects a tab - selectedBoardHash = tab.BoardHash - selectedTestName = "" // Clear test selection when tab changes - - brokenPanel.Clear() - for _, test := range tab.TestRuns { - brokenPanel.AddItem(tview.Escape(test.TestName), "", 0, nil) - } - app.SetFocus(brokenPanel) - brokenPanel.SetCurrentItem(0) - brokenPanel.SetChangedFunc(func(i int, testName string, secondaryText string, shortcut rune) { - position.SetText(defaultPositionText) - // Store the selected test name when user navigates tests - if i >= 0 && i < brokenPanel.GetItemCount() { - _, selectedTestName = brokenPanel.GetItemText(i) - } - }) - // Broken panel rendering the function selection - brokenPanel.SetSelectedFunc(func(i int, testName string, secondaryText string, shortcut rune) { - // Store the selected test name - selectedTestName = testName - var currentTest = tab.TestRuns[i] - updateSlackPanel(tab, ¤tTest) - updateGitHubPanel(tab, ¤tTest, githubToken) - app.SetFocus(slackPanel) - }) - } - }(tab) + // Add pages + m.pages.AddPage("broken_tests", m.brokenTestsPage, true, true) + m.pages.AddPage("mcp_issues", m.mcpIssuesPage, true, false) - tabCallbacks[tab.BoardHash] = tabCallback - tabsPanel.AddItem(tabText, "", 0, tabCallback) + // Set up global key handler + m.app.SetInputCapture(m.globalKeyHandler) + + // Start auto-refresh if configured + if m.testgridClient != nil { + m.startAutoRefresh() } - // Update stored tabs - currentTabs = tabs - - // Try to restore selection by BoardHash - if selectedBoardHash != "" { - for i, tab := range tabs { - if tab.BoardHash == selectedBoardHash { - tabsPanel.SetCurrentItem(i) - // Save test selection before callback clears it - savedTestName := selectedTestName - // Trigger the selection callback to restore brokenPanel - if callback, exists := tabCallbacks[selectedBoardHash]; exists { - callback() - // Restore test selection if it exists - if savedTestName != "" { - for j := 0; j < brokenPanel.GetItemCount(); j++ { - testName, _ := brokenPanel.GetItemText(j) - if testName == savedTestName { - brokenPanel.SetCurrentItem(j) - selectedTestName = savedTestName // Restore the stored value - break - } - } - } - } - break - } - } + // Cleanup on exit + defer m.stopAutoRefresh() + + return m.app.SetRoot(m.pages, true).EnableMouse(true).Run() +} + +// globalKeyHandler handles global keyboard shortcuts for navigation +func (m *MultiWindowTUI) globalKeyHandler(event *tcell.EventKey) *tcell.EventKey { + // handle F1 for broken tests + if event.Key() == tcell.KeyF1 { + m.pages.SwitchToPage("broken_tests") + return nil + } + // handle F2 for MCP issues + if event.Key() == tcell.KeyF2 { + m.pages.SwitchToPage("mcp_issues") + return nil } + // handle Ctrl-C for exit + if event.Key() == tcell.KeyCtrlC { + m.app.Stop() + return nil + } + return event } -// RenderVisual loads the entire grid and componnents in the app. -// this is a blocking functions. -func RenderVisual(tabs []*v1alpha1.DashboardTab, token string, refreshInterval time.Duration, refreshFunc func() ([]*v1alpha1.DashboardTab, error)) error { - app = tview.NewApplication() - githubToken = token - currentTabs = tabs +// createBrokenTestsView creates the broken tests view with tabs, tests, slack, and github panels +func (m *MultiWindowTUI) createBrokenTestsView() *tview.Flex { + + // Header panel with keybindings + headerPanel := tview.NewTextView() + setPanelDefaultStyle(headerPanel.Box) + headerPanel.SetTitle(formatTitle("Keybindings")) + headerPanel.SetDynamicColors(true) + headerText := `[white]Actions: [yellow]Ctrl-Space[white] Copy [yellow]Ctrl-B[white] Create Issue [yellow]F-1[white] Broken Tests [yellow]F-2[white] MCP Issues [yellow]Ctrl-C[white] Exit` + headerPanel.SetText(headerText) // Render tab in the first row - tabsPanel = tview.NewList().ShowSecondaryText(false) + tabsPanel := tview.NewList().ShowSecondaryText(false) setPanelDefaultStyle(tabsPanel.Box) - tabsPanel.SetSelectedBackgroundColor(tcell.ColorBlue) - tabsPanel.SetHighlightFullLine(true) - tabsPanel.SetMainTextStyle(tcell.StyleDefault) - tabsPanel.SetTitle(formatTitle("Board#Tabs")) + tabsPanel.SetTitle(formatTitle("Board - Tabs")) // Broken tests in the tab - brokenPanel.ShowSecondaryText(false).SetDoneFunc(func() { app.SetFocus(tabsPanel) }) + brokenPanel := tview.NewList().ShowSecondaryText(false) + brokenPanel.SetDoneFunc(func() { m.app.SetFocus(tabsPanel) }) setPanelDefaultStyle(brokenPanel.Box) brokenPanel.SetTitle(formatTitle("Tests")) - brokenPanel.SetSelectedBackgroundColor(tcell.ColorBlue) - brokenPanel.SetHighlightFullLine(true) - brokenPanel.SetMainTextStyle(tcell.StyleDefault) // Slack Final issue rendering + slackPanel := tview.NewTextArea() setPanelDefaultStyle(slackPanel.Box) slackPanel.SetTitle(formatTitle("Slack Message")) slackPanel.SetWrap(true).SetDisabled(true) - slackPanel.SetTextStyle(tcell.StyleDefault) // GitHub panel rendering + githubPanel := tview.NewTextArea() setPanelDefaultStyle(githubPanel.Box) githubPanel.SetTitle(formatTitle("Github Issue")) - githubPanel.SetWrap(true).SetDisabled(true) - githubPanel.SetTextStyle(tcell.StyleDefault) + githubPanel.SetWrap(true) // Final position bottom panel for information - position.SetDynamicColors(true).SetTextAlign(tview.AlignCenter).SetText(defaultPositionText).SetTextStyle(tcell.StyleDefault) + position := tview.NewTextView() + var positionText = "[yellow]Select a test to view details" + position.SetDynamicColors(true).SetTextAlign(tview.AlignCenter).SetText(positionText) + + // Tabs iteration for building the middle panels and actions settings + for _, tab := range m.tabs { + icon := "🟣" + if tab.TabState == v1alpha1.FAILING_STATUS { + icon = "🔴" + } + tabCopy := tab // Capture for closure + tabsPanel.AddItem(fmt.Sprintf("[%s] %s", icon, strings.ReplaceAll(tab.BoardHash, "#", " - ")), "", 0, func() { + brokenPanel.Clear() + for _, test := range tabCopy.TestRuns { + brokenPanel.AddItem(test.TestName, "", 0, nil) + } + m.app.SetFocus(brokenPanel) + brokenPanel.SetCurrentItem(0) + brokenPanel.SetChangedFunc(func(i int, testName string, t string, s rune) { + position.SetText(fmt.Sprintf("[blue] selected %s test ", testName)) + }) + // Broken panel rendering the function selection + brokenPanel.SetSelectedFunc(func(i int, testName string, t string, s rune) { + var currentTest = tabCopy.TestRuns[i] + m.updateSlackPanel(slackPanel, tabCopy, ¤tTest, position) + m.updateGitHubPanel(githubPanel, tabCopy, ¤tTest, position) + m.app.SetFocus(slackPanel) + }) + position.SetText(fmt.Sprintf("[blue] selected %s board", tab.TabName)) + }) + } + + // Store panel references for navigation setup + m.tabsPanelRef = tabsPanel + m.brokenPanelRef = brokenPanel + m.slackPanelRef = slackPanel + m.githubPanelRef = githubPanel + m.positionRef = position + + // Set up navigation keybindings for panels + m.setupPanelNavigation() // Create the grid layout - grid := tview.NewGrid().SetRows(10, 10, 0, 0, 1). - AddItem(tabsPanel, 0, 0, 1, 2, 0, 0, true). - AddItem(brokenPanel, 1, 0, 1, 2, 0, 0, false). - AddItem(position, 4, 0, 1, 2, 0, 0, false) - - // Adding middle panel and split across rows and columns - grid.AddItem(slackPanel, 2, 0, 2, 1, 0, 0, false). - AddItem(githubPanel, 2, 1, 2, 1, 0, 0, false) - - // Initial tabs setup - updateTabsPanel(tabs) - - // Set up periodic refresh if interval is configured and refresh function is provided - if refreshInterval > 0 && refreshFunc != nil { - go func() { - ticker := time.NewTicker(refreshInterval) - defer ticker.Stop() - for range ticker.C { - newTabs, err := refreshFunc() + // Row sizes: header(3), tabs(10), broken(10), slack/github(flexible), position(1) + grid := tview.NewGrid().SetRows(3, 10, 10, 0, 0, 1). + AddItem(headerPanel, 0, 0, 1, 2, 0, 0, false). + AddItem(tabsPanel, 1, 0, 1, 2, 0, 0, true). + AddItem(brokenPanel, 2, 0, 1, 2, 0, 0, false). + AddItem(slackPanel, 3, 0, 2, 1, 0, 0, false). + AddItem(githubPanel, 3, 1, 2, 1, 0, 0, false). + AddItem(position, 5, 0, 1, 2, 0, 0, false) + return tview.NewFlex().SetDirection(tview.FlexRow).AddItem(grid, 0, 1, true) +} + +// startAutoRefresh starts the auto-refresh ticker for broken tests +func (m *MultiWindowTUI) startAutoRefresh() { + if m.refreshTicker != nil { + return // Already started + } + m.refreshBrokenTestsAsync() + m.refreshTicker = time.NewTicker(defaultRefreshInterval) + go func() { + for { + select { + case <-m.refreshTicker.C: + m.refreshBrokenTestsAsync() + case <-m.refreshStopCh: + return + } + } + }() +} + +// stopAutoRefresh stops the auto-refresh ticker +func (m *MultiWindowTUI) stopAutoRefresh() { + if m.refreshTicker != nil { + m.refreshTicker.Stop() + m.refreshTicker = nil + } + select { + case <-m.refreshStopCh: + // Channel already closed + default: + close(m.refreshStopCh) + } +} + +// refreshBrokenTestsAsync refreshes the broken tests data in a background goroutine +func (m *MultiWindowTUI) refreshBrokenTestsAsync() { + if m.testgridClient == nil || len(m.dashboards) == 0 { + return + } + go func() { + var dashboardTabs []*v1alpha1.DashboardTab + for _, dashboard := range m.dashboards { + dashSummaries, err := m.testgridClient.FetchTabSummary(dashboard, v1alpha1.ERROR_STATUSES) + if err != nil { + m.updatePositionWithError(fmt.Errorf("error fetching dashboard %s: %v", dashboard, err)) + continue + } + for _, dashSummary := range dashSummaries { + dashTab, err := m.testgridClient.FetchTabTests(&dashSummary, m.minFailure, m.minFlake) if err != nil { - app.QueueUpdateDraw(func() { - position.SetText(fmt.Sprintf("[red]Refresh error: %v", err)) - }) + tabName := dashboard + if dashSummary.DashboardTab != nil { + tabName = dashSummary.DashboardTab.TabName + } + m.updatePositionWithError(fmt.Errorf("error fetching table %s: %v", tabName, err)) continue } - app.QueueUpdateDraw(func() { - updateTabsPanel(newTabs) - position.SetText(fmt.Sprintf("[green]Refreshed at %s", time.Now().Format("15:04:05"))) - // Clear refresh message after 1 seconds - go func() { - time.Sleep(1 * time.Second) - app.QueueUpdateDraw(func() { - position.SetText(defaultPositionText) + if len(dashTab.TestRuns) > 0 { + dashboardTabs = append(dashboardTabs, dashTab) + } + } + } + m.updateBrokenTestsUI(dashboardTabs) + }() +} + +// updateBrokenTestsUI updates the UI with new tabs data +func (m *MultiWindowTUI) updateBrokenTestsUI(newTabs []*v1alpha1.DashboardTab) { + m.app.QueueUpdateDraw(func() { + // Update tabs data + m.tabs = newTabs + + // Clear and rebuild tabs panel + if m.tabsPanelRef != nil { + m.tabsPanelRef.Clear() + for _, tab := range m.tabs { + icon := "🟣" + if tab.TabState == v1alpha1.FAILING_STATUS { + icon = "🔴" + } + tabCopy := tab // Capture for closure + m.tabsPanelRef.AddItem(fmt.Sprintf("[%s] %s", icon, strings.ReplaceAll(tab.BoardHash, "#", " - ")), "", 0, func() { + if m.brokenPanelRef != nil { + m.brokenPanelRef.Clear() + for _, test := range tabCopy.TestRuns { + m.brokenPanelRef.AddItem(test.TestName, "", 0, nil) + } + m.app.SetFocus(m.brokenPanelRef) + m.brokenPanelRef.SetCurrentItem(0) + m.brokenPanelRef.SetChangedFunc(func(i int, testName string, t string, s rune) { + if m.positionRef != nil { + m.positionRef.SetText(fmt.Sprintf("[blue] selected %s test ", testName)) + } }) - }() + // Broken panel rendering the function selection + m.brokenPanelRef.SetSelectedFunc(func(i int, testName string, t string, s rune) { + var currentTest = tabCopy.TestRuns[i] + if m.slackPanelRef != nil && m.githubPanelRef != nil && m.positionRef != nil { + m.updateSlackPanel(m.slackPanelRef, tabCopy, ¤tTest, m.positionRef) + m.updateGitHubPanel(m.githubPanelRef, tabCopy, ¤tTest, m.positionRef) + m.app.SetFocus(m.slackPanelRef) + } + }) + if m.positionRef != nil { + m.positionRef.SetText(fmt.Sprintf("[blue] selected %s board", tab.TabName)) + } + } }) } - }() + // Update position message + if m.positionRef != nil { + m.positionRef.SetText(fmt.Sprintf("[green]Auto-refreshed: %d tabs loaded", len(m.tabs))) + } + } + }) +} + +// updatePositionWithError updates the position panel with an error message +func (m *MultiWindowTUI) updatePositionWithError(err error) { + if m.positionRef != nil { + m.app.QueueUpdateDraw(func() { + m.positionRef.SetText(fmt.Sprintf("[red]Refresh error: %v", err)) + }) } +} - // Render the final page. - pages = tview.NewPages().AddPage(pagesName, grid, true, true) - return app.SetRoot(pages, true).EnableMouse(true).Run() +// setupPanelNavigation sets up keyboard navigation between panels +func (m *MultiWindowTUI) setupPanelNavigation() { + // Board#Tabs panel navigation + m.tabsPanelRef.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyDown, tcell.KeyUp: + // Allow normal list navigation + return event + } + return event + }) + + // Tests panel navigation + m.brokenPanelRef.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyEscape: + // Go back to Board#Tabs + m.app.SetFocus(m.tabsPanelRef) + return nil + case tcell.KeyDown, tcell.KeyUp: + // Allow normal list navigation + return event + case tcell.KeyTab: + // Move to Slack panel + m.app.SetFocus(m.slackPanelRef) + return nil + } + return event + }) +} + +// createMCPIssuesView creates the MCP issues view +func (m *MultiWindowTUI) createMCPIssuesView() *tview.Flex { + // MCP panel rendering + mcpPanel := tview.NewTextArea() + setPanelDefaultStyle(mcpPanel.Box) + mcpPanel.SetTitle(formatTitle("MCP Issues")) + mcpPanel.SetWrap(true).SetDisabled(false) + + // Status panel + statusPanel := tview.NewTextView() + setPanelDefaultStyle(statusPanel.Box) + statusPanel.SetTitle(formatTitle("Status")) + statusPanel.SetDynamicColors(true) + statusPanel.SetText("[yellow]Loading issues from MCP server...") + + // Help panel + helpPanel := tview.NewTextView() + var helpText = "[yellow]Press [blue]F-1 [yellow]for Broken Tests, [blue]F-2 [yellow]for MCP Issues, [blue]Ctrl-C [yellow]to exit" + helpPanel.SetDynamicColors(true).SetTextAlign(tview.AlignCenter).SetText(helpText) + + // Store mcpPanel reference for async updates + m.mcpPanelRef = mcpPanel + m.statusPanelRef = statusPanel + + // Create layout + flex := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(statusPanel, 3, 0, false). + AddItem(mcpPanel, 0, 1, true). + AddItem(helpPanel, 1, 0, false) + + m.loadGithubIssuesAsync() + return flex +} +func initMCPConfig() (endpoint, apiKey string) { + endpoint = os.Getenv("MCP_SERVER_ENDPOINT") + if endpoint == "" { + endpoint = defaultMCPEndpoint + } + + apiKey = os.Getenv("ANTHROPIC_API_KEY") + if apiKey == "" { + apiKey = os.Getenv("SIGNALHOUND_ANTHROPIC_API_KEY") + } + return endpoint, apiKey +} + +// updateUIWithError updates UI components with the given error +func (m *MultiWindowTUI) updateUIWithError(err error) { + m.app.QueueUpdateDraw(func() { + errMsg := fmt.Sprintf(errorMsgFormat, err) + if m.mcpPanelRef != nil { + m.mcpPanelRef.SetText(errMsg, false) + } + if m.statusPanelRef != nil { + m.statusPanelRef.SetText(fmt.Sprintf("[red]Error: %v", err)) + } + }) +} + +// updateUIWithSuccess updates UI components with successful response +func (m *MultiWindowTUI) updateUIWithSuccess(response string) { + m.app.QueueUpdateDraw(func() { + if m.mcpPanelRef != nil { + m.mcpPanelRef.SetText(response, false) + } + if m.statusPanelRef != nil { + m.statusPanelRef.SetText(successMsg) + } + }) +} + +// loadGithubIssuesAsync loads GitHub issues in a background goroutine +func (m *MultiWindowTUI) loadGithubIssuesAsync() { + mcpEndpoint, anthropicAPIKey := initMCPConfig() + go func() { + for i := 0; i < 3; i++ { + time.Sleep(10 * time.Second) + client, err := intmcp.NewMCPClient(anthropicAPIKey, mcpEndpoint) + if err != nil { + m.updateUIWithError(err) + return + } + response, err := client.LoadGithubIssues(m.tabs) + if err != nil { + m.updateUIWithError(err) + return + } + m.updateUIWithSuccess(response) + return + } + }() } // updateSlackPanel writes down to left panel (Slack) content. -func updateSlackPanel(tab *v1alpha1.DashboardTab, currentTest *v1alpha1.TestResult) { +func (m *MultiWindowTUI) updateSlackPanel(slackPanel *tview.TextArea, tab *v1alpha1.DashboardTab, currentTest *v1alpha1.TestResult, position *tview.TextView) { // set the item string with current test content item := fmt.Sprintf("%s %s on [%s](%s): `%s` [Prow](%s), [Triage](%s), last failure on %s\n", tab.StateIcon, cases.Title(language.English).String(tab.TabState), tab.BoardHash, tab.TabURL, currentTest.TestName, currentTest.ProwJobURL, currentTest.TriageURL, timeClean(currentTest.LatestTimestamp), ) - // set input capture, ctrl-space for clipboard copy, esc to cancel panel selection. + // set input capture, ctrl-space for clipboard copy slackPanel.SetText(item, true) + // Set up navigation and actions for Slack panel slackPanel.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyCtrlSpace { position.SetText("[blue]COPIED [yellow]SLACK [blue]TO THE CLIPBOARD!") @@ -261,30 +495,29 @@ func updateSlackPanel(tab *v1alpha1.DashboardTab, currentTest *v1alpha1.TestResu return event } setPanelFocusStyle(slackPanel.Box) - slackPanel.SetTextStyle(tcell.StyleDefault.Foreground(tcell.ColorWhite)) go func() { time.Sleep(1 * time.Second) - app.QueueUpdateDraw(func() { - app.SetFocus(brokenPanel) + m.app.QueueUpdateDraw(func() { setPanelDefaultStyle(slackPanel.Box) - slackPanel.SetTextStyle(tcell.StyleDefault) }) }() + return nil } - if event.Key() == tcell.KeyEscape || event.Key() == tcell.KeyUp { - slackPanel.SetText("", false) - githubPanel.SetText("", false) - app.SetFocus(brokenPanel) - } - if event.Key() == tcell.KeyRight { - app.SetFocus(githubPanel) + // Navigation + switch event.Key() { + case tcell.KeyRight: + m.app.SetFocus(m.githubPanelRef) + return nil + case tcell.KeyLeft, tcell.KeyUp, tcell.KeyEscape: + m.app.SetFocus(m.brokenPanelRef) + return nil } return event }) } // updateGitHubPanel writes down to the right panel (GitHub) content. -func updateGitHubPanel(tab *v1alpha1.DashboardTab, currentTest *v1alpha1.TestResult, token string) { +func (m *MultiWindowTUI) updateGitHubPanel(githubPanel *tview.TextArea, tab *v1alpha1.DashboardTab, currentTest *v1alpha1.TestResult, position *tview.TextView) { // create the filled-out issue template object splitBoard := strings.Split(tab.BoardHash, "#") issue := &IssueTemplate{ @@ -323,18 +556,16 @@ func updateGitHubPanel(tab *v1alpha1.DashboardTab, currentTest *v1alpha1.TestRes return event } setPanelFocusStyle(githubPanel.Box) - githubPanel.SetTextStyle(tcell.StyleDefault.Foreground(tcell.ColorWhite)) go func() { time.Sleep(1 * time.Second) - app.QueueUpdateDraw(func() { - app.SetFocus(brokenPanel) + m.app.QueueUpdateDraw(func() { setPanelDefaultStyle(githubPanel.Box) - githubPanel.SetTextStyle(tcell.StyleDefault) }) }() + return nil } if event.Key() == tcell.KeyCtrlB { - gh := github.NewProjectManager(context.Background(), token) + gh := github.NewProjectManager(context.Background(), m.githubToken) if err := gh.CreateDraftIssue(issueTitle, issueBody, tab.BoardHash); err != nil { position.SetText(fmt.Sprintf("[red]error: %v", err.Error())) return event @@ -342,22 +573,21 @@ func updateGitHubPanel(tab *v1alpha1.DashboardTab, currentTest *v1alpha1.TestRes position.SetText("[blue]Created [yellow]DRAFT ISSUE [blue] on GitHub Project!") setPanelFocusStyle(githubPanel.Box) go func() { - app.QueueUpdateDraw(func() { - app.SetFocus(brokenPanel) + time.Sleep(1 * time.Second) + m.app.QueueUpdateDraw(func() { setPanelDefaultStyle(githubPanel.Box) }) }() + return nil } - if event.Key() == tcell.KeyEscape { - slackPanel.SetText("", false) - githubPanel.SetText("", false) - app.SetFocus(brokenPanel) - } - if event.Key() == tcell.KeyLeft { - app.SetFocus(slackPanel) - } - if event.Key() == tcell.KeyRight { - app.SetFocus(slackPanel) + // Navigation + switch event.Key() { + case tcell.KeyLeft: + m.app.SetFocus(m.slackPanelRef) + return nil + case tcell.KeyUp, tcell.KeyEscape: + m.app.SetFocus(m.brokenPanelRef) + return nil } return event }) @@ -375,8 +605,8 @@ func CopyToClipboard(text string) error { switch runtime.GOOS { case "windows": // Native Windows - cmd = exec.Command("cmd", "/c", "echo "+text+" | clip") - // Alternative: cmd = exec.Command("powershell", "-command", "Set-Clipboard", "-Value", text) + cmd = exec.Command("clip.exe") + cmd.Stdin = strings.NewReader(text) case "darwin": // macOS cmd = exec.Command("pbcopy")