diff --git a/docs/guides/feedback.mdx b/docs/guides/feedback.mdx index 2e9cf650d..00b360463 100644 --- a/docs/guides/feedback.mdx +++ b/docs/guides/feedback.mdx @@ -74,9 +74,28 @@ hyperframes feedback --rating 3 --comment "render succeeded but GSAP timeline di |------|-------------| | `--rating` | Satisfaction score, 1–5 (required) | | `--comment` | Optional free-text details | +| `--file-issue` | Also open a pre-filled GitHub issue with a published minimal repro (opt-in) | +| `--dir` | Project directory to publish as the repro (default: current directory) | +| `--yes` | Skip the publish + file-issue consent prompt (for scripts) | This command collects a doctor summary automatically, flushes telemetry, and exits. It appears under the **Settings** group in `hyperframes --help`. +### Filing a GitHub issue (`--file-issue`) + +When a render misbehaves, add `--file-issue` so maintainers can reproduce it: + +```bash +hyperframes feedback --rating 2 --comment "GSAP timeline froze on seek" --file-issue +``` + +This is **opt-in** and **consented**. With `--file-issue` set, after the usual feedback is sent the CLI: + +1. Asks you to confirm (interactive) or requires `--yes` in non-interactive shells, because it will **publicly publish** the project at `--dir`. +2. Publishes a minimal repro of the project and gets a public URL (the same upload as `hyperframes publish`). +3. Opens your browser with a **pre-filled** GitHub issue draft, labelled `bug`, containing the rating, your comment, the public repro link, and the environment summary. It also prints the URL so you can copy it if no browser opens. + +The issue is **not auto-submitted**: you review and file it under your own GitHub account. There is no token, backend, or `gh` invocation; if publishing fails the issue still opens, just without a repro link. + ## Agent Runtimes When an AI agent is detected, HyperFrames **skips the interactive readline prompt** and prints a structured hint instead: diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 3e0af8203..2df790629 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -876,12 +876,20 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o # Rating with optional details npx hyperframes feedback --rating 3 --comment "render succeeded but GSAP timeline didn't animate" + + # Also file a pre-filled GitHub issue with a published minimal repro (opt-in, consented) + npx hyperframes feedback --rating 2 --comment "GSAP timeline froze on seek" --file-issue ``` | Flag | Description | |------|-------------| | `--rating` | Satisfaction score, 1–5 (required) | | `--comment` | Optional free-text details | + | `--file-issue` | Also open a pre-filled GitHub issue with a published minimal repro (opt-in) | + | `--dir` | Project directory to publish as the repro (default: current directory) | + | `--yes` | Skip the publish + file-issue consent prompt (for scripts) | + + With `--file-issue`, the CLI publishes a minimal repro to a public URL (with consent) and opens a pre-filled `bug` issue draft you review and submit yourself (no token or backend). See [Feedback Collection](/guides/feedback#filing-a-github-issue---file-issue). This command is also available to AI agents after a render — see [Feedback Collection](/guides/feedback#agent-runtimes) for how agent detection and the automatic post-render hint work. diff --git a/packages/cli/src/commands/feedback.ts b/packages/cli/src/commands/feedback.ts index b353a1b25..cc97e15e5 100644 --- a/packages/cli/src/commands/feedback.ts +++ b/packages/cli/src/commands/feedback.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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, + }); + } }, }); diff --git a/packages/cli/src/utils/feedbackIssue.test.ts b/packages/cli/src/utils/feedbackIssue.test.ts new file mode 100644 index 000000000..20e2023de --- /dev/null +++ b/packages/cli/src/utils/feedbackIssue.test.ts @@ -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"); + }); +}); diff --git a/packages/cli/src/utils/feedbackIssue.ts b/packages/cli/src/utils/feedbackIssue.ts new file mode 100644 index 000000000..3632c6bc2 --- /dev/null +++ b/packages/cli/src/utils/feedbackIssue.ts @@ -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`; +} diff --git a/skills-manifest.json b/skills-manifest.json index 806204b15..e224917e0 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -22,7 +22,7 @@ "files": 115 }, "hyperframes-cli": { - "hash": "ea8141bc9dcf8805", + "hash": "9b36a367a0e3a332", "files": 7 }, "hyperframes-core": { diff --git a/skills/hyperframes-cli/references/preview-render.md b/skills/hyperframes-cli/references/preview-render.md index 767f38c39..212910fce 100644 --- a/skills/hyperframes-cli/references/preview-render.md +++ b/skills/hyperframes-cli/references/preview-render.md @@ -151,6 +151,8 @@ npx hyperframes feedback --rating 3 --comment "bg