mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -1,13 +1,23 @@
|
||||
import { resolve } from "node:path";
|
||||
import { defineCommand } from "citty";
|
||||
import * as clack from "@clack/prompts";
|
||||
import open from "open";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { trackRenderFeedback } from "../telemetry/events.js";
|
||||
import { shouldTrack, flush } from "../telemetry/client.js";
|
||||
import { getDoctorSummary } from "../telemetry/feedback.js";
|
||||
import { publishProjectArchive } from "../utils/publishProject.js";
|
||||
import { buildIssueUrl, HYPERFRAMES_REPO_URL } from "../utils/feedbackIssue.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
|
||||
export const examples: Example[] = [
|
||||
["Submit render feedback", 'hyperframes feedback --rating 4 --comment "fast but font missing"'],
|
||||
["Quick rating only", "hyperframes feedback --rating 5"],
|
||||
[
|
||||
"Also file a GitHub issue with a published repro",
|
||||
'hyperframes feedback --rating 2 --comment "GSAP timeline froze" --file-issue',
|
||||
],
|
||||
];
|
||||
|
||||
function parseRating(raw: string): number | null {
|
||||
@@ -15,6 +25,97 @@ function parseRating(raw: string): number | null {
|
||||
return n >= 1 && n <= 5 && Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function normalizeComment(raw?: string): string | undefined {
|
||||
return raw || undefined;
|
||||
}
|
||||
|
||||
function printIssueConsent(dir: string): void {
|
||||
console.log();
|
||||
console.log(
|
||||
` ${c.bold("Filing an issue will publish this project publicly and open a GitHub issue draft.")}`,
|
||||
);
|
||||
console.log(` ${c.dim(`Project at ${dir} will be uploaded to a public URL.`)}`);
|
||||
console.log(
|
||||
` ${c.dim("The issue draft will contain that public link plus your feedback; you review and submit it.")}`,
|
||||
);
|
||||
console.log();
|
||||
}
|
||||
|
||||
async function promptConfirm(): Promise<boolean> {
|
||||
const approved = await clack.confirm({ message: "Publish this project and draft the issue?" });
|
||||
return !clack.isCancel(approved) && approved === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consent gate: publishing uploads the project to a PUBLIC url, so confirm
|
||||
* before proceeding. Returns true when the caller may publish + file.
|
||||
*/
|
||||
async function confirmFileIssue(dir: string, yes: boolean): Promise<boolean> {
|
||||
printIssueConsent(dir);
|
||||
if (yes) return true;
|
||||
if (!process.stdout.isTTY) {
|
||||
console.log(` ${c.dim("Re-run with --yes to publish the repro and file the issue.")}\n`);
|
||||
return false;
|
||||
}
|
||||
if (await promptConfirm()) return true;
|
||||
console.log(`\n ${c.dim("Aborted. Feedback was still sent.")}\n`);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a minimal repro and return its public URL. Degrades gracefully:
|
||||
* on failure it returns undefined so the issue still opens without a link.
|
||||
*/
|
||||
async function publishRepro(dir: string): Promise<string | undefined> {
|
||||
const spinner = clack.spinner();
|
||||
spinner.start("Publishing minimal repro...");
|
||||
try {
|
||||
const published = await publishProjectArchive(dir);
|
||||
spinner.stop(c.success("Repro published"));
|
||||
return published.url;
|
||||
} catch (err: unknown) {
|
||||
spinner.stop(c.error("Publish failed"));
|
||||
console.error(` ${(err as Error).message}`);
|
||||
console.log(` ${c.dim("Filing the issue without a repro link.")}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function openAndPrintIssue(url: string): Promise<void> {
|
||||
if (process.stdout.isTTY) {
|
||||
try {
|
||||
await open(url);
|
||||
} catch {
|
||||
// Headless or no browser; the printed URL below is the fallback.
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
console.log(` ${c.dim("Review and submit the pre-filled issue (it is not auto-submitted):")}`);
|
||||
console.log(` ${c.accent(url)}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
async function fileGithubIssue(opts: {
|
||||
rating: number;
|
||||
comment?: string;
|
||||
rawDir?: string;
|
||||
yes: boolean;
|
||||
doctorSummary: string;
|
||||
}): Promise<void> {
|
||||
const dir = resolve(opts.rawDir ?? ".");
|
||||
if (!(await confirmFileIssue(dir, opts.yes))) return;
|
||||
const repoPublicUrl = await publishRepro(dir);
|
||||
const url = buildIssueUrl({
|
||||
repoUrl: HYPERFRAMES_REPO_URL,
|
||||
rating: opts.rating,
|
||||
comment: opts.comment,
|
||||
repoPublicUrl,
|
||||
environment: opts.doctorSummary,
|
||||
cliVersion: VERSION,
|
||||
});
|
||||
await openAndPrintIssue(url);
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
meta: { name: "feedback", description: "Submit anonymous feedback about your experience" },
|
||||
args: {
|
||||
@@ -27,6 +128,21 @@ export default defineCommand({
|
||||
type: "string",
|
||||
description: "Optional details about your experience",
|
||||
},
|
||||
"file-issue": {
|
||||
type: "boolean",
|
||||
description: "Also open a pre-filled GitHub issue with a published minimal repro",
|
||||
default: false,
|
||||
},
|
||||
dir: {
|
||||
type: "string",
|
||||
description: "Project directory to publish as the repro (default: current directory)",
|
||||
},
|
||||
yes: {
|
||||
type: "boolean",
|
||||
alias: "y",
|
||||
description: "Skip the publish + file-issue consent prompt",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const rating = parseRating(args.rating);
|
||||
@@ -40,17 +156,24 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
const comment = normalizeComment(args.comment);
|
||||
const doctorSummary = await getDoctorSummary();
|
||||
|
||||
// The standalone command runs separately from `render`, so it has no real
|
||||
// elapsed time to report. Omit it rather than recording a fake duration.
|
||||
trackRenderFeedback({
|
||||
rating,
|
||||
comment: args.comment || undefined,
|
||||
doctorSummary,
|
||||
});
|
||||
trackRenderFeedback({ rating, comment, doctorSummary });
|
||||
|
||||
await flush();
|
||||
console.log(c.dim("Thanks for the feedback!"));
|
||||
|
||||
if (args["file-issue"] === true) {
|
||||
await fileGithubIssue({
|
||||
rating,
|
||||
comment,
|
||||
rawDir: args.dir,
|
||||
yes: args.yes === true,
|
||||
doctorSummary,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
Reference in New Issue
Block a user