mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-13 23:51:16 -07:00
Copy commands as part of setup-github (#13464)
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
|||||||
setupGithubCommand,
|
setupGithubCommand,
|
||||||
updateGitignore,
|
updateGitignore,
|
||||||
GITHUB_WORKFLOW_PATHS,
|
GITHUB_WORKFLOW_PATHS,
|
||||||
|
GITHUB_COMMANDS_PATHS,
|
||||||
} from './setupGithubCommand.js';
|
} from './setupGithubCommand.js';
|
||||||
import type { CommandContext, ToolActionReturn } from './types.js';
|
import type { CommandContext, ToolActionReturn } from './types.js';
|
||||||
import * as commandUtils from '../utils/commandUtils.js';
|
import * as commandUtils from '../utils/commandUtils.js';
|
||||||
@@ -56,11 +57,16 @@ describe('setupGithubCommand', async () => {
|
|||||||
const fakeReleaseVersion = 'v1.2.3';
|
const fakeReleaseVersion = 'v1.2.3';
|
||||||
|
|
||||||
const workflows = GITHUB_WORKFLOW_PATHS.map((p) => path.basename(p));
|
const workflows = GITHUB_WORKFLOW_PATHS.map((p) => path.basename(p));
|
||||||
for (const workflow of workflows) {
|
const commands = GITHUB_COMMANDS_PATHS.map((p) => path.basename(p));
|
||||||
vi.mocked(global.fetch).mockReturnValueOnce(
|
|
||||||
Promise.resolve(new Response(workflow)),
|
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.isGitHubRepository).mockReturnValueOnce(true);
|
||||||
vi.mocked(gitUtils.getGitRepoRoot).mockReturnValueOnce(fakeRepoRoot);
|
vi.mocked(gitUtils.getGitRepoRoot).mockReturnValueOnce(fakeRepoRoot);
|
||||||
@@ -102,6 +108,12 @@ describe('setupGithubCommand', async () => {
|
|||||||
expect(contents).toContain(workflow);
|
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
|
// Verify that .gitignore was created with the expected entries
|
||||||
const gitignorePath = path.join(scratchDir, '.gitignore');
|
const gitignorePath = path.join(scratchDir, '.gitignore');
|
||||||
const gitignoreExists = await fs
|
const gitignoreExists = await fs
|
||||||
@@ -116,6 +128,32 @@ describe('setupGithubCommand', async () => {
|
|||||||
expect(gitignoreContent).toContain('gha-creds-*.json');
|
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', () => {
|
describe('updateGitignore', () => {
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ export const GITHUB_WORKFLOW_PATHS = [
|
|||||||
'pr-review/gemini-review.yml',
|
'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.
|
// Generate OS-specific commands to open the GitHub pages needed for setup.
|
||||||
function getOpenUrlsCommands(readmeUrl: string): string[] {
|
function getOpenUrlsCommands(readmeUrl: string): string[] {
|
||||||
// Determine the OS-specific command to open URLs, ex: 'open', 'xdg-open', etc
|
// 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 = {
|
export const setupGithubCommand: SlashCommand = {
|
||||||
name: 'setup-github',
|
name: 'setup-github',
|
||||||
description: 'Set up GitHub Actions',
|
description: 'Set up GitHub Actions',
|
||||||
@@ -97,8 +206,6 @@ export const setupGithubCommand: SlashCommand = {
|
|||||||
action: async (
|
action: async (
|
||||||
context: CommandContext,
|
context: CommandContext,
|
||||||
): Promise<SlashCommandActionReturn> => {
|
): Promise<SlashCommandActionReturn> => {
|
||||||
const abortController = new AbortController();
|
|
||||||
|
|
||||||
if (!isGitHubRepository()) {
|
if (!isGitHubRepository()) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Unable to determine the GitHub repository. /setup-github must be run from a git repository.',
|
'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 releaseTag = await getLatestGitHubRelease(proxy);
|
||||||
const readmeUrl = `https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`;
|
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
|
// Create workflows directory
|
||||||
const githubWorkflowsDir = path.join(gitRepoRoot, '.github', 'workflows');
|
const workflowsDir = path.join(gitRepoRoot, '.github', 'workflows');
|
||||||
try {
|
await createDirectory(workflowsDir);
|
||||||
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?`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download each workflow in parallel - there aren't enough files to warrant
|
// Create commands directory
|
||||||
// a full workerpool model here.
|
const commandsDir = path.join(gitRepoRoot, '.github', 'commands');
|
||||||
const downloads = [];
|
await createDirectory(commandsDir);
|
||||||
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);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
await downloadSetupFiles({
|
||||||
throw new Error(
|
configs: [
|
||||||
`Invalid response code downloading ${endpoint}: ${response.status} - ${response.statusText}`,
|
{ paths: GITHUB_WORKFLOW_PATHS, targetDir: workflowsDir },
|
||||||
);
|
{ paths: GITHUB_COMMANDS_PATHS, targetDir: commandsDir },
|
||||||
}
|
],
|
||||||
const body = response.body;
|
releaseTag,
|
||||||
if (!body) {
|
proxy,
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add entries to .gitignore file
|
// Add entries to .gitignore file
|
||||||
@@ -192,7 +252,7 @@ export const setupGithubCommand: SlashCommand = {
|
|||||||
const commands = [];
|
const commands = [];
|
||||||
commands.push('set -eEuo pipefail');
|
commands.push('set -eEuo pipefail');
|
||||||
commands.push(
|
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));
|
commands.push(...getOpenUrlsCommands(readmeUrl));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user