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:}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, createEventDispatcher } from 'svelte';
|
||||
import { marked } from 'marked';
|
||||
import { fetchNav, fetchModule } from '../lib/api';
|
||||
import { fetchNav, fetchModule, submitFeedback } from '../lib/api';
|
||||
import type { NavApp, ModuleView } from '../lib/api';
|
||||
import { addToast } from '../lib/stores';
|
||||
|
||||
@@ -23,9 +23,39 @@
|
||||
const app = key.split('.')[0];
|
||||
if (app === 'platform') open = { ...open, platform: true };
|
||||
else if (app === 'technical') open = { ...open, technical: true };
|
||||
else if (app === 'processes') open = { ...open, processes: true };
|
||||
else open = { ...open, modules: true, ['app:' + app]: true };
|
||||
}
|
||||
|
||||
// ── Feedback (in-guide bug/enhancement → Gitea) ──────────
|
||||
const FB_AREAS = ['fleet','devices','incident','transactions','analytics','reports','messaging',
|
||||
'claims','recon','vault','aria','msp','dashboard','profile','mobile','agent','guide'];
|
||||
let fbOpen = false;
|
||||
let fbType = 'Bug';
|
||||
let fbArea = 'guide';
|
||||
let fbTitle = '';
|
||||
let fbDescription = '';
|
||||
let fbSubmitting = false;
|
||||
let fbResult: { number: number; url: string } | null = null;
|
||||
let fbError = '';
|
||||
|
||||
function openFeedback() {
|
||||
const parts = activeModuleKey.split('.');
|
||||
const app = parts[0];
|
||||
fbArea = FB_AREAS.includes(app) ? app
|
||||
: (app === 'technical' && FB_AREAS.includes(parts[1]) ? parts[1] : 'guide');
|
||||
fbType = 'Bug'; fbTitle = ''; fbDescription = ''; fbResult = null; fbError = ''; fbOpen = true;
|
||||
}
|
||||
async function sendFeedback() {
|
||||
if (!fbTitle.trim() || !fbDescription.trim()) { fbError = 'Title and description are required.'; return; }
|
||||
fbSubmitting = true; fbError = '';
|
||||
try {
|
||||
fbResult = await submitFeedback({ type: fbType, area: fbArea, title: fbTitle, description: fbDescription, module: activeModuleKey });
|
||||
} catch (e: any) {
|
||||
fbError = e?.response?.data?.error ?? e?.message ?? 'Failed to submit.';
|
||||
} finally { fbSubmitting = false; }
|
||||
}
|
||||
|
||||
marked.setOptions({ gfm: true, breaks: false });
|
||||
const render = (md: string): string => marked.parse(md, { async: false }) as string;
|
||||
|
||||
@@ -180,6 +210,7 @@
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<button class="side-feedback" on:click={openFeedback}>💬 Send feedback</button>
|
||||
{#if userEmail}
|
||||
<div class="side-user">
|
||||
<span class="side-user-label">Signed in as</span>
|
||||
@@ -232,6 +263,44 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Feedback → Gitea -->
|
||||
{#if fbOpen}
|
||||
<div class="fb-overlay" on:click={() => (fbOpen = false)} role="presentation"></div>
|
||||
<div class="fb-panel" role="dialog" aria-modal="true" aria-label="Send feedback">
|
||||
<div class="fb-head">
|
||||
<span>Send feedback</span>
|
||||
<button class="fb-close" on:click={() => (fbOpen = false)} aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="fb-body">
|
||||
{#if fbResult}
|
||||
<div class="fb-success">✅ Created <strong>#{fbResult.number}</strong> — thank you!</div>
|
||||
<a class="fb-link" href={fbResult.url} target="_blank" rel="noopener">View on Gitea →</a>
|
||||
<button class="fb-submit" on:click={() => (fbOpen = false)}>Done</button>
|
||||
{:else}
|
||||
<label class="fb-label">Type</label>
|
||||
<div class="fb-types">
|
||||
<button class="fb-type" class:active={fbType === 'Bug'} on:click={() => (fbType = 'Bug')}>🐞 Bug</button>
|
||||
<button class="fb-type" class:active={fbType === 'Enhancement'} on:click={() => (fbType = 'Enhancement')}>✨ Enhancement</button>
|
||||
</div>
|
||||
<label class="fb-label" for="fb-area">Area</label>
|
||||
<select id="fb-area" class="fb-input" bind:value={fbArea}>
|
||||
{#each FB_AREAS as a}<option value={a}>{a}</option>{/each}
|
||||
</select>
|
||||
<label class="fb-label" for="fb-title">Title</label>
|
||||
<input id="fb-title" class="fb-input" bind:value={fbTitle} placeholder="Short summary" />
|
||||
<label class="fb-label" for="fb-desc">Description</label>
|
||||
<textarea id="fb-desc" class="fb-input" rows="6" bind:value={fbDescription}
|
||||
placeholder="What happened / what you'd like — steps, page, account used…"></textarea>
|
||||
{#if fbError}<div class="fb-error">{fbError}</div>{/if}
|
||||
<button class="fb-submit" disabled={fbSubmitting} on:click={sendFeedback}>
|
||||
{fbSubmitting ? 'Sending…' : `Submit ${fbType}`}
|
||||
</button>
|
||||
{#if activeModuleKey}<div class="fb-ctx">Filed against <code>{fbArea}</code> · from <code>{activeModuleKey}</code></div>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.reader { display: flex; height: 100vh; overflow: hidden; font-size: 14px; }
|
||||
|
||||
@@ -304,6 +373,48 @@
|
||||
}
|
||||
.side-logout:hover { background: rgba(255,255,255,0.2); }
|
||||
|
||||
.side-feedback {
|
||||
margin: 0.75rem 1rem 0; padding: 0.5rem; border-radius: 6px;
|
||||
background: rgba(59,130,246,0.2); border: 1px solid rgba(59,130,246,0.4);
|
||||
color: #bfdbfe; font-size: 0.82rem; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.side-feedback:hover { background: rgba(59,130,246,0.32); color: #fff; }
|
||||
|
||||
/* Feedback panel */
|
||||
.fb-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 1000; cursor: pointer; }
|
||||
.fb-panel {
|
||||
position: fixed; top: 0; right: 0; width: 420px; max-width: 92vw; height: 100vh;
|
||||
background: #111827; border-left: 1px solid #1e293b; z-index: 1001;
|
||||
display: flex; flex-direction: column; box-shadow: -6px 0 24px rgba(0,0,0,0.4);
|
||||
animation: fbIn 0.22s ease;
|
||||
}
|
||||
@keyframes fbIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
|
||||
.fb-head {
|
||||
background: linear-gradient(180deg, #081651 0%, #1c49b8 100%);
|
||||
padding: 1rem 1.25rem; display: flex; align-items: center; justify-content: space-between;
|
||||
color: white; font-weight: 700; flex-shrink: 0;
|
||||
}
|
||||
.fb-close { background: rgba(255,255,255,0.15); border: none; color: white; width: 28px; height: 28px; border-radius: 6px; cursor: pointer; }
|
||||
.fb-close:hover { background: rgba(255,255,255,0.3); }
|
||||
.fb-body { flex: 1; overflow-y: auto; padding: 1.25rem; display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.fb-label { color: #94a3b8; font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 0.6rem; }
|
||||
.fb-types { display: flex; gap: 0.5rem; }
|
||||
.fb-type { flex: 1; padding: 0.5rem; border-radius: 6px; border: 1px solid #334155; background: #0f172a; color: #cbd5e1; font-size: 0.85rem; font-weight: 600; cursor: pointer; }
|
||||
.fb-type:hover { border-color: #3b82f6; }
|
||||
.fb-type.active { background: #1e3a5f; border-color: #3b82f6; color: #bfdbfe; }
|
||||
.fb-input { padding: 0.5rem 0.65rem; border-radius: 6px; border: 1px solid #334155; background: #0f172a; color: #e2e8f0; font-size: 0.85rem; font-family: inherit; width: 100%; box-sizing: border-box; }
|
||||
.fb-input:focus { outline: none; border-color: #3b82f6; }
|
||||
textarea.fb-input { resize: vertical; }
|
||||
.fb-error { background: #3b1a1a; border: 1px solid #7f1d1d; color: #fca5a5; font-size: 0.8rem; border-radius: 6px; padding: 0.5rem 0.65rem; margin-top: 0.5rem; }
|
||||
.fb-success { color: #86efac; font-size: 0.95rem; margin-bottom: 0.3rem; }
|
||||
.fb-link { color: #60a5fa; font-size: 0.85rem; text-decoration: none; }
|
||||
.fb-link:hover { text-decoration: underline; }
|
||||
.fb-submit { margin-top: 1rem; padding: 0.6rem; border-radius: 6px; border: none; background: #2563eb; color: white; font-size: 0.88rem; font-weight: 600; cursor: pointer; }
|
||||
.fb-submit:hover { background: #1d4ed8; }
|
||||
.fb-submit:disabled { opacity: 0.6; cursor: default; }
|
||||
.fb-ctx { color: #64748b; font-size: 0.72rem; margin-top: 0.6rem; }
|
||||
.fb-ctx code { color: #94a3b8; }
|
||||
|
||||
/* ── Content ─────────────────────────────────────────── */
|
||||
.content { flex: 1; display: flex; flex-direction: column; overflow: hidden; background: #0b1220; }
|
||||
.content-msg { flex: 1; display: flex; align-items: center; justify-content: center; color: #94a3b8; }
|
||||
|
||||
@@ -50,3 +50,11 @@ export async function fetchModule(module: string): Promise<ModuleView> {
|
||||
const res = await api.get(`/api/v1/guide/modules/${encodeURIComponent(module)}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/** submit in-guide feedback → creates a Gitea Bug/Enhancement issue. */
|
||||
export async function submitFeedback(payload: {
|
||||
type: string; area: string; title: string; description: string; module?: string;
|
||||
}): Promise<{ number: number; url: string }> {
|
||||
const res = await api.post('/api/v1/guide/feedback', payload);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user