fix(feedback): keep full reports in issue bodies (#1653)

* fix(feedback): keep full reports in issue bodies

* fix(feedback): preserve report formatting
This commit is contained in:
Clay Good
2026-08-19 20:18:51 +00:00
committed by GitHub
parent 91813641cf
commit fc0fec1250
6 changed files with 151 additions and 24 deletions
+2 -2
View File
@@ -1204,13 +1204,13 @@ openspec feedback <message> [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 <text>` | Detailed description |
| `--body <text>` | Additional details included after the summary |
**Requirements:** GitHub CLI (`gh`) must be installed and authenticated.
+10 -2
View File
@@ -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" --<TAB>`
- **THEN** the shell suggests available flags (`--body`)
+34 -7
View File
@@ -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,46 @@ 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;
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}…`;
}
/**
* Format the full feedback body
*/
function formatBody(bodyText?: string): string {
const parts: string[] = [];
function formatBody(message: string, bodyText?: string): string {
const parts = ['## Summary', '', message];
if (bodyText) {
parts.push(bodyText);
parts.push(''); // Empty line before metadata
parts.push('', '## Details', '', bodyText);
}
parts.push(generateMetadata());
parts.push('', generateMetadata());
return parts.join('\n');
}
@@ -247,7 +274,7 @@ export class FeedbackCommand {
async execute(message: string, options?: { body?: string }): Promise<void> {
// 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()) {
+1
View File
@@ -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**
+103 -12
View File
@@ -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,19 +221,99 @@ 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.stringContaining('Detailed 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---`
);
});
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 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') {
@@ -525,8 +605,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 +619,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 +629,12 @@ describe('FeedbackCommand', () => {
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('--- END FEEDBACK ---')
);
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 () => {
@@ -60,7 +60,7 @@ const EXPECTED_FUNCTION_HASHES: Record<string, string> = {
getOpsxVerifyCommandTemplate: '1efcf7eff0671f48e9d9420f50865c563dd3079ee60f8c380bb7a90dd0102696',
getOpsxProposeSkillTemplate: '24623c066f97e34b957d448d1f9a9e8b8a13da3dfce45d45671f6226a2534848',
getOpsxProposeCommandTemplate: 'e67ba591efb0fecacb2229d06dfa84af18b825fab8a7b01377279e4f09a06ce4',
getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d',
getFeedbackSkillTemplate: 'dabeb5e825b9349abc8156c3e7b8608f27987912a6d9bf47ef29addde6138133',
getUpdateChangeSkillTemplate: '7dc8abc6f64c58bf34d7581ed4ab095a3b7a53cb372349bee2d840db58622819',
getOpsxUpdateCommandTemplate: 'e2388521b22f92f74561df9a0c2f98e1fa4d265af93b5ba26f42fb47a6c5bfed',
};