feat(cli): file a GitHub issue with a published repro from feedback (#1816)

Add an opt-in --file-issue flag to hyperframes feedback. When set, after
sending the usual feedback the CLI publishes a minimal repro of the project
to a public URL (consent-gated, mirroring publish --yes) and opens a
pre-filled GitHub bug issue draft containing the rating, comment, public
repro link, and environment summary. The user reviews and submits the issue
under their own account; there is no token, backend, or gh invocation. New
--dir selects the project to publish; --yes skips the consent prompt for
scripts. URL/body building is extracted into pure, unit-tested helpers.
This commit is contained in:
Miguel Ángel
2026-06-30 11:48:22 -07:00
committed by GitHub
parent 27a7f37494
commit 010df6a0a4
7 changed files with 292 additions and 6 deletions
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { buildIssueUrl, HYPERFRAMES_REPO_URL } from "./feedbackIssue.js";
function decoded(url: string, key: "title" | "body"): string {
const value = new URL(url).searchParams.get(key);
return value ?? "";
}
describe("buildIssueUrl", () => {
const base = {
repoUrl: HYPERFRAMES_REPO_URL,
rating: 2,
comment: "GSAP timeline froze on seek",
repoPublicUrl: "https://hyperframes.dev/p/abc123",
environment: "os=darwin/arm64 node=v22.11.0 ffmpeg=yes",
cliVersion: "1.2.3",
};
it("points at the repo /issues/new with the bug label", () => {
const url = buildIssueUrl(base);
expect(url.startsWith(`${HYPERFRAMES_REPO_URL}/issues/new?`)).toBe(true);
expect(new URL(url).searchParams.get("labels")).toBe("bug");
});
it("encodes the title and includes rating + repro URL in the body", () => {
const url = buildIssueUrl(base);
expect(decoded(url, "title")).toBe("[feedback] GSAP timeline froze on seek");
const body = decoded(url, "body");
expect(body).toContain("2/5");
expect(body).toContain("https://hyperframes.dev/p/abc123");
expect(body).toContain("os=darwin/arm64");
expect(body).toContain("cli=1.2.3");
});
it("falls back to a generic title when there is no comment", () => {
const url = buildIssueUrl({ ...base, comment: undefined });
expect(decoded(url, "title")).toBe("Render feedback (rating 2/5)");
});
it("truncates an overlong comment in the body", () => {
const longComment = "x".repeat(9000);
const url = buildIssueUrl({ ...base, comment: longComment });
const body = decoded(url, "body");
expect(body).not.toContain("x".repeat(9000));
expect(body).toContain("…");
// Whole URL stays well under the ~8 KB pre-fill limit.
expect(url.length).toBeLessThan(8000);
});
it("strips a trailing .git from the repo url", () => {
const url = buildIssueUrl({
...base,
repoUrl: "https://github.com/heygen-com/hyperframes.git",
});
expect(url.startsWith(`${HYPERFRAMES_REPO_URL}/issues/new?`)).toBe(true);
});
it("notes when no repro link is available", () => {
const url = buildIssueUrl({ ...base, repoPublicUrl: undefined });
expect(decoded(url, "body")).toContain("Publishing the repro failed");
});
});
+71
View File
@@ -0,0 +1,71 @@
// Reading package.json at runtime from the single-file bundled CLI is awkward,
// so we keep the canonical repo as a constant. It must match the `repository.url`
// in packages/cli/package.json.
export const HYPERFRAMES_REPO_URL = "https://github.com/heygen-com/hyperframes";
const TITLE_MAX = 80;
// Pre-filled issue URLs have a practical length limit (~8 KB), so cap the
// comment that goes into the body.
const COMMENT_MAX = 4000;
export interface IssueInput {
repoUrl: string;
rating: number;
comment?: string;
/** Public URL of the published minimal repro, if publishing succeeded. */
repoPublicUrl?: string;
/** Doctor summary string (os/node/ffmpeg...). */
environment: string;
cliVersion: string;
}
function normalizeRepoUrl(repoUrl: string): string {
const trimmed = repoUrl
.trim()
.replace(/\/$/, "")
.replace(/\.git$/, "");
return trimmed || HYPERFRAMES_REPO_URL;
}
function truncate(value: string, max: number): string {
return value.length > max ? `${value.slice(0, max - 1)}` : value;
}
function buildIssueTitle(rating: number, comment?: string): string {
const firstLine = comment?.split("\n")[0]?.trim();
if (!firstLine) return `Render feedback (rating ${rating}/5)`;
return `[feedback] ${truncate(firstLine, TITLE_MAX)}`;
}
function buildIssueBody(input: IssueInput): string {
const comment = input.comment?.trim();
const repro = input.repoPublicUrl
? `Published minimal repro: ${input.repoPublicUrl}`
: "_Publishing the repro failed, no public link available._";
return [
`**Rating:** ${input.rating}/5`,
"",
"## Comment",
comment ? truncate(comment, COMMENT_MAX) : "_No comment provided._",
"",
"## Minimal repro",
repro,
"",
"## Environment",
"```",
input.environment || "(unavailable)",
`cli=${input.cliVersion}`,
"```",
"",
"---",
"_Filed via `hyperframes feedback --file-issue`._",
].join("\n");
}
export function buildIssueUrl(input: IssueInput): string {
const repo = normalizeRepoUrl(input.repoUrl);
const title = encodeURIComponent(buildIssueTitle(input.rating, input.comment));
const body = encodeURIComponent(buildIssueBody(input));
return `${repo}/issues/new?title=${title}&body=${body}&labels=bug`;
}