mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
Copy commands as part of setup-github (#13464)
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
setupGithubCommand,
|
||||
updateGitignore,
|
||||
GITHUB_WORKFLOW_PATHS,
|
||||
GITHUB_COMMANDS_PATHS,
|
||||
} from './setupGithubCommand.js';
|
||||
import type { CommandContext, ToolActionReturn } from './types.js';
|
||||
import * as commandUtils from '../utils/commandUtils.js';
|
||||
@@ -56,11 +57,16 @@ describe('setupGithubCommand', async () => {
|
||||
const fakeReleaseVersion = 'v1.2.3';
|
||||
|
||||
const workflows = GITHUB_WORKFLOW_PATHS.map((p) => path.basename(p));
|
||||
for (const workflow of workflows) {
|
||||
vi.mocked(global.fetch).mockReturnValueOnce(
|
||||
Promise.resolve(new Response(workflow)),
|
||||
);
|
||||
}
|
||||
const commands = GITHUB_COMMANDS_PATHS.map((p) => path.basename(p));
|
||||
|
||||
vi.mocked(global.fetch).mockImplementation(async (url) => {
|
||||
const filename = path.basename(url.toString());
|
||||
return new Response(filename, {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
});
|
||||
|
||||
vi.mocked(gitUtils.isGitHubRepository).mockReturnValueOnce(true);
|
||||
vi.mocked(gitUtils.getGitRepoRoot).mockReturnValueOnce(fakeRepoRoot);
|
||||
@@ -102,6 +108,12 @@ describe('setupGithubCommand', async () => {
|
||||
expect(contents).toContain(workflow);
|
||||
}
|
||||
|
||||
for (const command of commands) {
|
||||
const commandFile = path.join(scratchDir, '.github', 'commands', command);
|
||||
const contents = await fs.readFile(commandFile, 'utf8');
|
||||
expect(contents).toContain(command);
|
||||
}
|
||||
|
||||
// Verify that .gitignore was created with the expected entries
|
||||
const gitignorePath = path.join(scratchDir, '.gitignore');
|
||||
const gitignoreExists = await fs
|
||||
@@ -116,6 +128,32 @@ describe('setupGithubCommand', async () => {
|
||||
expect(gitignoreContent).toContain('gha-creds-*.json');
|
||||
}
|
||||
});
|
||||
|
||||
it('throws an error when download fails', async () => {
|
||||
const fakeRepoRoot = scratchDir;
|
||||
const fakeReleaseVersion = 'v1.2.3';
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValue(
|
||||
new Response('Not Found', {
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(gitUtils.isGitHubRepository).mockReturnValueOnce(true);
|
||||
vi.mocked(gitUtils.getGitRepoRoot).mockReturnValueOnce(fakeRepoRoot);
|
||||
vi.mocked(gitUtils.getLatestGitHubRelease).mockResolvedValueOnce(
|
||||
fakeReleaseVersion,
|
||||
);
|
||||
vi.mocked(gitUtils.getGitHubRepoInfo).mockReturnValue({
|
||||
owner: 'fake',
|
||||
repo: 'repo',
|
||||
});
|
||||
|
||||
await expect(
|
||||
setupGithubCommand.action?.({} as CommandContext, ''),
|
||||
).rejects.toThrow(/Invalid response code downloading.*404 - Not Found/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateGitignore', () => {
|
||||
|
||||
@@ -30,6 +30,16 @@ export const GITHUB_WORKFLOW_PATHS = [
|
||||
'pr-review/gemini-review.yml',
|
||||
];
|
||||
|
||||
export const GITHUB_COMMANDS_PATHS = [
|
||||
'gemini-assistant/gemini-invoke.toml',
|
||||
'issue-triage/gemini-scheduled-triage.toml',
|
||||
'issue-triage/gemini-triage.toml',
|
||||
'pr-review/gemini-review.toml',
|
||||
];
|
||||
|
||||
const REPO_DOWNLOAD_URL =
|
||||
'https://raw.githubusercontent.com/google-github-actions/run-gemini-cli';
|
||||
const SOURCE_DIR = 'examples/workflows';
|
||||
// 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
|
||||
@@ -90,6 +100,105 @@ export async function updateGitignore(gitRepoRoot: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFiles({
|
||||
paths,
|
||||
releaseTag,
|
||||
targetDir,
|
||||
proxy,
|
||||
abortController,
|
||||
}: {
|
||||
paths: string[];
|
||||
releaseTag: string;
|
||||
targetDir: string;
|
||||
proxy: string | undefined;
|
||||
abortController: AbortController;
|
||||
}): Promise<void> {
|
||||
const downloads = [];
|
||||
for (const fileBasename of paths) {
|
||||
downloads.push(
|
||||
(async () => {
|
||||
const endpoint = `${REPO_DOWNLOAD_URL}/refs/tags/${releaseTag}/${SOURCE_DIR}/${fileBasename}`;
|
||||
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(
|
||||
targetDir,
|
||||
path.basename(fileBasename),
|
||||
);
|
||||
|
||||
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));
|
||||
})(),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(downloads).finally(() => {
|
||||
abortController.abort();
|
||||
});
|
||||
}
|
||||
|
||||
async function createDirectory(dirPath: string): Promise<void> {
|
||||
try {
|
||||
await fs.promises.mkdir(dirPath, { recursive: true });
|
||||
} catch (_error) {
|
||||
debugLogger.debug(`Failed to create ${dirPath} directory:`, _error);
|
||||
throw new Error(
|
||||
`Unable to create ${dirPath} directory. Do you have file permissions in the current directory?`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadSetupFiles({
|
||||
configs,
|
||||
releaseTag,
|
||||
proxy,
|
||||
}: {
|
||||
configs: Array<{ paths: string[]; targetDir: string }>;
|
||||
releaseTag: string;
|
||||
proxy: string | undefined;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await Promise.all(
|
||||
configs.map(({ paths, targetDir }) => {
|
||||
const abortController = new AbortController();
|
||||
return downloadFiles({
|
||||
paths,
|
||||
releaseTag,
|
||||
targetDir,
|
||||
proxy,
|
||||
abortController,
|
||||
});
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
debugLogger.debug('Failed to download required setup files: ', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const setupGithubCommand: SlashCommand = {
|
||||
name: 'setup-github',
|
||||
description: 'Set up GitHub Actions',
|
||||
@@ -97,8 +206,6 @@ export const setupGithubCommand: SlashCommand = {
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
): Promise<SlashCommandActionReturn> => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
if (!isGitHubRepository()) {
|
||||
throw new Error(
|
||||
'Unable to determine the GitHub repository. /setup-github must be run from a git repository.',
|
||||
@@ -121,68 +228,21 @@ export const setupGithubCommand: SlashCommand = {
|
||||
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) {
|
||||
debugLogger.debug(
|
||||
`Failed to create ${githubWorkflowsDir} directory:`,
|
||||
_error,
|
||||
);
|
||||
throw new Error(
|
||||
`Unable to create ${githubWorkflowsDir} directory. Do you have file permissions in the current directory?`,
|
||||
);
|
||||
}
|
||||
// Create workflows directory
|
||||
const workflowsDir = path.join(gitRepoRoot, '.github', 'workflows');
|
||||
await createDirectory(workflowsDir);
|
||||
|
||||
// Download each workflow in parallel - there aren't enough files to warrant
|
||||
// a full workerpool model here.
|
||||
const downloads = [];
|
||||
for (const workflow of GITHUB_WORKFLOW_PATHS) {
|
||||
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);
|
||||
// Create commands directory
|
||||
const commandsDir = path.join(gitRepoRoot, '.github', 'commands');
|
||||
await createDirectory(commandsDir);
|
||||
|
||||
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();
|
||||
await downloadSetupFiles({
|
||||
configs: [
|
||||
{ paths: GITHUB_WORKFLOW_PATHS, targetDir: workflowsDir },
|
||||
{ paths: GITHUB_COMMANDS_PATHS, targetDir: commandsDir },
|
||||
],
|
||||
releaseTag,
|
||||
proxy,
|
||||
});
|
||||
|
||||
// Add entries to .gitignore file
|
||||
@@ -192,7 +252,7 @@ export const setupGithubCommand: SlashCommand = {
|
||||
const commands = [];
|
||||
commands.push('set -eEuo pipefail');
|
||||
commands.push(
|
||||
`echo "Successfully downloaded ${GITHUB_WORKFLOW_PATHS.length} workflows and updated .gitignore. Follow the steps in ${readmeUrl} (skipping the /setup-github step) to complete setup."`,
|
||||
`echo "Successfully downloaded ${GITHUB_WORKFLOW_PATHS.length} workflows , ${GITHUB_COMMANDS_PATHS.length} commands and updated .gitignore. Follow the steps in ${readmeUrl} (skipping the /setup-github step) to complete setup."`,
|
||||
);
|
||||
commands.push(...getOpenUrlsCommands(readmeUrl));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user