mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
chore: sync gemini-cli v0.1.19
This commit is contained in:
@@ -168,8 +168,12 @@ describe('chatCommand', () => {
|
||||
describe('save subcommand', () => {
|
||||
let saveCommand: SlashCommand;
|
||||
const tag = 'my-tag';
|
||||
let mockCheckpointExists: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
saveCommand = getSubCommand('save');
|
||||
mockCheckpointExists = vi.fn().mockResolvedValue(false);
|
||||
mockContext.services.logger.checkpointExists = mockCheckpointExists;
|
||||
});
|
||||
|
||||
it('should return an error if tag is missing', async () => {
|
||||
@@ -191,7 +195,7 @@ describe('chatCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should save the conversation', async () => {
|
||||
it('should save the conversation if checkpoint does not exist', async () => {
|
||||
const history: HistoryItemWithoutId[] = [
|
||||
{
|
||||
type: 'user',
|
||||
@@ -199,8 +203,52 @@ describe('chatCommand', () => {
|
||||
},
|
||||
];
|
||||
mockGetHistory.mockReturnValue(history);
|
||||
mockCheckpointExists.mockResolvedValue(false);
|
||||
|
||||
const result = await saveCommand?.action?.(mockContext, tag);
|
||||
|
||||
expect(mockCheckpointExists).toHaveBeenCalledWith(tag);
|
||||
expect(mockSaveCheckpoint).toHaveBeenCalledWith(history, tag);
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Conversation checkpoint saved with tag: ${tag}.`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return confirm_action if checkpoint already exists', async () => {
|
||||
mockCheckpointExists.mockResolvedValue(true);
|
||||
mockContext.invocation = {
|
||||
raw: `/chat save ${tag}`,
|
||||
name: 'save',
|
||||
args: tag,
|
||||
};
|
||||
|
||||
const result = await saveCommand?.action?.(mockContext, tag);
|
||||
|
||||
expect(mockCheckpointExists).toHaveBeenCalledWith(tag);
|
||||
expect(mockSaveCheckpoint).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
type: 'confirm_action',
|
||||
originalInvocation: { raw: `/chat save ${tag}` },
|
||||
});
|
||||
// Check that prompt is a React element
|
||||
expect(result).toHaveProperty('prompt');
|
||||
});
|
||||
|
||||
it('should save the conversation if overwrite is confirmed', async () => {
|
||||
const history: HistoryItemWithoutId[] = [
|
||||
{
|
||||
type: 'user',
|
||||
text: 'hello',
|
||||
},
|
||||
];
|
||||
mockGetHistory.mockReturnValue(history);
|
||||
mockContext.overwriteConfirmed = true;
|
||||
|
||||
const result = await saveCommand?.action?.(mockContext, tag);
|
||||
|
||||
expect(mockCheckpointExists).not.toHaveBeenCalled(); // Should skip existence check
|
||||
expect(mockSaveCheckpoint).toHaveBeenCalledWith(history, tag);
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
|
||||
@@ -5,11 +5,15 @@
|
||||
*/
|
||||
|
||||
import * as fsPromises from 'fs/promises';
|
||||
import React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import {
|
||||
CommandContext,
|
||||
SlashCommand,
|
||||
MessageActionReturn,
|
||||
CommandKind,
|
||||
SlashCommandActionReturn,
|
||||
} from './types.js';
|
||||
import path from 'path';
|
||||
import { HistoryItemWithoutId, MessageType } from '../types.js';
|
||||
@@ -96,7 +100,7 @@ const saveCommand: SlashCommand = {
|
||||
description:
|
||||
'Save the current conversation as a checkpoint. Usage: /chat save <tag>',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context, args): Promise<MessageActionReturn> => {
|
||||
action: async (context, args): Promise<SlashCommandActionReturn | void> => {
|
||||
const tag = args.trim();
|
||||
if (!tag) {
|
||||
return {
|
||||
@@ -108,6 +112,26 @@ const saveCommand: SlashCommand = {
|
||||
|
||||
const { logger, config } = context.services;
|
||||
await logger.initialize();
|
||||
|
||||
if (!context.overwriteConfirmed) {
|
||||
const exists = await logger.checkpointExists(tag);
|
||||
if (exists) {
|
||||
return {
|
||||
type: 'confirm_action',
|
||||
prompt: React.createElement(
|
||||
Text,
|
||||
null,
|
||||
'A checkpoint with the tag ',
|
||||
React.createElement(Text, { color: Colors.AccentPurple }, tag),
|
||||
' already exists. Do you want to overwrite it?',
|
||||
),
|
||||
originalInvocation: {
|
||||
raw: context.invocation?.raw || `/chat save ${tag}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const chat = await config?.getGeminiClient()?.getChat();
|
||||
if (!chat) {
|
||||
return {
|
||||
|
||||
@@ -93,13 +93,14 @@ describe('ideCommand', () => {
|
||||
} as unknown as ReturnType<Config['getIdeClient']>);
|
||||
});
|
||||
|
||||
it('should show connected status', () => {
|
||||
it('should show connected status', async () => {
|
||||
mockGetConnectionStatus.mockReturnValue({
|
||||
status: core.IDEConnectionStatus.Connected,
|
||||
});
|
||||
const command = ideCommand(mockConfig);
|
||||
const result = command!.subCommands!.find((c) => c.name === 'status')!
|
||||
.action!(mockContext, '');
|
||||
const result = await command!.subCommands!.find(
|
||||
(c) => c.name === 'status',
|
||||
)!.action!(mockContext, '');
|
||||
expect(mockGetConnectionStatus).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
@@ -108,13 +109,14 @@ describe('ideCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should show connecting status', () => {
|
||||
it('should show connecting status', async () => {
|
||||
mockGetConnectionStatus.mockReturnValue({
|
||||
status: core.IDEConnectionStatus.Connecting,
|
||||
});
|
||||
const command = ideCommand(mockConfig);
|
||||
const result = command!.subCommands!.find((c) => c.name === 'status')!
|
||||
.action!(mockContext, '');
|
||||
const result = await command!.subCommands!.find(
|
||||
(c) => c.name === 'status',
|
||||
)!.action!(mockContext, '');
|
||||
expect(mockGetConnectionStatus).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
@@ -122,13 +124,14 @@ describe('ideCommand', () => {
|
||||
content: `🟡 Connecting...`,
|
||||
});
|
||||
});
|
||||
it('should show disconnected status', () => {
|
||||
it('should show disconnected status', async () => {
|
||||
mockGetConnectionStatus.mockReturnValue({
|
||||
status: core.IDEConnectionStatus.Disconnected,
|
||||
});
|
||||
const command = ideCommand(mockConfig);
|
||||
const result = command!.subCommands!.find((c) => c.name === 'status')!
|
||||
.action!(mockContext, '');
|
||||
const result = await command!.subCommands!.find(
|
||||
(c) => c.name === 'status',
|
||||
)!.action!(mockContext, '');
|
||||
expect(mockGetConnectionStatus).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
@@ -137,15 +140,16 @@ describe('ideCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should show disconnected status with details', () => {
|
||||
it('should show disconnected status with details', async () => {
|
||||
const details = 'Something went wrong';
|
||||
mockGetConnectionStatus.mockReturnValue({
|
||||
status: core.IDEConnectionStatus.Disconnected,
|
||||
details,
|
||||
});
|
||||
const command = ideCommand(mockConfig);
|
||||
const result = command!.subCommands!.find((c) => c.name === 'status')!
|
||||
.action!(mockContext, '');
|
||||
const result = await command!.subCommands!.find(
|
||||
(c) => c.name === 'status',
|
||||
)!.action!(mockContext, '');
|
||||
expect(mockGetConnectionStatus).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
|
||||
@@ -8,10 +8,13 @@ import {
|
||||
Config,
|
||||
DetectedIde,
|
||||
IDEConnectionStatus,
|
||||
IdeClient,
|
||||
getIdeDisplayName,
|
||||
getIdeInstaller,
|
||||
IdeClient,
|
||||
type File,
|
||||
ideContext,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
CommandContext,
|
||||
SlashCommand,
|
||||
@@ -49,6 +52,68 @@ function getIdeStatusMessage(ideClient: IdeClient): {
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileList(openFiles: File[]): string {
|
||||
const basenameCounts = new Map<string, number>();
|
||||
for (const file of openFiles) {
|
||||
const basename = path.basename(file.path);
|
||||
basenameCounts.set(basename, (basenameCounts.get(basename) || 0) + 1);
|
||||
}
|
||||
|
||||
const fileList = openFiles
|
||||
.map((file: File) => {
|
||||
const basename = path.basename(file.path);
|
||||
const isDuplicate = (basenameCounts.get(basename) || 0) > 1;
|
||||
const parentDir = path.basename(path.dirname(file.path));
|
||||
const displayName = isDuplicate
|
||||
? `${basename} (/${parentDir})`
|
||||
: basename;
|
||||
|
||||
return ` - ${displayName}${file.isActive ? ' (active)' : ''}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const infoMessage = `
|
||||
(Note: The file list is limited to a number of recently accessed files within your workspace and only includes local files on disk)`;
|
||||
|
||||
return `\n\nOpen files:\n${fileList}\n${infoMessage}`;
|
||||
}
|
||||
|
||||
async function getIdeStatusMessageWithFiles(ideClient: IdeClient): Promise<{
|
||||
messageType: 'info' | 'error';
|
||||
content: string;
|
||||
}> {
|
||||
const connection = ideClient.getConnectionStatus();
|
||||
switch (connection.status) {
|
||||
case IDEConnectionStatus.Connected: {
|
||||
let content = `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`;
|
||||
const context = ideContext.getIdeContext();
|
||||
const openFiles = context?.workspaceState?.openFiles;
|
||||
if (openFiles && openFiles.length > 0) {
|
||||
content += formatFileList(openFiles);
|
||||
}
|
||||
return {
|
||||
messageType: 'info',
|
||||
content,
|
||||
};
|
||||
}
|
||||
case IDEConnectionStatus.Connecting:
|
||||
return {
|
||||
messageType: 'info',
|
||||
content: `🟡 Connecting...`,
|
||||
};
|
||||
default: {
|
||||
let content = `🔴 Disconnected`;
|
||||
if (connection?.details) {
|
||||
content += `: ${connection.details}`;
|
||||
}
|
||||
return {
|
||||
messageType: 'error',
|
||||
content,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ideCommand = (config: Config | null): SlashCommand | null => {
|
||||
if (!config || !config.getIdeModeFeature()) {
|
||||
return null;
|
||||
@@ -84,8 +149,9 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
|
||||
name: 'status',
|
||||
description: 'check status of IDE integration',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (): SlashCommandActionReturn => {
|
||||
const { messageType, content } = getIdeStatusMessage(ideClient);
|
||||
action: async (): Promise<SlashCommandActionReturn> => {
|
||||
const { messageType, content } =
|
||||
await getIdeStatusMessageWithFiles(ideClient);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType,
|
||||
|
||||
36
packages/cli/src/ui/commands/settingsCommand.test.ts
Normal file
36
packages/cli/src/ui/commands/settingsCommand.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { settingsCommand } from './settingsCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
describe('settingsCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext();
|
||||
});
|
||||
|
||||
it('should return a dialog action to open the settings dialog', () => {
|
||||
if (!settingsCommand.action) {
|
||||
throw new Error('The settings command must have an action.');
|
||||
}
|
||||
const result = settingsCommand.action(mockContext, '');
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'settings',
|
||||
});
|
||||
});
|
||||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(settingsCommand.name).toBe('settings');
|
||||
expect(settingsCommand.description).toBe(
|
||||
'View and edit Gemini CLI settings',
|
||||
);
|
||||
});
|
||||
});
|
||||
17
packages/cli/src/ui/commands/settingsCommand.ts
Normal file
17
packages/cli/src/ui/commands/settingsCommand.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||
|
||||
export const settingsCommand: SlashCommand = {
|
||||
name: 'settings',
|
||||
description: 'View and edit Gemini CLI settings',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (_context, _args): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'settings',
|
||||
}),
|
||||
};
|
||||
@@ -4,63 +4,103 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
import { vi, describe, expect, it, afterEach, beforeEach } from 'vitest';
|
||||
import * as child_process from 'child_process';
|
||||
import * as gitUtils from '../../utils/gitUtils.js';
|
||||
import { setupGithubCommand } from './setupGithubCommand.js';
|
||||
import { CommandContext, ToolActionReturn } from './types.js';
|
||||
import * as commandUtils from '../utils/commandUtils.js';
|
||||
|
||||
vi.mock('child_process');
|
||||
|
||||
describe('setupGithubCommand', () => {
|
||||
beforeEach(() => {
|
||||
// Mock fetch globally
|
||||
global.fetch = vi.fn();
|
||||
|
||||
vi.mock('../../utils/gitUtils.js', () => ({
|
||||
isGitHubRepository: vi.fn(),
|
||||
getGitRepoRoot: vi.fn(),
|
||||
getLatestGitHubRelease: vi.fn(),
|
||||
getGitHubRepoInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/commandUtils.js', () => ({
|
||||
getUrlOpenCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('setupGithubCommand', async () => {
|
||||
let scratchDir = '';
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
scratchDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'setup-github-command-'),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
if (scratchDir) await fs.rm(scratchDir, { recursive: true });
|
||||
});
|
||||
|
||||
it('returns a tool action to download github workflows and handles paths', () => {
|
||||
const fakeRepoRoot = '/github.com/fake/repo/root';
|
||||
vi.mocked(child_process.execSync).mockReturnValue(fakeRepoRoot);
|
||||
it('returns a tool action to download github workflows and handles paths', async () => {
|
||||
const fakeRepoOwner = 'fake';
|
||||
const fakeRepoName = 'repo';
|
||||
const fakeRepoRoot = scratchDir;
|
||||
const fakeReleaseVersion = 'v1.2.3';
|
||||
|
||||
const result = setupGithubCommand.action?.(
|
||||
const workflows = [
|
||||
'gemini-cli.yml',
|
||||
'gemini-issue-automated-triage.yml',
|
||||
'gemini-issue-scheduled-triage.yml',
|
||||
'gemini-pr-review.yml',
|
||||
];
|
||||
for (const workflow of workflows) {
|
||||
vi.mocked(global.fetch).mockReturnValueOnce(
|
||||
Promise.resolve(new Response(workflow)),
|
||||
);
|
||||
}
|
||||
|
||||
vi.mocked(gitUtils.isGitHubRepository).mockReturnValueOnce(true);
|
||||
vi.mocked(gitUtils.getGitRepoRoot).mockReturnValueOnce(fakeRepoRoot);
|
||||
vi.mocked(gitUtils.getLatestGitHubRelease).mockResolvedValueOnce(
|
||||
fakeReleaseVersion,
|
||||
);
|
||||
vi.mocked(gitUtils.getGitHubRepoInfo).mockReturnValue({
|
||||
owner: fakeRepoOwner,
|
||||
repo: fakeRepoName,
|
||||
});
|
||||
vi.mocked(commandUtils.getUrlOpenCommand).mockReturnValueOnce(
|
||||
'fakeOpenCommand',
|
||||
);
|
||||
|
||||
const result = (await setupGithubCommand.action?.(
|
||||
{} as CommandContext,
|
||||
'',
|
||||
) as ToolActionReturn;
|
||||
|
||||
expect(result.type).toBe('tool');
|
||||
expect(result.toolName).toBe('run_shell_command');
|
||||
expect(child_process.execSync).toHaveBeenCalledWith(
|
||||
'git rev-parse --show-toplevel',
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
},
|
||||
);
|
||||
expect(child_process.execSync).toHaveBeenCalledWith('git remote -v', {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
)) as ToolActionReturn;
|
||||
|
||||
const { command } = result.toolArgs;
|
||||
|
||||
const expectedSubstrings = [
|
||||
`mkdir -p "${fakeRepoRoot}/.github/workflows"`,
|
||||
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-cli.yml"`,
|
||||
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-issue-automated-triage.yml"`,
|
||||
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-issue-scheduled-triage.yml"`,
|
||||
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-pr-review.yml"`,
|
||||
'https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/v0/examples/workflows/',
|
||||
`set -eEuo pipefail`,
|
||||
`fakeOpenCommand "https://github.com/google-github-actions/run-gemini-cli`,
|
||||
];
|
||||
|
||||
for (const substring of expectedSubstrings) {
|
||||
expect(command).toContain(substring);
|
||||
}
|
||||
});
|
||||
|
||||
it('throws an error if git root cannot be determined', () => {
|
||||
vi.mocked(child_process.execSync).mockReturnValue('');
|
||||
expect(() => {
|
||||
setupGithubCommand.action?.({} as CommandContext, '');
|
||||
}).toThrow('Unable to determine the Git root directory.');
|
||||
for (const workflow of workflows) {
|
||||
const workflowFile = path.join(
|
||||
scratchDir,
|
||||
'.github',
|
||||
'workflows',
|
||||
workflow,
|
||||
);
|
||||
const contents = await fs.readFile(workflowFile, 'utf8');
|
||||
expect(contents).toContain(workflow);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,32 +4,93 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
import { isGitHubRepository } from '../../utils/gitUtils.js';
|
||||
import path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import { Writable } from 'node:stream';
|
||||
import { ProxyAgent } from 'undici';
|
||||
|
||||
import { CommandContext } from '../../ui/commands/types.js';
|
||||
import {
|
||||
getGitRepoRoot,
|
||||
getLatestGitHubRelease,
|
||||
isGitHubRepository,
|
||||
getGitHubRepoInfo,
|
||||
} from '../../utils/gitUtils.js';
|
||||
|
||||
import {
|
||||
CommandKind,
|
||||
SlashCommand,
|
||||
SlashCommandActionReturn,
|
||||
} from './types.js';
|
||||
import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js';
|
||||
|
||||
// Generate OS-specific commands to open the GitHub pages needed for setup.
|
||||
function getOpenUrlsCommands(readmeUrl: string): string[] {
|
||||
// Determine the OS-specific command to open URLs, ex: 'open', 'xdg-open', etc
|
||||
const openCmd = getUrlOpenCommand();
|
||||
|
||||
// Build a list of URLs to open
|
||||
const urlsToOpen = [readmeUrl];
|
||||
|
||||
const repoInfo = getGitHubRepoInfo();
|
||||
if (repoInfo) {
|
||||
urlsToOpen.push(
|
||||
`https://github.com/${repoInfo.owner}/${repoInfo.repo}/settings/secrets/actions`,
|
||||
);
|
||||
}
|
||||
|
||||
// Create and join the individual commands
|
||||
const commands = urlsToOpen.map((url) => `${openCmd} "${url}"`);
|
||||
return commands;
|
||||
}
|
||||
|
||||
export const setupGithubCommand: SlashCommand = {
|
||||
name: 'setup-github',
|
||||
description: 'Set up GitHub Actions',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (): SlashCommandActionReturn => {
|
||||
const gitRootRepo = execSync('git rev-parse --show-toplevel', {
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
): Promise<SlashCommandActionReturn> => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
if (!isGitHubRepository()) {
|
||||
throw new Error('Unable to determine the Git root directory.');
|
||||
throw new Error(
|
||||
'Unable to determine the GitHub repository. /setup-github must be run from a git repository.',
|
||||
);
|
||||
}
|
||||
|
||||
const version = 'v0';
|
||||
const workflowBaseUrl = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${version}/examples/workflows/`;
|
||||
// Find the root directory of the repo
|
||||
let gitRepoRoot: string;
|
||||
try {
|
||||
gitRepoRoot = getGitRepoRoot();
|
||||
} catch (_error) {
|
||||
console.debug(`Failed to get git repo root:`, _error);
|
||||
throw new Error(
|
||||
'Unable to determine the GitHub repository. /setup-github must be run from a git repository.',
|
||||
);
|
||||
}
|
||||
|
||||
// Get the latest release tag from GitHub
|
||||
const proxy = context?.services?.config?.getProxy();
|
||||
const releaseTag = await getLatestGitHubRelease(proxy);
|
||||
const readmeUrl = `https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`;
|
||||
|
||||
// Create the .github/workflows directory to download the files into
|
||||
const githubWorkflowsDir = path.join(gitRepoRoot, '.github', 'workflows');
|
||||
try {
|
||||
await fs.promises.mkdir(githubWorkflowsDir, { recursive: true });
|
||||
} catch (_error) {
|
||||
console.debug(
|
||||
`Failed to create ${githubWorkflowsDir} directory:`,
|
||||
_error,
|
||||
);
|
||||
throw new Error(
|
||||
`Unable to create ${githubWorkflowsDir} directory. Do you have file permissions in the current directory?`,
|
||||
);
|
||||
}
|
||||
|
||||
// Download each workflow in parallel - there aren't enough files to warrant
|
||||
// a full workerpool model here.
|
||||
const workflows = [
|
||||
'gemini-cli/gemini-cli.yml',
|
||||
'issue-triage/gemini-issue-automated-triage.yml',
|
||||
@@ -37,15 +98,63 @@ export const setupGithubCommand: SlashCommand = {
|
||||
'pr-review/gemini-pr-review.yml',
|
||||
];
|
||||
|
||||
const command = [
|
||||
'set -e',
|
||||
`mkdir -p "${gitRootRepo}/.github/workflows"`,
|
||||
...workflows.map((workflow) => {
|
||||
const fileName = path.basename(workflow);
|
||||
return `curl -fsSL -o "${gitRootRepo}/.github/workflows/${fileName}" "${workflowBaseUrl}/${workflow}"`;
|
||||
}),
|
||||
'echo "Workflows downloaded successfully."',
|
||||
].join(' && ');
|
||||
const downloads = [];
|
||||
for (const workflow of workflows) {
|
||||
downloads.push(
|
||||
(async () => {
|
||||
const endpoint = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${releaseTag}/examples/workflows/${workflow}`;
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
dispatcher: proxy ? new ProxyAgent(proxy) : undefined,
|
||||
signal: AbortSignal.any([
|
||||
AbortSignal.timeout(30_000),
|
||||
abortController.signal,
|
||||
]),
|
||||
} as RequestInit);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Invalid response code downloading ${endpoint}: ${response.status} - ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
const body = response.body;
|
||||
if (!body) {
|
||||
throw new Error(
|
||||
`Empty body while downloading ${endpoint}: ${response.status} - ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const destination = path.resolve(
|
||||
githubWorkflowsDir,
|
||||
path.basename(workflow),
|
||||
);
|
||||
|
||||
const fileStream = fs.createWriteStream(destination, {
|
||||
mode: 0o644, // -rw-r--r--, user(rw), group(r), other(r)
|
||||
flags: 'w', // write and overwrite
|
||||
flush: true,
|
||||
});
|
||||
|
||||
await body.pipeTo(Writable.toWeb(fileStream));
|
||||
})(),
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for all downloads to complete
|
||||
await Promise.all(downloads).finally(() => {
|
||||
// Stop existing downloads
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
// Print out a message
|
||||
const commands = [];
|
||||
commands.push('set -eEuo pipefail');
|
||||
commands.push(
|
||||
`echo "Successfully downloaded ${workflows.length} workflows. Follow the steps in ${readmeUrl} (skipping the /setup-github step) to complete setup."`,
|
||||
);
|
||||
commands.push(...getOpenUrlsCommands(readmeUrl));
|
||||
|
||||
const command = `(${commands.join(' && ')})`;
|
||||
return {
|
||||
type: 'tool',
|
||||
toolName: 'run_shell_command',
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type ReactNode } from 'react';
|
||||
import { Content } from '@google/genai';
|
||||
import { HistoryItemWithoutId } from '../types.js';
|
||||
import { Config, GitService, Logger } from '@qwen-code/qwen-code-core';
|
||||
@@ -68,6 +69,8 @@ export interface CommandContext {
|
||||
/** A transient list of shell commands the user has approved for this session. */
|
||||
sessionShellAllowlist: Set<string>;
|
||||
};
|
||||
// Flag to indicate if an overwrite has been confirmed
|
||||
overwriteConfirmed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,7 +103,8 @@ export interface MessageActionReturn {
|
||||
*/
|
||||
export interface OpenDialogActionReturn {
|
||||
type: 'dialog';
|
||||
dialog: 'auth' | 'theme' | 'editor' | 'privacy';
|
||||
|
||||
dialog: 'help' | 'auth' | 'theme' | 'editor' | 'privacy' | 'settings';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,6 +140,16 @@ export interface ConfirmShellCommandsActionReturn {
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConfirmActionReturn {
|
||||
type: 'confirm_action';
|
||||
/** The React node to display as the confirmation prompt. */
|
||||
prompt: ReactNode;
|
||||
/** The original invocation context to be re-run after confirmation. */
|
||||
originalInvocation: {
|
||||
raw: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type SlashCommandActionReturn =
|
||||
| ToolActionReturn
|
||||
| MessageActionReturn
|
||||
@@ -143,7 +157,8 @@ export type SlashCommandActionReturn =
|
||||
| OpenDialogActionReturn
|
||||
| LoadHistoryActionReturn
|
||||
| SubmitPromptActionReturn
|
||||
| ConfirmShellCommandsActionReturn;
|
||||
| ConfirmShellCommandsActionReturn
|
||||
| ConfirmActionReturn;
|
||||
|
||||
export enum CommandKind {
|
||||
BUILT_IN = 'built-in',
|
||||
|
||||
Reference in New Issue
Block a user