feat(diagnostics): send a bug report to a private Gitea issue tracker (E16 S4)

- admin config group "Bug reports": Gitea address, repository, token (secret),
  and "Check the connection" — the repository must answer, take issues and
  be PRIVATE (a report carries its reporter's log); checked on every send too
- POST /api/debug-sessions/{id}/issue sends the SAVED draft: create the issue,
  upload each file on its own request (the measured API), then show the
  screenshots inline; progress is kept in the draft, so a send that stops
  half-way keeps its issue and the retry sends only what is missing; 5 new
  issues an hour per user; audited as issue_create
- the composer: Create issue (only where a tracker is set up, naming the
  repository), a partial-send banner with retry, and a Preview — the tracker's
  own rendering in a sandboxed frame, or the zip's markdown without one
- the recorder's refusals answer in the localized family (debug_session_*,
  screenshot_*, issue(s)_*), not bare codes the UI could only call generic
- a tracker refusal logs the status only: its message can quote the title back
This commit is contained in:
2026-09-19 22:48:20 +02:00
parent a5fae489e0
commit 9cf177b198
26 changed files with 883 additions and 32 deletions
+24
View File
@@ -134,6 +134,14 @@ export default function ConfigPanel() {
notifyActionError(e, t("config.plexTestFailed"));
},
});
// Bug reports' "Check the connection" (E16): the repository answers, takes issues, is private.
const testIssues = useMutation({
mutationFn: () => api.testIssues(),
onSuccess: (r) =>
notify({ level: "success", message: t("config.issuesTestOk", { repo: r.repo }) }),
onError: (e) => notifyActionError(e, t("config.issuesTestFailed")),
});
const plexLibs = (draft["plex_libraries"] ?? "")
.split(",")
.map((s) => s.trim())
@@ -218,6 +226,22 @@ export default function ConfigPanel() {
</div>
)}
{activeGroup === "issues" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.issuesTestHint")}>
<FocusSafeButton
onClick={() => testIssues.mutate()}
disabled={testIssues.isPending || dirty}
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Plug className={`w-4 h-4 ${testIssues.isPending ? "animate-pulse" : ""}`} />
{t("config.issuesTest")}
</FocusSafeButton>
</Tooltip>
{dirty && <p className="text-2xs text-muted mt-2">{t("config.plexTestDirty")}</p>}
</div>
)}
{activeGroup === "plex" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.plexTestHint")}>
@@ -34,6 +34,7 @@ const { order, session, startDebugSession, stopDebugSession } = vi.hoisted(() =>
ended_at: null,
ended_reason: null,
issue_url: null,
pending_files: [],
screenshots: 0,
draft: null,
...over,
@@ -9,6 +9,7 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-li
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DebugDraft, DebugSession, ServerLogRow } from "../lib/api";
import { qk } from "../lib/queryKeys";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -28,6 +29,8 @@ const api = vi.hoisted(() => ({
saved: [] as DebugDraft[],
uploaded: [] as Blob[],
failSession: false,
sent: 0,
previewed: [] as string[],
}));
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
@@ -51,6 +54,19 @@ vi.mock("../lib/api", async (importOriginal) => {
});
},
debugExportUrl: () => "/api/debug-sessions/0123456789abcdef/export",
sendDebugIssue: () => {
api.sent += 1;
api.session = {
...api.session,
issue_url: "https://git.example/o/r/issues/7",
pending_files: [],
};
return Promise.resolve({ ok: true, session: api.session });
},
previewDebugIssue: (_id: string, text: string) => {
api.previewed.push(text);
return Promise.resolve({ ok: true, html: "<h3>Steps</h3><script>alert(1)</script>" });
},
},
};
});
@@ -84,6 +100,7 @@ const baseSession = (over: Partial<DebugSession> = {}): DebugSession => ({
ended_at: "2026-09-19T14:16:00.000Z",
ended_reason: "stopped",
issue_url: null,
pending_files: [],
screenshots: 0,
draft: null,
...over,
@@ -99,14 +116,20 @@ beforeEach(() => {
api.saved = [];
api.uploaded = [];
api.failSession = false;
api.sent = 0;
api.previewed = [];
tracker = null;
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
let tracker: { repo: string } | null = null;
function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, issues: tracker });
render(
<QueryClientProvider client={qc}>
<IssueComposer id={SID} />
@@ -222,4 +245,49 @@ describe("the issue composer", () => {
const message = await screen.findByText("recorder.composer.loadFailed");
expect(within(message.closest('[role="alert"]')!).getByRole("button")).not.toBeNull();
});
it("offers the tracker only where one is set up, and sends the saved draft", async () => {
mount();
await screen.findByRole("button", { name: "recorder.composer.export" });
expect(screen.queryByRole("button", { name: "recorder.composer.send" })).toBeNull();
cleanup();
tracker = { repo: "o/r" };
mount();
expect(await screen.findByText("recorder.composer.target:o/r")).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: "recorder.composer.send" }));
await waitFor(() => expect(api.sent).toBe(1));
expect(api.saved).toHaveLength(1); // saved first: the issue is built from what was saved
expect(await screen.findByRole("link", { name: /recorder.composer.openIssue/ })).not.toBeNull();
});
it("finishes a send that stopped half-way, and says what is missing", async () => {
tracker = { repo: "o/r" };
api.session = baseSession({
issue_url: "https://git.example/o/r/issues/7",
pending_files: ["server.log"],
});
mount();
expect(await screen.findByText("recorder.composer.partial:server.log")).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: "recorder.composer.retry" }));
await waitFor(() => expect(api.sent).toBe(1));
expect(api.saved).toHaveLength(0); // sent: nothing to save, only the rest to send
});
it("previews through the tracker in a frame where no script runs", async () => {
tracker = { repo: "o/r" };
mount();
fireEvent.click(await screen.findByRole("button", { name: "recorder.composer.preview" }));
const frame = (await screen.findByTitle("recorder.composer.previewTitle")) as HTMLIFrameElement;
expect(frame.getAttribute("sandbox")).not.toContain("allow-scripts");
expect(frame.getAttribute("srcdoc")).toContain("<h3>Steps</h3>");
expect(api.previewed[0]).toContain("1. Resume");
});
it("previews the zip's own text where there is no tracker", async () => {
mount();
fireEvent.click(await screen.findByRole("button", { name: "recorder.composer.preview" }));
expect(screen.getByText(/1\. Resume/).tagName).toBe("PRE");
expect(api.previewed).toHaveLength(0);
});
});
+130 -7
View File
@@ -5,6 +5,8 @@ import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../lib/api";
import { closeComposer } from "../lib/debugRecorder";
import { notify } from "../lib/notifications";
import { useMe } from "../lib/useMe";
import {
draftSteps,
environment,
@@ -20,6 +22,7 @@ import { LEVEL_TAG, logClock } from "./logLineParts";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
import { LoadingState, StateMessage } from "./ui/QueryState";
import SegmentedToggle from "./ui/SegmentedToggle";
import { inputCls } from "./ui/form";
// The issue composer (E16 S3, D4 A): one dialog — the draft on the left (title, description, the
@@ -165,6 +168,25 @@ function ComposerBody({
setForm(next);
};
// The issue (S4), like the export, is built from the SAVED draft: save what is on screen first.
// A send that stopped half-way is finished by the same call, which sends only what is missing.
const target = useMe()?.issues ?? null;
const pending = session.pending_files;
const send = useMutation({
mutationFn: async () => {
if (!sent) await save.mutateAsync();
return api.sendDebugIssue(session.id);
},
meta: { failTitle: "recorder.composer.sendFailed" },
onSuccess: (r) => {
qc.setQueryData(qk.debugSession(session.id), r.session);
notify({ level: "success", message: t("recorder.composer.sentOk") });
},
// A failure half-way still created the issue: show what the server now has.
onSettled: () => void qc.invalidateQueries({ queryKey: qk.debugSessions() }),
});
const [mode, setMode] = useState<"edit" | "preview">("edit");
// The export is built from the SAVED draft: save what is on screen first.
const exportZip = async () => {
if (!sent) await save.mutateAsync();
@@ -191,6 +213,19 @@ function ComposerBody({
level: t(`settings.diagnostics.levels.${session.level}`),
})}
</p>
{sent && pending.length > 0 && (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-warn/40 bg-warn/10 px-3 py-2 text-sm">
<span>{t("recorder.composer.partial", { files: pending.join(", ") })}</span>
<Button
variant="outline"
size="sm"
loading={send.isPending}
onClick={() => send.mutate()}
>
{t("recorder.composer.retry")}
</Button>
</div>
)}
{sent && (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-success/40 bg-success/10 px-3 py-2 text-sm">
<span>{t("recorder.composer.sent")}</span>
@@ -205,7 +240,26 @@ function ComposerBody({
</a>
</div>
)}
{reviewing ? (
{!reviewing && (
<SegmentedToggle
size="md"
className="self-start"
ariaLabel={t("recorder.composer.previewTitle")}
options={[
{ value: "edit", label: t("recorder.composer.edit") },
{ value: "preview", label: t("recorder.composer.preview") },
]}
value={mode}
onChange={setMode}
/>
)}
{mode === "preview" && !reviewing ? (
<IssuePreview
sessionId={session.id}
text={`# ${form.title.trim() || "…"}\n\n${body}`}
viaTracker={!!target}
/>
) : reviewing ? (
<ReviewList
rows={rows.filter((r) => sourceOf(r) === reviewing)}
name={t(
@@ -239,12 +293,17 @@ function ComposerBody({
</div>
)}
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-border pt-3">
<span className="mr-auto text-xs text-muted" aria-live="polite">
{save.isPending
? t("recorder.composer.saving")
: save.isSuccess
? t("recorder.composer.saved")
: ""}
<span className="mr-auto flex flex-col text-xs text-muted">
{target && <span>{t("recorder.composer.target", { repo: target.repo })}</span>}
<span aria-live="polite">
{send.isPending
? t("recorder.composer.sending")
: save.isPending
? t("recorder.composer.saving")
: save.isSuccess
? t("recorder.composer.saved")
: ""}
</span>
</span>
<Button variant="ghost" onClick={closeComposer}>
{t("recorder.composer.close")}
@@ -252,6 +311,11 @@ function ComposerBody({
<Button variant="outline" loading={save.isPending} onClick={() => void exportZip()}>
{t("recorder.composer.export")}
</Button>
{target && !sent && (
<Button variant="primary" loading={send.isPending} onClick={() => send.mutate()}>
{t("recorder.composer.send")}
</Button>
)}
</div>
</div>
);
@@ -563,3 +627,62 @@ function ReviewList({
</div>
);
}
/** The report as it will read. Through the tracker, its OWN rendering (Gitea's markdown and
* sanitiser) — drawn in a sandboxed frame: no script runs in it, and nothing it holds reaches the
* app's document. Without a tracker, the markdown itself: it is the zip's issue.md. */
function IssuePreview({
sessionId,
text,
viaTracker,
}: {
sessionId: string;
text: string;
viaTracker: boolean;
}) {
const { t } = useTranslation();
const q = useQuery({
queryKey: [...qk.debugSession(sessionId), "preview", text],
queryFn: () => api.previewDebugIssue(sessionId, text),
enabled: viaTracker,
});
const frame = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(240);
if (!viaTracker)
return (
<div className="flex flex-col gap-1.5">
<p className="text-xs text-muted">{t("recorder.composer.previewLocal")}</p>
<pre className="max-h-[60vh] overflow-auto whitespace-pre-wrap rounded-xl border border-border bg-bg/60 p-3 font-mono text-xs">
{text}
</pre>
</div>
);
if (q.isError && !q.data)
return (
<StateMessage tone="error" compact onRetry={() => void q.refetch()}>
{t("recorder.composer.previewFailed")}
</StateMessage>
);
if (!q.data) return <LoadingState />;
const css = getComputedStyle(document.documentElement);
const color = (name: string, fallback: string) => css.getPropertyValue(name).trim() || fallback;
const doc = `<!doctype html><html><head><meta charset="utf-8"><base target="_blank"><style>
body{margin:0;padding:12px 14px;font:14px/1.55 system-ui,sans-serif;color:${color("--fg", "#e6e9f0")};background:${color("--card", "#161f38")}}
a{color:${color("--accent", "#6d8cff")}}table{border-collapse:collapse}td,th{border:1px solid ${color("--border", "#243049")};padding:4px 8px}
code{font-family:ui-monospace,Consolas,monospace;font-size:12px}h1,h2,h3{line-height:1.25}
</style></head><body>${q.data.html}</body></html>`;
return (
<iframe
ref={frame}
title={t("recorder.composer.previewTitle")}
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
srcDoc={doc}
onLoad={() => {
const body = frame.current?.contentDocument?.body;
if (body) setHeight(Math.min(body.scrollHeight + 4, window.innerHeight * 0.6));
}}
style={{ height }}
className="w-full rounded-xl border border-border"
/>
);
}