diff --git a/build.gradle.kts b/build.gradle.kts
index dc8d93d..d5e240d 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -49,7 +49,6 @@ dependencies {
// Загрузчик BSL Language Server
implementation("org.slf4j:slf4j-api:2.0.18")
- implementation("org.kohsuke:github-api:1.330")
implementation("org.apache.commons:commons-compress:1.28.0")
implementation("org.semver4j:semver4j:6.0.0")
diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java
index 82bca80..dfd674e 100644
--- a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java
+++ b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java
@@ -88,8 +88,8 @@ public class BslLanguageServerDownloader {
* @param installDir каталог установки сервера; в нём создаются подпапки с версиями
* и файл {@code SERVER-INFO}
* @param releaseClient источник сведений о последнем релизе
- * @param httpClient клиент только для скачивания ассета (github-api эту загрузку не умеет);
- * должен следовать редиректам — ассеты GitHub отдаются редиректом на CDN
+ * @param httpClient клиент для скачивания ассета; должен следовать редиректам —
+ * ассеты GitHub отдаются редиректом на CDN
*/
public BslLanguageServerDownloader(Path installDir, GitHubReleaseClient releaseClient,
HttpClient httpClient) {
diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java
index 87eeee9..0c73238 100644
--- a/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java
+++ b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java
@@ -22,21 +22,22 @@
package com.github._1c_syntax.utils.downloader;
import org.jspecify.annotations.Nullable;
-import org.kohsuke.github.GHAsset;
-import org.kohsuke.github.GHRelease;
-import org.kohsuke.github.GHRepository;
-import org.kohsuke.github.GitHub;
-import org.kohsuke.github.GitHubBuilder;
-import org.kohsuke.github.extras.HttpClientGitHubConnector;
import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
/**
* Клиент GitHub-релизов BSL Language Server: находит последний релиз канала в репозитории
- * {@value #REPOSITORY}. HTTP-доступ обеспечивает сама github-api (свой {@link HttpClientGitHubConnector}
- * со встроенным клиентом) — отдельный HTTP-клиент здесь не нужен.
+ * {@value #REPOSITORY} через GitHub REST API. Работает на {@link java.net.http.HttpClient}
+ * и встроенном JSON-парсере — без клиентских библиотек GitHub и внешних JSON-библиотек,
+ * чтобы рантайм-замкнутость оставалась минимальной (важно для встраивания в OSGi).
*
*
Отдельная зависимость загрузчика — чтобы в тестах его можно было замокать и прогнать поток
* скачивания без обращения к GitHub. Класс не {@code final} специально: так его мокает Mockito.
@@ -44,14 +45,33 @@
public class GitHubReleaseClient {
private static final String REPOSITORY = "1c-syntax/bsl-language-server";
+ private static final String API_ROOT = "https://api.github.com";
+ private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
+ private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30);
+ // Релизы отдаются newest-first: не-draft почти всегда на первой странице, поэтому страницы
+ // небольшие; пагинация ниже дочитает хвост в вырожденном случае «страница целиком из драфтов».
+ private static final int RELEASES_PER_PAGE = 30;
private final @Nullable String token;
+ private final HttpClient httpClient;
/**
* @param token GitHub OAuth-токен для обхода лимитов анонимного API; может быть {@code null}
*/
public GitHubReleaseClient(@Nullable String token) {
+ this(token, HttpClient.newBuilder()
+ .connectTimeout(CONNECT_TIMEOUT)
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .build());
+ }
+
+ /**
+ * @param token GitHub OAuth-токен для обхода лимитов анонимного API; может быть {@code null}
+ * @param httpClient клиент для запросов к GitHub API — например, с настроенным прокси
+ */
+ public GitHubReleaseClient(@Nullable String token, HttpClient httpClient) {
this.token = token;
+ this.httpClient = httpClient;
}
/**
@@ -62,37 +82,94 @@ public GitHubReleaseClient(@Nullable String token) {
* @throws IOException если релизы недоступны или подходящего релиза нет
*/
public Release latestRelease(BslLanguageServerReleaseChannel channel) throws IOException {
- var builder = new GitHubBuilder().withConnector(new HttpClientGitHubConnector());
- if (token != null && !token.isBlank()) {
- builder.withOAuthToken(token);
+ Map, ?> release;
+ if (channel == BslLanguageServerReleaseChannel.PRERELEASE) {
+ release = latestNonDraftRelease();
+ } else {
+ release = latestStableRelease();
}
- GitHub github = builder.build();
- GHRepository repository = github.getRepository(REPOSITORY);
+ if (release == null || !(release.get("tag_name") instanceof String tagName)) {
+ throw new IOException(
+ "Repository " + REPOSITORY + " has no suitable releases for channel " + channel);
+ }
+ return new Release(tagName, assetDownloadUrls(release));
+ }
- GHRelease release;
- if (channel == BslLanguageServerReleaseChannel.PRERELEASE) {
- // GitHub отдаёт релизы newest-first — берём первый не-draft, не подгружая все страницы.
- release = null;
- for (GHRelease candidate : repository.listReleases()) {
- if (!candidate.isDraft()) {
- release = candidate;
- break;
+ /**
+ * Последний стабильный релиз: эндпоинт {@code releases/latest} сам исключает draft
+ * и pre-release, а при полном отсутствии стабильных релизов отвечает 404.
+ */
+ private @Nullable Map, ?> latestStableRelease() throws IOException {
+ var response = send("/repos/" + REPOSITORY + "/releases/latest");
+ if (response.statusCode() == 404) {
+ return null;
+ }
+ return Json.parse(body(response)) instanceof Map, ?> release ? release : null;
+ }
+
+ /**
+ * Последний релиз с учётом pre-release: список {@code releases} отдаётся newest-first,
+ * берём первый не-draft. Драфты видны только пользователям с push-доступом, но при вызове
+ * с таким токеном их нужно пропустить, дочитывая следующие страницы при необходимости.
+ */
+ private @Nullable Map, ?> latestNonDraftRelease() throws IOException {
+ for (var page = 1; ; page++) {
+ var path = "/repos/" + REPOSITORY + "/releases?per_page=" + RELEASES_PER_PAGE + "&page=" + page;
+ if (!(Json.parse(get(path)) instanceof List> releases) || releases.isEmpty()) {
+ return null;
+ }
+ for (Object candidate : releases) {
+ if (candidate instanceof Map, ?> release && !Boolean.TRUE.equals(release.get("draft"))) {
+ return release;
}
}
- } else {
- release = repository.getLatestRelease();
}
+ }
- if (release == null) {
- throw new IOException("Repository " + REPOSITORY + " has no suitable releases for channel " + channel);
+ private static Map assetDownloadUrls(Map, ?> release) {
+ var assetUrls = new HashMap();
+ if (release.get("assets") instanceof List> assets) {
+ for (Object candidate : assets) {
+ if (candidate instanceof Map, ?> asset
+ && asset.get("name") instanceof String name
+ && asset.get("browser_download_url") instanceof String url) {
+ assetUrls.putIfAbsent(name, url);
+ }
+ }
}
+ return Map.copyOf(assetUrls);
+ }
- var assetUrls = new HashMap();
- for (GHAsset asset : release.listAssets().toList()) {
- assetUrls.putIfAbsent(asset.getName(), asset.getBrowserDownloadUrl());
+ private String get(String path) throws IOException {
+ return body(send(path));
+ }
+
+ private HttpResponse send(String path) throws IOException {
+ var builder = HttpRequest.newBuilder(URI.create(API_ROOT + path))
+ .header("Accept", "application/vnd.github+json")
+ .header("X-GitHub-Api-Version", "2022-11-28")
+ .header("User-Agent", "1c-syntax-utils")
+ .timeout(REQUEST_TIMEOUT)
+ .GET();
+ if (token != null && !token.isBlank()) {
+ builder.header("Authorization", "Bearer " + token);
+ }
+
+ try {
+ return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("GitHub API request " + path + " was interrupted", e);
+ }
+ }
+
+ private static String body(HttpResponse response) throws IOException {
+ if (response.statusCode() != 200) {
+ throw new IOException(
+ "GitHub API request " + response.request().uri() + " failed: HTTP " + response.statusCode());
}
- return new Release(release.getTagName(), Map.copyOf(assetUrls));
+ return response.body();
}
/**
diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/Json.java b/src/main/java/com/github/_1c_syntax/utils/downloader/Json.java
new file mode 100644
index 0000000..993e211
--- /dev/null
+++ b/src/main/java/com/github/_1c_syntax/utils/downloader/Json.java
@@ -0,0 +1,247 @@
+/*
+ * This file is a part of 1c-syntax utils.
+ *
+ * Copyright (c) 2018-2026
+ * Alexey Sosnoviy , Nikita Fedkin and contributors
+ *
+ * SPDX-License-Identifier: LGPL-3.0-or-later
+ *
+ * 1c-syntax utils is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3.0 of the License, or (at your option) any later version.
+ *
+ * 1c-syntax utils is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with 1c-syntax utils.
+ */
+package com.github._1c_syntax.utils.downloader;
+
+import org.jspecify.annotations.Nullable;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Минимальный JSON-парсер для ответов GitHub REST API — чтобы не тянуть Jackson/gson
+ * в рантайм-замкнутость библиотеки (важно для встраивания в OSGi, см. issue #81).
+ *
+ *
Поддерживает весь синтаксис RFC 8259. Значения отображаются в {@link Map}
+ * (объект, порядок ключей сохраняется), {@link List} (массив), {@link String},
+ * {@link Long}/{@link Double} (число), {@link Boolean} и {@code null}.
+ *
+ *
Некорректный JSON приводит к {@link IOException}: парсер применяется только к сетевым
+ * ответам, где повреждённые данные — та же ошибка обмена, что и обрыв соединения.
+ */
+final class Json {
+
+ private final String text;
+ private int pos;
+
+ private Json(String text) {
+ this.text = text;
+ }
+
+ /**
+ * Разбирает строку с одним JSON-значением.
+ *
+ * @param text текст JSON
+ * @return значение: {@code Map}, {@code List