diff --git a/packages/cli/src/ui/commands/setupGithubCommand.test.ts b/packages/cli/src/ui/commands/setupGithubCommand.test.ts index be0a657f..38589e58 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.test.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.test.ts @@ -4,10 +4,15 @@ * 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 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'); @@ -21,21 +26,43 @@ vi.mock('../../utils/gitUtils.js', () => ({ getGitHubRepoInfo: vi.fn(), })); +vi.mock('../utils/commandUtils.js', () => ({ + getUrlOpenCommand: vi.fn(), +})); + describe('setupGithubCommand', async () => { - beforeEach(() => { + 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', async () => { const fakeRepoOwner = 'fake'; const fakeRepoName = 'repo'; - const fakeRepoRoot = `/github.com/${fakeRepoOwner}/${fakeRepoName}/root`; + const fakeRepoRoot = scratchDir; const fakeReleaseVersion = 'v1.2.3'; + 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( @@ -45,6 +72,9 @@ describe('setupGithubCommand', async () => { owner: fakeRepoOwner, repo: fakeRepoName, }); + vi.mocked(commandUtils.getUrlOpenCommand).mockReturnValueOnce( + 'fakeOpenCommand', + ); const result = (await setupGithubCommand.action?.( {} as CommandContext, @@ -55,16 +85,22 @@ describe('setupGithubCommand', async () => { const expectedSubstrings = [ `set -eEuo pipefail`, - `mkdir -p "${fakeRepoRoot}/.github/workflows"`, - `curl --fail --location --output "/github.com/fake/repo/root/.github/workflows/gemini-cli.yml" --show-error --silent`, - `curl --fail --location --output "/github.com/fake/repo/root/.github/workflows/gemini-issue-automated-triage.yml" --show-error --silent`, - `curl --fail --location --output "/github.com/fake/repo/root/.github/workflows/gemini-issue-scheduled-triage.yml" --show-error --silent`, - `curl --fail --location --output "/github.com/fake/repo/root/.github/workflows/gemini-pr-review.yml" --show-error --silent`, - `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/`, + `fakeOpenCommand "https://github.com/google-github-actions/run-gemini-cli`, ]; for (const substring of expectedSubstrings) { expect(command).toContain(substring); } + + for (const workflow of workflows) { + const workflowFile = path.join( + scratchDir, + '.github', + 'workflows', + workflow, + ); + const contents = await fs.readFile(workflowFile, 'utf8'); + expect(contents).toContain(workflow); + } }); }); diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index 84d6b5af..2f024e60 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -4,7 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import path from 'path'; +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 { @@ -48,6 +51,8 @@ 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.', @@ -68,7 +73,24 @@ export const setupGithubCommand: SlashCommand = { // 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', @@ -76,29 +98,60 @@ export const setupGithubCommand: SlashCommand = { 'pr-review/gemini-pr-review.yml', ]; - const commands = []; - - // Ensure fast exit - commands.push(`set -eEuo pipefail`); - - // Make the directory if it doesn't exist - commands.push(`mkdir -p "${gitRepoRoot}/.github/workflows"`); - + const downloads = []; for (const workflow of workflows) { - const fileName = path.basename(workflow); - const curlCommand = buildCurlCommand( - `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${releaseTag}/examples/workflows/${workflow}`, - [`--output "${gitRepoRoot}/.github/workflows/${fileName}"`], + 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)); + })(), ); - commands.push(curlCommand); } - const readmeUrl = `https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`; + // 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(' && ')})`; @@ -113,20 +166,3 @@ export const setupGithubCommand: SlashCommand = { }; }, }; - -// buildCurlCommand is a helper for constructing a consistent curl command. -function buildCurlCommand(u: string, additionalArgs?: string[]): string { - const args = []; - args.push('--fail'); - args.push('--location'); - args.push('--show-error'); - args.push('--silent'); - - for (const val of additionalArgs || []) { - args.push(val); - } - - args.sort(); - - return `curl ${args.join(' ')} "${u}"`; -} diff --git a/packages/cli/src/utils/gitUtils.ts b/packages/cli/src/utils/gitUtils.ts index f5f9cb92..7b271ac4 100644 --- a/packages/cli/src/utils/gitUtils.ts +++ b/packages/cli/src/utils/gitUtils.ts @@ -5,7 +5,7 @@ */ import { execSync } from 'child_process'; -import { ProxyAgent, setGlobalDispatcher } from 'undici'; +import { ProxyAgent } from 'undici'; /** * Checks if a directory is within a git repository hosted on GitHub. @@ -57,9 +57,6 @@ export const getLatestGitHubRelease = async ( ): Promise => { try { const controller = new AbortController(); - if (proxy) { - setGlobalDispatcher(new ProxyAgent(proxy)); - } const endpoint = `https://api.github.com/repos/google-github-actions/run-gemini-cli/releases/latest`; @@ -70,8 +67,9 @@ export const getLatestGitHubRelease = async ( 'Content-Type': 'application/json', 'X-GitHub-Api-Version': '2022-11-28', }, - signal: controller.signal, - }); + dispatcher: proxy ? new ProxyAgent(proxy) : undefined, + signal: AbortSignal.any([AbortSignal.timeout(30_000), controller.signal]), + } as RequestInit); if (!response.ok) { throw new Error(