Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions packages/mcp/src/functions/create-session.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,32 @@
import { execSync } from 'node:child_process';
import type { JulesClient, SessionConfig } from '@google/jules-sdk';
import type { CreateSessionResult, CreateSessionOptions } from './types.js';

function detectGitRepo(): string | undefined {
try {
const url = execSync('git remote get-url origin', {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
const match = url.match(/github\.com[:/](.+?)(?:\.git)?$/);
return match?.[1] ?? undefined;
} catch {
return undefined;
}
}

function detectGitBranch(): string | undefined {
try {
const branch = execSync('git branch --show-current', {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
return branch || undefined;
} catch {
return undefined;
}
}

/**
* Create a new Jules session or automated run.
*
Expand All @@ -12,6 +38,9 @@ export async function createSession(
client: JulesClient,
options: CreateSessionOptions,
): Promise<CreateSessionResult> {
const repo = options.repo ?? detectGitRepo();
const branch = options.branch ?? detectGitBranch();

// Build config - source is optional for repoless sessions
const config: SessionConfig = {
prompt: options.prompt,
Expand All @@ -20,9 +49,8 @@ export async function createSession(
autoPr: options.autoPr !== undefined ? options.autoPr : true,
};

// Only add source if both repo and branch are provided
if (options.repo && options.branch) {
config.source = { github: options.repo, baseBranch: options.branch };
if (repo && branch) {
config.source = { github: repo, baseBranch: branch };
}

const result = options.interactive
Expand Down
7 changes: 4 additions & 3 deletions packages/mcp/src/tools/create-session.tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { defineTool, toMcpResponse } from './utils.js';
export default defineTool({
name: 'create_session',
description:
'Creates a new Jules session or automated run to perform code tasks. If repo and branch are omitted, creates a "repoless" session where the user provides their own context in the prompt and Jules will perform code tasks based on that context instead of a GitHub repo.',
'Creates a new Jules session or automated run to perform code tasks. If repo and branch are omitted, they are auto-detected from the current git remote and branch. If auto-detection fails, creates a "repoless" session where the user provides their own context in the prompt and Jules will perform code tasks based on that context instead of a GitHub repo.',
inputSchema: {
type: 'object',
properties: {
Expand All @@ -16,11 +16,12 @@ export default defineTool({
repo: {
type: 'string',
description:
'GitHub repository (owner/repo). Optional for repoless sessions.',
'GitHub repository (owner/repo). If omitted, auto-detected from the current git remote.',
},
branch: {
type: 'string',
description: 'Target branch. Optional for repoless sessions.',
description:
'Target branch. If omitted, auto-detected from the current git branch.',
},
interactive: {
type: 'boolean',
Expand Down
127 changes: 126 additions & 1 deletion packages/mcp/tests/functions/create-session.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { execSync } from 'node:child_process';
import { createSession } from '../../src/functions/create-session.js';
import { createMockClient } from './helpers.js';
import type { SessionConfig } from '@google/jules-sdk';

vi.mock('node:child_process', () => ({
execSync: vi.fn(),
}));

describe('createSession', () => {
let mockClient: ReturnType<typeof createMockClient>;
let capturedConfig: SessionConfig;

beforeEach(() => {
// Default: git detection fails → repoless behaviour preserved
vi.mocked(execSync).mockImplementation(() => {
throw new Error('not a git repository');
});

mockClient = createMockClient();
// Mock both client.run and client.session to capture the config
vi.spyOn(mockClient, 'run').mockImplementation(async (config) => {
Expand Down Expand Up @@ -106,12 +116,127 @@ describe('createSession', () => {
});
});

it('omits source when only repo is provided', async () => {
it('omits source when only repo is provided and branch cannot be detected', async () => {
await createSession(mockClient, {
prompt: 'Fix the bug',
repo: 'owner/repo',
});

expect(capturedConfig.source).toBeUndefined();
});

describe('auto-detection', () => {
it('detects repo and branch from git when both are omitted', async () => {
vi.mocked(execSync).mockImplementation((cmd: any) => {
if (cmd === 'git remote get-url origin')
return 'https://github.com/owner/detected-repo.git';
if (cmd === 'git branch --show-current') return 'feature-branch';
throw new Error(`unexpected command: ${cmd}`);
});

await createSession(mockClient, { prompt: 'Fix the bug' });

expect(capturedConfig.source).toEqual({
github: 'owner/detected-repo',
baseBranch: 'feature-branch',
});
});

it('explicit repo and branch are used without calling git detection', async () => {
await createSession(mockClient, {
prompt: 'Fix the bug',
repo: 'explicit/repo',
branch: 'explicit-branch',
});

expect(execSync).not.toHaveBeenCalled();
expect(capturedConfig.source).toEqual({
github: 'explicit/repo',
baseBranch: 'explicit-branch',
});
});

it('falls back to repoless when remote URL is not a GitHub URL', async () => {
vi.mocked(execSync).mockImplementation((cmd: any) => {
if (cmd === 'git remote get-url origin')
return 'git@gitlab.com:owner/repo.git';
if (cmd === 'git branch --show-current') return 'main';
throw new Error(`unexpected command: ${cmd}`);
});

await createSession(mockClient, { prompt: 'Fix the bug' });

expect(capturedConfig.source).toBeUndefined();
});

it('falls back to repoless when git remote throws', async () => {
vi.mocked(execSync).mockImplementation(() => {
throw new Error('not a git repository');
});

await createSession(mockClient, { prompt: 'Fix the bug' });

expect(capturedConfig.source).toBeUndefined();
});

it('falls back to repoless when git branch returns empty string', async () => {
vi.mocked(execSync).mockImplementation((cmd: any) => {
if (cmd === 'git remote get-url origin')
return 'https://github.com/owner/repo.git';
if (cmd === 'git branch --show-current') return '';
throw new Error(`unexpected command: ${cmd}`);
});

await createSession(mockClient, { prompt: 'Fix the bug' });

expect(capturedConfig.source).toBeUndefined();
});

it('falls back to repoless when git branch throws', async () => {
vi.mocked(execSync).mockImplementation((cmd: any) => {
if (cmd === 'git remote get-url origin')
return 'https://github.com/owner/repo.git';
throw new Error('git branch failed');
});

await createSession(mockClient, { prompt: 'Fix the bug' });

expect(capturedConfig.source).toBeUndefined();
});

it('uses detected branch when repo is explicit but branch is absent', async () => {
vi.mocked(execSync).mockImplementation((cmd: any) => {
if (cmd === 'git branch --show-current') return 'detected-branch';
throw new Error(`unexpected command: ${cmd}`);
});

await createSession(mockClient, {
prompt: 'Fix the bug',
repo: 'explicit/repo',
});

expect(capturedConfig.source).toEqual({
github: 'explicit/repo',
baseBranch: 'detected-branch',
});
});

it('uses detected repo when branch is explicit but repo is absent', async () => {
vi.mocked(execSync).mockImplementation((cmd: any) => {
if (cmd === 'git remote get-url origin')
return 'git@github.com:detected/repo.git';
throw new Error(`unexpected command: ${cmd}`);
});

await createSession(mockClient, {
prompt: 'Fix the bug',
branch: 'explicit-branch',
});

expect(capturedConfig.source).toEqual({
github: 'detected/repo',
baseBranch: 'explicit-branch',
});
});
});
});