diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cee8ba4da5..1db06adc6d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ All notable changes to Sourcegraph are documented in this file. - Code Insights creation UI now has auto-save logic and clear all fields functionality [#21744](https://github.com/sourcegraph/sourcegraph/pull/21744) - A new bulk operation to retry many changesets at once has been added to Batch Changes. [#21173](https://github.com/sourcegraph/sourcegraph/pull/21173) - A `security_event_logs` database table has been added in support of upcoming security-related efforts. [#21949](https://github.com/sourcegraph/sourcegraph/pull/21949) +- Added featured Sourcegraph extensions query to the GraphQL API, as well as a section in the extension registry to display featured extensions. [#21665](https://github.com/sourcegraph/sourcegraph/pull/21665) ### Changed diff --git a/client/web/src/extensions/ExtensionCard.scss b/client/web/src/extensions/ExtensionCard.scss index ce2fd297e04..d66379d727c 100644 --- a/client/web/src/extensions/ExtensionCard.scss +++ b/client/web/src/extensions/ExtensionCard.scss @@ -1,6 +1,8 @@ @import '../../../branded/src/components/Toggle.scss'; .extension-card { + --icon-width: 3rem; + &__background-section { height: 4.25rem; position: relative; @@ -19,16 +21,26 @@ opacity: 0.15; } } + + &--featured { + height: 8rem; + } } &__icon { - width: 3rem; + width: var(--icon-width); height: 3rem; object-fit: contain; position: absolute; left: 0; margin-left: 0.75rem; + + &--featured { + // horizontally center icon over ::before pseudo-element background + left: calc(50% - (var(--icon-width) / 2)); + margin-left: 0; + } } &__badge { diff --git a/client/web/src/extensions/ExtensionCard.tsx b/client/web/src/extensions/ExtensionCard.tsx index d4f595b794c..0685402e1ca 100644 --- a/client/web/src/extensions/ExtensionCard.tsx +++ b/client/web/src/extensions/ExtensionCard.tsx @@ -45,6 +45,9 @@ interface Props extends SettingsCascadeProps, PlatformContextProps<'updateSettin settingsURL: string | null | undefined /** The currently authenticated user. */ authenticatedUser: AuthenticatedUser | null + + /** Whether this is a featured extension. */ + featured?: boolean } /** ms after which to remove visual feedback */ @@ -63,6 +66,7 @@ export const ExtensionCard = memo(function ExtensionCard({ viewerSubject, siteSubject, authenticatedUser, + featured, }) { const manifest: ExtensionManifest | undefined = extension.manifest && !isErrorLike(extension.manifest) ? extension.manifest : undefined @@ -174,6 +178,8 @@ export const ExtensionCard = memo(function ExtensionCard({ return headerColorFromExtensionID(extension.id) }, [manifest?.headerColor, extension.id]) + const iconClassName = classNames('extension-card__icon', featured && 'extension-card__icon--featured') + return (
(function ExtensionCard({
{icon ? ( - + ) : isSourcegraphExtension ? ( - + ) : ( - + )} {extension.registryExtension?.isWorkInProgress && ( (function ExtensionCard({ )}
-
+
{extension.manifest ? ( isErrorLike(extension.manifest) ? ( diff --git a/client/web/src/extensions/ExtensionRegistry.tsx b/client/web/src/extensions/ExtensionRegistry.tsx index 87a02ae303c..62d235c1cbf 100644 --- a/client/web/src/extensions/ExtensionRegistry.tsx +++ b/client/web/src/extensions/ExtensionRegistry.tsx @@ -25,7 +25,12 @@ import { eventLogger } from '../tracking/eventLogger' import { ExtensionBanner } from './ExtensionBanner' import { ExtensionRegistrySidenav } from './ExtensionRegistrySidenav' -import { configureExtensionRegistry, ConfiguredExtensionRegistry } from './extensions' +import { + configureExtensionRegistry, + ConfiguredExtensionRegistry, + MinimalConfiguredRegistryExtension, + configureFeaturedExtensions, +} from './extensions' import { ExtensionsAreaRouteContext } from './ExtensionsArea' import { ExtensionsList } from './ExtensionsList' @@ -43,14 +48,20 @@ const URL_QUERY_PARAM = 'query' const URL_CATEGORY_PARAM = 'category' const SHOW_EXPERIMENTAL_EXTENSIONS_KEY = 'show-experimental-extensions' -export type ExtensionListData = typeof LOADING | (ConfiguredExtensionRegistry & { error: string | null }) | ErrorLike +export type ExtensionListData = + | typeof LOADING + | (ConfiguredExtensionRegistry & { + featuredExtensions?: MinimalConfiguredRegistryExtension[] + error: string | null + }) + | ErrorLike export type ExtensionsEnablement = 'all' | 'enabled' | 'disabled' export type ExtensionCategoryOrAll = ExtensionCategory | 'All' const extensionRegistryQuery = gql` - query RegistryExtensions($query: String, $prioritizeExtensionIDs: [String!]!) { + query RegistryExtensions($query: String, $prioritizeExtensionIDs: [String!]!, $getFeatured: Boolean!) { extensionRegistry { extensions(query: $query, prioritizeExtensionIDs: $prioritizeExtensionIDs) { nodes { @@ -58,6 +69,12 @@ const extensionRegistryQuery = gql` } error } + featuredExtensions @include(if: $getFeatured) { + nodes { + ...RegistryExtensionFieldsForList + } + error + } } } fragment RegistryExtensionFieldsForList on RegistryExtension { @@ -95,10 +112,7 @@ const extensionRegistryQuery = gql` } ` -export type ConfiguredExtensionCache = Map< - string, - Pick, 'manifest' | 'id'> -> +export type ConfiguredExtensionCache = Map /** A page that displays overview information about the available extensions. */ export const ExtensionRegistry: React.FunctionComponent = props => { @@ -179,12 +193,19 @@ export const ExtensionRegistry: React.FunctionComponent = props => { query = `${query} category:"${category}"` } + // Only fetch + show featured extensions when there's no query or category selected. + const shouldGetFeaturedExtensions = category === 'All' && query.trim() === '' + const resultOrError = platformContext.requestGraphQL< RegistryExtensionsResult, RegistryExtensionsVariables >({ request: extensionRegistryQuery, - variables: { query, prioritizeExtensionIDs: viewerConfiguredExtensions }, + variables: { + query, + prioritizeExtensionIDs: viewerConfiguredExtensions, + getFeatured: shouldGetFeaturedExtensions, + }, mightContainPrivateInfo: true, }) @@ -206,8 +227,16 @@ export const ExtensionRegistry: React.FunctionComponent = props => { const { error, nodes } = data.extensionRegistry.extensions + const featuredExtensions = data.extensionRegistry.featuredExtensions?.nodes + ? configureFeaturedExtensions( + data.extensionRegistry.featuredExtensions.nodes, + configuredExtensionCache + ) + : undefined + return { error, + featuredExtensions, ...configureExtensionRegistry(nodes, configuredExtensionCache), } }), diff --git a/client/web/src/extensions/ExtensionsList.scss b/client/web/src/extensions/ExtensionsList.scss index e51fff2f973..8c57e88124a 100644 --- a/client/web/src/extensions/ExtensionsList.scss +++ b/client/web/src/extensions/ExtensionsList.scss @@ -2,12 +2,36 @@ &__cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(17.5rem, 1fr)); - grid-auto-rows: minmax(4rem, auto); + grid-auto-rows: minmax(16.25rem, auto); gap: $spacer * 0.75; + + &--featured { + grid-auto-rows: minmax(21.25rem, auto); + } } &__category { margin-top: 2rem; font-size: 1.5rem !important; } + + &__featured-section { + position: relative; + // 2rem gap before next section, 1rem extra for visual effect of ::before pseudo-element. + margin-bottom: 3rem; + // Same as above but for 2rem gap between featured section and search bar. + margin-top: 3rem; + + // Use pseudo-element to make background "bleed out" of the container. + &::before { + content: ''; + position: absolute; + z-index: -1; + inset: -1rem; + background-color: var(--color-bg-1); + border: 1px solid var(--border-color-2); + border-radius: var(--border-radius); + padding: 0.5rem; + } + } } diff --git a/client/web/src/extensions/ExtensionsList.tsx b/client/web/src/extensions/ExtensionsList.tsx index 3c3e55b7adc..cdec25cd944 100644 --- a/client/web/src/extensions/ExtensionsList.tsx +++ b/client/web/src/extensions/ExtensionsList.tsx @@ -87,7 +87,39 @@ export const ExtensionsList: React.FunctionComponent = ({ return } - const { error, extensions, extensionIDsByCategory } = data + const { error, extensions, extensionIDsByCategory, featuredExtensions } = data + + const featuredExtensionsSection = featuredExtensions && featuredExtensions.length > 0 && ( +
+

+ Featured +

+
+ {featuredExtensions.map(featuredExtension => ( + + ))} +
+
+ ) if (Object.keys(extensions).length === 0) { return ( @@ -213,6 +245,7 @@ export const ExtensionsList: React.FunctionComponent = ({ return ( <> {error && } + {featuredExtensionsSection} {categorySections.length > 0 ? ( categorySections ) : ( diff --git a/client/web/src/extensions/extensions.ts b/client/web/src/extensions/extensions.ts index e03ee51fe3a..ee962da7801 100644 --- a/client/web/src/extensions/extensions.ts +++ b/client/web/src/extensions/extensions.ts @@ -14,8 +14,13 @@ import { RegistryExtensionFieldsForList } from '../graphql-operations' import { validCategories } from './extension/extension' import { ConfiguredExtensionCache, ExtensionsEnablement } from './ExtensionRegistry' +export type MinimalConfiguredRegistryExtension = Pick< + ConfiguredRegistryExtension, + 'manifest' | 'id' +> + export interface ConfiguredRegistryExtensions { - [id: string]: Pick, 'manifest' | 'id'> + [id: string]: MinimalConfiguredRegistryExtension } export interface ConfiguredExtensionRegistry { @@ -85,6 +90,30 @@ export function configureExtensionRegistry( return { extensions, extensionIDsByCategory } } +/** + * Configures featured extensions to be displayed on the extension registry. + * + * Share configured extension cache with `configureExtensionRegistry` + * since featured extensions are likely to be displayed twice on the page. + */ +export function configureFeaturedExtensions( + featuredExtensions: RegistryExtensionFieldsForList[], + configuredExtensionCache: ConfiguredExtensionCache +): MinimalConfiguredRegistryExtension[] { + const extensions: MinimalConfiguredRegistryExtension[] = [] + + for (const featuredExtension of featuredExtensions) { + let configuredRegistryExtension = configuredExtensionCache.get(featuredExtension.id) + if (!configuredRegistryExtension) { + configuredRegistryExtension = toConfiguredRegistryExtension(featuredExtension) + configuredExtensionCache.set(featuredExtension.id, configuredRegistryExtension) + } + extensions.push(configuredRegistryExtension) + } + + return extensions +} + /** * Removes extensions that do not satify the enablement filter. * diff --git a/client/web/src/integration/extension-registry.test.ts b/client/web/src/integration/extension-registry.test.ts index 262c99070ed..efe4bc880d4 100644 --- a/client/web/src/integration/extension-registry.test.ts +++ b/client/web/src/integration/extension-registry.test.ts @@ -196,6 +196,7 @@ describe('Extension Registry', () => { error: null, nodes: registryExtensionNodes, }, + featuredExtensions: null, }, }), Extensions: () => ({ @@ -274,7 +275,11 @@ describe('Extension Registry', () => { }) }, 'RegistryExtensions') - assert.deepStrictEqual(request, { query: 'sqs', prioritizeExtensionIDs: ['sqs/word-count'] }) + assert.deepStrictEqual(request, { + getFeatured: false, + query: 'sqs', + prioritizeExtensionIDs: ['sqs/word-count'], + }) }) }) diff --git a/client/web/src/integration/search.test.ts b/client/web/src/integration/search.test.ts index 20db3fdf193..dc3bb7ef847 100644 --- a/client/web/src/integration/search.test.ts +++ b/client/web/src/integration/search.test.ts @@ -222,7 +222,9 @@ describe('Search', () => { test('Is set from the URL query parameter when loading a search-related page', async () => { testContext.overrideGraphQL({ ...commonSearchGraphQLResults, - RegistryExtensions: () => ({ extensionRegistry: { extensions: { error: null, nodes: [] } } }), + RegistryExtensions: () => ({ + extensionRegistry: { extensions: { error: null, nodes: [] }, featuredExtensions: null }, + }), }) testContext.overrideSearchStreamEvents(mockDefaultStreamEvents) diff --git a/cmd/frontend/graphqlbackend/extension_registry.go b/cmd/frontend/graphqlbackend/extension_registry.go index 1c122fc98ce..af4bb6a6fda 100644 --- a/cmd/frontend/graphqlbackend/extension_registry.go +++ b/cmd/frontend/graphqlbackend/extension_registry.go @@ -46,6 +46,7 @@ type ExtensionRegistryResolver interface { PublishExtension(context.Context, *ExtensionRegistryPublishExtensionArgs) (ExtensionRegistryMutationResult, error) DeleteExtension(context.Context, *ExtensionRegistryDeleteExtensionArgs) (*EmptyResponse, error) LocalExtensionIDPrefix() *string + FeaturedExtensions(context.Context) (FeaturedExtensionsConnection, error) ImplementsLocalExtensionRegistry() bool // not exposed via GraphQL // FilterRemoteExtensions enforces `allowRemoteExtensions` by returning a @@ -154,3 +155,9 @@ type RegistryPublisherConnection interface { TotalCount(context.Context) (int32, error) PageInfo(context.Context) (*graphqlutil.PageInfo, error) } + +// FeaturedExtensions is the interface for the GraphQL type FeaturedExtensionsConnection. +type FeaturedExtensionsConnection interface { + Nodes(context.Context) ([]RegistryExtension, error) + Error(context.Context) *string +} diff --git a/cmd/frontend/graphqlbackend/schema.graphql b/cmd/frontend/graphqlbackend/schema.graphql index 44bd00780df..3dabfd7bcd8 100755 --- a/cmd/frontend/graphqlbackend/schema.graphql +++ b/cmd/frontend/graphqlbackend/schema.graphql @@ -5819,6 +5819,10 @@ type ExtensionRegistry { Examples: "sourcegraph.example.com/", "sourcegraph.example.com:1234/" """ localExtensionIDPrefix: String + """ + A list of featured extensions in the registry. + """ + featuredExtensions: FeaturedExtensionsConnection } """ @@ -6072,6 +6076,23 @@ type RegistryExtensionConnection { error: String } +""" +A list of featured extensions in the registry. +""" +type FeaturedExtensionsConnection { + """ + A list of featured registry extensions. + """ + nodes: [RegistryExtension!]! + + """ + Errors that occurred while communicating with remote registries to obtain the list of extensions. + In order to be able to return local extensions even when the remote registry is unreachable, errors are + recorded here instead of in the top-level GraphQL errors list. + """ + error: String +} + """ Aggregate local code intelligence for all ranges that fall between a window of lines in a document. """ diff --git a/cmd/frontend/registry/api/extensions.go b/cmd/frontend/registry/api/extensions.go index 1272367d2f7..fbf3f41c0b1 100644 --- a/cmd/frontend/registry/api/extensions.go +++ b/cmd/frontend/registry/api/extensions.go @@ -204,6 +204,41 @@ func listRemoteRegistryExtensions(ctx context.Context, query string) ([]*registr return xs, nil } +// GetLocalFeaturedExtensions looks up and returns the featured registry extensions in the local registry +// If this is not sourcegraph.com, it is not implemented. +var GetLocalFeaturedExtensions func(ctx context.Context, db dbutil.DB) ([]graphqlbackend.RegistryExtension, error) + +// GetFeaturedExtensions returns the set of featured extensions. +// +// If this is sourcegraph.com, these are local extensions. Otherwise, these are remote extensions +// retrieved from sourcegraph.com. +func GetFeaturedExtensions(ctx context.Context, db dbutil.DB) ([]graphqlbackend.RegistryExtension, error) { + if envvar.SourcegraphDotComMode() && GetLocalFeaturedExtensions != nil { + return GetLocalFeaturedExtensions(ctx, db) + } + + // Get remote featured extensions if the remote registry is sourcegraph.com. + registryURL, err := getRemoteRegistryURL() + if registryURL == nil || registryURL.String() != "https://sourcegraph.com/.api/registry" || err != nil { + return nil, err + } + + remote, err := registry.GetFeaturedExtensions(ctx, registryURL) + if err != nil { + return nil, err + } + remote = FilterRemoteExtensions(remote) + for _, x := range remote { + x.RegistryURL = registryURL.String() + } + registryExtensions := make([]graphqlbackend.RegistryExtension, len(remote)) + for i, x := range remote { + registryExtensions[i] = ®istryExtensionRemoteResolver{v: x} + } + + return registryExtensions, nil +} + // IsWorkInProgressExtension reports whether the extension manifest indicates that this extension is // marked as a work-in-progress extension (by having a "wip": true property, or (for backcompat) a // title that begins with "WIP:" or "[WIP]"). diff --git a/cmd/frontend/registry/api/featured_extensions.go b/cmd/frontend/registry/api/featured_extensions.go new file mode 100644 index 00000000000..d86de115e2f --- /dev/null +++ b/cmd/frontend/registry/api/featured_extensions.go @@ -0,0 +1,45 @@ +package api + +import ( + "context" + "sync" + + "github.com/sourcegraph/sourcegraph/cmd/frontend/graphqlbackend" + "github.com/sourcegraph/sourcegraph/internal/database/dbutil" +) + +type featuredExtensionsResolver struct { + // cache results because they are used by multiple fields + once sync.Once + + featuredExtensions []graphqlbackend.RegistryExtension + err error + db dbutil.DB +} + +func (r *extensionRegistryResolver) FeaturedExtensions(ctx context.Context) (graphqlbackend.FeaturedExtensionsConnection, error) { + return &featuredExtensionsResolver{db: r.db}, nil +} + +func (r *featuredExtensionsResolver) compute(ctx context.Context) ([]graphqlbackend.RegistryExtension, error) { + r.once.Do(func() { + r.featuredExtensions, r.err = GetFeaturedExtensions(ctx, r.db) + }) + return r.featuredExtensions, r.err +} + +func (r *featuredExtensionsResolver) Nodes(ctx context.Context) ([]graphqlbackend.RegistryExtension, error) { + // See (*featuredExtensionsResolver).Error for why we ignore the error. + xs, _ := r.compute(ctx) + return xs, nil +} + +func (r *featuredExtensionsResolver) Error(ctx context.Context) *string { + // See the GraphQL API schema documentation for this field for an explanation of why we return + // errors in this way. + _, err := r.compute(ctx) + if err != nil { + return strptr(err.Error()) + } + return nil +} diff --git a/cmd/frontend/registry/client/client.go b/cmd/frontend/registry/client/client.go index 2eea2f726dd..7b5d66d1d19 100644 --- a/cmd/frontend/registry/client/client.go +++ b/cmd/frontend/registry/client/client.go @@ -72,6 +72,14 @@ func getBy(ctx context.Context, registry *url.URL, op, field, value string) (*Ex return x, nil } +func GetFeaturedExtensions(ctx context.Context, registry *url.URL) ([]*Extension, error) { + var x []*Extension + if err := httpGet(ctx, "registry.GetFeaturedExtensions", toURL(registry, "extensions/featured", nil), &x); err != nil { + return nil, err + } + return x, nil +} + type notFoundError struct{ field, value string } func (notFoundError) NotFound() bool { return true } diff --git a/enterprise/cmd/frontend/internal/registry/extensions.go b/enterprise/cmd/frontend/internal/registry/extensions.go index b2f995d4222..93257cf7c10 100644 --- a/enterprise/cmd/frontend/internal/registry/extensions.go +++ b/enterprise/cmd/frontend/internal/registry/extensions.go @@ -21,6 +21,18 @@ func init() { } return &extensionDBResolver{db: db, v: x}, nil } + + registry.GetLocalFeaturedExtensions = func(ctx context.Context, db dbutil.DB) ([]graphqlbackend.RegistryExtension, error) { + dbExtensions, err := dbExtensions{}.GetFeaturedExtensions(ctx) + if err != nil { + return nil, err + } + registryExtensions := make([]graphqlbackend.RegistryExtension, len(dbExtensions)) + for i, x := range dbExtensions { + registryExtensions[i] = &extensionDBResolver{db: db, v: x} + } + return registryExtensions, nil + } } // prefixLocalExtensionID adds the local registry's extension ID prefix (from diff --git a/enterprise/cmd/frontend/internal/registry/extensions_db.go b/enterprise/cmd/frontend/internal/registry/extensions_db.go index cf8f07a229e..f0d0cc7aba8 100644 --- a/enterprise/cmd/frontend/internal/registry/extensions_db.go +++ b/enterprise/cmd/frontend/internal/registry/extensions_db.go @@ -11,6 +11,7 @@ import ( "github.com/keegancsmith/sqlf" "github.com/pkg/errors" + "github.com/sourcegraph/sourcegraph/cmd/frontend/envvar" registry "github.com/sourcegraph/sourcegraph/cmd/frontend/registry/api" "github.com/sourcegraph/sourcegraph/internal/database" "github.com/sourcegraph/sourcegraph/internal/database/dbconn" @@ -180,6 +181,52 @@ func (s dbExtensions) GetByExtensionID(ctx context.Context, extensionID string) return results[0], nil } +// Temporary: we manually set these. Featured extensions live on sourcegraph.com, all other instances ask +// dotcom for these extensions and filter based on site configuration. +var featuredExtensionIDs = []string{"sourcegraph/codecov", "sourcegraph/sentry", "sourcegraph/vscode-extras"} + +// GetFeaturedExtensions retrieves the set of currently featured extensions. +// This should only be called on dotcom; all other instances should retrieve these +// extensions from dotcom through the HTTP API. +// +// 🚨 SECURITY: The caller must ensure that the actor is permitted to view these registry extensions. +func (s dbExtensions) GetFeaturedExtensions(ctx context.Context) ([]*dbExtension, error) { + if envvar.SourcegraphDotComMode() { + return s.getFeaturedExtensions(ctx, featuredExtensionIDs) + } + + return nil, errors.New("GetFeaturedExtensions should only be called on Sourcegraph.com") +} + +func (s dbExtensions) getFeaturedExtensions(ctx context.Context, featuredExtensionIDs []string) ([]*dbExtension, error) { + if mocks.extensions.GetFeaturedExtensions != nil { + return mocks.extensions.GetFeaturedExtensions() + } + + conds := make([]*sqlf.Query, 0, len(featuredExtensionIDs)) + + for i := 0; i < len(featuredExtensionIDs); i++ { + extensionID := featuredExtensionIDs[i] + parts := strings.SplitN(extensionID, "/", 2) + if len(parts) < 2 { + continue + } + publisherName := parts[0] + extensionName := parts[1] + conds = append(conds, sqlf.Sprintf("(x.name=%s AND (users.username=%s OR orgs.name=%s))", extensionName, publisherName, publisherName)) + } + + conds = []*sqlf.Query{ + sqlf.Join(conds, " OR "), + } + + results, err := s.list(ctx, conds, nil, nil) + if err != nil { + return nil, err + } + return results, nil +} + // dbExtensionsListOptions contains options for listing registry extensions. type dbExtensionsListOptions struct { Publisher dbPublisher @@ -366,10 +413,11 @@ func (dbExtensions) Delete(ctx context.Context, id int32) error { // mockExtensions mocks the registry extensions store. type mockExtensions struct { - Create func(publisherUserID, publisherOrgID int32, name string) (int32, error) - GetByID func(id int32) (*dbExtension, error) - GetByUUID func(uuid string) (*dbExtension, error) - GetByExtensionID func(extensionID string) (*dbExtension, error) - Update func(id int32, name *string) error - Delete func(id int32) error + Create func(publisherUserID, publisherOrgID int32, name string) (int32, error) + GetByID func(id int32) (*dbExtension, error) + GetByUUID func(uuid string) (*dbExtension, error) + GetByExtensionID func(extensionID string) (*dbExtension, error) + GetFeaturedExtensions func() ([]*dbExtension, error) + Update func(id int32, name *string) error + Delete func(id int32) error } diff --git a/enterprise/cmd/frontend/internal/registry/extensions_db_test.go b/enterprise/cmd/frontend/internal/registry/extensions_db_test.go index c2f27a61670..ea3d2c8c4d5 100644 --- a/enterprise/cmd/frontend/internal/registry/extensions_db_test.go +++ b/enterprise/cmd/frontend/internal/registry/extensions_db_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" "github.com/jackc/pgconn" "github.com/sourcegraph/sourcegraph/internal/database" @@ -423,6 +424,68 @@ func TestRegistryExtensions_ListCount(t *testing.T) { }) } +func TestFeaturedExtensions(t *testing.T) { + if testing.Short() { + t.Skip() + } + db := dbtesting.GetDB(t) + ctx := context.Background() + + user, err := database.Users(db).Create(ctx, database.NewUser{Username: "u"}) + if err != nil { + t.Fatal(err) + } + + createAndGet := func(t *testing.T, name, manifest string) *dbExtension { + t.Helper() + xID, err := dbExtensions{}.Create(ctx, user.ID, 0, name) + if err != nil { + t.Fatal(err) + } + if manifest != "" { + _, err = dbReleases{}.Create(ctx, &dbRelease{ + RegistryExtensionID: xID, + CreatorUserID: user.ID, + ReleaseTag: "release", + Manifest: manifest, + Bundle: strptr(""), + SourceMap: strptr(""), + }) + if err != nil { + t.Fatal(err) + } + } + x, err := dbExtensions{}.GetByID(ctx, xID) + if err != nil { + t.Fatal(err) + } + return x + } + + mockFeaturedExtensionIDs := []string{"u/one", "u/two", "u/three"} + + one := createAndGet(t, "one", `{"name": "one", "publisher": "u"}`) + two := createAndGet(t, "two", `{"name": "two", "publisher": "u"}`) + three := createAndGet(t, "three", `{"name": "three", "publisher": "u"}`) + // Non-featured extension shouldn't be returned. + createAndGet(t, "four", `{"name": "four", "publisher": "u"}`) + + want := []*dbExtension{ + one, + two, + three, + } + + featuredExtensions, err := dbExtensions{}.getFeaturedExtensions(ctx, mockFeaturedExtensionIDs) + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(want, featuredExtensions); diff != "" { + t.Fatalf("Mismatch (-want +got):\n%s", diff) + } +} + func asJSON(t *testing.T, v interface{}) string { b, err := json.MarshalIndent(v, "", " ") if err != nil { diff --git a/enterprise/cmd/frontend/internal/registry/http_api.go b/enterprise/cmd/frontend/internal/registry/http_api.go index b57441528b5..551345175bc 100644 --- a/enterprise/cmd/frontend/internal/registry/http_api.go +++ b/enterprise/cmd/frontend/internal/registry/http_api.go @@ -69,6 +69,22 @@ var ( } return toRegistryAPIExtension(ctx, x) } + + registryGetFeaturedExtensions = func(ctx context.Context) ([]*registry.Extension, error) { + dbExtensions, err := dbExtensions{}.GetFeaturedExtensions(ctx) + if err != nil { + return nil, err + } + registryExtensions := []*registry.Extension{} + for _, x := range dbExtensions { + registryExtension, err := toRegistryAPIExtension(ctx, x) + if err != nil { + continue + } + registryExtensions = append(registryExtensions, registryExtension) + } + return registryExtensions, nil + } ) func toRegistryAPIExtension(ctx context.Context, v *dbExtension) (*registry.Extension, error) { @@ -184,6 +200,14 @@ func handleRegistry(w http.ResponseWriter, r *http.Request) (err error) { } result = xs + case urlPath == extensionsPath+"/featured": + operation = "featured" + x, err := registryGetFeaturedExtensions(r.Context()) + if err != nil { + return err + } + result = x + case strings.HasPrefix(urlPath, extensionsPath+"/"): var ( spec = strings.TrimPrefix(urlPath, extensionsPath+"/") @@ -270,4 +294,7 @@ func init() { } return frontendregistry.FindRegistryExtension(xs, "extensionID", extensionID), nil } + registryGetFeaturedExtensions = func(ctx context.Context) ([]*registry.Extension, error) { + return []*registry.Extension{}, nil + } } diff --git a/go.mod b/go.mod index 927b5befa91..80df924f8d1 100644 --- a/go.mod +++ b/go.mod @@ -188,7 +188,7 @@ require ( golang.org/x/sync v0.0.0-20210220032951-036812b2e83c golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e - golang.org/x/tools v0.1.2 + golang.org/x/tools v0.1.3 google.golang.org/api v0.46.0 google.golang.org/genproto v0.0.0-20210517163617-5e0236093d7a google.golang.org/protobuf v1.26.0 diff --git a/go.sum b/go.sum index 07a3ee9a332..7eb62076b6b 100644 --- a/go.sum +++ b/go.sum @@ -1847,8 +1847,8 @@ golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.2 h1:kRBLX7v7Af8W7Gdbbc908OJcdgtK8bOz9Uaj8/F1ACA= -golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.3 h1:L69ShwSZEyCsLKoAxDKeMvLDZkumEe8gXUZAjab0tX8= +golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/package.json b/package.json index 572cbce9198..a30946b3e5a 100644 --- a/package.json +++ b/package.json @@ -350,6 +350,7 @@ "monaco-editor": "^0.24.0", "nice-ticks": "^1.0.1", "open-color": "^1.8.0", + "postcss-inset": "^1.0.0", "pretty-bytes": "^5.3.0", "prop-types": "^15.7.2", "react": "^16.14.0", diff --git a/postcss.config.js b/postcss.config.js index 7eca2906118..b8073bda9d0 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,3 +1,8 @@ module.exports = { - plugins: [require('autoprefixer'), require('postcss-focus-visible'), require('postcss-custom-media')], + plugins: [ + require('autoprefixer'), + require('postcss-focus-visible'), + require('postcss-custom-media'), + require('postcss-inset'), + ], } diff --git a/yarn.lock b/yarn.lock index eb5dc762088..2ca0a63b40d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17705,6 +17705,13 @@ postcss-html@^0.36.0: dependencies: htmlparser2 "^3.10.0" +postcss-inset@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/postcss-inset/-/postcss-inset-1.0.0.tgz#1bc0937996a5f042f7054643dbf8f60e49065fa7" + integrity sha512-GAGG8dCDL8zq3BLo2spfcY7YxVQzIZscHDklcJVy/VI2V5lO8V5N9b/p96411w8nf40v7lx3VOiAzBNJlmrI4Q== + dependencies: + postcss "^6.0.1" + postcss-less@^3.1.4: version "3.1.4" resolved "https://registry.npmjs.org/postcss-less/-/postcss-less-3.1.4.tgz#369f58642b5928ef898ffbc1a6e93c958304c5ad"