feat(guide): in-guide feedback → creates Gitea Bug/Enhancement issues
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> 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<String> 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<String> 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<String> 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));
|
||||
}
|
||||
}
|
||||
@@ -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:}
|
||||
|
||||
Reference in New Issue
Block a user