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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user