diff --git a/.github/workflows/close-inactive-issues.yml b/.github/workflows/close-inactive-issues.yml new file mode 100644 index 000000000..c9ece2b6f --- /dev/null +++ b/.github/workflows/close-inactive-issues.yml @@ -0,0 +1,28 @@ +name: Close inactive issues +on: + schedule: + - cron: "30 8 * * *" + +jobs: + close-issues: + runs-on: ubuntu-latest + env: + PR_DAYS_BEFORE_STALE: 60 + PR_DAYS_BEFORE_CLOSE: 10 + PR_STALE_LABEL: stale + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/stale@v9 + with: + days-before-issue-stale: ${{ env.PR_DAYS_BEFORE_STALE }} + days-before-issue-close: ${{ env.PR_DAYS_BEFORE_CLOSE }} + stale-issue-label: ${{ env.PR_STALE_LABEL }} + stale-issue-message: "This issue is stale because it has been open for ${{ env.PR_DAYS_BEFORE_STALE }} days with no activity. Leave a comment to avoid closing this issue in ${{ env.PR_DAYS_BEFORE_CLOSE }} days." + close-issue-message: "This issue was closed because it has been inactive for ${{ env.PR_DAYS_BEFORE_CLOSE }} days since being marked as stale." + days-before-pr-stale: -1 + days-before-pr-close: -1 + # Start with the oldest items first + ascending: true + repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index c8a55ea7b..ce27bdb06 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,7 @@ To keep your GitHub PAT secure and reusable across different MCP hosts: - **Minimum scopes**: Only grant necessary permissions - `repo` - Repository operations - `read:packages` - Docker image access + - `read:org` - Organization team access - **Separate tokens**: Use different PATs for different projects/environments - **Regular rotation**: Update tokens periodically - **Never commit**: Keep tokens out of version control @@ -421,6 +422,13 @@ The following sets of tools are available (all are on by default): - **get_me** - Get my user profile - No parameters required +- **get_team_members** - Get team members + - `org`: Organization login (owner) that contains the team. (string, required) + - `team_slug`: Team slug (string, required) + +- **get_teams** - Get teams + - `user`: Username to get teams for. If not provided, uses the authenticated user. (string, optional) +
@@ -538,6 +546,9 @@ The following sets of tools are available (all are on by default): - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) +- **list_issue_types** - List available issue types + - `owner`: The organization owner of the repository (string, required) + - **list_issues** - List issues - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional) @@ -829,6 +840,10 @@ The following sets of tools are available (all are on by default): - `repo`: Repository name (string, required) - `sha`: Accepts optional commit SHA. If specified, it will be used instead of ref (string, optional) +- **get_latest_release** - Get latest release + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - **get_tag** - Get tag details - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) @@ -848,6 +863,12 @@ The following sets of tools are available (all are on by default): - `repo`: Repository name (string, required) - `sha`: Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA. (string, optional) +- **list_releases** - List releases + - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `repo`: Repository name (string, required) + - **list_tags** - List tags - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) @@ -1075,4 +1096,4 @@ The exported Go API of this module should currently be considered unstable, and ## License -This project is licensed under the terms of the MIT open source license. Please refer to [MIT](./LICENSE) for the full terms. \ No newline at end of file +This project is licensed under the terms of the MIT open source license. Please refer to [MIT](./LICENSE) for the full terms. diff --git a/github-mcp-server b/github-mcp-server new file mode 100755 index 000000000..864242c24 Binary files /dev/null and b/github-mcp-server differ diff --git a/go.mod b/go.mod index 5f114825d..a2a86feaf 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,6 @@ require ( github.com/josephburnett/jd v1.9.2 github.com/mark3labs/mcp-go v0.32.0 github.com/migueleliasweb/go-github-mock v1.3.0 - github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.10.0 diff --git a/go.sum b/go.sum index 64ce05453..33395e6bb 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,6 @@ github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 h1:cYCy18SHPKRkv github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0= github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= @@ -83,7 +81,6 @@ github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqj github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -98,7 +95,6 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 5079ab847..5fb9582b9 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "log" + "log/slog" "net/http" "net/url" "os" @@ -21,7 +22,6 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/shurcooL/githubv4" - "github.com/sirupsen/logrus" ) type MCPServerConfig struct { @@ -49,6 +49,8 @@ type MCPServerConfig struct { Translator translations.TranslationHelperFunc } +const stdioServerLogPrefix = "stdioserver" + func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { apiHost, err := parseAPIHost(cfg.Host) if err != nil { @@ -203,17 +205,22 @@ func RunStdioServer(cfg StdioServerConfig) error { stdioServer := server.NewStdioServer(ghServer) - logrusLogger := logrus.New() + var slogHandler slog.Handler + var logOutput io.Writer if cfg.LogFilePath != "" { file, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) if err != nil { return fmt.Errorf("failed to open log file: %w", err) } - - logrusLogger.SetLevel(logrus.DebugLevel) - logrusLogger.SetOutput(file) - } - stdLogger := log.New(logrusLogger.Writer(), "stdioserver", 0) + logOutput = file + slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelDebug}) + } else { + logOutput = os.Stderr + slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelInfo}) + } + logger := slog.New(slogHandler) + logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly) + stdLogger := log.New(logOutput, stdioServerLogPrefix, 0) stdioServer.SetErrorLogger(stdLogger) if cfg.ExportTranslations { @@ -227,7 +234,7 @@ func RunStdioServer(cfg StdioServerConfig) error { in, out := io.Reader(os.Stdin), io.Writer(os.Stdout) if cfg.EnableCommandLogging { - loggedIO := mcplog.NewIOLogger(in, out, logrusLogger) + loggedIO := mcplog.NewIOLogger(in, out, logger) in, out = loggedIO, loggedIO } // enable GitHub errors in the context @@ -241,9 +248,10 @@ func RunStdioServer(cfg StdioServerConfig) error { // Wait for shutdown signal select { case <-ctx.Done(): - logrusLogger.Infof("shutting down server...") + logger.Info("shutting down server", "signal", "context done") case err := <-errC: if err != nil { + logger.Error("error running server", "error", err) return fmt.Errorf("error running server: %w", err) } } diff --git a/pkg/github/__toolsnaps__/get_team_members.snap b/pkg/github/__toolsnaps__/get_team_members.snap new file mode 100644 index 000000000..2d91bb5ea --- /dev/null +++ b/pkg/github/__toolsnaps__/get_team_members.snap @@ -0,0 +1,25 @@ +{ + "annotations": { + "title": "Get team members", + "readOnlyHint": true + }, + "description": "Get member usernames of a specific team in an organization. Limited to organizations accessible with current credentials", + "inputSchema": { + "properties": { + "org": { + "description": "Organization login (owner) that contains the team.", + "type": "string" + }, + "team_slug": { + "description": "Team slug", + "type": "string" + } + }, + "required": [ + "org", + "team_slug" + ], + "type": "object" + }, + "name": "get_team_members" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_teams.snap b/pkg/github/__toolsnaps__/get_teams.snap new file mode 100644 index 000000000..39ed4db35 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_teams.snap @@ -0,0 +1,17 @@ +{ + "annotations": { + "title": "Get teams", + "readOnlyHint": true + }, + "description": "Get details of the teams the user is a member of. Limited to organizations accessible with current credentials", + "inputSchema": { + "properties": { + "user": { + "description": "Username to get teams for. If not provided, uses the authenticated user.", + "type": "string" + } + }, + "type": "object" + }, + "name": "get_teams" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_issue_types.snap b/pkg/github/__toolsnaps__/list_issue_types.snap new file mode 100644 index 000000000..93c3e51d9 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_issue_types.snap @@ -0,0 +1,20 @@ +{ + "annotations": { + "title": "List available issue types", + "readOnlyHint": true + }, + "description": "List supported issue types for repository owner (organization).", + "inputSchema": { + "properties": { + "owner": { + "description": "The organization owner of the repository", + "type": "string" + } + }, + "required": [ + "owner" + ], + "type": "object" + }, + "name": "list_issue_types" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_issues.snap b/pkg/github/__toolsnaps__/list_issues.snap index f63da9c85..5475988c2 100644 --- a/pkg/github/__toolsnaps__/list_issues.snap +++ b/pkg/github/__toolsnaps__/list_issues.snap @@ -29,7 +29,8 @@ "description": "Order issues by field. If provided, the 'direction' also needs to be provided.", "enum": [ "CREATED_AT", - "UPDATED_AT" + "UPDATED_AT", + "COMMENTS" ], "type": "string" }, diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 12bbb3394..38719f155 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -955,7 +955,9 @@ func CancelWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFu resp, err := client.Actions.CancelWorkflowRunByID(ctx, owner, repo, runID) if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to cancel workflow run", resp, err), nil + if _, ok := err.(*github.AcceptedError); !ok { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to cancel workflow run", resp, err), nil + } } defer func() { _ = resp.Body.Close() }() diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 58759dbd0..3d7521125 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -323,12 +323,14 @@ func Test_CancelWorkflowRun(t *testing.T) { { name: "successful workflow run cancellation", mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( + mock.WithRequestMatchHandler( mock.EndpointPattern{ Pattern: "/repos/owner/repo/actions/runs/12345/cancel", Method: "POST", }, - "", // Empty response body for 202 Accepted + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusAccepted) + }), ), ), requestArgs: map[string]any{ @@ -338,6 +340,27 @@ func Test_CancelWorkflowRun(t *testing.T) { }, expectError: false, }, + { + name: "conflict when cancelling a workflow run", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/repos/owner/repo/actions/runs/12345/cancel", + Method: "POST", + }, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "run_id": float64(12345), + }, + expectError: true, + expectedErrMsg: "failed to cancel workflow run", + }, { name: "missing required parameter run_id", mockedClient: mock.NewMockedHTTPClient(), @@ -369,7 +392,7 @@ func Test_CancelWorkflowRun(t *testing.T) { textContent := getTextResult(t, result) if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) + assert.Contains(t, textContent.Text, tc.expectedErrMsg) return } diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 9817fea7b..06642aa15 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -8,6 +8,7 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" + "github.com/shurcooL/githubv4" ) // UserDetails contains additional fields about a GitHub user not already @@ -90,3 +91,161 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Too return tool, handler } + +type TeamInfo struct { + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` +} + +type OrganizationTeams struct { + Org string `json:"org"` + Teams []TeamInfo `json:"teams"` +} + +func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool("get_teams", + mcp.WithDescription(t("TOOL_GET_TEAMS_DESCRIPTION", "Get details of the teams the user is a member of. Limited to organizations accessible with current credentials")), + mcp.WithString("user", + mcp.Description(t("TOOL_GET_TEAMS_USER_DESCRIPTION", "Username to get teams for. If not provided, uses the authenticated user.")), + ), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_TEAMS_TITLE", "Get teams"), + ReadOnlyHint: ToBoolPtr(true), + }), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + user, err := OptionalParam[string](request, "user") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + var username string + if user != "" { + username = user + } else { + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultErrorFromErr("failed to get GitHub client", err), nil + } + + userResp, res, err := client.Users.Get(ctx, "") + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get user", + res, + err, + ), nil + } + username = userResp.GetLogin() + } + + gqlClient, err := getGQLClient(ctx) + if err != nil { + return mcp.NewToolResultErrorFromErr("failed to get GitHub GQL client", err), nil + } + + var q struct { + User struct { + Organizations struct { + Nodes []struct { + Login githubv4.String + Teams struct { + Nodes []struct { + Name githubv4.String + Slug githubv4.String + Description githubv4.String + } + } `graphql:"teams(first: 100, userLogins: [$login])"` + } + } `graphql:"organizations(first: 100)"` + } `graphql:"user(login: $login)"` + } + vars := map[string]interface{}{ + "login": githubv4.String(username), + } + if err := gqlClient.Query(ctx, &q, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find teams", err), nil + } + + var organizations []OrganizationTeams + for _, org := range q.User.Organizations.Nodes { + orgTeams := OrganizationTeams{ + Org: string(org.Login), + Teams: make([]TeamInfo, 0, len(org.Teams.Nodes)), + } + + for _, team := range org.Teams.Nodes { + orgTeams.Teams = append(orgTeams.Teams, TeamInfo{ + Name: string(team.Name), + Slug: string(team.Slug), + Description: string(team.Description), + }) + } + + organizations = append(organizations, orgTeams) + } + + return MarshalledTextResult(organizations), nil + } +} + +func GetTeamMembers(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool("get_team_members", + mcp.WithDescription(t("TOOL_GET_TEAM_MEMBERS_DESCRIPTION", "Get member usernames of a specific team in an organization. Limited to organizations accessible with current credentials")), + mcp.WithString("org", + mcp.Description(t("TOOL_GET_TEAM_MEMBERS_ORG_DESCRIPTION", "Organization login (owner) that contains the team.")), + mcp.Required(), + ), + mcp.WithString("team_slug", + mcp.Description(t("TOOL_GET_TEAM_MEMBERS_TEAM_SLUG_DESCRIPTION", "Team slug")), + mcp.Required(), + ), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_TEAM_MEMBERS_TITLE", "Get team members"), + ReadOnlyHint: ToBoolPtr(true), + }), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + org, err := RequiredParam[string](request, "org") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + teamSlug, err := RequiredParam[string](request, "team_slug") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + gqlClient, err := getGQLClient(ctx) + if err != nil { + return mcp.NewToolResultErrorFromErr("failed to get GitHub GQL client", err), nil + } + + var q struct { + Organization struct { + Team struct { + Members struct { + Nodes []struct { + Login githubv4.String + } + } `graphql:"members(first: 100)"` + } `graphql:"team(slug: $teamSlug)"` + } `graphql:"organization(login: $org)"` + } + vars := map[string]interface{}{ + "org": githubv4.String(org), + "teamSlug": githubv4.String(teamSlug), + } + if err := gqlClient.Query(ctx, &q, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to get team members", err), nil + } + + var members []string + for _, member := range q.Organization.Team.Members.Nodes { + members = append(members, string(member.Login)) + } + + return MarshalledTextResult(members), nil + } +} diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index ca33f8493..641707a47 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -3,13 +3,16 @@ package github import ( "context" "encoding/json" + "fmt" "testing" "time" + "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v74/github" "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -139,3 +142,358 @@ func Test_GetMe(t *testing.T) { }) } } + +func Test_GetTeams(t *testing.T) { + t.Parallel() + + tool, _ := GetTeams(nil, nil, translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_teams", tool.Name) + assert.True(t, *tool.Annotations.ReadOnlyHint, "get_teams tool should be read-only") + + mockUser := &github.User{ + Login: github.Ptr("testuser"), + Name: github.Ptr("Test User"), + Email: github.Ptr("test@example.com"), + Bio: github.Ptr("GitHub user for testing"), + Company: github.Ptr("Test Company"), + Location: github.Ptr("Test Location"), + HTMLURL: github.Ptr("https://github.com/testuser"), + CreatedAt: &github.Timestamp{Time: time.Now().Add(-365 * 24 * time.Hour)}, + Type: github.Ptr("User"), + Hireable: github.Ptr(true), + TwitterUsername: github.Ptr("testuser_twitter"), + Plan: &github.Plan{ + Name: github.Ptr("pro"), + }, + } + + mockTeamsResponse := githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{ + "organizations": map[string]any{ + "nodes": []map[string]any{ + { + "login": "testorg1", + "teams": map[string]any{ + "nodes": []map[string]any{ + { + "name": "team1", + "slug": "team1", + "description": "Team 1", + }, + { + "name": "team2", + "slug": "team2", + "description": "Team 2", + }, + }, + }, + }, + { + "login": "testorg2", + "teams": map[string]any{ + "nodes": []map[string]any{ + { + "name": "team3", + "slug": "team3", + "description": "Team 3", + }, + }, + }, + }, + }, + }, + }, + }) + + mockNoTeamsResponse := githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{ + "organizations": map[string]any{ + "nodes": []map[string]any{}, + }, + }, + }) + + tests := []struct { + name string + stubbedGetClientFn GetClientFn + stubbedGetGQLClientFn GetGQLClientFn + requestArgs map[string]any + expectToolError bool + expectedToolErrMsg string + expectedTeamsCount int + }{ + { + name: "successful get teams", + stubbedGetClientFn: stubGetClientFromHTTPFn( + mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUser, + mockUser, + ), + ), + ), + stubbedGetGQLClientFn: func(_ context.Context) (*githubv4.Client, error) { + queryStr := "query($login:String!){user(login: $login){organizations(first: 100){nodes{login,teams(first: 100, userLogins: [$login]){nodes{name,slug,description}}}}}}" + vars := map[string]interface{}{ + "login": "testuser", + } + matcher := githubv4mock.NewQueryMatcher(queryStr, vars, mockTeamsResponse) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + return githubv4.NewClient(httpClient), nil + }, + requestArgs: map[string]any{}, + expectToolError: false, + expectedTeamsCount: 2, + }, + { + name: "successful get teams for specific user", + stubbedGetClientFn: nil, + stubbedGetGQLClientFn: func(_ context.Context) (*githubv4.Client, error) { + queryStr := "query($login:String!){user(login: $login){organizations(first: 100){nodes{login,teams(first: 100, userLogins: [$login]){nodes{name,slug,description}}}}}}" + vars := map[string]interface{}{ + "login": "specificuser", + } + matcher := githubv4mock.NewQueryMatcher(queryStr, vars, mockTeamsResponse) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + return githubv4.NewClient(httpClient), nil + }, + requestArgs: map[string]any{ + "user": "specificuser", + }, + expectToolError: false, + expectedTeamsCount: 2, + }, + { + name: "no teams found", + stubbedGetClientFn: stubGetClientFromHTTPFn( + mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUser, + mockUser, + ), + ), + ), + stubbedGetGQLClientFn: func(_ context.Context) (*githubv4.Client, error) { + queryStr := "query($login:String!){user(login: $login){organizations(first: 100){nodes{login,teams(first: 100, userLogins: [$login]){nodes{name,slug,description}}}}}}" + vars := map[string]interface{}{ + "login": "testuser", + } + matcher := githubv4mock.NewQueryMatcher(queryStr, vars, mockNoTeamsResponse) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + return githubv4.NewClient(httpClient), nil + }, + requestArgs: map[string]any{}, + expectToolError: false, + expectedTeamsCount: 0, + }, + { + name: "getting client fails", + stubbedGetClientFn: stubGetClientFnErr("expected test error"), + stubbedGetGQLClientFn: nil, + requestArgs: map[string]any{}, + expectToolError: true, + expectedToolErrMsg: "failed to get GitHub client: expected test error", + }, + { + name: "get user fails", + stubbedGetClientFn: stubGetClientFromHTTPFn( + mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetUser, + badRequestHandler("expected test failure"), + ), + ), + ), + stubbedGetGQLClientFn: nil, + requestArgs: map[string]any{}, + expectToolError: true, + expectedToolErrMsg: "expected test failure", + }, + { + name: "getting GraphQL client fails", + stubbedGetClientFn: stubGetClientFromHTTPFn( + mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUser, + mockUser, + ), + ), + ), + stubbedGetGQLClientFn: func(_ context.Context) (*githubv4.Client, error) { + return nil, fmt.Errorf("GraphQL client error") + }, + requestArgs: map[string]any{}, + expectToolError: true, + expectedToolErrMsg: "failed to get GitHub GQL client: GraphQL client error", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, handler := GetTeams(tc.stubbedGetClientFn, tc.stubbedGetGQLClientFn, translations.NullTranslationHelper) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + require.NoError(t, err) + textContent := getTextResult(t, result) + + if tc.expectToolError { + assert.True(t, result.IsError, "expected tool call result to be an error") + assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) + return + } + + var organizations []OrganizationTeams + err = json.Unmarshal([]byte(textContent.Text), &organizations) + require.NoError(t, err) + + assert.Len(t, organizations, tc.expectedTeamsCount) + + if tc.expectedTeamsCount > 0 { + assert.Equal(t, "testorg1", organizations[0].Org) + assert.Len(t, organizations[0].Teams, 2) + assert.Equal(t, "team1", organizations[0].Teams[0].Name) + assert.Equal(t, "team1", organizations[0].Teams[0].Slug) + assert.Equal(t, "Team 1", organizations[0].Teams[0].Description) + + if tc.expectedTeamsCount > 1 { + assert.Equal(t, "testorg2", organizations[1].Org) + assert.Len(t, organizations[1].Teams, 1) + assert.Equal(t, "team3", organizations[1].Teams[0].Name) + assert.Equal(t, "team3", organizations[1].Teams[0].Slug) + assert.Equal(t, "Team 3", organizations[1].Teams[0].Description) + } + } + }) + } +} + +func Test_GetTeamMembers(t *testing.T) { + t.Parallel() + + tool, _ := GetTeamMembers(nil, translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_team_members", tool.Name) + assert.True(t, *tool.Annotations.ReadOnlyHint, "get_team_members tool should be read-only") + + mockTeamMembersResponse := githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "team": map[string]any{ + "members": map[string]any{ + "nodes": []map[string]any{ + { + "login": "user1", + }, + { + "login": "user2", + }, + }, + }, + }, + }, + }) + + mockNoMembersResponse := githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "team": map[string]any{ + "members": map[string]any{ + "nodes": []map[string]any{}, + }, + }, + }, + }) + + tests := []struct { + name string + stubbedGetGQLClientFn GetGQLClientFn + requestArgs map[string]any + expectToolError bool + expectedToolErrMsg string + expectedMembersCount int + }{ + { + name: "successful get team members", + stubbedGetGQLClientFn: func(_ context.Context) (*githubv4.Client, error) { + queryStr := "query($org:String!$teamSlug:String!){organization(login: $org){team(slug: $teamSlug){members(first: 100){nodes{login}}}}}" + vars := map[string]interface{}{ + "org": "testorg", + "teamSlug": "testteam", + } + matcher := githubv4mock.NewQueryMatcher(queryStr, vars, mockTeamMembersResponse) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + return githubv4.NewClient(httpClient), nil + }, + requestArgs: map[string]any{ + "org": "testorg", + "team_slug": "testteam", + }, + expectToolError: false, + expectedMembersCount: 2, + }, + { + name: "team with no members", + stubbedGetGQLClientFn: func(_ context.Context) (*githubv4.Client, error) { + queryStr := "query($org:String!$teamSlug:String!){organization(login: $org){team(slug: $teamSlug){members(first: 100){nodes{login}}}}}" + vars := map[string]interface{}{ + "org": "testorg", + "teamSlug": "emptyteam", + } + matcher := githubv4mock.NewQueryMatcher(queryStr, vars, mockNoMembersResponse) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + return githubv4.NewClient(httpClient), nil + }, + requestArgs: map[string]any{ + "org": "testorg", + "team_slug": "emptyteam", + }, + expectToolError: false, + expectedMembersCount: 0, + }, + { + name: "getting GraphQL client fails", + stubbedGetGQLClientFn: func(_ context.Context) (*githubv4.Client, error) { + return nil, fmt.Errorf("GraphQL client error") + }, + requestArgs: map[string]any{ + "org": "testorg", + "team_slug": "testteam", + }, + expectToolError: true, + expectedToolErrMsg: "failed to get GitHub GQL client: GraphQL client error", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, handler := GetTeamMembers(tc.stubbedGetGQLClientFn, translations.NullTranslationHelper) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + require.NoError(t, err) + textContent := getTextResult(t, result) + + if tc.expectToolError { + assert.True(t, result.IsError, "expected tool call result to be an error") + assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) + return + } + + var members []string + err = json.Unmarshal([]byte(textContent.Text), &members) + require.NoError(t, err) + + assert.Len(t, members, tc.expectedMembersCount) + + if tc.expectedMembersCount > 0 { + assert.Equal(t, "user1", members[0]) + + if tc.expectedMembersCount > 1 { + assert.Equal(t, "user2", members[1]) + } + } + }) + } +} diff --git a/pkg/github/issues.go b/pkg/github/issues.go index ad0a0749b..3a1440489 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -38,6 +38,9 @@ type IssueFragment struct { Description githubv4.String } } `graphql:"labels(first: 100)"` + Comments struct { + TotalCount githubv4.Int + } `graphql:"comments"` } // Common interface for all issue query types @@ -133,10 +136,11 @@ func fragmentToIssue(fragment IssueFragment) *github.Issue { User: &github.User{ Login: github.Ptr(string(fragment.Author.Login)), }, - State: github.Ptr(string(fragment.State)), - ID: github.Ptr(fragment.DatabaseID), - Body: github.Ptr(string(fragment.Body)), - Labels: foundLabels, + State: github.Ptr(string(fragment.State)), + ID: github.Ptr(fragment.DatabaseID), + Body: github.Ptr(string(fragment.Body)), + Labels: foundLabels, + Comments: github.Ptr(int(fragment.Comments.TotalCount)), } } @@ -202,6 +206,53 @@ func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool } } +// ListIssueTypes creates a tool to list defined issue types for an organization. This can be used to understand supported issue type values for creating or updating issues. +func ListIssueTypes(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + + return mcp.NewTool("list_issue_types", + mcp.WithDescription(t("TOOL_LIST_ISSUE_TYPES_FOR_ORG", "List supported issue types for repository owner (organization).")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_ISSUE_TYPES_USER_TITLE", "List available issue types"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The organization owner of the repository"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + issueTypes, resp, err := client.Organizations.ListIssueTypes(ctx, owner) + if err != nil { + return nil, fmt.Errorf("failed to list issue types: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to list issue types: %s", string(body))), nil + } + + r, err := json.Marshal(issueTypes) + if err != nil { + return nil, fmt.Errorf("failed to marshal issue types: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + // AddIssueComment creates a tool to add a comment to an issue. func AddIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("add_issue_comment", @@ -502,67 +553,39 @@ func RemoveSubIssue(getClient GetClientFn, t translations.TranslationHelperFunc) } client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - // Create the request body - requestBody := map[string]interface{}{ - "sub_issue_id": subIssueID, - } - reqBodyBytes, err := json.Marshal(requestBody) - if err != nil { - return nil, fmt.Errorf("failed to marshal request body: %w", err) - } - - // Create the HTTP request - url := fmt.Sprintf("%srepos/%s/%s/issues/%d/sub_issue", - client.BaseURL.String(), owner, repo, issueNumber) - req, err := http.NewRequestWithContext(ctx, "DELETE", url, strings.NewReader(string(reqBodyBytes))) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-GitHub-Api-Version", "2022-11-28") - - httpClient := client.Client() // Use authenticated GitHub client - resp, err := httpClient.Do(req) - if err != nil { - var ghResp *github.Response - if resp != nil { - ghResp = &github.Response{Response: resp} - } - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to remove sub-issue", - ghResp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return mcp.NewToolResultError(fmt.Sprintf("failed to remove sub-issue: %s", string(body))), nil - } - - // Parse and re-marshal to ensure consistent formatting - var result interface{} - if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("failed to unmarshal response: %w", err) - } - - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + subIssueRequest := github.SubIssueRequest{ + SubIssueID: int64(subIssueID), + } + + subIssue, resp, err := client.SubIssue.Remove(ctx, owner, repo, int64(issueNumber), subIssueRequest) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to remove sub-issue", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to remove sub-issue: %s", string(body))), nil + } + + r, err := json.Marshal(subIssue) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } } // ReprioritizeSubIssue creates a tool to reprioritize a sub-issue to a different position in the parent list. @@ -875,7 +898,7 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun ), mcp.WithString("orderBy", mcp.Description("Order issues by field. If provided, the 'direction' also needs to be provided."), - mcp.Enum("CREATED_AT", "UPDATED_AT"), + mcp.Enum("CREATED_AT", "UPDATED_AT", "COMMENTS"), ), mcp.WithString("direction", mcp.Description("Order direction. If provided, the 'orderBy' also needs to be provided."), diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 2a530ef48..249fadef8 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "testing" "time" @@ -410,6 +411,100 @@ func Test_SearchIssues(t *testing.T) { expectError: false, expectedResult: mockSearchResult, }, + { + name: "query with existing is:issue filter - no duplication", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchIssues, + expectQueryParams( + t, + map[string]string{ + "q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "query with existing repo: filter and conflicting owner/repo params - uses query filter", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchIssues, + expectQueryParams( + t, + map[string]string{ + "q": "is:issue repo:github/github-mcp-server critical", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "repo:github/github-mcp-server critical", + "owner": "different-owner", + "repo": "different-repo", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "query with both is: and repo: filters already present", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchIssues, + expectQueryParams( + t, + map[string]string{ + "q": "is:issue repo:octocat/Hello-World bug", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "is:issue repo:octocat/Hello-World bug", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "complex query with multiple OR operators and existing filters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchIssues, + expectQueryParams( + t, + map[string]string{ + "q": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)", + }, + expectError: false, + expectedResult: mockSearchResult, + }, { name: "search issues fails", mockedClient: mock.NewMockedHTTPClient( @@ -681,6 +776,9 @@ func Test_ListIssues(t *testing.T) { {"name": "bug", "id": "label1", "description": "Bug label"}, }, }, + "comments": map[string]any{ + "totalCount": 5, + }, }, { "number": 456, @@ -696,6 +794,9 @@ func Test_ListIssues(t *testing.T) { {"name": "enhancement", "id": "label2", "description": "Enhancement label"}, }, }, + "comments": map[string]any{ + "totalCount": 3, + }, }, } @@ -713,6 +814,9 @@ func Test_ListIssues(t *testing.T) { "labels": map[string]any{ "nodes": []map[string]any{}, }, + "comments": map[string]any{ + "totalCount": 1, + }, }, } @@ -875,8 +979,8 @@ func Test_ListIssues(t *testing.T) { } // Define the actual query strings that match the implementation - qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" - qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" + qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" + qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -2723,3 +2827,146 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }) } } + +func Test_ListIssueTypes(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListIssueTypes(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_issue_types", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner"}) + + // Setup mock issue types for success case + mockIssueTypes := []*github.IssueType{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("bug"), + Description: github.Ptr("Something isn't working"), + Color: github.Ptr("d73a4a"), + }, + { + ID: github.Ptr(int64(2)), + Name: github.Ptr("feature"), + Description: github.Ptr("New feature or enhancement"), + Color: github.Ptr("a2eeef"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedIssueTypes []*github.IssueType + expectedErrMsg string + }{ + { + name: "successful issue types retrieval", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/orgs/testorg/issue-types", + Method: "GET", + }, + mockResponse(t, http.StatusOK, mockIssueTypes), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "testorg", + }, + expectError: false, + expectedIssueTypes: mockIssueTypes, + }, + { + name: "organization not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/orgs/nonexistent/issue-types", + Method: "GET", + }, + mockResponse(t, http.StatusNotFound, `{"message": "Organization not found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "nonexistent", + }, + expectError: true, + expectedErrMsg: "failed to list issue types", + }, + { + name: "missing owner parameter", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/orgs/testorg/issue-types", + Method: "GET", + }, + mockResponse(t, http.StatusOK, mockIssueTypes), + ), + ), + requestArgs: map[string]interface{}{}, + expectError: false, // This should be handled by parameter validation, error returned in result + expectedErrMsg: "missing required parameter: owner", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListIssueTypes(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + if err != nil { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + // Check if error is returned as tool result error + require.NotNil(t, result) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + // Check if it's a parameter validation error (returned as tool result error) + if result != nil && result.IsError { + errorContent := getErrorResult(t, result) + if tc.expectedErrMsg != "" && strings.Contains(errorContent.Text, tc.expectedErrMsg) { + return // This is expected for parameter validation errors + } + } + + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, result.IsError) + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedIssueTypes []*github.IssueType + err = json.Unmarshal([]byte(textContent.Text), &returnedIssueTypes) + require.NoError(t, err) + + if tc.expectedIssueTypes != nil { + require.Equal(t, len(tc.expectedIssueTypes), len(returnedIssueTypes)) + for i, expected := range tc.expectedIssueTypes { + assert.Equal(t, *expected.Name, *returnedIssueTypes[i].Name) + assert.Equal(t, *expected.Description, *returnedIssueTypes[i].Description) + assert.Equal(t, *expected.Color, *returnedIssueTypes[i].Color) + assert.Equal(t, *expected.ID, *returnedIssueTypes[i].ID) + } + } + }) + } +} diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index f759885ee..ed6921477 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -1030,6 +1030,77 @@ func Test_SearchPullRequests(t *testing.T) { expectError: false, expectedResult: mockSearchResult, }, + { + name: "query with existing is:pr filter - no duplication", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchIssues, + expectQueryParams( + t, + map[string]string{ + "q": "is:pr repo:github/github-mcp-server is:open draft:false", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "is:pr repo:github/github-mcp-server is:open draft:false", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "query with existing repo: filter and conflicting owner/repo params - uses query filter", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchIssues, + expectQueryParams( + t, + map[string]string{ + "q": "is:pr repo:github/github-mcp-server author:octocat", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "repo:github/github-mcp-server author:octocat", + "owner": "different-owner", + "repo": "different-repo", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "complex query with existing is:pr filter and OR operators", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchIssues, + expectQueryParams( + t, + map[string]string{ + "q": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)", + }, + expectError: false, + expectedResult: mockSearchResult, + }, { name: "search pull requests fails", mockedClient: mock.NewMockedHTTPClient( diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 5cb7769b0..0925829a1 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -1321,6 +1321,126 @@ func GetTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool m } } +// ListReleases creates a tool to list releases in a GitHub repository. +func ListReleases(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_releases", + mcp.WithDescription(t("TOOL_LIST_RELEASES_DESCRIPTION", "List releases in a GitHub repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_RELEASES_USER_TITLE", "List releases"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + WithPagination(), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + releases, resp, err := client.Repositories.ListReleases(ctx, owner, repo, opts) + if err != nil { + return nil, fmt.Errorf("failed to list releases: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to list releases: %s", string(body))), nil + } + + r, err := json.Marshal(releases) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// GetLatestRelease creates a tool to get the latest release in a GitHub repository. +func GetLatestRelease(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_latest_release", + mcp.WithDescription(t("TOOL_GET_LATEST_RELEASE_DESCRIPTION", "Get the latest release in a GitHub repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_LATEST_RELEASE_USER_TITLE", "Get latest release"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + release, resp, err := client.Repositories.GetLatestRelease(ctx, owner, repo) + if err != nil { + return nil, fmt.Errorf("failed to get latest release: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to get latest release: %s", string(body))), nil + } + + r, err := json.Marshal(release) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + // filterPaths filters the entries in a GitHub tree to find paths that // match the given suffix. // maxResults limits the number of results returned to first maxResults entries, @@ -1358,36 +1478,100 @@ func filterPaths(entries []*github.TreeEntry, path string, maxResults int) []str return matchedPaths } -// resolveGitReference resolves git references with the following logic: -// 1. If SHA is provided, it takes precedence -// 2. If neither is provided, use the default branch as ref -// 3. Get commit SHA from the ref -// Refs can look like `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head` -// The function returns the resolved ref, commit SHA and any error. +// resolveGitReference takes a user-provided ref and sha and resolves them into a +// definitive commit SHA and its corresponding fully-qualified reference. +// +// The resolution logic follows a clear priority: +// +// 1. If a specific commit `sha` is provided, it takes precedence and is used directly, +// and all reference resolution is skipped. +// +// 2. If no `sha` is provided, the function resolves the `ref` +// string into a fully-qualified format (e.g., "refs/heads/main") by trying +// the following steps in order: +// a). **Empty Ref:** If `ref` is empty, the repository's default branch is used. +// b). **Fully-Qualified:** If `ref` already starts with "refs/", it's considered fully +// qualified and used as-is. +// c). **Partially-Qualified:** If `ref` starts with "heads/" or "tags/", it is +// prefixed with "refs/" to make it fully-qualified. +// d). **Short Name:** Otherwise, the `ref` is treated as a short name. The function +// first attempts to resolve it as a branch ("refs/heads/"). If that +// returns a 404 Not Found error, it then attempts to resolve it as a tag +// ("refs/tags/"). +// +// 3. **Final Lookup:** Once a fully-qualified ref is determined, a final API call +// is made to fetch that reference's definitive commit SHA. +// +// Any unexpected (non-404) errors during the resolution process are returned +// immediately. All API errors are logged with rich context to aid diagnostics. func resolveGitReference(ctx context.Context, githubClient *github.Client, owner, repo, ref, sha string) (*raw.ContentOpts, error) { - // 1. If SHA is provided, use it directly + // 1) If SHA explicitly provided, it's the highest priority. if sha != "" { return &raw.ContentOpts{Ref: "", SHA: sha}, nil } - // 2. If neither provided, use the default branch as ref - if ref == "" { + originalRef := ref // Keep original ref for clearer error messages down the line. + + // 2) If no SHA is provided, we try to resolve the ref into a fully-qualified format. + var reference *github.Reference + var resp *github.Response + var err error + + switch { + case originalRef == "": + // 2a) If ref is empty, determine the default branch. repoInfo, resp, err := githubClient.Repositories.Get(ctx, owner, repo) if err != nil { _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get repository info", resp, err) return nil, fmt.Errorf("failed to get repository info: %w", err) } ref = fmt.Sprintf("refs/heads/%s", repoInfo.GetDefaultBranch()) + case strings.HasPrefix(originalRef, "refs/"): + // 2b) Already fully qualified. The reference will be fetched at the end. + case strings.HasPrefix(originalRef, "heads/") || strings.HasPrefix(originalRef, "tags/"): + // 2c) Partially qualified. Make it fully qualified. + ref = "refs/" + originalRef + default: + // 2d) It's a short name, so we try to resolve it to either a branch or a tag. + branchRef := "refs/heads/" + originalRef + reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, branchRef) + + if err == nil { + ref = branchRef // It's a branch. + } else { + // The branch lookup failed. Check if it was a 404 Not Found error. + ghErr, isGhErr := err.(*github.ErrorResponse) + if isGhErr && ghErr.Response.StatusCode == http.StatusNotFound { + tagRef := "refs/tags/" + originalRef + reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, tagRef) + if err == nil { + ref = tagRef // It's a tag. + } else { + // The tag lookup also failed. Check if it was a 404 Not Found error. + ghErr2, isGhErr2 := err.(*github.ErrorResponse) + if isGhErr2 && ghErr2.Response.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("could not resolve ref %q as a branch or a tag", originalRef) + } + // The tag lookup failed for a different reason. + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (tag)", resp, err) + return nil, fmt.Errorf("failed to get reference for tag '%s': %w", originalRef, err) + } + } else { + // The branch lookup failed for a different reason. + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (branch)", resp, err) + return nil, fmt.Errorf("failed to get reference for branch '%s': %w", originalRef, err) + } + } } - // 3. Get the SHA from the ref - reference, resp, err := githubClient.Git.GetRef(ctx, owner, repo, ref) - if err != nil { - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference", resp, err) - return nil, fmt.Errorf("failed to get reference: %w", err) + if reference == nil { + reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, ref) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err) + return nil, fmt.Errorf("failed to get final reference for %q: %w", ref, err) + } } - sha = reference.GetObject().GetSHA() - // Use provided ref, or it will be empty which defaults to the default branch + sha = reference.GetObject().GetSHA() return &raw.ContentOpts{Ref: ref, SHA: sha}, nil } diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 2e522b426..63e577600 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "net/http" "net/url" + "strings" "testing" "time" @@ -2113,6 +2114,179 @@ func Test_GetTag(t *testing.T) { } } +func Test_ListReleases(t *testing.T) { + mockClient := github.NewClient(nil) + tool, _ := ListReleases(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "list_releases", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + mockReleases := []*github.RepositoryRelease{ + { + ID: github.Ptr(int64(1)), + TagName: github.Ptr("v1.0.0"), + Name: github.Ptr("First Release"), + }, + { + ID: github.Ptr(int64(2)), + TagName: github.Ptr("v0.9.0"), + Name: github.Ptr("Beta Release"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedResult []*github.RepositoryRelease + expectedErrMsg string + }{ + { + name: "successful releases list", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposReleasesByOwnerByRepo, + mockReleases, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedResult: mockReleases, + }, + { + name: "releases list fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposReleasesByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "failed to list releases", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := ListReleases(stubGetClientFn(client), translations.NullTranslationHelper) + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + textContent := getTextResult(t, result) + var returnedReleases []*github.RepositoryRelease + err = json.Unmarshal([]byte(textContent.Text), &returnedReleases) + require.NoError(t, err) + assert.Len(t, returnedReleases, len(tc.expectedResult)) + for i, rel := range returnedReleases { + assert.Equal(t, *tc.expectedResult[i].TagName, *rel.TagName) + } + }) + } +} +func Test_GetLatestRelease(t *testing.T) { + mockClient := github.NewClient(nil) + tool, _ := GetLatestRelease(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "get_latest_release", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + mockRelease := &github.RepositoryRelease{ + ID: github.Ptr(int64(1)), + TagName: github.Ptr("v1.0.0"), + Name: github.Ptr("First Release"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedResult *github.RepositoryRelease + expectedErrMsg string + }{ + { + name: "successful latest release fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposReleasesLatestByOwnerByRepo, + mockRelease, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedResult: mockRelease, + }, + { + name: "latest release fetch fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposReleasesLatestByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "failed to get latest release", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := GetLatestRelease(stubGetClientFn(client), translations.NullTranslationHelper) + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + textContent := getTextResult(t, result) + var returnedRelease github.RepositoryRelease + err = json.Unmarshal([]byte(textContent.Text), &returnedRelease) + require.NoError(t, err) + assert.Equal(t, *tc.expectedResult.TagName, *returnedRelease.TagName) + }) + } +} + func Test_filterPaths(t *testing.T) { tests := []struct { name string @@ -2212,63 +2386,239 @@ func Test_resolveGitReference(t *testing.T) { ctx := context.Background() owner := "owner" repo := "repo" - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"name": "repo", "default_branch": "main"}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": "123sha456"}}`)) - }), - ), - ) tests := []struct { name string ref string sha string + mockSetup func() *http.Client expectedOutput *raw.ContentOpts + expectError bool + errorContains string }{ { name: "sha takes precedence over ref", ref: "refs/heads/main", sha: "123sha456", + mockSetup: func() *http.Client { + // No API calls should be made when SHA is provided + return mock.NewMockedHTTPClient() + }, expectedOutput: &raw.ContentOpts{ SHA: "123sha456", }, + expectError: false, }, { name: "use default branch if ref and sha both empty", ref: "", sha: "", + mockSetup: func() *http.Client { + return mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"name": "repo", "default_branch": "main"}`)) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Path, "/git/ref/heads/main") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": "main-sha"}}`)) + }), + ), + ) + }, expectedOutput: &raw.ContentOpts{ Ref: "refs/heads/main", - SHA: "123sha456", + SHA: "main-sha", }, + expectError: false, }, { - name: "get SHA from ref", - ref: "refs/heads/main", + name: "fully qualified ref passed through unchanged", + ref: "refs/heads/feature-branch", sha: "", + mockSetup: func() *http.Client { + return mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Path, "/git/ref/heads/feature-branch") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/heads/feature-branch", "object": {"sha": "feature-sha"}}`)) + }), + ), + ) + }, + expectedOutput: &raw.ContentOpts{ + Ref: "refs/heads/feature-branch", + SHA: "feature-sha", + }, + expectError: false, + }, + { + name: "short branch name resolves to refs/heads/", + ref: "main", + sha: "", + mockSetup: func() *http.Client { + return mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/git/ref/heads/main") { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": "main-sha"}}`)) + } else { + t.Errorf("Unexpected path: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + }), + ), + ) + }, expectedOutput: &raw.ContentOpts{ Ref: "refs/heads/main", - SHA: "123sha456", + SHA: "main-sha", + }, + expectError: false, + }, + { + name: "short tag name falls back to refs/tags/ when branch not found", + ref: "v1.0.0", + sha: "", + mockSetup: func() *http.Client { + return mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/git/ref/heads/v1.0.0"): + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + case strings.Contains(r.URL.Path, "/git/ref/tags/v1.0.0"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/tags/v1.0.0", "object": {"sha": "tag-sha"}}`)) + default: + t.Errorf("Unexpected path: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + }), + ), + ) + }, + expectedOutput: &raw.ContentOpts{ + Ref: "refs/tags/v1.0.0", + SHA: "tag-sha", + }, + expectError: false, + }, + { + name: "heads/ prefix gets refs/ prepended", + ref: "heads/feature-branch", + sha: "", + mockSetup: func() *http.Client { + return mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Path, "/git/ref/heads/feature-branch") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/heads/feature-branch", "object": {"sha": "feature-sha"}}`)) + }), + ), + ) }, + expectedOutput: &raw.ContentOpts{ + Ref: "refs/heads/feature-branch", + SHA: "feature-sha", + }, + expectError: false, + }, + { + name: "tags/ prefix gets refs/ prepended", + ref: "tags/v1.0.0", + sha: "", + mockSetup: func() *http.Client { + return mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Path, "/git/ref/tags/v1.0.0") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/tags/v1.0.0", "object": {"sha": "tag-sha"}}`)) + }), + ), + ) + }, + expectedOutput: &raw.ContentOpts{ + Ref: "refs/tags/v1.0.0", + SHA: "tag-sha", + }, + expectError: false, + }, + { + name: "invalid short name that doesn't exist as branch or tag", + ref: "nonexistent", + sha: "", + mockSetup: func() *http.Client { + return mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // Both branch and tag attempts should return 404 + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ) + }, + expectError: true, + errorContains: "could not resolve ref \"nonexistent\" as a branch or a tag", + }, + { + name: "fully qualified pull request ref", + ref: "refs/pull/123/head", + sha: "", + mockSetup: func() *http.Client { + return mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.Path, "/git/ref/pull/123/head") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/pull/123/head", "object": {"sha": "pr-sha"}}`)) + }), + ), + ) + }, + expectedOutput: &raw.ContentOpts{ + Ref: "refs/pull/123/head", + SHA: "pr-sha", + }, + expectError: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(mockedClient) + client := github.NewClient(tc.mockSetup()) opts, err := resolveGitReference(ctx, client, owner, repo, tc.ref, tc.sha) + + if tc.expectError { + require.Error(t, err) + if tc.errorContains != "" { + assert.Contains(t, err.Error(), tc.errorContains) + } + return + } + require.NoError(t, err) + require.NotNil(t, opts) if tc.expectedOutput.SHA != "" { assert.Equal(t, tc.expectedOutput.SHA, opts.SHA) diff --git a/pkg/github/search.go b/pkg/github/search.go index 4fe390f86..248f17e17 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -204,7 +204,10 @@ func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHand return nil, fmt.Errorf("failed to get GitHub client: %w", err) } - searchQuery := "type:" + accountType + " " + query + searchQuery := query + if !hasTypeFilter(query) { + searchQuery = "type:" + accountType + " " + query + } result, resp, err := client.Search.Users(ctx, searchQuery, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index 66b57a8d4..cfc87c02b 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -410,6 +410,46 @@ func Test_SearchUsers(t *testing.T) { expectError: false, expectedResult: mockSearchResult, }, + { + name: "query with existing type:user filter - no duplication", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchUsers, + expectQueryParams(t, map[string]string{ + "q": "type:user location:seattle followers:>100", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "type:user location:seattle followers:>100", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "complex query with existing type:user filter and OR operators", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchUsers, + expectQueryParams(t, map[string]string{ + "q": "type:user (location:seattle OR location:california) followers:>50", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "type:user (location:seattle OR location:california) followers:>50", + }, + expectError: false, + expectedResult: mockSearchResult, + }, { name: "search users fails", mockedClient: mock.NewMockedHTTPClient( @@ -537,6 +577,46 @@ func Test_SearchOrgs(t *testing.T) { expectError: false, expectedResult: mockSearchResult, }, + { + name: "query with existing type:org filter - no duplication", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchUsers, + expectQueryParams(t, map[string]string{ + "q": "type:org location:california followers:>1000", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "type:org location:california followers:>1000", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "complex query with existing type:org filter and OR operators", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetSearchUsers, + expectQueryParams(t, map[string]string{ + "q": "type:org (location:seattle OR location:california OR location:newyork) repos:>10", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + ), + ), + requestArgs: map[string]interface{}{ + "query": "type:org (location:seattle OR location:california OR location:newyork) repos:>10", + }, + expectError: false, + expectedResult: mockSearchResult, + }, { name: "org search fails", mockedClient: mock.NewMockedHTTPClient( diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go index 014b57249..159518c91 100644 --- a/pkg/github/search_utils.go +++ b/pkg/github/search_utils.go @@ -6,11 +6,35 @@ import ( "fmt" "io" "net/http" + "regexp" "github.com/google/go-github/v74/github" "github.com/mark3labs/mcp-go/mcp" ) +func hasFilter(query, filterType string) bool { + // Match filter at start of string, after whitespace, or after non-word characters like '(' + pattern := fmt.Sprintf(`(^|\s|\W)%s:\S+`, regexp.QuoteMeta(filterType)) + matched, _ := regexp.MatchString(pattern, query) + return matched +} + +func hasSpecificFilter(query, filterType, filterValue string) bool { + // Match specific filter:value at start, after whitespace, or after non-word characters + // End with word boundary, whitespace, or non-word characters like ')' + pattern := fmt.Sprintf(`(^|\s|\W)%s:%s($|\s|\W)`, regexp.QuoteMeta(filterType), regexp.QuoteMeta(filterValue)) + matched, _ := regexp.MatchString(pattern, query) + return matched +} + +func hasRepoFilter(query string) bool { + return hasFilter(query, "repo") +} + +func hasTypeFilter(query string) bool { + return hasFilter(query, "type") +} + func searchHandler( ctx context.Context, getClient GetClientFn, @@ -22,7 +46,10 @@ func searchHandler( if err != nil { return mcp.NewToolResultError(err.Error()), nil } - query = fmt.Sprintf("is:%s %s", searchType, query) + + if !hasSpecificFilter(query, "is", searchType) { + query = fmt.Sprintf("is:%s %s", searchType, query) + } owner, err := OptionalParam[string](request, "owner") if err != nil { @@ -34,7 +61,7 @@ func searchHandler( return mcp.NewToolResultError(err.Error()), nil } - if owner != "" && repo != "" { + if owner != "" && repo != "" && !hasRepoFilter(query) { query = fmt.Sprintf("repo:%s/%s %s", owner, repo, query) } diff --git a/pkg/github/search_utils_test.go b/pkg/github/search_utils_test.go new file mode 100644 index 000000000..85f953eed --- /dev/null +++ b/pkg/github/search_utils_test.go @@ -0,0 +1,352 @@ +package github + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_hasFilter(t *testing.T) { + tests := []struct { + name string + query string + filterType string + expected bool + }{ + { + name: "query has is:issue filter", + query: "is:issue bug report", + filterType: "is", + expected: true, + }, + { + name: "query has repo: filter", + query: "repo:github/github-mcp-server critical bug", + filterType: "repo", + expected: true, + }, + { + name: "query has multiple is: filters", + query: "is:issue is:open bug", + filterType: "is", + expected: true, + }, + { + name: "query has filter at the beginning", + query: "is:issue some text", + filterType: "is", + expected: true, + }, + { + name: "query has filter in the middle", + query: "some text is:issue more text", + filterType: "is", + expected: true, + }, + { + name: "query has filter at the end", + query: "some text is:issue", + filterType: "is", + expected: true, + }, + { + name: "query does not have the filter", + query: "bug report critical", + filterType: "is", + expected: false, + }, + { + name: "query has similar text but not the filter", + query: "this issue is important", + filterType: "is", + expected: false, + }, + { + name: "empty query", + query: "", + filterType: "is", + expected: false, + }, + { + name: "query has label: filter but looking for is:", + query: "label:bug critical", + filterType: "is", + expected: false, + }, + { + name: "query has author: filter", + query: "author:octocat bug", + filterType: "author", + expected: true, + }, + { + name: "query with complex OR expression", + query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)", + filterType: "is", + expected: true, + }, + { + name: "query with complex OR expression checking repo", + query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)", + filterType: "repo", + expected: true, + }, + { + name: "filter in parentheses at start", + query: "(label:bug OR owner:bob) is:issue", + filterType: "label", + expected: true, + }, + { + name: "filter after opening parenthesis", + query: "is:issue (label:critical OR repo:test/test)", + filterType: "label", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasFilter(tt.query, tt.filterType) + assert.Equal(t, tt.expected, result, "hasFilter(%q, %q) = %v, expected %v", tt.query, tt.filterType, result, tt.expected) + }) + } +} + +func Test_hasRepoFilter(t *testing.T) { + tests := []struct { + name string + query string + expected bool + }{ + { + name: "query with repo: filter at beginning", + query: "repo:github/github-mcp-server is:issue", + expected: true, + }, + { + name: "query with repo: filter in middle", + query: "is:issue repo:octocat/Hello-World bug", + expected: true, + }, + { + name: "query with repo: filter at end", + query: "is:issue critical repo:owner/repo-name", + expected: true, + }, + { + name: "query with complex repo name", + query: "repo:microsoft/vscode-extension-samples bug", + expected: true, + }, + { + name: "query without repo: filter", + query: "is:issue bug critical", + expected: false, + }, + { + name: "query with malformed repo: filter (no slash)", + query: "repo:github bug", + expected: true, // hasRepoFilter only checks for repo: prefix, not format + }, + { + name: "empty query", + query: "", + expected: false, + }, + { + name: "query with multiple repo: filters", + query: "repo:github/first repo:octocat/second", + expected: true, + }, + { + name: "query with repo: in text but not as filter", + query: "this repo: is important", + expected: false, + }, + { + name: "query with complex OR expression", + query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasRepoFilter(tt.query) + assert.Equal(t, tt.expected, result, "hasRepoFilter(%q) = %v, expected %v", tt.query, result, tt.expected) + }) + } +} + +func Test_hasSpecificFilter(t *testing.T) { + tests := []struct { + name string + query string + filterType string + filterValue string + expected bool + }{ + { + name: "query has exact is:issue filter", + query: "is:issue bug report", + filterType: "is", + filterValue: "issue", + expected: true, + }, + { + name: "query has is:open but looking for is:issue", + query: "is:open bug report", + filterType: "is", + filterValue: "issue", + expected: false, + }, + { + name: "query has both is:issue and is:open, looking for is:issue", + query: "is:issue is:open bug", + filterType: "is", + filterValue: "issue", + expected: true, + }, + { + name: "query has both is:issue and is:open, looking for is:open", + query: "is:issue is:open bug", + filterType: "is", + filterValue: "open", + expected: true, + }, + { + name: "query has is:issue at the beginning", + query: "is:issue some text", + filterType: "is", + filterValue: "issue", + expected: true, + }, + { + name: "query has is:issue in the middle", + query: "some text is:issue more text", + filterType: "is", + filterValue: "issue", + expected: true, + }, + { + name: "query has is:issue at the end", + query: "some text is:issue", + filterType: "is", + filterValue: "issue", + expected: true, + }, + { + name: "query does not have is:issue", + query: "bug report critical", + filterType: "is", + filterValue: "issue", + expected: false, + }, + { + name: "query has similar text but not the exact filter", + query: "this issue is important", + filterType: "is", + filterValue: "issue", + expected: false, + }, + { + name: "empty query", + query: "", + filterType: "is", + filterValue: "issue", + expected: false, + }, + { + name: "partial match should not count", + query: "is:issues bug", // "issues" vs "issue" + filterType: "is", + filterValue: "issue", + expected: false, + }, + { + name: "complex query with parentheses", + query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)", + filterType: "is", + filterValue: "issue", + expected: true, + }, + { + name: "filter:value in parentheses at start", + query: "(is:issue OR is:pr) label:bug", + filterType: "is", + filterValue: "issue", + expected: true, + }, + { + name: "filter:value after opening parenthesis", + query: "repo:test/repo (is:issue AND label:bug)", + filterType: "is", + filterValue: "issue", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasSpecificFilter(tt.query, tt.filterType, tt.filterValue) + assert.Equal(t, tt.expected, result, "hasSpecificFilter(%q, %q, %q) = %v, expected %v", tt.query, tt.filterType, tt.filterValue, result, tt.expected) + }) + } +} + +func Test_hasTypeFilter(t *testing.T) { + tests := []struct { + name string + query string + expected bool + }{ + { + name: "query with type:user filter at beginning", + query: "type:user location:seattle", + expected: true, + }, + { + name: "query with type:org filter in middle", + query: "location:california type:org followers:>100", + expected: true, + }, + { + name: "query with type:user filter at end", + query: "location:seattle followers:>50 type:user", + expected: true, + }, + { + name: "query without type: filter", + query: "location:seattle followers:>50", + expected: false, + }, + { + name: "empty query", + query: "", + expected: false, + }, + { + name: "query with type: in text but not as filter", + query: "this type: is important", + expected: false, + }, + { + name: "query with multiple type: filters", + query: "type:user type:org", + expected: true, + }, + { + name: "complex query with OR expression", + query: "type:user (location:seattle OR location:california)", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasTypeFilter(tt.query) + assert.Equal(t, tt.expected, result, "hasTypeFilter(%q) = %v, expected %v", tt.query, result, tt.expected) + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index b41ba9467..3fb39ada7 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -31,6 +31,8 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(ListBranches(getClient, t)), toolsets.NewServerTool(ListTags(getClient, t)), toolsets.NewServerTool(GetTag(getClient, t)), + toolsets.NewServerTool(ListReleases(getClient, t)), + toolsets.NewServerTool(GetLatestRelease(getClient, t)), ). AddWriteTools( toolsets.NewServerTool(CreateOrUpdateFile(getClient, t)), @@ -53,6 +55,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(SearchIssues(getClient, t)), toolsets.NewServerTool(ListIssues(getGQLClient, t)), toolsets.NewServerTool(GetIssueComments(getClient, t)), + toolsets.NewServerTool(ListIssueTypes(getClient, t)), toolsets.NewServerTool(ListSubIssues(getClient, t)), ). AddWriteTools( @@ -162,6 +165,8 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG contextTools := toolsets.NewToolset("context", "Tools that provide context about the current user and GitHub context you are operating in"). AddReadTools( toolsets.NewServerTool(GetMe(getClient, t)), + toolsets.NewServerTool(GetTeams(getClient, getGQLClient, t)), + toolsets.NewServerTool(GetTeamMembers(getGQLClient, t)), ) gists := toolsets.NewToolset("gists", "GitHub Gist related tools"). diff --git a/pkg/log/io.go b/pkg/log/io.go index de2210278..44b8dc17a 100644 --- a/pkg/log/io.go +++ b/pkg/log/io.go @@ -3,7 +3,7 @@ package log import ( "io" - log "github.com/sirupsen/logrus" + "log/slog" ) // IOLogger is a wrapper around io.Reader and io.Writer that can be used @@ -11,11 +11,11 @@ import ( type IOLogger struct { reader io.Reader writer io.Writer - logger *log.Logger + logger *slog.Logger } // NewIOLogger creates a new IOLogger instance -func NewIOLogger(r io.Reader, w io.Writer, logger *log.Logger) *IOLogger { +func NewIOLogger(r io.Reader, w io.Writer, logger *slog.Logger) *IOLogger { return &IOLogger{ reader: r, writer: w, @@ -30,7 +30,7 @@ func (l *IOLogger) Read(p []byte) (n int, err error) { } n, err = l.reader.Read(p) if n > 0 { - l.logger.Infof("[stdin]: received %d bytes: %s", n, string(p[:n])) + l.logger.Info("[stdin]: received bytes", "count", n, "data", string(p[:n])) } return n, err } @@ -40,6 +40,6 @@ func (l *IOLogger) Write(p []byte) (n int, err error) { if l.writer == nil { return 0, io.ErrClosedPipe } - l.logger.Infof("[stdout]: sending %d bytes: %s", len(p), string(p)) + l.logger.Info("[stdout]: sending bytes", "count", len(p), "data", string(p)) return l.writer.Write(p) } diff --git a/pkg/log/io_test.go b/pkg/log/io_test.go index 0d0cd8959..2661de164 100644 --- a/pkg/log/io_test.go +++ b/pkg/log/io_test.go @@ -5,7 +5,8 @@ import ( "strings" "testing" - log "github.com/sirupsen/logrus" + "log/slog" + "github.com/stretchr/testify/assert" ) @@ -17,11 +18,7 @@ func TestLoggedReadWriter(t *testing.T) { // Create logger with buffer to capture output var logBuffer bytes.Buffer - logger := log.New() - logger.SetOutput(&logBuffer) - logger.SetFormatter(&log.TextFormatter{ - DisableTimestamp: true, - }) + logger := slog.New(slog.NewTextHandler(&logBuffer, &slog.HandlerOptions{ReplaceAttr: removeTimeAttr})) lrw := NewIOLogger(reader, nil, logger) @@ -44,11 +41,7 @@ func TestLoggedReadWriter(t *testing.T) { // Create logger with buffer to capture output var logBuffer bytes.Buffer - logger := log.New() - logger.SetOutput(&logBuffer) - logger.SetFormatter(&log.TextFormatter{ - DisableTimestamp: true, - }) + logger := slog.New(slog.NewTextHandler(&logBuffer, &slog.HandlerOptions{ReplaceAttr: removeTimeAttr})) lrw := NewIOLogger(nil, &writeBuffer, logger) @@ -63,3 +56,10 @@ func TestLoggedReadWriter(t *testing.T) { assert.Contains(t, logBuffer.String(), outputData) }) } + +func removeTimeAttr(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey && len(groups) == 0 { + return slog.Attr{} + } + return a +} diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index 2f6c0ecb8..88ee6a838 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -26,7 +26,6 @@ Some packages may only be included on certain architectures or operating systems - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.9.0/LICENSE)) - [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/48295856cce7/LICENSE)) - [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/ed46e5a46466/LICENSE)) - - [github.com/sirupsen/logrus](https://pkg.go.dev/github.com/sirupsen/logrus) ([MIT](https://github.com/sirupsen/logrus/blob/v1.9.3/LICENSE)) - [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/v0.3.0/LICENSE)) - [github.com/spf13/afero](https://pkg.go.dev/github.com/spf13/afero) ([Apache-2.0](https://github.com/spf13/afero/blob/v1.14.0/LICENSE.txt)) - [github.com/spf13/cast](https://pkg.go.dev/github.com/spf13/cast) ([MIT](https://github.com/spf13/cast/blob/v1.7.1/LICENSE)) diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index 2f6c0ecb8..88ee6a838 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -26,7 +26,6 @@ Some packages may only be included on certain architectures or operating systems - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.9.0/LICENSE)) - [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/48295856cce7/LICENSE)) - [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/ed46e5a46466/LICENSE)) - - [github.com/sirupsen/logrus](https://pkg.go.dev/github.com/sirupsen/logrus) ([MIT](https://github.com/sirupsen/logrus/blob/v1.9.3/LICENSE)) - [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/v0.3.0/LICENSE)) - [github.com/spf13/afero](https://pkg.go.dev/github.com/spf13/afero) ([Apache-2.0](https://github.com/spf13/afero/blob/v1.14.0/LICENSE.txt)) - [github.com/spf13/cast](https://pkg.go.dev/github.com/spf13/cast) ([MIT](https://github.com/spf13/cast/blob/v1.7.1/LICENSE)) diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index 63bf0cb69..bb742aeb0 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -27,7 +27,6 @@ Some packages may only be included on certain architectures or operating systems - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.9.0/LICENSE)) - [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/48295856cce7/LICENSE)) - [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/ed46e5a46466/LICENSE)) - - [github.com/sirupsen/logrus](https://pkg.go.dev/github.com/sirupsen/logrus) ([MIT](https://github.com/sirupsen/logrus/blob/v1.9.3/LICENSE)) - [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/v0.3.0/LICENSE)) - [github.com/spf13/afero](https://pkg.go.dev/github.com/spf13/afero) ([Apache-2.0](https://github.com/spf13/afero/blob/v1.14.0/LICENSE.txt)) - [github.com/spf13/cast](https://pkg.go.dev/github.com/spf13/cast) ([MIT](https://github.com/spf13/cast/blob/v1.7.1/LICENSE)) diff --git a/third-party/github.com/sirupsen/logrus/LICENSE b/third-party/github.com/sirupsen/logrus/LICENSE deleted file mode 100644 index f090cb42f..000000000 --- a/third-party/github.com/sirupsen/logrus/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2014 Simon Eskildsen - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE.