diff --git a/openspec/changes/add-feedback-command/proposal.md b/openspec/changes/add-feedback-command/proposal.md index cacd29ccb..c91a7ed0e 100644 --- a/openspec/changes/add-feedback-command/proposal.md +++ b/openspec/changes/add-feedback-command/proposal.md @@ -1,20 +1,20 @@ ## Why -Users and agents need a simple way to submit feedback about OpenSpec directly from the CLI. Currently there's no mechanism to collect user feedback, feature requests, or bug reports in a way that enables follow-up conversation. +Users and agents need a simple way to submit feedback about OpenSpec directly from the CLI. Currently there's no mechanism to collect user feedback, feature requests, or bug reports in a way that enables follow-up conversation. Using GitHub Issues allows us to track feedback, prevent spam via GitHub auth, and enables outreach to users. ## What Changes - Add `openspec feedback ` CLI command -- Add GitHub Device OAuth flow for user authentication -- Create GitHub Issues in the openspec repository for each feedback submission -- Add `/feedback` skill for agent-assisted feedback with context enrichment and anonymization +- Leverage `gh` CLI for GitHub authentication and issue creation +- Add `/feedback` skill for agent-assisted feedback with context enrichment +- Ensure cross-platform compatibility (macOS, Linux, Windows) ## Impact - Affected specs: New `cli-feedback` capability - Affected code: - `src/cli/index.ts` - Register feedback command - - `src/commands/feedback.ts` - Command implementation - - `src/auth/github.ts` - GitHub OAuth device flow + - `src/commands/feedback.ts` - Command implementation using `gh` CLI - `src/core/templates/skill-templates.ts` - Feedback skill template - `src/core/completions/command-registry.ts` - Shell completions +- External dependency: Requires `gh` CLI installed and authenticated diff --git a/openspec/changes/add-feedback-command/specs/cli-feedback/spec.md b/openspec/changes/add-feedback-command/specs/cli-feedback/spec.md index e57998e73..1137e85dc 100644 --- a/openspec/changes/add-feedback-command/specs/cli-feedback/spec.md +++ b/openspec/changes/add-feedback-command/specs/cli-feedback/spec.md @@ -2,91 +2,75 @@ ### Requirement: Feedback command -The system SHALL provide an `openspec feedback` command that creates a GitHub Issue in the openspec repository with the user's feedback. +The system SHALL provide an `openspec feedback` command that creates a GitHub Issue in the openspec repository using the `gh` CLI. The system SHALL use `execFileSync` with argument arrays to prevent shell injection vulnerabilities. #### Scenario: Simple feedback submission - **WHEN** user executes `openspec feedback "Great tool!"` -- **THEN** the system creates a GitHub Issue with title "Feedback: Great tool!" +- **THEN** the system executes `gh issue create` with title "Feedback: Great tool!" +- **AND** the issue is created in the openspec repository - **AND** the issue has the `feedback` label - **AND** the system displays the created issue URL -#### Scenario: Rich feedback with body +#### Scenario: Safe command execution + +- **WHEN** submitting feedback via `gh` CLI +- **THEN** the system uses `execFileSync` with separate arguments array +- **AND** user input is NOT passed through a shell +- **AND** shell metacharacters (quotes, backticks, $(), etc.) are treated as literal text + +#### Scenario: Feedback with body - **WHEN** user executes `openspec feedback "Title here" --body "Detailed description..."` - **THEN** the system creates a GitHub Issue with the specified title - **AND** the issue body contains the detailed description -- **AND** the issue body includes metadata (OpenSpec version, platform) - -#### Scenario: Multiline message - -- **WHEN** user provides a multiline message (first line as title, rest as body) -- **THEN** the system uses the first line as the issue title -- **AND** the remaining lines become the issue body - -### Requirement: GitHub authentication - -The system SHALL authenticate users via GitHub Device OAuth flow before submitting feedback. - -#### Scenario: First-time authentication - -- **WHEN** user runs `openspec feedback` for the first time -- **AND** no GitHub token is stored -- **THEN** the system initiates GitHub Device OAuth flow -- **AND** displays a URL and code for the user to authorize -- **AND** polls for authorization completion -- **AND** stores the token in global config on success - -#### Scenario: Cached authentication - -- **WHEN** user runs `openspec feedback` -- **AND** a valid GitHub token is stored -- **THEN** the system uses the cached token without re-authentication - -#### Scenario: Token refresh - -- **WHEN** the stored GitHub token is expired or invalid -- **THEN** the system initiates a new Device OAuth flow -- **AND** updates the stored token on success +- **AND** the issue body includes metadata (OpenSpec version, platform, timestamp) -#### Scenario: Authentication cancellation +### Requirement: GitHub CLI dependency -- **WHEN** user cancels the OAuth flow (Ctrl+C) -- **THEN** the system exits gracefully without storing any token -- **AND** displays a message indicating feedback was not submitted +The system SHALL use `gh` CLI for automatic feedback submission when available, and provide a manual submission fallback when `gh` is not installed or not authenticated. The system SHALL use platform-appropriate commands to detect `gh` CLI availability. -### Requirement: GitHub token storage +#### Scenario: Missing gh CLI with fallback -The system SHALL securely store GitHub authentication tokens in the global config directory. +- **WHEN** user runs `openspec feedback "message"` +- **AND** `gh` CLI is not installed (not found in PATH) +- **THEN** the system displays warning: "GitHub CLI not found. Manual submission required." +- **AND** outputs structured feedback content with delimiters: + - "--- FORMATTED FEEDBACK ---" + - Title line + - Labels line + - Body content with metadata + - "--- END FEEDBACK ---" +- **AND** displays pre-filled GitHub issue URL for manual submission +- **AND** exits with zero code (successful fallback) -#### Scenario: Token persistence +#### Scenario: Cross-platform gh CLI detection on Unix -- **WHEN** GitHub authentication completes successfully -- **THEN** the system stores the access token in `~/.config/openspec/config.json` -- **AND** the token persists across CLI sessions +- **WHEN** system is running on macOS or Linux (platform is 'darwin' or 'linux') +- **AND** checking if `gh` CLI is installed +- **THEN** the system executes `which gh` command -#### Scenario: Token isolation +#### Scenario: Cross-platform gh CLI detection on Windows -- **WHEN** storing the GitHub token -- **THEN** the token is stored separately from telemetry configuration -- **AND** does not affect or depend on telemetry settings +- **WHEN** system is running on Windows (platform is 'win32') +- **AND** checking if `gh` CLI is installed +- **THEN** the system executes `where gh` command -### Requirement: Feedback always works - -The system SHALL allow feedback submission regardless of telemetry settings. +#### Scenario: Unauthenticated gh CLI with fallback -#### Scenario: Feedback with telemetry disabled - -- **WHEN** user has disabled telemetry via `OPENSPEC_TELEMETRY=0` -- **AND** user runs `openspec feedback "message"` -- **THEN** the feedback is still submitted to GitHub -- **AND** telemetry events are not sent +- **WHEN** user runs `openspec feedback "message"` +- **AND** `gh` CLI is installed but not authenticated +- **THEN** the system displays warning: "GitHub authentication required. Manual submission required." +- **AND** outputs structured feedback content (same format as missing gh CLI scenario) +- **AND** displays pre-filled GitHub issue URL for manual submission +- **AND** displays authentication instructions: "To auto-submit in the future: gh auth login" +- **AND** exits with zero code (successful fallback) -#### Scenario: Feedback in CI environment +#### Scenario: Authenticated gh CLI -- **WHEN** `CI=true` is set in the environment -- **AND** user runs `openspec feedback "message"` -- **THEN** the feedback submission proceeds normally +- **WHEN** user runs `openspec feedback "message"` +- **AND** `gh auth status` returns success (authenticated) +- **THEN** the system proceeds with feedback submission ### Requirement: Issue metadata @@ -99,7 +83,13 @@ The system SHALL include relevant metadata in the GitHub Issue body. - OpenSpec CLI version - Platform (darwin, linux, win32) - Submission timestamp - - Separator line indicating "Submitted via OpenSpec CLI" + - Separator line: "---\nSubmitted via OpenSpec CLI" + +#### Scenario: Windows platform metadata + +- **WHEN** creating a GitHub Issue for feedback on Windows +- **THEN** the issue body includes "Platform: win32" +- **AND** all platform detection uses Node.js `os.platform()` API #### Scenario: No sensitive metadata @@ -110,41 +100,52 @@ The system SHALL include relevant metadata in the GitHub Issue body. - Environment variables - IP addresses +### Requirement: Feedback always works + +The system SHALL allow feedback submission regardless of telemetry settings. + +#### Scenario: Feedback with telemetry disabled + +- **WHEN** user has disabled telemetry via `OPENSPEC_TELEMETRY=0` +- **AND** user runs `openspec feedback "message"` +- **THEN** the feedback is still submitted via `gh` CLI +- **AND** telemetry events are not sent + +#### Scenario: Feedback in CI environment + +- **WHEN** `CI=true` is set in the environment +- **AND** user runs `openspec feedback "message"` +- **THEN** the feedback submission proceeds normally (if `gh` is available and authenticated) + ### Requirement: Error handling The system SHALL handle feedback submission errors gracefully. -#### Scenario: Network failure +#### Scenario: gh CLI execution failure -- **WHEN** GitHub API is unreachable -- **THEN** the system displays a clear error message -- **AND** suggests checking network connectivity -- **AND** exits with non-zero code +- **WHEN** `gh issue create` command fails +- **THEN** the system displays the error output from `gh` CLI +- **AND** exits with the same exit code as `gh` -#### Scenario: GitHub API error +#### Scenario: Network failure -- **WHEN** GitHub API returns an error (rate limit, server error) -- **THEN** the system displays the error message from GitHub +- **WHEN** `gh` CLI reports network connectivity issues +- **THEN** the system displays the error message from `gh` +- **AND** suggests checking network connectivity - **AND** exits with non-zero code -#### Scenario: Invalid token - -- **WHEN** the stored token is revoked or invalid -- **THEN** the system clears the stored token -- **AND** initiates a new OAuth flow - ### Requirement: Feedback skill for agents The system SHALL provide a `/feedback` skill that guides agents through collecting and submitting user feedback. #### Scenario: Agent-initiated feedback -- **WHEN** user invokes `/feedback ` in an agent conversation +- **WHEN** user invokes `/feedback` in an agent conversation - **THEN** the agent gathers context from the conversation - **AND** drafts a feedback issue with enriched content - **AND** anonymizes sensitive information - **AND** presents the draft to the user for approval -- **AND** submits via `openspec feedback` on user confirmation +- **AND** submits via `openspec feedback` command on user confirmation #### Scenario: Context enrichment diff --git a/openspec/changes/add-feedback-command/tasks.md b/openspec/changes/add-feedback-command/tasks.md index 074065284..e8c7a6c75 100644 --- a/openspec/changes/add-feedback-command/tasks.md +++ b/openspec/changes/add-feedback-command/tasks.md @@ -1,32 +1,30 @@ -## 1. GitHub Authentication +## 1. Feedback Command -- [ ] 1.1 Create `src/auth/github.ts` module with Device OAuth flow -- [ ] 1.2 Implement token storage in global config (`~/.config/openspec/`) -- [ ] 1.3 Add `getGitHubAuth()` function that returns cached token or initiates auth -- [ ] 1.4 Add `clearGitHubAuth()` function for logout capability +- [x] 1.1 Create `src/commands/feedback.ts` with command implementation +- [x] 1.2 Check `gh` CLI availability using platform-appropriate command (`which` on Unix/macOS, `where` on Windows) +- [x] 1.3 Check GitHub auth status with `gh auth status` +- [x] 1.4 Execute `gh issue create` with formatted title and body using `execFileSync` to prevent shell injection +- [x] 1.5 Display issue URL returned by `gh` CLI +- [x] 1.6 Register `feedback ` command in `src/cli/index.ts` +- [x] 1.7 Ensure cross-platform compatibility (macOS, Linux, Windows) -## 2. Feedback Command +## 2. Shell Completions -- [ ] 2.1 Create `src/commands/feedback.ts` with command implementation -- [ ] 2.2 Register `feedback ` command in CLI -- [ ] 2.3 Implement `--body` flag for rich content (title + body) -- [ ] 2.4 Create GitHub Issue via API with `feedback` label -- [ ] 2.5 Display created issue URL on success +- [x] 2.1 Add `feedback` command to command registry +- [x] 2.2 Regenerate completion scripts for all shells -## 3. Shell Completions +## 3. Feedback Skill -- [ ] 3.1 Add `feedback` command to command registry -- [ ] 3.2 Regenerate completion scripts for all shells +- [x] 3.1 Create feedback skill template in `skill-templates.ts` +- [x] 3.2 Document context gathering workflow +- [x] 3.3 Document anonymization rules +- [x] 3.4 Document user confirmation flow -## 4. Feedback Skill +## 4. Testing -- [ ] 4.1 Create feedback skill template in `skill-templates.ts` -- [ ] 4.2 Document context gathering workflow -- [ ] 4.3 Document anonymization rules -- [ ] 4.4 Document user confirmation flow - -## 5. Testing - -- [ ] 5.1 Add unit tests for GitHub auth module -- [ ] 5.2 Add unit tests for feedback command -- [ ] 5.3 Add integration test for full feedback flow (mocked GitHub API) +- [x] 4.1 Add unit tests for feedback command (mock `gh` subprocess calls) +- [x] 4.2 Add integration test for full feedback flow with mocked `gh` CLI +- [x] 4.3 Test error handling for missing `gh` CLI +- [x] 4.4 Test error handling for unauthenticated `gh` session +- [x] 4.5 Test cross-platform `gh` CLI detection (verify `which` on Unix, `where` on Windows) +- [x] 4.6 Test platform metadata includes correct value for Windows (win32) diff --git a/src/cli/index.ts b/src/cli/index.ts index a02ec5efa..65d1568fc 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -13,6 +13,7 @@ import { ChangeCommand } from '../commands/change.js'; import { ValidateCommand } from '../commands/validate.js'; import { ShowCommand } from '../commands/show.js'; import { CompletionCommand } from '../commands/completion.js'; +import { FeedbackCommand } from '../commands/feedback.js'; import { registerConfigCommand } from '../commands/config.js'; import { registerArtifactWorkflowCommands } from '../commands/artifact-workflow.js'; import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js'; @@ -293,6 +294,22 @@ program } }); +// Feedback command +program + .command('feedback ') + .description('Submit feedback about OpenSpec') + .option('--body ', 'Detailed description for the feedback') + .action(async (message: string, options?: { body?: string }) => { + try { + const feedbackCommand = new FeedbackCommand(); + await feedbackCommand.execute(message, options); + } catch (error) { + console.log(); + ora().fail(`Error: ${(error as Error).message}`); + process.exit(1); + } + }); + // Completion command with subcommands const completionCmd = program .command('completion') diff --git a/src/commands/feedback.ts b/src/commands/feedback.ts new file mode 100644 index 000000000..e157d11e1 --- /dev/null +++ b/src/commands/feedback.ts @@ -0,0 +1,208 @@ +import { execSync, execFileSync } from 'child_process'; +import { createRequire } from 'module'; +import os from 'os'; + +const require = createRequire(import.meta.url); + +/** + * Check if gh CLI is installed and available in PATH + * Uses platform-appropriate command: 'where' on Windows, 'which' on Unix/macOS + */ +function isGhInstalled(): boolean { + try { + const command = process.platform === 'win32' ? 'where gh' : 'which gh'; + execSync(command, { stdio: 'pipe' }); + return true; + } catch { + return false; + } +} + +/** + * Check if gh CLI is authenticated + */ +function isGhAuthenticated(): boolean { + try { + execSync('gh auth status', { stdio: 'pipe' }); + return true; + } catch { + return false; + } +} + +/** + * Get OpenSpec version from package.json + */ +function getVersion(): string { + try { + const { version } = require('../../package.json'); + return version; + } catch { + return 'unknown'; + } +} + +/** + * Get platform name + */ +function getPlatform(): string { + return os.platform(); +} + +/** + * Get current timestamp in ISO format + */ +function getTimestamp(): string { + return new Date().toISOString(); +} + +/** + * Generate metadata footer for feedback + */ +function generateMetadata(): string { + const version = getVersion(); + const platform = getPlatform(); + const timestamp = getTimestamp(); + + return `--- +Submitted via OpenSpec CLI +- Version: ${version} +- Platform: ${platform} +- Timestamp: ${timestamp}`; +} + +/** + * Format the feedback title + */ +function formatTitle(message: string): string { + return `Feedback: ${message}`; +} + +/** + * Format the full feedback body + */ +function formatBody(bodyText?: string): string { + const parts: string[] = []; + + if (bodyText) { + parts.push(bodyText); + parts.push(''); // Empty line before metadata + } + + parts.push(generateMetadata()); + + return parts.join('\n'); +} + +/** + * Generate a pre-filled GitHub issue URL for manual submission + */ +function generateManualSubmissionUrl(title: string, body: string): string { + const repo = 'Fission-AI/OpenSpec'; + const encodedTitle = encodeURIComponent(title); + const encodedBody = encodeURIComponent(body); + const encodedLabels = encodeURIComponent('feedback'); + + return `https://github.com/${repo}/issues/new?title=${encodedTitle}&body=${encodedBody}&labels=${encodedLabels}`; +} + +/** + * Display formatted feedback content for manual submission + */ +function displayFormattedFeedback(title: string, body: string): void { + console.log('\n--- FORMATTED FEEDBACK ---'); + console.log(`Title: ${title}`); + console.log(`Labels: feedback`); + console.log('\nBody:'); + console.log(body); + console.log('--- END FEEDBACK ---\n'); +} + +/** + * Submit feedback via gh CLI + * Uses execFileSync to prevent shell injection vulnerabilities + */ +function submitViaGhCli(title: string, body: string): void { + try { + const result = execFileSync( + 'gh', + [ + 'issue', + 'create', + '--repo', + 'Fission-AI/OpenSpec', + '--title', + title, + '--body', + body, + '--label', + 'feedback', + ], + { encoding: 'utf-8', stdio: 'pipe' } + ); + + const issueUrl = result.trim(); + console.log(`\n✓ Feedback submitted successfully!`); + console.log(`Issue URL: ${issueUrl}\n`); + } catch (error: any) { + // Display the error output from gh CLI + if (error.stderr) { + console.error(error.stderr.toString()); + } else if (error.message) { + console.error(error.message); + } + + // Exit with the same code as gh CLI + process.exit(error.status ?? 1); + } +} + +/** + * Handle fallback when gh CLI is not available or not authenticated + */ +function handleFallback(title: string, body: string, reason: 'missing' | 'unauthenticated'): void { + if (reason === 'missing') { + console.log('⚠️ GitHub CLI not found. Manual submission required.'); + } else { + console.log('⚠️ GitHub authentication required. Manual submission required.'); + } + + displayFormattedFeedback(title, body); + + const manualUrl = generateManualSubmissionUrl(title, body); + console.log('Please submit your feedback manually:'); + console.log(manualUrl); + + if (reason === 'unauthenticated') { + console.log('\nTo auto-submit in the future: gh auth login'); + } + + // Exit with success code (fallback is successful) + process.exit(0); +} + +/** + * Feedback command implementation + */ +export class FeedbackCommand { + async execute(message: string, options?: { body?: string }): Promise { + // Format title and body once for all code paths + const title = formatTitle(message); + const body = formatBody(options?.body); + + // Check if gh CLI is installed + if (!isGhInstalled()) { + handleFallback(title, body, 'missing'); + return; + } + + // Check if gh CLI is authenticated + if (!isGhAuthenticated()) { + handleFallback(title, body, 'unauthenticated'); + return; + } + + // Submit via gh CLI + submitViaGhCli(title, body); + } +} diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 10fe16ddd..16d82019e 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -155,6 +155,18 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ }, ], }, + { + name: 'feedback', + description: 'Submit feedback about OpenSpec', + acceptsPositional: true, + flags: [ + { + name: 'body', + description: 'Detailed description for the feedback', + takesValue: true, + }, + ], + }, { name: 'change', description: 'Manage OpenSpec change proposals (deprecated)', diff --git a/src/core/templates/skill-templates.ts b/src/core/templates/skill-templates.ts index f5f0b4886..cd67b634e 100644 --- a/src/core/templates/skill-templates.ts +++ b/src/core/templates/skill-templates.ts @@ -2301,3 +2301,111 @@ Use clear markdown with: - No vague suggestions like "consider reviewing"` }; } +/** + * Template for feedback skill + * For collecting and submitting user feedback with context enrichment + */ +export function getFeedbackSkillTemplate(): SkillTemplate { + return { + name: 'feedback', + description: 'Collect and submit user feedback about OpenSpec with context enrichment and anonymization.', + instructions: `Help the user submit feedback about OpenSpec. + +**Goal**: Guide the user through collecting, enriching, and submitting feedback while ensuring privacy through anonymization. + +**Process** + +1. **Gather context from the conversation** + - Review recent conversation history for context + - Identify what task was being performed + - Note what worked well or poorly + - Capture specific friction points or praise + +2. **Draft enriched feedback** + - Create a clear, descriptive title (single sentence, no "Feedback:" prefix needed) + - Write a body that includes: + - What the user was trying to do + - What happened (good or bad) + - Relevant context from the conversation + - Any specific suggestions or requests + +3. **Anonymize sensitive information** + - Replace file paths with \`\` or generic descriptions + - Replace API keys, tokens, secrets with \`\` + - Replace company/organization names with \`\` + - Replace personal names with \`\` + - Replace specific URLs with \`\` unless public/relevant + - Keep technical details that help understand the issue + +4. **Present draft for approval** + - Show the complete draft to the user + - Display both title and body clearly + - Ask for explicit approval before submitting + - Allow the user to request modifications + +5. **Submit on confirmation** + - Use the \`openspec feedback\` command to submit + - Format: \`openspec feedback "title" --body "body content"\` + - The command will automatically add metadata (version, platform, timestamp) + +**Example Draft** + +\`\`\` +Title: Error handling in artifact workflow needs improvement + +Body: +I was working on creating a new change and encountered an issue with +the artifact workflow. When I tried to continue after creating the +proposal, the system didn't clearly indicate that I needed to complete +the specs first. + +Suggestion: Add clearer error messages that explain dependency chains +in the artifact workflow. Something like "Cannot create design.md +because specs are not complete (0/2 done)." + +Context: Using the spec-driven schema with /my-project +\`\`\` + +**Anonymization Examples** + +Before: +\`\`\` +Working on /Users/john/mycompany/auth-service/src/oauth.ts +Failed with API key: sk_live_abc123xyz +Working at Acme Corp +\`\`\` + +After: +\`\`\` +Working on /oauth.ts +Failed with API key: +Working at +\`\`\` + +**Guardrails** + +- MUST show complete draft before submitting +- MUST ask for explicit approval +- MUST anonymize sensitive information +- ALLOW user to modify draft before submitting +- DO NOT submit without user confirmation +- DO include relevant technical context +- DO keep conversation-specific insights + +**User Confirmation Required** + +Always ask: +\`\`\` +Here's the feedback I've drafted: + +Title: [title] + +Body: +[body] + +Does this look good? I can modify it if you'd like, or submit it as-is. +\`\`\` + +Only proceed with submission after user confirms.` + }; +} diff --git a/test/commands/feedback.test.ts b/test/commands/feedback.test.ts new file mode 100644 index 000000000..7a2125f16 --- /dev/null +++ b/test/commands/feedback.test.ts @@ -0,0 +1,429 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { FeedbackCommand } from '../../src/commands/feedback.js'; +import { execSync, execFileSync } from 'child_process'; + +// Mock child_process functions +vi.mock('child_process', () => ({ + execSync: vi.fn(), + execFileSync: vi.fn(), +})); + +describe('FeedbackCommand', () => { + let feedbackCommand: FeedbackCommand; + let consoleLogSpy: any; + let consoleErrorSpy: any; + let processExitSpy: any; + const mockExecSync = execSync as unknown as ReturnType; + const mockExecFileSync = execFileSync as unknown as ReturnType; + + beforeEach(() => { + feedbackCommand = new FeedbackCommand(); + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { + throw new Error(`process.exit(${code})`); + }); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('gh CLI availability check', () => { + it('should use which command on Unix/macOS platforms', async () => { + // Mock platform as darwin + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'darwin' }); + + mockExecSync.mockImplementation((cmd: string) => { + if (cmd === 'which gh') { + return Buffer.from('/usr/local/bin/gh'); + } + if (cmd === 'gh auth status') { + return Buffer.from('Logged in'); + } + return ''; + }); + + mockExecFileSync.mockReturnValue('https://github.com/Fission-AI/OpenSpec/issues/123\n'); + + await feedbackCommand.execute('Test'); + + // Verify 'which gh' was called + expect(mockExecSync).toHaveBeenCalledWith('which gh', expect.any(Object)); + + // Restore original platform + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('should use where command on Windows platform', async () => { + // Mock platform as win32 + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + + mockExecSync.mockImplementation((cmd: string) => { + if (cmd === 'where gh') { + return Buffer.from('C:\\Program Files\\GitHub CLI\\gh.exe'); + } + if (cmd === 'gh auth status') { + return Buffer.from('Logged in'); + } + return ''; + }); + + mockExecFileSync.mockReturnValue('https://github.com/Fission-AI/OpenSpec/issues/123\n'); + + await feedbackCommand.execute('Test'); + + // Verify 'where gh' was called + expect(mockExecSync).toHaveBeenCalledWith('where gh', expect.any(Object)); + + // Restore original platform + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('should handle missing gh CLI with fallback', async () => { + // Simulate gh not installed + mockExecSync.mockImplementation((cmd: string) => { + if (cmd === 'which gh' || cmd === 'where gh') { + throw new Error('Command not found'); + } + }); + + try { + await feedbackCommand.execute('Test feedback'); + } catch (error: any) { + // Should exit with code 0 (successful fallback) + expect(error.message).toBe('process.exit(0)'); + } + + // Should display warning + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('GitHub CLI not found') + ); + + // Should show formatted feedback + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('--- FORMATTED FEEDBACK ---') + ); + + // Should show manual submission URL + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('https://github.com/Fission-AI/OpenSpec/issues/new') + ); + }); + + it('should handle unauthenticated gh CLI with fallback', async () => { + // Simulate gh installed but not authenticated + mockExecSync.mockImplementation((cmd: string) => { + if (cmd === 'which gh' || cmd === 'where gh') { + return Buffer.from('/usr/local/bin/gh'); + } + if (cmd === 'gh auth status') { + throw new Error('Not authenticated'); + } + }); + + try { + await feedbackCommand.execute('Test feedback'); + } catch (error: any) { + // Should exit with code 0 (successful fallback) + expect(error.message).toBe('process.exit(0)'); + } + + // Should display warning + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('GitHub authentication required') + ); + + // Should show auth instructions + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('To auto-submit in the future: gh auth login') + ); + + // Should show formatted feedback + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('--- FORMATTED FEEDBACK ---') + ); + }); + }); + + describe('successful feedback submission', () => { + it('should submit feedback via gh CLI when authenticated', async () => { + const issueUrl = 'https://github.com/Fission-AI/OpenSpec/issues/123'; + + // Simulate gh installed and authenticated + mockExecSync.mockImplementation((cmd: string, options?: any) => { + if (cmd === 'which gh' || cmd === 'where gh') { + return Buffer.from('/usr/local/bin/gh'); + } + if (cmd === 'gh auth status') { + return Buffer.from('Logged in'); + } + return ''; + }); + + mockExecFileSync.mockReturnValue(`${issueUrl}\n`); + + await feedbackCommand.execute('Great tool!'); + + // Should call gh with correct arguments using execFileSync + expect(mockExecFileSync).toHaveBeenCalledWith( + 'gh', + [ + 'issue', + 'create', + '--repo', + 'Fission-AI/OpenSpec', + '--title', + 'Feedback: Great tool!', + '--body', + expect.stringContaining('Submitted via OpenSpec CLI'), + '--label', + 'feedback', + ], + expect.objectContaining({ + encoding: 'utf-8', + stdio: 'pipe', + }) + ); + + // Should display success message + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Feedback submitted successfully') + ); + + // Should display issue URL + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining(issueUrl) + ); + }); + + it('should include --body flag when body is provided', async () => { + const issueUrl = 'https://github.com/Fission-AI/OpenSpec/issues/124'; + + mockExecSync.mockImplementation((cmd: string, options?: any) => { + if (cmd === 'which gh' || cmd === 'where gh') { + return Buffer.from('/usr/local/bin/gh'); + } + if (cmd === 'gh auth status') { + return Buffer.from('Logged in'); + } + return ''; + }); + + mockExecFileSync.mockReturnValue(`${issueUrl}\n`); + + await feedbackCommand.execute('Title here', { body: 'Detailed description' }); + + // Verify body is included in the arguments + expect(mockExecFileSync).toHaveBeenCalledWith( + 'gh', + expect.arrayContaining([ + '--body', + expect.stringContaining('Detailed description'), + ]), + expect.any(Object) + ); + }); + + it('should format title with "Feedback:" prefix', async () => { + mockExecSync.mockImplementation((cmd: string, options?: any) => { + if (cmd === 'which gh' || cmd === 'where gh') { + return Buffer.from('/usr/local/bin/gh'); + } + if (cmd === 'gh auth status') { + return Buffer.from('Logged in'); + } + return ''; + }); + + mockExecFileSync.mockReturnValue('https://github.com/Fission-AI/OpenSpec/issues/125\n'); + + await feedbackCommand.execute('Test message'); + + // Verify title has "Feedback:" prefix + expect(mockExecFileSync).toHaveBeenCalledWith( + 'gh', + expect.arrayContaining([ + '--title', + 'Feedback: Test message', + ]), + expect.any(Object) + ); + }); + + it('should include metadata in issue body', async () => { + mockExecSync.mockImplementation((cmd: string, options?: any) => { + if (cmd === 'which gh' || cmd === 'where gh') { + return Buffer.from('/usr/local/bin/gh'); + } + if (cmd === 'gh auth status') { + return Buffer.from('Logged in'); + } + return ''; + }); + + mockExecFileSync.mockReturnValue('https://github.com/Fission-AI/OpenSpec/issues/126\n'); + + await feedbackCommand.execute('Test', { body: 'Body text' }); + + // Verify metadata is included in body + expect(mockExecFileSync).toHaveBeenCalledWith( + 'gh', + expect.arrayContaining([ + '--body', + expect.stringMatching(/Submitted via OpenSpec CLI[\s\S]*Version:[\s\S]*Platform:[\s\S]*Timestamp:/), + ]), + expect.any(Object) + ); + }); + + it('should add feedback label to the issue', async () => { + mockExecSync.mockImplementation((cmd: string, options?: any) => { + if (cmd === 'which gh' || cmd === 'where gh') { + return Buffer.from('/usr/local/bin/gh'); + } + if (cmd === 'gh auth status') { + return Buffer.from('Logged in'); + } + return ''; + }); + + mockExecFileSync.mockReturnValue('https://github.com/Fission-AI/OpenSpec/issues/127\n'); + + await feedbackCommand.execute('Test'); + + // Verify feedback label is added + expect(mockExecFileSync).toHaveBeenCalledWith( + 'gh', + expect.arrayContaining([ + '--label', + 'feedback', + ]), + expect.any(Object) + ); + }); + }); + + describe('error handling', () => { + it('should handle gh CLI execution failure', async () => { + mockExecSync.mockImplementation((cmd: string, options?: any) => { + if (cmd === 'which gh' || cmd === 'where gh') { + return Buffer.from('/usr/local/bin/gh'); + } + if (cmd === 'gh auth status') { + return Buffer.from('Logged in'); + } + return ''; + }); + + // Mock execFileSync to throw error + mockExecFileSync.mockImplementation(() => { + const error: any = new Error('Network error'); + error.status = 1; + error.stderr = Buffer.from('Error: Network connectivity issue'); + throw error; + }); + + try { + await feedbackCommand.execute('Test'); + } catch (error: any) { + // Should exit with the same code as gh CLI + expect(error.message).toBe('process.exit(1)'); + } + + // Should display the error from gh CLI + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Network connectivity issue') + ); + }); + + it('should handle quotes in title and body without escaping (no shell injection)', async () => { + mockExecSync.mockImplementation((cmd: string, options?: any) => { + if (cmd === 'which gh' || cmd === 'where gh') { + return Buffer.from('/usr/local/bin/gh'); + } + if (cmd === 'gh auth status') { + return Buffer.from('Logged in'); + } + return ''; + }); + + mockExecFileSync.mockReturnValue('https://github.com/Fission-AI/OpenSpec/issues/128\n'); + + await feedbackCommand.execute('Test with "quotes"', { + body: 'Body with "quotes"', + }); + + // Verify quotes are passed as-is (no escaping needed with execFileSync) + expect(mockExecFileSync).toHaveBeenCalledWith( + 'gh', + expect.arrayContaining([ + '--title', + 'Feedback: Test with "quotes"', + '--body', + expect.stringContaining('Body with "quotes"'), + ]), + expect.any(Object) + ); + }); + }); + + describe('formatted feedback output', () => { + it('should display formatted feedback with proper structure', async () => { + mockExecSync.mockImplementation((cmd: string) => { + if (cmd === 'which gh' || cmd === 'where gh') { + throw new Error('Command not found'); + } + }); + + try { + await feedbackCommand.execute('Test message', { body: 'Test body' }); + } catch (error: any) { + // Expected to exit + } + + // Verify formatted output structure + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('--- FORMATTED FEEDBACK ---') + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Title: Feedback: Test message') + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Labels: feedback') + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('--- END FEEDBACK ---') + ); + }); + + it('should generate correct manual submission URL', async () => { + mockExecSync.mockImplementation((cmd: string) => { + if (cmd === 'which gh' || cmd === 'where gh') { + throw new Error('Command not found'); + } + }); + + try { + await feedbackCommand.execute('Test'); + } catch (error: any) { + // Expected to exit + } + + // Verify URL is shown + const urlCall = consoleLogSpy.mock.calls.find((call: any[]) => + call[0]?.includes('https://github.com/Fission-AI/OpenSpec/issues/new') + ); + expect(urlCall).toBeDefined(); + + // Verify URL has proper parameters + const url = urlCall?.[0]; + expect(url).toContain('title='); + expect(url).toContain('body='); + expect(url).toContain('labels=feedback'); + }); + }); +});