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:
@@ -74,9 +74,28 @@ hyperframes feedback --rating 3 --comment "render succeeded but GSAP timeline di
|
|||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `--rating` | Satisfaction score, 1–5 (required) |
|
| `--rating` | Satisfaction score, 1–5 (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:
|
||||||
|
|||||||
@@ -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, 1–5 (required) |
|
| `--rating` | Satisfaction score, 1–5 (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.
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user