diff --git a/docs/user/reference/cli/azldev_component.md b/docs/user/reference/cli/azldev_component.md index 1c78ba8d..34dce1b8 100644 --- a/docs/user/reference/cli/azldev_component.md +++ b/docs/user/reference/cli/azldev_component.md @@ -44,6 +44,7 @@ components defined in the project configuration. * [azldev component diff-sources](azldev_component_diff-sources.md) - Show the diff that overlays apply to a component's sources * [azldev component history](azldev_component_history.md) - Report per-component change activity and customization detail * [azldev component list](azldev_component_list.md) - List components in this project +* [azldev component metadata](azldev_component_metadata.md) - List documentation metadata for components' overlays and groups * [azldev component prepare-sources](azldev_component_prepare-sources.md) - Prepare buildable sources for components * [azldev component query](azldev_component_query.md) - Query info for components in this project * [azldev component render](azldev_component_render.md) - Render post-overlay specs and sidecar files to a checked-in directory diff --git a/docs/user/reference/cli/azldev_component_metadata.md b/docs/user/reference/cli/azldev_component_metadata.md new file mode 100644 index 00000000..5d5c6e7d --- /dev/null +++ b/docs/user/reference/cli/azldev_component_metadata.md @@ -0,0 +1,89 @@ + + +## azldev component metadata + +List documentation metadata for components' overlays and groups + +### Synopsis + +List documentation metadata (category, commits, upstream PR, bug links, +upstream status, etc.) for the selected components. Only entries carrying metadata +are listed; entries with no metadata are excluded. Metadata comes from two sources, +each tagged in the output: + + - 'overlay': metadata attached to one of the component's overlays, declared + inline in the component config or inherited from an overlay file + matched by 'overlay-files' (its [metadata] block applies to every + overlay declared in the file). + - 'group': metadata attached to a component group the component is an + explicit member of (per the group's 'components' list). + +By default both sources are listed. Pass --overlays to show only overlay +metadata, or --groups to show only component-group metadata. + +This command is read-only and does not parse spec files or fetch upstream +sources, so it is fast and works even when locks are missing or stale. + +``` +azldev component metadata [flags] +``` + +### Examples + +``` + # List all metadata (overlays and groups) for all components + azldev component metadata -a + + # List metadata for one component + azldev component metadata -p curl + + # Only overlay metadata + azldev component metadata -p curl --overlays + + # Only component-group metadata + azldev component metadata -p curl --groups + + # Filter by category + azldev component metadata -a --category upstream-backport + + # Filter by upstream status + azldev component metadata -a --upstream-status upstreamable + + # JSON output for scripting + azldev component metadata -a -q -O json +``` + +### Options + +``` + -a, --all-components Include all components + --category string only include entries whose metadata declares this category + -p, --component stringArray Component name pattern + -g, --component-group stringArray Component group name + --groups list component-group metadata (default lists both overlays and groups) + -h, --help help for metadata + --overlays list overlay metadata (default lists both overlays and groups) + -s, --spec-path stringArray Spec path + --upstream-status string only include entries whose metadata declares this upstream status ('upstreamed', 'upstreamable', 'needs-upstream-hook', 'inapplicable', or 'unknown') +``` + +### Options inherited from parent commands + +``` + -y, --accept-all accept all prompts + --color mode output colorization mode {always, auto, never} (default auto) + --config-file stringArray additional TOML config file(s) to merge (may be repeated) + -n, --dry-run dry run only (do not take action) + --network-retries int maximum number of attempts for network operations (minimum 1) (default 3) + --no-default-config disable default configuration + -O, --output-format fmt output format {csv, json, markdown, table} (default table) + --permissive-config do not fail on unknown fields in TOML config files + -C, --project string path to Azure Linux project + -q, --quiet only enable minimal output + -v, --verbose enable verbose output +``` + +### SEE ALSO + +* [azldev component](azldev_component.md) - Manage components + diff --git a/docs/user/reference/config/overlays.md b/docs/user/reference/config/overlays.md index 40e7a6d4..3262a669 100644 --- a/docs/user/reference/config/overlays.md +++ b/docs/user/reference/config/overlays.md @@ -248,6 +248,10 @@ section = "%changelog" lines = ["- Fix CVE-2024-1234"] ``` +### Inspecting overlay metadata + +Use `azldev component metadata` to list documentation metadata for one or more components. By default it lists metadata from both sources — component overlays and the component groups each component belongs to — tagging each row with its `source`, and shows only entries that carry a `[metadata]` block. Pass `--overlays` or `--groups` to restrict to one source. The command is read-only and supports `--category` and `--upstream-status` filters; output is available as a table (default) or JSON (`-O json`). + ## Examples ### Adding a Build Dependency diff --git a/internal/app/azldev/cmds/component/component.go b/internal/app/azldev/cmds/component/component.go index 9cfee2ea..4f085ff4 100644 --- a/internal/app/azldev/cmds/component/component.go +++ b/internal/app/azldev/cmds/component/component.go @@ -33,4 +33,5 @@ components defined in the project configuration.`, queryOnAppInit(app, cmd) renderOnAppInit(app, cmd) updateOnAppInit(app, cmd) + metadataOnAppInit(app, cmd) } diff --git a/internal/app/azldev/cmds/component/metadata.go b/internal/app/azldev/cmds/component/metadata.go new file mode 100644 index 00000000..2512f729 --- /dev/null +++ b/internal/app/azldev/cmds/component/metadata.go @@ -0,0 +1,345 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package component + +import ( + "fmt" + "slices" + "sort" + "strings" + + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev" + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/spf13/cobra" +) + +// MetadataSource identifies where a listed documentation-metadata entry originates. +type MetadataSource string + +const ( + // MetadataSourceOverlay marks an entry describing one of a component's overlays + // (declared inline in the component config or inherited from an overlay-files entry). + MetadataSourceOverlay MetadataSource = "overlay" + + // MetadataSourceGroup marks an entry describing a component group that the component is + // an explicit member of. + MetadataSourceGroup MetadataSource = "group" +) + +// MetadataOptions configures the 'component metadata' subcommand. +type MetadataOptions struct { + // ComponentFilter selects which components to inspect. + ComponentFilter components.ComponentFilter + + // Overlays selects overlay metadata for output. When both Overlays and Groups are + // false, both sources are listed. + Overlays bool + + // Groups selects component-group metadata for output. When both Overlays and Groups + // are false, both sources are listed. + Groups bool + + // Category, when non-empty, filters output to entries whose metadata declares this + // category. + Category string + + // UpstreamStatus, when non-empty, filters output to entries whose metadata declares + // this upstream status (one of the [projectconfig.OverlayUpstreamStatus] values). + UpstreamStatus string +} + +// wantOverlays reports whether overlay entries should be listed. With no source flag set, +// both sources are listed. +func (o *MetadataOptions) wantOverlays() bool { + return o.Overlays || !o.Groups +} + +// wantGroups reports whether component-group entries should be listed. With no source flag +// set, both sources are listed. +func (o *MetadataOptions) wantGroups() bool { + return o.Groups || !o.Overlays +} + +func metadataOnAppInit(_ *azldev.App, parentCmd *cobra.Command) { + parentCmd.AddCommand(NewComponentMetadataCommand()) +} + +// NewComponentMetadataCommand constructs the 'component metadata' subcommand. +func NewComponentMetadataCommand() *cobra.Command { + options := &MetadataOptions{} + + cmd := &cobra.Command{ + Use: "metadata", + Short: "List documentation metadata for components' overlays and groups", + Long: `List documentation metadata (category, commits, upstream PR, bug links, +upstream status, etc.) for the selected components. Only entries carrying metadata +are listed; entries with no metadata are excluded. Metadata comes from two sources, +each tagged in the output: + + - 'overlay': metadata attached to one of the component's overlays, declared + inline in the component config or inherited from an overlay file + matched by 'overlay-files' (its [metadata] block applies to every + overlay declared in the file). + - 'group': metadata attached to a component group the component is an + explicit member of (per the group's 'components' list). + +By default both sources are listed. Pass --overlays to show only overlay +metadata, or --groups to show only component-group metadata. + +This command is read-only and does not parse spec files or fetch upstream +sources, so it is fast and works even when locks are missing or stale.`, + Example: ` # List all metadata (overlays and groups) for all components + azldev component metadata -a + + # List metadata for one component + azldev component metadata -p curl + + # Only overlay metadata + azldev component metadata -p curl --overlays + + # Only component-group metadata + azldev component metadata -p curl --groups + + # Filter by category + azldev component metadata -a --category upstream-backport + + # Filter by upstream status + azldev component metadata -a --upstream-status upstreamable + + # JSON output for scripting + azldev component metadata -a -q -O json`, + RunE: azldev.RunFuncWithExtraArgs(func(env *azldev.Env, args []string) (interface{}, error) { + options.ComponentFilter.ComponentNamePatterns = append(args, options.ComponentFilter.ComponentNamePatterns...) + + return ListMetadata(env, options) + }), + ValidArgsFunction: components.GenerateComponentNameCompletions, + } + + azldev.ExportAsMCPTool(cmd) + + components.AddComponentFilterOptionsToCommand(cmd, &options.ComponentFilter) + + cmd.Flags().BoolVar(&options.Overlays, "overlays", false, + "list overlay metadata (default lists both overlays and groups)") + cmd.Flags().BoolVar(&options.Groups, "groups", false, + "list component-group metadata (default lists both overlays and groups)") + cmd.Flags().StringVar(&options.Category, "category", "", + "only include entries whose metadata declares this category") + cmd.Flags().StringVar(&options.UpstreamStatus, "upstream-status", "", + "only include entries whose metadata declares this upstream status "+ + "('upstreamed', 'upstreamable', 'needs-upstream-hook', 'inapplicable', or 'unknown')") + + // This command is read-only; lock validation is irrelevant. + _ = cmd.Flags().MarkHidden("skip-lock-validation") + + return cmd +} + +// MetadataInfo is the per-entry output for the 'component metadata' subcommand. Each entry +// describes either one of a component's overlays or a component group it belongs to. +type MetadataInfo struct { + // Component is the name of the component the entry relates to. The output is + // pre-sorted by [sortMetadataInfos] (by component, then overlays before groups, then + // index/group name); no `sortkey` tag is set so the table writer preserves that order + // and stays consistent with JSON/CSV output. + Component string `json:"component"` + + // Source identifies whether the entry describes an overlay or a component group. + Source MetadataSource `json:"source"` + + // Index is the 1-based position of the overlay within the component's overlay list. It + // is 0 for group entries. + Index int `json:"index,omitempty" table:",omitempty"` + + // Group is the name of the component group for group entries. Empty for overlay entries. + Group string `json:"group,omitempty" table:",omitempty"` + + // Type is the overlay type (e.g. 'spec-set-tag', 'patch-add'). Empty for group entries. + Type projectconfig.ComponentOverlayType `json:"type,omitempty" table:",omitempty"` + + // Description is the overlay's or group's top-level human-readable description. + Description string `json:"description,omitempty" table:",omitempty"` + + // Category surfaces [projectconfig.OverlayMetadata.Category] for tabular output without + // forcing callers to drill into [MetadataInfo.Metadata]. Empty when the entry has no metadata. + Category projectconfig.OverlayCategory `json:"category,omitempty" table:",omitempty"` + + // UpstreamStatus surfaces [projectconfig.OverlayMetadata.UpstreamStatus] for tabular + // output without forcing callers to drill into [MetadataInfo.Metadata]. Empty when + // the entry has no metadata. + UpstreamStatus projectconfig.OverlayUpstreamStatus `json:"upstreamStatus,omitempty" table:",omitempty"` + + // Metadata is the full metadata for the entry. Nil when it has none. + Metadata *projectconfig.OverlayMetadata `json:"metadata,omitempty" table:"-"` +} + +// ListMetadata returns the documentation-metadata entries across the selected components. +// Only entries carrying metadata are returned; entries with no metadata are excluded. +// Overlay entries and/or component-group entries are included according to the source +// selectors in options (both by default). Lock validation is always skipped - this command +// is read-only. +func ListMetadata(env *azldev.Env, options *MetadataOptions) ([]MetadataInfo, error) { + if options.Category != "" && !projectconfig.OverlayCategory(options.Category).IsValid() { + return nil, fmt.Errorf("%w: unknown overlay category %#q", azldev.ErrInvalidUsage, options.Category) + } + + if options.UpstreamStatus != "" && !projectconfig.OverlayUpstreamStatus(options.UpstreamStatus).IsValid() { + return nil, fmt.Errorf( + "%w: unknown upstream-status value %#q; want 'upstreamed', 'upstreamable', "+ + "'needs-upstream-hook', 'inapplicable', or 'unknown'", + azldev.ErrInvalidUsage, options.UpstreamStatus, + ) + } + + options.ComponentFilter.SkipLockValidation = true + + resolver := components.NewResolver(env) + + comps, err := resolver.FindComponents(&options.ComponentFilter) + if err != nil { + return nil, fmt.Errorf("failed to resolve components:\n%w", err) + } + + results := make([]MetadataInfo, 0) + + for _, comp := range comps.Components() { + name := comp.GetName() + + for _, info := range collectMetadataInfos(env, name, comp.GetConfig(), options) { + if metadataInfoMatchesFilters(info, options) { + results = append(results, info) + } + } + } + + sortMetadataInfos(results) + + return results, nil +} + +// collectMetadataInfos returns the metadata entries for a component, honoring the overlay +// and group source selectors in options. +func collectMetadataInfos( + env *azldev.Env, componentName string, config *projectconfig.ComponentConfig, options *MetadataOptions, +) []MetadataInfo { + infos := make([]MetadataInfo, 0, len(config.Overlays)) + + if options.wantOverlays() { + for idx := range config.Overlays { + overlay := &config.Overlays[idx] + infos = append(infos, buildOverlayMetadataInfo(componentName, idx+1, overlay)) + } + } + + if options.wantGroups() { + groupNames := slices.Clone(env.Config().GroupsByComponent[componentName]) + sort.Strings(groupNames) + + for _, groupName := range groupNames { + group := env.Config().ComponentGroups[groupName] + infos = append(infos, buildGroupMetadataInfo(componentName, groupName, group)) + } + } + + return infos +} + +// buildOverlayMetadataInfo constructs a [MetadataInfo] for one of a component's overlays. +func buildOverlayMetadataInfo( + componentName string, idx int, overlay *projectconfig.ComponentOverlay, +) MetadataInfo { + info := MetadataInfo{ + Component: componentName, + Source: MetadataSourceOverlay, + Index: idx, + Type: overlay.Type, + Description: overlay.Description, + Metadata: overlay.Metadata, + } + + applyMetadataSummary(&info, overlay.Metadata) + + return info +} + +// buildGroupMetadataInfo constructs a [MetadataInfo] for a component group the component +// belongs to. +func buildGroupMetadataInfo( + componentName, groupName string, group projectconfig.ComponentGroupConfig, +) MetadataInfo { + info := MetadataInfo{ + Component: componentName, + Source: MetadataSourceGroup, + Group: groupName, + Description: group.Description, + Metadata: group.Metadata, + } + + applyMetadataSummary(&info, group.Metadata) + + return info +} + +// applyMetadataSummary copies the category and upstream-status summary fields from the +// (possibly nil) metadata onto the info. Entries without metadata leave the fields unset. +func applyMetadataSummary(info *MetadataInfo, metadata *projectconfig.OverlayMetadata) { + if metadata == nil { + return + } + + info.Category = metadata.Category + info.UpstreamStatus = metadata.UpstreamStatus +} + +// metadataInfoMatchesFilters reports whether a [MetadataInfo] passes the user-supplied +// category and upstream-status filters. Entries with no metadata are always excluded - +// the command lists only annotated entries. +func metadataInfoMatchesFilters(info MetadataInfo, options *MetadataOptions) bool { + if info.Metadata == nil { + return false + } + + if options.Category != "" && string(info.Category) != options.Category { + return false + } + + if options.UpstreamStatus != "" && + string(info.Metadata.UpstreamStatus) != options.UpstreamStatus { + return false + } + + return true +} + +// sortMetadataInfos orders entries by component, then overlays before groups. Overlay +// entries keep their natural index order; group entries are ordered by group name. +func sortMetadataInfos(infos []MetadataInfo) { + slices.SortStableFunc(infos, func(left, right MetadataInfo) int { + if c := strings.Compare(left.Component, right.Component); c != 0 { + return c + } + + if c := metadataSourceRank(left.Source) - metadataSourceRank(right.Source); c != 0 { + return c + } + + if left.Source == MetadataSourceGroup { + return strings.Compare(left.Group, right.Group) + } + + return left.Index - right.Index + }) +} + +// metadataSourceRank assigns a stable ordering rank so overlay entries sort before group +// entries regardless of the source label's lexical order. +func metadataSourceRank(source MetadataSource) int { + if source == MetadataSourceOverlay { + return 0 + } + + return 1 +} diff --git a/internal/app/azldev/cmds/component/metadata_test.go b/internal/app/azldev/cmds/component/metadata_test.go new file mode 100644 index 00000000..a4dc7b9e --- /dev/null +++ b/internal/app/azldev/cmds/component/metadata_test.go @@ -0,0 +1,326 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package component_test + +import ( + "testing" + + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/cmds/component" + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/components" + "github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/testutils" + "github.com/microsoft/azure-linux-dev-tools/internal/projectconfig" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewComponentMetadataCommand(t *testing.T) { + cmd := component.NewComponentMetadataCommand() + require.NotNil(t, cmd) + assert.Equal(t, "metadata", cmd.Use) +} + +func TestComponentMetadataCmd_NoMatch(t *testing.T) { + const testComponentName = "test-component" + + testEnv := testutils.NewTestEnv(t) + + cmd := component.NewComponentMetadataCommand() + cmd.SetArgs([]string{testComponentName}) + + err := cmd.ExecuteContext(testEnv.Env) + + // We expect an error because we haven't set up any components. + require.Error(t, err) +} + +// seedComponentsWithOverlays adds two components with a mix of annotated and unannotated +// overlays (no group membership). +func seedComponentsWithOverlays(t *testing.T, testEnv *testutils.TestEnv) { + t.Helper() + + testEnv.Config.Components["pkg-a"] = projectconfig.ComponentConfig{ + Name: "pkg-a", + Spec: projectconfig.SpecSource{Path: "/specs/pkg-a.spec"}, + Overlays: []projectconfig.ComponentOverlay{ + { + Type: projectconfig.ComponentOverlayAddSpecTag, + Tag: "Vendor", + Value: "Microsoft", + Metadata: &projectconfig.OverlayMetadata{ + Category: projectconfig.OverlayCategoryAZLBrandingPolicy, + }, + }, + { + Type: projectconfig.ComponentOverlayAddPatch, + Source: "patches/fix.patch", + Metadata: &projectconfig.OverlayMetadata{ + Category: projectconfig.OverlayCategoryUpstreamBackport, + Commits: []projectconfig.URLRef{{URL: "https://src.fedoraproject.org/rpms/pkg-a/c/abc"}}, + UpstreamStatus: projectconfig.OverlayUpstreamStatusUpstreamable, + }, + }, + { + // No metadata. + Type: projectconfig.ComponentOverlaySearchAndReplaceInSpec, + Regex: "foo", + Replacement: "bar", + }, + }, + } + + testEnv.Config.Components["pkg-b"] = projectconfig.ComponentConfig{ + Name: "pkg-b", + Spec: projectconfig.SpecSource{Path: "/specs/pkg-b.spec"}, + Overlays: []projectconfig.ComponentOverlay{ + { + Type: projectconfig.ComponentOverlayAddSpecTag, + Tag: "Packager", + Value: "azl", + Metadata: &projectconfig.OverlayMetadata{ + Category: projectconfig.OverlayCategoryAZLCompatibility, + Bugs: []projectconfig.URLRef{{URL: "https://github.com/example/repo/issues/1"}}, + UpstreamStatus: projectconfig.OverlayUpstreamStatusUpstreamable, + }, + }, + }, + } +} + +func TestListMetadata_AllOverlays(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + seedComponentsWithOverlays(t, testEnv) + + options := &component.MetadataOptions{ + ComponentFilter: components.ComponentFilter{IncludeAllComponents: true}, + } + + results, err := component.ListMetadata(testEnv.Env, options) + require.NoError(t, err) + require.Len(t, results, 3, "unannotated search-replace overlay must be excluded") + + // Sorted by (component, source, index). + assert.Equal(t, "pkg-a", results[0].Component) + assert.Equal(t, component.MetadataSourceOverlay, results[0].Source) + assert.Equal(t, 1, results[0].Index) + assert.Equal(t, projectconfig.OverlayCategoryAZLBrandingPolicy, results[0].Category) + + assert.Equal(t, "pkg-a", results[1].Component) + assert.Equal(t, 2, results[1].Index) + assert.Equal(t, projectconfig.OverlayCategoryUpstreamBackport, results[1].Category) + require.NotNil(t, results[1].Metadata) + assert.Equal(t, + []projectconfig.URLRef{{URL: "https://src.fedoraproject.org/rpms/pkg-a/c/abc"}}, + results[1].Metadata.Commits, + ) + + assert.Equal(t, "pkg-b", results[2].Component) + assert.Equal(t, projectconfig.OverlayCategoryAZLCompatibility, results[2].Category) +} + +func TestListMetadata_FilterByCategory(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + seedComponentsWithOverlays(t, testEnv) + + options := &component.MetadataOptions{ + ComponentFilter: components.ComponentFilter{IncludeAllComponents: true}, + Category: string(projectconfig.OverlayCategoryUpstreamBackport), + } + + results, err := component.ListMetadata(testEnv.Env, options) + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, "pkg-a", results[0].Component) + assert.Equal(t, 2, results[0].Index) +} + +func TestListMetadata_UnknownCategoryRejected(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + seedComponentsWithOverlays(t, testEnv) + + options := &component.MetadataOptions{ + ComponentFilter: components.ComponentFilter{IncludeAllComponents: true}, + Category: "bogus", + } + + _, err := component.ListMetadata(testEnv.Env, options) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown overlay category") +} + +func TestListMetadata_FilterByUpstreamStatus(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + seedComponentsWithOverlays(t, testEnv) + + options := &component.MetadataOptions{ + ComponentFilter: components.ComponentFilter{IncludeAllComponents: true}, + UpstreamStatus: string(projectconfig.OverlayUpstreamStatusUpstreamable), + } + + results, err := component.ListMetadata(testEnv.Env, options) + require.NoError(t, err) + require.Len(t, results, 2, + "both overlays annotated with upstream-status=upstreamable should be included") + + // Sorted by (component, source, index). + assert.Equal(t, "pkg-a", results[0].Component) + assert.Equal(t, projectconfig.OverlayCategoryUpstreamBackport, results[0].Category) + require.NotNil(t, results[0].Metadata) + assert.Equal(t, + projectconfig.OverlayUpstreamStatusUpstreamable, + results[0].Metadata.UpstreamStatus, + ) + + assert.Equal(t, "pkg-b", results[1].Component) + assert.Equal(t, projectconfig.OverlayCategoryAZLCompatibility, results[1].Category) +} + +func TestListMetadata_UnknownUpstreamStatusRejected(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + seedComponentsWithOverlays(t, testEnv) + + options := &component.MetadataOptions{ + ComponentFilter: components.ComponentFilter{IncludeAllComponents: true}, + UpstreamStatus: "maybe", + } + + _, err := component.ListMetadata(testEnv.Env, options) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown upstream-status value") +} + +func TestListMetadata_FilterByUpstreamStatusInapplicable(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + seedComponentWithGroups(t, testEnv) + + options := &component.MetadataOptions{ + ComponentFilter: components.ComponentFilter{IncludeAllComponents: true}, + UpstreamStatus: string(projectconfig.OverlayUpstreamStatusInapplicable), + } + + results, err := component.ListMetadata(testEnv.Env, options) + require.NoError(t, err) + require.Len(t, results, 1, "only the group annotated as inapplicable should be included") + assert.Equal(t, component.MetadataSourceGroup, results[0].Source) + assert.Equal(t, "annotated-group", results[0].Group) + require.NotNil(t, results[0].Metadata) + assert.Equal(t, + projectconfig.OverlayUpstreamStatusInapplicable, + results[0].Metadata.UpstreamStatus, + ) +} + +// seedComponentWithGroups adds a component with one annotated overlay that belongs to two +// groups, one annotated and one bare. +func seedComponentWithGroups(t *testing.T, testEnv *testutils.TestEnv) { + t.Helper() + + testEnv.Config.Components["pkg-a"] = projectconfig.ComponentConfig{ + Name: "pkg-a", + Spec: projectconfig.SpecSource{Path: "/specs/pkg-a.spec"}, + Overlays: []projectconfig.ComponentOverlay{ + { + Type: projectconfig.ComponentOverlayAddSpecTag, + Tag: "Vendor", + Value: "Microsoft", + Metadata: &projectconfig.OverlayMetadata{ + Category: projectconfig.OverlayCategoryAZLBrandingPolicy, + }, + }, + }, + } + + testEnv.Config.ComponentGroups["annotated-group"] = projectconfig.ComponentGroupConfig{ + Description: "Group with metadata", + Components: []string{"pkg-a"}, + Metadata: &projectconfig.OverlayMetadata{ + Category: projectconfig.OverlayCategoryAZLDisableFlakyTests, + UpstreamStatus: projectconfig.OverlayUpstreamStatusInapplicable, + }, + } + + testEnv.Config.ComponentGroups["bare-group"] = projectconfig.ComponentGroupConfig{ + Description: "Group without metadata", + Components: []string{"pkg-a"}, + } + + // Mirror the reverse index the loader builds for explicit group membership. + testEnv.Config.GroupsByComponent["pkg-a"] = []string{"annotated-group", "bare-group"} +} + +func TestListMetadata_DefaultListsBothSources(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + seedComponentWithGroups(t, testEnv) + + options := &component.MetadataOptions{ + ComponentFilter: components.ComponentFilter{IncludeAllComponents: true}, + } + + results, err := component.ListMetadata(testEnv.Env, options) + require.NoError(t, err) + require.Len(t, results, 2, "one overlay plus one annotated group (bare group excluded)") + + // Overlay sorts before groups. + assert.Equal(t, component.MetadataSourceOverlay, results[0].Source) + assert.Equal(t, 1, results[0].Index) + assert.Empty(t, results[0].Group) + assert.Equal(t, projectconfig.OverlayCategoryAZLBrandingPolicy, results[0].Category) + + // Only the annotated group appears; 'bare-group' has no metadata. + assert.Equal(t, component.MetadataSourceGroup, results[1].Source) + assert.Equal(t, "annotated-group", results[1].Group) + assert.Equal(t, 0, results[1].Index) + require.NotNil(t, results[1].Metadata) + assert.Equal(t, projectconfig.OverlayCategoryAZLDisableFlakyTests, results[1].Category) + assert.Equal(t, + projectconfig.OverlayUpstreamStatusInapplicable, + results[1].UpstreamStatus, + ) +} + +func TestListMetadata_OverlaysOnly(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + seedComponentWithGroups(t, testEnv) + + options := &component.MetadataOptions{ + ComponentFilter: components.ComponentFilter{IncludeAllComponents: true}, + Overlays: true, + } + + results, err := component.ListMetadata(testEnv.Env, options) + require.NoError(t, err) + require.Len(t, results, 1, "only the overlay entry is listed") + assert.Equal(t, component.MetadataSourceOverlay, results[0].Source) +} + +func TestListMetadata_GroupsOnly(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + seedComponentWithGroups(t, testEnv) + + options := &component.MetadataOptions{ + ComponentFilter: components.ComponentFilter{IncludeAllComponents: true}, + Groups: true, + } + + results, err := component.ListMetadata(testEnv.Env, options) + require.NoError(t, err) + require.Len(t, results, 1, "only the annotated group entry is listed (bare group excluded)") + + assert.Equal(t, component.MetadataSourceGroup, results[0].Source) + assert.Equal(t, "annotated-group", results[0].Group) +} + +func TestListMetadata_BothSelectorsListBoth(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + seedComponentWithGroups(t, testEnv) + + options := &component.MetadataOptions{ + ComponentFilter: components.ComponentFilter{IncludeAllComponents: true}, + Overlays: true, + Groups: true, + } + + results, err := component.ListMetadata(testEnv.Env, options) + require.NoError(t, err) + require.Len(t, results, 2, "both selectors set lists overlay and annotated group") +} diff --git a/scenario/__snapshots__/TestMCPServerMode_1.snap.json b/scenario/__snapshots__/TestMCPServerMode_1.snap.json index 876ca6e7..320bda27 100755 --- a/scenario/__snapshots__/TestMCPServerMode_1.snap.json +++ b/scenario/__snapshots__/TestMCPServerMode_1.snap.json @@ -380,6 +380,111 @@ }, "name": "component-list" }, + { + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false + }, + "description": "List documentation metadata for components' overlays and groups", + "inputSchema": { + "properties": { + "accept-all": { + "default": false, + "description": "accept all prompts", + "type": "boolean" + }, + "all-components": { + "default": false, + "description": "Include all components", + "type": "boolean" + }, + "category": { + "default": "", + "description": "only include entries whose metadata declares this category", + "type": "string" + }, + "color": { + "description": "output colorization mode {always, auto, never}", + "type": "string" + }, + "component": { + "description": "Component name pattern", + "type": "string" + }, + "component-group": { + "description": "Component group name", + "type": "string" + }, + "config-file": { + "description": "additional TOML config file(s) to merge (may be repeated)", + "type": "string" + }, + "dry-run": { + "default": false, + "description": "dry run only (do not take action)", + "type": "boolean" + }, + "groups": { + "default": false, + "description": "list component-group metadata (default lists both overlays and groups)", + "type": "boolean" + }, + "network-retries": { + "default": 3, + "description": "maximum number of attempts for network operations (minimum 1)", + "type": "number" + }, + "no-default-config": { + "default": false, + "description": "disable default configuration", + "type": "boolean" + }, + "output-format": { + "description": "output format {csv, json, markdown, table}", + "type": "string" + }, + "overlays": { + "default": false, + "description": "list overlay metadata (default lists both overlays and groups)", + "type": "boolean" + }, + "permissive-config": { + "default": false, + "description": "do not fail on unknown fields in TOML config files", + "type": "boolean" + }, + "project": { + "default": "", + "description": "path to Azure Linux project", + "type": "string" + }, + "quiet": { + "default": false, + "description": "only enable minimal output", + "type": "boolean" + }, + "spec-path": { + "description": "Spec path", + "type": "string" + }, + "upstream-status": { + "default": "", + "description": "only include entries whose metadata declares this upstream status ('upstreamed', 'upstreamable', 'needs-upstream-hook', 'inapplicable', or 'unknown')", + "type": "string" + }, + "verbose": { + "default": false, + "description": "enable verbose output", + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "name": "component-metadata" + }, { "annotations": { "destructiveHint": true,