From 77f82325105edee5ee1460f6acd8c44b722cf345 Mon Sep 17 00:00:00 2001 From: Andreas Wirth Date: Mon, 20 Jul 2026 13:23:17 +0200 Subject: [PATCH] Add configuration options and validation for resolving models from an external github repo --- README.md | 8 +++ package.json | 44 ++++++++++++- src/extension.ts | 31 +++++++++ src/githubRepositoryValidator.ts | 108 +++++++++++++++++++++++++++++++ src/languageClient.ts | 1 + src/settings.ts | 12 ++++ 6 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 src/githubRepositoryValidator.ts diff --git a/README.md b/README.md index 0211ff8..a5fff77 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,14 @@ features *Go to Definition* for elements and semantic model validation. - TCP port used to connect to the SAMM language server. - `semantic-models.languageServerSettings.traceLevel` (string, default: `off`) - Controls the verbosity of Language Server Protocol (LSP) tracing. Options: `off`, `messages`, `verbose`. +- `semantic-models.modelResolution.githubRepositories` (array, default: `[]`) + - Additional GitHub repositories to use for Aspect Model Resolution (e.g. `Go to Definition` and validation of models referencing Aspect Models hosted in other repositories). Each entry supports: + - `repository` (string, required) - repository in the format `owner/repository`, e.g. `eclipse-esmf/esmf-sdk`. + - `branch` (string, default: `main`) - branch to resolve Aspect Models from. Must not be set together with `tag`. + - `tag` (string) - tag to resolve Aspect Models from. Must not be set together with `branch`. + - `path` (string, default: `/`) - path inside the repository under which Aspect Models are located. + - `token` (string) - GitHub token for authentication. If omitted, the repository is accessed anonymously. + - Whenever this setting changes, the extension validates that each repository exists and is accessible, and shows an error notification (with a link to the settings) if a problem is found, e.g. a missing repository, invalid/expired token, or exceeded API rate limits. Use the command `Semantic Models: Select SAMM CLI Executable` to choose either: - one of the latest SAMM CLI GitHub releases, or diff --git a/package.json b/package.json index dfafcdb..3183f41 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,48 @@ "default": "off", "description": "Controls the verbosity of Language Server Protocol (LSP) tracing in the output channel.", "order": 4 + }, + "semantic-models.modelResolution.githubRepositories": { + "type": "array", + "items": { + "type": "object", + "properties": { + "repository": { + "type": "string", + "default": "", + "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", + "patternErrorMessage": "Must be in the format \"owner/repository\".", + "markdownDescription": "GitHub repository to use for Aspect Model Resolution. Format: `owner/repository`. Example: `eclipse-esmf/esmf-sdk`." + }, + "branch": { + "type": "string", + "default": "main", + "description": "Branch to resolve Aspect Models from. Must not be set together with \"tag\"." + }, + "tag": { + "type": "string", + "default": "", + "description": "Tag to resolve Aspect Models from. Must not be set together with \"branch\"." + }, + "path": { + "type": "string", + "default": "/", + "description": "Path inside the repository under which Aspect Models are located." + }, + "token": { + "type": "string", + "default": "", + "description": "GitHub token used for authentication when accessing this repository. If not provided, the repository will be accessed anonymously." + } + }, + "required": [ + "repository" + ], + "additionalProperties": false + }, + "default": [], + "markdownDescription": "Additional GitHub repositories to be used for Aspect Model Resolution. Enables e.g. go to Definition and validation of models that reference Aspect Models in other repositories. Each entry must specify the repository in the format `owner/repository`, and may optionally specify a branch/tag, path, and GitHub token for authentication.", + "order": 5 } } }, @@ -125,7 +167,7 @@ { "id": "select-samm-cli", "title": "Select a SAMM CLI Executable", - "description": "Complete the initial setup by downloading or selecting a SAMM CLI executable, which provides the required SAMM Language Server.\n[Download or Select SAMM CLI](command:turtle.selectSammCliExecutable)", + "description": "Complete the initial setup by downloading or selecting a SAMM CLI executable, which provides the required SAMM Language Server.\n[Download or Select SAMM CLI](command:turtle.selectSammCliExecutable)", "media": { "image": "media/walkthrough_select_samm_cli.png", "altText": "Example of selecting a SAMM CLI executable" diff --git a/src/extension.ts b/src/extension.ts index 19f9098..d26b56f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -17,21 +17,25 @@ import { TurtleLanguageServer } from './languageServer'; import { SammCliDownloader } from './sammCliDownloader'; import { TurtleExtensionSettings } from './settings'; import { TurtleLanguageClient } from './languageClient'; +import { GitHubRepositoryValidator } from './githubRepositoryValidator'; import type { ExtensionLogger } from './outputChannel'; const SELECT_EXECUTABLE_COMMAND = 'semantic-models.selectSammCliExecutable'; const SELECT_EXECUTABLE_TITLE = 'Select SAMM CLI Executable'; const RESTART_LANGUAGE_SERVICES_COMMAND = 'semantic-models.restartLanguageServices'; +const GITHUB_REPOSITORY_VALIDATION_DEBOUNCE_MS = 2000; let settings: TurtleExtensionSettings; let languageServer: TurtleLanguageServer | undefined; let languageClient: TurtleLanguageClient; let aspectValidationController: AspectValidationController; let sammCliDownloader: SammCliDownloader; +let gitHubRepositoryValidator: GitHubRepositoryValidator; let outputChannel: ExtensionLogger; let context: vscode.ExtensionContext; let restartChain: Promise = Promise.resolve(); +let githubRepositoryValidationTimeout: ReturnType | undefined; export async function activate(ctx: vscode.ExtensionContext): Promise { context = ctx; @@ -40,6 +44,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { outputChannel = logOutputChannel; settings = new TurtleExtensionSettings(); sammCliDownloader = new SammCliDownloader(context, settings, outputChannel); + gitHubRepositoryValidator = new GitHubRepositoryValidator(outputChannel); languageClient = new TurtleLanguageClient(outputChannel, settings.getSammCliLspServerPort(), settings.getLanguageClientTraceLevel()); aspectValidationController = new AspectValidationController(createUnavailableClient(), vscode.window, vscode.workspace, outputChannel); aspectValidationController.register(context); @@ -55,9 +60,14 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { if (e.affectsConfiguration('semantic-models.languageServerSettings')) { void queueLanguageServicesRestart('Configuration change detected'); } + if (e.affectsConfiguration('semantic-models.modelResolution')) { + scheduleGithubRepositoryValidation(); + } }) ); + void validateConfiguredGithubRepositories(); + if (settings.isEmbeddedLanguageServerStartEnabled() && !settings.getSammCliPath()) { const selection = await vscode.window.showErrorMessage( 'No SAMM CLI path configured. Required for Language Server functionality.', @@ -78,6 +88,23 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } } +async function validateConfiguredGithubRepositories(): Promise { + await gitHubRepositoryValidator.validate(settings.getGithubRepositories()); +} + +// Wait for configuration edits to settle (e.g. while the user is still typing in settings.json) +// before validating, instead of validating after every keystroke. +function scheduleGithubRepositoryValidation(): void { + if (githubRepositoryValidationTimeout) { + clearTimeout(githubRepositoryValidationTimeout); + } + + githubRepositoryValidationTimeout = setTimeout(() => { + githubRepositoryValidationTimeout = undefined; + void validateConfiguredGithubRepositories(); + }, GITHUB_REPOSITORY_VALIDATION_DEBOUNCE_MS); +} + function queueLanguageServicesRestart(reason: string): Promise { restartChain = restartChain .then(() => restartLanguageServices(reason)) @@ -257,6 +284,10 @@ function createUnavailableClient(): RequestClient { } export async function deactivate(): Promise { + if (githubRepositoryValidationTimeout) { + clearTimeout(githubRepositoryValidationTimeout); + githubRepositoryValidationTimeout = undefined; + } await restartChain; await languageClient.disconnect(); await stopLanguageServer(); diff --git a/src/githubRepositoryValidator.ts b/src/githubRepositoryValidator.ts new file mode 100644 index 0000000..2ca14db --- /dev/null +++ b/src/githubRepositoryValidator.ts @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +import * as vscode from 'vscode'; +import type { GithubRepositoryConfig } from './settings'; +import type { ExtensionLogger } from './outputChannel'; + +/** + * Validates the `semantic-models.modelResolution.githubRepositories` setting by checking, for + * each configured entry, that the repository exists and is accessible using the provided token + * (or anonymously, if none was provided). Problems are logged and surfaced to the user via an + * error notification. + */ +export class GitHubRepositoryValidator { + constructor(private readonly outputChannel: ExtensionLogger) { } + + async validate(repositories: Array): Promise { + if (repositories.length === 0) { + return; + } + + const errors = (await Promise.all(repositories.map(repository => this.validateRepository(repository)))) + .filter((error): error is string => error !== undefined); + + if (errors.length === 0) { + return; + } + + const message = errors.length === 1 + ? errors[0] + : `Multiple issues were found with the configured GitHub repositories for Aspect Model Resolution:\n- ${errors.join('\n- ')}`; + + this.outputChannel.error(message); + + const selection = await vscode.window.showErrorMessage(message, 'Check Extension Settings'); + if (selection === 'Check Extension Settings') { + void vscode.commands.executeCommand('workbench.action.openSettings', 'semantic-models.modelResolution'); + } + } + + private async validateRepository(repository: GithubRepositoryConfig): Promise { + if (!repository.repository) { + return 'A GitHub repository entry is missing the required "repository" field.'; + } + + if (repository.branch && repository.tag) { + return `GitHub repository "${repository.repository}" has both "branch" and "tag" configured; only one of them may be set.`; + } + + try { + const response = await fetch(`https://api.github.com/repos/${repository.repository}`, { + headers: this.buildHeaders(repository.token), + }); + + if (response.ok) { + return undefined; + } + + return this.describeError(repository, response); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Failed to reach GitHub while validating repository "${repository.repository}": ${message}`; + } + } + + private buildHeaders(token: string | undefined): Record { + const headers: Record = { + 'User-Agent': 'esmf-vs-code-plugin', + Accept: 'application/vnd.github+json', + }; + + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + return headers; + } + + private describeError(repository: GithubRepositoryConfig, response: Response): string { + const hasToken = !!repository.token; + + switch (response.status) { + case 401: + return `The configured token for GitHub repository "${repository.repository}" is invalid or expired.`; + case 403: + if (response.headers.get('x-ratelimit-remaining') === '0') { + const reset = response.headers.get('x-ratelimit-reset'); + const resetHint = reset ? ` Try again after ${new Date(Number(reset) * 1000).toLocaleTimeString()}.` : ''; + return `GitHub API rate limit exceeded while validating repository "${repository.repository}".${resetHint}`; + } + return `Access to GitHub repository "${repository.repository}" is forbidden${hasToken ? ' with the configured token' : '; it may be private and require a token'}.`; + case 404: + return `GitHub repository "${repository.repository}" was not found or is not accessible${hasToken ? ' with the configured token' : '; it may be private, configure a token if needed'}.`; + default: + return `Failed to validate GitHub repository "${repository.repository}": ${response.status} ${response.statusText}`; + } + } +} diff --git a/src/languageClient.ts b/src/languageClient.ts index 384d645..615e10f 100644 --- a/src/languageClient.ts +++ b/src/languageClient.ts @@ -55,6 +55,7 @@ export class TurtleLanguageClient implements RequestClient { documentSelector: ['turtle'], synchronize: { fileEvents: vscode.workspace.createFileSystemWatcher('**/*.ttl'), + configurationSection: 'semantic-models.modelResolution', }, }; diff --git a/src/settings.ts b/src/settings.ts index 5d9930a..e9fedcd 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -13,6 +13,14 @@ import * as vscode from 'vscode'; +export interface GithubRepositoryConfig { + repository: string; + branch: string; + tag: string; + path: string; + token: string; +} + export class TurtleExtensionSettings { isEmbeddedLanguageServerStartEnabled(): boolean { @@ -43,4 +51,8 @@ export class TurtleExtensionSettings { return vscode.workspace.getConfiguration('semantic-models.languageServerSettings').get<'off' | 'messages' | 'verbose'>('traceLevel', 'off'); } + getGithubRepositories(): Array { + return vscode.workspace.getConfiguration('semantic-models.modelResolution').get>('githubRepositories', []); + } + }