mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-02 05:24:34 +08:00
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:
+2
-2
@@ -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.
|
||||
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
@@ -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',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user