diff --git a/agent/src/AgentFixupControls.ts b/agent/src/AgentFixupControls.ts index bcb1f28d54d8..73b37bb7427a 100644 --- a/agent/src/AgentFixupControls.ts +++ b/agent/src/AgentFixupControls.ts @@ -116,6 +116,7 @@ export class AgentFixupControls extends FixupCodeLenses { instruction: task.instruction?.toString().trim(), model: task.model.toString().trim(), originalText: task.original, + rules: task.rules, } } } diff --git a/agent/src/agent.ts b/agent/src/agent.ts index 1bad13eda865..3865faa0d8e5 100644 --- a/agent/src/agent.ts +++ b/agent/src/agent.ts @@ -394,7 +394,8 @@ export class Agent extends MessageHandler implements ExtensionClient { } this.workspace.workspaceRootUri = clientInfo.workspaceRootUri - ? vscode.Uri.parse(clientInfo.workspaceRootUri).with({ scheme: 'file' }) + ? // TODO!(sqs): why was this previously set to always be file scheme? + vscode.Uri.parse(clientInfo.workspaceRootUri) : vscode.Uri.from({ scheme: 'file', path: clientInfo.workspaceRootPath ?? undefined, @@ -1148,6 +1149,7 @@ export class Agent extends MessageHandler implements ExtensionClient { range: vscodeRange(params.range), intent: 'edit', mode: params.mode, + rules: params.rules, } if (!this.fixups) return Promise.reject() diff --git a/lib/prompt-editor/src/mentions/mentionMenu/MentionMenuItem.tsx b/lib/prompt-editor/src/mentions/mentionMenu/MentionMenuItem.tsx index fcd56604feb7..f96205743881 100644 --- a/lib/prompt-editor/src/mentions/mentionMenu/MentionMenuItem.tsx +++ b/lib/prompt-editor/src/mentions/mentionMenu/MentionMenuItem.tsx @@ -8,6 +8,7 @@ import { REMOTE_DIRECTORY_PROVIDER_URI, REMOTE_FILE_PROVIDER_URI, REMOTE_REPOSITORY_PROVIDER_URI, + RULES_PROVIDER_URI, SYMBOL_CONTEXT_MENTION_PROVIDER, WEB_PROVIDER_URI, displayLineRange, @@ -18,6 +19,7 @@ import { import { clsx } from 'clsx' import { ArrowRightIcon, + BookCheckIcon, BoxIcon, DatabaseIcon, ExternalLinkIcon, @@ -178,6 +180,7 @@ export const iconForProvider: Record< [REMOTE_FILE_PROVIDER_URI]: FileIcon, [REMOTE_DIRECTORY_PROVIDER_URI]: FolderGitIcon, [WEB_PROVIDER_URI]: LinkIcon, + [RULES_PROVIDER_URI]: BookCheckIcon, } export const iconForItem: Record< diff --git a/lib/prompt-editor/src/nodes/tooltip.ts b/lib/prompt-editor/src/nodes/tooltip.ts index 7e42b7dc1a59..747b22989b15 100644 --- a/lib/prompt-editor/src/nodes/tooltip.ts +++ b/lib/prompt-editor/src/nodes/tooltip.ts @@ -32,7 +32,7 @@ export function tooltipForContextItem(item: SerializedContextItem): string | und return item.mention.data.tooltip } if (item.type === 'openctx') { - return item.uri + return item.description ?? item.uri } return undefined } diff --git a/lib/shared/package.json b/lib/shared/package.json index d1d4d63e21b2..711c60405860 100644 --- a/lib/shared/package.json +++ b/lib/shared/package.json @@ -35,7 +35,8 @@ "ollama": "^0.5.1", "re2js": "^0.4.1", "semver": "^7.5.4", - "vscode-uri": "^3.0.8" + "vscode-uri": "^3.0.8", + "yaml": "^2.3.4" }, "devDependencies": { "@sourcegraph/cody-context-filters-test-dataset": "^1.0.0", diff --git a/lib/shared/src/common/path.ts b/lib/shared/src/common/path.ts index 694e39b7331b..643df9c3ba20 100644 --- a/lib/shared/src/common/path.ts +++ b/lib/shared/src/common/path.ts @@ -2,7 +2,7 @@ import type { URI } from 'vscode-uri' import { isWindows as _isWindows, isMacOS } from './platform' -interface PathFunctions { +export interface PathFunctions { /** * All but the last element of path, or "." if that would be the empty path. */ diff --git a/lib/shared/src/context/openctx/api.ts b/lib/shared/src/context/openctx/api.ts index 4f915c693185..f1949df848af 100644 --- a/lib/shared/src/context/openctx/api.ts +++ b/lib/shared/src/context/openctx/api.ts @@ -1,29 +1,42 @@ import type { Client } from '@openctx/client' +import type { Observable } from 'observable-fns' import type * as vscode from 'vscode' +import { fromLateSetSource, shareReplay, storeLastValue } from '../../misc/observable' -type OpenCtxController = Pick< +export type OpenCtxController = Pick< Client, 'meta' | 'metaChanges' | 'mentions' | 'mentionsChanges' | 'items' -> & {} +> -interface OpenCtx { - controller?: OpenCtxController - disposable?: vscode.Disposable -} +const _openctxController = fromLateSetSource() -export const openCtx: OpenCtx = {} +export const openctxController: Observable = _openctxController.observable.pipe( + shareReplay({ shouldCountRefs: false }) +) /** - * Set the handle to the OpenCtx. If there is an existing handle it will be - * disposed and replaced. + * Set the observable that will be used to provide the global {@link openctxController}. */ -export function setOpenCtx({ controller, disposable }: OpenCtx): void { - const { disposable: oldDisposable } = openCtx +export function setOpenCtxControllerObservable(input: Observable): void { + _openctxController.setSource(input) +} - openCtx.controller = controller - openCtx.disposable = disposable +const { value: syncValue } = storeLastValue(openctxController) - oldDisposable?.dispose() +/** + * The current OpenCtx controller. Callers should use {@link openctxController} instead so that + * they react to changes. This function is provided for old call sites that haven't been updated + * to use an Observable. + * + * Callers should take care to avoid race conditions and prefer observing {@link openctxController}. + * + * Throws if the OpenCtx controller is not yet set. + */ +export function currentOpenCtxController(): OpenCtxController { + if (!syncValue.isSet) { + throw new Error('OpenCtx controller is not initialized') + } + return syncValue.last } export const REMOTE_REPOSITORY_PROVIDER_URI = 'internal-remote-repository-search' @@ -32,3 +45,4 @@ export const REMOTE_DIRECTORY_PROVIDER_URI = 'internal-remote-directory-search' export const WEB_PROVIDER_URI = 'internal-web-provider' export const GIT_OPENCTX_PROVIDER_URI = 'internal-git-openctx-provider' export const CODE_SEARCH_PROVIDER_URI = 'internal-code-search-provider' +export const RULES_PROVIDER_URI = 'internal-rules-provider' diff --git a/lib/shared/src/context/openctx/context.ts b/lib/shared/src/context/openctx/context.ts index 9ea62305079d..21988ca2ce17 100644 --- a/lib/shared/src/context/openctx/context.ts +++ b/lib/shared/src/context/openctx/context.ts @@ -1,6 +1,6 @@ import { URI } from 'vscode-uri' import { type ContextItemOpenCtx, ContextItemSource } from '../../codebase-context/messages' -import { openCtx } from './api' +import { currentOpenCtxController } from './api' // getContextForChatMessage returns context items for a given chat message from the OpenCtx providers. export const getContextForChatMessage = async ( @@ -8,7 +8,7 @@ export const getContextForChatMessage = async ( signal?: AbortSignal ): Promise => { try { - const openCtxClient = openCtx.controller + const openCtxClient = currentOpenCtxController() if (!openCtxClient) { return [] } @@ -52,7 +52,7 @@ export const getContextForChatMessage = async ( content: item.ai?.content || '', provider: 'openctx', source: ContextItemSource.User, // To indicate that this is a user-added item. - }) as ContextItemOpenCtx + }) satisfies ContextItemOpenCtx ) } catch { return [] diff --git a/lib/shared/src/experimentation/FeatureFlagProvider.ts b/lib/shared/src/experimentation/FeatureFlagProvider.ts index f337400fd50b..3407464d2f32 100644 --- a/lib/shared/src/experimentation/FeatureFlagProvider.ts +++ b/lib/shared/src/experimentation/FeatureFlagProvider.ts @@ -125,6 +125,8 @@ export enum FeatureFlag { * Whether the user will see the CTA about upgrading to Sourcegraph Teams */ SourcegraphTeamsUpgradeCTA = 'teams-upgrade-available-cta', + + Rules = 'rules', } const ONE_HOUR = 60 * 60 * 1000 diff --git a/lib/shared/src/index.ts b/lib/shared/src/index.ts index e84be69ebd63..552d6251c014 100644 --- a/lib/shared/src/index.ts +++ b/lib/shared/src/index.ts @@ -352,14 +352,17 @@ export * from './token' export * from './token/constants' export * from './configuration' export { - setOpenCtx, - openCtx, + setOpenCtxControllerObservable, + openctxController, + type OpenCtxController, REMOTE_REPOSITORY_PROVIDER_URI, REMOTE_FILE_PROVIDER_URI, REMOTE_DIRECTORY_PROVIDER_URI, WEB_PROVIDER_URI, GIT_OPENCTX_PROVIDER_URI, CODE_SEARCH_PROVIDER_URI, + currentOpenCtxController, + RULES_PROVIDER_URI, } from './context/openctx/api' export * from './context/openctx/context' export * from './lexicalEditor/editorState' @@ -409,3 +412,11 @@ export { isS2, isWorkspaceInstance } from './sourcegraph-api/environments' export { createGitDiff } from './editor/create-git-diff' export { serialize, deserialize } from './lexicalEditor/atMentionsSerializer' + +export { type Rule, isRuleFilename, ruleTitle, parseRuleFile, ruleSearchPaths } from './rules/rules' +export { + type CandidateRule, + type RuleProvider, + createRuleService, + type RuleService, +} from './rules/service' diff --git a/lib/shared/src/mentions/api.ts b/lib/shared/src/mentions/api.ts index f8b2291b616a..e28bcfdd1833 100644 --- a/lib/shared/src/mentions/api.ts +++ b/lib/shared/src/mentions/api.ts @@ -1,7 +1,7 @@ import type { MetaResult } from '@openctx/client' -import { Observable, map } from 'observable-fns' -import { openCtx } from '../context/openctx/api' -import { distinctUntilChanged } from '../misc/observable' +import { type Observable, map } from 'observable-fns' +import { openctxController } from '../context/openctx/api' +import { distinctUntilChanged, switchMap } from '../misc/observable' /** * Props required by context item providers to return possible context items. @@ -73,18 +73,17 @@ export function openCtxProviderMetadata( } function openCtxMentionProviders(): Observable { - const controller = openCtx.controller - if (!controller) { - return Observable.of([]) - } - - return controller.metaChanges({}, {}).pipe( - map(providers => - providers - .filter(provider => !!provider.mentions) - .map(openCtxProviderMetadata) - .sort((a, b) => (a.title > b.title ? 1 : -1)) - ), - distinctUntilChanged() + return openctxController.pipe( + switchMap(c => + c.metaChanges({}, {}).pipe( + map(providers => + providers + .filter(provider => !!provider.mentions) + .map(openCtxProviderMetadata) + .sort((a, b) => (a.title > b.title ? 1 : -1)) + ), + distinctUntilChanged() + ) + ) ) } diff --git a/lib/shared/src/misc/observable.test.ts b/lib/shared/src/misc/observable.test.ts index 4f99eb699984..e66d50d096e0 100644 --- a/lib/shared/src/misc/observable.test.ts +++ b/lib/shared/src/misc/observable.test.ts @@ -15,6 +15,7 @@ import { fromVSCodeEvent, lifecycle, memoizeLastValue, + merge, observableOfSequence, observableOfTimedSequence, promiseFactoryToObservable, @@ -321,6 +322,16 @@ describe('fromLateSetSource', () => { }) }) +describe('merge', { timeout: 500 }, () => { + test('emits values from all inputs', async () => { + vi.useRealTimers() + const input1 = observableOfTimedSequence(0, 'A', 10, 'B', 10, 'C') + const input2 = observableOfTimedSequence(5, 'x', 10, 'y', 15, 'z') + const observable = merge(input1, input2) + expect(await allValuesFrom(observable)).toEqual(['A', 'x', 'B', 'y', 'C', 'z']) + }) +}) + describe('combineLatest', { timeout: 500 }, () => { afterEach(() => { vi.useRealTimers() diff --git a/lib/shared/src/misc/observable.ts b/lib/shared/src/misc/observable.ts index c93c4ff5d58c..28aa9da0a77a 100644 --- a/lib/shared/src/misc/observable.ts +++ b/lib/shared/src/misc/observable.ts @@ -308,6 +308,33 @@ export const EMPTY = new Observable(observer => { */ export const NEVER: Observable = new Observable(() => {}) +/** + * Merge all {@link Observable}s into a single {@link Observable} that emits each value emitted by + * any of the input observables. + */ +export function merge( + ...observables: { [K in keyof T]: Observable } +): Observable { + return new Observable(observer => { + let completed = 0 + const subscriptions = observables.map(observable => + observable.subscribe({ + next: value => observer.next(value), + error: err => observer.error(err), + complete: () => { + completed++ + if (completed === observables.length) { + observer.complete() + } + }, + }) + ) + return () => { + unsubscribeAll(subscriptions) + } + }) +} + /** * Combine the latest values from multiple {@link Observable}s into a single {@link Observable} that * emits only after all input observables have emitted once. diff --git a/lib/shared/src/misc/rpc/webviewAPI.ts b/lib/shared/src/misc/rpc/webviewAPI.ts index 46f227dd2bec..8fdcc41e055b 100644 --- a/lib/shared/src/misc/rpc/webviewAPI.ts +++ b/lib/shared/src/misc/rpc/webviewAPI.ts @@ -1,4 +1,4 @@ -import { Observable } from 'observable-fns' +import { type Observable, map } from 'observable-fns' import type { AuthStatus, ModelsData, ResolvedConfiguration, UserProductSubscription } from '../..' import type { SerializedPromptEditorState } from '../..' import type { ChatMessage, UserLocalHistory } from '../../chat/transcript/messages' @@ -132,9 +132,24 @@ export function createExtensionAPI( hydratePromptMessage: promptText => hydratePromptMessage(promptText, staticDefaultContext?.initialContext), setChatModel: proxyExtensionAPI(messageAPI, 'setChatModel'), - defaultContext: staticDefaultContext - ? () => Observable.of(staticDefaultContext) - : proxyExtensionAPI(messageAPI, 'defaultContext'), + defaultContext: () => + proxyExtensionAPI(messageAPI, 'defaultContext')().pipe( + map(result => + staticDefaultContext + ? ({ + ...result, + corpusContext: [ + ...result.corpusContext, + ...staticDefaultContext.corpusContext, + ], + initialContext: [ + ...result.initialContext, + ...staticDefaultContext.initialContext, + ], + } satisfies DefaultContext) + : result + ) + ), detectIntent: proxyExtensionAPI(messageAPI, 'detectIntent'), promptsMigrationStatus: proxyExtensionAPI(messageAPI, 'promptsMigrationStatus'), startPromptsMigration: proxyExtensionAPI(messageAPI, 'startPromptsMigration'), diff --git a/lib/shared/src/prompt/prompt-string.ts b/lib/shared/src/prompt/prompt-string.ts index 43f4aabbaddd..c0be0434bc22 100644 --- a/lib/shared/src/prompt/prompt-string.ts +++ b/lib/shared/src/prompt/prompt-string.ts @@ -1,5 +1,6 @@ import dedent from 'dedent' import type * as vscode from 'vscode' +import { URI } from 'vscode-uri' import type { ChatMessage, SerializedChatMessage } from '../chat/transcript/messages' import type { ContextItem } from '../codebase-context/messages' import type { ContextFiltersProvider } from '../cody-ignore/context-filters-provider' @@ -17,6 +18,7 @@ import { createGitDiff } from '../editor/create-git-diff' import { displayPath, displayPathWithLines } from '../editor/displayPath' import { getEditorInsertSpaces, getEditorTabSize } from '../editor/utils' import { logDebug } from '../logger' +import { type Rule, ruleTitle } from '../rules/rules' import { telemetryRecorder } from '../telemetry-v2/singleton' // This module is designed to encourage, and to some degree enforce, safe @@ -397,6 +399,19 @@ export class PromptString { } } + public static fromRule(rule: Rule): { + title: PromptString + description: PromptString | null + instruction: PromptString + } { + const ref = [URI.parse(rule.uri)] + return { + title: internal_createPromptString(ruleTitle(rule), ref), + description: rule.description ? internal_createPromptString(rule.description, ref) : null, + instruction: internal_createPromptString(rule.instruction ?? '', ref), + } + } + // 🚨 Use this function only for user-generated queries. // TODO: Can we detect if the user is pasting in content from a document? public static unsafe_fromUserQuery(string: string): PromptString { diff --git a/lib/shared/src/rules/__testdata__/my.rule.md b/lib/shared/src/rules/__testdata__/my.rule.md new file mode 100644 index 000000000000..a2b1a1e727c9 --- /dev/null +++ b/lib/shared/src/rules/__testdata__/my.rule.md @@ -0,0 +1,5 @@ +--- +title: My rule +--- + +My instruction diff --git a/lib/shared/src/rules/editing-helpers.ts b/lib/shared/src/rules/editing-helpers.ts new file mode 100644 index 000000000000..923fdeeb91cd --- /dev/null +++ b/lib/shared/src/rules/editing-helpers.ts @@ -0,0 +1,29 @@ +import type { Rule } from './rules' + +export const RULE_EDITING_HELPER_RULE: Rule = { + uri: 'sourcegraph-builtin-rule:builtin-rule-editing-helper', + display_name: 'builtin-rule-editing-helper', + title: 'Rule editing helper (builtin)', + description: 'A builtin rule that helps when editing `*.rule.md` files for Sourcegraph.', + instruction: ` +Rule files are Markdown files with YAML front matter. + +The YAML front matter has the following fields: + +- title (required string) +- description (optional string) +- tags (optional string[]) +- langauge (optional string) +- language_filters, repo_filters, path_filters, text_content_filters (optional {include: string[], exclude: string[]}) + +The Markdown body is an LLM prompt that is included in AI code chat and editing on files that the rule applies to: + +- Provide a succinct description of the desired outcome of the rule, written in the same way you would write for an internal code review or style guide. +- Give at least 1 example of bad code and 1 example of good code + `, + tags: ['builtin'], + path_filters: { + // TODO!(sqs): switch this to using globs not regexps + include: ['\\.rule\\.md$'], + }, +} diff --git a/lib/shared/src/rules/filters.test.ts b/lib/shared/src/rules/filters.test.ts new file mode 100644 index 000000000000..5963ddc512d6 --- /dev/null +++ b/lib/shared/src/rules/filters.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import { ruleAppliesToFile } from './filters' + +describe('ruleAppliesToFile', () => { + const testFile: Parameters[1] = { + repo: 'github.com/sourcegraph/sourcegraph', + path: 'lib/shared/src/example.ts', + languages: ['typescript'], + textContent: 'const x = 1', + } + + it('returns true when no filters are specified', () => { + expect(ruleAppliesToFile({}, testFile)).toBe(true) + }) + + it('matches repo filters', () => { + expect( + ruleAppliesToFile( + { + repo_filters: { + include: ['github.com/sourcegraph/.*'], + exclude: ['github.com/sourcegraph/other'], + }, + }, + testFile + ) + ).toBe(true) + expect( + ruleAppliesToFile( + { + repo_filters: { + include: ['github.com/other/.*'], + }, + }, + testFile + ) + ).toBe(false) + }) + + it('matches path filters', () => { + expect( + ruleAppliesToFile( + { + path_filters: { + include: ['.*/src/.*\\.ts$'], + exclude: ['.*/test/.*'], + }, + }, + testFile + ) + ).toBe(true) + expect( + ruleAppliesToFile( + { + path_filters: { + include: ['.*/test/.*'], + }, + }, + testFile + ) + ).toBe(false) + }) + + it('matches language filters', () => { + expect( + ruleAppliesToFile( + { + language_filters: { + include: ['typescript'], + exclude: ['javascript'], + }, + }, + testFile + ) + ).toBe(true) + expect( + ruleAppliesToFile( + { + language_filters: { + include: ['go'], + }, + }, + testFile + ) + ).toBe(false) + }) + + it('matches text content filters', () => { + expect( + ruleAppliesToFile( + { + text_content_filters: { + include: ['const.*='], + exclude: ['function'], + }, + }, + testFile + ) + ).toBe(true) + expect( + ruleAppliesToFile( + { + text_content_filters: { + include: ['function'], + }, + }, + testFile + ) + ).toBe(false) + }) + + it('requires all filters to match', () => { + expect( + ruleAppliesToFile( + { + repo_filters: { include: ['github.com/sourcegraph/.*'] }, + path_filters: { include: ['.*/src/.*\\.ts$'] }, + language_filters: { include: ['typescript'] }, + text_content_filters: { include: ['const.*='] }, + }, + testFile + ) + ).toBe(true) + expect( + ruleAppliesToFile( + { + repo_filters: { include: ['github.com/sourcegraph/.*'] }, + path_filters: { include: ['.*/test/.*'] }, + }, + testFile + ) + ).toBe(false) + }) +}) diff --git a/lib/shared/src/rules/filters.ts b/lib/shared/src/rules/filters.ts new file mode 100644 index 000000000000..75b7c943e528 --- /dev/null +++ b/lib/shared/src/rules/filters.ts @@ -0,0 +1,61 @@ +import type { PatternFilters, Rule } from './rules' + +export interface FileInfoForRuleApplication { + repo: string + path: string + languages: string[] + textContent: string +} + +/** + * Report whether a rule applies to a given file. + * + * TODO(sqs): pre-parse the regexps for perf + * + * @param rule The rule to check. + * @param file Information about the file that the rule may apply to. + * @see [AppliesToFile](https://sourcegraph.sourcegraph.com/github.com/sourcegraph/sourcegraph/-/blob/internal/rule/filters.go) + */ +export function ruleAppliesToFile( + rule: Pick, + file: FileInfoForRuleApplication +): boolean { + if (rule.repo_filters) { + if (!match(rule.repo_filters, file.repo)) { + return false + } + } + + if (rule.path_filters) { + if (!match(rule.path_filters, file.path)) { + return false + } + } + + const language_filters = rule.language_filters + if (language_filters) { + const anyMatch = file.languages.some(language => match(language_filters, language)) + if (!anyMatch) { + return false + } + } + + if (rule.text_content_filters) { + if (!match(rule.text_content_filters, file.textContent)) { + return false + } + } + + // All filters matched, so the file applies to the rule + return true +} + +function match(filters: PatternFilters, value: string): boolean { + if (filters.include && !filters.include.some(pattern => new RegExp(pattern).test(value))) { + return false + } + if (filters.exclude?.some(pattern => new RegExp(pattern).test(value))) { + return false + } + return true +} diff --git a/lib/shared/src/rules/rules.test.ts b/lib/shared/src/rules/rules.test.ts new file mode 100644 index 000000000000..f14994634144 --- /dev/null +++ b/lib/shared/src/rules/rules.test.ts @@ -0,0 +1,125 @@ +import dedent from 'dedent' +import { describe, expect, it } from 'vitest' +import { URI } from 'vscode-uri' +import { type Rule, parseRuleFile, ruleFileDisplayName, ruleSearchPaths } from './rules' + +describe('parseRuleFile', () => { + it('parses rule file content', () => { + const uri = URI.parse('file:///a/b/c/.sourcegraph/foo.rule.md') + const root = URI.parse('file:///a/b') + const content = dedent` + --- + title: My rule + description: My description + tags: ['t1', 't2'] + lang: go + repo_filters: + include: + - r1 + - r2 + exclude: + - r3 + path_filters: + include: + - p1 + - p2 + text_content_filters: + include: + - x + --- + My instruction + ` + + expect(parseRuleFile(uri, root, content)).toStrictEqual({ + uri: uri.toString(), + display_name: 'c/foo', + title: 'My rule', + description: 'My description', + instruction: 'My instruction', + tags: ['t1', 't2'], + language_filters: { include: ['go'] }, + repo_filters: { include: ['r1', 'r2'], exclude: ['r3'] }, + path_filters: { include: ['p1', 'p2'] }, + text_content_filters: { include: ['x'] }, + }) + }) + + it('handles files with no front matter', () => { + const uri = URI.parse('file:///a/b/.sourcegraph/foo.rule.md') + const root = URI.parse('file:///a/b') + const content = dedent` + My instruction + ` + + const result = parseRuleFile(uri, root, content) + + expect(result).toStrictEqual({ + uri: uri.toString(), + display_name: 'foo', + instruction: 'My instruction', + }) + }) + + it('ignores malformed front matter', () => { + expect( + parseRuleFile( + URI.parse('file:///a/b/.sourcegraph/foo.rule.md'), + URI.parse('file:///a/b'), + dedent` + --- + title: My rule + repo_filters: a + path_filters: 2 + language_filters: ['x'] + text_content_filters: null + --- + My instruction + ` + ) + ).toStrictEqual({ + uri: 'file:///a/b/.sourcegraph/foo.rule.md', + display_name: 'foo', + title: 'My rule', + instruction: 'My instruction', + }) + }) +}) + +describe('ruleFileDisplayName', () => { + it('handles root dirs', () => { + const uri = URI.parse('file:///a/b/.sourcegraph/foo.rule.md') + const root = URI.parse('file:///a/b') + expect(ruleFileDisplayName(uri, root)).toBe('foo') + }) + + it('handles non-root dirs', () => { + const uri = URI.parse('file:///a/b/c/.sourcegraph/foo.rule.md') + const root = URI.parse('file:///a/b') + expect(ruleFileDisplayName(uri, root)).toBe('c/foo') + }) + + it('handles deeply nested non-root dirs', () => { + const uri = URI.parse('file:///a/b/c/d/.sourcegraph/foo.rule.md') + const root = URI.parse('file:///a/b') + expect(ruleFileDisplayName(uri, root)).toBe('c/d/foo') + }) +}) + +describe('ruleSearchPaths', () => { + it('returns search paths for .sourcegraph files', () => { + const uri = URI.parse('file:///a/b/c/src/example.ts') + const root = URI.parse('file:///a/b/c') + const searchPaths = ruleSearchPaths(uri, root) + expect(searchPaths.map(u => u.toString())).toStrictEqual([ + 'file:///a/b/c/src/.sourcegraph', + 'file:///a/b/c/.sourcegraph', + ]) + }) + + it('handles root path', () => { + const uri = URI.parse('file:///a/b/c') // is a dir not a file, but test this anyway + const root = URI.parse('file:///a/b/c') + const searchPaths = ruleSearchPaths(uri, root) + expect(searchPaths.map(u => u.toString())).toStrictEqual([]) + }) +}) diff --git a/lib/shared/src/rules/rules.ts b/lib/shared/src/rules/rules.ts new file mode 100644 index 000000000000..d6d3306e57b3 --- /dev/null +++ b/lib/shared/src/rules/rules.ts @@ -0,0 +1,159 @@ +import type { URI } from 'vscode-uri' +import YAML from 'yaml' +import { PromptString, isDefined, pathFunctionsForURI, ps } from '..' + +/** + * From sourcegraph/sourcegraph rule.tsp `Rule`. + * + * Only fields we (currently) need are included. + * + * @see https://sourcegraph.sourcegraph.com/github.com/sourcegraph/sourcegraph/-/blob/internal/openapi/rule.tsp + */ +export interface Rule extends ReviewFilterFields { + uri: string + display_name: string + title?: string | null + description?: string | null + instruction?: string | null + tags?: string[] | null +} + +/** + * From sourcegraph/sourcegraph review.tsp `ReviewFilterFields`. + * + * Only fields we (currently) need are included. + * + * @see https://sourcegraph.sourcegraph.com/github.com/sourcegraph/sourcegraph/-/blob/internal/openapi/review.tsp + */ +interface ReviewFilterFields { + path_filters?: PatternFilters | null + repo_filters?: PatternFilters | null + language_filters?: PatternFilters | null + text_content_filters?: PatternFilters | null +} + +/** + * From sourcegraph/sourcegraph shared.tsp `PatternFilters`. + * + * @see https://sourcegraph.sourcegraph.com/github.com/sourcegraph/sourcegraph/-/blob/internal/openapi/shared.tsp + */ +export interface PatternFilters { + include?: string[] | null + exclude?: string[] | null +} + +/** + * Parse a *.rule.md file. The {@link uri} and {@link root} are used to determine the `uri` and + * `display_name` field values. + * + * @see [parseRuleMarkdown](https://sourcegraph.sourcegraph.com/github.com/sourcegraph/sourcegraph/-/blob/internal/rule/rulemd.go) + */ +export function parseRuleFile(uri: URI, root: URI, content: string): Rule { + const rule: Rule = { + uri: uri.toString(), + display_name: ruleFileDisplayName(uri, root), + instruction: content, + } + + const frontMatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/) + if (frontMatterMatch) { + const [, frontMatter, remainingContent] = frontMatterMatch + const metadata = YAML.parse(frontMatter) + + if (typeof metadata?.title === 'string') { + rule.title = metadata.title + } + + if (typeof metadata?.description === 'string') { + rule.description = metadata.description + } + + rule.instruction = remainingContent.trim() + + if ( + metadata && + Array.isArray(metadata.tags) && + metadata.tags.every((t: any) => typeof t === 'string') + ) { + rule.tags = metadata.tags + } + + if (isValidPatternFilters(metadata.repo_filters)) { + rule.repo_filters = metadata.repo_filters + } + if (isValidPatternFilters(metadata.path_filters)) { + rule.path_filters = metadata.path_filters + } + if (isValidPatternFilters(metadata.language_filters)) { + rule.language_filters = metadata.language_filters + } + if (isValidPatternFilters(metadata.text_content_filters)) { + rule.text_content_filters = metadata.text_content_filters + } + + // `lang: go` is convenience syntax for `language_filters: {include: ["go"]}`. + if (metadata.lang && typeof metadata.lang === 'string') { + rule.language_filters = { include: [metadata.lang] } + } + } + + return rule +} + +function isValidPatternFilters(v: any): v is PatternFilters { + return ( + v && + typeof v === 'object' && + !Array.isArray(v) && + (v.include === undefined || + v.include === null || + (Array.isArray(v.include) && v.include.every((p: any) => typeof p === 'string'))) && + (v.exclude === undefined || + v.exclude === null || + (Array.isArray(v.exclude) && v.exclude.every((p: any) => typeof p === 'string'))) + ) +} + +export function ruleFileDisplayName(uri: URI, root: URI): string { + return pathFunctionsForURI(uri) + .relative(root.path, uri.path) + .replace(/\.sourcegraph\/([^/]+)\.rule\.md$/, '$1') +} + +export function isRuleFilename(file: string | URI): boolean { + return /\.rule\.md$/.test(typeof file === 'string' ? file : file.path) +} + +/** + * Return all search paths (possible `.sourcegraph/` dirs) for a given URI, stopping ascending the + * directory tree at {@link root}. + */ +export function ruleSearchPaths(uri: URI, root: URI): URI[] { + const pathFuncs = pathFunctionsForURI(uri) + const searchPaths: URI[] = [] + let current = uri + while (true) { + if (pathFuncs.relative(current.path, root.path) === '') { + break + } + current = current.with({ path: pathFuncs.dirname(current.path) }) + searchPaths.push(current.with({ path: pathFuncs.resolve(current.path, '.sourcegraph') })) + } + return searchPaths +} + +export function formatRuleForPrompt(rule: Rule): PromptString { + const { title, description, instruction } = PromptString.fromRule(rule) + return PromptString.join( + [ + ps`Title: ${title}`, + description ? ps`Description: ${description}` : undefined, + ps`Instruction: ${instruction}`, + ].filter(isDefined), + ps`\n` + ) +} + +export function ruleTitle(rule: Pick): string { + return rule.title ?? rule.display_name +} diff --git a/lib/shared/src/rules/service.test.ts b/lib/shared/src/rules/service.test.ts new file mode 100644 index 000000000000..ba79e8151332 --- /dev/null +++ b/lib/shared/src/rules/service.test.ts @@ -0,0 +1,44 @@ +import { Observable } from 'observable-fns' +import { describe, expect, it } from 'vitest' +import { URI } from 'vscode-uri' +import { type Rule, firstValueFrom } from '..' +import type { FileInfoForRuleApplication } from './filters' +import { type RuleProvider, createRuleService } from './service' + +describe('createRuleService', () => { + it('combines rules from multiple providers and filters them', async () => { + const rule1: Rule = { + uri: 'file:///a/.sourcegraph/b.rule.md', + display_name: 'b', + title: 'Rule 1', + path_filters: { exclude: ['\\.ts$'] }, + } + const rule2: Rule = { + uri: 'file:///a/b/.sourcegraph/c.rule.md', + display_name: 'b/c', + title: 'Rule 2', + } + + const files = [ + URI.parse('file:///a/x.ts'), + URI.parse('file:///a/b/y.ts'), + URI.parse('file:///a/z.go'), + ] + const provider1: RuleProvider = { + candidateRulesForPaths: () => Observable.of([{ rule: rule1, appliesToFiles: files }]), + } + const provider2: RuleProvider = { + candidateRulesForPaths: () => Observable.of([{ rule: rule2, appliesToFiles: [files[1]] }]), + } + + const fileInfo = (uri: URI): FileInfoForRuleApplication => ({ + path: uri.path, + languages: [], + repo: 'my/repo', + textContent: 'foo', + }) + const service = createRuleService(Observable.of([provider1, provider2]), { fileInfo }) + expect(await firstValueFrom(service.rulesForPaths(files))).toStrictEqual([rule1, rule2]) + expect(await firstValueFrom(service.rulesForPaths([files[0], files[1]]))).toStrictEqual([rule2]) + }) +}) diff --git a/lib/shared/src/rules/service.ts b/lib/shared/src/rules/service.ts new file mode 100644 index 000000000000..45421bf81341 --- /dev/null +++ b/lib/shared/src/rules/service.ts @@ -0,0 +1,108 @@ +import { type Observable, map } from 'observable-fns' +import type { URI } from 'vscode-uri' +import { combineLatest, switchMap } from '..' +import { RULE_EDITING_HELPER_RULE } from './editing-helpers' +import { type FileInfoForRuleApplication, ruleAppliesToFile } from './filters' +import type { Rule } from './rules' + +/** + * A provider for discovering the rules that may apply to files. The ultimate {@link RuleService} + * created by {@link createRuleService} calls one or more {@link RuleProvider}s to obtain the + * candidate rules, and then applies the rules' filters and can perform other evaluations to see + * which ones *actually* apply. + */ +export interface RuleProvider { + /** + * Observe the rules that may apply to at least one of the given files. + * + * It returns all rules found for all paths. The rules for a path are all + * `.sourcegraph/*.rule.md` files from the file's directory or its ancestor directories. + * + * Implementations SHOULD NOT apply the rules' filters; that is handled later. + */ + candidateRulesForPaths(files: URI[]): Observable +} + +/** + * A rule discovered by a {@link RuleProvider}, plus the files it may apply to. + */ +export interface CandidateRule { + rule: Rule + + /** + * The files passed to {@link RuleProvider.candidateRulesForPaths} that this rule applies to + * based on its path. + */ + appliesToFiles: URI[] +} + +/** + * A service for getting the set of {@link Rule}s to apply to file paths. + * + * It calls one or more {@link RuleProvider}s to obtain the candidate rules, and then applies the + * rules' filters and maybe other evaluations to see which rules *actually* apply. + */ +export interface RuleService { + /** + * Observe the rules that apply to at least one of the given files. + */ + rulesForPaths(files: URI[]): Observable +} + +/** + * Create a {@link RuleService} that combines the results of the given rule discovery {@link providers}. + */ +export function createRuleService( + providers: Observable, + { + fileInfo, + }: { + fileInfo: (file: URI) => FileInfoForRuleApplication + } +): RuleService { + return { + rulesForPaths: files => + providers.pipe( + switchMap(providers => + combineLatest(...providers.map(s => s.candidateRulesForPaths(files))).pipe( + map(rules_ => { + const rules = rules_.flat() + rules.push(...BUILTIN_RULES.map(rule => ({ rule, appliesToFiles: files }))) + + const requestedFiles = new Set(files.map(f => f.toString())) + const fileInfos = new Map() + for (const uri of rules.flatMap(({ appliesToFiles }) => appliesToFiles)) { + if (!requestedFiles.has(uri.toString())) { + // Ignore files that were not passed in the `rulesForPaths` `files` arg. + continue + } + fileInfos.set(uri.toString(), fileInfo(uri)) + } + function ruleAppliesToFiles(rule: Rule, files: URI[]): boolean { + return files.some(file => { + // All files should be in `fileInfos`, but be defensive in case a {@link + // RuleProvider} returns a file in `appliesToFiles` that it shouldn't + // have. + const info = fileInfos.get(file.toString()) + return info ? ruleAppliesToFile(rule, info) : false + }) + } + + return rules + .filter(({ rule, appliesToFiles }) => + ruleAppliesToFiles(rule, appliesToFiles) + ) + .map(({ rule }) => rule) + }) + ) + ) + ), + } +} + +/** + * Builtin rules, which are always included and MUST only be used for Sourcegraph functionality + * (such as when editing rule files for use in Sourcegraph), not for giving guidance on users' own + * codebases. + */ +const BUILTIN_RULES: Rule[] = [RULE_EDITING_HELPER_RULE] diff --git a/lib/shared/src/sourcegraph-api/client-name-version.ts b/lib/shared/src/sourcegraph-api/client-name-version.ts index dd6466567a02..8e86f9318e1c 100644 --- a/lib/shared/src/sourcegraph-api/client-name-version.ts +++ b/lib/shared/src/sourcegraph-api/client-name-version.ts @@ -39,13 +39,7 @@ export function getClientIdentificationHeaders() { : 'Unknown environment' const headers: { [header: string]: string } = { 'User-Agent': `${clientName}/${clientVersion} (${runtimeInfo})`, - } - - // Only set these headers in non-demo mode, because the demo mode is - // running in a local server and thus the backend will regard it as an - // untrusted cross-origin request. - if (!process.env.CODY_WEB_DEMO) { - headers['X-Requested-With'] = `${clientName} ${clientVersion}` + 'X-Requested-With': `${clientName} ${clientVersion}`, } return headers } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97f7d1e8dd4e..81b35b0813d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -442,6 +442,9 @@ importers: vscode-uri: specifier: ^3.0.8 version: 3.0.8 + yaml: + specifier: ^2.3.4 + version: 2.3.4 devDependencies: '@sourcegraph/cody-context-filters-test-dataset': specifier: ^1.0.0 @@ -17858,7 +17861,6 @@ packages: /yaml@2.3.4: resolution: {integrity: sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==} engines: {node: '>= 14'} - dev: true /yaml@2.4.2: resolution: {integrity: sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==} diff --git a/vscode/src/autoedits/autoedits-config.ts b/vscode/src/autoedits/autoedits-config.ts index 407c00f9c41d..6ef813ef1d36 100644 --- a/vscode/src/autoedits/autoedits-config.ts +++ b/vscode/src/autoedits/autoedits-config.ts @@ -36,6 +36,7 @@ const defaultTokenLimit = { [RetrieverIdentifier.RecentCopyRetriever]: 500, [RetrieverIdentifier.DiagnosticsRetriever]: 500, [RetrieverIdentifier.RecentViewPortRetriever]: 2500, + [RetrieverIdentifier.RulesRetriever]: 1000, }, } as const satisfies AutoEditsTokenLimit diff --git a/vscode/src/autoedits/prompt/constants.ts b/vscode/src/autoedits/prompt/constants.ts index df4a9cf72060..4544648702f6 100644 --- a/vscode/src/autoedits/prompt/constants.ts +++ b/vscode/src/autoedits/prompt/constants.ts @@ -19,6 +19,10 @@ export const AREA_FOR_CODE_MARKER_OPEN = ps`` export const AREA_FOR_CODE_MARKER_CLOSE = ps`` export const CODE_TO_REWRITE_TAG_CLOSE = ps`` export const CODE_TO_REWRITE_TAG_OPEN = ps`` +export const RULES_TAG_OPEN = ps`` +export const RULES_TAG_CLOSE = ps`` +export const RULE_TAG_OPEN = ps`` +export const RULE_TAG_CLOSE = ps`` // Some common prompt instructions export const SYSTEM_PROMPT = ps`You are an intelligent programmer named CodyBot. You are an expert at coding. Your goal is to help your colleague finish a code change.` @@ -31,3 +35,4 @@ export const LINT_ERRORS_INSTRUCTION = ps`Here are some linter errors from the c export const RECENT_COPY_INSTRUCTION = ps`Here is some recent code I copied from the editor.` export const CURRENT_FILE_INSTRUCTION = ps`Here is the file that I am looking at ` export const SHORT_TERM_SNIPPET_VIEWS_INSTRUCTION = ps`Here are some snippets of code I just looked at:` +export const RULES_INSTRUCTION = ps`Your response MUST comply with the following rules: ` diff --git a/vscode/src/autoedits/prompt/default-prompt-strategy.test.ts b/vscode/src/autoedits/prompt/default-prompt-strategy.test.ts index b21f8339cd8e..040924c5415f 100644 --- a/vscode/src/autoedits/prompt/default-prompt-strategy.test.ts +++ b/vscode/src/autoedits/prompt/default-prompt-strategy.test.ts @@ -62,6 +62,7 @@ describe('DefaultUserPromptStrategy', () => { [RetrieverIdentifier.RecentCopyRetriever]: 100, [RetrieverIdentifier.JaccardSimilarityRetriever]: 100, [RetrieverIdentifier.DiagnosticsRetriever]: 100, + [RetrieverIdentifier.RulesRetriever]: 100, }, } diff --git a/vscode/src/autoedits/prompt/default-prompt-strategy.ts b/vscode/src/autoedits/prompt/default-prompt-strategy.ts index e13375888e86..434a863b6ca1 100644 --- a/vscode/src/autoedits/prompt/default-prompt-strategy.ts +++ b/vscode/src/autoedits/prompt/default-prompt-strategy.ts @@ -16,6 +16,7 @@ import { getRecentCopyPrompt, getRecentEditsPrompt, getRecentlyViewedSnippetsPrompt, + getRulesPrompt, joinPromptsWithNewlineSeparator, } from './prompt-utils' @@ -25,6 +26,13 @@ export class DefaultUserPromptStrategy extends AutoeditsUserPromptStrategy { context, tokenBudget.contextSpecificTokenLimit ) + + const rulesPrompt = getPromptForTheContextSource( + contextItemMapping.get(RetrieverIdentifier.RulesRetriever) || [], + constants.RULES_INSTRUCTION, + getRulesPrompt + ) + const recentViewsPrompt = getPromptForTheContextSource( contextItemMapping.get(RetrieverIdentifier.RecentViewPortRetriever) || [], constants.LONG_TERM_SNIPPET_VIEWS_INSTRUCTION, @@ -64,6 +72,7 @@ export class DefaultUserPromptStrategy extends AutoeditsUserPromptStrategy { const finalPrompt = joinPromptsWithNewlineSeparator( getPromptWithNewline(constants.BASE_USER_PROMPT), + getPromptWithNewline(rulesPrompt), getPromptWithNewline(jaccardSimilarityPrompt), getPromptWithNewline(recentViewsPrompt), getPromptWithNewline(currentFilePrompt), diff --git a/vscode/src/autoedits/prompt/prompt-utils.ts b/vscode/src/autoedits/prompt/prompt-utils.ts index 9057741492bf..e26a8e86ccf8 100644 --- a/vscode/src/autoedits/prompt/prompt-utils.ts +++ b/vscode/src/autoedits/prompt/prompt-utils.ts @@ -389,6 +389,24 @@ export function getJaccardSimilarityPrompt(contextItems: AutocompleteContextSnip ) } +export function getRulesPrompt(contextItems: AutocompleteContextSnippet[]): PromptString { + if (contextItems.length === 0) { + return ps`` + } + const rulesPrompts = contextItems.map(item => + joinPromptsWithNewlineSeparator( + constants.RULE_TAG_OPEN, + PromptString.fromAutocompleteContextSnippet(item).content, + constants.RULE_TAG_CLOSE + ) + ) + return joinPromptsWithNewlineSeparator( + constants.RULES_TAG_OPEN, + PromptString.join(rulesPrompts, ps`\n`), + constants.RULES_TAG_CLOSE + ) +} + // Helper functions export function getContextItemMappingWithTokenLimit( contextItems: AutocompleteContextSnippet[], diff --git a/vscode/src/autoedits/prompt/short-term-diff-prompt-strategy.test.ts b/vscode/src/autoedits/prompt/short-term-diff-prompt-strategy.test.ts index 3b47dcac3853..68aafb585cfa 100644 --- a/vscode/src/autoedits/prompt/short-term-diff-prompt-strategy.test.ts +++ b/vscode/src/autoedits/prompt/short-term-diff-prompt-strategy.test.ts @@ -67,6 +67,7 @@ describe('ShortTermPromptStrategy', () => { [RetrieverIdentifier.RecentCopyRetriever]: 100, [RetrieverIdentifier.JaccardSimilarityRetriever]: 100, [RetrieverIdentifier.DiagnosticsRetriever]: 100, + [RetrieverIdentifier.RulesRetriever]: 1000, }, } const codeToReplaceData = getCodeToReplaceData({ diff --git a/vscode/src/autoedits/prompt/short-term-diff-prompt-strategy.ts b/vscode/src/autoedits/prompt/short-term-diff-prompt-strategy.ts index 6bca9800297d..0ac3ab13dfcd 100644 --- a/vscode/src/autoedits/prompt/short-term-diff-prompt-strategy.ts +++ b/vscode/src/autoedits/prompt/short-term-diff-prompt-strategy.ts @@ -18,6 +18,7 @@ import { getRecentCopyPrompt, getRecentEditsPrompt, getRecentlyViewedSnippetsPrompt, + getRulesPrompt, joinPromptsWithNewlineSeparator, } from './prompt-utils' @@ -29,6 +30,13 @@ export class ShortTermPromptStrategy extends AutoeditsUserPromptStrategy { context, tokenBudget.contextSpecificTokenLimit ) + + const rulesPrompt = getPromptForTheContextSource( + contextItemMapping.get(RetrieverIdentifier.RulesRetriever) || [], + constants.RULES_INSTRUCTION, + getRulesPrompt + ) + const { shortTermViewPrompt, longTermViewPrompt } = this.getRecentSnippetViewPrompt( contextItemMapping.get(RetrieverIdentifier.RecentViewPortRetriever) || [] ) @@ -62,6 +70,7 @@ export class ShortTermPromptStrategy extends AutoeditsUserPromptStrategy { const finalPrompt = joinPromptsWithNewlineSeparator( getPromptWithNewline(constants.BASE_USER_PROMPT), + getPromptWithNewline(rulesPrompt), getPromptWithNewline(jaccardSimilarityPrompt), getPromptWithNewline(longTermViewPrompt), getPromptWithNewline(currentFilePrompt), diff --git a/vscode/src/chat/agentic/CodyTool.ts b/vscode/src/chat/agentic/CodyTool.ts index 795f5d0225b8..d0f40fcb529b 100644 --- a/vscode/src/chat/agentic/CodyTool.ts +++ b/vscode/src/chat/agentic/CodyTool.ts @@ -6,9 +6,9 @@ import { type ContextMentionProviderMetadata, ProcessType, PromptString, + currentOpenCtxController, firstValueFrom, logDebug, - openCtx, parseMentionQuery, pendingOperation, ps, @@ -305,7 +305,7 @@ export class OpenCtxTool extends CodyTool { async execute(span: Span, queries: string[]): Promise { span.addEvent('executeOpenCtxTool') - const openCtxClient = openCtx.controller + const openCtxClient = currentOpenCtxController() if (!queries?.length || !openCtxClient) { return [] } diff --git a/vscode/src/chat/agentic/CodyToolProvider.test.ts b/vscode/src/chat/agentic/CodyToolProvider.test.ts index 8767a7e4dd25..bba749b2cf84 100644 --- a/vscode/src/chat/agentic/CodyToolProvider.test.ts +++ b/vscode/src/chat/agentic/CodyToolProvider.test.ts @@ -1,7 +1,8 @@ -import { type ContextItem, ContextItemSource, openCtx, ps } from '@sourcegraph/cody-shared' +import { type ContextItem, ContextItemSource, ps } from '@sourcegraph/cody-shared' import { Observable } from 'observable-fns' import { beforeEach, describe, expect, it, vi } from 'vitest' import { URI } from 'vscode-uri' +import * as openctxAPI from '../../../../lib/shared/src/context/openctx/api' import { mockLocalStorage } from '../../services/LocalStorageProvider' import type { ContextRetriever } from '../chat-view/ContextRetriever' import { CodyTool, type CodyToolConfig } from './CodyTool' @@ -62,7 +63,7 @@ describe('CodyToolProvider', () => { beforeEach(() => { vi.clearAllMocks() CodyToolProvider.initialize(mockContextRetriever) - openCtx.controller = mockController + vi.spyOn(openctxAPI, 'openctxController', 'get').mockReturnValue(Observable.of(mockController)) }) it('should register default tools on initialization', () => { @@ -73,9 +74,9 @@ describe('CodyToolProvider', () => { }) it('should set up OpenCtx provider listener and build OpenCtx tools from provider metadata', async () => { - openCtx.controller = mockController CodyToolProvider.setupOpenCtxProviderListener() - expect(openCtx.controller?.metaChanges).toHaveBeenCalled() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(mockController.metaChanges).toHaveBeenCalled() // Wait for the observable to emit await new Promise(resolve => setTimeout(resolve, 0)) diff --git a/vscode/src/chat/agentic/CodyToolProvider.ts b/vscode/src/chat/agentic/CodyToolProvider.ts index 86af3011f1bc..64999cc88894 100644 --- a/vscode/src/chat/agentic/CodyToolProvider.ts +++ b/vscode/src/chat/agentic/CodyToolProvider.ts @@ -4,9 +4,10 @@ import { PromptString, type Unsubscribable, isDefined, - openCtx, openCtxProviderMetadata, + openctxController, ps, + switchMap, } from '@sourcegraph/cody-shared' import { map } from 'observable-fns' import type { ContextRetriever } from '../chat-view/ContextRetriever' @@ -190,10 +191,19 @@ export class CodyToolProvider { if (provider && !CodyToolProvider.configSubscription) { CodyToolProvider.configSubscription = toolboxManager.observable.subscribe({}) } - if (provider && !CodyToolProvider.openCtxSubscription && openCtx.controller) { - CodyToolProvider.openCtxSubscription = openCtx.controller - .metaChanges({}, {}) - .pipe(map(providers => providers.filter(p => !!p.mentions).map(openCtxProviderMetadata))) + if (provider && !CodyToolProvider.openCtxSubscription) { + CodyToolProvider.openCtxSubscription = openctxController + .pipe( + switchMap(c => + c + .metaChanges({}, {}) + .pipe( + map(providers => + providers.filter(p => !!p.mentions).map(openCtxProviderMetadata) + ) + ) + ) + ) .subscribe(providerMeta => provider.factory.createOpenCtxTools(providerMeta)) } } diff --git a/vscode/src/chat/context/chatContext.ts b/vscode/src/chat/context/chatContext.ts index 0dbf88a04d7d..dd0ac4f3363d 100644 --- a/vscode/src/chat/context/chatContext.ts +++ b/vscode/src/chat/context/chatContext.ts @@ -11,12 +11,12 @@ import { SYMBOL_CONTEXT_MENTION_PROVIDER, clientCapabilities, combineLatest, + currentOpenCtxController, firstResultFromOperation, fromVSCodeEvent, isAbortError, isError, mentionProvidersMetadata, - openCtx, pendingOperation, promiseFactoryToObservable, skipPendingOperation, @@ -153,11 +153,7 @@ export async function getChatContextItemsForMention( } default: { - if (!openCtx.controller) { - return [] - } - - const items = await openCtx.controller.mentions( + const items = await currentOpenCtxController().mentions( { query: mentionQuery.text, ...(await firstResultFromOperation(activeEditorContextForOpenCtxMentions)), diff --git a/vscode/src/chat/initialContext.ts b/vscode/src/chat/initialContext.ts index 1ae2e3cb4ee3..6daea03b9d2f 100644 --- a/vscode/src/chat/initialContext.ts +++ b/vscode/src/chat/initialContext.ts @@ -20,7 +20,7 @@ import { fromVSCodeEvent, isDotCom, isError, - openCtx, + openctxController, pendingOperation, shareReplay, startWith, @@ -265,53 +265,52 @@ export function getCorpusContextItemsForEditorState(): Observable< } function getOpenCtxContextItems(): Observable { - const openctxController = openCtx.controller - if (!openctxController) { - return Observable.of([]) - } - - return openctxController.metaChanges({}).pipe( - switchMap((providers): Observable => { - const providersWithAutoInclude = providers.filter(meta => meta.mentions?.autoInclude) - if (providersWithAutoInclude.length === 0) { - return Observable.of([]) - } - - return activeTextEditor.pipe( - debounceTime(50), - switchMap(() => activeEditorContextForOpenCtxMentions), - switchMap(activeEditorContext => { - if (activeEditorContext === pendingOperation) { - return Observable.of(pendingOperation) - } - if (isError(activeEditorContext)) { + return openctxController.pipe( + switchMap(c => + c.metaChanges({}, {}).pipe( + switchMap((providers): Observable => { + const providersWithAutoInclude = providers.filter(meta => meta.mentions?.autoInclude) + if (providersWithAutoInclude.length === 0) { return Observable.of([]) } - return combineLatest( - ...providersWithAutoInclude.map(provider => - openctxController.mentionsChanges( - { ...activeEditorContext, autoInclude: true }, - provider - ) - ) - ).pipe( - map(mentionsResults => - mentionsResults.flat().map( - mention => - ({ - ...mention, - provider: 'openctx', - type: 'openctx', - uri: URI.parse(mention.uri), - source: ContextItemSource.Initial, - mention, // include the original mention to pass to `items` later - }) satisfies ContextItem + + return activeTextEditor.pipe( + debounceTime(50), + switchMap(() => activeEditorContextForOpenCtxMentions), + switchMap(activeEditorContext => { + if (activeEditorContext === pendingOperation) { + return Observable.of(pendingOperation) + } + if (isError(activeEditorContext)) { + return Observable.of([]) + } + return combineLatest( + ...providersWithAutoInclude.map(provider => + c.mentionsChanges( + { ...activeEditorContext, autoInclude: true }, + provider + ) + ) + ).pipe( + map(mentionsResults => + mentionsResults.flat().map( + mention => + ({ + ...mention, + provider: 'openctx', + type: 'openctx', + uri: URI.parse(mention.uri), + source: ContextItemSource.Initial, + mention, // include the original mention to pass to `items` later + }) satisfies ContextItem + ) + ), + startWith(pendingOperation) ) - ), - startWith(pendingOperation) + }) ) }) ) - }) + ) ) } diff --git a/vscode/src/completions/context/context-strategy.ts b/vscode/src/completions/context/context-strategy.ts index 2a5cb005bbe3..a3c36522dceb 100644 --- a/vscode/src/completions/context/context-strategy.ts +++ b/vscode/src/completions/context/context-strategy.ts @@ -15,6 +15,7 @@ import { LineLevelDiffStrategy } from './retrievers/recent-user-actions/recent-e import { UnifiedDiffStrategy } from './retrievers/recent-user-actions/recent-edits-diff-helpers/unified-diff' import { RecentEditsRetriever } from './retrievers/recent-user-actions/recent-edits-retriever' import { RecentViewPortRetriever } from './retrievers/recent-user-actions/recent-view-port' +import { RulesRetriever } from './retrievers/rules-retriever' import { loadTscRetriever } from './retrievers/tsc/load-tsc-retriever' export type ContextStrategy = @@ -151,6 +152,7 @@ export class DefaultContextStrategyFactory implements ContextStrategyFactory { maxTrackedViewPorts: 50, maxRetrievedViewPorts: 10, }), + new RulesRetriever(), ] break case 'jaccard-similarity': diff --git a/vscode/src/completions/context/retrievers/rules-retriever.ts b/vscode/src/completions/context/retrievers/rules-retriever.ts new file mode 100644 index 000000000000..aadf75aae4b1 --- /dev/null +++ b/vscode/src/completions/context/retrievers/rules-retriever.ts @@ -0,0 +1,31 @@ +import { type AutocompleteContextSnippet, firstValueFrom } from '@sourcegraph/cody-shared' +import { formatRuleForPrompt } from '@sourcegraph/cody-shared/src/rules/rules' +import type { Disposable } from 'vscode' +import { URI } from 'vscode-uri' +import { ruleService } from '../../../rules/service' +import type { ContextRetriever, ContextRetrieverOptions } from '../../types' +import { RetrieverIdentifier } from '../utils' + +export class RulesRetriever implements Disposable, ContextRetriever { + public identifier = RetrieverIdentifier.RulesRetriever + + public async retrieve({ document }: ContextRetrieverOptions): Promise { + const rules = await firstValueFrom(ruleService.rulesForPaths([document.uri])) + + return rules.map( + rule => + ({ + type: 'base', + identifier: this.identifier, + content: formatRuleForPrompt(rule).toString(), + uri: URI.parse(rule.uri), + }) satisfies AutocompleteContextSnippet + ) + } + + public isSupportedForLanguageId(): boolean { + return true + } + + public dispose() {} +} diff --git a/vscode/src/completions/context/utils.ts b/vscode/src/completions/context/utils.ts index 77a98b17b5f1..618e722a1cfc 100644 --- a/vscode/src/completions/context/utils.ts +++ b/vscode/src/completions/context/utils.ts @@ -9,6 +9,7 @@ export enum RetrieverIdentifier { RecentCopyRetriever = 'recent-copy', DiagnosticsRetriever = 'diagnostics', RecentViewPortRetriever = 'recent-view-port', + RulesRetriever = 'rules', } export interface ShouldUseContextParams { diff --git a/vscode/src/context/openctx.ts b/vscode/src/context/openctx.ts index 47883493982a..9e00fbeda497 100644 --- a/vscode/src/context/openctx.ts +++ b/vscode/src/context/openctx.ts @@ -6,6 +6,8 @@ import { type CodyClientConfig, FeatureFlag, GIT_OPENCTX_PROVIDER_URI, + type OpenCtxController, + RULES_PROVIDER_URI, WEB_PROVIDER_URI, authStatus, clientCapabilities, @@ -21,7 +23,6 @@ import { pluck, promiseFactoryToObservable, resolvedConfig, - setOpenCtx, skipPendingOperation, switchMap, } from '@sourcegraph/cody-shared' @@ -41,12 +42,17 @@ import LinearIssuesProvider from './openctx/linear-issues' import RemoteDirectoryProvider, { createRemoteDirectoryProvider } from './openctx/remoteDirectorySearch' import RemoteFileProvider, { createRemoteFileProvider } from './openctx/remoteFileSearch' import RemoteRepositorySearch, { createRemoteRepositoryProvider } from './openctx/remoteRepositorySearch' +import { createRulesProvider } from './openctx/rules' import { createWebProvider } from './openctx/web' -export function exposeOpenCtxClient( +/** + * DO NOT USE except in `main.ts` initial activation. Instead, ise the global `openctxController` + * observable to obtain the OpenCtx controller. + */ +export function observeOpenCtxController( context: Pick, createOpenCtxController: typeof createController | undefined -): Observable { +): Observable { void warnIfOpenCtxExtensionConflict() return combineLatest( @@ -76,7 +82,7 @@ export function exposeOpenCtxClient( async () => createOpenCtxController ?? (await import('@openctx/vscode-lib')).createController ) ).pipe( - createDisposables(([{ experimentalNoodle }, isValidSiteVersion, createController]) => { + map(([{ experimentalNoodle }, isValidSiteVersion, createController]) => { try { // Enable fetching of openctx configuration from Sourcegraph instance const mergeConfiguration = experimentalNoodle @@ -106,18 +112,15 @@ export function exposeOpenCtxClient( ), mergeConfiguration, }) - setOpenCtx({ - controller: controller.controller, - disposable: controller.disposable, - }) CodyToolProvider.setupOpenCtxProviderListener() - return controller.disposable + return controller } catch (error) { logDebug('openctx', `Failed to load OpenCtx client: ${error}`) - return undefined + throw error } }), - map(() => undefined) + createDisposables(controller => controller.disposable), + map(controller => controller.controller) ) } @@ -146,6 +149,11 @@ export function getOpenCtxProviders( provider: createWebProvider(false), providerUri: WEB_PROVIDER_URI, }, + { + settings: true, + provider: createRulesProvider(), + providerUri: RULES_PROVIDER_URI, + }, ] if (!isDotCom(authStatus)) { @@ -225,6 +233,11 @@ function getCodyWebOpenCtxProviders(): Observable ruleTitle(r)).join('\n'), + uri: 'rules+openctx://rules', // dummy URI + data: { rules: rules satisfies Rule[] }, + }, + ] + }, + + async items(params) { + const rules = params.mention?.data?.rules as Rule[] | undefined + return ( + rules?.map( + rule => + ({ + url: rule.uri, + title: rule.title ?? rule.display_name, + ai: { content: rule.instruction ?? undefined }, + }) satisfies Item + ) ?? [] + ) + }, + } +} diff --git a/vscode/src/edit/execute.ts b/vscode/src/edit/execute.ts index cfc8fcda4b68..58abf19fbd6a 100644 --- a/vscode/src/edit/execute.ts +++ b/vscode/src/edit/execute.ts @@ -6,6 +6,7 @@ import type { EditModel, EventSource, PromptString, + Rule, } from '@sourcegraph/cody-shared' import type { FixupTask, FixupTaskID, FixupTelemetryMetadata } from '../non-stop/FixupTask' @@ -45,6 +46,7 @@ export interface ExecuteEditArguments { intent?: EditIntent mode?: EditMode model?: EditModel + rules?: Rule[] | null // The file to write the edit to. If not provided, the edit will be applied to the current file. destinationFile?: vscode.Uri insertionPoint?: vscode.Position diff --git a/vscode/src/edit/input/get-input.ts b/vscode/src/edit/input/get-input.ts index 324da489a37e..1130e9de62ca 100644 --- a/vscode/src/edit/input/get-input.ts +++ b/vscode/src/edit/input/get-input.ts @@ -3,15 +3,19 @@ import { type EditModel, type EventSource, FILE_CONTEXT_MENTION_PROVIDER, + FeatureFlag, GENERAL_HELP_LABEL, LARGE_FILE_WARNING_LABEL, ModelUsage, PromptString, + type Rule, SYMBOL_CONTEXT_MENTION_PROVIDER, checkIfEnterpriseUser, currentUserProductSubscription, displayLineRange, + featureFlagProvider, firstResultFromOperation, + firstValueFrom, modelsService, parseMentionQuery, scanForMentionTriggerInUserTextInput, @@ -24,6 +28,7 @@ import { ACCOUNT_UPGRADE_URL } from '../../chat/protocol' import { executeDocCommand, executeTestEditCommand } from '../../commands/execute' import { getEditor } from '../../editor/active-editor' import { type TextChange, updateRangeMultipleChanges } from '../../non-stop/tracked-range' +import { ruleService } from '../../rules/service' import type { EditIntent, EditMode } from '../types' import { isGenerateIntent } from '../utils/edit-intent' import { CURSOR_RANGE_ITEM, EXPANDED_RANGE_ITEM, SELECTION_RANGE_ITEM } from './get-items/constants' @@ -53,6 +58,9 @@ export interface QuickPickInput { intent: EditIntent /** The derived mode from the users' selected range */ mode: EditMode + + /** Rules to apply. */ + rules: Rule[] | null } export interface EditInputInitialValues { @@ -62,6 +70,7 @@ export interface EditInputInitialValues { initialIntent: EditIntent initialInputValue?: PromptString initialSelectedContextItems?: ContextItem[] + initialRules?: Rule[] | null } const PREVIEW_RANGE_DECORATION = vscode.window.createTextEditorDecorationType({ @@ -134,6 +143,12 @@ export const getInput = async ( } updateActiveTitle(activeRange) + const rulesEnabled = + (await firstValueFrom(featureFlagProvider.evaluatedFeatureFlag(FeatureFlag.Rules))) || true // TODO!(sqs) + const rulesToApply: Rule[] | null = rulesEnabled + ? await firstValueFrom(ruleService.rulesForPaths([document.uri])) + : null + /** * Listens for text document changes and updates the range when changes occur. * This allows the range to stay in sync if the user continues editing after @@ -178,7 +193,7 @@ export const getInput = async ( // Start fetching symbols early, so they can be used immediately if an option is selected const symbolsPromise = fetchDocumentSymbols(document) - return new Promise(resolve => { + return new Promise(resolve => { const modelInput = createQuickPick({ title: activeTitle, placeHolder: 'Select a model', @@ -321,7 +336,8 @@ export const getInput = async ( editInput.input.value, activeRangeItem, activeModelItem, - showModelSelector + showModelSelector, + rulesToApply ) }, onDidHide: () => editor.setDecorations(PREVIEW_RANGE_DECORATION, []), @@ -360,7 +376,8 @@ export const getInput = async ( input.value, activeRangeItem, activeModelItem, - showModelSelector + showModelSelector, + rulesToApply ).items return } @@ -488,6 +505,7 @@ export const getInput = async ( range: activeRange, intent: isGenerate ? 'add' : 'edit', mode: isGenerate ? 'insert' : 'edit', + rules: rulesToApply, }) }, }) diff --git a/vscode/src/edit/input/get-items/edit.ts b/vscode/src/edit/input/get-items/edit.ts index 4ed10ab8fc24..3342dc34b84e 100644 --- a/vscode/src/edit/input/get-items/edit.ts +++ b/vscode/src/edit/input/get-items/edit.ts @@ -1,3 +1,4 @@ +import { type Rule, ruleTitle } from '@sourcegraph/cody-shared' import * as vscode from 'vscode' import type { GetItemsResult } from '../quick-pick' import { getItemLabel } from '../utils' @@ -12,6 +13,11 @@ export const MODEL_ITEM: vscode.QuickPickItem = { alwaysShow: true, } +export const RULES_ITEM: vscode.QuickPickItem = { + label: 'Rules', + alwaysShow: true, +} + export const DOCUMENT_ITEM: vscode.QuickPickItem = { label: 'Document Code', alwaysShow: true, @@ -36,11 +42,12 @@ export const getEditInputItems = ( activeValue: string, activeRangeItem: vscode.QuickPickItem, activeModelItem: vscode.QuickPickItem | undefined, - showModelSelector: boolean + showModelSelector: boolean, + rulesToApply: Rule[] | null ): GetItemsResult => { const hasActiveValue = activeValue.trim().length > 0 const submitItems = hasActiveValue ? [SUBMIT_SEPARATOR, SUBMIT_ITEM] : [] - const commandItems = hasActiveValue + const commandItems: vscode.QuickPickItem[] = hasActiveValue ? [] : [ { @@ -50,7 +57,7 @@ export const getEditInputItems = ( DOCUMENT_ITEM, TEST_ITEM, ] - const editItems = [ + const editItems: vscode.QuickPickItem[] = [ { label: 'edit options', kind: vscode.QuickPickItemKind.Separator, @@ -59,11 +66,10 @@ export const getEditInputItems = ( showModelSelector ? { ...MODEL_ITEM, detail: activeModelItem ? getItemLabel(activeModelItem) : undefined } : null, - ] - - const items = [...submitItems, ...editItems, ...commandItems].filter( - Boolean - ) as vscode.QuickPickItem[] + rulesToApply !== null && rulesToApply.length > 0 + ? { ...RULES_ITEM, detail: rulesToApply.map(ruleTitle).join(', ') } + : null, + ].filter(v => v !== null) - return { items } + return { items: [...submitItems, ...editItems, ...commandItems] } } diff --git a/vscode/src/edit/manager.ts b/vscode/src/edit/manager.ts index ae5add2be29d..278e0c7cc745 100644 --- a/vscode/src/edit/manager.ts +++ b/vscode/src/edit/manager.ts @@ -163,6 +163,7 @@ export class EditManager implements vscode.Disposable { intent, mode, model, + configuration.rules ?? null, source, configuration.destinationFile, configuration.insertionPoint, @@ -177,6 +178,7 @@ export class EditManager implements vscode.Disposable { expandedRange, mode, model, + configuration.rules ?? null, intent, source, telemetryMetadata @@ -288,6 +290,7 @@ export class EditManager implements vscode.Disposable { 'add', 'insert', model, + null, source, configuration.document.uri, undefined, @@ -416,6 +419,7 @@ export class EditManager implements vscode.Disposable { 'add', 'insert', model, + null, source, configuration.document.uri, undefined, diff --git a/vscode/src/edit/prompt/index.ts b/vscode/src/edit/prompt/index.ts index 8b55c8ec7d0c..3d2358bfdde6 100644 --- a/vscode/src/edit/prompt/index.ts +++ b/vscode/src/edit/prompt/index.ts @@ -103,6 +103,7 @@ export const buildInteraction = async ({ precedingText, selectedText, instruction: task.instruction, + rules: task.rules, document, }) const promptBuilder = await PromptBuilder.create(modelsService.getContextWindowByID(model)) diff --git a/vscode/src/edit/prompt/models/generic.ts b/vscode/src/edit/prompt/models/generic.ts index c5ea25e7bc7e..ea905a3d10ff 100644 --- a/vscode/src/edit/prompt/models/generic.ts +++ b/vscode/src/edit/prompt/models/generic.ts @@ -1,4 +1,5 @@ -import { PromptString, psDedent } from '@sourcegraph/cody-shared' +import { PromptString, isDefined, ps, psDedent } from '@sourcegraph/cody-shared' +import { formatRuleForPrompt } from '@sourcegraph/cody-shared/src/rules/rules' import type { EditIntent } from '../../types' import { PROMPT_TOPICS } from '../constants' import type { GetLLMInteractionOptions, LLMPrompt } from '../type' @@ -114,14 +115,22 @@ const GENERIC_PROMPTS: Record = { export const buildGenericPrompt = ( intent: EditIntent, - { instruction, selectedText, uri }: GetLLMInteractionOptions + { instruction, selectedText, uri, rules }: GetLLMInteractionOptions ): LLMPrompt => { + const instructionWithRules = PromptString.join( + [ + instruction, + rules ? ps`Follow these rules:` : undefined, + ...(rules?.map(formatRuleForPrompt) ?? []), + ].filter(isDefined), + ps`\n` + ) switch (intent) { case 'edit': return { system: GENERIC_PROMPTS.edit.system, instruction: GENERIC_PROMPTS.edit.instruction - .replaceAll('{instruction}', instruction) + .replaceAll('{instruction}', instructionWithRules) .replaceAll('{selectedText}', selectedText) .replaceAll('{filePath}', PromptString.fromDisplayPath(uri)), } @@ -129,14 +138,14 @@ export const buildGenericPrompt = ( return { system: GENERIC_PROMPTS.add.system, instruction: GENERIC_PROMPTS.add.instruction - .replaceAll('{instruction}', instruction) + .replaceAll('{instruction}', instructionWithRules) .replaceAll('{filePath}', PromptString.fromDisplayPath(uri)), } case 'fix': return { system: GENERIC_PROMPTS.fix.system, instruction: GENERIC_PROMPTS.fix.instruction - .replaceAll('{instruction}', instruction) + .replaceAll('{instruction}', instructionWithRules) .replaceAll('{selectedText}', selectedText) .replaceAll('{filePath}', PromptString.fromDisplayPath(uri)), } @@ -144,7 +153,7 @@ export const buildGenericPrompt = ( return { system: GENERIC_PROMPTS.test.system, instruction: GENERIC_PROMPTS.test.instruction - .replaceAll('{instruction}', instruction) + .replaceAll('{instruction}', instructionWithRules) .replaceAll('{selectedText}', selectedText) .replaceAll('{filePath}', PromptString.fromDisplayPath(uri)), } @@ -152,7 +161,7 @@ export const buildGenericPrompt = ( return { system: GENERIC_PROMPTS.doc.system, instruction: GENERIC_PROMPTS.doc.instruction - .replaceAll('{instruction}', instruction) + .replaceAll('{instruction}', instructionWithRules) .replaceAll('{selectedText}', selectedText) .replaceAll('{filePath}', PromptString.fromDisplayPath(uri)), } diff --git a/vscode/src/edit/prompt/models/prompts.test.ts b/vscode/src/edit/prompt/models/prompts.test.ts index ec5a1978df1d..a0d8324c41aa 100644 --- a/vscode/src/edit/prompt/models/prompts.test.ts +++ b/vscode/src/edit/prompt/models/prompts.test.ts @@ -23,6 +23,7 @@ describe('Edit Prompts', () => { 'typescript', uri.toString() ), + rules: [{ uri: 'file:///a.rule.md', display_name: 'a', instruction: 'My instruction' }], } function normalize(text: string): string { diff --git a/vscode/src/edit/prompt/type.ts b/vscode/src/edit/prompt/type.ts index 588bbfd83c2e..1bd66e4c4224 100644 --- a/vscode/src/edit/prompt/type.ts +++ b/vscode/src/edit/prompt/type.ts @@ -1,4 +1,4 @@ -import type { PromptString } from '@sourcegraph/cody-shared' +import type { PromptString, Rule } from '@sourcegraph/cody-shared' import type * as vscode from 'vscode' export interface LLMPrompt { @@ -21,6 +21,7 @@ export interface GetLLMInteractionOptions { followingText: PromptString uri: vscode.Uri document: vscode.TextDocument + rules?: Rule[] | null } type LLMInteractionBuilder = (options: GetLLMInteractionOptions) => LLMInteraction diff --git a/vscode/src/editor/utils/editor-context.ts b/vscode/src/editor/utils/editor-context.ts index fe901a54476b..ade5596493bb 100644 --- a/vscode/src/editor/utils/editor-context.ts +++ b/vscode/src/editor/utils/editor-context.ts @@ -15,6 +15,7 @@ import { type SymbolKind, TokenCounterUtils, contextFiltersProvider, + currentOpenCtxController, currentResolvedConfig, displayPath, firstValueFrom, @@ -24,7 +25,6 @@ import { isErrorLike, isWindows, logError, - openCtx, toRangeData, } from '@sourcegraph/cody-shared' @@ -431,7 +431,7 @@ async function resolveContextMentionProviderContextItem( return [] } - const openCtxClient = openCtx.controller + const openCtxClient = currentOpenCtxController() if (!openCtxClient) { return [] } diff --git a/vscode/src/jsonrpc/agent-protocol.ts b/vscode/src/jsonrpc/agent-protocol.ts index 4cfff43ef980..d6aa4b1449e4 100644 --- a/vscode/src/jsonrpc/agent-protocol.ts +++ b/vscode/src/jsonrpc/agent-protocol.ts @@ -9,6 +9,7 @@ import type { Model, ModelAvailabilityStatus, ModelUsage, + Rule, SerializedChatTranscript, } from '@sourcegraph/cody-shared' import type { TelemetryEventMarketingTrackingInput } from '@sourcegraph/telemetry' @@ -116,6 +117,7 @@ export type ClientRequests = { model: string mode: 'edit' | 'insert' range: Range + rules: Rule[] | null }, EditTask, ] @@ -863,6 +865,7 @@ export interface EditTask { instruction?: string | undefined | null model?: string | undefined | null originalText?: string | undefined | null + rules?: Rule[] | null } export interface CodyError { diff --git a/vscode/src/main.ts b/vscode/src/main.ts index 33f8b62cc68e..5890be0cb064 100644 --- a/vscode/src/main.ts +++ b/vscode/src/main.ts @@ -33,6 +33,7 @@ import { setClientNameVersion, setEditorWindowIsFocused, setLogger, + setOpenCtxControllerObservable, setResolvedConfigurationObservable, startWith, subscriptionDisposable, @@ -79,7 +80,7 @@ import type { CodyCommandArgs } from './commands/types' import { newCodyCommandArgs } from './commands/utils/get-commands' import { createInlineCompletionItemProvider } from './completions/create-inline-completion-item-provider' import { getConfiguration } from './configuration' -import { exposeOpenCtxClient } from './context/openctx' +import { observeOpenCtxController } from './context/openctx' import { logGlobalStateEmissions } from './dev/helpers' import { EditManager } from './edit/manager' import { manageDisplayPathEnvInfoForExtension } from './editor/displayPathEnvInfo' @@ -232,6 +233,8 @@ const register = async ( // Initialize singletons await initializeSingletons(platform, disposables) + setOpenCtxControllerObservable(observeOpenCtxController(context, platform.createOpenCtxController)) + // Ensure Git API is available disposables.push(await initVSCodeGitApi()) @@ -275,14 +278,7 @@ const register = async ( CodyToolProvider.initialize(contextRetriever) - disposables.push( - chatsController, - ghostHintDecorator, - editManager, - subscriptionDisposable( - exposeOpenCtxClient(context, platform.createOpenCtxController).subscribe({}) - ) - ) + disposables.push(chatsController, ghostHintDecorator, editManager) const statusBar = CodyStatusBar.init() disposables.push(statusBar) diff --git a/vscode/src/non-stop/FixupController.ts b/vscode/src/non-stop/FixupController.ts index 03720d391d11..381ebbeed6a4 100644 --- a/vscode/src/non-stop/FixupController.ts +++ b/vscode/src/non-stop/FixupController.ts @@ -5,6 +5,7 @@ import { type EditModel, type EventSource, type PromptString, + type Rule, currentAuthStatus, displayPathBasename, telemetryRecorder, @@ -375,6 +376,7 @@ export class FixupController initialSelectedContextItems: task.userContextItems, initialModel: task.model, initialIntent: task.intent, + initialRules: task.rules, }, source )) @@ -441,6 +443,7 @@ export class FixupController expandedRange: vscode.Range | undefined, mode: EditMode, model: EditModel, + rules: Rule[] | null, intent: EditIntent, source: EventSource, telemetryMetadata?: FixupTelemetryMetadata @@ -453,6 +456,7 @@ export class FixupController initialModel: model, initialIntent: intent, initialInputValue: preInstruction, + initialRules: rules, }, source ) @@ -468,6 +472,7 @@ export class FixupController input.intent, input.mode, input.model, + input.rules, source, undefined, undefined, @@ -492,6 +497,7 @@ export class FixupController intent: EditIntent, mode: EditMode, model: EditModel, + rules: Rule[] | null, source?: EventSource, destinationFile?: vscode.Uri, insertionPoint?: vscode.Position, @@ -510,6 +516,7 @@ export class FixupController selectionRange, mode, overriddenModel, + rules, source, destinationFile, insertionPoint, diff --git a/vscode/src/non-stop/FixupTask.ts b/vscode/src/non-stop/FixupTask.ts index d5476ed3fb7a..26bf684005ff 100644 --- a/vscode/src/non-stop/FixupTask.ts +++ b/vscode/src/non-stop/FixupTask.ts @@ -5,6 +5,7 @@ import { type EditModel, type EventSource, type PromptString, + type Rule, ps, } from '@sourcegraph/cody-shared' @@ -70,6 +71,7 @@ export class FixupTask { /* The mode indicates how code should be inserted */ public readonly mode: EditMode, public readonly model: EditModel, + public readonly rules: Rule[] | null, /* the source of the instruction, e.g. 'code-action', 'doc', etc */ public source?: EventSource, /* The file to write the edit to. If not provided, the edit will be applied to the fixupFile. */ diff --git a/vscode/src/repository/remote-urls-from-parent-dirs.ts b/vscode/src/repository/remote-urls-from-parent-dirs.ts index 40dcd8004341..93c35c99b437 100644 --- a/vscode/src/repository/remote-urls-from-parent-dirs.ts +++ b/vscode/src/repository/remote-urls-from-parent-dirs.ts @@ -16,6 +16,12 @@ const textDecoder = new TextDecoder('utf-8') export async function gitRemoteUrlsForUri(uri: vscode.Uri, signal?: AbortSignal): Promise { let remoteUrls = gitRemoteUrlsFromGitExtension(uri) + // HACK(sqs) TODO!(sqs) + if (uri.toString().startsWith('https://github.com/')) { + const parts = uri.toString().split('/').slice(0, 5) + return [parts.join('/')] + } + // If not results from the Git extension API, try crawling the file system. if (!remoteUrls || remoteUrls.length === 0) { remoteUrls = await gitRemoteUrlsFromParentDirs(uri, signal) diff --git a/vscode/src/repository/remoteRepos.ts b/vscode/src/repository/remoteRepos.ts index e361d94e36c0..6f6397479f84 100644 --- a/vscode/src/repository/remoteRepos.ts +++ b/vscode/src/repository/remoteRepos.ts @@ -27,12 +27,11 @@ export interface RemoteRepo { const MAX_REPO_COUNT = 10 -const workspaceFolders: Observable = fromVSCodeEvent( - vscode.workspace.onDidChangeWorkspaceFolders -).pipe( - startWith(undefined), - map(() => vscode.workspace.workspaceFolders) -) +export const workspaceFolders: Observable = + fromVSCodeEvent(vscode.workspace.onDidChangeWorkspaceFolders).pipe( + startWith(undefined), + map(() => vscode.workspace.workspaceFolders) + ) /** * A list of all remote repositories for all workspace root folders. diff --git a/vscode/src/rules/fs-rule-provider.test.ts b/vscode/src/rules/fs-rule-provider.test.ts new file mode 100644 index 000000000000..65e5fb6018f7 --- /dev/null +++ b/vscode/src/rules/fs-rule-provider.test.ts @@ -0,0 +1,143 @@ +import { type CandidateRule, firstValueFrom, uriBasename } from '@sourcegraph/cody-shared' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as vscode from 'vscode' +import { URI } from 'vscode-uri' +import { createFileSystemRuleProvider } from './fs-rule-provider' + +describe('createFileSystemRuleProvider', () => { + beforeEach(() => { + vi.resetAllMocks() + vi.spyOn(vscode.workspace, 'onDidCreateFiles').mockReturnValue({ dispose() {} }) + vi.spyOn(vscode.workspace, 'onDidDeleteFiles').mockReturnValue({ dispose() {} }) + vi.spyOn(vscode.workspace, 'onDidChangeTextDocument').mockReturnValue({ dispose() {} }) + vi.spyOn(vscode.workspace, 'onDidChangeWorkspaceFolders').mockReturnValue({ dispose() {} }) + }) + + it('should read and parse rule files from workspace directories', async () => { + const mockWorkspaceFolder: vscode.WorkspaceFolder = { + uri: URI.parse('file:///workspace'), + name: 'workspace', + index: 0, + } + const testFile = URI.parse('file:///workspace/src/test.ts') + const ruleContent = Buffer.from('foo instruction') + vi.spyOn(vscode.workspace, 'getWorkspaceFolder').mockReturnValue(mockWorkspaceFolder) + vi.spyOn(vscode.workspace.fs, 'readDirectory').mockResolvedValue([ + ['foo.rule.md', vscode.FileType.File], + ]) + vi.spyOn(vscode.workspace.fs, 'readFile').mockImplementation(uri => { + if (uri.toString() === 'file:///workspace/.sourcegraph/foo.rule.md') { + return Promise.resolve(ruleContent) + } + throw new vscode.FileSystemError(uri) + }) + + const rules = await firstValueFrom( + createFileSystemRuleProvider().candidateRulesForPaths([testFile]) + ) + expect(rules).toHaveLength(1) + expect(rules[0]).toMatchObject({ + rule: { + uri: 'file:///workspace/.sourcegraph/foo.rule.md', + display_name: 'foo', + instruction: 'foo instruction', + }, + appliesToFiles: [testFile], + }) + expect(vscode.workspace.fs.readDirectory).toHaveBeenCalled() + expect(vscode.workspace.fs.readFile).toHaveBeenCalled() + }) + + it('handles multiple files', async () => { + const mockWorkspaceFolders: vscode.WorkspaceFolder[] = [ + { uri: URI.parse('file:///w1'), name: 'w1', index: 0 }, + { uri: URI.parse('file:///w2'), name: 'w2', index: 1 }, + ] + const testFiles = [ + URI.parse('file:///w1/src/testA.ts'), + URI.parse('file:///w1/src/foo/bar/testB.ts'), + URI.parse('file:///w2/src/testC.ts'), + ] + vi.spyOn(vscode.workspace, 'getWorkspaceFolder').mockImplementation(uri => + mockWorkspaceFolders.find(folder => uri.toString().startsWith(folder.uri.toString())) + ) + vi.spyOn(vscode.workspace.fs, 'readDirectory').mockImplementation(uri => { + const rulesByDir: Record = { + 'file:///w1/.sourcegraph': [['r0.rule.md', vscode.FileType.File]], + 'file:///w1/src/.sourcegraph': [['r1.rule.md', vscode.FileType.File]], + 'file:///w1/src/foo/.sourcegraph': [['r2.rule.md', vscode.FileType.File]], + 'file:///w2/.sourcegraph': [['r3.rule.md', vscode.FileType.File]], + } + return Promise.resolve(rulesByDir[uri.toString()] || []) + }) + vi.spyOn(vscode.workspace.fs, 'readFile').mockImplementation(uri => { + return Promise.resolve(Buffer.from('instruction ' + uriBasename(uri))) + }) + + const rules = await firstValueFrom( + createFileSystemRuleProvider().candidateRulesForPaths(testFiles) + ) + expect(rules).toHaveLength(4) + expect(rules[0]).toMatchObject({ + rule: { + uri: 'file:///w1/src/.sourcegraph/r1.rule.md', + display_name: 'src/r1', + instruction: 'instruction r1.rule.md', + }, + appliesToFiles: [testFiles[1], testFiles[0]], + }) + expect(rules[1]).toMatchObject({ + rule: { + uri: 'file:///w1/.sourcegraph/r0.rule.md', + display_name: 'r0', + instruction: 'instruction r0.rule.md', + }, + appliesToFiles: [testFiles[1], testFiles[0]], + }) + expect(rules[2]).toMatchObject({ + rule: { + uri: 'file:///w1/src/foo/.sourcegraph/r2.rule.md', + display_name: 'src/foo/r2', + instruction: 'instruction r2.rule.md', + }, + appliesToFiles: [testFiles[1]], + }) + expect(rules[3]).toMatchObject({ + rule: { + uri: 'file:///w2/.sourcegraph/r3.rule.md', + display_name: 'r3', + instruction: 'instruction r3.rule.md', + }, + appliesToFiles: [testFiles[2]], + }) + expect(vscode.workspace.fs.readDirectory).toHaveBeenCalled() + expect(vscode.workspace.fs.readFile).toHaveBeenCalled() + }) + + it('should not search for rules outside workspace', async () => { + const testFile = URI.parse('file:///outside/workspace/test.ts') + vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(undefined) + + expect( + await firstValueFrom(createFileSystemRuleProvider().candidateRulesForPaths([testFile])) + ).toHaveLength(0) + expect(vscode.workspace.fs.readDirectory).not.toHaveBeenCalled() + }) + + it('should handle filesystem errors gracefully', async () => { + const mockWorkspaceFolder: vscode.WorkspaceFolder = { + uri: URI.parse('file:///workspace'), + name: 'workspace', + index: 0, + } + const testFile = URI.parse('file:///workspace/src/test.ts') + vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(mockWorkspaceFolder) + vi.mocked(vscode.workspace.fs.readDirectory).mockImplementation(uri => { + throw new vscode.FileSystemError(uri) + }) + + expect( + await firstValueFrom(createFileSystemRuleProvider().candidateRulesForPaths([testFile])) + ).toHaveLength(0) + }) +}) diff --git a/vscode/src/rules/fs-rule-provider.ts b/vscode/src/rules/fs-rule-provider.ts new file mode 100644 index 000000000000..f77ad6084874 --- /dev/null +++ b/vscode/src/rules/fs-rule-provider.ts @@ -0,0 +1,135 @@ +import { + type CandidateRule, + type RuleProvider, + abortableOperation, + debounceTime, + defer, + fromVSCodeEvent, + isRuleFilename, + logDebug, + merge, + parseRuleFile, + pathFunctionsForURI, + ruleSearchPaths, + startWith, +} from '@sourcegraph/cody-shared' +import { type Observable, filter } from 'observable-fns' +import * as vscode from 'vscode' +import { URI } from 'vscode-uri' + +function isRuleFile(uri: URI): boolean { + return uri.path.endsWith('.rule.md') +} + +/** + * An Observable that fires when the user interactively creates, edits, or deletes a + * `.sourcegraph/*.rule.md` file. + */ +const ruleFileInteractiveChanges: Observable = defer(() => + merge( + merge( + fromVSCodeEvent(vscode.workspace.onDidCreateFiles), + fromVSCodeEvent(vscode.workspace.onDidDeleteFiles) + ).pipe(filter(e => e.files.some(isRuleFile))), + fromVSCodeEvent(vscode.workspace.onDidChangeTextDocument).pipe( + filter(e => isRuleFile(e.document.uri)) + ) + ).pipe(debounceTime(1000)) +) + +const workspaceFoldersChanges: Observable = defer(() => + fromVSCodeEvent(vscode.workspace.onDidChangeWorkspaceFolders) +) + +/** + * A {@link RuleProvider} that searches the file system (using the VS Code file system API). + */ +export function createFileSystemRuleProvider(): RuleProvider { + return { + candidateRulesForPaths(files: URI[]): Observable { + const searchPathsForFiles = new Map< + string /* searchPath */, + URI[] /* applies to resources */ + >() + return merge(ruleFileInteractiveChanges, workspaceFoldersChanges).pipe( + startWith(undefined), + abortableOperation(async (_, signal) => { + for (const uri of files) { + // Do not search for rules outside of a workspace folder. + const root = vscode.workspace.getWorkspaceFolder(uri) + if (!root) { + continue + } + const searchPaths = ruleSearchPaths(uri, root.uri) + for (const searchPath of searchPaths) { + const appliesToResources = + searchPathsForFiles.get(searchPath.toString()) ?? [] + appliesToResources.push(uri) + searchPathsForFiles.set(searchPath.toString(), appliesToResources) + } + } + + const results = await Promise.all( + Array.from(searchPathsForFiles.entries()).map( + async ([searchPathStr, appliesToFiles]): Promise => { + appliesToFiles.sort() + const searchPath = URI.parse(searchPathStr) + const pathFuncs = pathFunctionsForURI(searchPath) + try { + const entries = await vscode.workspace.fs.readDirectory(searchPath) + signal?.throwIfAborted() + + const rootFolder = vscode.workspace.getWorkspaceFolder(searchPath) + // There should always be a root since we checked it above, but + // be defensive. + if (!rootFolder) { + return [] + } + const root = rootFolder.uri + + const ruleFiles = entries.filter(([name]) => isRuleFilename(name)) + const rules = await Promise.all( + ruleFiles.map(async ([name]) => { + const ruleURI = searchPath.with({ + path: pathFuncs.resolve(searchPath.path, name), + }) + const content = await vscode.workspace.fs.readFile(ruleURI) + signal?.throwIfAborted() + const rule = parseRuleFile( + ruleURI, + root, + new TextDecoder().decode(content) + ) + return { + rule, + appliesToFiles, + } satisfies CandidateRule + }) + ) + return rules + } catch (error) { + if ( + !( + error && + typeof error === 'object' && + 'code' in error && + error.code === 'FileNotFound' + ) + ) { + logDebug( + 'rules', + `Error reading rules for ${searchPath}: ${error}` + ) + } + return [] + } + } + ) + ) + signal?.throwIfAborted() + return results.flat() + }) + ) + }, + } +} diff --git a/vscode/src/rules/remote-rule-provider.test.ts b/vscode/src/rules/remote-rule-provider.test.ts new file mode 100644 index 000000000000..4311e693cc5d --- /dev/null +++ b/vscode/src/rules/remote-rule-provider.test.ts @@ -0,0 +1,112 @@ +import { type Rule, firstValueFrom } from '@sourcegraph/cody-shared' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as vscode from 'vscode' +import { URI } from 'vscode-uri' +import * as repoResolver from '../repository/repo-name-resolver' +import { type RuleRetrieveResponse, createRemoteRuleProvider } from './remote-rule-provider' + +describe('createRemoteRuleProvider', () => { + const mockClient = { + fetchHTTP: vi.fn(), + } + beforeEach(() => { + vi.resetAllMocks() + }) + + it('should fetch rules from remote API for workspace files', async () => { + const testFile = URI.parse('https://example.com/a/src/test.ts') + const mockRule: Rule = { + uri: 'https://example.com/a/.sourcegraph/a.rule.md', + display_name: 'a', + instruction: 'test instruction', + } + + vi.spyOn(repoResolver, 'getFirstRepoNameContainingUri').mockResolvedValue('example.com/a') + vi.spyOn(vscode.workspace, 'asRelativePath').mockReturnValue('src/test.ts') + mockClient.fetchHTTP.mockResolvedValue({ rules: [mockRule] } satisfies RuleRetrieveResponse) + + const rules = await firstValueFrom( + createRemoteRuleProvider(mockClient).candidateRulesForPaths([testFile]) + ) + + expect(mockClient.fetchHTTP).toHaveBeenCalledTimes(1) + expect(mockClient.fetchHTTP).toHaveBeenCalledWith( + 'rules', + 'GET', + expect.stringContaining('/.api/rules'), + undefined, + expect.any(AbortSignal) + ) + expect(rules).toHaveLength(1) + expect(rules[0]).toMatchObject({ + rule: mockRule, + appliesToFiles: [testFile], + }) + }) + + it('should handle multiple files from different repos', async () => { + const testFiles = [ + URI.parse('file:///workspace1/src/test1.ts'), + URI.parse('file:///workspace2/src/test2.ts'), + ] + const mockRules: Rule[] = [ + { + uri: 'https://example.com/repo1/.sourcegraph/a.rule.md', + display_name: 'a', + instruction: 'instruction 1', + }, + { + uri: 'https://example.com/repo2/.sourcegraph/b.rule.md', + display_name: 'b', + instruction: 'instruction 2', + }, + ] + + vi.spyOn(repoResolver, 'getFirstRepoNameContainingUri').mockImplementation(uri => + Promise.resolve( + uri.toString().includes('workspace1') ? 'example.com/repo1' : 'example.com/repo2' + ) + ) + vi.spyOn(vscode.workspace, 'asRelativePath').mockImplementation(uri => + uri.toString().includes('workspace1') ? 'src/test1.ts' : 'src/test2.ts' + ) + mockClient.fetchHTTP.mockImplementation( + async (_, __, url): Promise => + url.includes('repo1') ? { rules: [mockRules[0]] } : { rules: [mockRules[1]] } + ) + + const rules = await firstValueFrom( + createRemoteRuleProvider(mockClient).candidateRulesForPaths(testFiles) + ) + + expect(rules).toHaveLength(2) + expect(rules[0].rule).toEqual(mockRules[0]) + expect(rules[1].rule).toEqual(mockRules[1]) + expect(mockClient.fetchHTTP).toHaveBeenCalledTimes(2) + }) + + it('should ignore files without associated repos', async () => { + vi.spyOn(repoResolver, 'getFirstRepoNameContainingUri').mockResolvedValue(undefined) + + const rules = await firstValueFrom( + createRemoteRuleProvider(mockClient).candidateRulesForPaths([ + URI.parse('https://example.com/a/test.ts'), + ]) + ) + expect(rules).toHaveLength(0) + expect(mockClient.fetchHTTP).not.toHaveBeenCalled() + }) + + it('should handle API errors gracefully', async () => { + vi.spyOn(repoResolver, 'getFirstRepoNameContainingUri').mockResolvedValue('test/repo') + vi.spyOn(vscode.workspace, 'asRelativePath').mockReturnValue('src/test.ts') + mockClient.fetchHTTP.mockRejectedValue(new Error('API Error')) + + const rules = await firstValueFrom( + createRemoteRuleProvider(mockClient).candidateRulesForPaths([ + URI.parse('https://example.com/a/test.ts'), + ]) + ) + expect(rules).toHaveLength(0) + }) +}) diff --git a/vscode/src/rules/remote-rule-provider.ts b/vscode/src/rules/remote-rule-provider.ts new file mode 100644 index 000000000000..c2b4e55e055a --- /dev/null +++ b/vscode/src/rules/remote-rule-provider.ts @@ -0,0 +1,108 @@ +import { + type CandidateRule, + type Rule, + type RuleProvider, + type SourcegraphGraphQLAPIClient, + graphqlClient, + isError, + logDebug, + promiseFactoryToObservable, +} from '@sourcegraph/cody-shared' +import type { Observable } from 'observable-fns' +import * as vscode from 'vscode' +import type { URI } from 'vscode-uri' +import { getFirstRepoNameContainingUri } from '../repository/repo-name-resolver' + +/** + * A {@link RuleProvider} that fetches rules from the Sourcegraph instance API. + */ +export function createRemoteRuleProvider( + client: Pick = graphqlClient +): RuleProvider { + return { + candidateRulesForPaths(files: URI[]): Observable { + return promiseFactoryToObservable(async signal => { + const filesByRepo = new Map() + await Promise.all( + files.map(async uri => { + const repoName = await getFirstRepoNameContainingUri(uri) + if (!repoName) { + return + } + filesByRepo.set(repoName, [...(filesByRepo.get(repoName) ?? []), uri]) + }) + ) + + const candidateRules = new Map() + await Promise.all( + Array.from(filesByRepo.entries()).map(async ([repoName, files]): Promise => { + await Promise.all( + files.map(async uri => { + // TODO(sqs): better mapping of local files to paths within a remote repo + const filePath = vscode.workspace.asRelativePath(uri) + const rules = await listRulesApplyingToRemoteFile( + client, + repoName, + filePath, + signal + ) + for (const rule of rules) { + let c = candidateRules.get(rule.uri) + if (c) { + c.appliesToFiles.push(uri) + } else { + c = { + rule, + appliesToFiles: [uri], + } + candidateRules.set(rule.uri, c) + } + } + }) + ) + }) + ) + + return Array.from(candidateRules.values()) + }) + }, + } +} + +/** + * @see [RuleRetrieveResponse](https://sourcegraph.sourcegraph.com/github.com/sourcegraph/sourcegraph/-/blob/internal/openapi/rule.tsp) + */ +export interface RuleRetrieveResponse { + rules: Rule[] | null +} + +async function listRulesApplyingToRemoteFile( + client: Pick, + repoName: string, + filePath: string, + signal?: AbortSignal +): Promise { + try { + const query = new URLSearchParams() + query.set('filter[applies_to_repo]', repoName) + // TODO(sqs): supply the relevant branch/rev, if any + query.set('filter[applies_to_path]', filePath) + const resp = await client.fetchHTTP( + 'rules', + 'GET', + `/.api/rules?${query.toString()}`, + undefined, + signal + ) + if (isError(resp)) { + return [] + } + return resp.rules ?? [] + } catch (error) { + logDebug( + 'rules', + `Error listing rules for remote file ${filePath} in repository ${repoName}: ${error}` + ) + return [] + } +} diff --git a/vscode/src/rules/service.ts b/vscode/src/rules/service.ts new file mode 100644 index 000000000000..a20e98e881de --- /dev/null +++ b/vscode/src/rules/service.ts @@ -0,0 +1,35 @@ +import { + type RuleService, + clientCapabilities, + createRuleService, + defer, + isDefined, +} from '@sourcegraph/cody-shared' +import { Observable } from 'observable-fns' +import { createFileSystemRuleProvider } from './fs-rule-provider' +import { createRemoteRuleProvider } from './remote-rule-provider' + +/** + * The global singleton {@link RuleService}. + */ +export const ruleService: RuleService = createRuleService( + defer(() => + Observable.of( + [ + clientCapabilities().isVSCode + ? createFileSystemRuleProvider() + : createRemoteRuleProvider(), + ].filter(isDefined) + ) + ), + { + fileInfo: file => { + return { + languages: [], // TODO!(sqs): fill in languages + path: file.path, + repo: 'github.com/sourcegraph/review-agent-sandbox', // TODO!(sqs): fill in repo, use this instead of RepoNameResolver + textContent: '', // TODO(sqs): fill in text content + } + }, + } +) diff --git a/vscode/src/testutils/mocks.ts b/vscode/src/testutils/mocks.ts index 3a8f84f2fe8e..649e3e2635cb 100644 --- a/vscode/src/testutils/mocks.ts +++ b/vscode/src/testutils/mocks.ts @@ -866,10 +866,12 @@ export const vsCodeMocks = { onDidChangeTextDocument() {}, onDidOpenTextDocument() {}, onDidCloseTextDocument() {}, + onDidCreateFiles() {}, onDidRenameFiles() {}, onDidDeleteFiles() {}, textDocuments: vscodeWorkspaceTextDocuments, workspaceFolders: undefined, + getWorkspaceFolder: () => undefined, onDidChangeWorkspaceFolders: () => {}, }, ConfigurationTarget: { diff --git a/vscode/webviews/chat/cells/contextCell/ContextCell.tsx b/vscode/webviews/chat/cells/contextCell/ContextCell.tsx index 17598863a3ff..b8772c033066 100644 --- a/vscode/webviews/chat/cells/contextCell/ContextCell.tsx +++ b/vscode/webviews/chat/cells/contextCell/ContextCell.tsx @@ -269,6 +269,11 @@ export const ContextCell: FunctionComponent<{ isTooLarge={item.isTooLarge} isTooLargeReason={item.isTooLargeReason} isIgnored={item.isIgnored} + providerUri={ + item.type === 'openctx' + ? item.providerUri + : undefined + } linkClassName={styles.contextItemLink} className={clsx( styles.linkContainer, diff --git a/vscode/webviews/components/FileLink.tsx b/vscode/webviews/components/FileLink.tsx index 2a7a5e322a3e..fb6d9f9d2e92 100644 --- a/vscode/webviews/components/FileLink.tsx +++ b/vscode/webviews/components/FileLink.tsx @@ -3,12 +3,14 @@ import type React from 'react' import { type ContextItemSource, + RULES_PROVIDER_URI, type RangeData, displayLineRange, displayPath, webviewOpenURIForContextItem, } from '@sourcegraph/cody-shared' +import { BookCheckIcon } from 'lucide-react' import { useCallback, useMemo } from 'react' import type { URI } from 'vscode-uri' import { getVSCodeAPI } from '../utils/VSCodeApi' @@ -27,6 +29,7 @@ interface FileLinkProps { isTooLarge?: boolean isTooLargeReason?: string isIgnored?: boolean + providerUri?: string } const LIMIT_WARNING = 'Excluded due to context window limit' @@ -61,6 +64,7 @@ export const FileLink: React.FunctionComponent< isTooLarge, isTooLargeReason, isIgnored, + providerUri, className, linkClassName, }) => { @@ -151,7 +155,11 @@ export const FileLink: React.FunctionComponent< href={linkDetails.href} target={linkDetails.target} > - + {providerUri === RULES_PROVIDER_URI ? ( + + ) : ( + + )}
{ serverEndpoint={serverEndpoint} createAgentWorker={CREATE_AGENT_WORKER} telemetryClientName="codydemo.testing" - initialContext={MOCK_INITIAL_CONTEXT} viewType="sidebar" />
diff --git a/web/lib/agent/agent.client.ts b/web/lib/agent/agent.client.ts index 64fe2604fe4d..6495e05f4d02 100644 --- a/web/lib/agent/agent.client.ts +++ b/web/lib/agent/agent.client.ts @@ -70,7 +70,9 @@ export async function createAgentClient({ version: '0.0.1', // Empty root URI leads to openctx configuration resolution failure, any non-empty // mock value (Cody Web doesn't really use any workspace related features) - workspaceRootUri: 'sourcegraph/cody', + // + // TODO!(sqs): figure out sane default for this + workspaceRootUri: 'https://github.com/sourcegraph/review-agent-sandbox', capabilities: { edit: 'none', completions: 'none',