diff --git a/README.md b/README.md index 5b90f64a58..9b1798818f 100644 --- a/README.md +++ b/README.md @@ -1413,6 +1413,28 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - `tag`: Tag name (string, required) +- **get_traffic_clones** - Get repository traffic clones + - **OAuth Challenge Scopes**: `repo` + - `owner`: Repository owner (string, required) + - `per`: The time frame to aggregate by. Either 'day' or 'week'. Defaults to 'day'. (string, optional) + - `repo`: Repository name (string, required) + +- **get_traffic_paths** - Get repository traffic paths + - **OAuth Challenge Scopes**: `repo` + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + +- **get_traffic_referrers** - Get repository traffic referrers + - **OAuth Challenge Scopes**: `repo` + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + +- **get_traffic_views** - Get repository traffic views + - **OAuth Challenge Scopes**: `repo` + - `owner`: Repository owner (string, required) + - `per`: The time frame to aggregate by. Either 'day' or 'week'. Defaults to 'day'. (string, optional) + - `repo`: Repository name (string, required) + - **list_branches** - List branches - **OAuth Challenge Scopes**: `repo` - `owner`: Repository owner (string, required) diff --git a/pkg/github/__toolsnaps__/get_traffic_clones.snap b/pkg/github/__toolsnaps__/get_traffic_clones.snap new file mode 100644 index 0000000000..25130a688d --- /dev/null +++ b/pkg/github/__toolsnaps__/get_traffic_clones.snap @@ -0,0 +1,34 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Get repository traffic clones" + }, + "description": "Get the total git clones and unique cloners for a GitHub repository over the last 14 days. Requires push access to the repository.", + "inputSchema": { + "properties": { + "owner": { + "description": "Repository owner", + "type": "string" + }, + "per": { + "description": "The time frame to aggregate by. Either 'day' or 'week'. Defaults to 'day'.", + "enum": [ + "day", + "week" + ], + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "name": "get_traffic_clones" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_traffic_paths.snap b/pkg/github/__toolsnaps__/get_traffic_paths.snap new file mode 100644 index 0000000000..78b2222e5d --- /dev/null +++ b/pkg/github/__toolsnaps__/get_traffic_paths.snap @@ -0,0 +1,26 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Get repository traffic paths" + }, + "description": "Get the top 10 most-visited paths (pages) for a GitHub repository over the last 14 days. Requires push access to the repository.", + "inputSchema": { + "properties": { + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "name": "get_traffic_paths" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_traffic_referrers.snap b/pkg/github/__toolsnaps__/get_traffic_referrers.snap new file mode 100644 index 0000000000..cc942ae23e --- /dev/null +++ b/pkg/github/__toolsnaps__/get_traffic_referrers.snap @@ -0,0 +1,26 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Get repository traffic referrers" + }, + "description": "Get the top 10 referring sites (sources of traffic) for a GitHub repository over the last 14 days. Requires push access to the repository.", + "inputSchema": { + "properties": { + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "name": "get_traffic_referrers" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_traffic_views.snap b/pkg/github/__toolsnaps__/get_traffic_views.snap new file mode 100644 index 0000000000..62aa209920 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_traffic_views.snap @@ -0,0 +1,34 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Get repository traffic views" + }, + "description": "Get the total page views and unique visitors for a GitHub repository over the last 14 days. Requires push access to the repository.", + "inputSchema": { + "properties": { + "owner": { + "description": "Repository owner", + "type": "string" + }, + "per": { + "description": "The time frame to aggregate by. Either 'day' or 'week'. Defaults to 'day'.", + "enum": [ + "day", + "week" + ], + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "name": "get_traffic_views" +} \ No newline at end of file diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 5fc541d45d..85b6a0047e 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -109,6 +109,12 @@ const ( GetReposReleasesLatestByOwnerByRepo = "GET /repos/{owner}/{repo}/releases/latest" GetReposReleasesTagsByOwnerByRepoByTag = "GET /repos/{owner}/{repo}/releases/tags/{tag}" + // Traffic endpoints + GetReposTrafficViewsByOwnerByRepo = "GET /repos/{owner}/{repo}/traffic/views" + GetReposTrafficClonesByOwnerByRepo = "GET /repos/{owner}/{repo}/traffic/clones" + GetReposTrafficPopularReferrersByOwnerByRepo = "GET /repos/{owner}/{repo}/traffic/popular/referrers" + GetReposTrafficPopularPathsByOwnerByRepo = "GET /repos/{owner}/{repo}/traffic/popular/paths" + // Code quality endpoints GetReposCodeQualityFindingsByOwnerByRepoByFindingNumber = "GET /repos/{owner}/{repo}/code-quality/findings/{finding_number}" diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 8e2dd25172..53d3f95b74 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -2340,6 +2340,316 @@ func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool ) } +// GetTrafficViews creates a tool to get the page views and unique visitors for +// a repository over the last 14 days. +func GetTrafficViews(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataRepos, + mcp.Tool{ + Name: "get_traffic_views", + Description: t("TOOL_GET_TRAFFIC_VIEWS_DESCRIPTION", "Get the total page views and unique visitors for a GitHub repository over the last 14 days. Requires push access to the repository."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_TRAFFIC_VIEWS_USER_TITLE", "Get repository traffic views"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "per": { + Type: "string", + Description: "The time frame to aggregate by. Either 'day' or 'week'. Defaults to 'day'.", + Enum: []any{"day", "week"}, + }, + }, + Required: []string{"owner", "repo"}, + }, + }, + scopes.RequireAll(scopes.Repo), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + per, err := OptionalParam[string](args, "per") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var opts *github.TrafficBreakdownOptions + if per != "" { + opts = &github.TrafficBreakdownOptions{Per: per} + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + views, resp, err := client.Repositories.ListTrafficViews(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get traffic views", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get traffic views", resp, body), nil, nil + } + + r, err := json.Marshal(views) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + result := utils.NewToolResultText(string(r)) + // Traffic analytics require push access to read (GitHub gates the + // endpoint behind Administration: Read), so the data is never + // world-readable, even on a public repository. Always private. + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelRepoTraffic()) + return result, nil, nil + }, + ) +} + +// GetTrafficClones creates a tool to get the git clones and unique cloners for +// a repository over the last 14 days. +func GetTrafficClones(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataRepos, + mcp.Tool{ + Name: "get_traffic_clones", + Description: t("TOOL_GET_TRAFFIC_CLONES_DESCRIPTION", "Get the total git clones and unique cloners for a GitHub repository over the last 14 days. Requires push access to the repository."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_TRAFFIC_CLONES_USER_TITLE", "Get repository traffic clones"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "per": { + Type: "string", + Description: "The time frame to aggregate by. Either 'day' or 'week'. Defaults to 'day'.", + Enum: []any{"day", "week"}, + }, + }, + Required: []string{"owner", "repo"}, + }, + }, + scopes.RequireAll(scopes.Repo), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + per, err := OptionalParam[string](args, "per") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var opts *github.TrafficBreakdownOptions + if per != "" { + opts = &github.TrafficBreakdownOptions{Per: per} + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + clones, resp, err := client.Repositories.ListTrafficClones(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get traffic clones", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get traffic clones", resp, body), nil, nil + } + + r, err := json.Marshal(clones) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + result := utils.NewToolResultText(string(r)) + // Traffic analytics require push access to read; never world-readable. + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelRepoTraffic()) + return result, nil, nil + }, + ) +} + +// GetTrafficReferrers creates a tool to get the top 10 referring sites for a +// repository over the last 14 days. +func GetTrafficReferrers(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataRepos, + mcp.Tool{ + Name: "get_traffic_referrers", + Description: t("TOOL_GET_TRAFFIC_REFERRERS_DESCRIPTION", "Get the top 10 referring sites (sources of traffic) for a GitHub repository over the last 14 days. Requires push access to the repository."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_TRAFFIC_REFERRERS_USER_TITLE", "Get repository traffic referrers"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + }, + Required: []string{"owner", "repo"}, + }, + }, + scopes.RequireAll(scopes.Repo), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + referrers, resp, err := client.Repositories.ListTrafficReferrers(ctx, owner, repo) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get traffic referrers", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get traffic referrers", resp, body), nil, nil + } + + r, err := json.Marshal(referrers) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + result := utils.NewToolResultText(string(r)) + // Traffic analytics require push access to read; never world-readable. + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelRepoTraffic()) + return result, nil, nil + }, + ) +} + +// GetTrafficPaths creates a tool to get the top 10 most-visited paths for a +// repository over the last 14 days. +func GetTrafficPaths(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataRepos, + mcp.Tool{ + Name: "get_traffic_paths", + Description: t("TOOL_GET_TRAFFIC_PATHS_DESCRIPTION", "Get the top 10 most-visited paths (pages) for a GitHub repository over the last 14 days. Requires push access to the repository."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_TRAFFIC_PATHS_USER_TITLE", "Get repository traffic paths"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + }, + Required: []string{"owner", "repo"}, + }, + }, + scopes.RequireAll(scopes.Repo), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + paths, resp, err := client.Repositories.ListTrafficPaths(ctx, owner, repo) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get traffic paths", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get traffic paths", resp, body), nil, nil + } + + r, err := json.Marshal(paths) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + result := utils.NewToolResultText(string(r)) + // Traffic analytics require push access to read; never world-readable. + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelRepoTraffic()) + return result, nil, nil + }, + ) +} + func sanitizeReleaseNameAndBody(release *github.RepositoryRelease) { if release == nil { return diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 8194895afa..7ea5bbdcc7 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -5201,6 +5201,337 @@ func Test_GetReleaseByTag_IFC_FeatureFlag(t *testing.T) { }) } +func Test_GetTrafficViews(t *testing.T) { + serverTool := GetTrafficViews(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + + assert.Equal(t, "get_traffic_views", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "per") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"}) + + mockViews := &github.TrafficViews{ + Count: github.Ptr(1234), + Uniques: github.Ptr(567), + Views: []*github.TrafficData{ + {Count: github.Ptr(100), Uniques: github.Ptr(40)}, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedCount int + expectedErrMsg string + }{ + { + name: "successful traffic views fetch", + mockedClient: NewMockedHTTPClient( + WithRequestMatch( + GetReposTrafficViewsByOwnerByRepo, + mockViews, + ), + ), + requestArgs: map[string]any{"owner": "owner", "repo": "repo"}, + expectError: false, + expectedCount: 1234, + }, + { + name: "successful traffic views fetch with per=week", + mockedClient: NewMockedHTTPClient( + WithRequestMatch( + GetReposTrafficViewsByOwnerByRepo, + mockViews, + ), + ), + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "per": "week"}, + expectError: false, + expectedCount: 1234, + }, + { + name: "traffic views fetch fails without push access", + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposTrafficViewsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message": "Must have push access to repository"}`)) + }), + ), + ), + requestArgs: map[string]any{"owner": "owner", "repo": "repo"}, + expectError: true, + expectedErrMsg: "failed to get traffic views", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, tc.mockedClient) + deps := BaseDeps{Client: client} + handler := serverTool.Handler(deps) + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + textContent := getTextResult(t, result) + if tc.expectError { + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + return + } + + var returned github.TrafficViews + err = json.Unmarshal([]byte(textContent.Text), &returned) + require.NoError(t, err) + assert.Equal(t, tc.expectedCount, returned.GetCount()) + }) + } +} + +func Test_GetTrafficClones(t *testing.T) { + serverTool := GetTrafficClones(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + + assert.Equal(t, "get_traffic_clones", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "per") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"}) + + mockClones := &github.TrafficClones{ + Count: github.Ptr(88), + Uniques: github.Ptr(42), + Clones: []*github.TrafficData{ + {Count: github.Ptr(10), Uniques: github.Ptr(6)}, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedCount int + expectedErrMsg string + }{ + { + name: "successful traffic clones fetch", + mockedClient: NewMockedHTTPClient( + WithRequestMatch(GetReposTrafficClonesByOwnerByRepo, mockClones), + ), + requestArgs: map[string]any{"owner": "owner", "repo": "repo"}, + expectError: false, + expectedCount: 88, + }, + { + name: "traffic clones fetch fails without push access", + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposTrafficClonesByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message": "Must have push access to repository"}`)) + }), + ), + ), + requestArgs: map[string]any{"owner": "owner", "repo": "repo"}, + expectError: true, + expectedErrMsg: "failed to get traffic clones", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, tc.mockedClient) + deps := BaseDeps{Client: client} + handler := serverTool.Handler(deps) + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + textContent := getTextResult(t, result) + if tc.expectError { + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + return + } + + var returned github.TrafficClones + err = json.Unmarshal([]byte(textContent.Text), &returned) + require.NoError(t, err) + assert.Equal(t, tc.expectedCount, returned.GetCount()) + }) + } +} + +func Test_GetTrafficReferrers(t *testing.T) { + serverTool := GetTrafficReferrers(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + + assert.Equal(t, "get_traffic_referrers", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.NotContains(t, schema.Properties, "per") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"}) + + mockReferrers := []*github.TrafficReferrer{ + {Referrer: github.Ptr("google.com"), Count: github.Ptr(120), Uniques: github.Ptr(50)}, + {Referrer: github.Ptr("github.com"), Count: github.Ptr(80), Uniques: github.Ptr(30)}, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedLen int + expectedErrMsg string + }{ + { + name: "successful referrers fetch", + mockedClient: NewMockedHTTPClient( + WithRequestMatch(GetReposTrafficPopularReferrersByOwnerByRepo, mockReferrers), + ), + requestArgs: map[string]any{"owner": "owner", "repo": "repo"}, + expectError: false, + expectedLen: 2, + }, + { + name: "referrers fetch fails without push access", + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposTrafficPopularReferrersByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message": "Must have push access to repository"}`)) + }), + ), + ), + requestArgs: map[string]any{"owner": "owner", "repo": "repo"}, + expectError: true, + expectedErrMsg: "failed to get traffic referrers", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, tc.mockedClient) + deps := BaseDeps{Client: client} + handler := serverTool.Handler(deps) + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + textContent := getTextResult(t, result) + if tc.expectError { + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + return + } + + var returned []*github.TrafficReferrer + err = json.Unmarshal([]byte(textContent.Text), &returned) + require.NoError(t, err) + assert.Len(t, returned, tc.expectedLen) + assert.Equal(t, "google.com", returned[0].GetReferrer()) + }) + } +} + +func Test_GetTrafficPaths(t *testing.T) { + serverTool := GetTrafficPaths(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + + assert.Equal(t, "get_traffic_paths", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.NotContains(t, schema.Properties, "per") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"}) + + mockPaths := []*github.TrafficPath{ + {Path: github.Ptr("/owner/repo"), Title: github.Ptr("owner/repo"), Count: github.Ptr(200), Uniques: github.Ptr(90)}, + {Path: github.Ptr("/owner/repo/issues"), Title: github.Ptr("Issues"), Count: github.Ptr(50), Uniques: github.Ptr(20)}, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedLen int + expectedErrMsg string + }{ + { + name: "successful paths fetch", + mockedClient: NewMockedHTTPClient( + WithRequestMatch(GetReposTrafficPopularPathsByOwnerByRepo, mockPaths), + ), + requestArgs: map[string]any{"owner": "owner", "repo": "repo"}, + expectError: false, + expectedLen: 2, + }, + { + name: "paths fetch fails without push access", + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposTrafficPopularPathsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message": "Must have push access to repository"}`)) + }), + ), + ), + requestArgs: map[string]any{"owner": "owner", "repo": "repo"}, + expectError: true, + expectedErrMsg: "failed to get traffic paths", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, tc.mockedClient) + deps := BaseDeps{Client: client} + handler := serverTool.Handler(deps) + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + textContent := getTextResult(t, result) + if tc.expectError { + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + return + } + + var returned []*github.TrafficPath + err = json.Unmarshal([]byte(textContent.Text), &returned) + require.NoError(t, err) + assert.Len(t, returned, tc.expectedLen) + assert.Equal(t, "/owner/repo", returned[0].GetPath()) + }) + } +} + func Test_looksLikeSHA(t *testing.T) { tests := []struct { name string diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 6764edfc26..42791f0776 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -236,6 +236,10 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent ListReleases(t), GetLatestRelease(t), GetReleaseByTag(t), + GetTrafficViews(t), + GetTrafficClones(t), + GetTrafficReferrers(t), + GetTrafficPaths(t), CreateOrUpdateFile(t), CreateRepository(t), DeleteRepository(t), diff --git a/pkg/ifc/ifc.go b/pkg/ifc/ifc.go index f23383ce73..887b3d64e8 100644 --- a/pkg/ifc/ifc.go +++ b/pkg/ifc/ifc.go @@ -204,6 +204,21 @@ func LabelCollaboratorRoster() SecurityLabel { return PrivateTrusted() } +// LabelRepoTraffic returns the IFC label for repository traffic analytics +// (get_traffic_views, get_traffic_clones, get_traffic_referrers, +// get_traffic_paths). +// +// Integrity is trusted: traffic data is GitHub-computed analytics, not +// attacker-authored content. +// +// Confidentiality is always private. Reading traffic requires push access to +// the repository (GitHub gates the endpoints behind Administration: Read), so +// the data is never world-readable — not even for a public repository. This +// mirrors LabelCollaboratorRoster. +func LabelRepoTraffic() SecurityLabel { + return PrivateTrusted() +} + // LabelCommitContents returns the IFC label for committed repository content // reachable from the default branch and its history: commits, commit diffs, // and the repository file tree. diff --git a/pkg/ifc/ifc_test.go b/pkg/ifc/ifc_test.go index f4b25c1876..0968ea5b1b 100644 --- a/pkg/ifc/ifc_test.go +++ b/pkg/ifc/ifc_test.go @@ -160,6 +160,17 @@ func TestLabelCollaboratorRoster(t *testing.T) { assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) } +func TestLabelRepoTraffic(t *testing.T) { + t.Parallel() + + // Traffic analytics require push access to read, so the data is never + // world-readable — always trusted and private, regardless of repo + // visibility. Mirrors TestLabelCollaboratorRoster. + label := LabelRepoTraffic() + assert.Equal(t, IntegrityTrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) +} + func TestLabelCommitContents(t *testing.T) { t.Parallel()