diff --git a/packages/cli/src/ui/commands/setupGithubCommand.test.ts b/packages/cli/src/ui/commands/setupGithubCommand.test.ts index 6417c60a..be0a657f 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.test.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.test.ts @@ -18,6 +18,7 @@ vi.mock('../../utils/gitUtils.js', () => ({ isGitHubRepository: vi.fn(), getGitRepoRoot: vi.fn(), getLatestGitHubRelease: vi.fn(), + getGitHubRepoInfo: vi.fn(), })); describe('setupGithubCommand', async () => { @@ -30,7 +31,9 @@ describe('setupGithubCommand', async () => { }); it('returns a tool action to download github workflows and handles paths', async () => { - const fakeRepoRoot = '/github.com/fake/repo/root'; + const fakeRepoOwner = 'fake'; + const fakeRepoName = 'repo'; + const fakeRepoRoot = `/github.com/${fakeRepoOwner}/${fakeRepoName}/root`; const fakeReleaseVersion = 'v1.2.3'; vi.mocked(gitUtils.isGitHubRepository).mockReturnValueOnce(true); @@ -38,6 +41,10 @@ describe('setupGithubCommand', async () => { vi.mocked(gitUtils.getLatestGitHubRelease).mockResolvedValueOnce( fakeReleaseVersion, ); + vi.mocked(gitUtils.getGitHubRepoInfo).mockReturnValue({ + owner: fakeRepoOwner, + repo: fakeRepoName, + }); const result = (await setupGithubCommand.action?.( {} as CommandContext, diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index 1b5b3277..84d6b5af 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -11,6 +11,7 @@ import { getGitRepoRoot, getLatestGitHubRelease, isGitHubRepository, + getGitHubRepoInfo, } from '../../utils/gitUtils.js'; import { @@ -18,6 +19,27 @@ import { SlashCommand, SlashCommandActionReturn, } from './types.js'; +import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js'; + +// 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 + const openCmd = getUrlOpenCommand(); + + // Build a list of URLs to open + const urlsToOpen = [readmeUrl]; + + const repoInfo = getGitHubRepoInfo(); + if (repoInfo) { + urlsToOpen.push( + `https://github.com/${repoInfo.owner}/${repoInfo.repo}/settings/secrets/actions`, + ); + } + + // Create and join the individual commands + const commands = urlsToOpen.map((url) => `${openCmd} "${url}"`); + return commands; +} export const setupGithubCommand: SlashCommand = { name: 'setup-github', @@ -71,11 +93,14 @@ export const setupGithubCommand: SlashCommand = { commands.push(curlCommand); } + const readmeUrl = `https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`; + 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`, + `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(' && ')})`; return { type: 'tool', diff --git a/packages/cli/src/ui/utils/commandUtils.test.ts b/packages/cli/src/ui/utils/commandUtils.test.ts index 4bd48cee..db333e72 100644 --- a/packages/cli/src/ui/utils/commandUtils.test.ts +++ b/packages/cli/src/ui/utils/commandUtils.test.ts @@ -11,6 +11,7 @@ import { isAtCommand, isSlashCommand, copyToClipboard, + getUrlOpenCommand, } from './commandUtils.js'; // Mock child_process @@ -342,4 +343,42 @@ describe('commandUtils', () => { }); }); }); + + describe('getUrlOpenCommand', () => { + describe('on macOS (darwin)', () => { + beforeEach(() => { + mockProcess.platform = 'darwin'; + }); + it('should return open', () => { + expect(getUrlOpenCommand()).toBe('open'); + }); + }); + + describe('on Windows (win32)', () => { + beforeEach(() => { + mockProcess.platform = 'win32'; + }); + it('should return start', () => { + expect(getUrlOpenCommand()).toBe('start'); + }); + }); + + describe('on Linux (linux)', () => { + beforeEach(() => { + mockProcess.platform = 'linux'; + }); + it('should return xdg-open', () => { + expect(getUrlOpenCommand()).toBe('xdg-open'); + }); + }); + + describe('on unmatched OS', () => { + beforeEach(() => { + mockProcess.platform = 'unmatched'; + }); + it('should return xdg-open', () => { + expect(getUrlOpenCommand()).toBe('xdg-open'); + }); + }); + }); }); diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index 4280388f..80ed51ae 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -27,7 +27,7 @@ export const isAtCommand = (query: string): boolean => */ export const isSlashCommand = (query: string): boolean => query.startsWith('/'); -//Copies a string snippet to the clipboard for different platforms +// Copies a string snippet to the clipboard for different platforms export const copyToClipboard = async (text: string): Promise => { const run = (cmd: string, args: string[]) => new Promise((resolve, reject) => { @@ -80,3 +80,27 @@ export const copyToClipboard = async (text: string): Promise => { throw new Error(`Unsupported platform: ${process.platform}`); } }; + +export const getUrlOpenCommand = (): string => { + // --- Determine the OS-specific command to open URLs --- + let openCmd: string; + switch (process.platform) { + case 'darwin': + openCmd = 'open'; + break; + case 'win32': + openCmd = 'start'; + break; + case 'linux': + openCmd = 'xdg-open'; + break; + default: + // Default to xdg-open, which appears to be supported for the less popular operating systems. + openCmd = 'xdg-open'; + console.warn( + `Unknown platform: ${process.platform}. Attempting to open URLs with: ${openCmd}.`, + ); + break; + } + return openCmd; +}; diff --git a/packages/cli/src/utils/gitUtils.test.ts b/packages/cli/src/utils/gitUtils.test.ts index 4a29f589..7a5f210c 100644 --- a/packages/cli/src/utils/gitUtils.test.ts +++ b/packages/cli/src/utils/gitUtils.test.ts @@ -10,6 +10,7 @@ import { isGitHubRepository, getGitRepoRoot, getLatestGitHubRelease, + getGitHubRepoInfo, } from './gitUtils.js'; vi.mock('child_process'); @@ -44,6 +45,39 @@ describe('isGitHubRepository', async () => { }); }); +describe('getGitHubRepoInfo', async () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('throws an error if github repo info cannot be determined', async () => { + vi.mocked(child_process.execSync).mockImplementation((): string => { + throw new Error('oops'); + }); + expect(() => { + getGitHubRepoInfo(); + }).toThrowError(/oops/); + }); + + it('throws an error if owner/repo could not be determined', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce(''); + expect(() => { + getGitHubRepoInfo(); + }).toThrowError(/Owner & repo could not be extracted from remote URL/); + }); + + it('returns the owner and repo', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://github.com/owner/repo.git ', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); +}); + describe('getGitRepoRoot', async () => { beforeEach(() => { vi.resetAllMocks(); diff --git a/packages/cli/src/utils/gitUtils.ts b/packages/cli/src/utils/gitUtils.ts index 30ca2245..f5f9cb92 100644 --- a/packages/cli/src/utils/gitUtils.ts +++ b/packages/cli/src/utils/gitUtils.ts @@ -91,3 +91,28 @@ export const getLatestGitHubRelease = async ( ); } }; + +/** + * getGitHubRepoInfo returns the owner and repository for a GitHub repo. + * @returns the owner and repository of the github repo. + * @throws error if the exec command fails. + */ +export function getGitHubRepoInfo(): { owner: string; repo: string } { + const remoteUrl = execSync('git remote get-url origin', { + encoding: 'utf-8', + }).trim(); + + // Matches either https://github.com/owner/repo.git or git@github.com:owner/repo.git + const match = remoteUrl.match( + /(?:https?:\/\/|git@)github\.com(?::|\/)([^/]+)\/([^/]+?)(?:\.git)?$/, + ); + + // If the regex fails match, throw an error. + if (!match || !match[1] || !match[2]) { + throw new Error( + `Owner & repo could not be extracted from remote URL: ${remoteUrl}`, + ); + } + + return { owner: match[1], repo: match[2] }; +}