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.
This commit is contained in:
anthorob committed 2026-10-13 13:16:54 -04:00
1 parent a23e3843ed
commit abdd2d1656
11 files changed
+189 -84

No files matched your search

+4 -3
View File
@@ -25,6 +25,7 @@ NETNEUTRALITY_WEBPO_URL=http://webpo:8080
NETNEUTRALITY_WEBPO_PASSWORD=
NETNEUTRALITY_SENTRY_DSN=
GITLAB_HOST=
GITLAB_TOKEN=
GITLAB_PROJECT_ID=
GITEA_HOST=
GITEA_TOKEN=
GITEA_OWNER=
GITEA_REPO=
+4 -3
View File
@@ -25,6 +25,7 @@ NETNEUTRALITY_LAVALINK_PASSWORD=youshallnotpass
NETNEUTRALITY_LAVALINK_NAME=main
NETNEUTRALITY_SENTRY_DSN=
GITLAB_HOST=
GITLAB_TOKEN=
GITLAB_PROJECT_ID=
GITEA_HOST=
GITEA_TOKEN=
GITEA_OWNER=
GITEA_REPO=
-1
View File
@@ -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 |
+2 -2
View File
@@ -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. |
+4 -5
View File
@@ -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)
+1 -1
View File
@@ -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`. |
-12
View File
@@ -272,18 +272,6 @@
<artifactId>sentry</artifactId>
<version>8.41.0</version>
</dependency>
<dependency>
<groupId>org.gitlab4j</groupId>
<artifactId>gitlab4j-api</artifactId>
<version>6.2.0</version>
<exclusions>
<!-- Clashes with the Servlet API used by Javalin's Jetty 12 in the shaded jar. -->
<exclusion>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>net.fellbaum</groupId>
@@ -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));
}
@@ -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);
}
@@ -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<String, String> 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();
}
}
}
@@ -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<String> lastAuth = new AtomicReference<>();
private final AtomicReference<String> 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();
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\""));
}
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;
@Test
void nonCreatedStatusThrows() {
status = 401;
assertThrows(IOException.class, () -> client().createIssue("t", "b"));
}
Issue mockedIssue = new Issue();
mockedIssue.setTitle(expectedTitle);
mockedIssue.setDescription(expectedDescription);
@Test
void malformedJsonThrows() {
response = "not json";
assertThrows(IOException.class, () -> client().createIssue("t", "b"));
}
when(mockIssuesApi.createIssue(eq(projectId), eq(expectedTitle), eq(expectedDescription))).thenReturn(mockedIssue);
@Test
void missingHtmlUrlThrows() {
response = "{}";
assertThrows(IOException.class, () -> client().createIssue("t", "b"));
}
Issue created = mockGitLabApi.getIssuesApi().createIssue(projectId, expectedTitle, expectedDescription);
assertNotNull(created);
assertEquals(expectedTitle, created.getTitle());
assertEquals(expectedDescription, created.getDescription());
verify(mockIssuesApi).createIssue(projectId, expectedTitle, expectedDescription);
@Test
void pingWorksAndFailsWhenDown() {
assertTrue(client().ping());
server.stop(0);
assertFalse(client().ping());
}
}