diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 3b54047c..46ecb37c 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -31,6 +31,8 @@ import { statsCommand } from '../ui/commands/statsCommand.js'; import { themeCommand } from '../ui/commands/themeCommand.js'; import { toolsCommand } from '../ui/commands/toolsCommand.js'; import { vimCommand } from '../ui/commands/vimCommand.js'; +import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; +import { isGitHubRepository } from '../utils/gitUtils.js'; /** * Loads the core, hard-coded slash commands that are an integral part @@ -72,6 +74,7 @@ export class BuiltinCommandLoader implements ICommandLoader { themeCommand, toolsCommand, vimCommand, + ...(isGitHubRepository() ? [setupGithubCommand] : []), ]; return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null); diff --git a/packages/cli/src/ui/commands/setupGithubCommand.test.ts b/packages/cli/src/ui/commands/setupGithubCommand.test.ts new file mode 100644 index 00000000..fe68be0c --- /dev/null +++ b/packages/cli/src/ui/commands/setupGithubCommand.test.ts @@ -0,0 +1,66 @@ +/** + * @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 { setupGithubCommand } from './setupGithubCommand.js'; +import { CommandContext, ToolActionReturn } from './types.js'; + +vi.mock('child_process'); + +describe('setupGithubCommand', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns a tool action to download github workflows and handles paths', () => { + const fakeRepoRoot = '/github.com/fake/repo/root'; + vi.mocked(child_process.execSync).mockReturnValue(fakeRepoRoot); + + const result = 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', + }); + + const { command } = result.toolArgs; + + const expectedSubstrings = [ + `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/heads/main/workflows/', + ]; + + 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 Git root directory.'); + }); +}); diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts new file mode 100644 index 00000000..14314423 --- /dev/null +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'path'; +import { execSync } from 'child_process'; +import { isGitHubRepository } from '../../utils/gitUtils.js'; + +import { + CommandKind, + SlashCommand, + SlashCommandActionReturn, +} from './types.js'; + +export const setupGithubCommand: SlashCommand = { + name: 'setup-github', + description: 'Set up GitHub Actions', + kind: CommandKind.BUILT_IN, + action: (): SlashCommandActionReturn => { + const gitRootRepo = execSync('git rev-parse --show-toplevel', { + encoding: 'utf-8', + }).trim(); + + if (!isGitHubRepository()) { + throw new Error('Unable to determine the Git root directory.'); + } + + // TODO(#5198): pin workflow versions for release controls + const version = 'main'; + const workflowBaseUrl = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/heads/${version}/workflows/`; + + const workflows = [ + 'gemini-cli/gemini-cli.yml', + 'issue-triage/gemini-issue-automated-triage.yml', + 'issue-triage/gemini-issue-scheduled-triage.yml', + '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."', + ].join(' && '); + return { + type: 'tool', + toolName: 'run_shell_command', + toolArgs: { + description: + 'Setting up GitHub Actions to triage issues and review PRs with Gemini.', + command, + }, + }; + }, +}; diff --git a/packages/cli/src/utils/gitUtils.ts b/packages/cli/src/utils/gitUtils.ts new file mode 100644 index 00000000..d510008c --- /dev/null +++ b/packages/cli/src/utils/gitUtils.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'child_process'; + +/** + * 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 { + try { + const remotes = execSync('git remote -v', { + encoding: 'utf-8', + }); + + const pattern = /github\.com/; + + return pattern.test(remotes); + } catch (_error) { + // If any filesystem error occurs, assume not a git repo + return false; + } +}