diff --git a/apps/cli/src/legacy/cli/root.ts b/apps/cli/src/legacy/cli/root.ts index 6fe2791320..d84ab36806 100644 --- a/apps/cli/src/legacy/cli/root.ts +++ b/apps/cli/src/legacy/cli/root.ts @@ -12,6 +12,7 @@ import { legacyFunctionsCommand } from "../commands/functions/functions.command. import { legacyGenCommand } from "../commands/gen/gen.command.ts"; import { legacyInitCommand } from "../commands/init/init.command.ts"; import { legacyInspectCommand } from "../commands/inspect/inspect.command.ts"; +import { legacyIssueCommand } from "../commands/issue/issue.command.ts"; import { legacyLinkCommand } from "../commands/link/link.command.ts"; import { legacyLoginCommand } from "../commands/login/login.command.ts"; import { legacyLogoutCommand } from "../commands/logout/logout.command.ts"; @@ -69,6 +70,7 @@ export const legacyRoot = Command.make("supabase").pipe( legacyGenCommand, legacyInitCommand, legacyInspectCommand, + legacyIssueCommand, legacyLinkCommand, legacyLoginCommand, legacyLogoutCommand, diff --git a/apps/cli/src/legacy/commands/issue/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/issue/SIDE_EFFECTS.md new file mode 100644 index 0000000000..3c588804ad --- /dev/null +++ b/apps/cli/src/legacy/commands/issue/SIDE_EFFECTS.md @@ -0,0 +1,11 @@ +# `supabase issue` + +## Side effects + +- Opens a GitHub issue form URL in the user's default browser, unless `--no-browser` is passed. +- Writes the generated issue form URL to stdout. + +## No local project changes + +This command does not read or write Supabase project files, stack state, credentials, or linked +project metadata. diff --git a/apps/cli/src/legacy/commands/issue/issue.command.ts b/apps/cli/src/legacy/commands/issue/issue.command.ts new file mode 100644 index 0000000000..cfa6251241 --- /dev/null +++ b/apps/cli/src/legacy/commands/issue/issue.command.ts @@ -0,0 +1,129 @@ +import { Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { browserLayer } from "../../../shared/runtime/browser.layer.ts"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; +import { legacyIssueBug, legacyIssueDocs, legacyIssueFeature } from "./issue.handler.ts"; + +const legacyIssueNoBrowserFlag = Flag.boolean("no-browser").pipe( + Flag.withDescription("Print the issue form URL without opening a browser."), +); + +const legacyIssueOptionalTextFlag = (name: string, description: string) => + Flag.string(name).pipe(Flag.withDescription(description), Flag.optional); + +const legacyIssueCommonContextFlag = legacyIssueOptionalTextFlag( + "additional-context", + "Extra context to prefill on the issue form.", +); + +const legacyIssueBugConfig = { + area: legacyIssueOptionalTextFlag("area", "Affected CLI area."), + command: legacyIssueOptionalTextFlag("command", "Command that failed."), + actualOutput: legacyIssueOptionalTextFlag("actual-output", "Actual output or error text."), + expectedBehavior: legacyIssueOptionalTextFlag("expected-behavior", "Expected behavior."), + reproduce: legacyIssueOptionalTextFlag("reproduce", "Steps to reproduce."), + ticketId: legacyIssueOptionalTextFlag("ticket-id", "Crash report or support ticket ID."), + dockerServices: legacyIssueOptionalTextFlag( + "docker-services", + "Relevant Docker service status or logs.", + ), + additionalContext: legacyIssueCommonContextFlag, + noBrowser: legacyIssueNoBrowserFlag, +} as const; + +const legacyIssueFeatureConfig = { + existingIssues: Flag.boolean("existing-issues").pipe( + Flag.withDescription("Prefill the existing issues checklist."), + ), + area: legacyIssueOptionalTextFlag("area", "Affected CLI area."), + problem: legacyIssueOptionalTextFlag("problem", "Problem the feature should solve."), + proposedSolution: legacyIssueOptionalTextFlag("proposed-solution", "Proposed solution."), + alternatives: legacyIssueOptionalTextFlag("alternatives", "Alternatives considered."), + additionalContext: legacyIssueCommonContextFlag, + noBrowser: legacyIssueNoBrowserFlag, +} as const; + +const legacyIssueDocsConfig = { + link: legacyIssueOptionalTextFlag("link", "Relevant documentation link."), + issueType: legacyIssueOptionalTextFlag("issue-type", "Documentation issue type."), + problem: legacyIssueOptionalTextFlag("problem", "What is confusing, missing, or incorrect."), + improvement: legacyIssueOptionalTextFlag("improvement", "Suggested documentation improvement."), + additionalContext: legacyIssueCommonContextFlag, + noBrowser: legacyIssueNoBrowserFlag, +} as const; + +export type LegacyIssueBugFlags = CliCommand.Command.Config.Infer; +export type LegacyIssueFeatureFlags = CliCommand.Command.Config.Infer< + typeof legacyIssueFeatureConfig +>; +export type LegacyIssueDocsFlags = CliCommand.Command.Config.Infer; + +const legacyIssueBugCommand = Command.make("bug", legacyIssueBugConfig).pipe( + Command.withDescription("Open a GitHub bug report with local CLI details prefilled."), + Command.withShortDescription("Open a bug report"), + Command.withExamples([ + { + command: + 'supabase issue bug --command "supabase start" --actual-output "database failed to start"', + description: "Open a prefilled bug report for a failing command", + }, + { + command: 'supabase issue bug --ticket-id "abc123" --no-browser', + description: "Print a prefilled issue URL for a crash report", + }, + ]), + Command.withHandler((flags) => + legacyIssueBug(flags).pipe(withLegacyCommandInstrumentation({ flags }), withJsonErrorHandling), + ), + Command.provide(commandRuntimeLayer(["issue", "bug"])), + Command.provide(browserLayer), +); + +const legacyIssueFeatureCommand = Command.make("feature", legacyIssueFeatureConfig).pipe( + Command.withDescription("Open a GitHub feature request with useful context prefilled."), + Command.withShortDescription("Open a feature request"), + Command.withExamples([ + { + command: + 'supabase issue feature --existing-issues --problem "I need to rotate local secrets" --proposed-solution "Add a secrets rotate command"', + description: "Open a prefilled feature request", + }, + ]), + Command.withHandler((flags) => + legacyIssueFeature(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(commandRuntimeLayer(["issue", "feature"])), + Command.provide(browserLayer), +); + +const legacyIssueDocsCommand = Command.make("docs", legacyIssueDocsConfig).pipe( + Command.withDescription("Open a GitHub documentation issue with useful context prefilled."), + Command.withShortDescription("Open a documentation issue"), + Command.withExamples([ + { + command: + 'supabase issue docs --link "https://supabase.com/docs/guides/cli" --problem "The flag description is outdated"', + description: "Open a prefilled documentation issue", + }, + ]), + Command.withHandler((flags) => + legacyIssueDocs(flags).pipe(withLegacyCommandInstrumentation({ flags }), withJsonErrorHandling), + ), + Command.provide(commandRuntimeLayer(["issue", "docs"])), + Command.provide(browserLayer), +); + +export const legacyIssueCommand = Command.make("issue").pipe( + Command.withDescription("Open Supabase CLI GitHub issue forms."), + Command.withShortDescription("Open GitHub issue forms"), + Command.withSubcommands([ + legacyIssueBugCommand, + legacyIssueFeatureCommand, + legacyIssueDocsCommand, + ]), +); diff --git a/apps/cli/src/legacy/commands/issue/issue.handler.ts b/apps/cli/src/legacy/commands/issue/issue.handler.ts new file mode 100644 index 0000000000..2cacc6d5b6 --- /dev/null +++ b/apps/cli/src/legacy/commands/issue/issue.handler.ts @@ -0,0 +1,88 @@ +import { Effect } from "effect"; +import { + buildIssueUrl, + inferIssueInstallMethod, + issueTemplateContract, + readIssueFlagValue, + searchedExistingIssuesValue, +} from "../../../shared/issue/issue-url.ts"; +import { Output } from "../../../shared/output/output.service.ts"; +import { Browser } from "../../../shared/runtime/browser.service.ts"; +import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; +import type { + LegacyIssueBugFlags, + LegacyIssueDocsFlags, + LegacyIssueFeatureFlags, +} from "./issue.command.ts"; + +const legacyOpenIssueUrl = Effect.fnUntraced(function* (url: string, noBrowser: boolean) { + const output = yield* Output; + yield* output.raw(`${url}\n`); + if (!noBrowser) { + const browser = yield* Browser; + yield* browser.open(url); + yield* output.success("Opened GitHub issue form.", { url }); + } else { + yield* output.info("GitHub issue form URL:"); + } +}); + +export const legacyIssueBug = Effect.fn("legacy.issue.bug")(function* (flags: LegacyIssueBugFlags) { + const runtimeInfo = yield* RuntimeInfo; + const telemetryRuntime = yield* TelemetryRuntime; + + const url = buildIssueUrl({ + template: issueTemplateContract.bug.template, + fields: { + "affected-area": readIssueFlagValue(flags.area), + "cli-version": telemetryRuntime.cliVersion, + os: `${runtimeInfo.platform} ${runtimeInfo.arch}`, + "install-method": inferIssueInstallMethod(runtimeInfo), + command: readIssueFlagValue(flags.command), + "actual-output": readIssueFlagValue(flags.actualOutput), + "expected-behavior": readIssueFlagValue(flags.expectedBehavior), + reproduce: readIssueFlagValue(flags.reproduce), + "ticket-id": readIssueFlagValue(flags.ticketId), + "docker-services": readIssueFlagValue(flags.dockerServices), + "additional-context": readIssueFlagValue(flags.additionalContext), + }, + }); + + yield* legacyOpenIssueUrl(url, flags.noBrowser); +}); + +export const legacyIssueFeature = Effect.fn("legacy.issue.feature")(function* ( + flags: LegacyIssueFeatureFlags, +) { + const url = buildIssueUrl({ + template: issueTemplateContract.feature.template, + fields: { + "existing-issues": flags.existingIssues ? searchedExistingIssuesValue : undefined, + "affected-area": readIssueFlagValue(flags.area), + problem: readIssueFlagValue(flags.problem), + "proposed-solution": readIssueFlagValue(flags.proposedSolution), + alternatives: readIssueFlagValue(flags.alternatives), + "additional-context": readIssueFlagValue(flags.additionalContext), + }, + }); + + yield* legacyOpenIssueUrl(url, flags.noBrowser); +}); + +export const legacyIssueDocs = Effect.fn("legacy.issue.docs")(function* ( + flags: LegacyIssueDocsFlags, +) { + const url = buildIssueUrl({ + template: issueTemplateContract.docs.template, + fields: { + link: readIssueFlagValue(flags.link), + "issue-type": readIssueFlagValue(flags.issueType), + problem: readIssueFlagValue(flags.problem), + improvement: readIssueFlagValue(flags.improvement), + "additional-context": readIssueFlagValue(flags.additionalContext), + }, + }); + + yield* legacyOpenIssueUrl(url, flags.noBrowser); +}); diff --git a/apps/cli/src/legacy/commands/issue/issue.integration.test.ts b/apps/cli/src/legacy/commands/issue/issue.integration.test.ts new file mode 100644 index 0000000000..aed540735c --- /dev/null +++ b/apps/cli/src/legacy/commands/issue/issue.integration.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; +import { buildIssueUrl } from "../../../shared/issue/issue-url.ts"; +import { Output } from "../../../shared/output/output.service.ts"; +import type { OutputFormat } from "../../../shared/output/types.ts"; +import { Browser } from "../../../shared/runtime/browser.service.ts"; +import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; +import { legacyIssueBug, legacyIssueDocs, legacyIssueFeature } from "./issue.handler.ts"; + +type LegacyIssueOutputMessage = { + readonly type: "info" | "success"; + readonly message: string; + readonly data?: Record; +}; + +function legacyIssueProcessEnvLayer(values: Readonly> = {}) { + return Layer.effectDiscard( + Effect.acquireRelease( + Effect.sync(() => { + const snapshot = { ...process.env }; + for (const key of Object.keys(process.env)) { + delete process.env[key]; + } + for (const [key, value] of Object.entries(values)) { + if (value !== undefined) process.env[key] = value; + } + return snapshot; + }), + (snapshot) => + Effect.sync(() => { + for (const key of Object.keys(process.env)) { + delete process.env[key]; + } + for (const [key, value] of Object.entries(snapshot)) { + if (value !== undefined) process.env[key] = value; + } + }), + ), + ); +} + +function legacyIssueMockOutput(opts: { readonly format?: OutputFormat } = {}) { + const messages: LegacyIssueOutputMessage[] = []; + const rawChunks: string[] = []; + return { + layer: Layer.succeed(Output, { + format: opts.format ?? "text", + interactive: true, + intro: () => Effect.void, + outro: () => Effect.void, + info: (message: string) => + Effect.sync(() => { + messages.push({ type: "info", message }); + }), + warn: () => Effect.void, + error: () => Effect.void, + event: () => Effect.void, + task: () => + Effect.succeed({ + message: () => Effect.void, + succeed: () => Effect.void, + fail: () => Effect.void, + info: () => Effect.void, + cancel: () => Effect.void, + clear: () => Effect.void, + }), + promptText: () => Effect.succeed(""), + promptPassword: () => Effect.succeed(""), + promptConfirm: () => Effect.succeed(true), + promptSelect: (_message, options) => Effect.succeed(options[0]!.value), + promptMultiSelect: (_message, options) => + Effect.succeed(options.map((option) => option.value)), + progress: () => + Effect.succeed({ + start: () => Effect.void, + advance: () => Effect.void, + message: () => Effect.void, + stop: () => Effect.void, + }), + success: (message: string, data?: Record) => + Effect.sync(() => { + messages.push({ type: "success", message, data }); + }), + fail: () => Effect.void, + raw: (text: string) => + Effect.sync(() => { + rawChunks.push(text); + }), + }), + messages, + get stdoutText() { + return rawChunks.join(""); + }, + }; +} + +function legacyIssueCaptureBrowser() { + const openedUrls: string[] = []; + return { + layer: Layer.succeed(Browser, { + open: (url: string) => + Effect.sync(() => { + openedUrls.push(url); + }), + }), + openedUrls, + }; +} + +function legacyIssueParams(url: string) { + return new URL(url).searchParams; +} + +function legacyIssueSetup( + opts: { + readonly env?: Record; + readonly execPath?: string; + } = {}, +) { + const out = legacyIssueMockOutput(); + const browser = legacyIssueCaptureBrowser(); + const runtimeInfo = Layer.succeed(RuntimeInfo, { + cwd: "/test/project", + platform: "darwin", + arch: "arm64", + homeDir: "/test/home", + execPath: opts.execPath ?? "/opt/homebrew/bin/supabase", + pid: 1234, + }); + const telemetryRuntime = Layer.succeed( + TelemetryRuntime, + TelemetryRuntime.of({ + configDir: "/test/config", + tracesDir: "/test/config/traces", + consent: "granted", + showDebug: false, + deviceId: "device-id", + sessionId: "session-id", + isFirstRun: false, + isTty: true, + isCi: false, + os: "darwin", + arch: "arm64", + cliVersion: "1.2.3-test", + }), + ); + const layer = Layer.mergeAll( + out.layer, + browser.layer, + runtimeInfo, + telemetryRuntime, + legacyIssueProcessEnvLayer(opts.env ?? {}), + ); + return { layer, out, browser }; +} + +describe("legacy issue", () => { + it.live("opens bug form with runtime fields and user-provided context", () => { + const { layer, out, browser } = legacyIssueSetup(); + + return Effect.gen(function* () { + yield* legacyIssueBug({ + area: Option.some("Local development"), + command: Option.some("supabase start"), + actualOutput: Option.some("database failed to start"), + expectedBehavior: Option.none(), + reproduce: Option.some("Run supabase start in a fresh project"), + ticketId: Option.some("event-123"), + dockerServices: Option.none(), + additionalContext: Option.none(), + noBrowser: false, + }); + + expect(browser.openedUrls).toHaveLength(1); + const params = legacyIssueParams(browser.openedUrls[0]!); + expect(params.get("template")).toBe("bug-report.yml"); + expect(params.get("affected-area")).toBe("Local development"); + expect(params.get("cli-version")).toBe("1.2.3-test"); + expect(params.get("os")).toBe("darwin arm64"); + expect(params.get("install-method")).toBe("brew"); + expect(params.get("command")).toBe("supabase start"); + expect(params.get("actual-output")).toBe("database failed to start"); + expect(params.get("reproduce")).toBe("Run supabase start in a fresh project"); + expect(params.get("ticket-id")).toBe("event-123"); + expect(out.stdoutText).toBe(`${browser.openedUrls[0]}\n`); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "success", message: "Opened GitHub issue form." }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("prints the bug URL without opening a browser when requested", () => { + const { layer, out, browser } = legacyIssueSetup({ + env: { SUPABASE_INSTALL_METHOD: "asdf" }, + }); + + return Effect.gen(function* () { + yield* legacyIssueBug({ + area: Option.none(), + command: Option.none(), + actualOutput: Option.none(), + expectedBehavior: Option.none(), + reproduce: Option.none(), + ticketId: Option.none(), + dockerServices: Option.none(), + additionalContext: Option.none(), + noBrowser: true, + }); + + expect(browser.openedUrls).toEqual([]); + const params = legacyIssueParams(out.stdoutText.trim()); + expect(params.get("install-method")).toBe("Other"); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "info", message: "GitHub issue form URL:" }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("opens feature form with matching issue form field IDs", () => { + const { layer, browser } = legacyIssueSetup(); + + return Effect.gen(function* () { + yield* legacyIssueFeature({ + existingIssues: true, + area: Option.some("Auth"), + problem: Option.some("I need to rotate credentials"), + proposedSolution: Option.some("Add supabase secrets rotate"), + alternatives: Option.some("Manual dashboard workflow"), + additionalContext: Option.none(), + noBrowser: false, + }); + + const params = legacyIssueParams(browser.openedUrls[0]!); + expect(params.get("template")).toBe("feature-request.yml"); + expect(params.get("existing-issues")).toBe("I have searched the existing issues."); + expect(params.get("affected-area")).toBe("Auth"); + expect(params.get("problem")).toBe("I need to rotate credentials"); + expect(params.get("proposed-solution")).toBe("Add supabase secrets rotate"); + expect(params.get("alternatives")).toBe("Manual dashboard workflow"); + }).pipe(Effect.provide(layer)); + }); + + it.live("opens docs form with matching issue form field IDs", () => { + const { layer, browser } = legacyIssueSetup(); + + return Effect.gen(function* () { + yield* legacyIssueDocs({ + link: Option.some("https://supabase.com/docs/guides/cli"), + issueType: Option.some("Incorrect documentation"), + problem: Option.some("The output example is stale"), + improvement: Option.some("Update the output block"), + additionalContext: Option.some("Reported after testing v1.2.3"), + noBrowser: false, + }); + + const params = legacyIssueParams(browser.openedUrls[0]!); + expect(params.get("template")).toBe("docs.yml"); + expect(params.get("link")).toBe("https://supabase.com/docs/guides/cli"); + expect(params.get("issue-type")).toBe("Incorrect documentation"); + expect(params.get("problem")).toBe("The output example is stale"); + expect(params.get("improvement")).toBe("Update the output block"); + expect(params.get("additional-context")).toBe("Reported after testing v1.2.3"); + }).pipe(Effect.provide(layer)); + }); + + it("truncates long fields before encoding the issue URL", () => { + const longOutput = "x".repeat(2_000); + const params = legacyIssueParams( + buildIssueUrl({ + template: "bug-report.yml", + fields: { + "actual-output": longOutput, + }, + }), + ); + + const actualOutput = params.get("actual-output"); + expect(actualOutput).toHaveLength(1_500); + expect(actualOutput?.endsWith("[truncated by Supabase CLI]")).toBe(true); + }); +}); diff --git a/apps/cli/src/next/cli/root.ts b/apps/cli/src/next/cli/root.ts index 332507a772..1c99535577 100644 --- a/apps/cli/src/next/cli/root.ts +++ b/apps/cli/src/next/cli/root.ts @@ -3,6 +3,7 @@ import { CliOutput, Command } from "effect/unstable/cli"; import { OutputFormatFlag } from "../../shared/cli/global-flags.ts"; import { branchesCommand } from "../commands/branches/branches.command.ts"; import { functionsCommand } from "../commands/functions/functions.command.ts"; +import { issueCommand } from "../commands/issue/issue.command.ts"; import { linkCommand } from "../commands/link/link.command.ts"; import { initCommand } from "../commands/init/init.command.ts"; import { listCommand } from "../commands/list/list.command.ts"; @@ -32,6 +33,7 @@ export const nextRoot = Command.make("supabase").pipe( loginCommand, logoutCommand, telemetryCommand, + issueCommand, functionsCommand, branchesCommand, linkCommand, diff --git a/apps/cli/src/next/commands/issue/issue.command.ts b/apps/cli/src/next/commands/issue/issue.command.ts new file mode 100644 index 0000000000..cf915ccfc2 --- /dev/null +++ b/apps/cli/src/next/commands/issue/issue.command.ts @@ -0,0 +1,117 @@ +import { Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { browserLayer } from "../../../shared/runtime/browser.layer.ts"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { withCommandInstrumentation } from "../../../shared/telemetry/command-instrumentation.ts"; +import { openBugIssue, openDocsIssue, openFeatureIssue } from "./issue.handler.ts"; + +const noBrowserFlag = Flag.boolean("no-browser").pipe( + Flag.withDescription("Print the issue form URL without opening a browser"), +); + +const optionalTextFlag = (name: string, description: string) => + Flag.string(name).pipe(Flag.withDescription(description), Flag.optional); + +const commonContextFlag = optionalTextFlag( + "additional-context", + "Extra context to prefill on the issue form", +); + +const bugFlags = { + area: optionalTextFlag("area", "Affected CLI area"), + command: optionalTextFlag("command", "Command that failed"), + actualOutput: optionalTextFlag("actual-output", "Actual output or error text"), + expectedBehavior: optionalTextFlag("expected-behavior", "Expected behavior"), + reproduce: optionalTextFlag("reproduce", "Steps to reproduce"), + ticketId: optionalTextFlag("ticket-id", "Crash report or support ticket ID"), + dockerServices: optionalTextFlag("docker-services", "Relevant Docker service status or logs"), + additionalContext: commonContextFlag, + noBrowser: noBrowserFlag, +} as const; + +const featureFlags = { + existingIssues: Flag.boolean("existing-issues").pipe( + Flag.withDescription("Prefill the existing issues checklist"), + ), + area: optionalTextFlag("area", "Affected CLI area"), + problem: optionalTextFlag("problem", "Problem the feature should solve"), + proposedSolution: optionalTextFlag("proposed-solution", "Proposed solution"), + alternatives: optionalTextFlag("alternatives", "Alternatives considered"), + additionalContext: commonContextFlag, + noBrowser: noBrowserFlag, +} as const; + +const docsFlags = { + link: optionalTextFlag("link", "Relevant documentation link"), + issueType: optionalTextFlag("issue-type", "Documentation issue type"), + problem: optionalTextFlag("problem", "What is confusing, missing, or incorrect"), + improvement: optionalTextFlag("improvement", "Suggested documentation improvement"), + additionalContext: commonContextFlag, + noBrowser: noBrowserFlag, +} as const; + +export type BugIssueFlags = CliCommand.Command.Config.Infer; +export type FeatureIssueFlags = CliCommand.Command.Config.Infer; +export type DocsIssueFlags = CliCommand.Command.Config.Infer; + +const bugIssueCommand = Command.make("bug", bugFlags).pipe( + Command.withDescription("Open a GitHub bug report with local CLI details prefilled."), + Command.withShortDescription("Open a bug report"), + Command.withExamples([ + { + command: + 'supabase issue bug --command "supabase start" --actual-output "database failed to start"', + description: "Open a prefilled bug report for a failing command", + }, + { + command: 'supabase issue bug --ticket-id "abc123" --no-browser', + description: "Print a prefilled issue URL for a crash report", + }, + ]), + Command.withHandler((flags) => + openBugIssue(flags).pipe(withCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(commandRuntimeLayer(["issue", "bug"])), + Command.provide(browserLayer), +); + +const featureIssueCommand = Command.make("feature", featureFlags).pipe( + Command.withDescription("Open a GitHub feature request with useful context prefilled."), + Command.withShortDescription("Open a feature request"), + Command.withExamples([ + { + command: + 'supabase issue feature --problem "I need to rotate local secrets" --proposed-solution "Add a secrets rotate command"', + description: "Open a prefilled feature request", + }, + ]), + Command.withHandler((flags) => + openFeatureIssue(flags).pipe(withCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(commandRuntimeLayer(["issue", "feature"])), + Command.provide(browserLayer), +); + +const docsIssueCommand = Command.make("docs", docsFlags).pipe( + Command.withDescription("Open a GitHub documentation issue with useful context prefilled."), + Command.withShortDescription("Open a documentation issue"), + Command.withExamples([ + { + command: + 'supabase issue docs --link "https://supabase.com/docs/guides/cli" --problem "The flag description is outdated"', + description: "Open a prefilled documentation issue", + }, + ]), + Command.withHandler((flags) => + openDocsIssue(flags).pipe(withCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(commandRuntimeLayer(["issue", "docs"])), + Command.provide(browserLayer), +); + +export const issueCommand = Command.make("issue").pipe( + Command.withDescription("Open Supabase CLI GitHub issue forms."), + Command.withShortDescription("Open GitHub issue forms"), + Command.withSubcommands([bugIssueCommand, featureIssueCommand, docsIssueCommand]), +); diff --git a/apps/cli/src/next/commands/issue/issue.handler.ts b/apps/cli/src/next/commands/issue/issue.handler.ts new file mode 100644 index 0000000000..56eed39006 --- /dev/null +++ b/apps/cli/src/next/commands/issue/issue.handler.ts @@ -0,0 +1,80 @@ +import { Effect } from "effect"; +import { Browser } from "../../../shared/runtime/browser.service.ts"; +import { Output } from "../../../shared/output/output.service.ts"; +import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; +import { + buildIssueUrl, + inferIssueInstallMethod, + issueTemplateContract, + readIssueFlagValue, + searchedExistingIssuesValue, +} from "../../../shared/issue/issue-url.ts"; +import type { BugIssueFlags, DocsIssueFlags, FeatureIssueFlags } from "./issue.command.ts"; + +const openIssueUrl = Effect.fnUntraced(function* (url: string, noBrowser: boolean) { + const output = yield* Output; + yield* output.raw(`${url}\n`); + if (!noBrowser) { + const browser = yield* Browser; + yield* browser.open(url); + yield* output.success("Opened GitHub issue form.", { url }); + } else { + yield* output.info("GitHub issue form URL:"); + } +}); + +export const openBugIssue = Effect.fn("issue.bug")(function* (flags: BugIssueFlags) { + const runtimeInfo = yield* RuntimeInfo; + const telemetryRuntime = yield* TelemetryRuntime; + + const url = buildIssueUrl({ + template: issueTemplateContract.bug.template, + fields: { + "affected-area": readIssueFlagValue(flags.area), + "cli-version": telemetryRuntime.cliVersion, + os: `${runtimeInfo.platform} ${runtimeInfo.arch}`, + "install-method": inferIssueInstallMethod(runtimeInfo), + command: readIssueFlagValue(flags.command), + "actual-output": readIssueFlagValue(flags.actualOutput), + "expected-behavior": readIssueFlagValue(flags.expectedBehavior), + reproduce: readIssueFlagValue(flags.reproduce), + "ticket-id": readIssueFlagValue(flags.ticketId), + "docker-services": readIssueFlagValue(flags.dockerServices), + "additional-context": readIssueFlagValue(flags.additionalContext), + }, + }); + + yield* openIssueUrl(url, flags.noBrowser); +}); + +export const openFeatureIssue = Effect.fn("issue.feature")(function* (flags: FeatureIssueFlags) { + const url = buildIssueUrl({ + template: issueTemplateContract.feature.template, + fields: { + "existing-issues": flags.existingIssues ? searchedExistingIssuesValue : undefined, + "affected-area": readIssueFlagValue(flags.area), + problem: readIssueFlagValue(flags.problem), + "proposed-solution": readIssueFlagValue(flags.proposedSolution), + alternatives: readIssueFlagValue(flags.alternatives), + "additional-context": readIssueFlagValue(flags.additionalContext), + }, + }); + + yield* openIssueUrl(url, flags.noBrowser); +}); + +export const openDocsIssue = Effect.fn("issue.docs")(function* (flags: DocsIssueFlags) { + const url = buildIssueUrl({ + template: issueTemplateContract.docs.template, + fields: { + link: readIssueFlagValue(flags.link), + "issue-type": readIssueFlagValue(flags.issueType), + problem: readIssueFlagValue(flags.problem), + improvement: readIssueFlagValue(flags.improvement), + "additional-context": readIssueFlagValue(flags.additionalContext), + }, + }); + + yield* openIssueUrl(url, flags.noBrowser); +}); diff --git a/apps/cli/src/next/commands/issue/issue.integration.test.ts b/apps/cli/src/next/commands/issue/issue.integration.test.ts new file mode 100644 index 0000000000..063529b76e --- /dev/null +++ b/apps/cli/src/next/commands/issue/issue.integration.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; +import { Output } from "../../../shared/output/output.service.ts"; +import type { OutputFormat } from "../../../shared/output/types.ts"; +import { Browser } from "../../../shared/runtime/browser.service.ts"; +import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; +import { buildIssueUrl } from "../../../shared/issue/issue-url.ts"; +import { openBugIssue, openDocsIssue, openFeatureIssue } from "./issue.handler.ts"; + +type OutputMessage = { + readonly type: "info" | "success"; + readonly message: string; + readonly data?: Record; +}; + +function processEnvLayer(values: Readonly> = {}) { + return Layer.effectDiscard( + Effect.acquireRelease( + Effect.sync(() => { + const snapshot = { ...process.env }; + for (const key of Object.keys(process.env)) { + delete process.env[key]; + } + for (const [key, value] of Object.entries(values)) { + if (value !== undefined) process.env[key] = value; + } + return snapshot; + }), + (snapshot) => + Effect.sync(() => { + for (const key of Object.keys(process.env)) { + delete process.env[key]; + } + for (const [key, value] of Object.entries(snapshot)) { + if (value !== undefined) process.env[key] = value; + } + }), + ), + ); +} + +function mockOutput(opts: { readonly format?: OutputFormat } = {}) { + const messages: OutputMessage[] = []; + const rawChunks: string[] = []; + return { + layer: Layer.succeed(Output, { + format: opts.format ?? "text", + interactive: true, + intro: () => Effect.void, + outro: () => Effect.void, + info: (message: string) => + Effect.sync(() => { + messages.push({ type: "info", message }); + }), + warn: () => Effect.void, + error: () => Effect.void, + event: () => Effect.void, + task: () => + Effect.succeed({ + message: () => Effect.void, + succeed: () => Effect.void, + fail: () => Effect.void, + info: () => Effect.void, + cancel: () => Effect.void, + clear: () => Effect.void, + }), + promptText: () => Effect.succeed(""), + promptPassword: () => Effect.succeed(""), + promptConfirm: () => Effect.succeed(true), + promptSelect: (_message, options) => Effect.succeed(options[0]!.value), + promptMultiSelect: (_message, options) => + Effect.succeed(options.map((option) => option.value)), + progress: () => + Effect.succeed({ + start: () => Effect.void, + advance: () => Effect.void, + message: () => Effect.void, + stop: () => Effect.void, + }), + success: (message: string, data?: Record) => + Effect.sync(() => { + messages.push({ type: "success", message, data }); + }), + fail: () => Effect.void, + raw: (text: string) => + Effect.sync(() => { + rawChunks.push(text); + }), + }), + messages, + get stdoutText() { + return rawChunks.join(""); + }, + }; +} + +function captureBrowser() { + const openedUrls: string[] = []; + return { + layer: Layer.succeed(Browser, { + open: (url: string) => + Effect.sync(() => { + openedUrls.push(url); + }), + }), + openedUrls, + }; +} + +function issueParams(url: string) { + return new URL(url).searchParams; +} + +function setup( + opts: { + readonly env?: Record; + readonly execPath?: string; + } = {}, +) { + const out = mockOutput(); + const browser = captureBrowser(); + const runtimeInfo = Layer.succeed(RuntimeInfo, { + cwd: "/test/project", + platform: "darwin", + arch: "arm64", + homeDir: "/test/home", + execPath: opts.execPath ?? "/opt/homebrew/bin/supabase", + pid: 1234, + }); + const telemetryRuntime = Layer.succeed( + TelemetryRuntime, + TelemetryRuntime.of({ + configDir: "/test/config", + tracesDir: "/test/config/traces", + consent: "granted", + showDebug: false, + deviceId: "device-id", + sessionId: "session-id", + isFirstRun: false, + isTty: true, + isCi: false, + os: "darwin", + arch: "arm64", + cliVersion: "1.2.3-test", + }), + ); + const layer = Layer.mergeAll( + out.layer, + browser.layer, + runtimeInfo, + telemetryRuntime, + processEnvLayer(opts.env ?? {}), + ); + return { layer, out, browser }; +} + +describe("issue", () => { + it.live("opens bug form with runtime fields and user-provided context", () => { + const { layer, out, browser } = setup(); + + return Effect.gen(function* () { + yield* openBugIssue({ + area: Option.some("Local development"), + command: Option.some("supabase start"), + actualOutput: Option.some("database failed to start"), + expectedBehavior: Option.none(), + reproduce: Option.some("Run supabase start in a fresh project"), + ticketId: Option.some("event-123"), + dockerServices: Option.none(), + additionalContext: Option.none(), + noBrowser: false, + }); + + expect(browser.openedUrls).toHaveLength(1); + const params = issueParams(browser.openedUrls[0]!); + expect(params.get("template")).toBe("bug-report.yml"); + expect(params.get("affected-area")).toBe("Local development"); + expect(params.get("cli-version")).toBe("1.2.3-test"); + expect(params.get("os")).toBe("darwin arm64"); + expect(params.get("install-method")).toBe("brew"); + expect(params.get("command")).toBe("supabase start"); + expect(params.get("actual-output")).toBe("database failed to start"); + expect(params.get("reproduce")).toBe("Run supabase start in a fresh project"); + expect(params.get("ticket-id")).toBe("event-123"); + expect(out.stdoutText).toBe(`${browser.openedUrls[0]}\n`); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "success", message: "Opened GitHub issue form." }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("prints the bug URL without opening a browser when requested", () => { + const { layer, out, browser } = setup({ env: { SUPABASE_INSTALL_METHOD: "asdf" } }); + + return Effect.gen(function* () { + yield* openBugIssue({ + area: Option.none(), + command: Option.none(), + actualOutput: Option.none(), + expectedBehavior: Option.none(), + reproduce: Option.none(), + ticketId: Option.none(), + dockerServices: Option.none(), + additionalContext: Option.none(), + noBrowser: true, + }); + + expect(browser.openedUrls).toEqual([]); + const params = issueParams(out.stdoutText.trim()); + expect(params.get("install-method")).toBe("Other"); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "info", message: "GitHub issue form URL:" }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("opens feature form with matching issue form field IDs", () => { + const { layer, browser } = setup(); + + return Effect.gen(function* () { + yield* openFeatureIssue({ + existingIssues: true, + area: Option.some("Auth"), + problem: Option.some("I need to rotate credentials"), + proposedSolution: Option.some("Add supabase secrets rotate"), + alternatives: Option.some("Manual dashboard workflow"), + additionalContext: Option.none(), + noBrowser: false, + }); + + const params = issueParams(browser.openedUrls[0]!); + expect(params.get("template")).toBe("feature-request.yml"); + expect(params.get("existing-issues")).toBe("I have searched the existing issues."); + expect(params.get("affected-area")).toBe("Auth"); + expect(params.get("problem")).toBe("I need to rotate credentials"); + expect(params.get("proposed-solution")).toBe("Add supabase secrets rotate"); + expect(params.get("alternatives")).toBe("Manual dashboard workflow"); + }).pipe(Effect.provide(layer)); + }); + + it.live("opens docs form with matching issue form field IDs", () => { + const { layer, browser } = setup(); + + return Effect.gen(function* () { + yield* openDocsIssue({ + link: Option.some("https://supabase.com/docs/guides/cli"), + issueType: Option.some("Incorrect docs"), + problem: Option.some("The output example is stale"), + improvement: Option.some("Update the output block"), + additionalContext: Option.some("Reported after testing v1.2.3"), + noBrowser: false, + }); + + const params = issueParams(browser.openedUrls[0]!); + expect(params.get("template")).toBe("docs.yml"); + expect(params.get("link")).toBe("https://supabase.com/docs/guides/cli"); + expect(params.get("issue-type")).toBe("Incorrect docs"); + expect(params.get("problem")).toBe("The output example is stale"); + expect(params.get("improvement")).toBe("Update the output block"); + expect(params.get("additional-context")).toBe("Reported after testing v1.2.3"); + }).pipe(Effect.provide(layer)); + }); + + it("truncates long fields before encoding the issue URL", () => { + const longOutput = "x".repeat(2_000); + const params = issueParams( + buildIssueUrl({ + template: "bug-report.yml", + fields: { + "actual-output": longOutput, + }, + }), + ); + + const actualOutput = params.get("actual-output"); + expect(actualOutput).toHaveLength(1_500); + expect(actualOutput?.endsWith("[truncated by Supabase CLI]")).toBe(true); + }); +}); diff --git a/apps/cli/src/shared/issue/issue-template-contract.unit.test.ts b/apps/cli/src/shared/issue/issue-template-contract.unit.test.ts new file mode 100644 index 0000000000..8a1c82ce78 --- /dev/null +++ b/apps/cli/src/shared/issue/issue-template-contract.unit.test.ts @@ -0,0 +1,158 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; +import { + buildIssueUrl, + inferIssueInstallMethod, + issueInstallMethodValues, + issueTemplateContract, +} from "./issue-url.ts"; + +type IssueFormOption = + | string + | { + readonly label?: unknown; + readonly required?: unknown; + }; + +type IssueFormBodyItem = { + readonly id?: unknown; + readonly validations?: { + readonly required?: unknown; + }; + readonly attributes?: { + readonly options?: unknown; + }; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isBodyItem(value: unknown): value is IssueFormBodyItem { + return isRecord(value); +} + +function issueTemplateDir() { + return resolve(process.cwd(), "../../.github/ISSUE_TEMPLATE"); +} + +function readTemplate(template: string): ReadonlyArray { + const path = resolve(issueTemplateDir(), template); + const parsed = parse(readFileSync(path, "utf8")); + if (!isRecord(parsed) || !Array.isArray(parsed.body)) return []; + return parsed.body.filter(isBodyItem); +} + +function fieldIds(body: ReadonlyArray) { + return body.flatMap((item) => (typeof item.id === "string" ? [item.id] : [])); +} + +function optionLabels(item: IssueFormBodyItem) { + const options = item.attributes?.options; + if (!Array.isArray(options)) return []; + return options.flatMap((option: IssueFormOption) => { + if (typeof option === "string") return [option]; + if (typeof option.label === "string") return [option.label]; + return []; + }); +} + +function requiredFields(body: ReadonlyArray) { + return body.flatMap((item) => { + if (item.validations?.required === true && typeof item.id === "string") { + return [item.id]; + } + + const options = item.attributes?.options; + if (!Array.isArray(options) || typeof item.id !== "string") return []; + return options.flatMap((option: IssueFormOption) => { + if (typeof option === "string") return []; + return option.required === true ? [`${item.id}:${String(option.label)}`] : []; + }); + }); +} + +describe("issue template contract", () => { + it("points to issue form templates that exist", () => { + for (const form of Object.values(issueTemplateContract)) { + expect(existsSync(resolve(issueTemplateDir(), form.template))).toBe(true); + } + }); + + it("keeps issue command field ids aligned with the GitHub issue forms", () => { + for (const form of Object.values(issueTemplateContract)) { + const ids = fieldIds(readTemplate(form.template)); + expect(ids).toEqual(expect.arrayContaining([...form.fields])); + expect(form.fields).toEqual(expect.arrayContaining(ids)); + } + }); + + it("keeps issue command prefilled option values valid for their fields", () => { + for (const form of Object.values(issueTemplateContract)) { + const body = readTemplate(form.template); + for (const [fieldId, values] of Object.entries(form.optionValues)) { + const item = body.find((entry) => entry.id === fieldId); + expect(item, `${form.template} should include field ${fieldId}`).toBeDefined(); + expect(optionLabels(item!)).toEqual(expect.arrayContaining([...values])); + } + } + }); + + it("keeps inferred install methods compatible with the template dropdown", () => { + const originalUserAgent = process.env["npm_config_user_agent"]; + const originalInstallMethod = process.env["SUPABASE_INSTALL_METHOD"]; + const cases = [ + { userAgent: "pnpm/10.0.0", execPath: "/usr/local/bin/supabase", expected: "pnpm" }, + { userAgent: "npm/11.0.0", execPath: "/usr/local/bin/supabase", expected: "npm" }, + { userAgent: "yarn/4.0.0", execPath: "/usr/local/bin/supabase", expected: "yarn" }, + { userAgent: "bun/1.2.0", execPath: "/usr/local/bin/supabase", expected: "bun" }, + { userAgent: undefined, execPath: "/opt/homebrew/bin/supabase", expected: "brew" }, + { userAgent: undefined, execPath: "/usr/local/bin/supabase", expected: "Other" }, + ] as const; + + try { + delete process.env["SUPABASE_INSTALL_METHOD"]; + for (const testcase of cases) { + if (testcase.userAgent === undefined) { + delete process.env["npm_config_user_agent"]; + } else { + process.env["npm_config_user_agent"] = testcase.userAgent; + } + const value = inferIssueInstallMethod({ execPath: testcase.execPath }); + expect(value).toBe(testcase.expected); + expect(issueInstallMethodValues).toContain(value); + } + + process.env["SUPABASE_INSTALL_METHOD"] = "Docker image"; + expect(inferIssueInstallMethod({ execPath: "/usr/local/bin/supabase" })).toBe("Docker image"); + + process.env["SUPABASE_INSTALL_METHOD"] = "asdf"; + expect(inferIssueInstallMethod({ execPath: "/usr/local/bin/supabase" })).toBe("Other"); + } finally { + if (originalUserAgent === undefined) delete process.env["npm_config_user_agent"]; + else process.env["npm_config_user_agent"] = originalUserAgent; + if (originalInstallMethod === undefined) delete process.env["SUPABASE_INSTALL_METHOD"]; + else process.env["SUPABASE_INSTALL_METHOD"] = originalInstallMethod; + } + }); + + it("keeps generated issue URLs under the browser-friendly limit", () => { + const longField = "x".repeat(4_000); + const url = buildIssueUrl({ + template: "bug-report.yml", + fields: Object.fromEntries( + issueTemplateContract.bug.fields.map((field) => [field, longField]), + ), + }); + + expect(url.length).toBeLessThanOrEqual(8_000); + }); + + it("keeps issue form required fields aligned with the command contract", () => { + for (const form of Object.values(issueTemplateContract)) { + expect(requiredFields(readTemplate(form.template))).toEqual([...form.requiredFields]); + } + }); +}); diff --git a/apps/cli/src/shared/issue/issue-url.ts b/apps/cli/src/shared/issue/issue-url.ts new file mode 100644 index 0000000000..2c24d5ca6c --- /dev/null +++ b/apps/cli/src/shared/issue/issue-url.ts @@ -0,0 +1,152 @@ +import { Option } from "effect"; + +const ISSUE_NEW_URL = "https://github.com/supabase/cli/issues/new"; +const MAX_FIELD_LENGTH = 1_500; +const MAX_URL_LENGTH = 8_000; +const TRUNCATED_SUFFIX = "\n\n[truncated by Supabase CLI]"; + +export const searchedExistingIssuesValue = "I have searched the existing issues."; +export const issueInstallMethodValues = [ + "brew", + "bun", + "npm", + "pnpm", + "yarn", + "Docker image", + "GitHub release binary", + "Other", +] as const; + +const issueInstallMethodValueSet = new Set(issueInstallMethodValues); + +export const issueTemplateContract = { + bug: { + template: "bug-report.yml", + fields: [ + "affected-area", + "cli-version", + "os", + "install-method", + "command", + "actual-output", + "expected-behavior", + "reproduce", + "ticket-id", + "docker-services", + "additional-context", + ], + requiredFields: [ + "affected-area", + "cli-version", + "os", + "command", + "actual-output", + "expected-behavior", + "reproduce", + ], + optionValues: { + "install-method": issueInstallMethodValues, + }, + }, + feature: { + template: "feature-request.yml", + fields: [ + "existing-issues", + "affected-area", + "problem", + "proposed-solution", + "alternatives", + "additional-context", + ], + requiredFields: ["affected-area", "problem", "proposed-solution"], + optionValues: { + "existing-issues": [searchedExistingIssuesValue], + }, + }, + docs: { + template: "docs.yml", + fields: ["link", "issue-type", "problem", "improvement", "additional-context"], + requiredFields: ["issue-type", "problem", "improvement"], + optionValues: {}, + }, +} as const; + +type IssueTemplate = "bug-report.yml" | "feature-request.yml" | "docs.yml"; + +export type IssueUrlInput = { + readonly template: IssueTemplate; + readonly fields: Readonly>; +}; + +export function readIssueFlagValue(value: Option.Option): string | undefined { + if (Option.isNone(value)) return undefined; + const trimmed = value.value.trim(); + return trimmed === "" ? undefined : trimmed; +} + +function truncateField(value: string, maxLength = MAX_FIELD_LENGTH): string { + if (value.length <= maxLength) return value; + if (maxLength <= TRUNCATED_SUFFIX.length) return value.slice(0, maxLength); + return `${value.slice(0, maxLength - TRUNCATED_SUFFIX.length)}${TRUNCATED_SUFFIX}`; +} + +function issueUrl(params: URLSearchParams): string { + return `${ISSUE_NEW_URL}?${params.toString()}`; +} + +function appendField(params: URLSearchParams, id: string, value: string | undefined) { + if (value === undefined) return; + params.set(id, truncateField(value)); + if (issueUrl(params).length <= MAX_URL_LENGTH) return; + + let bestFit: string | undefined; + let lower = 0; + let upper = Math.min(value.length, MAX_FIELD_LENGTH); + while (lower <= upper) { + const midpoint = Math.floor((lower + upper) / 2); + const candidate = truncateField(value, midpoint); + params.set(id, candidate); + if (issueUrl(params).length <= MAX_URL_LENGTH) { + bestFit = candidate; + lower = midpoint + 1; + } else { + upper = midpoint - 1; + } + } + + if (bestFit === undefined) { + params.delete(id); + } else { + params.set(id, bestFit); + } +} + +export function buildIssueUrl(input: IssueUrlInput): string { + const params = new URLSearchParams(); + params.set("template", input.template); + for (const [id, value] of Object.entries(input.fields)) { + appendField(params, id, value); + } + return issueUrl(params); +} + +function validInstallMethod(value: string): string { + return issueInstallMethodValueSet.has(value) ? value : "Other"; +} + +export function inferIssueInstallMethod(runtimeInfo: { readonly execPath: string }): string { + const explicit = process.env["SUPABASE_INSTALL_METHOD"]?.trim(); + if (explicit) return validInstallMethod(explicit); + + const userAgent = process.env["npm_config_user_agent"]?.toLowerCase(); + if (userAgent?.startsWith("pnpm/")) return "pnpm"; + if (userAgent?.startsWith("npm/")) return "npm"; + if (userAgent?.startsWith("yarn/")) return "yarn"; + if (userAgent?.startsWith("bun/")) return "bun"; + + const execPath = runtimeInfo.execPath.toLowerCase(); + if (execPath.includes("homebrew") || execPath.includes("/cellar/")) return "brew"; + if (execPath.includes("/node_modules/") || execPath.includes("\\node_modules\\")) return "npm"; + + return "Other"; +}