diff --git a/.github/workflows/pr-standards.yml b/.github/workflows/pr-standards.yml index 66534784ea..a721679f23 100644 --- a/.github/workflows/pr-standards.yml +++ b/.github/workflows/pr-standards.yml @@ -28,16 +28,20 @@ jobs: // Check if author is a team member or bot if (login === 'opencode-agent[bot]') return; - const { data: file } = await github.rest.repos.getContent({ - owner: context.repo.owner, - repo: context.repo.repo, - path: '.github/TEAM_MEMBERS', - ref: 'main' - }); - const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean); - if (members.includes(login)) { - console.log(`Skipping: ${login} is a team member`); - return; + try { + const { data: file } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: '.github/TEAM_MEMBERS', + ref: 'main' + }); + const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean); + if (members.includes(login)) { + console.log(`Skipping: ${login} is a team member`); + return; + } + } catch (e) { + console.log('TEAM_MEMBERS file not found, skipping team member check'); } const title = pr.title; @@ -175,16 +179,20 @@ jobs: // Check if author is a team member or bot if (login === 'opencode-agent[bot]') return; - const { data: file } = await github.rest.repos.getContent({ - owner: context.repo.owner, - repo: context.repo.repo, - path: '.github/TEAM_MEMBERS', - ref: 'main' - }); - const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean); - if (members.includes(login)) { - console.log(`Skipping: ${login} is a team member`); - return; + try { + const { data: file } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: '.github/TEAM_MEMBERS', + ref: 'main' + }); + const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean); + if (members.includes(login)) { + console.log(`Skipping: ${login} is a team member`); + return; + } + } catch (e) { + console.log('TEAM_MEMBERS file not found, skipping team member check'); } const body = pr.body || ''; diff --git a/docs/docs/configure/commands.md b/docs/docs/configure/commands.md index 94791e6f0a..d7b75aced5 100644 --- a/docs/docs/configure/commands.md +++ b/docs/docs/configure/commands.md @@ -2,13 +2,14 @@ ## Built-in Commands -altimate ships with three built-in slash commands: +altimate ships with four built-in slash commands: | Command | Description | |---------|-------------| | `/init` | Create or update an AGENTS.md file with build commands and code style guidelines. | | `/discover` | Scan your data stack and set up warehouse connections. Detects dbt projects, warehouse connections from profiles/Docker/env vars, installed tools, and config files. Walks you through adding and testing new connections, then indexes schemas. | | `/review` | Review changes — accepts `commit`, `branch`, or `pr` as an argument (defaults to uncommitted changes). | +| `/feedback` | Submit product feedback as a GitHub issue. Guides you through title, category, description, and optional session context. | ### `/discover` @@ -30,6 +31,22 @@ The recommended way to set up a new data engineering project. Run `/discover` in /review pr # review the current pull request ``` +### `/feedback` + +Submit product feedback directly from the CLI. The agent walks you through: + +1. **Title** — a short summary of your feedback +2. **Category** — bug, feature, improvement, or ux +3. **Description** — detailed explanation +4. **Session context** (opt-in) — includes working directory name and session ID for debugging + +``` +/feedback # start the guided feedback flow +/feedback dark mode support # pre-fill the description +``` + +Requires the `gh` CLI to be installed and authenticated (`gh auth login`). + ## Custom Commands Custom commands let you define reusable slash commands. diff --git a/packages/opencode/src/altimate/tools/feedback-submit.ts b/packages/opencode/src/altimate/tools/feedback-submit.ts new file mode 100644 index 0000000000..b5420371aa --- /dev/null +++ b/packages/opencode/src/altimate/tools/feedback-submit.ts @@ -0,0 +1,138 @@ +import z from "zod" +// Use Bun.$ (namespace access) instead of destructured $ to support test mocking +import Bun from "bun" +import os from "os" +import path from "path" +import { Tool } from "../../tool/tool" +import { Installation } from "@/installation" + +const CATEGORY_LABELS = { + bug: "bug", + feature: "enhancement", + improvement: "improvement", + ux: "ux", +} satisfies Record<"bug" | "feature" | "improvement" | "ux", string> + +export const FeedbackSubmitTool = Tool.define("feedback_submit", { + description: + "Submit user feedback as a GitHub issue to the altimate-code repository. " + + "Creates an issue with appropriate labels and metadata. " + + "Requires the `gh` CLI to be installed and authenticated.", + parameters: z.object({ + title: z.string().trim().min(1).describe("A concise title for the feedback issue"), + category: z + .enum(["bug", "feature", "improvement", "ux"]) + .describe("The category of feedback: bug, feature, improvement, or ux"), + description: z.string().trim().min(1).describe("Detailed description of the feedback"), + include_context: z + .boolean() + .optional() + .default(false) + .describe("Whether to include session context (working directory basename, platform info) in the issue body"), + }), + async execute(args, ctx) { + const ghNotInstalled = { + title: "Feedback submission failed", + metadata: { error: "gh_not_installed", issueUrl: "" }, + output: + "The `gh` CLI is not installed. Please install it to submit feedback:\n" + + " - macOS: `brew install gh`\n" + + " - Linux: https://github.com/cli/cli/blob/trunk/docs/install_linux.md\n" + + " - Windows: `winget install GitHub.cli`\n\n" + + "Then authenticate with: `gh auth login`", + } + + // Check if gh CLI is available + let ghVersion: string + try { + ghVersion = await Bun.$`gh --version`.quiet().nothrow().text() + } catch { + // ENOENT — gh binary not found on PATH + return ghNotInstalled + } + if (!ghVersion.trim().startsWith("gh version")) { + return ghNotInstalled + } + + // Check if authenticated + let authStatus: { exitCode: number } + try { + authStatus = await Bun.$`gh auth status`.quiet().nothrow() + } catch { + return { + title: "Feedback submission failed", + metadata: { error: "gh_auth_check_failed", issueUrl: "" }, + output: + "Failed to verify `gh` authentication status. Please check your installation with:\n" + + " `gh auth status`", + } + } + if (authStatus.exitCode !== 0) { + return { + title: "Feedback submission failed", + metadata: { error: "gh_not_authenticated", issueUrl: "" }, + output: + "The `gh` CLI is not authenticated. Please run:\n" + + " `gh auth login`\n\n" + + "Then try submitting feedback again.", + } + } + + // Collect metadata + const version = Installation.VERSION + const platform = process.platform + const arch = process.arch + const osRelease = os.release() + + // Build issue body + let body = `${args.description}\n\n` + body += `---\n\n` + body += `### Metadata\n\n` + body += `| Field | Value |\n` + body += `|-------|-------|\n` + body += `| CLI Version | ${version} |\n` + body += `| Platform | ${platform} |\n` + body += `| Architecture | ${arch} |\n` + body += `| OS Release | ${osRelease} |\n` + body += `| Category | ${args.category} |\n` + + if (args.include_context) { + const cwdBasename = path.basename(process.cwd()) || "unknown" + body += `| Working Directory | ${cwdBasename} |\n` + body += `| Session ID | ${ctx.sessionID} |\n` + } + + // Build labels + const labels = ["user-feedback", "from-cli", CATEGORY_LABELS[args.category]] + + // Create the issue + let issueResult: { stdout: Buffer; stderr: Buffer; exitCode: number } + try { + issueResult = await Bun.$`gh issue create --repo AltimateAI/altimate-code --title ${args.title} --body ${body} --label ${labels.join(",")}`.quiet().nothrow() + } catch { + return { + title: "Feedback submission failed", + metadata: { error: "issue_creation_failed", issueUrl: "" }, + output: "Failed to create GitHub issue. The `gh` CLI encountered an unexpected error.\n\nPlease check your gh CLI installation and try again.", + } + } + + const stdout = issueResult.stdout.toString().trim() + const stderr = issueResult.stderr.toString().trim() + + if (issueResult.exitCode !== 0 || !stdout || !stdout.includes("github.com")) { + const errorDetail = stderr || stdout || "No output from gh CLI" + return { + title: "Feedback submission failed", + metadata: { error: "issue_creation_failed", issueUrl: "" }, + output: `Failed to create GitHub issue.\n\n${errorDetail}\n\nPlease check your gh CLI authentication and try again.`, + } + } + + return { + title: "Feedback submitted", + metadata: { error: "", issueUrl: stdout }, + output: `Feedback submitted successfully!\n\nIssue URL: ${stdout}`, + } + }, +}) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 7c4647b75c..7b56220556 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -6,6 +6,7 @@ import { Identifier } from "../id/id" import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_DISCOVER from "./template/discover.txt" import PROMPT_REVIEW from "./template/review.txt" +import PROMPT_FEEDBACK from "./template/feedback.txt" import { MCP } from "../mcp" import { Skill } from "../skill" import { Log } from "../util/log" @@ -57,6 +58,7 @@ export namespace Command { INIT: "init", DISCOVER: "discover", REVIEW: "review", + FEEDBACK: "feedback", } as const const state = Instance.state(async () => { @@ -91,6 +93,15 @@ export namespace Command { subtask: true, hints: hints(PROMPT_REVIEW), }, + [Default.FEEDBACK]: { + name: Default.FEEDBACK, + description: "submit product feedback as a GitHub issue", + source: "command", + get template() { + return PROMPT_FEEDBACK + }, + hints: hints(PROMPT_FEEDBACK), + }, } for (const [name, command] of Object.entries(cfg.command ?? {})) { diff --git a/packages/opencode/src/command/template/feedback.txt b/packages/opencode/src/command/template/feedback.txt new file mode 100644 index 0000000000..e18b7bcdd8 --- /dev/null +++ b/packages/opencode/src/command/template/feedback.txt @@ -0,0 +1,42 @@ +You are helping the user submit product feedback for altimate-code. Feedback is filed as a GitHub issue. + +If $ARGUMENTS is provided, use it as the initial description and skip asking for a description. Still confirm the title and category before submitting. + +Step 1 — Collect feedback details: + +Ask the user for the following information. Collect each piece one at a time: + +1. **Title**: A short summary of the feedback (one line). +2. **Category**: Ask the user to pick one: + - bug — Something is broken or not working as expected + - feature — A new capability or feature request + - improvement — An enhancement to existing functionality + - ux — Feedback on usability, flow, or developer experience +3. **Description**: A detailed explanation of the feedback. If $ARGUMENTS was provided, present it back and ask if they want to add anything or if it looks good. + +Step 2 — Session context (opt-in): + +Ask the user if they want to include session context with their feedback. Explain what this includes: +- Working directory name (basename only, not the full path) +- Session ID (for debugging correlation) +- No code, credentials, or personal data is included + +If they opt in, set `include_context` to true when submitting. + +Step 3 — Confirm and submit: + +Show a summary of the feedback before submitting: +- **Title**: ... +- **Category**: ... +- **Description**: ... +- **Session context**: included / not included + +Ask the user to confirm. If they confirm, call the `feedback_submit` tool with: +- `title`: the feedback title +- `category`: the selected category +- `description`: the full description +- `include_context`: true or false + +Step 4 — Show result: + +After submission, display the created GitHub issue URL to the user so they can track it. Thank them for the feedback. diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 684ff4376e..473162a44c 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -100,6 +100,7 @@ import { AltimateCoreParseDbtTool } from "../altimate/tools/altimate-core-parse- import { AltimateCoreIsSafeTool } from "../altimate/tools/altimate-core-is-safe" import { ProjectScanTool } from "../altimate/tools/project-scan" import { DatamateManagerTool } from "../altimate/tools/datamate" +import { FeedbackSubmitTool } from "../altimate/tools/feedback-submit" // altimate_change end export namespace ToolRegistry { @@ -263,6 +264,7 @@ export namespace ToolRegistry { AltimateCoreIsSafeTool, ProjectScanTool, DatamateManagerTool, + FeedbackSubmitTool, // altimate_change end ...custom, ] diff --git a/packages/opencode/test/bridge/client.test.ts b/packages/opencode/test/bridge/client.test.ts index 3676711390..1c6973098e 100644 --- a/packages/opencode/test/bridge/client.test.ts +++ b/packages/opencode/test/bridge/client.test.ts @@ -140,9 +140,10 @@ describe("Bridge.start integration", () => { test("ensureEngine is called when bridge starts", async () => { const { Bridge } = await import("../../src/altimate/bridge/client") - // /bin/echo exists and will spawn successfully but won't respond to - // the JSON-RPC ping, so start() will eventually fail on verification. - process.env.OPENCODE_PYTHON = "/bin/echo" + // process.execPath (the current Bun/Node binary) exists on all platforms. + // When spawned as a Python replacement it exits quickly without speaking + // JSON-RPC, so start() fails on the ping verification as expected. + process.env.OPENCODE_PYTHON = process.execPath try { await Bridge.call("ping", {} as any) diff --git a/packages/opencode/test/command/feedback.test.ts b/packages/opencode/test/command/feedback.test.ts new file mode 100644 index 0000000000..6e307538ea --- /dev/null +++ b/packages/opencode/test/command/feedback.test.ts @@ -0,0 +1,126 @@ +import { describe, test, expect } from "bun:test" +import { Command } from "../../src/command/index" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +async function withInstance(fn: () => Promise) { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ directory: tmp.path, fn }) +} + +describe("/feedback command", () => { + describe("command registration", () => { + test("feedback is present in default commands", async () => { + await withInstance(async () => { + const commands = await Command.list() + const names = commands.map((c) => c.name) + expect(names).toContain("feedback") + }) + }) + + test("feedback command has correct metadata", async () => { + await withInstance(async () => { + const cmd = await Command.get("feedback") + expect(cmd).toBeDefined() + expect(cmd.name).toBe("feedback") + expect(cmd.source).toBe("command") + expect(cmd.description).toBe("submit product feedback as a GitHub issue") + }) + }) + + test("feedback is in Command.Default constants", () => { + expect(Command.Default.FEEDBACK).toBe("feedback") + }) + }) + + describe("template content", () => { + test("template references feedback_submit tool", async () => { + await withInstance(async () => { + const cmd = await Command.get("feedback") + const template = await cmd.template + expect(template).toContain("feedback_submit") + }) + }) + + test("template has $ARGUMENTS placeholder", async () => { + await withInstance(async () => { + const cmd = await Command.get("feedback") + expect(cmd.hints).toContain("$ARGUMENTS") + }) + }) + + test("template mentions all four categories", async () => { + await withInstance(async () => { + const cmd = await Command.get("feedback") + const template = await cmd.template + expect(template).toContain("bug") + expect(template).toContain("feature") + expect(template).toContain("improvement") + expect(template).toContain("ux") + }) + }) + + test("template describes the multi-step collection flow", async () => { + await withInstance(async () => { + const cmd = await Command.get("feedback") + const template = await cmd.template + // Should have steps for collecting feedback details + expect(template).toContain("Title") + expect(template).toContain("Category") + expect(template).toContain("Description") + }) + }) + + test("template mentions session context opt-in", async () => { + await withInstance(async () => { + const cmd = await Command.get("feedback") + const template = await cmd.template + expect(template).toContain("include_context") + expect(template).toContain("session context") + }) + }) + + test("template warns about not including credentials", async () => { + await withInstance(async () => { + const cmd = await Command.get("feedback") + const template = await cmd.template + expect(template).toContain("credentials") + }) + }) + + test("template includes confirmation step", async () => { + await withInstance(async () => { + const cmd = await Command.get("feedback") + const template = await cmd.template + expect(template).toContain("confirm") + }) + }) + }) + + describe("command isolation", () => { + test("feedback command is not a subtask", async () => { + await withInstance(async () => { + const cmd = await Command.get("feedback") + expect(cmd.subtask).toBeUndefined() + }) + }) + + test("feedback command has source 'command'", async () => { + await withInstance(async () => { + const cmd = await Command.get("feedback") + expect(cmd.source).toBe("command") + }) + }) + + test("feedback does not interfere with other default commands", async () => { + await withInstance(async () => { + const commands = await Command.list() + const names = commands.map((c) => c.name) + expect(names).toContain("init") + expect(names).toContain("discover") + expect(names).toContain("review") + expect(names).toContain("feedback") + }) + }) + }) +}) diff --git a/packages/opencode/test/install/bin-wrapper.test.ts b/packages/opencode/test/install/bin-wrapper.test.ts index 270ce57664..da31b19bc6 100644 --- a/packages/opencode/test/install/bin-wrapper.test.ts +++ b/packages/opencode/test/install/bin-wrapper.test.ts @@ -11,6 +11,10 @@ import { CURRENT_ARCH, } from "./fixture" +// Dummy binaries created by the fixture are Unix shell scripts (#!/bin/sh). +// Tests that run these binaries can only pass on Unix platforms. +const unixtest = process.platform !== "win32" ? test : test.skip + let cleanup: (() => void) | undefined afterEach(() => { @@ -27,7 +31,7 @@ function copyBinWrapper(destDir: string): string { } describe("bin/altimate-code wrapper", () => { - test("uses ALTIMATE_CODE_BIN_PATH env var when set", () => { + unixtest("uses ALTIMATE_CODE_BIN_PATH env var when set", () => { const { dir, cleanup: c } = installTmpdir() cleanup = c @@ -41,7 +45,7 @@ describe("bin/altimate-code wrapper", () => { expect(result.stdout).toContain("altimate-code-test-ok") }) - test("uses cached .opencode when present", () => { + unixtest("uses cached .opencode when present", () => { const { dir, cleanup: c } = installTmpdir() cleanup = c @@ -54,7 +58,7 @@ describe("bin/altimate-code wrapper", () => { expect(result.stdout).toContain("altimate-code-test-ok") }) - test("finds binary in sibling node_modules package", () => { + unixtest("finds binary in sibling node_modules package", () => { const { dir, cleanup: c } = installTmpdir() cleanup = c @@ -73,7 +77,7 @@ describe("bin/altimate-code wrapper", () => { expect(result.stdout).toContain("altimate-code-test-ok") }) - test("finds binary in parent node_modules (hoisted)", () => { + unixtest("finds binary in parent node_modules (hoisted)", () => { const { dir, cleanup: c } = installTmpdir() cleanup = c diff --git a/packages/opencode/test/install/integration.test.ts b/packages/opencode/test/install/integration.test.ts index f8c73bd7d6..9c07870dff 100644 --- a/packages/opencode/test/install/integration.test.ts +++ b/packages/opencode/test/install/integration.test.ts @@ -11,6 +11,11 @@ import { CURRENT_PLATFORM, } from "./fixture" +// These integration tests combine postinstall (Unix hard-link path) with +// bin-wrapper execution of a Unix shell-script dummy binary. Skip on Windows +// where both behave differently. +const unixtest = process.platform !== "win32" ? test : test.skip + let cleanup: (() => void) | undefined afterEach(() => { @@ -19,7 +24,7 @@ afterEach(() => { }) describe("install pipeline integration", () => { - test("full flow: layout -> postinstall -> bin wrapper executes dummy binary", () => { + unixtest("full flow: layout -> postinstall -> bin wrapper executes dummy binary", () => { const { dir, cleanup: c } = installTmpdir() cleanup = c @@ -44,7 +49,7 @@ describe("install pipeline integration", () => { expect(wrapperResult.stdout).toContain("altimate-code-test-ok") }) - test("missing optional dep: postinstall fails, bin wrapper also fails gracefully", () => { + unixtest("missing optional dep: postinstall fails, bin wrapper also fails gracefully", () => { const { dir, cleanup: c } = installTmpdir() cleanup = c @@ -70,7 +75,7 @@ describe("install pipeline integration", () => { expect(wrapperResult.stderr).toContain("package manager failed to install") }) - test("wrong-platform-only install: both scripts fail with clear errors", () => { + unixtest("wrong-platform-only install: both scripts fail with clear errors", () => { const { dir, cleanup: c } = installTmpdir() cleanup = c diff --git a/packages/opencode/test/install/postinstall.test.ts b/packages/opencode/test/install/postinstall.test.ts index 443a2a2919..b15a290303 100644 --- a/packages/opencode/test/install/postinstall.test.ts +++ b/packages/opencode/test/install/postinstall.test.ts @@ -9,6 +9,11 @@ import { CURRENT_PLATFORM, } from "./fixture" +// On Windows, postinstall.mjs takes a different early-exit path that skips +// binary setup entirely (the .exe is packaged separately). Skip all tests that +// depend on the Unix hard-link / error-path behavior. +const unixtest = process.platform !== "win32" ? test : test.skip + let cleanup: (() => void) | undefined afterEach(() => { @@ -17,7 +22,7 @@ afterEach(() => { }) describe("postinstall.mjs", () => { - test("finds binary and creates hard link in bin/", () => { + unixtest("finds binary and creates hard link in bin/", () => { const { dir, cleanup: c } = installTmpdir() cleanup = c @@ -34,7 +39,7 @@ describe("postinstall.mjs", () => { expect(stat.mode & 0o111).toBeGreaterThan(0) }) - test("replaces existing stale binary", () => { + unixtest("replaces existing stale binary", () => { const { dir, cleanup: c } = installTmpdir() cleanup = c @@ -54,7 +59,7 @@ describe("postinstall.mjs", () => { expect(content).toContain("altimate-code-test-ok") }) - test("creates bin/ dir if missing", () => { + unixtest("creates bin/ dir if missing", () => { const { dir, cleanup: c } = installTmpdir() cleanup = c @@ -84,7 +89,7 @@ describe("postinstall.mjs", () => { expect(result.stdout).toContain("altimate-code v2.5.0 installed") }) - test("exits 1 when platform binary package is missing", () => { + unixtest("exits 1 when platform binary package is missing", () => { const { dir, cleanup: c } = installTmpdir() cleanup = c @@ -96,7 +101,7 @@ describe("postinstall.mjs", () => { expect(result.stderr).toContain("Failed to setup altimate-code binary") }) - test("exits 1 when package exists but binary file is missing", () => { + unixtest("exits 1 when package exists but binary file is missing", () => { const { dir, cleanup: c } = installTmpdir() cleanup = c @@ -108,7 +113,7 @@ describe("postinstall.mjs", () => { expect(result.stderr).toContain("Failed to setup altimate-code binary") }) - test("exits 1 when only wrong-platform package is present", () => { + unixtest("exits 1 when only wrong-platform package is present", () => { const { dir, cleanup: c } = installTmpdir() cleanup = c diff --git a/packages/opencode/test/tool/feedback-submit.test.ts b/packages/opencode/test/tool/feedback-submit.test.ts new file mode 100644 index 0000000000..d2b2e7de14 --- /dev/null +++ b/packages/opencode/test/tool/feedback-submit.test.ts @@ -0,0 +1,747 @@ +import { describe, expect, test, beforeEach, afterAll } from "bun:test" +import Bun from "bun" + +// --------------------------------------------------------------------------- +// Mock state — controls what the mocked bun `$` returns per sequential call +// --------------------------------------------------------------------------- + +let shellResults: Array<{ + text: string + exitCode: number + throw?: boolean +}> = [] +let shellCallIndex = 0 +let shellCalls: string[][] = [] + +function resetShellMock() { + shellResults = [] + shellCallIndex = 0 + shellCalls = [] +} + +function pushShellResult(text: string, exitCode = 0) { + shellResults.push({ text, exitCode }) +} + +function pushShellThrow() { + shellResults.push({ text: "", exitCode: 1, throw: true }) +} + +// --------------------------------------------------------------------------- +// Replace Bun.$ with a controllable mock BEFORE importing the tool module. +// The tool does `import Bun from "bun"` — since Bun.$ is writable, +// replacing it here means dynamically imported modules will pick up our mock. +// --------------------------------------------------------------------------- + +const originalBunShell = Bun.$ +afterAll(() => { + Bun.$ = originalBunShell +}) + +Bun.$ = function mockedShell(strings: TemplateStringsArray, ...values: any[]) { + const parts: string[] = [] + strings.forEach((s, i) => { + parts.push(s) + if (i < values.length) parts.push(String(values[i])) + }) + shellCalls.push(parts) + + const idx = shellCallIndex++ + const result = shellResults[idx] || { text: "", exitCode: 1 } + + if (result.throw) { + throw new Error("ENOENT: spawn failed") + } + + const stdoutBuf = Buffer.from(result.text) + const stderrBuf = Buffer.from("") + + const chainable = { + quiet() { + return chainable + }, + nothrow() { + return chainable + }, + async text() { + return result.text + }, + exitCode: result.exitCode, + stdout: stdoutBuf, + stderr: stderrBuf, + } + return chainable +} as any + +// --------------------------------------------------------------------------- +// Import module under test — AFTER mock setup +// --------------------------------------------------------------------------- + +const { FeedbackSubmitTool } = await import("../../src/altimate/tools/feedback-submit") + +// --------------------------------------------------------------------------- +// Shared test context +// --------------------------------------------------------------------------- + +const ctx = { + sessionID: "test-session-123", + messageID: "test-message", + callID: "test-call", + agent: "test-agent", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("tool.feedback_submit", () => { + beforeEach(() => { + resetShellMock() + }) + + // ------------------------------------------------------------------------- + // Initialization + // ------------------------------------------------------------------------- + + describe("initialization", () => { + test("has correct tool id", () => { + expect(FeedbackSubmitTool.id).toBe("feedback_submit") + }) + + test("has correct description mentioning feedback and GitHub", async () => { + const tool = await FeedbackSubmitTool.init() + expect(tool.description).toContain("feedback") + expect(tool.description).toContain("GitHub issue") + expect(tool.description).toContain("gh") + }) + + test("has parameter schema defined", async () => { + const tool = await FeedbackSubmitTool.init() + expect(tool.parameters).toBeDefined() + }) + }) + + // ------------------------------------------------------------------------- + // Parameter validation + // ------------------------------------------------------------------------- + + describe("parameter validation", () => { + test("accepts valid parameters with all fields", async () => { + const tool = await FeedbackSubmitTool.init() + const result = tool.parameters.safeParse({ + title: "Test issue", + category: "bug", + description: "Something is broken", + include_context: true, + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.title).toBe("Test issue") + expect(result.data.category).toBe("bug") + expect(result.data.description).toBe("Something is broken") + expect(result.data.include_context).toBe(true) + } + }) + + test("defaults include_context to false when omitted", async () => { + const tool = await FeedbackSubmitTool.init() + const result = tool.parameters.safeParse({ + title: "Test", + category: "bug", + description: "test", + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.include_context).toBe(false) + } + }) + + test("rejects empty title", async () => { + const tool = await FeedbackSubmitTool.init() + const result = tool.parameters.safeParse({ + title: "", + category: "bug", + description: "test", + }) + expect(result.success).toBe(false) + }) + + test("rejects whitespace-only title", async () => { + const tool = await FeedbackSubmitTool.init() + const result = tool.parameters.safeParse({ + title: " ", + category: "bug", + description: "test", + }) + expect(result.success).toBe(false) + }) + + test("rejects empty description", async () => { + const tool = await FeedbackSubmitTool.init() + const result = tool.parameters.safeParse({ + title: "Test", + category: "bug", + description: "", + }) + expect(result.success).toBe(false) + }) + + test("rejects whitespace-only description", async () => { + const tool = await FeedbackSubmitTool.init() + const result = tool.parameters.safeParse({ + title: "Test", + category: "bug", + description: " ", + }) + expect(result.success).toBe(false) + }) + + test("rejects missing title", async () => { + const tool = await FeedbackSubmitTool.init() + const result = tool.parameters.safeParse({ + category: "bug", + description: "test", + }) + expect(result.success).toBe(false) + }) + + test("rejects missing category", async () => { + const tool = await FeedbackSubmitTool.init() + const result = tool.parameters.safeParse({ + title: "Test", + description: "test", + }) + expect(result.success).toBe(false) + }) + + test("rejects invalid category", async () => { + const tool = await FeedbackSubmitTool.init() + const result = tool.parameters.safeParse({ + title: "Test", + category: "invalid-category", + description: "test", + }) + expect(result.success).toBe(false) + }) + + test("rejects missing description", async () => { + const tool = await FeedbackSubmitTool.init() + const result = tool.parameters.safeParse({ + title: "Test", + category: "bug", + }) + expect(result.success).toBe(false) + }) + + test("rejects empty object", async () => { + const tool = await FeedbackSubmitTool.init() + const result = tool.parameters.safeParse({}) + expect(result.success).toBe(false) + }) + + test("accepts all valid categories", async () => { + const tool = await FeedbackSubmitTool.init() + for (const category of ["bug", "feature", "improvement", "ux"]) { + const result = tool.parameters.safeParse({ + title: "Test", + category, + description: "test", + }) + expect(result.success).toBe(true) + } + }) + }) + + // ------------------------------------------------------------------------- + // gh CLI not available + // ------------------------------------------------------------------------- + + describe("gh CLI not available", () => { + test("returns error with install instructions when gh is not found", async () => { + const tool = await FeedbackSubmitTool.init() + + // gh --version returns empty string (not found) + pushShellResult("") + + const result = await tool.execute( + { + title: "Test", + category: "bug" as const, + description: "test", + include_context: false, + }, + ctx, + ) + + expect(result.title).toBe("Feedback submission failed") + expect(result.metadata.error).toBe("gh_not_installed") + expect(result.metadata.issueUrl).toBe("") + expect(result.output).toContain("gh") + expect(result.output).toContain("brew install gh") + expect(result.output).toContain("gh auth login") + }) + + test("returns error when gh --version output does not start with 'gh version'", async () => { + const tool = await FeedbackSubmitTool.init() + + pushShellResult("command not found: gh") + + const result = await tool.execute( + { + title: "Test", + category: "bug" as const, + description: "test", + include_context: false, + }, + ctx, + ) + + expect(result.title).toBe("Feedback submission failed") + expect(result.metadata.error).toBe("gh_not_installed") + }) + + test("returns error when gh binary spawn throws ENOENT", async () => { + const tool = await FeedbackSubmitTool.init() + + // Simulate ENOENT — binary not on PATH + pushShellThrow() + + const result = await tool.execute( + { + title: "Test", + category: "bug" as const, + description: "test", + include_context: false, + }, + ctx, + ) + + expect(result.title).toBe("Feedback submission failed") + expect(result.metadata.error).toBe("gh_not_installed") + }) + }) + + // ------------------------------------------------------------------------- + // gh auth status throws + // ------------------------------------------------------------------------- + + describe("gh auth status throws", () => { + test("returns gh_auth_check_failed (not gh_not_installed) when auth check throws", async () => { + const tool = await FeedbackSubmitTool.init() + + // gh --version succeeds + pushShellResult("gh version 2.40.0") + // gh auth status throws + pushShellThrow() + + const result = await tool.execute( + { + title: "Test", + category: "bug" as const, + description: "test", + include_context: false, + }, + ctx, + ) + + expect(result.title).toBe("Feedback submission failed") + expect(result.metadata.error).toBe("gh_auth_check_failed") + expect(result.output).toContain("gh auth status") + }) + }) + + // ------------------------------------------------------------------------- + // gh CLI not authenticated + // ------------------------------------------------------------------------- + + describe("gh CLI not authenticated", () => { + test("returns error with auth instructions when not logged in", async () => { + const tool = await FeedbackSubmitTool.init() + + // gh --version succeeds + pushShellResult("gh version 2.40.0") + // gh auth status fails (exitCode !== 0) + pushShellResult("", 1) + + const result = await tool.execute( + { + title: "Test", + category: "bug" as const, + description: "test", + include_context: false, + }, + ctx, + ) + + expect(result.title).toBe("Feedback submission failed") + expect(result.metadata.error).toBe("gh_not_authenticated") + expect(result.metadata.issueUrl).toBe("") + expect(result.output).toContain("gh auth login") + }) + }) + + // ------------------------------------------------------------------------- + // Successful issue creation + // ------------------------------------------------------------------------- + + describe("successful issue creation", () => { + test("returns success with issue URL", async () => { + const tool = await FeedbackSubmitTool.init() + + // gh --version + pushShellResult("gh version 2.40.0") + // gh auth status — exitCode 0 + pushShellResult("Logged in as user", 0) + // gh issue create — returns URL + pushShellResult("https://github.com/AltimateAI/altimate-code/issues/42") + + const result = await tool.execute( + { + title: "Test feedback", + category: "bug" as const, + description: "Something is broken", + include_context: false, + }, + ctx, + ) + + expect(result.title).toBe("Feedback submitted") + expect(result.metadata.error).toBe("") + expect(result.metadata.issueUrl).toBe( + "https://github.com/AltimateAI/altimate-code/issues/42", + ) + expect(result.output).toContain("successfully") + expect(result.output).toContain( + "https://github.com/AltimateAI/altimate-code/issues/42", + ) + }) + + test("returns failure when issue creation output has no github URL", async () => { + const tool = await FeedbackSubmitTool.init() + + // gh --version + pushShellResult("gh version 2.40.0") + // gh auth status + pushShellResult("Logged in as user", 0) + // gh issue create fails with unexpected output + pushShellResult("some error occurred") + + const result = await tool.execute( + { + title: "Test", + category: "bug" as const, + description: "test", + include_context: false, + }, + ctx, + ) + + expect(result.title).toBe("Feedback submission failed") + expect(result.metadata.error).toBe("issue_creation_failed") + expect(result.output).toContain("Failed to create GitHub issue") + }) + + test("returns failure when issue creation returns empty output", async () => { + const tool = await FeedbackSubmitTool.init() + + pushShellResult("gh version 2.40.0") + pushShellResult("Logged in", 0) + pushShellResult("") + + const result = await tool.execute( + { + title: "Test", + category: "bug" as const, + description: "test", + include_context: false, + }, + ctx, + ) + + expect(result.title).toBe("Feedback submission failed") + expect(result.metadata.error).toBe("issue_creation_failed") + }) + + test("returns failure when issue creation has non-zero exitCode even with stdout", async () => { + const tool = await FeedbackSubmitTool.init() + + pushShellResult("gh version 2.40.0") + pushShellResult("Logged in", 0) + // gh issue create exits non-zero (e.g. label doesn't exist) with partial output + shellResults.push({ text: "https://github.com/AltimateAI/altimate-code/issues/1", exitCode: 1 }) + + const result = await tool.execute( + { + title: "Test", + category: "bug" as const, + description: "test", + include_context: false, + }, + ctx, + ) + + expect(result.title).toBe("Feedback submission failed") + expect(result.metadata.error).toBe("issue_creation_failed") + }) + + test("returns failure when issue creation throws", async () => { + const tool = await FeedbackSubmitTool.init() + + pushShellResult("gh version 2.40.0") + pushShellResult("Logged in", 0) + // gh issue create throws ENOENT + pushShellThrow() + + const result = await tool.execute( + { + title: "Test", + category: "bug" as const, + description: "test", + include_context: false, + }, + ctx, + ) + + expect(result.title).toBe("Feedback submission failed") + expect(result.metadata.error).toBe("issue_creation_failed") + }) + }) + + // ------------------------------------------------------------------------- + // Category label mapping + // ------------------------------------------------------------------------- + + describe("category label mapping", () => { + async function createIssueWithCategory(category: string) { + const tool = await FeedbackSubmitTool.init() + resetShellMock() + + pushShellResult("gh version 2.40.0") + pushShellResult("Logged in", 0) + pushShellResult("https://github.com/AltimateAI/altimate-code/issues/1") + + await tool.execute( + { + title: "Test", + category: category as any, + description: "test", + include_context: false, + }, + ctx, + ) + + // The third shell call is the gh issue create command + // shellCalls[2] contains the template string parts + interpolated values + return shellCalls[2] + } + + test("maps 'bug' to 'bug' label", async () => { + const callParts = await createIssueWithCategory("bug") + const joined = callParts.join("") + expect(joined).toContain("user-feedback,from-cli,bug") + }) + + test("maps 'feature' to 'enhancement' label", async () => { + const callParts = await createIssueWithCategory("feature") + const joined = callParts.join("") + expect(joined).toContain("user-feedback,from-cli,enhancement") + }) + + test("maps 'improvement' to 'improvement' label", async () => { + const callParts = await createIssueWithCategory("improvement") + const joined = callParts.join("") + expect(joined).toContain("user-feedback,from-cli,improvement") + }) + + test("maps 'ux' to 'ux' label", async () => { + const callParts = await createIssueWithCategory("ux") + const joined = callParts.join("") + expect(joined).toContain("user-feedback,from-cli,ux") + }) + }) + + // ------------------------------------------------------------------------- + // Metadata in issue body + // ------------------------------------------------------------------------- + + describe("metadata in issue body", () => { + async function getIssueBody(includeContext: boolean) { + const tool = await FeedbackSubmitTool.init() + resetShellMock() + + pushShellResult("gh version 2.40.0") + pushShellResult("Logged in", 0) + pushShellResult("https://github.com/AltimateAI/altimate-code/issues/1") + + await tool.execute( + { + title: "Test", + category: "bug" as const, + description: "My feedback description", + include_context: includeContext, + }, + ctx, + ) + + // The third call is gh issue create; join interpolated parts to get full command + const callParts = shellCalls[2] + return callParts.join("") + } + + test("includes CLI version in issue body", async () => { + const body = await getIssueBody(false) + expect(body).toContain("CLI Version") + }) + + test("includes platform info in issue body", async () => { + const body = await getIssueBody(false) + expect(body).toContain("Platform") + expect(body).toContain(process.platform) + }) + + test("includes architecture in issue body", async () => { + const body = await getIssueBody(false) + expect(body).toContain("Architecture") + expect(body).toContain(process.arch) + }) + + test("includes OS release in issue body", async () => { + const os = await import("os") + const body = await getIssueBody(false) + expect(body).toContain("OS Release") + expect(body).toContain(os.release()) + }) + + test("includes category in issue body", async () => { + const body = await getIssueBody(false) + expect(body).toContain("Category") + }) + + test("includes description text in issue body", async () => { + const body = await getIssueBody(false) + expect(body).toContain("My feedback description") + }) + + test("includes session context when include_context is true", async () => { + const body = await getIssueBody(true) + expect(body).toContain("Working Directory") + expect(body).toContain("Session ID") + expect(body).toContain("test-session-123") + }) + + test("excludes session context when include_context is false", async () => { + const body = await getIssueBody(false) + expect(body).not.toContain("Working Directory") + expect(body).not.toContain("Session ID") + }) + }) + + // ------------------------------------------------------------------------- + // Issue creation targets correct repo + // ------------------------------------------------------------------------- + + describe("issue creation", () => { + test("targets the AltimateAI/altimate-code repository", async () => { + const tool = await FeedbackSubmitTool.init() + + pushShellResult("gh version 2.40.0") + pushShellResult("Logged in", 0) + pushShellResult("https://github.com/AltimateAI/altimate-code/issues/1") + + await tool.execute( + { + title: "Test", + category: "bug" as const, + description: "test", + include_context: false, + }, + ctx, + ) + + const createCall = shellCalls[2].join("") + expect(createCall).toContain("AltimateAI/altimate-code") + }) + + test("passes the title to gh issue create", async () => { + const tool = await FeedbackSubmitTool.init() + + pushShellResult("gh version 2.40.0") + pushShellResult("Logged in", 0) + pushShellResult("https://github.com/AltimateAI/altimate-code/issues/1") + + await tool.execute( + { + title: "My specific title", + category: "feature" as const, + description: "test", + include_context: false, + }, + ctx, + ) + + const createCall = shellCalls[2].join("") + expect(createCall).toContain("My specific title") + }) + + test("makes exactly 3 shell calls for successful submission", async () => { + const tool = await FeedbackSubmitTool.init() + + pushShellResult("gh version 2.40.0") + pushShellResult("Logged in", 0) + pushShellResult("https://github.com/AltimateAI/altimate-code/issues/1") + + await tool.execute( + { + title: "Test", + category: "bug" as const, + description: "test", + include_context: false, + }, + ctx, + ) + + expect(shellCalls.length).toBe(3) + }) + + test("makes only 1 shell call when gh is not installed", async () => { + const tool = await FeedbackSubmitTool.init() + + pushShellResult("") + + await tool.execute( + { + title: "Test", + category: "bug" as const, + description: "test", + include_context: false, + }, + ctx, + ) + + expect(shellCalls.length).toBe(1) + }) + + test("makes only 2 shell calls when gh is not authenticated", async () => { + const tool = await FeedbackSubmitTool.init() + + pushShellResult("gh version 2.40.0") + pushShellResult("", 1) + + await tool.execute( + { + title: "Test", + category: "bug" as const, + description: "test", + include_context: false, + }, + ctx, + ) + + expect(shellCalls.length).toBe(2) + }) + }) +})