From abdd2d165688544e08279f8475ef119fc8710dfc Mon Sep 17 00:00:00 2001 From: anthorob Date: Tue, 13 Oct 2026 13:16:54 -0400 Subject: [PATCH] Replace GitLab integration with Gitea in issue reporting Migrated issue reporting functionality from GitLab to Gitea. Updated environment variables, dependencies, and configuration references to support Gitea. Introduced `GiteaClient` for communication with the Gitea API and refactored related code and tests to align with the new integration. --- .env.example | 7 +- bot.properties.example | 7 +- docs/architecture.md | 1 - docs/commands.md | 4 +- docs/tasks.md | 9 +- docs/utilities-and-bootstrap.md | 2 +- pom.xml | 12 -- .../command/git/ReportIssueCommand.java | 14 +-- .../tasks/git/ReportIssueTask.java | 31 ++--- .../netneutrality/utils/GiteaClient.java | 88 ++++++++++++++ .../command/git/ReportIssueCommandTest.java | 108 +++++++++++------- 11 files changed, 194 insertions(+), 89 deletions(-) create mode 100644 src/main/java/me/bobert/netneutrality/utils/GiteaClient.java diff --git a/.env.example b/.env.example index 9cde698..cc48acf 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,7 @@ NETNEUTRALITY_WEBPO_URL=http://webpo:8080 NETNEUTRALITY_WEBPO_PASSWORD= NETNEUTRALITY_SENTRY_DSN= -GITLAB_HOST= -GITLAB_TOKEN= -GITLAB_PROJECT_ID= \ No newline at end of file +GITEA_HOST= +GITEA_TOKEN= +GITEA_OWNER= +GITEA_REPO= \ No newline at end of file diff --git a/bot.properties.example b/bot.properties.example index 075412f..fcf31c8 100644 --- a/bot.properties.example +++ b/bot.properties.example @@ -25,6 +25,7 @@ NETNEUTRALITY_LAVALINK_PASSWORD=youshallnotpass NETNEUTRALITY_LAVALINK_NAME=main NETNEUTRALITY_SENTRY_DSN= -GITLAB_HOST= -GITLAB_TOKEN= -GITLAB_PROJECT_ID= \ No newline at end of file +GITEA_HOST= +GITEA_TOKEN= +GITEA_OWNER= +GITEA_REPO= \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md index 5b2ba5a..2fa99a6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,7 +52,6 @@ infrastructure. | JDBC driver | `com.mysql:mysql-connector-j` | 9.7.0 | | DB migrations | `org.flywaydb:flyway-core` / `flyway-mysql` | 11.20.3 | | Error tracking | `io.sentry:sentry` | 8.41.0 | -| GitLab integration | `org.gitlab4j:gitlab4j-api` | 6.2.0 | | HTTP client | `com.squareup.okhttp3:okhttp-jvm` | 5.3.2 | | JSON | `org.json:json` + `com.google.code.gson:gson` | — | | Classpath scanning | `org.reflections:reflections` | 0.10.2 | diff --git a/docs/commands.md b/docs/commands.md index 14e6e00..b4b1cae 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,4 +1,4 @@ -# Commands +# Commands **Packages:** `me.bobert.netneutrality.framework.command` (framework), `me.bobert.netneutrality.feature.*.command` (concrete commands) @@ -139,7 +139,7 @@ Concrete commands live under `me.bobert.netneutrality.feature.*.command`, groupe | `PingCommand` | `basic` | Slash | Reply with the gateway ping. | | `ConfigureCommand` | `bot` | Slash | Configure the bot for the server (subcommands). | | `CryptoPriceCommand` | `crypto` | Slash | Fetch cryptocurrency prices. | -| `ReportIssueCommand` | `git` | Slash | Report a bug (opens the GitLab report flow). | +| `ReportIssueCommand` | `git` | Slash | Report a bug (opens the Gitea report flow). | | `PlayCommand` | `music` | Slash | Search/queue and start playback. | | `QueueCommand` / `ViewQueueCommand` | `music` | Slash | View/manage the queue. | | `SkipCommand` / `VoteSkipCommand` | `music` | Slash | Skip (or vote-skip) the current track. | diff --git a/docs/tasks.md b/docs/tasks.md index 3b2921d..b6eb70e 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -1,4 +1,4 @@ -# Tasks +# Tasks **Packages:** `me.bobert.netneutrality.framework.task` (framework), `me.bobert.netneutrality.feature.*.task` (concrete tasks) @@ -249,7 +249,7 @@ Tasks can opt into extra behavior by implementing marker/behavior interfaces in | `EmojiReactionRoleTask` | `emoji` | `EmojiTask` (persistable) | Assign/remove a role when a user reacts with a mapped emoji. | | `AcceptButton` | `generic` | `ButtonTask` | Generic "accept" button running a supplied function. | | `CancelButton` | `generic` | `ButtonTask` | Generic "cancel" button running a supplied function. | -| `ReportIssueTask` | `git` | `ModalTask` | Modal to report a bug; creates a GitLab issue. | +| `ReportIssueTask` | `git` | `ModalTask` | Modal to report a bug; creates a Gitea issue. | | `BotInfo` | `help` | `AbstractBuiltInTask` | Show bot info + invite link. | | `CommandHelp` | `help` | `AbstractBuiltInTask` | Show command help + the bot prefix. | | `ReportBug` | `help` | `AbstractBuiltInTask` | Start a bug-report flow / provide the report link. | @@ -275,12 +275,11 @@ Tasks can opt into extra behavior by implementing marker/behavior interfaces in on the next startup via the [`PersistableTaskRegistry`](#persistabletaskregistry--discovery-and-restore). -### Worked example — GitLab bug report (modal) +### Worked example — Gitea bug report (modal) 1. `ReportIssueTask.replyToInteraction` opens a modal with **title** and **description** fields. 2. On submit, `ModalTaskContext` routes back to the task; `execute` reads the field values. -3. The task connects to GitLab (using `GITLAB_HOST` / `GITLAB_TOKEN` / `GITLAB_PROJECT_ID` from - configuration) and creates an issue, then returns `Lifecycle.REMOVE_SELF`. +3. The task calls the Gitea API via `GiteaClient` (using `GITEA_HOST` / `GITEA_TOKEN` /`r`n `GITEA_OWNER` / `GITEA_REPO` from configuration) and creates an issue, then returns `Lifecycle.REMOVE_SELF`. ### Worked example — music results (parent/children) diff --git a/docs/utilities-and-bootstrap.md b/docs/utilities-and-bootstrap.md index 698eda2..c5907d9 100644 --- a/docs/utilities-and-bootstrap.md +++ b/docs/utilities-and-bootstrap.md @@ -84,7 +84,7 @@ An example configuration file is `bot.properties.example`. Key configuration val | `NETNEUTRALITY_API_PORT` | Port for the [Web API](web-api.md) (default `9000`). | | `MYSQL_*` | Database connection settings (see [persistence.md](persistence.md)). | | `NETNEUTRALITY_SENTRY_DSN` | Enables Sentry when present. | -| `GITLAB_HOST` / `GITLAB_TOKEN` / `GITLAB_PROJECT_ID` | GitLab issue reporting ([`ReportIssueTask`](tasks.md#worked-example--gitlab-bug-report-modal)). | +| `GITEA_HOST` / `GITEA_TOKEN` / `GITEA_OWNER` / `GITEA_REPO` | Gitea issue reporting ([`ReportIssueTask`](tasks.md#worked-example--gitea-bug-report-modal)). Token needs `write:issue`. | | `NETNEUTRALITY_LAVALINK_URI` / `_PASSWORD` / `_NAME` | Connection settings for the [Lavalink node](music.md#settings). | | `NETNEUTRALITY_PO_TOKEN` / `NETNEUTRALITY_VISITOR_DATA` / `NETNEUTRALITY_CIPHER_*` | YouTube auth/cipher settings, consumed by the node via `lavalink/application.yml`. | diff --git a/pom.xml b/pom.xml index 541d199..1d1a36f 100644 --- a/pom.xml +++ b/pom.xml @@ -272,18 +272,6 @@ sentry 8.41.0 - - org.gitlab4j - gitlab4j-api - 6.2.0 - - - - jakarta.servlet - jakarta.servlet-api - - - net.fellbaum diff --git a/src/main/java/me/bobert/netneutrality/command/git/ReportIssueCommand.java b/src/main/java/me/bobert/netneutrality/command/git/ReportIssueCommand.java index 8f9b426..212486a 100644 --- a/src/main/java/me/bobert/netneutrality/command/git/ReportIssueCommand.java +++ b/src/main/java/me/bobert/netneutrality/command/git/ReportIssueCommand.java @@ -10,11 +10,10 @@ import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandOptionsBuilder; import me.bobert.netneutrality.core.command.impl.AbstractCommand; import me.bobert.netneutrality.core.context.CommandContext; -import me.bobert.netneutrality.http.WebModule; +import me.bobert.netneutrality.utils.GiteaClient; import me.bobert.netneutrality.tasks.git.ReportIssueTask; import java.util.Objects; -import java.util.concurrent.TimeUnit; import me.bobert.netneutrality.core.tasks.TaskManager; import me.bobert.netneutrality.utils.SettingsProvider; @@ -72,15 +71,16 @@ public class ReportIssueCommand extends AbstractCommand { @Override public CommandExecutionResult execute(CommandContext context) { - String gitlabHost = settings.get("GITLAB_HOST"); - String gitlabToken = settings.get("GITLAB_TOKEN"); - String projectId = settings.get("GITLAB_PROJECT_ID"); + String host = settings.get("GITEA_HOST"); + String token = settings.get("GITEA_TOKEN"); + String owner = settings.get("GITEA_OWNER"); + String repo = settings.get("GITEA_REPO"); - if (gitlabHost == null || gitlabToken == null || projectId == null) { + if (host == null || token == null || owner == null || repo == null) { return CommandExecutionResult.error("Command is not configured, failed to create issue."); } - if (new WebModule.Builder(gitlabHost).ping().timeout(2, TimeUnit.SECONDS).build().request().isSuccess()) { + if (new GiteaClient(host, token, owner, repo).ping()) { return CommandExecutionResult.task(new ReportIssueTask(taskManager, context, settings)); } diff --git a/src/main/java/me/bobert/netneutrality/tasks/git/ReportIssueTask.java b/src/main/java/me/bobert/netneutrality/tasks/git/ReportIssueTask.java index 4fbe79e..e1913c3 100644 --- a/src/main/java/me/bobert/netneutrality/tasks/git/ReportIssueTask.java +++ b/src/main/java/me/bobert/netneutrality/tasks/git/ReportIssueTask.java @@ -21,9 +21,9 @@ import net.dv8tion.jda.api.interactions.modals.ModalMapping; import net.dv8tion.jda.api.modals.Modal; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.gitlab4j.api.GitLabApi; -import org.gitlab4j.api.GitLabApiException; -import org.gitlab4j.api.models.Issue; +import me.bobert.netneutrality.utils.GiteaClient; + +import java.io.IOException; public class ReportIssueTask extends ModalTask { private static final Logger logger = LogManager.getLogger(ReportIssueTask.class); @@ -72,14 +72,16 @@ public class ReportIssueTask extends ModalTask { public Lifecycle execute(TaskContext context) { context.ephemeral(); - String gitlabHost = settings.get("GITLAB_HOST"); - String gitlabToken = settings.get("GITLAB_TOKEN"); - String projectId = settings.get("GITLAB_PROJECT_ID"); + String host = settings.get("GITEA_HOST"); + String token = settings.get("GITEA_TOKEN"); + String owner = settings.get("GITEA_OWNER"); + String repo = settings.get("GITEA_REPO"); - if (gitlabHost == null || gitlabHost.isBlank() - || gitlabToken == null || gitlabToken.isBlank() - || projectId == null || projectId.isBlank()) { - logger.error("Gitlab is not configured in the bot settings, can't execute task"); + if (host == null || host.isBlank() + || token == null || token.isBlank() + || owner == null || owner.isBlank() + || repo == null || repo.isBlank()) { + logger.error("Gitea is not configured in the bot settings, can't execute task"); context.reply("Command is not configured, failed to create issue."); return null; } @@ -93,8 +95,7 @@ public class ReportIssueTask extends ModalTask { } User user = context.getDiscordUser(); - try (GitLabApi gitLabApi = new GitLabApi(gitlabHost, gitlabToken)) { - gitLabApi.setRequestTimeout(1000, 5000); + try { String title = String.format("Bug report: %s", modalTitle.getAsString()); String description = "Issue reported from user of bot\n\n" + @@ -102,11 +103,11 @@ public class ReportIssueTask extends ModalTask { "Last Sentry ID: " + Sentry.getLastEventId() + "\n\n" + "Description from user: \n\n" + modalDescription.getAsString(); - Issue issue = gitLabApi.getIssuesApi().createIssue(projectId, title, description); + String url = new GiteaClient(host, token, owner, repo).createIssue(title, description); - context.reply(String.format("Issue was successfully created: %s", issue.getWebUrl())); + context.reply(String.format("Issue was successfully created: %s", url)); - } catch (GitLabApiException e) { + } catch (IOException e) { context.reply("Failed to create issue."); LogHandler.log(e); } diff --git a/src/main/java/me/bobert/netneutrality/utils/GiteaClient.java b/src/main/java/me/bobert/netneutrality/utils/GiteaClient.java new file mode 100644 index 0000000..e775fa0 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/utils/GiteaClient.java @@ -0,0 +1,88 @@ +package me.bobert.netneutrality.utils; +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +import java.io.IOException; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +public class GiteaClient { + private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final String host; + private final String token; + private final String owner; + private final String repo; + private final OkHttpClient client; + + public GiteaClient(String host, String token, String owner, String repo) { + this(host, token, owner, repo, new OkHttpClient.Builder() + .connectTimeout(Duration.ofSeconds(1)) + .readTimeout(Duration.ofSeconds(5)) + .build()); + } + + public GiteaClient(String host, String token, String owner, String repo, OkHttpClient client) { + Objects.requireNonNull(host, "host"); + this.host = host.endsWith("/") ? host.replaceAll("/+$", "") : host; + this.token = Objects.requireNonNull(token, "token"); + this.owner = Objects.requireNonNull(owner, "owner"); + this.repo = Objects.requireNonNull(repo, "repo"); + this.client = Objects.requireNonNull(client, "client"); + } + + public boolean ping() { + Request request = new Request.Builder().url(host + "/api/v1/version").get().build(); + try (Response response = client.newCall(request).execute()) { + return response.isSuccessful(); + } catch (IOException e) { + return false; + } + } + + /** + * Creates an issue and returns its html url. + * + * @throws IOException on network failure, non-201 response or malformed response + */ + public String createIssue(String title, String body) throws IOException { + Map payload = new LinkedHashMap<>(); + payload.put("title", title); + payload.put("body", body); + + Request request = new Request.Builder() + .url(host + "/api/v1/repos/" + owner + "/" + repo + "/issues") + .header("Authorization", "token " + token) + .header("Accept", "application/json") + .post(RequestBody.create(MAPPER.writeValueAsString(payload), JSON)) + .build(); + + try (Response response = client.newCall(request).execute()) { + if (response.code() != 201) { + throw new IOException("Gitea returned HTTP " + response.code() + " when creating issue"); + } + if (response.body() == null) { + throw new IOException("Gitea returned an empty response"); + } + JsonNode url = MAPPER.readTree(response.body().string()).get("html_url"); + if (url == null || !url.isTextual()) { + throw new IOException("Gitea response did not contain html_url"); + } + return url.asText(); + } + } +} diff --git a/src/test/java/me/bobert/netneutrality/command/git/ReportIssueCommandTest.java b/src/test/java/me/bobert/netneutrality/command/git/ReportIssueCommandTest.java index a21ae5f..f947227 100644 --- a/src/test/java/me/bobert/netneutrality/command/git/ReportIssueCommandTest.java +++ b/src/test/java/me/bobert/netneutrality/command/git/ReportIssueCommandTest.java @@ -6,57 +6,85 @@ package me.bobert.netneutrality.command.git; * https://discord.gg/qUa6zNFZsd */ -import org.gitlab4j.api.GitLabApi; -import org.gitlab4j.api.IssuesApi; -import org.gitlab4j.api.models.Issue; +import com.sun.net.httpserver.HttpServer; +import me.bobert.netneutrality.utils.GiteaClient; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.util.UUID; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class ReportIssueCommandTest { - private GitLabApi mockGitLabApi; - private IssuesApi mockIssuesApi; + private HttpServer server; + private final AtomicReference lastAuth = new AtomicReference<>(); + private final AtomicReference lastBody = new AtomicReference<>(); + private volatile int status = 201; + private volatile String response = "{\"html_url\":\"http://x/o/r/issues/1\"}"; @BeforeEach - void setup() { - mockGitLabApi = mock(GitLabApi.class); - mockIssuesApi = mock(IssuesApi.class); + void setup() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/api/v1/version", ex -> { + ex.sendResponseHeaders(200, -1); + ex.close(); + }); + server.createContext("/api/v1/repos/o/r/issues", ex -> { + lastAuth.set(ex.getRequestHeaders().getFirst("Authorization")); + lastBody.set(new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + byte[] bytes = response.getBytes(StandardCharsets.UTF_8); + ex.sendResponseHeaders(status, bytes.length); + ex.getResponseBody().write(bytes); + ex.close(); + }); + server.start(); + } - when(mockGitLabApi.getIssuesApi()).thenReturn(mockIssuesApi); + @AfterEach + void teardown() { + server.stop(0); + } + + private GiteaClient client() { + return new GiteaClient("http://127.0.0.1:" + server.getAddress().getPort() + "/", "tok", "o", "r"); } @Test - void testCreateIssueMocked() throws Exception { - int projectId = 123456; - String modalTitle = "Bug report: mocked bug report"; - String modalDescription = "This is a mocked description"; - String userName = "TestUser"; - String userId = UUID.randomUUID().toString(); - String sentryId = UUID.randomUUID().toString(); - - String expectedTitle = String.format("Bug report: %s", modalTitle); - String expectedDescription = "Issue reported from user of bot\n\n" + - "User: " + userName + String.format("[%s]", userId) + "\n\n" + - "Last Sentry ID: " + sentryId + "\n\n" + - "Description from user: \n\n" + modalDescription; - - Issue mockedIssue = new Issue(); - mockedIssue.setTitle(expectedTitle); - mockedIssue.setDescription(expectedDescription); - - when(mockIssuesApi.createIssue(eq(projectId), eq(expectedTitle), eq(expectedDescription))).thenReturn(mockedIssue); - - Issue created = mockGitLabApi.getIssuesApi().createIssue(projectId, expectedTitle, expectedDescription); - assertNotNull(created); - assertEquals(expectedTitle, created.getTitle()); - assertEquals(expectedDescription, created.getDescription()); - - verify(mockIssuesApi).createIssue(projectId, expectedTitle, expectedDescription); + void createIssueReturnsHtmlUrl() throws IOException { + assertEquals("http://x/o/r/issues/1", client().createIssue("t", "b")); + assertEquals("token tok", lastAuth.get()); + assertTrue(lastBody.get().contains("\"title\":\"t\"")); } -} \ No newline at end of file + + @Test + void nonCreatedStatusThrows() { + status = 401; + assertThrows(IOException.class, () -> client().createIssue("t", "b")); + } + + @Test + void malformedJsonThrows() { + response = "not json"; + assertThrows(IOException.class, () -> client().createIssue("t", "b")); + } + + @Test + void missingHtmlUrlThrows() { + response = "{}"; + assertThrows(IOException.class, () -> client().createIssue("t", "b")); + } + + @Test + void pingWorksAndFailsWhenDown() { + assertTrue(client().ping()); + server.stop(0); + assertFalse(client().ping()); + } +}