Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
12 changes: 10 additions & 2 deletions openspec/specs/cli-feedback/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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`)

41 changes: 34 additions & 7 deletions src/commands/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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()) {
Expand Down
1 change: 1 addition & 0 deletions src/core/templates/workflows/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
115 changes: 103 additions & 12 deletions test/commands/feedback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -221,17 +221,97 @@ 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 () => {
Expand Down Expand Up @@ -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' });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (error: any) {
// Expected to exit
}
Expand All @@ -536,14 +619,22 @@ 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')
);
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 () => {
Expand Down
2 changes: 1 addition & 1 deletion test/core/templates/skill-templates-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
Expand Down
Loading