diff --git a/packages/cli/src/ui/commands/setupGithubCommand.test.ts b/packages/cli/src/ui/commands/setupGithubCommand.test.ts index ae6378c7..6417c60a 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.test.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.test.ts @@ -5,13 +5,22 @@ */ 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'; vi.mock('child_process'); -describe('setupGithubCommand', () => { +// Mock fetch globally +global.fetch = vi.fn(); + +vi.mock('../../utils/gitUtils.js', () => ({ + isGitHubRepository: vi.fn(), + getGitRepoRoot: vi.fn(), + getLatestGitHubRelease: vi.fn(), +})); + +describe('setupGithubCommand', async () => { beforeEach(() => { vi.resetAllMocks(); }); @@ -20,49 +29,35 @@ describe('setupGithubCommand', () => { vi.restoreAllMocks(); }); - it('returns a tool action to download github workflows and handles paths', () => { + it('returns a tool action to download github workflows and handles paths', async () => { const fakeRepoRoot = '/github.com/fake/repo/root'; - vi.mocked(child_process.execSync).mockReturnValue(fakeRepoRoot); + const fakeReleaseVersion = 'v1.2.3'; - const result = setupGithubCommand.action?.( + vi.mocked(gitUtils.isGitHubRepository).mockReturnValueOnce(true); + vi.mocked(gitUtils.getGitRepoRoot).mockReturnValueOnce(fakeRepoRoot); + vi.mocked(gitUtils.getLatestGitHubRelease).mockResolvedValueOnce( + fakeReleaseVersion, + ); + + 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 = [ + `set -eEuo pipefail`, `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/', + `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/`, ]; 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 GitHub repository. /setup-github must be run from a git repository.', - ); - }); }); diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index 047e11eb..1b5b3277 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -5,8 +5,13 @@ */ import path from 'path'; -import { execSync } from 'child_process'; -import { isGitHubRepository } from '../../utils/gitUtils.js'; + +import { CommandContext } from '../../ui/commands/types.js'; +import { + getGitRepoRoot, + getLatestGitHubRelease, + isGitHubRepository, +} from '../../utils/gitUtils.js'; import { CommandKind, @@ -18,26 +23,29 @@ export const setupGithubCommand: SlashCommand = { name: 'setup-github', description: 'Set up GitHub Actions', kind: CommandKind.BUILT_IN, - action: (): SlashCommandActionReturn => { + action: async ( + context: CommandContext, + ): Promise => { if (!isGitHubRepository()) { throw new Error( 'Unable to determine the GitHub repository. /setup-github must be run from a git repository.', ); } - let gitRootRepo: string; + // Find the root directory of the repo + let gitRepoRoot: string; try { - gitRootRepo = execSync('git rev-parse --show-toplevel', { - encoding: 'utf-8', - }).trim(); - } catch { + 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.', ); } - const version = 'v0'; - const workflowBaseUrl = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${version}/examples/workflows/`; + // Get the latest release tag from GitHub + const proxy = context?.services?.config?.getProxy(); + const releaseTag = await getLatestGitHubRelease(proxy); const workflows = [ 'gemini-cli/gemini-cli.yml', @@ -46,16 +54,29 @@ 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. Follow steps in https://github.com/google-github-actions/run-gemini-cli/blob/v0/README.md#quick-start (skipping the /setup-github step) to complete setup."', - 'open https://github.com/google-github-actions/run-gemini-cli/blob/v0/README.md#quick-start', - ].join(' && '); + 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"`); + + 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}"`], + ); + commands.push(curlCommand); + } + + commands.push( + `echo "Successfully downloaded ${workflows.length} workflows. Follow the steps in https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start (skipping the /setup-github step) to complete setup."`, + `open https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`, + ); + + const command = `(${commands.join(' && ')})`; return { type: 'tool', toolName: 'run_shell_command', @@ -67,3 +88,20 @@ 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.test.ts b/packages/cli/src/utils/gitUtils.test.ts new file mode 100644 index 00000000..4a29f589 --- /dev/null +++ b/packages/cli/src/utils/gitUtils.test.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, expect, it, afterEach, beforeEach } from 'vitest'; +import * as child_process from 'child_process'; +import { + isGitHubRepository, + getGitRepoRoot, + getLatestGitHubRelease, +} from './gitUtils.js'; + +vi.mock('child_process'); + +describe('isGitHubRepository', async () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns false if the git command fails', async () => { + vi.mocked(child_process.execSync).mockImplementation((): string => { + throw new Error('oops'); + }); + expect(isGitHubRepository()).toBe(false); + }); + + it('returns false if the remote is not github.com', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce('https://gitlab.com'); + expect(isGitHubRepository()).toBe(false); + }); + + it('returns true if the remote is github.com', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce(` + origin https://github.com/sethvargo/gemini-cli (fetch) + origin https://github.com/sethvargo/gemini-cli (push) + `); + expect(isGitHubRepository()).toBe(true); + }); +}); + +describe('getGitRepoRoot', async () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('throws an error if git root cannot be determined', async () => { + vi.mocked(child_process.execSync).mockImplementation((): string => { + throw new Error('oops'); + }); + expect(() => { + getGitRepoRoot(); + }).toThrowError(/oops/); + }); + + it('throws an error if git root is empty', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce(''); + expect(() => { + getGitRepoRoot(); + }).toThrowError(/Git repo returned empty value/); + }); + + it('returns the root', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce('/path/to/git/repo'); + expect(getGitRepoRoot()).toBe('/path/to/git/repo'); + }); +}); + +describe('getLatestRelease', async () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('throws an error if the fetch fails', async () => { + global.fetch = vi.fn(() => Promise.reject('nope')); + expect(getLatestGitHubRelease()).rejects.toThrowError( + /Unable to determine the latest/, + ); + }); + + it('throws an error if the fetch does not return a json body', async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ foo: 'bar' }), + } as Response), + ); + expect(getLatestGitHubRelease()).rejects.toThrowError( + /Unable to determine the latest/, + ); + }); + + it('returns the release version', async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ tag_name: 'v1.2.3' }), + } as Response), + ); + expect(getLatestGitHubRelease()).resolves.toBe('v1.2.3'); + }); +}); diff --git a/packages/cli/src/utils/gitUtils.ts b/packages/cli/src/utils/gitUtils.ts index d510008c..30ca2245 100644 --- a/packages/cli/src/utils/gitUtils.ts +++ b/packages/cli/src/utils/gitUtils.ts @@ -5,22 +5,89 @@ */ import { execSync } from 'child_process'; +import { ProxyAgent, setGlobalDispatcher } from 'undici'; /** * Checks if a directory is within a git repository hosted on GitHub. * @returns true if the directory is in a git repository with a github.com remote, false otherwise */ -export function isGitHubRepository(): boolean { +export const isGitHubRepository = (): boolean => { try { - const remotes = execSync('git remote -v', { - encoding: 'utf-8', - }); + const remotes = ( + execSync('git remote -v', { + encoding: 'utf-8', + }) || '' + ).trim(); const pattern = /github\.com/; return pattern.test(remotes); } catch (_error) { // If any filesystem error occurs, assume not a git repo + console.debug(`Failed to get git remote:`, _error); return false; } -} +}; + +/** + * getGitRepoRoot returns the root directory of the git repository. + * @returns the path to the root of the git repo. + * @throws error if the exec command fails. + */ +export const getGitRepoRoot = (): string => { + const gitRepoRoot = ( + execSync('git rev-parse --show-toplevel', { + encoding: 'utf-8', + }) || '' + ).trim(); + + if (!gitRepoRoot) { + throw new Error(`Git repo returned empty value`); + } + + return gitRepoRoot; +}; + +/** + * getLatestGitHubRelease returns the release tag as a string. + * @returns string of the release tag (e.g. "v1.2.3"). + */ +export const getLatestGitHubRelease = async ( + proxy?: string, +): 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`; + + const response = await fetch(endpoint, { + method: 'GET', + headers: { + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error( + `Invalid response code: ${response.status} - ${response.statusText}`, + ); + } + + const releaseTag = (await response.json()).tag_name; + if (!releaseTag) { + throw new Error(`Response did not include tag_name field`); + } + return releaseTag; + } catch (_error) { + console.debug(`Failed to determine latest run-gemini-cli release:`, _error); + throw new Error( + `Unable to determine the latest run-gemini-cli release on GitHub.`, + ); + } +};