mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 14:10:37 -07:00
feat(cli): export chat history in /bug and prefill GitHub issue (#16115)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -56,3 +56,4 @@ patch_output.log
|
||||
|
||||
.genkit
|
||||
.gemini-clipboard/
|
||||
.eslintcache
|
||||
|
||||
@@ -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: () => [],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
79
packages/cli/src/ui/utils/historyExportUtils.ts
Normal file
79
packages/cli/src/ui/utils/historyExportUtils.ts
Normal 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');
|
||||
}
|
||||
Reference in New Issue
Block a user