feat: per-repo issue forms and prefilled bug reports instead of the support repo

This commit is contained in:
LukeGus committed 2026-10-06 22:18:01 -05:00
1 parent 15058119a3
commit ac19183f40
18 files changed
+553 -43

No files matched your search

+3 -2
View File
@@ -61,6 +61,7 @@ import {
StatsStrip,
type VersionStatus,
} from "./dashboard-core-cards";
import { reportCoreIssueUrl } from "@/lib/issue-url";
export { HostStatusList as HostStatusCard } from "./dashboard-core-cards";
@@ -795,8 +796,8 @@ export function DashboardTab({
label: t("dashboard.github"),
},
{
href: "https://github.com/Termix-SSH/Support",
label: t("dashboard.support"),
href: reportCoreIssueUrl(import.meta.env.VITE_APP_VERSION || undefined),
label: t("dashboard.reportBug"),
},
{
href: "https://discord.com/invite/jVQGdvHDrf",
+101
View File
@@ -0,0 +1,101 @@
import { isElectron } from "@/lib/electron";
export const CORE_REPO_URL = "https://github.com/Termix-SSH/Termix";
const MAX_URL_LENGTH = 7500;
/** A GitHub repo URL without .git or a trailing slash, or null if it is not one. */
export function normalizeGitHubRepo(repository?: string | null): string | null {
const repo = repository
?.trim()
.replace(/\.git$/, "")
.replace(/\/+$/, "");
if (!repo || !/^https:\/\/github\.com\/[^/]+\/[^/]+$/.test(repo)) {
return null;
}
return repo;
}
/**
* A new issue from one of the repo's issue forms, with fields prefilled by
* their form ids. Logs get cut first when the URL would be too long.
*/
export function buildIssueUrl(
repo: string,
options: {
template: string;
title?: string;
fields?: Record<string, string | null | undefined>;
},
): string {
const fields = Object.fromEntries(
Object.entries(options.fields ?? {}).filter(
(entry): entry is [string, string] => !!entry[1]?.trim(),
),
);
const build = () => {
const params = new URLSearchParams({ template: options.template });
if (options.title) params.set("title", options.title);
for (const [key, value] of Object.entries(fields)) params.set(key, value);
return `${repo}/issues/new?${params.toString()}`;
};
let url = build();
while (url.length > MAX_URL_LENGTH && fields.logs) {
const cut = Math.max(0, fields.logs.length - (url.length - MAX_URL_LENGTH));
fields.logs =
cut > 200 ? `${fields.logs.slice(0, cut - 20)}\n...(cut)` : undefined;
if (!fields.logs) delete fields.logs;
url = build();
}
return url;
}
function osName(ua: string): string {
if (/Android/i.test(ua)) return "Android";
if (/iPhone|iPad|iPod/i.test(ua)) return "iOS";
if (/Windows/i.test(ua)) return "Windows";
if (/Mac OS X|Macintosh/i.test(ua)) return "macOS";
if (/CrOS/i.test(ua)) return "ChromeOS";
if (/Linux/i.test(ua)) return "Linux";
return "Unknown OS";
}
function browserName(ua: string): string {
const match =
ua.match(/(Edg)\/(\d+)/) ??
ua.match(/(OPR)\/(\d+)/) ??
ua.match(/(Firefox)\/(\d+)/) ??
ua.match(/(Chrome)\/(\d+)/) ??
ua.match(/Version\/(\d+).*(Safari)/);
if (!match) return "Unknown browser";
if (match[2] === "Safari") return `Safari ${match[1]}`;
const names: Record<string, string> = { Edg: "Edge", OPR: "Opera" };
return `${names[match[1]] ?? match[1]} ${match[2]}`;
}
/** One line on where Termix is running, for the environment field. */
export function describeEnvironment(
userAgent: string = typeof navigator === "undefined"
? ""
: navigator.userAgent,
desktop: boolean = isElectron(),
): string {
const os = osName(userAgent);
return desktop
? `Desktop app on ${os}`
: `Web, ${browserName(userAgent)} on ${os}`;
}
/** A bug report on the Termix core repo. */
export function reportCoreIssueUrl(
termixVersion?: string,
template: "bug_report.yml" | "beta_feedback.yml" = "bug_report.yml",
): string {
return buildIssueUrl(CORE_REPO_URL, {
template,
fields: {
"termix-version": termixVersion,
environment: describeEnvironment(),
},
});
}
+1 -1
View File
@@ -1172,7 +1172,7 @@
"title": "Dashboard",
"loading": "Loading dashboard...",
"github": "GitHub",
"support": "Support",
"reportBug": "Report a bug",
"discord": "Discord",
"docs": "Docs",
"donate": "Donate",
+34 -9
View File
@@ -16,6 +16,11 @@ import type {
RegistryPluginEntry,
RegistryPluginVersion,
} from "@/api/plugins-api";
import {
buildIssueUrl,
describeEnvironment,
normalizeGitHubRepo,
} from "@/lib/issue-url";
export type PluginStatus = "running" | "disabled" | "failed" | "blocked";
@@ -314,28 +319,48 @@ export function formatCount(count: number): string {
/**
* A new GitHub issue on the plugin's own repo with what a maintainer asks
* first already filled in. Null when the repository is not on GitHub.
* first already filled in. Official plugins use the org bug form; a third
* party repo may not have it, so it gets a plain prefilled body. Null when
* the repository is not on GitHub.
*/
export function reportIssueUrl(
plugin: Pick<
PluginEntry,
"id" | "repository" | "version" | "latestVersion" | "status" | "source"
| "id"
| "repository"
| "version"
| "latestVersion"
| "status"
| "source"
| "lastError"
>,
termixVersion?: string,
): string | null {
const repo = plugin.repository
?.trim()
.replace(/\.git$/, "")
.replace(/\/+$/, "");
if (!repo || !/^https:\/\/github\.com\/[^/]+\/[^/]+$/.test(repo)) {
return null;
const repo = normalizeGitHubRepo(plugin.repository);
if (!repo) return null;
const version = plugin.version ?? plugin.latestVersion ?? "not installed";
const state = `Plugin ${plugin.id}, ${plugin.source}, ${plugin.status ?? "not installed"}`;
if (plugin.source !== "unverified") {
return buildIssueUrl(repo, {
template: "bug_report.yml",
fields: {
"termix-version": termixVersion,
"plugin-version": version,
environment: `${describeEnvironment()}\n${state}`,
logs: plugin.lastError,
},
});
}
const lines = [
`**Plugin:** ${plugin.id}`,
`**Version:** ${plugin.version ?? plugin.latestVersion ?? "not installed"}`,
`**Version:** ${version}`,
`**Source:** ${plugin.source}`,
`**State:** ${plugin.status ?? "not installed"}`,
...(termixVersion ? [`**Termix:** ${termixVersion}`] : []),
`**Environment:** ${describeEnvironment()}`,
...(plugin.lastError
? ["", "**Error:**", "```", plugin.lastError, "```"]
: []),
"",
"**What happened:**",
"",
+6 -5
View File
@@ -11,11 +11,11 @@ import {
CommandEmpty,
} from "@/components/command";
import {
Bug,
Clock,
Folder,
Globe,
KeyRound,
LifeBuoy,
MessagesSquare,
Pencil,
Plus,
@@ -43,6 +43,7 @@ import {
type PaletteItemDef,
} from "./palette-registry";
import { activityTarget } from "@/lib/activity-types";
import { reportCoreIssueUrl } from "@/lib/issue-url";
import { shell } from "@/plugin-host/shell-bridge";
import { getLiveHostStatus } from "@/lib/ServerStatusContext";
@@ -228,10 +229,10 @@ export function CommandPalette({
url: "https://discord.com/invite/jVQGdvHDrf",
},
{
id: "link-support",
label: t("dashboard.support"),
icon: LifeBuoy,
url: "https://github.com/Termix-SSH/Support/issues/new",
id: "link-report-bug",
label: t("dashboard.reportBug"),
icon: Bug,
url: reportCoreIssueUrl(import.meta.env.VITE_APP_VERSION || undefined),
},
].filter((link) => matches(link.label));
+5 -1
View File
@@ -68,6 +68,7 @@ import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
import { Select2 } from "@/components/select2";
import { LANGUAGES, THEMES } from "@/settings/appearance/appearance-controls";
import { clearLocalAdaptivePreferences } from "@/lib/local-adaptive-preferences";
import { reportCoreIssueUrl } from "@/lib/issue-url";
import {
FeatureSettingsSection,
featureSectionId,
@@ -1302,7 +1303,10 @@ export function UserProfilePanel({
<span className="text-[10px] text-muted-foreground">
{t("newUi.sidebar.userProfile.betaProgramDescription")}{" "}
<a
href="https://github.com/Termix-SSH/Support/issues/new?template=beta_feedback.yml"
href={reportCoreIssueUrl(
version || undefined,
"beta_feedback.yml",
)}
target="_blank"
rel="noopener noreferrer"
className="text-accent-brand hover:underline"
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import {
buildIssueUrl,
describeEnvironment,
normalizeGitHubRepo,
reportCoreIssueUrl,
} from "@/lib/issue-url";
const CHROME_WIN =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36";
const SAFARI_MAC =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15";
const FIREFOX_LINUX =
"Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0";
describe("normalizeGitHubRepo", () => {
it("strips .git and trailing slashes", () => {
expect(normalizeGitHubRepo(" https://github.com/a/b.git ")).toBe(
"https://github.com/a/b",
);
expect(normalizeGitHubRepo("https://github.com/a/b/")).toBe(
"https://github.com/a/b",
);
});
it("rejects anything that is not a GitHub repo", () => {
expect(normalizeGitHubRepo("https://gitlab.com/a/b")).toBeNull();
expect(normalizeGitHubRepo("https://github.com/a")).toBeNull();
expect(normalizeGitHubRepo(undefined)).toBeNull();
});
});
describe("buildIssueUrl", () => {
it("sets the template and skips empty fields", () => {
const url = new URL(
buildIssueUrl("https://github.com/a/b", {
template: "bug_report.yml",
fields: { "termix-version": "26.10.0", logs: "", other: undefined },
}),
);
expect(url.pathname).toBe("/a/b/issues/new");
expect(url.searchParams.get("template")).toBe("bug_report.yml");
expect(url.searchParams.get("termix-version")).toBe("26.10.0");
expect(url.searchParams.has("logs")).toBe(false);
expect(url.searchParams.has("other")).toBe(false);
});
it("encodes values safely", () => {
const url = new URL(
buildIssueUrl("https://github.com/a/b", {
template: "bug_report.yml",
fields: { environment: "a & b\nc=d" },
}),
);
expect(url.searchParams.get("environment")).toBe("a & b\nc=d");
});
it("cuts long logs to keep the URL short", () => {
const url = buildIssueUrl("https://github.com/a/b", {
template: "bug_report.yml",
fields: { logs: "x".repeat(20000) },
});
expect(url.length).toBeLessThanOrEqual(7500);
expect(new URL(url).searchParams.get("logs")).toMatch(/\(cut\)$/);
});
});
describe("describeEnvironment", () => {
it("names the browser and OS on the web", () => {
expect(describeEnvironment(CHROME_WIN, false)).toBe(
"Web, Chrome 141 on Windows",
);
expect(describeEnvironment(SAFARI_MAC, false)).toBe(
"Web, Safari 18 on macOS",
);
expect(describeEnvironment(FIREFOX_LINUX, false)).toBe(
"Web, Firefox 130 on Linux",
);
});
it("names the desktop app", () => {
expect(describeEnvironment(CHROME_WIN, true)).toBe(
"Desktop app on Windows",
);
});
});
describe("reportCoreIssueUrl", () => {
it("points at the core repo with the version filled in", () => {
const url = new URL(reportCoreIssueUrl("26.10.0", "beta_feedback.yml"));
expect(url.pathname).toBe("/Termix-SSH/Termix/issues/new");
expect(url.searchParams.get("template")).toBe("beta_feedback.yml");
expect(url.searchParams.get("termix-version")).toBe("26.10.0");
expect(url.searchParams.get("environment")).toBeTruthy();
});
});
+30 -19
View File
@@ -248,40 +248,51 @@ describe("source and trust", () => {
});
describe("reportIssueUrl", () => {
it("prefills id, version, source and state on the plugin repo", () => {
const url = reportIssueUrl(
{
id: "docker",
repository: "https://github.com/Termix-SSH/Plugin-Docker.git",
version: "1.0.0",
latestVersion: "1.1.0",
status: "failed",
source: "official",
},
"26.10.0",
)!;
const parsed = new URL(url);
const base = {
id: "docker",
repository: "https://github.com/Termix-SSH/Plugin-Docker.git",
version: "1.0.0",
latestVersion: "1.1.0",
status: "failed" as const,
source: "official" as const,
lastError: "boom",
};
it("fills the bug form on an official plugin repo", () => {
const parsed = new URL(reportIssueUrl(base, "26.10.0")!);
expect(parsed.origin + parsed.pathname).toBe(
"https://github.com/Termix-SSH/Plugin-Docker/issues/new",
);
expect(parsed.searchParams.get("template")).toBe("bug_report.yml");
expect(parsed.searchParams.get("termix-version")).toBe("26.10.0");
expect(parsed.searchParams.get("plugin-version")).toBe("1.0.0");
expect(parsed.searchParams.get("environment")).toContain(
"Plugin docker, official, failed",
);
expect(parsed.searchParams.get("logs")).toBe("boom");
expect(parsed.searchParams.has("body")).toBe(false);
});
it("uses a plain body for an unverified plugin", () => {
const parsed = new URL(
reportIssueUrl({ ...base, source: "unverified" }, "26.10.0")!,
);
expect(parsed.searchParams.has("template")).toBe(false);
expect(parsed.searchParams.get("title")).toBe("[docker] ");
const body = parsed.searchParams.get("body")!;
expect(body).toContain("**Plugin:** docker");
expect(body).toContain("**Version:** 1.0.0");
expect(body).toContain("**Source:** official");
expect(body).toContain("**Source:** unverified");
expect(body).toContain("**State:** failed");
expect(body).toContain("**Termix:** 26.10.0");
expect(body).toContain("boom");
});
it("gives nothing for a repo that is not on GitHub", () => {
expect(
reportIssueUrl({
id: "x",
...base,
repository: "https://gitlab.com/a/b",
version: null,
latestVersion: null,
status: null,
source: "official",
}),
).toBeNull();
});