mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
* feat(cli): forward feedback submissions to backend endpoint * fix(cli): truncate feedback fields to backend caps + ack before forwarding Addresses PR review (via): - Truncate comment (2000) / cli_version (100) / env (500) to the backend DTO caps before POSTing, so a pasted stack trace is forwarded truncated instead of rejected with a 422 the best-effort path swallows silently. - Print "Thanks for the feedback!" before the best-effort forward so the ack isn't blocked behind the (bounded) network call. * fix(cli): type feedback fetch mock
184 lines
5.9 KiB
TypeScript
184 lines
5.9 KiB
TypeScript
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 { submitFeedback } from "../utils/submitFeedback.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 {
|
|
const n = parseInt(raw, 10);
|
|
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: {
|
|
rating: {
|
|
type: "string",
|
|
description: "Satisfaction rating (1=poor, 5=great)",
|
|
required: true,
|
|
},
|
|
comment: {
|
|
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);
|
|
if (rating === null) {
|
|
console.error(c.error("Rating must be between 1 and 5"));
|
|
process.exit(1);
|
|
}
|
|
|
|
if (!shouldTrack()) {
|
|
console.log(c.dim("Telemetry is disabled. Feedback not sent."));
|
|
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, doctorSummary });
|
|
|
|
await flush();
|
|
// Ack first so the user isn't kept waiting on the best-effort forward (which
|
|
// is bounded to a few seconds and never surfaces an error either way).
|
|
console.log(c.dim("Thanks for the feedback!"));
|
|
await submitFeedback({ rating, comment, cliVersion: VERSION, env: doctorSummary });
|
|
|
|
if (args["file-issue"] === true) {
|
|
await fileGithubIssue({
|
|
rating,
|
|
comment,
|
|
rawDir: args.dir,
|
|
yes: args.yes === true,
|
|
doctorSummary,
|
|
});
|
|
}
|
|
},
|
|
});
|