fix(svelte): Respect cody ignore settings (#63677)

This commit adds support for cody ignore settings to the SvelteKit app.
When cody is disabled on the instance or for the user, the cody button
and sidebar are not shown anymore.

Likewise if the repository is excluded from cody, the buttons and
sidebar are not shown.

The implementation was inspired by `useCodyIngore.ts`, but simplified
for use in the SvelteKit app. It seems that the file exclusion logic is
not actually used for the sidebar and thus was omitted. It also seems
that for the sidebar itself we are only checking whether the current
repository is excluded or not, which lets us simplify the whole setup
and simply pass a boolean (store) from the data loader, indicating
whether cody is enabled or not.

Furthermore I introduced zod to validate that the value of
`codyContextFilters.raw`, which is typed as `JSONValue`, has the
expected shape. We've run into issues in the past where such values have
just been cast to the expected Typescript type. zod adds runtime
validation.

Note that we use JSON schema base validation (with `ajv`) in some
places, but that requires importing and sending the whole JSON schema to
the client, which is something I'd like to avoid. The advantage JSON
schema is that we also use it for generating Go code. We should find a
way to use JSON schema but generate specific validators at build time.
There are also other libraries that do runtime validation and are
smaller but they don't necessarily allow asynchronous validation (which
we want to do because we only want to import the `re2js` library when
necessary; of course we could organize the code differently but it's
nice to be able to encapsulate this logic)

## Test plan

Manual testing and new integration tests.
This commit is contained in:
Felix Kling 2024-07-09 09:40:12 +02:00 committed by GitHub
parent e3d112b01c
commit fb1106be15
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 351 additions and 13 deletions

View File

