diff --git a/languageserver/src/connection.ts b/languageserver/src/connection.ts index 90b139b2..e91af70b 100644 --- a/languageserver/src/connection.ts +++ b/languageserver/src/connection.ts @@ -23,7 +23,7 @@ import {Commands} from "./commands"; import {contextProviders} from "./context-providers"; import {descriptionProvider} from "./description-provider"; import {getFileProvider} from "./file-provider"; -import {InitializationOptions, RepositoryContext} from "./initializationOptions"; +import {InitializationOptions, RepositoryContext, SecretsValidationMode} from "./initializationOptions"; import {onCompletion} from "./on-completion"; import {ReadFileRequest, Requests} from "./request"; import {getActionsMetadataProvider} from "./utils/action-metadata"; @@ -36,6 +36,7 @@ export function initConnection(connection: Connection) { let client: Octokit | undefined; let repos: RepositoryContext[] = []; + let secretsValidation: SecretsValidationMode = "auto"; const cache = new TTLCache(); let hasWorkspaceFolderCapability = false; @@ -62,6 +63,10 @@ export function initConnection(connection: Connection) { setLogLevel(options.logLevel); } + if (options.secretsValidation) { + secretsValidation = options.secretsValidation; + } + const result: InitializeResult = { capabilities: { textDocumentSync: TextDocumentSyncKind.Full, @@ -107,7 +112,7 @@ export function initConnection(connection: Connection) { const config: ValidationConfig = { valueProviderConfig: valueProviders(client, repoContext, cache), - contextProviderConfig: contextProviders(client, repoContext, cache), + contextProviderConfig: contextProviders(client, repoContext, cache, secretsValidation), actionsMetadataProvider: getActionsMetadataProvider(client, cache), fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => { return await connection.sendRequest(Requests.ReadFile, {path} satisfies ReadFileRequest); @@ -138,7 +143,7 @@ export function initConnection(connection: Connection) { const repoContext = repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri)); return await hover(getDocument(documents, textDocument), position, { descriptionProvider: descriptionProvider(client, cache), - contextProviderConfig: repoContext && contextProviders(client, repoContext, cache), + contextProviderConfig: repoContext && contextProviders(client, repoContext, cache, secretsValidation), fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => { return await connection.sendRequest(Requests.ReadFile, {path}); }) diff --git a/languageserver/src/context-providers.test.ts b/languageserver/src/context-providers.test.ts new file mode 100644 index 00000000..5a15432e --- /dev/null +++ b/languageserver/src/context-providers.test.ts @@ -0,0 +1,87 @@ +import {DescriptionDictionary} from "@actions/expressions"; +import {Octokit} from "@octokit/rest"; + +import {contextProviders} from "./context-providers"; +import {RepositoryContext} from "./initializationOptions"; +import {TTLCache} from "./utils/cache"; + +const mockClient = new Octokit(); +const mockRepo: RepositoryContext = { + id: 123, + owner: "test-owner", + name: "test-repo", + workspaceUri: "file:///test", + organizationOwned: false +}; + +describe("contextProviders", () => { + describe("with secretsValidation = 'auto' (default)", () => { + it("returns incomplete secrets context when client is undefined", async () => { + const config = contextProviders(undefined, undefined, new TTLCache()); + const result = await config.getContext("secrets", undefined, {} as never, 0); + + expect(result).toBeInstanceOf(DescriptionDictionary); + expect((result as DescriptionDictionary).complete).toBe(false); + }); + + it("returns incomplete vars context when client is undefined", async () => { + const config = contextProviders(undefined, undefined, new TTLCache()); + const result = await config.getContext("vars", undefined, {} as never, 0); + + expect(result).toBeInstanceOf(DescriptionDictionary); + expect((result as DescriptionDictionary).complete).toBe(false); + }); + + it("preserves existing context when provided for secrets", async () => { + const existingContext = new DescriptionDictionary(); + existingContext.add("EXISTING_SECRET", {kind: 0, value: "***"} as never); + + const config = contextProviders(undefined, undefined, new TTLCache()); + const result = await config.getContext("secrets", existingContext, {} as never, 0); + + expect(result).toBe(existingContext); + expect((result as DescriptionDictionary).complete).toBe(false); + }); + + it("returns undefined for other context types", async () => { + const config = contextProviders(undefined, undefined, new TTLCache()); + const result = await config.getContext("steps", undefined, {} as never, 0); + + expect(result).toBeUndefined(); + }); + }); + + describe("with secretsValidation = 'always'", () => { + it("returns undefined for secrets when not signed in (triggers warnings)", async () => { + const config = contextProviders(undefined, undefined, new TTLCache(), "always"); + const result = await config.getContext("secrets", undefined, {} as never, 0); + + expect(result).toBeUndefined(); + }); + + it("returns undefined for vars when not signed in (triggers warnings)", async () => { + const config = contextProviders(undefined, undefined, new TTLCache(), "always"); + const result = await config.getContext("vars", undefined, {} as never, 0); + + expect(result).toBeUndefined(); + }); + }); + + describe("with secretsValidation = 'never'", () => { + it("returns incomplete secrets context even when signed in", async () => { + const config = contextProviders(mockClient, mockRepo, new TTLCache(), "never"); + const result = await config.getContext("secrets", undefined, {} as never, 0); + + expect(result).toBeInstanceOf(DescriptionDictionary); + expect((result as DescriptionDictionary).complete).toBe(false); + }); + + it("returns incomplete vars context even when signed in", async () => { + const config = contextProviders(mockClient, mockRepo, new TTLCache(), "never"); + const result = await config.getContext("vars", undefined, {} as never, 0); + + expect(result).toBeInstanceOf(DescriptionDictionary); + expect((result as DescriptionDictionary).complete).toBe(false); + }); + }); +}); diff --git a/languageserver/src/context-providers.ts b/languageserver/src/context-providers.ts index 243baf77..cbaf5242 100644 --- a/languageserver/src/context-providers.ts +++ b/languageserver/src/context-providers.ts @@ -6,16 +6,49 @@ import {Octokit} from "@octokit/rest"; import {getSecrets} from "./context-providers/secrets"; import {getStepsContext} from "./context-providers/steps"; import {getVariables} from "./context-providers/variables"; -import {RepositoryContext} from "./initializationOptions"; +import {RepositoryContext, SecretsValidationMode} from "./initializationOptions"; import {TTLCache} from "./utils/cache"; export function contextProviders( client: Octokit | undefined, repo: RepositoryContext | undefined, - cache: TTLCache + cache: TTLCache, + secretsValidation: SecretsValidationMode = "auto" ): ContextProviderConfig { + // Handle missing client/repo based on validation mode if (!repo || !client) { - return {getContext: () => Promise.resolve(undefined)}; + // "never" - always suppress validation + // "auto" - suppress when context is incomplete (client or repo missing) + // "always" - show warnings even when context is incomplete + const shouldSuppress = secretsValidation === "never" || secretsValidation === "auto"; + + if (shouldSuppress) { + // Mark secrets/vars as incomplete to prevent false warnings + return { + getContext: ( + name: string, + defaultContext: DescriptionDictionary | undefined, + workflowContext: WorkflowContext, + mode: Mode + ) => { + if (name === "secrets" || name === "vars") { + const dict = defaultContext || new DescriptionDictionary(); + dict.complete = false; + return Promise.resolve(dict); + } + return Promise.resolve(undefined); + } + }; + } + // "always" mode - return undefined to trigger warnings + return { + getContext: ( + name: string, + defaultContext: DescriptionDictionary | undefined, + workflowContext: WorkflowContext, + mode: Mode + ) => Promise.resolve(undefined) + }; } const getContext = async ( @@ -24,6 +57,13 @@ export function contextProviders( workflowContext: WorkflowContext, mode: Mode ) => { + // If validation is disabled, mark as incomplete + if (secretsValidation === "never" && (name === "secrets" || name === "vars")) { + const dict = defaultContext || new DescriptionDictionary(); + dict.complete = false; + return dict; + } + switch (name) { case "secrets": return await getSecrets(workflowContext, client, cache, repo, defaultContext, mode); @@ -31,6 +71,8 @@ export function contextProviders( return await getVariables(workflowContext, client, cache, repo, defaultContext); case "steps": return await getStepsContext(client, cache, defaultContext, workflowContext); + default: + return undefined; } }; diff --git a/languageserver/src/initializationOptions.ts b/languageserver/src/initializationOptions.ts index 59ef4623..790a4cdb 100644 --- a/languageserver/src/initializationOptions.ts +++ b/languageserver/src/initializationOptions.ts @@ -1,6 +1,8 @@ import {LogLevel} from "@actions/languageservice/log"; export {LogLevel} from "@actions/languageservice/log"; +export type SecretsValidationMode = "auto" | "always" | "never"; + export interface InitializationOptions { /** * GitHub token that will be used to retrieve additional information from github.com @@ -28,6 +30,14 @@ export interface InitializationOptions { * If a GitHub Enterprise Server should be used, the URL of the API endpoint, eg "https://ghe.my-company.com/api/v3" */ gitHubApiUrl?: string; + + /** + * Controls validation of secrets and variables context access + * - "auto": Validate only when signed in (recommended) + * - "always": Always validate - show warnings even when not signed in + * - "never": Never validate secrets/variables access + */ + secretsValidation?: SecretsValidationMode; } export interface RepositoryContext {