Fix IFC private repository labels (#2695)
This commit is contained in:
@@ -276,7 +276,7 @@ func ListDiscussions(t translations.TranslationHelperFunc) inventory.ServerTool
|
||||
result := utils.NewToolResultText(string(out))
|
||||
// Discussion content is user-authored (untrusted); confidentiality
|
||||
// follows repo visibility.
|
||||
result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelListIssues)
|
||||
result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoUserContent)
|
||||
return result, nil, nil
|
||||
},
|
||||
)
|
||||
@@ -384,7 +384,7 @@ func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool {
|
||||
result := utils.NewToolResultText(string(out))
|
||||
// Discussion content is user-authored (untrusted); confidentiality
|
||||
// follows repo visibility.
|
||||
result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelListIssues)
|
||||
result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelRepoUserContent)
|
||||
return result, nil, nil
|
||||
},
|
||||
)
|
||||
@@ -592,7 +592,7 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve
|
||||
result := utils.NewToolResultText(string(out))
|
||||
// Discussion comments are user-authored (untrusted); confidentiality
|
||||
// follows repo visibility.
|
||||
result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelListIssues)
|
||||
result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelRepoUserContent)
|
||||
return result, nil, nil
|
||||
},
|
||||
)
|
||||
|
||||
+2
-10
@@ -101,13 +101,7 @@ func ListGists(t translations.TranslationHelperFunc) inventory.ServerTool {
|
||||
}
|
||||
|
||||
result := utils.NewToolResultText(string(r))
|
||||
// Gist contents are user-authored (untrusted); confidentiality is
|
||||
// the IFC join of each gist's own public/secret flag.
|
||||
visibilities := make([]bool, 0, len(gists))
|
||||
for _, g := range gists {
|
||||
visibilities = append(visibilities, g.GetPublic())
|
||||
}
|
||||
result = attachJoinedIFCLabel(ctx, deps, result, visibilities, ifc.LabelGistList)
|
||||
result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGistList())
|
||||
return result, nil, nil
|
||||
},
|
||||
)
|
||||
@@ -167,9 +161,7 @@ func GetGist(t translations.TranslationHelperFunc) inventory.ServerTool {
|
||||
}
|
||||
|
||||
result := utils.NewToolResultText(string(r))
|
||||
// Gist contents are user-authored (untrusted); confidentiality
|
||||
// derives from the gist's own public/secret flag.
|
||||
result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGist(gist.GetPublic()))
|
||||
result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGist())
|
||||
return result, nil, nil
|
||||
},
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
const (
|
||||
// User endpoints
|
||||
GetUser = "GET /user"
|
||||
GetUsersByUsername = "GET /users/{username}"
|
||||
GetUserStarred = "GET /user/starred"
|
||||
GetUsersGistsByUsername = "GET /users/{username}/gists"
|
||||
GetUsersStarredByUsername = "GET /users/{username}/starred"
|
||||
|
||||
+27
-10
@@ -17,6 +17,10 @@ func setIFCLabel(r *mcp.CallToolResult, label ifc.SecurityLabel) {
|
||||
r.Meta["ifc"] = label
|
||||
}
|
||||
|
||||
func shouldAttachIFCLabel(ctx context.Context, deps ToolDependencies, r *mcp.CallToolResult) bool {
|
||||
return r != nil && !r.IsError && deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels)
|
||||
}
|
||||
|
||||
// attachStaticIFCLabel attaches a fixed IFC label to a successful tool result
|
||||
// when IFC labels are enabled. It is used by tools whose label does not depend
|
||||
// on any repository visibility lookup (e.g. security alerts, global
|
||||
@@ -25,7 +29,7 @@ func setIFCLabel(r *mcp.CallToolResult, label ifc.SecurityLabel) {
|
||||
// Error results are left untouched, and the label is omitted entirely when the
|
||||
// IFC feature flag is disabled.
|
||||
func attachStaticIFCLabel(ctx context.Context, deps ToolDependencies, r *mcp.CallToolResult, label ifc.SecurityLabel) *mcp.CallToolResult {
|
||||
if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
|
||||
if !shouldAttachIFCLabel(ctx, deps, r) {
|
||||
return r
|
||||
}
|
||||
setIFCLabel(r, label)
|
||||
@@ -49,7 +53,7 @@ func attachRepoVisibilityIFCLabel(
|
||||
r *mcp.CallToolResult,
|
||||
labelFn func(isPrivate bool) ifc.SecurityLabel,
|
||||
) *mcp.CallToolResult {
|
||||
if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
|
||||
if !shouldAttachIFCLabel(ctx, deps, r) {
|
||||
return r
|
||||
}
|
||||
isPrivate, err := FetchRepoIsPrivate(ctx, client, owner, repo)
|
||||
@@ -86,7 +90,7 @@ func attachRepoVisibilityIFCLabelLazy(
|
||||
r *mcp.CallToolResult,
|
||||
labelFn func(isPrivate bool) ifc.SecurityLabel,
|
||||
) *mcp.CallToolResult {
|
||||
if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
|
||||
if !shouldAttachIFCLabel(ctx, deps, r) {
|
||||
return r
|
||||
}
|
||||
client, err := deps.GetClient(ctx)
|
||||
@@ -97,12 +101,11 @@ func attachRepoVisibilityIFCLabelLazy(
|
||||
}
|
||||
|
||||
// attachJoinedIFCLabel attaches an IFC label computed by joining a set of
|
||||
// per-item visibilities (true == private for repositories, true == public for
|
||||
// gists) when IFC labels are enabled. joinFn is the lattice join for the
|
||||
// relevant item kind (e.g. ifc.LabelSearchIssues or ifc.LabelGistList). The
|
||||
// visibility slice is cheap to build from an already-fetched response, so
|
||||
// callers may construct it unconditionally and let this helper own the
|
||||
// feature-flag gate.
|
||||
// per-item visibilities (true == private) when IFC labels are enabled. joinFn
|
||||
// is the lattice join for the relevant item kind (e.g. ifc.LabelSearchIssues or
|
||||
// ifc.LabelProjectList). The visibility slice is cheap to build from an
|
||||
// already-fetched response, so callers may construct it unconditionally and let
|
||||
// this helper own the feature-flag gate.
|
||||
func attachJoinedIFCLabel(
|
||||
ctx context.Context,
|
||||
deps ToolDependencies,
|
||||
@@ -110,13 +113,27 @@ func attachJoinedIFCLabel(
|
||||
visibilities []bool,
|
||||
joinFn func([]bool) ifc.SecurityLabel,
|
||||
) *mcp.CallToolResult {
|
||||
if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
|
||||
if !shouldAttachIFCLabel(ctx, deps, r) {
|
||||
return r
|
||||
}
|
||||
setIFCLabel(r, joinFn(visibilities))
|
||||
return r
|
||||
}
|
||||
|
||||
func attachProjectVisibilityIFCLabel(
|
||||
ctx context.Context,
|
||||
deps ToolDependencies,
|
||||
r *mcp.CallToolResult,
|
||||
isPrivate bool,
|
||||
labelFn func(isPrivate bool) ifc.SecurityLabel,
|
||||
) *mcp.CallToolResult {
|
||||
if !shouldAttachIFCLabel(ctx, deps, r) {
|
||||
return r
|
||||
}
|
||||
setIFCLabel(r, labelFn(isPrivate))
|
||||
return r
|
||||
}
|
||||
|
||||
// newRepoVisibilityIFCLabeler returns a closure that attaches a repo-visibility
|
||||
// IFC label to a tool result, for handlers that have several return paths and
|
||||
// want to label each one. The returned function owns the feature-flag gate (so
|
||||
|
||||
@@ -804,7 +804,7 @@ Options are:
|
||||
// attachIFC adds the IFC label to a successful tool result when
|
||||
// IFC labels are enabled. If the visibility lookup fails the
|
||||
// label is omitted rather than misclassifying the result.
|
||||
attachIFC := newRepoVisibilityIFCLabeler(ctx, deps, client, owner, repo, ifc.LabelListIssues)
|
||||
attachIFC := newRepoVisibilityIFCLabeler(ctx, deps, client, owner, repo, ifc.LabelRepoUserContent)
|
||||
|
||||
switch method {
|
||||
case "get":
|
||||
|
||||
@@ -356,7 +356,7 @@ func Test_IssueRead_IFC_InsidersMode(t *testing.T) {
|
||||
assert.Equal(t, "public", ifcMap["confidentiality"])
|
||||
})
|
||||
|
||||
t.Run("insiders mode enabled on private repo with get_comments emits private untrusted", func(t *testing.T) {
|
||||
t.Run("insiders mode enabled on private repo with get_comments emits private trusted", func(t *testing.T) {
|
||||
deps := BaseDeps{
|
||||
Client: mustNewGHClient(t, makeMockClient(true, 0)),
|
||||
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
|
||||
@@ -370,7 +370,7 @@ func Test_IssueRead_IFC_InsidersMode(t *testing.T) {
|
||||
|
||||
require.NotNil(t, result.Meta)
|
||||
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
|
||||
assert.Equal(t, "untrusted", ifcMap["integrity"])
|
||||
assert.Equal(t, "trusted", ifcMap["integrity"])
|
||||
assert.Equal(t, "private", ifcMap["confidentiality"])
|
||||
})
|
||||
|
||||
@@ -2729,7 +2729,7 @@ func Test_ListIssues_IFC_InsidersMode(t *testing.T) {
|
||||
assert.Equal(t, "public", ifcMap["confidentiality"])
|
||||
})
|
||||
|
||||
t.Run("insiders mode enabled on private repo emits private untrusted label", func(t *testing.T) {
|
||||
t.Run("insiders mode enabled on private repo emits private trusted label", func(t *testing.T) {
|
||||
matcher := githubv4mock.NewQueryMatcher(query, vars, makeResponse(true))
|
||||
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher))
|
||||
deps := BaseDeps{
|
||||
@@ -2752,7 +2752,7 @@ func Test_ListIssues_IFC_InsidersMode(t *testing.T) {
|
||||
var ifcMap map[string]any
|
||||
require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap))
|
||||
|
||||
assert.Equal(t, "untrusted", ifcMap["integrity"])
|
||||
assert.Equal(t, "trusted", ifcMap["integrity"])
|
||||
assert.Equal(t, "private", ifcMap["confidentiality"])
|
||||
})
|
||||
}
|
||||
|
||||
+157
-88
@@ -65,33 +65,43 @@ type statusUpdateNode struct {
|
||||
}
|
||||
}
|
||||
|
||||
type projectVisibility struct {
|
||||
Public githubv4.Boolean
|
||||
}
|
||||
|
||||
type statusUpdateNodeWithProject struct {
|
||||
statusUpdateNode
|
||||
Project projectVisibility
|
||||
}
|
||||
|
||||
type statusUpdateConnection struct {
|
||||
Nodes []statusUpdateNode
|
||||
PageInfo PageInfoFragment
|
||||
}
|
||||
|
||||
type statusUpdatesProject struct {
|
||||
Public githubv4.Boolean
|
||||
StatusUpdates statusUpdateConnection `graphql:"statusUpdates(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: DESC})"`
|
||||
}
|
||||
|
||||
// statusUpdatesUserQuery is the GraphQL query for listing status updates on a user-owned project.
|
||||
type statusUpdatesUserQuery struct {
|
||||
User struct {
|
||||
ProjectV2 struct {
|
||||
StatusUpdates statusUpdateConnection `graphql:"statusUpdates(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: DESC})"`
|
||||
} `graphql:"projectV2(number: $projectNumber)"`
|
||||
ProjectV2 statusUpdatesProject `graphql:"projectV2(number: $projectNumber)"`
|
||||
} `graphql:"user(login: $owner)"`
|
||||
}
|
||||
|
||||
// statusUpdatesOrgQuery is the GraphQL query for listing status updates on an org-owned project.
|
||||
type statusUpdatesOrgQuery struct {
|
||||
Organization struct {
|
||||
ProjectV2 struct {
|
||||
StatusUpdates statusUpdateConnection `graphql:"statusUpdates(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: DESC})"`
|
||||
} `graphql:"projectV2(number: $projectNumber)"`
|
||||
ProjectV2 statusUpdatesProject `graphql:"projectV2(number: $projectNumber)"`
|
||||
} `graphql:"organization(login: $owner)"`
|
||||
}
|
||||
|
||||
// statusUpdateNodeQuery is the GraphQL query for fetching a single status update by node ID.
|
||||
type statusUpdateNodeQuery struct {
|
||||
Node struct {
|
||||
StatusUpdate statusUpdateNode `graphql:"... on ProjectV2StatusUpdate"`
|
||||
StatusUpdate statusUpdateNodeWithProject `graphql:"... on ProjectV2StatusUpdate"`
|
||||
} `graphql:"node(id: $id)"`
|
||||
}
|
||||
|
||||
@@ -228,26 +238,18 @@ Use this tool to list projects for a user or organization, or list project field
|
||||
return utils.NewToolResultError(err.Error()), nil, nil
|
||||
}
|
||||
|
||||
// attachIFC adds the IFC label to a successful result when IFC
|
||||
// labels are enabled. Project titles, item content, field
|
||||
// definitions, and status updates are user-authored free text
|
||||
// (untrusted); confidentiality is conservatively private since the
|
||||
// project's public flag is not available across every sub-result.
|
||||
attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult {
|
||||
return attachStaticIFCLabel(ctx, deps, r, ifc.LabelProject(false))
|
||||
}
|
||||
|
||||
switch method {
|
||||
case projectsMethodListProjects:
|
||||
result, payload, err := listProjects(ctx, client, args, owner, ownerType)
|
||||
return attachIFC(result), payload, err
|
||||
default:
|
||||
result, visibilities, payload, err := listProjects(ctx, client, args, owner, ownerType)
|
||||
result = attachJoinedIFCLabel(ctx, deps, result, visibilities, ifc.LabelProjectList)
|
||||
return result, payload, err
|
||||
case projectsMethodListProjectFields, projectsMethodListProjectItems, projectsMethodListProjectStatusUpdates:
|
||||
// All other methods require project_number and ownerType detection
|
||||
projectNumber, err := RequiredInt(args, "project_number")
|
||||
if err != nil {
|
||||
return utils.NewToolResultError(err.Error()), nil, nil
|
||||
}
|
||||
if ownerType == "" {
|
||||
projectNumber, err := RequiredInt(args, "project_number")
|
||||
if err != nil {
|
||||
return utils.NewToolResultError(err.Error()), nil, nil
|
||||
}
|
||||
ownerType, err = detectOwnerType(ctx, client, owner, projectNumber)
|
||||
if err != nil {
|
||||
return utils.NewToolResultError(err.Error()), nil, nil
|
||||
@@ -257,20 +259,35 @@ Use this tool to list projects for a user or organization, or list project field
|
||||
switch method {
|
||||
case projectsMethodListProjectFields:
|
||||
result, payload, err := listProjectFields(ctx, client, args, owner, ownerType)
|
||||
return attachIFC(result), payload, err
|
||||
if shouldAttachIFCLabel(ctx, deps, result) {
|
||||
isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber)
|
||||
if visibilityErr == nil {
|
||||
result = attachProjectVisibilityIFCLabel(ctx, deps, result, isPrivate, ifc.LabelProject)
|
||||
}
|
||||
}
|
||||
return result, payload, err
|
||||
case projectsMethodListProjectItems:
|
||||
result, payload, err := listProjectItems(ctx, client, args, owner, ownerType)
|
||||
return attachIFC(result), payload, err
|
||||
if shouldAttachIFCLabel(ctx, deps, result) {
|
||||
isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber)
|
||||
if visibilityErr == nil {
|
||||
result = attachProjectVisibilityIFCLabel(ctx, deps, result, isPrivate, ifc.LabelProjectContent)
|
||||
}
|
||||
}
|
||||
return result, payload, err
|
||||
case projectsMethodListProjectStatusUpdates:
|
||||
gqlClient, err := deps.GetGQLClient(ctx)
|
||||
if err != nil {
|
||||
return utils.NewToolResultError(err.Error()), nil, nil
|
||||
}
|
||||
result, payload, err := listProjectStatusUpdates(ctx, gqlClient, args, owner, ownerType)
|
||||
return attachIFC(result), payload, err
|
||||
result, isPrivate, payload, err := listProjectStatusUpdates(ctx, gqlClient, args, owner, ownerType)
|
||||
result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelProjectContent(isPrivate))
|
||||
return result, payload, err
|
||||
default:
|
||||
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
|
||||
}
|
||||
default:
|
||||
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -346,14 +363,6 @@ Use this tool to get details about individual projects, project fields, and proj
|
||||
return utils.NewToolResultError(err.Error()), nil, nil
|
||||
}
|
||||
|
||||
// attachIFC adds the IFC label to a successful result when IFC
|
||||
// labels are enabled. Project data is user-authored free text
|
||||
// (untrusted); confidentiality is conservatively private since the
|
||||
// project's public flag is not available across every sub-result.
|
||||
attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult {
|
||||
return attachStaticIFCLabel(ctx, deps, r, ifc.LabelProject(false))
|
||||
}
|
||||
|
||||
// Handle get_project_status_update early — it only needs status_update_id
|
||||
if method == projectsMethodGetProjectStatusUpdate {
|
||||
statusUpdateID, err := RequiredParam[string](args, "status_update_id")
|
||||
@@ -364,8 +373,9 @@ Use this tool to get details about individual projects, project fields, and proj
|
||||
if err != nil {
|
||||
return utils.NewToolResultError(err.Error()), nil, nil
|
||||
}
|
||||
result, payload, err := getProjectStatusUpdate(ctx, gqlClient, statusUpdateID)
|
||||
return attachIFC(result), payload, err
|
||||
result, isPrivate, payload, err := getProjectStatusUpdate(ctx, gqlClient, statusUpdateID)
|
||||
result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelProjectContent(isPrivate))
|
||||
return result, payload, err
|
||||
}
|
||||
|
||||
owner, err := RequiredParam[string](args, "owner")
|
||||
@@ -398,15 +408,22 @@ Use this tool to get details about individual projects, project fields, and proj
|
||||
|
||||
switch method {
|
||||
case projectsMethodGetProject:
|
||||
result, payload, err := getProject(ctx, client, owner, ownerType, projectNumber)
|
||||
return attachIFC(result), payload, err
|
||||
result, isPrivate, payload, err := getProject(ctx, client, owner, ownerType, projectNumber)
|
||||
result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelProject(isPrivate))
|
||||
return result, payload, err
|
||||
case projectsMethodGetProjectField:
|
||||
fieldID, err := RequiredBigInt(args, "field_id")
|
||||
if err != nil {
|
||||
return utils.NewToolResultError(err.Error()), nil, nil
|
||||
}
|
||||
result, payload, err := getProjectField(ctx, client, owner, ownerType, projectNumber, fieldID)
|
||||
return attachIFC(result), payload, err
|
||||
if shouldAttachIFCLabel(ctx, deps, result) {
|
||||
isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber)
|
||||
if visibilityErr == nil {
|
||||
result = attachProjectVisibilityIFCLabel(ctx, deps, result, isPrivate, ifc.LabelProject)
|
||||
}
|
||||
}
|
||||
return result, payload, err
|
||||
case projectsMethodGetProjectItem:
|
||||
itemID, err := RequiredBigInt(args, "item_id")
|
||||
if err != nil {
|
||||
@@ -417,7 +434,13 @@ Use this tool to get details about individual projects, project fields, and proj
|
||||
return utils.NewToolResultError(err.Error()), nil, nil
|
||||
}
|
||||
result, payload, err := getProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fields)
|
||||
return attachIFC(result), payload, err
|
||||
if shouldAttachIFCLabel(ctx, deps, result) {
|
||||
isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber)
|
||||
if visibilityErr == nil {
|
||||
result = attachProjectVisibilityIFCLabel(ctx, deps, result, isPrivate, ifc.LabelProjectContent)
|
||||
}
|
||||
}
|
||||
return result, payload, err
|
||||
default:
|
||||
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
|
||||
}
|
||||
@@ -678,15 +701,15 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
|
||||
|
||||
// Helper functions for consolidated projects tools
|
||||
|
||||
func listProjects(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) {
|
||||
func listProjects(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, []bool, any, error) {
|
||||
queryStr, err := OptionalParam[string](args, "query")
|
||||
if err != nil {
|
||||
return utils.NewToolResultError(err.Error()), nil, nil
|
||||
return utils.NewToolResultError(err.Error()), nil, nil, nil
|
||||
}
|
||||
|
||||
pagination, err := extractPaginationOptionsFromArgs(args)
|
||||
if err != nil {
|
||||
return utils.NewToolResultError(err.Error()), nil, nil
|
||||
return utils.NewToolResultError(err.Error()), nil, nil, nil
|
||||
}
|
||||
|
||||
var resp *github.Response
|
||||
@@ -709,7 +732,7 @@ func listProjects(ctx context.Context, client *github.Client, args map[string]an
|
||||
"failed to list projects",
|
||||
resp,
|
||||
err,
|
||||
), nil, nil
|
||||
), nil, nil, nil
|
||||
}
|
||||
default:
|
||||
projects, resp, err = client.Projects.ListUserProjects(ctx, owner, opts)
|
||||
@@ -718,7 +741,7 @@ func listProjects(ctx context.Context, client *github.Client, args map[string]an
|
||||
"failed to list projects",
|
||||
resp,
|
||||
err,
|
||||
), nil, nil
|
||||
), nil, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -739,18 +762,18 @@ func listProjects(ctx context.Context, client *github.Client, args map[string]an
|
||||
|
||||
r, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
|
||||
return nil, nil, nil, fmt.Errorf("failed to marshal response: %w", err)
|
||||
}
|
||||
|
||||
return utils.NewToolResultText(string(r)), nil, nil
|
||||
return utils.NewToolResultText(string(r)), projectVisibilities(minimalProjects), nil, nil
|
||||
}
|
||||
|
||||
return nil, nil, fmt.Errorf("unexpected state in listProjects")
|
||||
return nil, nil, nil, fmt.Errorf("unexpected state in listProjects")
|
||||
}
|
||||
|
||||
// listProjectsFromBothOwnerTypes fetches projects from both user and org endpoints
|
||||
// when owner_type is not specified, combining the results with owner_type labels.
|
||||
func listProjectsFromBothOwnerTypes(ctx context.Context, client *github.Client, owner string, opts *github.ListProjectsOptions) (*mcp.CallToolResult, any, error) {
|
||||
func listProjectsFromBothOwnerTypes(ctx context.Context, client *github.Client, owner string, opts *github.ListProjectsOptions) (*mcp.CallToolResult, []bool, any, error) {
|
||||
var minimalProjects []MinimalProject
|
||||
var resp *github.Response
|
||||
|
||||
@@ -781,7 +804,7 @@ func listProjectsFromBothOwnerTypes(ctx context.Context, client *github.Client,
|
||||
// If both failed, return error
|
||||
if (userErr != nil || userResp == nil || userResp.StatusCode != http.StatusOK) &&
|
||||
(orgErr != nil || orgResp == nil || orgResp.StatusCode != http.StatusOK) {
|
||||
return utils.NewToolResultError(fmt.Sprintf("failed to list projects for owner '%s': not found as user or organization", owner)), nil, nil
|
||||
return utils.NewToolResultError(fmt.Sprintf("failed to list projects for owner '%s': not found as user or organization", owner)), nil, nil, nil
|
||||
}
|
||||
|
||||
response := map[string]any{
|
||||
@@ -795,9 +818,21 @@ func listProjectsFromBothOwnerTypes(ctx context.Context, client *github.Client,
|
||||
|
||||
r, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
|
||||
return nil, nil, nil, fmt.Errorf("failed to marshal response: %w", err)
|
||||
}
|
||||
return utils.NewToolResultText(string(r)), nil, nil
|
||||
return utils.NewToolResultText(string(r)), projectVisibilities(minimalProjects), nil, nil
|
||||
}
|
||||
|
||||
func projectVisibilities(projects []MinimalProject) []bool {
|
||||
visibilities := make([]bool, 0, len(projects))
|
||||
for _, project := range projects {
|
||||
isPrivate := true
|
||||
if project.Public != nil {
|
||||
isPrivate = !*project.Public
|
||||
}
|
||||
visibilities = append(visibilities, isPrivate)
|
||||
}
|
||||
return visibilities
|
||||
}
|
||||
|
||||
func listProjectFields(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) {
|
||||
@@ -911,40 +946,54 @@ func listProjectItems(ctx context.Context, client *github.Client, args map[strin
|
||||
return utils.NewToolResultText(string(r)), nil, nil
|
||||
}
|
||||
|
||||
func getProject(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (*mcp.CallToolResult, any, error) {
|
||||
var resp *github.Response
|
||||
var project *github.ProjectV2
|
||||
var err error
|
||||
|
||||
func fetchProjectV2(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (*github.ProjectV2, *github.Response, error) {
|
||||
if ownerType == "org" {
|
||||
project, resp, err = client.Projects.GetOrganizationProject(ctx, owner, projectNumber)
|
||||
} else {
|
||||
project, resp, err = client.Projects.GetUserProject(ctx, owner, projectNumber)
|
||||
return client.Projects.GetOrganizationProject(ctx, owner, projectNumber)
|
||||
}
|
||||
return client.Projects.GetUserProject(ctx, owner, projectNumber)
|
||||
}
|
||||
|
||||
// FetchProjectIsPrivate returns whether a GitHub Project is private.
|
||||
func FetchProjectIsPrivate(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (bool, error) {
|
||||
project, resp, err := fetchProjectV2(ctx, client, owner, ownerType, projectNumber)
|
||||
if resp != nil && resp.Body != nil {
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if resp == nil || resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Errorf("failed to fetch project visibility")
|
||||
}
|
||||
return !project.GetPublic(), nil
|
||||
}
|
||||
|
||||
func getProject(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (*mcp.CallToolResult, bool, any, error) {
|
||||
project, resp, err := fetchProjectV2(ctx, client, owner, ownerType, projectNumber)
|
||||
if err != nil {
|
||||
return ghErrors.NewGitHubAPIErrorResponse(ctx,
|
||||
"failed to get project",
|
||||
resp,
|
||||
err,
|
||||
), nil, nil
|
||||
), false, 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 nil, false, nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project", resp, body), nil, nil
|
||||
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project", resp, body), false, nil, nil
|
||||
}
|
||||
|
||||
minimalProject := convertToMinimalProject(project)
|
||||
r, err := json.Marshal(minimalProject)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
|
||||
return nil, false, nil, fmt.Errorf("failed to marshal response: %w", err)
|
||||
}
|
||||
|
||||
return utils.NewToolResultText(string(r)), nil, nil
|
||||
return utils.NewToolResultText(string(r)), !project.GetPublic(), nil, nil
|
||||
}
|
||||
|
||||
func getProjectField(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, fieldID int64) (*mcp.CallToolResult, any, error) {
|
||||
@@ -1274,19 +1323,19 @@ func createProjectStatusUpdate(ctx context.Context, gqlClient *githubv4.Client,
|
||||
}
|
||||
|
||||
// listProjectStatusUpdates lists status updates for a project via GraphQL.
|
||||
func listProjectStatusUpdates(ctx context.Context, gqlClient *githubv4.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) {
|
||||
func listProjectStatusUpdates(ctx context.Context, gqlClient *githubv4.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, bool, any, error) {
|
||||
if ownerType != "user" && ownerType != "org" {
|
||||
return utils.NewToolResultError(fmt.Sprintf("invalid owner_type %q: must be \"user\" or \"org\"", ownerType)), nil, nil
|
||||
return utils.NewToolResultError(fmt.Sprintf("invalid owner_type %q: must be \"user\" or \"org\"", ownerType)), false, nil, nil
|
||||
}
|
||||
|
||||
projectNumber, err := RequiredInt(args, "project_number")
|
||||
if err != nil {
|
||||
return utils.NewToolResultError(err.Error()), nil, nil
|
||||
return utils.NewToolResultError(err.Error()), false, nil, nil
|
||||
}
|
||||
|
||||
perPage, err := OptionalIntParamWithDefault(args, "per_page", MaxProjectsPerPage)
|
||||
if err != nil {
|
||||
return utils.NewToolResultError(err.Error()), nil, nil
|
||||
return utils.NewToolResultError(err.Error()), false, nil, nil
|
||||
}
|
||||
if perPage > MaxProjectsPerPage {
|
||||
perPage = MaxProjectsPerPage
|
||||
@@ -1297,7 +1346,7 @@ func listProjectStatusUpdates(ctx context.Context, gqlClient *githubv4.Client, a
|
||||
|
||||
afterCursor, err := OptionalParam[string](args, "after")
|
||||
if err != nil {
|
||||
return utils.NewToolResultError(err.Error()), nil, nil
|
||||
return utils.NewToolResultError(err.Error()), false, nil, nil
|
||||
}
|
||||
|
||||
vars := map[string]any{
|
||||
@@ -1313,21 +1362,26 @@ func listProjectStatusUpdates(ctx context.Context, gqlClient *githubv4.Client, a
|
||||
|
||||
var nodes []statusUpdateNode
|
||||
var pi PageInfoFragment
|
||||
var isPrivate bool
|
||||
|
||||
if ownerType == "org" {
|
||||
var q statusUpdatesOrgQuery
|
||||
if err := gqlClient.Query(ctx, &q, vars); err != nil {
|
||||
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateListFailedError, err)), nil, nil
|
||||
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateListFailedError, err)), false, nil, nil
|
||||
}
|
||||
nodes = q.Organization.ProjectV2.StatusUpdates.Nodes
|
||||
pi = q.Organization.ProjectV2.StatusUpdates.PageInfo
|
||||
project := q.Organization.ProjectV2
|
||||
nodes = project.StatusUpdates.Nodes
|
||||
pi = project.StatusUpdates.PageInfo
|
||||
isPrivate = !bool(project.Public)
|
||||
} else {
|
||||
var q statusUpdatesUserQuery
|
||||
if err := gqlClient.Query(ctx, &q, vars); err != nil {
|
||||
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateListFailedError, err)), nil, nil
|
||||
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateListFailedError, err)), false, nil, nil
|
||||
}
|
||||
nodes = q.User.ProjectV2.StatusUpdates.Nodes
|
||||
pi = q.User.ProjectV2.StatusUpdates.PageInfo
|
||||
project := q.User.ProjectV2
|
||||
nodes = project.StatusUpdates.Nodes
|
||||
pi = project.StatusUpdates.PageInfo
|
||||
isPrivate = !bool(project.Public)
|
||||
}
|
||||
|
||||
updates := make([]MinimalProjectStatusUpdate, 0, len(nodes))
|
||||
@@ -1347,33 +1401,34 @@ func listProjectStatusUpdates(ctx context.Context, gqlClient *githubv4.Client, a
|
||||
|
||||
r, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
|
||||
return nil, false, nil, fmt.Errorf("failed to marshal response: %w", err)
|
||||
}
|
||||
return utils.NewToolResultText(string(r)), nil, nil
|
||||
return utils.NewToolResultText(string(r)), isPrivate, nil, nil
|
||||
}
|
||||
|
||||
// getProjectStatusUpdate fetches a single status update by its node ID via GraphQL.
|
||||
func getProjectStatusUpdate(ctx context.Context, gqlClient *githubv4.Client, statusUpdateID string) (*mcp.CallToolResult, any, error) {
|
||||
func getProjectStatusUpdate(ctx context.Context, gqlClient *githubv4.Client, statusUpdateID string) (*mcp.CallToolResult, bool, any, error) {
|
||||
var q statusUpdateNodeQuery
|
||||
vars := map[string]any{
|
||||
"id": githubv4.ID(statusUpdateID),
|
||||
}
|
||||
|
||||
if err := gqlClient.Query(ctx, &q, vars); err != nil {
|
||||
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateGetFailedError, err)), nil, nil
|
||||
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateGetFailedError, err)), false, nil, nil
|
||||
}
|
||||
|
||||
if q.Node.StatusUpdate.ID == nil || q.Node.StatusUpdate.ID == "" {
|
||||
return utils.NewToolResultError(fmt.Sprintf("%s: node is not a ProjectV2StatusUpdate or was not found", ProjectStatusUpdateGetFailedError)), nil, nil
|
||||
return utils.NewToolResultError(fmt.Sprintf("%s: node is not a ProjectV2StatusUpdate or was not found", ProjectStatusUpdateGetFailedError)), false, nil, nil
|
||||
}
|
||||
|
||||
update := convertToMinimalStatusUpdate(q.Node.StatusUpdate)
|
||||
update := convertToMinimalStatusUpdate(q.Node.StatusUpdate.statusUpdateNode)
|
||||
isPrivate := !bool(q.Node.StatusUpdate.Project.Public)
|
||||
|
||||
r, err := json.Marshal(update)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
|
||||
return nil, false, nil, fmt.Errorf("failed to marshal response: %w", err)
|
||||
}
|
||||
return utils.NewToolResultText(string(r)), nil, nil
|
||||
return utils.NewToolResultText(string(r)), isPrivate, nil, nil
|
||||
}
|
||||
|
||||
// validateAndConvertToInt64 ensures the value is a number and converts it to int64.
|
||||
@@ -1761,11 +1816,25 @@ type ProjectV2IterationFieldIterationInput struct {
|
||||
Title githubv4.String `json:"title"`
|
||||
}
|
||||
|
||||
// detectOwnerType attempts to detect the owner type by trying both user and org
|
||||
// Returns the detected type ("user" or "org") and any error encountered
|
||||
// detectOwnerType attempts to detect whether the project owner is a user or org.
|
||||
// It first asks GitHub for the account type, then falls back to project probes
|
||||
// for older or mocked clients where the account type is unavailable.
|
||||
func detectOwnerType(ctx context.Context, client *github.Client, owner string, projectNumber int) (string, error) {
|
||||
user, resp, err := client.Users.Get(ctx, owner)
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
if err == nil && resp != nil && resp.StatusCode == http.StatusOK {
|
||||
switch user.GetType() {
|
||||
case "User":
|
||||
return "user", nil
|
||||
case "Organization":
|
||||
return "org", nil
|
||||
}
|
||||
}
|
||||
|
||||
// Try user first (more common for personal projects)
|
||||
_, resp, err := client.Projects.GetUserProject(ctx, owner, projectNumber)
|
||||
_, resp, err = client.Projects.GetUserProject(ctx, owner, projectNumber)
|
||||
if err == nil && resp.StatusCode == http.StatusOK {
|
||||
_ = resp.Body.Close()
|
||||
return "user", nil
|
||||
|
||||
@@ -366,6 +366,196 @@ func Test_ProjectsList_ListProjectItems(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func Test_detectOwnerType(t *testing.T) {
|
||||
t.Run("uses organization account type", func(t *testing.T) {
|
||||
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
||||
GetUsersByUsername: mockResponse(t, http.StatusOK, map[string]any{
|
||||
"login": "github",
|
||||
"type": "Organization",
|
||||
}),
|
||||
})
|
||||
client := mustNewGHClient(t, mockedClient)
|
||||
|
||||
ownerType, err := detectOwnerType(context.Background(), client, "github", 1)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "org", ownerType)
|
||||
})
|
||||
|
||||
t.Run("uses user account type", func(t *testing.T) {
|
||||
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
||||
GetUsersByUsername: mockResponse(t, http.StatusOK, map[string]any{
|
||||
"login": "octocat",
|
||||
"type": "User",
|
||||
}),
|
||||
})
|
||||
client := mustNewGHClient(t, mockedClient)
|
||||
|
||||
ownerType, err := detectOwnerType(context.Background(), client, "octocat", 1)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "user", ownerType)
|
||||
})
|
||||
|
||||
t.Run("falls back to project probes", func(t *testing.T) {
|
||||
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
||||
GetUsersProjectsV2ByUsernameByProject: mockResponse(t, http.StatusNotFound, nil),
|
||||
GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, map[string]any{"id": 1}),
|
||||
})
|
||||
client := mustNewGHClient(t, mockedClient)
|
||||
|
||||
ownerType, err := detectOwnerType(context.Background(), client, "octo-org", 1)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "org", ownerType)
|
||||
})
|
||||
}
|
||||
|
||||
func Test_ProjectsList_IFC_InsidersMode(t *testing.T) {
|
||||
toolDef := ProjectsList(translations.NullTranslationHelper)
|
||||
|
||||
t.Run("list_projects joins returned project visibilities", func(t *testing.T) {
|
||||
projects := []map[string]any{
|
||||
{"id": 1, "node_id": "NODE1", "title": "Public Project", "public": true},
|
||||
{"id": 2, "node_id": "NODE2", "title": "Private Project", "public": false},
|
||||
}
|
||||
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
||||
GetOrgsProjectsV2: mockResponse(t, http.StatusOK, projects),
|
||||
})
|
||||
client := mustNewGHClient(t, mockedClient)
|
||||
deps := BaseDeps{
|
||||
Client: client,
|
||||
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
|
||||
}
|
||||
handler := toolDef.Handler(deps)
|
||||
request := createMCPRequest(map[string]any{
|
||||
"method": "list_projects",
|
||||
"owner": "octo-org",
|
||||
"owner_type": "org",
|
||||
})
|
||||
|
||||
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
||||
require.NoError(t, err)
|
||||
require.False(t, result.IsError)
|
||||
|
||||
require.NotNil(t, result.Meta)
|
||||
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
|
||||
assert.Equal(t, "untrusted", ifcMap["integrity"])
|
||||
assert.Equal(t, "private", ifcMap["confidentiality"])
|
||||
})
|
||||
|
||||
t.Run("list_project_fields uses project metadata label", func(t *testing.T) {
|
||||
fields := []map[string]any{{"id": 101, "name": "Status", "data_type": "single_select"}}
|
||||
project := map[string]any{"id": 1, "node_id": "NODE1", "title": "Private Project", "public": false}
|
||||
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
||||
GetOrgsProjectsV2FieldsByProject: mockResponse(t, http.StatusOK, fields),
|
||||
GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project),
|
||||
})
|
||||
client := mustNewGHClient(t, mockedClient)
|
||||
deps := BaseDeps{
|
||||
Client: client,
|
||||
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
|
||||
}
|
||||
handler := toolDef.Handler(deps)
|
||||
request := createMCPRequest(map[string]any{
|
||||
"method": "list_project_fields",
|
||||
"owner": "octo-org",
|
||||
"owner_type": "org",
|
||||
"project_number": float64(1),
|
||||
})
|
||||
|
||||
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
||||
require.NoError(t, err)
|
||||
require.False(t, result.IsError)
|
||||
|
||||
require.NotNil(t, result.Meta)
|
||||
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
|
||||
assert.Equal(t, "trusted", ifcMap["integrity"])
|
||||
assert.Equal(t, "private", ifcMap["confidentiality"])
|
||||
})
|
||||
|
||||
t.Run("list_project_items uses project content label", func(t *testing.T) {
|
||||
items := []map[string]any{verbosePullRequestProjectItemFixture()}
|
||||
project := map[string]any{"id": 1, "node_id": "NODE1", "title": "Private Project", "public": false}
|
||||
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
||||
GetOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusOK, items),
|
||||
GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project),
|
||||
})
|
||||
client := mustNewGHClient(t, mockedClient)
|
||||
deps := BaseDeps{
|
||||
Client: client,
|
||||
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
|
||||
}
|
||||
handler := toolDef.Handler(deps)
|
||||
request := createMCPRequest(map[string]any{
|
||||
"method": "list_project_items",
|
||||
"owner": "octo-org",
|
||||
"owner_type": "org",
|
||||
"project_number": float64(1),
|
||||
})
|
||||
|
||||
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
||||
require.NoError(t, err)
|
||||
require.False(t, result.IsError)
|
||||
|
||||
require.NotNil(t, result.Meta)
|
||||
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
|
||||
assert.Equal(t, "untrusted", ifcMap["integrity"])
|
||||
assert.Equal(t, "private", ifcMap["confidentiality"])
|
||||
})
|
||||
|
||||
t.Run("list_project_status_updates uses GraphQL project visibility", func(t *testing.T) {
|
||||
gqlMockedClient := githubv4mock.NewMockedHTTPClient(
|
||||
githubv4mock.NewQueryMatcher(
|
||||
statusUpdatesOrgQuery{},
|
||||
map[string]any{
|
||||
"owner": githubv4.String("octo-org"),
|
||||
"projectNumber": githubv4.Int(1),
|
||||
"first": githubv4.Int(50),
|
||||
"after": (*githubv4.String)(nil),
|
||||
},
|
||||
githubv4mock.DataResponse(map[string]any{
|
||||
"organization": map[string]any{
|
||||
"projectV2": map[string]any{
|
||||
"public": true,
|
||||
"statusUpdates": map[string]any{
|
||||
"nodes": []map[string]any{},
|
||||
"pageInfo": map[string]any{
|
||||
"hasNextPage": false,
|
||||
"hasPreviousPage": false,
|
||||
"startCursor": "",
|
||||
"endCursor": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
deps := BaseDeps{
|
||||
Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})),
|
||||
GQLClient: githubv4.NewClient(gqlMockedClient),
|
||||
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
|
||||
}
|
||||
handler := toolDef.Handler(deps)
|
||||
request := createMCPRequest(map[string]any{
|
||||
"method": "list_project_status_updates",
|
||||
"owner": "octo-org",
|
||||
"owner_type": "org",
|
||||
"project_number": float64(1),
|
||||
})
|
||||
|
||||
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
||||
require.NoError(t, err)
|
||||
require.False(t, result.IsError)
|
||||
|
||||
require.NotNil(t, result.Meta)
|
||||
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
|
||||
assert.Equal(t, "untrusted", ifcMap["integrity"])
|
||||
assert.Equal(t, "public", ifcMap["confidentiality"])
|
||||
})
|
||||
}
|
||||
|
||||
func Test_ProjectsGet(t *testing.T) {
|
||||
// Verify tool definition once
|
||||
toolDef := ProjectsGet(translations.NullTranslationHelper)
|
||||
@@ -438,6 +628,79 @@ func Test_ProjectsGet_GetProject(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func Test_ProjectsGet_IFC_InsidersMode(t *testing.T) {
|
||||
toolDef := ProjectsGet(translations.NullTranslationHelper)
|
||||
|
||||
t.Run("get_project uses project metadata label", func(t *testing.T) {
|
||||
project := map[string]any{"id": 123, "node_id": "NODE1", "title": "Private Project", "public": false}
|
||||
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
||||
GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project),
|
||||
})
|
||||
client := mustNewGHClient(t, mockedClient)
|
||||
deps := BaseDeps{
|
||||
Client: client,
|
||||
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
|
||||
}
|
||||
handler := toolDef.Handler(deps)
|
||||
request := createMCPRequest(map[string]any{
|
||||
"method": "get_project",
|
||||
"owner": "octo-org",
|
||||
"owner_type": "org",
|
||||
"project_number": float64(1),
|
||||
})
|
||||
|
||||
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
||||
require.NoError(t, err)
|
||||
require.False(t, result.IsError)
|
||||
|
||||
require.NotNil(t, result.Meta)
|
||||
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
|
||||
assert.Equal(t, "trusted", ifcMap["integrity"])
|
||||
assert.Equal(t, "private", ifcMap["confidentiality"])
|
||||
})
|
||||
|
||||
t.Run("get_project_status_update uses GraphQL project visibility", func(t *testing.T) {
|
||||
gqlMockedClient := githubv4mock.NewMockedHTTPClient(
|
||||
githubv4mock.NewQueryMatcher(
|
||||
statusUpdateNodeQuery{},
|
||||
map[string]any{
|
||||
"id": githubv4.ID("SU_abc123"),
|
||||
},
|
||||
githubv4mock.DataResponse(map[string]any{
|
||||
"node": map[string]any{
|
||||
"id": "SU_abc123",
|
||||
"body": "On track",
|
||||
"status": "ON_TRACK",
|
||||
"createdAt": "2026-01-15T10:00:00Z",
|
||||
"startDate": "2026-01-01",
|
||||
"targetDate": "2026-03-01",
|
||||
"creator": map[string]any{"login": "octocat"},
|
||||
"project": map[string]any{"public": true},
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
deps := BaseDeps{
|
||||
GQLClient: githubv4.NewClient(gqlMockedClient),
|
||||
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
|
||||
}
|
||||
handler := toolDef.Handler(deps)
|
||||
request := createMCPRequest(map[string]any{
|
||||
"method": "get_project_status_update",
|
||||
"status_update_id": "SU_abc123",
|
||||
})
|
||||
|
||||
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
||||
require.NoError(t, err)
|
||||
require.False(t, result.IsError)
|
||||
|
||||
require.NotNil(t, result.Meta)
|
||||
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
|
||||
assert.Equal(t, "untrusted", ifcMap["integrity"])
|
||||
assert.Equal(t, "public", ifcMap["confidentiality"])
|
||||
})
|
||||
}
|
||||
|
||||
func Test_ProjectsGet_GetProjectField(t *testing.T) {
|
||||
toolDef := ProjectsGet(translations.NullTranslationHelper)
|
||||
|
||||
@@ -1091,6 +1354,7 @@ func Test_ProjectsList_ListProjectStatusUpdates(t *testing.T) {
|
||||
githubv4mock.DataResponse(map[string]any{
|
||||
"user": map[string]any{
|
||||
"projectV2": map[string]any{
|
||||
"public": false,
|
||||
"statusUpdates": map[string]any{
|
||||
"nodes": []map[string]any{
|
||||
{
|
||||
@@ -1161,6 +1425,7 @@ func Test_ProjectsGet_GetProjectStatusUpdate(t *testing.T) {
|
||||
"startDate": "2026-01-01",
|
||||
"targetDate": "2026-03-01",
|
||||
"creator": map[string]any{"login": "octocat"},
|
||||
"project": map[string]any{"public": false},
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -115,7 +115,7 @@ Possible options:
|
||||
// visibility lookup fails the label is omitted rather than
|
||||
// misclassifying the result.
|
||||
attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult {
|
||||
return attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, r, ifc.LabelListIssues)
|
||||
return attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, r, ifc.LabelRepoUserContent)
|
||||
}
|
||||
|
||||
switch method {
|
||||
@@ -1436,7 +1436,7 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool
|
||||
result := utils.NewToolResultText(string(r))
|
||||
// Pull request titles/bodies are user-authored (untrusted);
|
||||
// confidentiality follows repo visibility.
|
||||
result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelListIssues)
|
||||
result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoUserContent)
|
||||
return result, nil, nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2121,9 +2121,10 @@ func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.Ser
|
||||
result := utils.NewToolResultText(string(r))
|
||||
// A starred-repository listing exposes repository data across many
|
||||
// repos; reuse the multi-repo join shared with search_repositories
|
||||
// (untrusted integrity; confidentiality private if any matched repo
|
||||
// is private). Visibility is read directly from the response, so no
|
||||
// extra API call is needed.
|
||||
// (public-only results stay public-untrusted, mixed-visibility
|
||||
// results become private-untrusted, all-private results become
|
||||
// private-trusted). Visibility is read directly from the response,
|
||||
// so no extra API call is needed.
|
||||
visibilities := make([]bool, 0, len(minimalRepos))
|
||||
for _, mr := range minimalRepos {
|
||||
visibilities = append(visibilities, mr.Private)
|
||||
|
||||
@@ -173,8 +173,9 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo
|
||||
// every matched repository and attaches the result to callResult when IFC
|
||||
// labels are enabled. Visibility is read directly from the search response —
|
||||
// no extra API call. The join math is shared with search_issues via
|
||||
// ifc.LabelSearchIssues: integrity is always untrusted; confidentiality is
|
||||
// private if any matched repository is private, otherwise public. The
|
||||
// ifc.LabelSearchIssues: public-only results stay public-untrusted,
|
||||
// mixed-visibility results become private-untrusted, and all-private results
|
||||
// become private-trusted. The
|
||||
// feature-flag check is centralized here (mirroring the attach* helpers in
|
||||
// ifc_labels.go) so the handler can call this unconditionally.
|
||||
func attachSearchRepositoriesIFCLabel(ctx context.Context, deps ToolDependencies, repos []*github.Repository, callResult *mcp.CallToolResult) {
|
||||
@@ -302,9 +303,9 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool {
|
||||
}
|
||||
|
||||
callResult := utils.NewToolResultText(string(r))
|
||||
// Code search spans repositories and exposes file contents
|
||||
// (untrusted). Confidentiality is the IFC join across every matched
|
||||
// repository's visibility, read directly from the search response.
|
||||
// Code search spans repositories; the IFC label is the conservative
|
||||
// join across every matched repository's visibility, read directly
|
||||
// from the search response.
|
||||
visibilities := make([]bool, 0, len(result.CodeResults))
|
||||
for _, code := range result.CodeResults {
|
||||
if code.Repository != nil {
|
||||
@@ -593,9 +594,9 @@ func SearchCommits(t translations.TranslationHelperFunc) inventory.ServerTool {
|
||||
}
|
||||
|
||||
callResult := utils.NewToolResultText(string(r))
|
||||
// Commit search spans repositories and exposes commit content
|
||||
// (untrusted). Confidentiality is the IFC join across every matched
|
||||
// repository's visibility, read directly from the search response.
|
||||
// Commit search spans repositories; the IFC label is the conservative
|
||||
// join across every matched repository's visibility, read directly
|
||||
// from the search response.
|
||||
visibilities := make([]bool, 0, len(result.Commits))
|
||||
for _, commit := range result.Commits {
|
||||
if commit.Repository != nil {
|
||||
|
||||
@@ -238,7 +238,7 @@ func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) {
|
||||
assert.Equal(t, "public", ifcMap["confidentiality"])
|
||||
})
|
||||
|
||||
t.Run("insiders mode any private match emits private untrusted", func(t *testing.T) {
|
||||
t.Run("insiders mode mixed public and private emits private untrusted", func(t *testing.T) {
|
||||
deps := BaseDeps{
|
||||
Client: mustNewGHClient(t, makeMockClient([]repoFixture{
|
||||
{owner: "octocat", name: "private-repo", isPrivate: true},
|
||||
|
||||
+87
-35
@@ -76,10 +76,23 @@ func LabelGetMe() SecurityLabel {
|
||||
// LabelListIssues returns the IFC label for a list_issues result.
|
||||
// Public repositories are universally readable; private repositories are
|
||||
// restricted to their collaborators (resolved client-side from the marker).
|
||||
// Issue contents are attacker-controllable, so integrity is always untrusted.
|
||||
// Public repository issue contents are attacker-controllable, while private
|
||||
// repository issues are treated as trusted collaborator-authored data.
|
||||
func LabelListIssues(isPrivate bool) SecurityLabel {
|
||||
if isPrivate {
|
||||
return PrivateUntrusted()
|
||||
return PrivateTrusted()
|
||||
}
|
||||
return PublicUntrusted()
|
||||
}
|
||||
|
||||
// LabelRepoUserContent returns the IFC label for user-authored content scoped
|
||||
// to a repository when that tool has not opted into a more specific integrity
|
||||
// policy. Public repository content is untrusted because it may be authored by
|
||||
// outside contributors. Private repository content is trusted because users who
|
||||
// can read it are trusted collaborators.
|
||||
func LabelRepoUserContent(isPrivate bool) SecurityLabel {
|
||||
if isPrivate {
|
||||
return PrivateTrusted()
|
||||
}
|
||||
return PublicUntrusted()
|
||||
}
|
||||
@@ -99,11 +112,12 @@ func LabelGetFileContents(isPrivate bool) SecurityLabel {
|
||||
// result, joining per-repository labels across all matched repositories.
|
||||
// Used by both search_issues and search_repositories.
|
||||
//
|
||||
// Integrity is always untrusted because results expose user-authored content.
|
||||
//
|
||||
// Confidentiality follows the IFC meet (greatest lower bound): if any matched
|
||||
// repository is private the joined label is private; otherwise public. The
|
||||
// reader set is opaque (the "private" marker); the client engine resolves
|
||||
// Public-only results are untrusted and public. All-private results are trusted
|
||||
// and private because private repository content is treated as trusted
|
||||
// collaborator-authored data. Mixed public/private results are untrusted and
|
||||
// private: the public items keep the joined payload's integrity untrusted,
|
||||
// while the private items keep the joined payload's confidentiality private.
|
||||
// The reader set is opaque (the "private" marker); the client engine resolves
|
||||
// concrete readers on demand at egress decision time.
|
||||
//
|
||||
// An empty result set is treated as public-untrusted (no repository data is
|
||||
@@ -119,12 +133,22 @@ func LabelGetFileContents(isPrivate bool) SecurityLabel {
|
||||
// until then they would invite unsafe declassification of a "public" item that
|
||||
// actually arrived alongside private data.
|
||||
func LabelSearchIssues(repoVisibilities []bool) SecurityLabel {
|
||||
var anyPrivate, anyPublic bool
|
||||
for _, isPrivate := range repoVisibilities {
|
||||
if isPrivate {
|
||||
return PrivateUntrusted()
|
||||
anyPrivate = true
|
||||
} else {
|
||||
anyPublic = true
|
||||
}
|
||||
}
|
||||
return PublicUntrusted()
|
||||
switch {
|
||||
case anyPrivate && anyPublic:
|
||||
return PrivateUntrusted()
|
||||
case anyPrivate:
|
||||
return PrivateTrusted()
|
||||
default:
|
||||
return PublicUntrusted()
|
||||
}
|
||||
}
|
||||
|
||||
// LabelRepoMetadata returns the IFC label for structural repository metadata
|
||||
@@ -261,47 +285,75 @@ func LabelRepositorySecurityAdvisory(isPrivate bool, allPublished bool) Security
|
||||
// LabelGist returns the IFC label for gist content.
|
||||
//
|
||||
// Integrity is untrusted: gist contents are arbitrary user-authored text.
|
||||
// Confidentiality derives from the gist's own visibility rather than any
|
||||
// repository — public gists are universally readable, while secret gists are
|
||||
// restricted to those who hold the gist URL (modeled with the opaque "private"
|
||||
// marker).
|
||||
func LabelGist(isPublic bool) SecurityLabel {
|
||||
if isPublic {
|
||||
return PublicUntrusted()
|
||||
}
|
||||
return PrivateUntrusted()
|
||||
// Confidentiality is public because secret gists are URL-accessible and cannot
|
||||
// be modeled as private to a GitHub reader set.
|
||||
func LabelGist() SecurityLabel {
|
||||
return PublicUntrusted()
|
||||
}
|
||||
|
||||
// LabelGistList returns the IFC label for a list of gists belonging to a user,
|
||||
// joining the per-gist confidentiality across the result set.
|
||||
//
|
||||
// Integrity is untrusted (user-authored content). Confidentiality follows the
|
||||
// IFC meet: if any gist in the result is secret the joined label is private;
|
||||
// otherwise public. An empty result is treated as public-untrusted.
|
||||
// Integrity is untrusted (user-authored content). Confidentiality is public
|
||||
// because even secret gists are URL-accessible.
|
||||
//
|
||||
// See LabelSearchIssues for why list results carry a single joined label
|
||||
// rather than one label per item.
|
||||
func LabelGistList(gistVisibilities []bool) SecurityLabel {
|
||||
for _, isPublic := range gistVisibilities {
|
||||
if !isPublic {
|
||||
return PrivateUntrusted()
|
||||
}
|
||||
func LabelGistList() SecurityLabel {
|
||||
return PublicUntrusted()
|
||||
}
|
||||
|
||||
// LabelProject returns the IFC label for GitHub Project metadata (Projects v2),
|
||||
// such as get_project results and project field definitions.
|
||||
//
|
||||
// Public project metadata can contain public user-authored text, so it is
|
||||
// untrusted. Private project metadata is treated as trusted
|
||||
// collaborator-controlled data.
|
||||
//
|
||||
// Confidentiality derives from the project's own privacy — private projects
|
||||
// restrict the reader set, while public projects are universally readable.
|
||||
func LabelProject(isPrivate bool) SecurityLabel {
|
||||
if isPrivate {
|
||||
return PrivateTrusted()
|
||||
}
|
||||
return PublicUntrusted()
|
||||
}
|
||||
|
||||
// LabelProject returns the IFC label for a GitHub Project (Projects v2) and its
|
||||
// items, status updates, and field definitions.
|
||||
// LabelProjectList returns the IFC label for a list_projects result, joining
|
||||
// the per-project labels across every returned project.
|
||||
//
|
||||
// Integrity is untrusted: project titles, item content, and status update
|
||||
// bodies are user-authored free text. Confidentiality derives from the
|
||||
// project's own public flag — public projects are universally readable, while
|
||||
// private projects restrict the reader set.
|
||||
func LabelProject(isPublic bool) SecurityLabel {
|
||||
if isPublic {
|
||||
// Public-only results are untrusted and public. All-private results are trusted
|
||||
// and private. Mixed public/private results are untrusted and private: public
|
||||
// items keep the joined payload's integrity untrusted, while private items keep
|
||||
// the joined payload's confidentiality private.
|
||||
func LabelProjectList(projectVisibilities []bool) SecurityLabel {
|
||||
var anyPrivate, anyPublic bool
|
||||
for _, isPrivate := range projectVisibilities {
|
||||
if isPrivate {
|
||||
anyPrivate = true
|
||||
} else {
|
||||
anyPublic = true
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case anyPrivate && anyPublic:
|
||||
return PrivateUntrusted()
|
||||
case anyPrivate:
|
||||
return PrivateTrusted()
|
||||
default:
|
||||
return PublicUntrusted()
|
||||
}
|
||||
return PrivateUntrusted()
|
||||
}
|
||||
|
||||
// LabelProjectContent returns the IFC label for project results that can include
|
||||
// item content, field values, or status update bodies. These can aggregate
|
||||
// content from a variety of sources, so integrity remains untrusted even when
|
||||
// the project is private.
|
||||
func LabelProjectContent(isPrivate bool) SecurityLabel {
|
||||
if isPrivate {
|
||||
return PrivateUntrusted()
|
||||
}
|
||||
return PublicUntrusted()
|
||||
}
|
||||
|
||||
// LabelTeam returns the IFC label for organization team membership data
|
||||
|
||||
+118
-45
@@ -6,36 +6,78 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLabelListIssues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("public repo issues are untrusted and public", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
label := LabelListIssues(false)
|
||||
assert.Equal(t, IntegrityUntrusted, label.Integrity)
|
||||
assert.Equal(t, ConfidentialityPublic, label.Confidentiality)
|
||||
})
|
||||
|
||||
t.Run("private repo issues are trusted and private", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
label := LabelListIssues(true)
|
||||
assert.Equal(t, IntegrityTrusted, label.Integrity)
|
||||
assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLabelRepoUserContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("public repo user content is untrusted and public", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
label := LabelRepoUserContent(false)
|
||||
assert.Equal(t, IntegrityUntrusted, label.Integrity)
|
||||
assert.Equal(t, ConfidentialityPublic, label.Confidentiality)
|
||||
})
|
||||
|
||||
t.Run("private repo user content is trusted and private", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
label := LabelRepoUserContent(true)
|
||||
assert.Equal(t, IntegrityTrusted, label.Integrity)
|
||||
assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLabelSearchIssues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
visibilities []bool
|
||||
visibilities []bool // true == private
|
||||
wantIntegrity Integrity
|
||||
wantConfidential Confidentiality
|
||||
}{
|
||||
{
|
||||
name: "empty result is treated as public",
|
||||
wantIntegrity: IntegrityUntrusted,
|
||||
wantConfidential: ConfidentialityPublic,
|
||||
},
|
||||
{
|
||||
name: "single public repo",
|
||||
visibilities: []bool{false},
|
||||
wantIntegrity: IntegrityUntrusted,
|
||||
wantConfidential: ConfidentialityPublic,
|
||||
},
|
||||
{
|
||||
name: "all public repos stay public",
|
||||
visibilities: []bool{false, false, false},
|
||||
wantIntegrity: IntegrityUntrusted,
|
||||
wantConfidential: ConfidentialityPublic,
|
||||
},
|
||||
{
|
||||
name: "any private match flips to private",
|
||||
name: "mixed public and private repos become untrusted private",
|
||||
visibilities: []bool{false, true, false},
|
||||
wantIntegrity: IntegrityUntrusted,
|
||||
wantConfidential: ConfidentialityPrivate,
|
||||
},
|
||||
{
|
||||
name: "all private repos stay private",
|
||||
name: "all private repos stay trusted private",
|
||||
visibilities: []bool{true, true},
|
||||
wantIntegrity: IntegrityTrusted,
|
||||
wantConfidential: ConfidentialityPrivate,
|
||||
},
|
||||
}
|
||||
@@ -44,7 +86,7 @@ func TestLabelSearchIssues(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
label := LabelSearchIssues(tc.visibilities)
|
||||
assert.Equal(t, IntegrityUntrusted, label.Integrity)
|
||||
assert.Equal(t, tc.wantIntegrity, label.Integrity)
|
||||
assert.Equal(t, tc.wantConfidential, label.Confidentiality)
|
||||
})
|
||||
}
|
||||
@@ -208,56 +250,25 @@ func TestLabelGist(t *testing.T) {
|
||||
|
||||
t.Run("public gist is untrusted and public", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
label := LabelGist(true)
|
||||
label := LabelGist()
|
||||
assert.Equal(t, IntegrityUntrusted, label.Integrity)
|
||||
assert.Equal(t, ConfidentialityPublic, label.Confidentiality)
|
||||
})
|
||||
|
||||
t.Run("secret gist is untrusted and private", func(t *testing.T) {
|
||||
t.Run("secret gist is untrusted and public", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
label := LabelGist(false)
|
||||
label := LabelGist()
|
||||
assert.Equal(t, IntegrityUntrusted, label.Integrity)
|
||||
assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
|
||||
assert.Equal(t, ConfidentialityPublic, label.Confidentiality)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLabelGistList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
visibilities []bool // true == public
|
||||
wantConfidential Confidentiality
|
||||
}{
|
||||
{
|
||||
name: "empty result is treated as public",
|
||||
wantConfidential: ConfidentialityPublic,
|
||||
},
|
||||
{
|
||||
name: "all public gists stay public",
|
||||
visibilities: []bool{true, true},
|
||||
wantConfidential: ConfidentialityPublic,
|
||||
},
|
||||
{
|
||||
name: "any secret gist flips to private",
|
||||
visibilities: []bool{true, false, true},
|
||||
wantConfidential: ConfidentialityPrivate,
|
||||
},
|
||||
{
|
||||
name: "all secret gists stay private",
|
||||
visibilities: []bool{false, false},
|
||||
wantConfidential: ConfidentialityPrivate,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
label := LabelGistList(tc.visibilities)
|
||||
assert.Equal(t, IntegrityUntrusted, label.Integrity)
|
||||
assert.Equal(t, tc.wantConfidential, label.Confidentiality)
|
||||
})
|
||||
}
|
||||
label := LabelGistList()
|
||||
assert.Equal(t, IntegrityUntrusted, label.Integrity)
|
||||
assert.Equal(t, ConfidentialityPublic, label.Confidentiality)
|
||||
}
|
||||
|
||||
func TestLabelProject(t *testing.T) {
|
||||
@@ -265,14 +276,76 @@ func TestLabelProject(t *testing.T) {
|
||||
|
||||
t.Run("public project is untrusted and public", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
label := LabelProject(true)
|
||||
label := LabelProject(false)
|
||||
assert.Equal(t, IntegrityUntrusted, label.Integrity)
|
||||
assert.Equal(t, ConfidentialityPublic, label.Confidentiality)
|
||||
})
|
||||
|
||||
t.Run("private project is untrusted and private", func(t *testing.T) {
|
||||
t.Run("private project metadata is trusted and private", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
label := LabelProject(false)
|
||||
label := LabelProject(true)
|
||||
assert.Equal(t, IntegrityTrusted, label.Integrity)
|
||||
assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLabelProjectList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
visibilities []bool // true == private
|
||||
wantIntegrity Integrity
|
||||
wantConfidential Confidentiality
|
||||
}{
|
||||
{
|
||||
name: "empty result is treated as public",
|
||||
wantIntegrity: IntegrityUntrusted,
|
||||
wantConfidential: ConfidentialityPublic,
|
||||
},
|
||||
{
|
||||
name: "all public projects stay public",
|
||||
visibilities: []bool{false, false},
|
||||
wantIntegrity: IntegrityUntrusted,
|
||||
wantConfidential: ConfidentialityPublic,
|
||||
},
|
||||
{
|
||||
name: "mixed public and private projects become untrusted private",
|
||||
visibilities: []bool{false, true},
|
||||
wantIntegrity: IntegrityUntrusted,
|
||||
wantConfidential: ConfidentialityPrivate,
|
||||
},
|
||||
{
|
||||
name: "all private projects stay trusted private",
|
||||
visibilities: []bool{true, true},
|
||||
wantIntegrity: IntegrityTrusted,
|
||||
wantConfidential: ConfidentialityPrivate,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
label := LabelProjectList(tc.visibilities)
|
||||
assert.Equal(t, tc.wantIntegrity, label.Integrity)
|
||||
assert.Equal(t, tc.wantConfidential, label.Confidentiality)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLabelProjectContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("public project content is untrusted and public", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
label := LabelProjectContent(false)
|
||||
assert.Equal(t, IntegrityUntrusted, label.Integrity)
|
||||
assert.Equal(t, ConfidentialityPublic, label.Confidentiality)
|
||||
})
|
||||
|
||||
t.Run("private project content is untrusted and private", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
label := LabelProjectContent(true)
|
||||
assert.Equal(t, IntegrityUntrusted, label.Integrity)
|
||||
assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user