diff --git a/packages/cli/src/ui/commands/setupGithubCommand.test.ts b/packages/cli/src/ui/commands/setupGithubCommand.test.ts index bac0207045..6a97084fce 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.test.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.test.ts @@ -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', () => { diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index d790a01424..0d2d5dd99f 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -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 { } } +async function downloadFiles({ + paths, + releaseTag, + targetDir, + proxy, + abortController, +}: { + paths: string[]; + releaseTag: string; + targetDir: string; + proxy: string | undefined; + abortController: AbortController; +}): Promise { + 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 { + 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 { + 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 => { - 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));