From aabdd2b08b0a16080c1a580c4d05b095cf9d9655 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Fri, 14 Aug 2026 12:42:50 -0500 Subject: [PATCH 1/2] fix(feedback): keep full reports in issue bodies --- docs/cli.md | 4 +- openspec/specs/cli-feedback/spec.md | 12 ++++- src/commands/feedback.ts | 26 ++++++++--- src/core/templates/workflows/feedback.ts | 1 + test/commands/feedback.test.ts | 45 +++++++++++++++++-- .../templates/skill-templates-parity.test.ts | 2 +- 6 files changed, 75 insertions(+), 15 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index d17c6d662f..ceb248139b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1204,13 +1204,13 @@ openspec feedback [options] | Argument | Required | Description | |----------|----------|-------------| -| `message` | Yes | Feedback message | +| `message` | Yes | Feedback summary; long text is shortened in the issue title and preserved in the body | **Options:** | Option | Description | |--------|-------------| -| `--body ` | Detailed description | +| `--body ` | Additional details included after the summary | **Requirements:** GitHub CLI (`gh`) must be installed and authenticated. diff --git a/openspec/specs/cli-feedback/spec.md b/openspec/specs/cli-feedback/spec.md index b3a4b022e0..35da60e19f 100644 --- a/openspec/specs/cli-feedback/spec.md +++ b/openspec/specs/cli-feedback/spec.md @@ -12,6 +12,7 @@ The system SHALL provide an `openspec feedback` command that creates a GitHub Is - **WHEN** user executes `openspec feedback "Great tool!"` - **THEN** the system executes `gh issue create` with title "Feedback: Great tool!" +- **AND** the issue body includes "Great tool!" under a Summary heading - **AND** the issue is created in the openspec repository - **AND** the issue has the `feedback` label - **AND** the system displays the created issue URL @@ -36,9 +37,17 @@ The system SHALL provide an `openspec feedback` command that creates a GitHub Is - **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 contains the message under a Summary heading +- **AND** the issue body contains the detailed description under a Details heading - **AND** the issue body includes metadata (OpenSpec version, platform, timestamp) +#### Scenario: Long or multiline feedback message + +- **WHEN** user executes `openspec feedback` with a long or multiline message +- **THEN** the issue title is a single whitespace-normalized line of at most 72 characters +- **AND** an ellipsis indicates when the title was shortened +- **AND** the complete message is preserved in the issue body + ### Requirement: GitHub CLI dependency 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. @@ -200,4 +209,3 @@ The system SHALL provide shell completions for the feedback command. - **WHEN** user types `openspec feedback "msg" --` - **THEN** the shell suggests available flags (`--body`) - diff --git a/src/commands/feedback.ts b/src/commands/feedback.ts index 86d25042bd..97a4bd9ac1 100644 --- a/src/commands/feedback.ts +++ b/src/commands/feedback.ts @@ -3,6 +3,8 @@ import { createRequire } from 'module'; import os from 'os'; const require = createRequire(import.meta.url); +const MAX_TITLE_LENGTH = 72; +const TITLE_PREFIX = 'Feedback: '; /** * Check if gh CLI is installed and available in PATH @@ -75,21 +77,31 @@ Submitted via OpenSpec CLI * Format the feedback title */ function formatTitle(message: string): string { - return `Feedback: ${message}`; + const normalizedMessage = message.replace(/\s+/g, ' ').trim(); + const title = `${TITLE_PREFIX}${normalizedMessage}`; + + if (Array.from(title).length <= MAX_TITLE_LENGTH) { + return title; + } + + const availableLength = MAX_TITLE_LENGTH - TITLE_PREFIX.length - 1; + const candidate = Array.from(normalizedMessage).slice(0, availableLength).join('').trimEnd(); + const lastSpace = candidate.lastIndexOf(' '); + const summary = lastSpace > 0 ? candidate.slice(0, lastSpace) : candidate; + return `${TITLE_PREFIX}${summary}โ€ฆ`; } /** * Format the full feedback body */ -function formatBody(bodyText?: string): string { - const parts: string[] = []; +function formatBody(message: string, bodyText?: string): string { + const parts = ['## Summary', '', message.trim()]; if (bodyText) { - parts.push(bodyText); - parts.push(''); // Empty line before metadata + parts.push('', '## Details', '', bodyText.trim()); } - parts.push(generateMetadata()); + parts.push('', generateMetadata()); return parts.join('\n'); } @@ -247,7 +259,7 @@ 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); + const body = formatBody(message, options?.body); // Check if gh CLI is installed if (!isGhInstalled()) { diff --git a/src/core/templates/workflows/feedback.ts b/src/core/templates/workflows/feedback.ts index bf1bd2528f..baa984b731 100644 --- a/src/core/templates/workflows/feedback.ts +++ b/src/core/templates/workflows/feedback.ts @@ -47,6 +47,7 @@ export function getFeedbackSkillTemplate(): SkillTemplate { 5. **Submit on confirmation** - Use the \`openspec feedback\` command to submit - Format: \`openspec feedback "title" --body "body content"\` + - The command preserves the title text in the issue body and shortens long GitHub issue titles - The command will automatically add metadata (version, platform, timestamp) **Example Draft** diff --git a/test/commands/feedback.test.ts b/test/commands/feedback.test.ts index 51fe40cd9d..51edfb6e08 100644 --- a/test/commands/feedback.test.ts +++ b/test/commands/feedback.test.ts @@ -228,12 +228,43 @@ describe('FeedbackCommand', () => { 'gh', expect.arrayContaining([ '--body', - expect.stringContaining('Detailed description'), + expect.stringMatching( + /## Summary\n\nTitle here[\s\S]*## Details\n\nDetailed description/ + ), ]), expect.any(Object) ); }); + it('should preserve the full message in the body and shorten a long title', async () => { + mockExecSync.mockImplementation((cmd: string) => { + 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'); + + const message = + 'Generated workflows declare too few allowed tools,\nso headless runs cannot write files and silently fail.'; + await feedbackCommand.execute(message); + + const args = mockExecFileSync.mock.calls[0][1] as string[]; + const title = args[args.indexOf('--title') + 1]; + const body = args[args.indexOf('--body') + 1]; + + expect(title).toBe( + 'Feedback: Generated workflows declare too few allowed tools, soโ€ฆ' + ); + expect(title.length).toBeLessThanOrEqual(72); + expect(title).not.toMatch(/[\r\n]/); + expect(body).toContain(`## Summary\n\n${message}`); + }); + it('should format title with "Feedback:" prefix', async () => { mockExecSync.mockImplementation((cmd: string, options?: any) => { if (cmd === 'which gh' || cmd === 'where gh') { @@ -525,8 +556,11 @@ describe('FeedbackCommand', () => { } }); + const message = + 'Generated workflows declare too few allowed tools,\nso headless runs cannot write files and silently fail.'; + try { - await feedbackCommand.execute('Test message', { body: 'Test body' }); + await feedbackCommand.execute(message, { body: 'Test body' }); } catch (error: any) { // Expected to exit } @@ -536,7 +570,9 @@ describe('FeedbackCommand', () => { expect.stringContaining('--- FORMATTED FEEDBACK ---') ); expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining('Title: Feedback: Test message') + expect.stringContaining( + 'Title: Feedback: Generated workflows declare too few allowed tools, soโ€ฆ' + ) ); expect(consoleLogSpy).toHaveBeenCalledWith( expect.stringContaining('Labels: feedback') @@ -544,6 +580,9 @@ describe('FeedbackCommand', () => { expect(consoleLogSpy).toHaveBeenCalledWith( expect.stringContaining('--- END FEEDBACK ---') ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining(`## Summary\n\n${message}`) + ); }); it('should generate correct manual submission URL', async () => { diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index decad7b0b5..cd15f5a5f0 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -60,7 +60,7 @@ const EXPECTED_FUNCTION_HASHES: Record = { getOpsxVerifyCommandTemplate: '1efcf7eff0671f48e9d9420f50865c563dd3079ee60f8c380bb7a90dd0102696', getOpsxProposeSkillTemplate: '24623c066f97e34b957d448d1f9a9e8b8a13da3dfce45d45671f6226a2534848', getOpsxProposeCommandTemplate: 'e67ba591efb0fecacb2229d06dfa84af18b825fab8a7b01377279e4f09a06ce4', - getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d', + getFeedbackSkillTemplate: 'dabeb5e825b9349abc8156c3e7b8608f27987912a6d9bf47ef29addde6138133', getUpdateChangeSkillTemplate: '7dc8abc6f64c58bf34d7581ed4ab095a3b7a53cb372349bee2d840db58622819', getOpsxUpdateCommandTemplate: 'e2388521b22f92f74561df9a0c2f98e1fa4d265af93b5ba26f42fb47a6c5bfed', }; From fcab0c351b6780f685687f69580a4226bb9c2460 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Fri, 14 Aug 2026 13:03:44 -0500 Subject: [PATCH 2/2] fix(feedback): preserve report formatting --- src/commands/feedback.ts | 21 ++++++++-- test/commands/feedback.test.ts | 76 ++++++++++++++++++++++++++++------ 2 files changed, 82 insertions(+), 15 deletions(-) diff --git a/src/commands/feedback.ts b/src/commands/feedback.ts index 97a4bd9ac1..ada7119154 100644 --- a/src/commands/feedback.ts +++ b/src/commands/feedback.ts @@ -85,7 +85,22 @@ function formatTitle(message: string): string { } const availableLength = MAX_TITLE_LENGTH - TITLE_PREFIX.length - 1; - const candidate = Array.from(normalizedMessage).slice(0, availableLength).join('').trimEnd(); + let candidate = ''; + let candidateLength = 0; + const segments = new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment( + normalizedMessage + ); + + for (const { segment } of segments) { + const segmentLength = Array.from(segment).length; + if (candidateLength + segmentLength > availableLength) { + break; + } + candidate += segment; + candidateLength += segmentLength; + } + + candidate = candidate.trimEnd(); const lastSpace = candidate.lastIndexOf(' '); const summary = lastSpace > 0 ? candidate.slice(0, lastSpace) : candidate; return `${TITLE_PREFIX}${summary}โ€ฆ`; @@ -95,10 +110,10 @@ function formatTitle(message: string): string { * Format the full feedback body */ function formatBody(message: string, bodyText?: string): string { - const parts = ['## Summary', '', message.trim()]; + const parts = ['## Summary', '', message]; if (bodyText) { - parts.push('', '## Details', '', bodyText.trim()); + parts.push('', '## Details', '', bodyText); } parts.push('', generateMetadata()); diff --git a/test/commands/feedback.test.ts b/test/commands/feedback.test.ts index 51edfb6e08..9503610610 100644 --- a/test/commands/feedback.test.ts +++ b/test/commands/feedback.test.ts @@ -206,7 +206,7 @@ describe('FeedbackCommand', () => { ); }); - it('should include --body flag when body is provided', async () => { + it('should preserve message and body whitespace in the issue body', async () => { const issueUrl = 'https://github.com/Fission-AI/OpenSpec/issues/124'; mockExecSync.mockImplementation((cmd: string, options?: any) => { @@ -221,18 +221,14 @@ describe('FeedbackCommand', () => { mockExecFileSync.mockReturnValue(`${issueUrl}\n`); - await feedbackCommand.execute('Title here', { body: 'Detailed description' }); + const message = ' Title here '; + const details = ' const x = 1; '; + await feedbackCommand.execute(message, { body: details }); - // Verify body is included in the arguments - expect(mockExecFileSync).toHaveBeenCalledWith( - 'gh', - expect.arrayContaining([ - '--body', - expect.stringMatching( - /## Summary\n\nTitle here[\s\S]*## Details\n\nDetailed description/ - ), - ]), - expect.any(Object) + const args = mockExecFileSync.mock.calls[0][1] as string[]; + const body = args[args.indexOf('--body') + 1]; + expect(body).toContain( + `## Summary\n\n${message}\n\n## Details\n\n${details}\n\n---` ); }); @@ -265,6 +261,59 @@ describe('FeedbackCommand', () => { expect(body).toContain(`## Summary\n\n${message}`); }); + it('should not split Unicode grapheme clusters when shortening a title', async () => { + mockExecSync.mockImplementation((cmd: string) => { + 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'); + + const family = '๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ'; + const message = family.repeat(20); + await feedbackCommand.execute(message); + + const args = mockExecFileSync.mock.calls[0][1] as string[]; + const title = args[args.indexOf('--title') + 1]; + const summary = title.slice('Feedback: '.length, -1); + + expect(Array.from(title).length).toBeLessThanOrEqual(72); + expect(title.endsWith('โ€ฆ')).toBe(true); + expect(summary).toMatch(/^(?:๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ)+$/u); + }); + + it('should enforce the title limit at the exact boundary', async () => { + mockExecSync.mockImplementation((cmd: string) => { + 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('x'.repeat(62)); + await feedbackCommand.execute('x'.repeat(63)); + + const exactArgs = mockExecFileSync.mock.calls[0][1] as string[]; + const shortenedArgs = mockExecFileSync.mock.calls[1][1] as string[]; + const exactTitle = exactArgs[exactArgs.indexOf('--title') + 1]; + const shortenedTitle = shortenedArgs[shortenedArgs.indexOf('--title') + 1]; + + expect(exactTitle).toBe(`Feedback: ${'x'.repeat(62)}`); + expect(Array.from(exactTitle)).toHaveLength(72); + expect(shortenedTitle).toBe(`Feedback: ${'x'.repeat(61)}โ€ฆ`); + expect(Array.from(shortenedTitle)).toHaveLength(72); + }); + it('should format title with "Feedback:" prefix', async () => { mockExecSync.mockImplementation((cmd: string, options?: any) => { if (cmd === 'which gh' || cmd === 'where gh') { @@ -583,6 +632,9 @@ describe('FeedbackCommand', () => { expect(consoleLogSpy).toHaveBeenCalledWith( expect.stringContaining(`## Summary\n\n${message}`) ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('## Details\n\nTest body') + ); }); it('should generate correct manual submission URL', async () => {