@ -62,6 +62,7 @@ BUILD_DEPS = [
"//:node_modules/@mdi/js",
"//:node_modules/@reach/combobox",
"//:node_modules/@reach/menu-button",
"//:node_modules/@sourcegraph/cody-context-filters-test-dataset",
"//:node_modules/@types/lodash",
"//:node_modules/@types/node",
"//:node_modules/@types/react",
@ -115,6 +116,7 @@ BUILD_DEPS = [
":node_modules/hotkeys-js",
":node_modules/mermaid",
":node_modules/prismjs",
":node_modules/re2js",
":node_modules/sass",
":node_modules/signale",
":node_modules/svelte",
@ -124,6 +126,7 @@ BUILD_DEPS = [
":node_modules/vite",
":node_modules/vite-plugin-inspect",
":node_modules/wonka",
":node_modules/zod",
] + glob([
"dev/**/*.cjs",
"dev/**/*.ts",

View File

@ -99,8 +99,10 @@
"hotkeys-js": "^3.13.7",
"mermaid": "^10.9.1",
"prismjs": "^1.29.0",
"re2js": "^0.4.1",
"ts-key-enum": "^2.0.12",
"wonka": "^6.3.4"
"wonka": "^6.3.4",
"zod": "^3.23.8"
},
"msw": {
"workerDirectory": "static"

View File

@ -1,3 +1,9 @@
<script context="module" lang="ts">
import { uniqueID } from '$lib/dom'
export const CODY_SIDEBAR_ID = uniqueID('cody-sidebar')
</script>
<script lang="ts">
import { createEventDispatcher } from 'svelte'
@ -12,17 +18,24 @@
export let repository: CodySidebar_ResolvedRevision
export let filePath: string
const headingID = uniqueID('cody-sidebar-heading')
const dispatch = createEventDispatcher<{ close: void }>()
</script>
<div class="root">
<aside id={CODY_SIDEBAR_ID} aria-labelledby={headingID}>
<div class="header">
<h3>
<h3 id={headingID}>
<Icon icon={ISgCody} /> Cody
<Badge variant="warning">Experimental</Badge>
</h3>
<Tooltip tooltip="Close Cody chat">
<Button variant="icon" aria-label="Close Cody" on:click={() => dispatch('close')}>
<Button
variant="icon"
aria-label="Close Cody"
on:click={() => dispatch('close')}
aria-controls={CODY_SIDEBAR_ID}
aria-expanded="true"
>
<Icon icon={ILucideX} inline aria-hidden />
</Button>
</Tooltip>
@ -39,10 +52,10 @@
<a href="/sign-in">Sign in</a> to use Cody.
</Alert>
{/if}
</div>
</aside>
<style lang="scss">
.root {
aside {
display: flex;
flex-direction: column;
height: 100%;

View File

@ -0,0 +1,31 @@
import { describe, test, expect } from 'vitest'
import { testCases } from '@sourcegraph/cody-context-filters-test-dataset/dataset.json'
import { CodyContextFiltersSchema, getFiltersFromCodyContextFilters } from './config'
describe('CodyContextFilters', () => {
test('invalid re2 regex', async () => {
const regexWithLookahead = '\\d(?=\\D)' // not supported in RE2
const result = await CodyContextFiltersSchema.safeParseAsync({
exclude: [{ repoNamePattern: regexWithLookahead }],
})
expect(result.success).toBe(false)
})
})
describe('getFiltersFromCodyContextFilters', () => {
for (const testCase of testCases) {
test(testCase.name, async () => {
const filters = testCase['cody.contextFilters']
if (!filters) {
return
}
const filter = getFiltersFromCodyContextFilters(await CodyContextFiltersSchema.parseAsync(filters))
const gotRepos = testCase.repos.filter(repo => filter(repo.name))
expect(gotRepos).toEqual(testCase.includedRepos)
})
}
})

View File

@ -0,0 +1,58 @@
import { memoize } from 'lodash'
import { z } from 'zod'
// This is used in the site config to enable/disable cody for specific repositories.
/**
* Re2Expression is a zod schema for a RE2 regular expression.
*/
const Re2Expression = z.string().transform(async (val, ctx) => {
try {
const { RE2JS } = await import('re2js')
return RE2JS.compile(val)
} catch (error) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Failed to parse r2 regex',
})
return z.NEVER
}
})
const CodyContextFilterItem = z.object({
repoNamePattern: Re2Expression,
})
/**
* CodyContextFilters is a zod schema for the filters used to enable/disable cody for specific repositories.
* It imports the RE2 parsing library and therefore needs to be
* called with parseSync or safeParseAsync.
*/
export const CodyContextFiltersSchema = z
.object({
include: z.array(CodyContextFilterItem),
exclude: z.array(CodyContextFilterItem),
})
.partial()
/**
* CodyContextFilters describes the filters used to enable/disable cody for specific repositories.
*/
export type CodyContextFilters = z.infer<typeof CodyContextFiltersSchema>
/**
* getFiltersFromCodyContextFilters imports the RE2 parsing library and returns a validation function.
* That function returns true if a repo matches any of the include filters and none of the exclude filters.
*
* If filters include repo name patterns that are not valid regexes the function
* always returns false.
*/
export const getFiltersFromCodyContextFilters = memoize(
({ include, exclude }: CodyContextFilters): ((repoName: string) => boolean) =>
(repoName: string): boolean => {
const isIncluded = !include?.length || include.some(filter => filter.repoNamePattern.matches(repoName))
const isExcluded = exclude?.some(filter => filter.repoNamePattern.matches(repoName))
return isIncluded && !isExcluded
},
filters => filters
)

View File

@ -1,4 +1,5 @@
<script lang="ts">
import { CODY_SIDEBAR_ID } from '$lib/cody/CodySidebar.svelte'
import Icon from '$lib/Icon.svelte'
import { rightPanelOpen } from '$lib/repo/stores'
import Tooltip from '$lib/Tooltip.svelte'
@ -9,7 +10,7 @@
</script>
<Tooltip tooltip="Open Cody chat">
<button on:click={handleClick}>
<button on:click={handleClick} aria-controls={CODY_SIDEBAR_ID} aria-expanded={$rightPanelOpen}>
<Icon icon={ISgCody} />
<span data-action-label>Cody</span>
</button>

View File

@ -97,7 +97,7 @@
let references: RepoPage_ReferencesLocationConnection | null
const fileTreeStore = createFileTreeStore({ fetchFileTreeData: fetchSidebarFileTree })
$: ({ revision = '', parentPath, repoName, resolvedRevision } = data)
$: ({ revision = '', parentPath, repoName, resolvedRevision, isCodyAvailable } = data)
$: fileTreeStore.set({ repoName, revision: resolvedRevision.commitID, path: parentPath })
$: commitHistoryQuery = data.commitHistory
$: lastCommitQuery = data.lastCommit
@ -256,7 +256,7 @@
<Panel order={1} id="main-content-panel">
<slot />
</Panel>
{#if $rightPanelOpen}
{#if $isCodyAvailable && $rightPanelOpen}
<PanelResizeHandle id="right-sidebar-resize-handle" />
<Panel id="right-sidebar-panel" order={2} minSize={20} maxSize={70}>
<CodySidebar

View File

@ -1,15 +1,17 @@
import { dirname } from 'path'
import { from } from 'rxjs'
import { readable, derived, type Readable } from 'svelte/store'
import { CodyContextFiltersSchema, getFiltersFromCodyContextFilters } from '$lib/cody/config'
import type { LineOrPositionOrRange } from '$lib/common'
import { getGraphQLClient, infinityQuery } from '$lib/graphql'
import { getGraphQLClient, infinityQuery, type GraphQLClient } from '$lib/graphql'
import { ROOT_PATH, fetchSidebarFileTree } from '$lib/repo/api/tree'
import { resolveRevision } from '$lib/repo/utils'
import { parseRepoRevision } from '$lib/shared'
import type { LayoutLoad } from './$types'
import { GitHistoryQuery, LastCommitQuery, RepoPage_PreciseCodeIntel } from './layout.gql'
import { CodyContextFiltersQuery, GitHistoryQuery, LastCommitQuery, RepoPage_PreciseCodeIntel } from './layout.gql'
const HISTORY_COMMITS_PER_PAGE = 20
const REFERENCES_PER_PAGE = 20
@ -38,6 +40,7 @@ export const load: LayoutLoad = async ({ parent, params }) => {
fileTree,
filePath,
parentPath,
isCodyAvailable: createCodyAvailableStore(client, repoName),
lastCommit: client.query(LastCommitQuery, {
repoName,
revspec: revision,
@ -147,3 +150,62 @@ export const load: LayoutLoad = async ({ parent, params }) => {
}),
}
}
/**
* Returns a store that indicates whether Cody is available for the current user and repository.
* If cody is not enabled on the instance or for the current user, the store will always return false.
* If this is sourcegraph.com, the store will always return true.
* Otherwise we'll check the site configuration to see if Cody is disabled for the current repository.
* Initially the store will return false until the site configuration is loaded. If there is an
* error loading the site configuration or processing it, the store will return false.
*/
function createCodyAvailableStore(client: GraphQLClient, repoName: string): Readable<boolean> {
if (!window.context.codyEnabledOnInstance || !window.context.codyEnabledForCurrentUser) {
return readable(false)
}
// Cody is always enabled on sourcegraph.com
if (window.context.sourcegraphDotComMode) {
return readable(true)
}
// On enterprise instances, we check whether the site config disables
// cody for specific repos.
const queryResult = readable(
// First check the cache to see if Cody is disabled for the current repo.
client.readQuery(CodyContextFiltersQuery, {}),
set => {
// Then update the store with the latest data.
client.query(CodyContextFiltersQuery, {}, { requestPolicy: 'network-only' }).then(set)
}
)
// NOTE: The way this is implemented won't trigger a GraphQL on data prefetching. This is intentional
// (for now) because we don't want to refetch the data for every data preload.
return derived(queryResult, ($codyContextFilters, set) => {
if (!$codyContextFilters || $codyContextFilters.error) {
// Cody context filters are not available, disable Cody
set(false)
return
}
const filters = $codyContextFilters.data?.site.codyContextFilters.raw
if (!filters) {
// Cody context filters are not defined, enable Cody
set(true)
return
}
CodyContextFiltersSchema.safeParseAsync(filters).then(result => {
if (!result.success) {
// codyContextFilters cannot be parsed properly, disable Cody
// TODO: log error with sentry
set(false)
return
}
if (result.data) {
set(getFiltersFromCodyContextFilters(result.data)(repoName))
}
})
})
}

View File

@ -71,6 +71,7 @@
revision,
resolvedRevision: { commitID },
revisionOverride,
isCodyAvailable,
} = data)
$: blobLoader.set(data.blob)
$: highlightsLoader.set(data.highlights)
@ -185,7 +186,9 @@
<OpenInCodeHostAction data={blob} lineOrPosition={data.lineOrPosition} />
{/if}
<Permalink {commitID} />
<OpenCodyAction />
{#if $isCodyAvailable}
<OpenCodyAction />
{/if}
</svelte:fragment>
<svelte:fragment slot="actionmenu">
<MenuLink href={rawURL} target="_blank">

View File

@ -22,6 +22,7 @@
const treeEntriesWithCommitInfo = createPromiseStore<TreeEntryWithCommitInfo[]>()
$: treeEntriesWithCommitInfo.set(data.treeEntriesWithCommitInfo)
$: isCodyAvailable = data.isCodyAvailable
afterNavigate(() => {
repositoryContext.set({ directoryPath: data.filePath })
@ -38,7 +39,9 @@
<FileHeader type="tree" repoName={data.repoName} revision={data.revision} path={data.filePath}>
<svelte:fragment slot="actions">
<Permalink commitID={data.resolvedRevision.commitID} />
<OpenCodyAction />
{#if isCodyAvailable}
<OpenCodyAction />
{/if}
</svelte:fragment>
</FileHeader>

View File

@ -1,3 +1,11 @@
query CodyContextFiltersQuery {
site {
codyContextFilters(version: V1) {
raw
}
}
}
query GitHistoryQuery($repoName: String!, $revspec: String!, $first: Int, $afterCursor: String, $filePath: String) {
repository(name: $repoName) {
id

View File

@ -312,3 +312,140 @@ test('file popover', async ({ page, sg }, testInfo) => {
await page.locator('span').filter({ hasText: /^src$/ }).getByRole('link').click()
await page.waitForURL(/src$/)
})
test.describe('cody sidebar', () => {
const path = `/${repoName}/-/blob/index.js`
async function hasCody(page: Page): Promise<void> {
const codyButton = page.getByLabel('Open Cody chat')
await expect(codyButton).toBeVisible()
await codyButton.click()
await expect(page.getByRole('complementary', { name: 'Cody' })).toBeVisible()
}
async function doesNotHaveCody(page: Page): Promise<void> {
const codyButton = page.getByLabel('Open Cody chat')
await expect(page.getByRole('link', { name: 'index.js' })).toBeVisible()
await expect(codyButton).not.toBeAttached()
}
test.describe('dotcom', () => {
test.beforeEach(({ sg }) => {
sg.dotcomMode()
})
test('enabled when signed out', async ({ page, sg }) => {
await page.goto(path)
await hasCody(page)
await expect(
page.getByText('Cody is only available to signed-in users. Sign in to use Cody.')
).toBeVisible()
})
test('enabled when signed in', async ({ page, sg }) => {
sg.signIn()
await page.goto(path)
await hasCody(page)
})
test('ignores context filters', async ({ page, sg }) => {
sg.mockTypes({
Site: () => ({
codyContextFilters: {
raw: {
include: [String.raw`source.*`],
},
},
}),
})
await page.goto(path)
await hasCody(page)
})
})
test.describe('enterprise', () => {
test.beforeEach(({ sg }) => {
sg.signIn()
sg.mockTypes({
Site: () => ({
codyContextFilters: {
raw: null,
},
}),
})
})
test('disabled when disabled on instance', async ({ page, sg }) => {
sg.setWindowContext({
codyEnabledOnInstance: false,
})
await page.goto(path)
await doesNotHaveCody(page)
})
test('disabled when disabled for user', async ({ page, sg }) => {
sg.setWindowContext({
codyEnabledOnInstance: true,
codyEnabledForCurrentUser: false,
})
await page.goto(path)
await doesNotHaveCody(page)
})
test('enabled for user', async ({ page, sg }) => {
// teardown takes longer than default timeout
test.setTimeout(10000)
sg.setWindowContext({
codyEnabledOnInstance: true,
codyEnabledForCurrentUser: true,
})
await page.goto(path)
await hasCody(page)
})
test('disabled for excluded repo', async ({ page, sg }) => {
sg.setWindowContext({
codyEnabledOnInstance: true,
codyEnabledForCurrentUser: true,
})
sg.mockTypes({
Site: () => ({
codyContextFilters: {
raw: {
include: [String.raw`source.*`],
},
},
}),
})
await page.goto(path)
await doesNotHaveCody(page)
})
test('disabled with invalid context filter', async ({ page, sg }) => {
sg.setWindowContext({
codyEnabledOnInstance: true,
codyEnabledForCurrentUser: true,
})
sg.mockTypes({
Site: () => ({
codyContextFilters: {
raw: {
include: [String.raw`*`],
},
},
}),
})
await page.goto(path)
await doesNotHaveCody(page)
})
})
})

View File

@ -260,6 +260,13 @@ class Sourcegraph {
})
}
/**
* Mock the current window context to be in "dotcom mode" (sourcegraph.com).
*/
public dotcomMode(): void {
this.setWindowContext({ sourcegraphDotComMode: true })
}
public teardown(): void {
this.graphqlMock.reset()
}

View File

@ -1614,12 +1614,18 @@ importers:
prismjs:
specifier: ^1.29.0
version: 1.29.0
re2js:
specifier: ^0.4.1
version: 0.4.1
ts-key-enum:
specifier: ^2.0.12
version: 2.0.12
wonka:
specifier: ^6.3.4
version: 6.3.4
zod:
specifier: ^3.23.8
version: 3.23.8
devDependencies:
'@faker-js/faker':
specifier: ^8.0.2
@ -27883,6 +27889,10 @@ packages:
/zen-observable@0.8.15:
resolution: {integrity: sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==}
/zod@3.23.8:
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
dev: false
/zone.js@0.11.6:
resolution: {integrity: sha512-umJqFtKyZlPli669gB1gOrRE9hxUUGkZr7mo878z+NEBJZZixJkKeVYfnoLa7g25SseUDc92OZrMKKHySyJrFg==}
dependencies: