mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 13:31:54 +00:00
extensibility: add featured extensions to registry (#21665)
Co-authored-by: ᴜɴᴋɴᴡᴏɴ <joe@sourcegraph.com>
This commit is contained in:
parent
db2f3e8a3a
commit
0517ceb801
@ -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
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<Props>(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<Props>(function ExtensionCard({
|
||||
return headerColorFromExtensionID(extension.id)
|
||||
}, [manifest?.headerColor, extension.id])
|
||||
|
||||
const iconClassName = classNames('extension-card__icon', featured && 'extension-card__icon--featured')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('extension-card card position-relative flex-1', {
|
||||
@ -185,15 +191,16 @@ export const ExtensionCard = memo<Props>(function ExtensionCard({
|
||||
<div
|
||||
className={classNames(
|
||||
'extension-card__background-section d-flex align-items-center',
|
||||
headerColorStyles[headerColorClassName]
|
||||
headerColorStyles[headerColorClassName],
|
||||
featured && 'extension-card__background-section--featured'
|
||||
)}
|
||||
>
|
||||
{icon ? (
|
||||
<img className="extension-card__icon" src={icon} alt="" />
|
||||
<img className={iconClassName} src={icon} alt="" />
|
||||
) : isSourcegraphExtension ? (
|
||||
<DefaultSourcegraphExtensionIcon className="extension-card__icon" />
|
||||
<DefaultSourcegraphExtensionIcon className={iconClassName} />
|
||||
) : (
|
||||
<DefaultExtensionIcon className="extension-card__icon" />
|
||||
<DefaultExtensionIcon className={iconClassName} />
|
||||
)}
|
||||
{extension.registryExtension?.isWorkInProgress && (
|
||||
<ExtensionStatusBadge
|
||||
@ -215,7 +222,12 @@ export const ExtensionCard = memo<Props>(function ExtensionCard({
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 extension-card__description">
|
||||
<div
|
||||
className={classNames(
|
||||
'mt-3 extension-card__description',
|
||||
featured && 'extension-card__description--featured'
|
||||
)}
|
||||
>
|
||||
{extension.manifest ? (
|
||||
isErrorLike(extension.manifest) ? (
|
||||
<span className="text-danger small" title={extension.manifest.message}>
|
||||
|
||||
@ -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<ConfiguredRegistryExtension<RegistryExtensionFieldsForList>, 'manifest' | 'id'>
|
||||
>
|
||||
export type ConfiguredExtensionCache = Map<string, MinimalConfiguredRegistryExtension>
|
||||
|
||||
/** A page that displays overview information about the available extensions. */
|
||||
export const ExtensionRegistry: React.FunctionComponent<Props> = props => {
|
||||
@ -179,12 +193,19 @@ export const ExtensionRegistry: React.FunctionComponent<Props> = 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> = 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),
|
||||
}
|
||||
}),
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,7 +87,39 @@ export const ExtensionsList: React.FunctionComponent<Props> = ({
|
||||
return <ErrorAlert error={data} />
|
||||
}
|
||||
|
||||
const { error, extensions, extensionIDsByCategory } = data
|
||||
const { error, extensions, extensionIDsByCategory, featuredExtensions } = data
|
||||
|
||||
const featuredExtensionsSection = featuredExtensions && featuredExtensions.length > 0 && (
|
||||
<div key="Featured" className="extensions-list__featured-section">
|
||||
<h3
|
||||
className="extensions-list__category mb-3 font-weight-normal"
|
||||
data-test-extension-category-header="Featured"
|
||||
>
|
||||
Featured
|
||||
</h3>
|
||||
<div className="extensions-list__cards extensions-list__cards--featured mt-1">
|
||||
{featuredExtensions.map(featuredExtension => (
|
||||
<ExtensionCard
|
||||
key={featuredExtension.id}
|
||||
subject={subject}
|
||||
viewerSubject={viewerSubject?.subject}
|
||||
siteSubject={siteSubject?.subject}
|
||||
node={featuredExtension}
|
||||
settingsCascade={settingsCascade}
|
||||
platformContext={platformContext}
|
||||
enabled={isExtensionEnabled(settingsCascade.final, featuredExtension.id)}
|
||||
enabledForAllUsers={
|
||||
siteSubject ? isExtensionEnabled(siteSubject.settings, featuredExtension.id) : false
|
||||
}
|
||||
isLightTheme={props.isLightTheme}
|
||||
settingsURL={authenticatedUser?.settingsURL}
|
||||
authenticatedUser={authenticatedUser}
|
||||
featured={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (Object.keys(extensions).length === 0) {
|
||||
return (
|
||||
@ -213,6 +245,7 @@ export const ExtensionsList: React.FunctionComponent<Props> = ({
|
||||
return (
|
||||
<>
|
||||
{error && <ErrorAlert className="mb-2" error={error} />}
|
||||
{featuredExtensionsSection}
|
||||
{categorySections.length > 0 ? (
|
||||
categorySections
|
||||
) : (
|
||||
|
||||
@ -14,8 +14,13 @@ import { RegistryExtensionFieldsForList } from '../graphql-operations'
|
||||
import { validCategories } from './extension/extension'
|
||||
import { ConfiguredExtensionCache, ExtensionsEnablement } from './ExtensionRegistry'
|
||||
|
||||
export type MinimalConfiguredRegistryExtension = Pick<
|
||||
ConfiguredRegistryExtension<RegistryExtensionFieldsForList>,
|
||||
'manifest' | 'id'
|
||||
>
|
||||
|
||||
export interface ConfiguredRegistryExtensions {
|
||||
[id: string]: Pick<ConfiguredRegistryExtension<RegistryExtensionFieldsForList>, '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.
|
||||
*
|
||||
|
||||
@ -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'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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.
|
||||
"""
|
||||
|
||||
@ -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]").
|
||||
|
||||
45
cmd/frontend/registry/api/featured_extensions.go
Normal file
45
cmd/frontend/registry/api/featured_extensions.go
Normal file
@ -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
|
||||
}
|
||||
@ -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 }
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
2
go.mod
2
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
|
||||
|
||||
4
go.sum
4
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=
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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'),
|
||||
],
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user