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
+19
View File
@@ -74,9 +74,28 @@ hyperframes feedback --rating 3 --comment "render succeeded but GSAP timeline di
|------|-------------| |------|-------------|
| `--rating` | Satisfaction score, 15 (required) | | `--rating` | Satisfaction score, 15 (required) |
| `--comment` | Optional free-text details | | `--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`. 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 ## Agent Runtimes
When an AI agent is detected, HyperFrames **skips the interactive readline prompt** and prints a structured hint instead: When an AI agent is detected, HyperFrames **skips the interactive readline prompt** and prints a structured hint instead:
+8
View File
@@ -876,12 +876,20 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o
# Rating with optional details # Rating with optional details
npx hyperframes feedback --rating 3 --comment "render succeeded but GSAP timeline didn't animate" 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 | | Flag | Description |
|------|-------------| |------|-------------|
| `--rating` | Satisfaction score, 15 (required) | | `--rating` | Satisfaction score, 15 (required) |
| `--comment` | Optional free-text details | | `--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. 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.
+128 -5
View File
@@ -1,13 +1,23 @@
import { resolve } from "node:path";
import { defineCommand } from "citty"; import { defineCommand } from "citty";
import * as clack from "@clack/prompts";
import open from "open";
import type { Example } from "./_examples.js"; import type { Example } from "./_examples.js";
import { trackRenderFeedback } from "../telemetry/events.js"; import { trackRenderFeedback } from "../telemetry/events.js";
import { shouldTrack, flush } from "../telemetry/client.js"; import { shouldTrack, flush } from "../telemetry/client.js";
import { getDoctorSummary } from "../telemetry/feedback.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"; import { c } from "../ui/colors.js";
export const examples: Example[] = [ export const examples: Example[] = [
["Submit render feedback", 'hyperframes feedback --rating 4 --comment "fast but font missing"'], ["Submit render feedback", 'hyperframes feedback --rating 4 --comment "fast but font missing"'],
["Quick rating only", "hyperframes feedback --rating 5"], ["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 { 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; 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({ export default defineCommand({
meta: { name: "feedback", description: "Submit anonymous feedback about your experience" }, meta: { name: "feedback", description: "Submit anonymous feedback about your experience" },
args: { args: {
@@ -27,6 +128,21 @@ export default defineCommand({
type: "string", type: "string",
description: "Optional details about your experience", 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 }) { async run({ args }) {
const rating = parseRating(args.rating); const rating = parseRating(args.rating);
@@ -40,17 +156,24 @@ export default defineCommand({
return; return;
} }
const comment = normalizeComment(args.comment);
const doctorSummary = await getDoctorSummary(); const doctorSummary = await getDoctorSummary();
// The standalone command runs separately from `render`, so it has no real // The standalone command runs separately from `render`, so it has no real
// elapsed time to report. Omit it rather than recording a fake duration. // elapsed time to report. Omit it rather than recording a fake duration.
trackRenderFeedback({ trackRenderFeedback({ rating, comment, doctorSummary });
rating,
comment: args.comment || undefined,
doctorSummary,
});
await flush(); await flush();
console.log(c.dim("Thanks for the feedback!")); 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");
});
});
+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`;
}
+1 -1
View File
@@ -22,7 +22,7 @@
"files": 115 "files": 115
}, },
"hyperframes-cli": { "hyperframes-cli": {
"hash": "ea8141bc9dcf8805", "hash": "9b36a367a0e3a332",
"files": 7 "files": 7
}, },
"hyperframes-core": { "hyperframes-core": {
@@ -151,6 +151,8 @@ npx hyperframes feedback --rating 3 --comment "bg <video> renders grey in multi-
`--rating` is 1-5 (required); `--comment` is free text — use it for any bug, workaround, missing feature, or confusing behaviour, plus the composition pattern that triggered it and what you tried. Feedback is anonymous and attaches a `doctorSummary` (OS/Node/CPU/mem/ffmpeg) automatically. No-ops when telemetry is disabled. `--rating` is 1-5 (required); `--comment` is free text — use it for any bug, workaround, missing feature, or confusing behaviour, plus the composition pattern that triggered it and what you tried. Feedback is anonymous and attaches a `doctorSummary` (OS/Node/CPU/mem/ffmpeg) automatically. No-ops when telemetry is disabled.
Hit a reproducible bug? Add `--file-issue` (optionally `--dir <project>` and `--yes` for non-interactive shells) to also publish a minimal repro to a public URL and open a pre-filled GitHub `bug` issue draft for a maintainer to file. This publishes the project publicly, so it is opt-in and consent-gated; the issue is never auto-submitted.
## publish ## publish
```bash ```bash