Skip to content

Commit 380530d

Browse files
anandgupta42claude
andauthored
feat: add /feedback command and feedback_submit tool (#89)
* feat: add `/feedback` command and `feedback_submit` tool (#86) - Add `feedback_submit` tool at `src/altimate/tools/feedback-submit.ts` with `try-catch` around all `Bun.$` calls (ENOENT safety) and buffer-based stdout/stderr capture for better error reporting - Add `/feedback` slash command with guided flow template - Register `FeedbackSubmitTool` in tool registry - Register feedback command in `command/index.ts` - Add 49 tests (tool + command integration) - Fix CI `pr-standards.yml`: change `ref: 'dev'` to `ref: 'main'` and wrap TEAM_MEMBERS lookup in `try-catch` for rebase resilience - Add `/feedback` to docs `commands.md` * fix: address code review findings in feedback-submit tool - Fix misleading error: `gh auth status` catch now returns `gh_auth_check_failed` instead of `gh_not_installed` (gh IS installed at that point) - Fix fragile version check: use `startsWith("gh version")` instead of `includes("not found")` for cross-platform correctness - Add `trim().min(1)` validation to `title` and `description` zod schemas to reject empty/whitespace-only inputs - Check `issueResult.exitCode !== 0` before stdout URL check on issue creation - Restore `Bun.$` in `afterAll` to prevent mock leaking across test files - Remove trailing `$ARGUMENTS` from end of feedback.txt template (duplicated from Step 1 pre-fill logic; could confuse model) - Add 8 new tests: empty/whitespace validation, `gh_auth_check_failed` path, non-zero exitCode with stdout scenario, and updated "not found" test name * test: fix Windows CI failures in install and bridge tests - Replace `/bin/echo` with `process.execPath` in bridge test — /bin/echo does not exist on Windows, causing ENOENT on spawn; process.execPath (the current Bun binary) exists on all platforms and exits quickly without speaking JSON-RPC as expected - Add `unixtest` guard to postinstall, bin-wrapper, and integration tests — on Windows, postinstall.mjs takes a different early-exit path that skips hard-link setup; dummy binaries are Unix shell scripts that cannot be executed on Windows; skip all Unix-specific test paths using the same `process.platform !== "win32" ? test : test.skip` pattern already used in fsmonitor.test.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f668a67 commit 380530d

12 files changed

Lines changed: 1143 additions & 37 deletions

File tree

.github/workflows/pr-standards.yml

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,20 @@ jobs:
2828
2929
// Check if author is a team member or bot
3030
if (login === 'opencode-agent[bot]') return;
31-
const { data: file } = await github.rest.repos.getContent({
32-
owner: context.repo.owner,
33-
repo: context.repo.repo,
34-
path: '.github/TEAM_MEMBERS',
35-
ref: 'main'
36-
});
37-
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
38-
if (members.includes(login)) {
39-
console.log(`Skipping: ${login} is a team member`);
40-
return;
31+
try {
32+
const { data: file } = await github.rest.repos.getContent({
33+
owner: context.repo.owner,
34+
repo: context.repo.repo,
35+
path: '.github/TEAM_MEMBERS',
36+
ref: 'main'
37+
});
38+
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
39+
if (members.includes(login)) {
40+
console.log(`Skipping: ${login} is a team member`);
41+
return;
42+
}
43+
} catch (e) {
44+
console.log('TEAM_MEMBERS file not found, skipping team member check');
4145
}
4246
4347
const title = pr.title;
@@ -175,16 +179,20 @@ jobs:
175179
176180
// Check if author is a team member or bot
177181
if (login === 'opencode-agent[bot]') return;
178-
const { data: file } = await github.rest.repos.getContent({
179-
owner: context.repo.owner,
180-
repo: context.repo.repo,
181-
path: '.github/TEAM_MEMBERS',
182-
ref: 'main'
183-
});
184-
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
185-
if (members.includes(login)) {
186-
console.log(`Skipping: ${login} is a team member`);
187-
return;
182+
try {
183+
const { data: file } = await github.rest.repos.getContent({
184+
owner: context.repo.owner,
185+
repo: context.repo.repo,
186+
path: '.github/TEAM_MEMBERS',
187+
ref: 'main'
188+
});
189+
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
190+
if (members.includes(login)) {
191+
console.log(`Skipping: ${login} is a team member`);
192+
return;
193+
}
194+
} catch (e) {
195+
console.log('TEAM_MEMBERS file not found, skipping team member check');
188196
}
189197
190198
const body = pr.body || '';

docs/docs/configure/commands.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22

33
## Built-in Commands
44

5-
altimate ships with three built-in slash commands:
5+
altimate ships with four built-in slash commands:
66

77
| Command | Description |
88
|---------|-------------|
99
| `/init` | Create or update an AGENTS.md file with build commands and code style guidelines. |
1010
| `/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. |
1111
| `/review` | Review changes — accepts `commit`, `branch`, or `pr` as an argument (defaults to uncommitted changes). |
12+
| `/feedback` | Submit product feedback as a GitHub issue. Guides you through title, category, description, and optional session context. |
1213

1314
### `/discover`
1415

@@ -30,6 +31,22 @@ The recommended way to set up a new data engineering project. Run `/discover` in
3031
/review pr # review the current pull request
3132
```
3233

34+
### `/feedback`
35+
36+
Submit product feedback directly from the CLI. The agent walks you through:
37+
38+
1. **Title** — a short summary of your feedback
39+
2. **Category** — bug, feature, improvement, or ux
40+
3. **Description** — detailed explanation
41+
4. **Session context** (opt-in) — includes working directory name and session ID for debugging
42+
43+
```
44+
/feedback # start the guided feedback flow
45+
/feedback dark mode support # pre-fill the description
46+
```
47+
48+
Requires the `gh` CLI to be installed and authenticated (`gh auth login`).
49+
3350
## Custom Commands
3451

3552
Custom commands let you define reusable slash commands.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import z from "zod"
2+
// Use Bun.$ (namespace access) instead of destructured $ to support test mocking
3+
import Bun from "bun"
4+
import os from "os"
5+
import path from "path"
6+
import { Tool } from "../../tool/tool"
7+
import { Installation } from "@/installation"
8+
9+
const CATEGORY_LABELS = {
10+
bug: "bug",
11+
feature: "enhancement",
12+
improvement: "improvement",
13+
ux: "ux",
14+
} satisfies Record<"bug" | "feature" | "improvement" | "ux", string>
15+
16+
export const FeedbackSubmitTool = Tool.define("feedback_submit", {
17+
description:
18+
"Submit user feedback as a GitHub issue to the altimate-code repository. " +
19+
"Creates an issue with appropriate labels and metadata. " +
20+
"Requires the `gh` CLI to be installed and authenticated.",
21+
parameters: z.object({
22+
title: z.string().trim().min(1).describe("A concise title for the feedback issue"),
23+
category: z
24+
.enum(["bug", "feature", "improvement", "ux"])
25+
.describe("The category of feedback: bug, feature, improvement, or ux"),
26+
description: z.string().trim().min(1).describe("Detailed description of the feedback"),
27+
include_context: z
28+
.boolean()
29+
.optional()
30+
.default(false)
31+
.describe("Whether to include session context (working directory basename, platform info) in the issue body"),
32+
}),
33+
async execute(args, ctx) {
34+
const ghNotInstalled = {
35+
title: "Feedback submission failed",
36+
metadata: { error: "gh_not_installed", issueUrl: "" },
37+
output:
38+
"The `gh` CLI is not installed. Please install it to submit feedback:\n" +
39+
" - macOS: `brew install gh`\n" +
40+
" - Linux: https://github.com/cli/cli/blob/trunk/docs/install_linux.md\n" +
41+
" - Windows: `winget install GitHub.cli`\n\n" +
42+
"Then authenticate with: `gh auth login`",
43+
}
44+
45+
// Check if gh CLI is available
46+
let ghVersion: string
47+
try {
48+
ghVersion = await Bun.$`gh --version`.quiet().nothrow().text()
49+
} catch {
50+
// ENOENT — gh binary not found on PATH
51+
return ghNotInstalled
52+
}
53+
if (!ghVersion.trim().startsWith("gh version")) {
54+
return ghNotInstalled
55+
}
56+
57+
// Check if authenticated
58+
let authStatus: { exitCode: number }
59+
try {
60+
authStatus = await Bun.$`gh auth status`.quiet().nothrow()
61+
} catch {
62+
return {
63+
title: "Feedback submission failed",
64+
metadata: { error: "gh_auth_check_failed", issueUrl: "" },
65+
output:
66+
"Failed to verify `gh` authentication status. Please check your installation with:\n" +
67+
" `gh auth status`",
68+
}
69+
}
70+
if (authStatus.exitCode !== 0) {
71+
return {
72+
title: "Feedback submission failed",
73+
metadata: { error: "gh_not_authenticated", issueUrl: "" },
74+
output:
75+
"The `gh` CLI is not authenticated. Please run:\n" +
76+
" `gh auth login`\n\n" +
77+
"Then try submitting feedback again.",
78+
}
79+
}
80+
81+
// Collect metadata
82+
const version = Installation.VERSION
83+
const platform = process.platform
84+
const arch = process.arch
85+
const osRelease = os.release()
86+
87+
// Build issue body
88+
let body = `${args.description}\n\n`
89+
body += `---\n\n`
90+
body += `### Metadata\n\n`
91+
body += `| Field | Value |\n`
92+
body += `|-------|-------|\n`
93+
body += `| CLI Version | ${version} |\n`
94+
body += `| Platform | ${platform} |\n`
95+
body += `| Architecture | ${arch} |\n`
96+
body += `| OS Release | ${osRelease} |\n`
97+
body += `| Category | ${args.category} |\n`
98+
99+
if (args.include_context) {
100+
const cwdBasename = path.basename(process.cwd()) || "unknown"
101+
body += `| Working Directory | ${cwdBasename} |\n`
102+
body += `| Session ID | ${ctx.sessionID} |\n`
103+
}
104+
105+
// Build labels
106+
const labels = ["user-feedback", "from-cli", CATEGORY_LABELS[args.category]]
107+
108+
// Create the issue
109+
let issueResult: { stdout: Buffer; stderr: Buffer; exitCode: number }
110+
try {
111+
issueResult = await Bun.$`gh issue create --repo AltimateAI/altimate-code --title ${args.title} --body ${body} --label ${labels.join(",")}`.quiet().nothrow()
112+
} catch {
113+
return {
114+
title: "Feedback submission failed",
115+
metadata: { error: "issue_creation_failed", issueUrl: "" },
116+
output: "Failed to create GitHub issue. The `gh` CLI encountered an unexpected error.\n\nPlease check your gh CLI installation and try again.",
117+
}
118+
}
119+
120+
const stdout = issueResult.stdout.toString().trim()
121+
const stderr = issueResult.stderr.toString().trim()
122+
123+
if (issueResult.exitCode !== 0 || !stdout || !stdout.includes("github.com")) {
124+
const errorDetail = stderr || stdout || "No output from gh CLI"
125+
return {
126+
title: "Feedback submission failed",
127+
metadata: { error: "issue_creation_failed", issueUrl: "" },
128+
output: `Failed to create GitHub issue.\n\n${errorDetail}\n\nPlease check your gh CLI authentication and try again.`,
129+
}
130+
}
131+
132+
return {
133+
title: "Feedback submitted",
134+
metadata: { error: "", issueUrl: stdout },
135+
output: `Feedback submitted successfully!\n\nIssue URL: ${stdout}`,
136+
}
137+
},
138+
})

packages/opencode/src/command/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Identifier } from "../id/id"
66
import PROMPT_INITIALIZE from "./template/initialize.txt"
77
import PROMPT_DISCOVER from "./template/discover.txt"
88
import PROMPT_REVIEW from "./template/review.txt"
9+
import PROMPT_FEEDBACK from "./template/feedback.txt"
910
import { MCP } from "../mcp"
1011
import { Skill } from "../skill"
1112
import { Log } from "../util/log"
@@ -57,6 +58,7 @@ export namespace Command {
5758
INIT: "init",
5859
DISCOVER: "discover",
5960
REVIEW: "review",
61+
FEEDBACK: "feedback",
6062
} as const
6163

6264
const state = Instance.state(async () => {
@@ -91,6 +93,15 @@ export namespace Command {
9193
subtask: true,
9294
hints: hints(PROMPT_REVIEW),
9395
},
96+
[Default.FEEDBACK]: {
97+
name: Default.FEEDBACK,
98+
description: "submit product feedback as a GitHub issue",
99+
source: "command",
100+
get template() {
101+
return PROMPT_FEEDBACK
102+
},
103+
hints: hints(PROMPT_FEEDBACK),
104+
},
94105
}
95106

96107
for (const [name, command] of Object.entries(cfg.command ?? {})) {
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
You are helping the user submit product feedback for altimate-code. Feedback is filed as a GitHub issue.
2+
3+
If $ARGUMENTS is provided, use it as the initial description and skip asking for a description. Still confirm the title and category before submitting.
4+
5+
Step 1 — Collect feedback details:
6+
7+
Ask the user for the following information. Collect each piece one at a time:
8+
9+
1. **Title**: A short summary of the feedback (one line).
10+
2. **Category**: Ask the user to pick one:
11+
- bug — Something is broken or not working as expected
12+
- feature — A new capability or feature request
13+
- improvement — An enhancement to existing functionality
14+
- ux — Feedback on usability, flow, or developer experience
15+
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.
16+
17+
Step 2 — Session context (opt-in):
18+
19+
Ask the user if they want to include session context with their feedback. Explain what this includes:
20+
- Working directory name (basename only, not the full path)
21+
- Session ID (for debugging correlation)
22+
- No code, credentials, or personal data is included
23+
24+
If they opt in, set `include_context` to true when submitting.
25+
26+
Step 3 — Confirm and submit:
27+
28+
Show a summary of the feedback before submitting:
29+
- **Title**: ...
30+
- **Category**: ...
31+
- **Description**: ...
32+
- **Session context**: included / not included
33+
34+
Ask the user to confirm. If they confirm, call the `feedback_submit` tool with:
35+
- `title`: the feedback title
36+
- `category`: the selected category
37+
- `description`: the full description
38+
- `include_context`: true or false
39+
40+
Step 4 — Show result:
41+
42+
After submission, display the created GitHub issue URL to the user so they can track it. Thank them for the feedback.

packages/opencode/src/tool/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ import { AltimateCoreParseDbtTool } from "../altimate/tools/altimate-core-parse-
100100
import { AltimateCoreIsSafeTool } from "../altimate/tools/altimate-core-is-safe"
101101
import { ProjectScanTool } from "../altimate/tools/project-scan"
102102
import { DatamateManagerTool } from "../altimate/tools/datamate"
103+
import { FeedbackSubmitTool } from "../altimate/tools/feedback-submit"
103104
// altimate_change end
104105

105106
export namespace ToolRegistry {
@@ -263,6 +264,7 @@ export namespace ToolRegistry {
263264
AltimateCoreIsSafeTool,
264265
ProjectScanTool,
265266
DatamateManagerTool,
267+
FeedbackSubmitTool,
266268
// altimate_change end
267269
...custom,
268270
]

packages/opencode/test/bridge/client.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,10 @@ describe("Bridge.start integration", () => {
140140
test("ensureEngine is called when bridge starts", async () => {
141141
const { Bridge } = await import("../../src/altimate/bridge/client")
142142

143-
// /bin/echo exists and will spawn successfully but won't respond to
144-
// the JSON-RPC ping, so start() will eventually fail on verification.
145-
process.env.OPENCODE_PYTHON = "/bin/echo"
143+
// process.execPath (the current Bun/Node binary) exists on all platforms.
144+
// When spawned as a Python replacement it exits quickly without speaking
145+
// JSON-RPC, so start() fails on the ping verification as expected.
146+
process.env.OPENCODE_PYTHON = process.execPath
146147

147148
try {
148149
await Bridge.call("ping", {} as any)

0 commit comments

Comments
 (0)