From e02aab4d3bc5b36d8302dc39a8bf4e623003564d Mon Sep 17 00:00:00 2001 From: Johannes Date: Wed, 1 Jul 2026 16:54:55 -0400 Subject: [PATCH] =?UTF-8?q?feat(guide):=20in-guide=20feedback=20=E2=86=92?= =?UTF-8?q?=20creates=20Gitea=20Bug/Enhancement=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend FeedbackController + GiteaFeedbackService (POST /api/v1/guide/feedback): resolves/creates the Bug/Enhancement label, appends reporter (from JWT) + module context, POSTs the issue via a server-side GITEA_FEEDBACK_TOKEN (reaches Gitea via public route). Areaβ†’repo map mirrors the Browser. Reader gets a πŸ’¬ Send feedback button + dark slide-in form (Bug/Enhancement, area prefilled from current module, title, description) with success link. Verified e2e (created hiveops-guide #4). Co-Authored-By: Claude Opus 4.8 --- .../guide/controller/FeedbackController.java | 34 +++++ .../guide/service/GiteaFeedbackService.java | 116 ++++++++++++++++++ .../src/main/resources/application.properties | 4 + frontend/src/components/GuideReader.svelte | 113 ++++++++++++++++- frontend/src/lib/api.ts | 8 ++ 5 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/com/hiveops/guide/controller/FeedbackController.java create mode 100644 backend/src/main/java/com/hiveops/guide/service/GiteaFeedbackService.java diff --git a/backend/src/main/java/com/hiveops/guide/controller/FeedbackController.java b/backend/src/main/java/com/hiveops/guide/controller/FeedbackController.java new file mode 100644 index 0000000..1a38b14 --- /dev/null +++ b/backend/src/main/java/com/hiveops/guide/controller/FeedbackController.java @@ -0,0 +1,34 @@ +package com.hiveops.guide.controller; + +import com.hiveops.guide.service.GiteaFeedbackService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** In-guide "report a bug / suggest an enhancement" β†’ creates a Gitea issue. Requires a logged-in user. */ +@RestController +@RequestMapping("/api/v1/guide/feedback") +@RequiredArgsConstructor +public class FeedbackController { + + private final GiteaFeedbackService service; + + public record FeedbackRequest(String type, String area, String title, String description, String module) {} + + @PostMapping + public ResponseEntity submit(@RequestBody FeedbackRequest r, Authentication auth) { + String email = auth != null ? auth.getName() : null; + String role = auth != null + ? auth.getAuthorities().stream().findFirst() + .map(a -> a.getAuthority().replaceFirst("^ROLE_", "")).orElse(null) + : null; + GiteaFeedbackService.Result res = service.create( + r.type(), r.area(), r.title(), r.description(), r.module(), email, role); + return res.success() + ? ResponseEntity.ok(Map.of("number", res.number(), "url", res.url())) + : ResponseEntity.badRequest().body(Map.of("error", res.error() == null ? "Failed" : res.error())); + } +} diff --git a/backend/src/main/java/com/hiveops/guide/service/GiteaFeedbackService.java b/backend/src/main/java/com/hiveops/guide/service/GiteaFeedbackService.java new file mode 100644 index 0000000..5b20bea --- /dev/null +++ b/backend/src/main/java/com/hiveops/guide/service/GiteaFeedbackService.java @@ -0,0 +1,116 @@ +package com.hiveops.guide.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +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.Map; + +/** + * Creates Gitea Bug/Enhancement issues from in-guide feedback. Mirrors the hiveops-browser + * feedback flow but keeps the token server-side. Reaches Gitea over the public (Cloudflare) route. + */ +@Service +@Slf4j +public class GiteaFeedbackService { + + private final ObjectMapper mapper = new ObjectMapper(); + private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); + + @Value("${gitea.base-url:https://hiveiq-gitea.directlx.dev}") + private String baseUrl; + @Value("${gitea.feedback.token:}") + private String token; + + private static final String ORG = "hiveiq-src"; + + /** area key (= guide module app-prefix, or explicit) β†’ repo name. Default: the guide itself. */ + private static final Map REPOS = Map.ofEntries( + Map.entry("fleet", "hiveops-fleet"), Map.entry("devices", "hiveops-devices"), + Map.entry("incident", "hiveops-incident"), Map.entry("transactions", "hiveops-transactions"), + Map.entry("analytics", "hiveops-analytics"), Map.entry("reports", "hiveops-reports"), + Map.entry("messaging", "hiveops-messaging"), Map.entry("claims", "hiveops-claims"), + Map.entry("recon", "hiveops-recon"), Map.entry("vault", "hiveops-vault"), + Map.entry("aria", "hiveops-aria"), Map.entry("msp", "hiveops-msp"), + Map.entry("dashboard", "hiveops-dashboard"), Map.entry("profile", "hiveops-profile"), + Map.entry("mobile", "hiveops-mobile"), Map.entry("agent", "hiveops-agent"), + Map.entry("guide", "hiveops-guide")); + + public record Result(boolean success, Integer number, String url, String error) {} + + public Result create(String type, String area, String title, String description, + String module, String email, String role) { + if (token == null || token.isBlank()) + return new Result(false, null, null, "Feedback is not configured."); + if (title == null || title.isBlank() || description == null || description.isBlank()) + return new Result(false, null, null, "Title and description are required."); + + String labelName = "enhancement".equalsIgnoreCase(type) ? "Enhancement" : "Bug"; + String repoName = REPOS.getOrDefault(area == null ? "" : area.toLowerCase(), "hiveops-guide"); + String repo = ORG + "/" + repoName; + + try { + Integer labelId = findLabel(repo, labelName); + if (labelId == null) labelId = createLabel(repo, labelName); + + String body = description + "\n\n---\n" + + "**Submitted via:** guide.bcos.dev\n" + + "**Area:** " + repoName + (module != null && !module.isBlank() ? " Β· " + module : "") + "\n" + + "**Reporter:** " + (email == null ? "unknown" : email) + + (role != null ? " (" + role + ")" : "") + "\n"; + + ObjectNode payload = mapper.createObjectNode(); + payload.put("title", title.trim()); + payload.put("body", body); + if (labelId != null) payload.putArray("labels").add(labelId.intValue()); + + HttpResponse resp = http.send( + req(repo, "/issues").POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(payload))).build(), + HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() >= 200 && resp.statusCode() < 300) { + JsonNode n = mapper.readTree(resp.body()); + log.info("Feedback issue created: {} #{}", repo, n.path("number").asInt()); + return new Result(true, n.path("number").asInt(), n.path("html_url").asText(), null); + } + log.warn("Gitea issue create failed {}: {}", resp.statusCode(), resp.body()); + return new Result(false, null, null, "Gitea returned " + resp.statusCode()); + } catch (Exception e) { + log.warn("Feedback submit failed: {}", e.getMessage()); + return new Result(false, null, null, e.getMessage()); + } + } + + private Integer findLabel(String repo, String name) throws Exception { + HttpResponse r = http.send(req(repo, "/labels?limit=100").GET().build(), + HttpResponse.BodyHandlers.ofString()); + if (r.statusCode() != 200) return null; + for (JsonNode l : mapper.readTree(r.body())) + if (l.path("name").asText().equalsIgnoreCase(name)) return l.path("id").asInt(); + return null; + } + + private Integer createLabel(String repo, String name) throws Exception { + ObjectNode p = mapper.createObjectNode(); + p.put("name", name); + p.put("color", name.equalsIgnoreCase("Enhancement") ? "#a2eeef" : "#d73a4a"); + HttpResponse r = http.send( + req(repo, "/labels").POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(p))).build(), + HttpResponse.BodyHandlers.ofString()); + return (r.statusCode() >= 200 && r.statusCode() < 300) ? mapper.readTree(r.body()).path("id").asInt() : null; + } + + private HttpRequest.Builder req(String repo, String path) { + return HttpRequest.newBuilder(URI.create(baseUrl + "/api/v1/repos/" + repo + path)) + .header("Authorization", "token " + token) + .header("Content-Type", "application/json") + .timeout(Duration.ofSeconds(20)); + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 34c1437..2bbe856 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -22,3 +22,7 @@ jwt.secret=${JWT_SECRET} internal.secret=${INTERNAL_SECRET:dev-internal-secret} cors.allowed-origins=${CORS_ALLOWED_ORIGINS:http://localhost:5188} + +# In-guide feedback β†’ Gitea issues (server-side token; reaches Gitea via public route) +gitea.base-url=${GITEA_BASE_URL:https://hiveiq-gitea.directlx.dev} +gitea.feedback.token=${GITEA_FEEDBACK_TOKEN:} diff --git a/frontend/src/components/GuideReader.svelte b/frontend/src/components/GuideReader.svelte index ad48d92..890ac51 100644 --- a/frontend/src/components/GuideReader.svelte +++ b/frontend/src/components/GuideReader.svelte @@ -1,7 +1,7 @@