feat(cli): export chat history in /bug and prefill GitHub issue (#16115)

This commit is contained in:
N. Taylor Mullen
2026-01-08 03:43:55 -08:00
committed by GitHub
parent 722c4933dc
commit 030847a80a
6 changed files with 234 additions and 101 deletions

1
.gitignore vendored
View File

@@ -56,3 +56,4 @@ patch_output.log
.genkit
.gemini-clipboard/
.eslintcache

View File

@@ -6,6 +6,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import open from 'open';
import path from 'node:path';
import { bugCommand } from './bugCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { getVersion } from '@google/gemini-cli-core';
@@ -15,6 +16,16 @@ import { formatMemoryUsage } from '../utils/formatters.js';
// Mock dependencies
vi.mock('open');
vi.mock('../utils/formatters.js');
vi.mock('../utils/historyExportUtils.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../utils/historyExportUtils.js')>();
return {
...actual,
exportHistoryToFile: vi.fn(),
};
});
import { exportHistoryToFile } from '../utils/historyExportUtils.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
@@ -27,6 +38,13 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
},
sessionId: 'test-session-id',
getVersion: vi.fn(),
INITIAL_HISTORY_LENGTH: 1,
debugLogger: {
error: vi.fn(),
log: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
},
};
});
vi.mock('node:process', () => ({
@@ -52,11 +70,14 @@ describe('bugCommand', () => {
vi.mocked(getVersion).mockResolvedValue('0.1.0');
vi.mocked(formatMemoryUsage).mockReturnValue('100 MB');
vi.stubEnv('SANDBOX', 'gemini-test');
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
});
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
vi.useRealTimers();
});
it('should generate the default GitHub issue URL', async () => {
@@ -66,6 +87,11 @@ describe('bugCommand', () => {
getModel: () => 'gemini-pro',
getBugCommand: () => undefined,
getIdeMode: () => true,
getGeminiClient: () => ({
getChat: () => ({
getHistory: () => [],
}),
}),
},
},
});
@@ -86,13 +112,58 @@ describe('bugCommand', () => {
* **Kitty Keyboard Protocol:** Supported
* **IDE Client:** VSCode
`;
const expectedUrl =
'https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml&title=A%20test%20bug&info=' +
encodeURIComponent(expectedInfo);
const expectedUrl = `https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml&title=A%20test%20bug&info=${encodeURIComponent(expectedInfo)}&problem=A%20test%20bug`;
expect(open).toHaveBeenCalledWith(expectedUrl);
});
it('should export chat history if available', async () => {
const history = [
{ role: 'user', parts: [{ text: 'hello' }] },
{ role: 'model', parts: [{ text: 'hi' }] },
];
const mockContext = createMockCommandContext({
services: {
config: {
getModel: () => 'gemini-pro',
getBugCommand: () => undefined,
getIdeMode: () => true,
getGeminiClient: () => ({
getChat: () => ({
getHistory: () => history,
}),
}),
storage: {
getProjectTempDir: () => '/tmp/gemini',
},
},
},
});
if (!bugCommand.action) throw new Error('Action is not defined');
await bugCommand.action(mockContext, 'Bug with history');
const expectedPath = path.join(
'/tmp/gemini',
'bug-report-history-1704067200000.json',
);
expect(exportHistoryToFile).toHaveBeenCalledWith({
history,
filePath: expectedPath,
});
const addItemCall = vi.mocked(mockContext.ui.addItem).mock.calls[0];
const messageText = addItemCall[0].text;
expect(messageText).toContain(expectedPath);
expect(messageText).toContain('📄 **Chat History Exported**');
expect(messageText).toContain('Privacy Disclaimer:');
expect(messageText).not.toContain('additional-context=');
expect(messageText).toContain('problem=');
const reminder =
'\n\n[ACTION REQUIRED] 📎 PLEASE ATTACH THE EXPORTED CHAT HISTORY JSON FILE TO THIS ISSUE IF YOU FEEL COMFORTABLE SHARING IT.';
expect(messageText).toContain(encodeURIComponent(reminder));
});
it('should use a custom URL template from config if provided', async () => {
const customTemplate =
'https://internal.bug-tracker.com/new?desc={title}&details={info}';
@@ -102,6 +173,11 @@ describe('bugCommand', () => {
getModel: () => 'gemini-pro',
getBugCommand: () => ({ urlTemplate: customTemplate }),
getIdeMode: () => true,
getGeminiClient: () => ({
getChat: () => ({
getHistory: () => [],
}),
}),
},
},
});

View File

@@ -14,8 +14,16 @@ import {
import { MessageType } from '../types.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { formatMemoryUsage } from '../utils/formatters.js';
import { IdeClient, sessionId, getVersion } from '@google/gemini-cli-core';
import {
IdeClient,
sessionId,
getVersion,
INITIAL_HISTORY_LENGTH,
debugLogger,
} from '@google/gemini-cli-core';
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
import { exportHistoryToFile } from '../utils/historyExportUtils.js';
import path from 'node:path';
export const bugCommand: SlashCommand = {
name: 'bug',
@@ -63,8 +71,31 @@ export const bugCommand: SlashCommand = {
info += `* **IDE Client:** ${ideClient}\n`;
}
const chat = config?.getGeminiClient()?.getChat();
const history = chat?.getHistory() || [];
let historyFileMessage = '';
let problemValue = bugDescription;
if (history.length > INITIAL_HISTORY_LENGTH) {
const tempDir = config?.storage?.getProjectTempDir();
if (tempDir) {
const historyFileName = `bug-report-history-${Date.now()}.json`;
const historyFilePath = path.join(tempDir, historyFileName);
try {
await exportHistoryToFile({ history, filePath: historyFilePath });
historyFileMessage = `\n\n--------------------------------------------------------------------------------\n\n📄 **Chat History Exported**\nTo help us debug, we've exported your current chat history to:\n${historyFilePath}\n\nPlease consider attaching this file to your GitHub issue if you feel comfortable doing so.\n\n**Privacy Disclaimer:** Please do not upload any logs containing sensitive or private information that you are not comfortable sharing publicly.`;
problemValue += `\n\n[ACTION REQUIRED] 📎 PLEASE ATTACH THE EXPORTED CHAT HISTORY JSON FILE TO THIS ISSUE IF YOU FEEL COMFORTABLE SHARING IT.`;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
debugLogger.error(
`Failed to export chat history for bug report: ${errorMessage}`,
);
}
}
}
let bugReportUrl =
'https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml&title={title}&info={info}';
'https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml&title={title}&info={info}&problem={problem}';
const bugCommandSettings = config?.getBugCommand();
if (bugCommandSettings?.urlTemplate) {
@@ -73,12 +104,13 @@ export const bugCommand: SlashCommand = {
bugReportUrl = bugReportUrl
.replace('{title}', encodeURIComponent(bugDescription))
.replace('{info}', encodeURIComponent(info));
.replace('{info}', encodeURIComponent(info))
.replace('{problem}', encodeURIComponent(problemValue));
context.ui.addItem(
{
type: MessageType.INFO,
text: `To submit your bug report, please open the following URL in your browser:\n${bugReportUrl}`,
text: `To submit your bug report, please open the following URL in your browser:\n${bugReportUrl}${historyFileMessage}`,
},
Date.now(),
);

View File

@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Mocked } from 'vitest';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { SlashCommand, CommandContext } from './types.js';
@@ -13,7 +12,11 @@ import type { Content } from '@google/genai';
import { AuthType, type GeminiClient } from '@google/gemini-cli-core';
import * as fsPromises from 'node:fs/promises';
import { chatCommand, serializeHistoryToMarkdown } from './chatCommand.js';
import { chatCommand } from './chatCommand.js';
import {
serializeHistoryToMarkdown,
exportHistoryToFile,
} from '../utils/historyExportUtils.js';
import type { Stats } from 'node:fs';
import type { HistoryItemWithoutId } from '../types.js';
import path from 'node:path';
@@ -24,8 +27,18 @@ vi.mock('fs/promises', () => ({
writeFile: vi.fn(),
}));
vi.mock('../utils/historyExportUtils.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../utils/historyExportUtils.js')>();
return {
...actual,
exportHistoryToFile: vi.fn(),
};
});
describe('chatCommand', () => {
const mockFs = fsPromises as Mocked<typeof fsPromises>;
const mockFs = vi.mocked(fsPromises);
const mockExport = vi.mocked(exportHistoryToFile);
let mockContext: CommandContext;
let mockGetChat: ReturnType<typeof vi.fn>;
@@ -448,9 +461,10 @@ describe('chatCommand', () => {
process.cwd(),
'gemini-conversation-1234567890.json',
);
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
expect(actualContent).toEqual(JSON.stringify(mockHistory, null, 2));
expect(mockExport).toHaveBeenCalledWith({
history: mockHistory,
filePath: expectedPath,
});
expect(result).toEqual({
type: 'message',
messageType: 'info',
@@ -462,9 +476,10 @@ describe('chatCommand', () => {
const filePath = 'my-chat.json';
const result = await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.json');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
expect(actualContent).toEqual(JSON.stringify(mockHistory, null, 2));
expect(mockExport).toHaveBeenCalledWith({
history: mockHistory,
filePath: expectedPath,
});
expect(result).toEqual({
type: 'message',
messageType: 'info',
@@ -476,30 +491,10 @@ describe('chatCommand', () => {
const filePath = 'my-chat.md';
const result = await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.md');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
const expectedContent = `## USER 🧑‍💻
context
---
## MODEL ✨
context response
---
## USER 🧑‍💻
Hello
---
## MODEL ✨
Hi there!`;
expect(actualContent).toEqual(expectedContent);
expect(mockExport).toHaveBeenCalledWith({
history: mockHistory,
filePath: expectedPath,
});
expect(result).toEqual({
type: 'message',
messageType: 'info',
@@ -510,7 +505,7 @@ Hi there!`;
it('should return an error for unsupported file extensions', async () => {
const filePath = 'my-chat.txt';
const result = await shareCommand?.action?.(mockContext, filePath);
expect(mockFs.writeFile).not.toHaveBeenCalled();
expect(mockExport).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
@@ -523,7 +518,7 @@ Hi there!`;
{ role: 'user', parts: [{ text: 'context' }] },
]);
const result = await shareCommand?.action?.(mockContext, 'my-chat.json');
expect(mockFs.writeFile).not.toHaveBeenCalled();
expect(mockExport).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
@@ -533,7 +528,7 @@ Hi there!`;
it('should handle errors during file writing', async () => {
const error = new Error('Permission denied');
mockFs.writeFile.mockRejectedValue(error);
mockExport.mockRejectedValue(error);
const result = await shareCommand?.action?.(mockContext, 'my-chat.json');
expect(result).toEqual({
type: 'message',
@@ -546,14 +541,9 @@ Hi there!`;
const filePath = 'my-chat.json';
await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.json');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
const parsedContent = JSON.parse(actualContent as string);
expect(Array.isArray(parsedContent)).toBe(true);
parsedContent.forEach((item: Content) => {
expect(item).toHaveProperty('role');
expect(item).toHaveProperty('parts');
expect(Array.isArray(item.parts)).toBe(true);
expect(mockExport).toHaveBeenCalledWith({
history: mockHistory,
filePath: expectedPath,
});
});
@@ -561,15 +551,9 @@ Hi there!`;
const filePath = 'my-chat.md';
await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.md');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
const entries = (actualContent as string).split('\n\n---\n\n');
expect(entries.length).toBe(mockHistory.length);
entries.forEach((entry: string, index: number) => {
const { role, parts } = mockHistory[index];
const text = parts.map((p) => p.text).join('');
const roleIcon = role === 'user' ? '🧑‍💻' : '✨';
expect(entry).toBe(`## ${role.toUpperCase()} ${roleIcon}\n\n${text}`);
expect(mockExport).toHaveBeenCalledWith({
history: mockHistory,
filePath: expectedPath,
});
});
});

View File

@@ -26,7 +26,7 @@ import type {
ChatDetail,
} from '../types.js';
import { MessageType } from '../types.js';
import type { Content } from '@google/genai';
import { exportHistoryToFile } from '../utils/historyExportUtils.js';
const getSavedChatTags = async (
context: CommandContext,
@@ -272,38 +272,6 @@ const deleteCommand: SlashCommand = {
},
};
export function serializeHistoryToMarkdown(history: Content[]): string {
return history
.map((item) => {
const text =
item.parts
?.map((part) => {
if (part.text) {
return part.text;
}
if (part.functionCall) {
return `**Tool Command**:\n\`\`\`json\n${JSON.stringify(
part.functionCall,
null,
2,
)}\n\`\`\``;
}
if (part.functionResponse) {
return `**Tool Response**:\n\`\`\`json\n${JSON.stringify(
part.functionResponse,
null,
2,
)}\n\`\`\``;
}
return '';
})
.join('') || '';
const roleIcon = item.role === 'user' ? '🧑‍💻' : '✨';
return `## ${(item.role || 'model').toUpperCase()} ${roleIcon}\n\n${text}`;
})
.join('\n\n---\n\n');
}
const shareCommand: SlashCommand = {
name: 'share',
description:
@@ -348,15 +316,8 @@ const shareCommand: SlashCommand = {
};
}
let content = '';
if (extension === '.json') {
content = JSON.stringify(history, null, 2);
} else {
content = serializeHistoryToMarkdown(history);
}
try {
await fsPromises.writeFile(filePath, content);
await exportHistoryToFile({ history, filePath });
return {
type: 'message',
messageType: 'info',

View File

@@ -0,0 +1,79 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fsPromises from 'node:fs/promises';
import path from 'node:path';
import type { Content } from '@google/genai';
/**
* Serializes chat history to a Markdown string.
*/
export function serializeHistoryToMarkdown(history: Content[]): string {
return history
.map((item) => {
const text =
item.parts
?.map((part) => {
if (part.text) {
return part.text;
}
if (part.functionCall) {
return (
`**Tool Command**:\n` +
'```json\n' +
JSON.stringify(part.functionCall, null, 2) +
'\n```'
);
}
if (part.functionResponse) {
return (
`**Tool Response**:\n` +
'```json\n' +
JSON.stringify(part.functionResponse, null, 2) +
'\n```'
);
}
return '';
})
.join('') || '';
const roleIcon = item.role === 'user' ? '🧑‍💻' : '✨';
return `## ${(item.role || 'model').toUpperCase()} ${roleIcon}\n\n${text}`;
})
.join('\n\n---\n\n');
}
/**
* Options for exporting chat history.
*/
export interface ExportHistoryOptions {
history: Content[];
filePath: string;
}
/**
* Exports chat history to a file (JSON or Markdown).
*/
export async function exportHistoryToFile(
options: ExportHistoryOptions,
): Promise<void> {
const { history, filePath } = options;
const extension = path.extname(filePath).toLowerCase();
let content: string;
if (extension === '.json') {
content = JSON.stringify(history, null, 2);
} else if (extension === '.md') {
content = serializeHistoryToMarkdown(history);
} else {
throw new Error(
`Unsupported file extension: ${extension}. Use .json or .md.`,
);
}
const dir = path.dirname(filePath);
await fsPromises.mkdir(dir, { recursive: true });
await fsPromises.writeFile(filePath, content, 'utf-8');
}