Skip to content
Draft
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
14 changes: 14 additions & 0 deletions docs/iloom-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -1249,6 +1249,20 @@ il plan --yolo 42

**Warning:** Autonomous mode will create issues and dependencies without confirmation. Use with caution - it can make irreversible changes to your issue tracker.

**Read-Only Mode (No Write Access):**

When you run `il plan` on a GitHub repository where you lack write or triage permissions (e.g., contributing to an open-source project you don't maintain), `il plan` automatically detects this and switches to read-only mode.

In read-only mode, `il plan` produces the full decomposition plan but posts it as a detailed comment on the parent issue (or as a single standalone issue in fresh planning mode) instead of creating child issues and dependencies. This avoids leaving orphaned issues that cannot be linked.

To check your permission level on a repository:

```bash
gh repo view --json viewerPermission
```

Permission levels `ADMIN`, `MAINTAIN`, and `WRITE` grant full `il plan` functionality. `TRIAGE` and `READ` (or no access) trigger read-only mode. Non-GitHub providers (Linear, Jira) are unaffected.

**Available MCP Tools in Session:**

| Category | Tools |
Expand Down
7 changes: 4 additions & 3 deletions src/commands/ignite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,8 @@ export class IgniteCommand {
try {
const provider = this.settings ? IssueTrackerFactory.getProviderName(this.settings) : 'github'
// Pass draftPrNumber to route comments to PR when in github-draft-pr mode
mcpConfig = await generateIssueManagementMcpConfig(context.type, undefined, provider, this.settings, draftPrNumber)
const mcpResult = await generateIssueManagementMcpConfig(context.type, undefined, provider, this.settings, draftPrNumber)
mcpConfig = mcpResult.configs
logger.debug('Generated MCP configuration for issue management', { provider, draftPrNumber })

// Configure tool filtering for issue/PR workflows
Expand Down Expand Up @@ -886,13 +887,13 @@ export class IgniteCommand {

// Issue management MCP
try {
const issueMcpConfigs = await generateIssueManagementMcpConfig(
const issueMcpResult = await generateIssueManagementMcpConfig(
'issue',
undefined,
providerName as 'github' | 'linear' | 'jira',
settings,
)
mcpConfigs.push(...issueMcpConfigs)
mcpConfigs.push(...issueMcpResult.configs)
} catch (error) {
logger.warn(`Failed to generate issue management MCP config: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
Expand Down
217 changes: 214 additions & 3 deletions src/commands/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@ import { IssueManagementProviderFactory } from '../mcp/IssueManagementProviderFa
import { TelemetryService } from '../lib/TelemetryService.js'
import * as identifierParser from '../utils/IdentifierParser.js'
import { IssueTrackerFactory } from '../lib/IssueTrackerFactory.js'
import { SettingsManager } from '../lib/SettingsManager.js'
import * as githubUtils from '../utils/github.js'

// Mock dependencies
vi.mock('../utils/claude.js')
vi.mock('../utils/mcp.js')
vi.mock('../utils/first-run-setup.js')
vi.mock('../utils/IdentifierParser.js')
vi.mock('../utils/github.js')
vi.mock('../mcp/IssueManagementProviderFactory.js')
vi.mock('../lib/TelemetryService.js', () => ({
TelemetryService: {
Expand Down Expand Up @@ -61,14 +64,17 @@ describe('PlanCommand', () => {
// Setup default mocks
vi.mocked(claudeUtils.detectClaudeCli).mockResolvedValue(true)
vi.mocked(claudeUtils.launchClaude).mockResolvedValue(undefined)
vi.mocked(mcpUtils.generateIssueManagementMcpConfig).mockResolvedValue([
{ mcpServers: { issue_management: {} } },
])
vi.mocked(mcpUtils.generateIssueManagementMcpConfig).mockResolvedValue({
configs: [{ mcpServers: { issue_management: {} } }],
repo: 'test-owner/test-repo',
})
// Default: project is already configured (no first-run setup needed)
vi.mocked(firstRunSetup.needsFirstRunSetup).mockResolvedValue(false)
vi.mocked(firstRunSetup.launchFirstRunSetup).mockResolvedValue(undefined)
// Default: input is not an issue identifier (non-decomposition mode)
vi.mocked(identifierParser.matchIssueIdentifier).mockReturnValue({ isIssueIdentifier: false })
// Default: user has write access (prevents read-only mode in existing tests)
vi.mocked(githubUtils.getRepoPermission).mockResolvedValue('WRITE')
// Default: TelemetryService mock
const mockTrack = vi.fn()
vi.mocked(TelemetryService.getInstance).mockReturnValue({ track: mockTrack } as unknown as TelemetryService)
Expand Down Expand Up @@ -517,6 +523,7 @@ describe('PlanCommand', () => {
expect(mockTrack).toHaveBeenCalledWith('epic.planned', {
child_count: 3,
tracker: 'github',
read_only_mode: false,
})
})

Expand All @@ -537,4 +544,208 @@ describe('PlanCommand', () => {
await expect(command.execute('42')).resolves.toBeUndefined()
})
})

describe('read-only mode (no write access)', () => {
beforeEach(() => {
vi.mocked(githubUtils.getRepoPermission).mockResolvedValue('READ')
})

it('should pass READ_ONLY_MODE: true when user lacks write access', async () => {
await command.execute()

expect(mockTemplateManager.getPrompt).toHaveBeenCalledWith(
'plan',
expect.objectContaining({ READ_ONLY_MODE: true })
)
})

it('should pass READ_ONLY_MODE: true for TRIAGE permission', async () => {
vi.mocked(githubUtils.getRepoPermission).mockResolvedValue('TRIAGE')

await command.execute()

expect(mockTemplateManager.getPrompt).toHaveBeenCalledWith(
'plan',
expect.objectContaining({ READ_ONLY_MODE: true })
)
})

it('should exclude write tools from allowedTools when READ_ONLY_MODE', async () => {
await command.execute()

const launchCall = vi.mocked(claudeUtils.launchClaude).mock.calls[0]
const allowedTools = launchCall[1].allowedTools as string[]
expect(allowedTools).not.toContain('mcp__issue_management__create_child_issue')
expect(allowedTools).not.toContain('mcp__issue_management__create_dependency')
expect(allowedTools).not.toContain('mcp__issue_management__remove_dependency')
// These should still be present
expect(allowedTools).toContain('mcp__issue_management__create_issue')
expect(allowedTools).toContain('mcp__issue_management__create_comment')
expect(allowedTools).toContain('mcp__issue_management__get_issue')
expect(allowedTools).toContain('mcp__issue_management__get_dependencies')
})

it('should pass resolved repo from MCP config to getRepoPermission', async () => {
vi.mocked(mcpUtils.generateIssueManagementMcpConfig).mockResolvedValue({
configs: [{ mcpServers: { issue_management: {} } }],
repo: 'upstream-owner/upstream-repo',
})

await command.execute()

expect(githubUtils.getRepoPermission).toHaveBeenCalledWith('upstream-owner/upstream-repo')
})

it('should log warning about read-only mode', async () => {
const { logger } = await import('../utils/logger.js')

await command.execute()

expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('read-only mode')
)
})

it('should pass read_only_mode: true in telemetry for decomposition sessions', async () => {
const mockTrack = vi.fn()
vi.mocked(TelemetryService.getInstance).mockReturnValue({ track: mockTrack } as unknown as TelemetryService)

const mockGetChildIssues = vi.fn().mockResolvedValue([
{ id: '100', title: 'Child 1', state: 'open' },
])
vi.mocked(IssueManagementProviderFactory.create).mockReturnValue({
getChildIssues: mockGetChildIssues,
} as never)

vi.mocked(identifierParser.matchIssueIdentifier).mockReturnValue({
isIssueIdentifier: true,
type: 'numeric',
identifier: '42',
})
const mockIssueTracker = {
detectInputType: vi.fn().mockResolvedValue({ type: 'issue', identifier: '42' }),
fetchIssue: vi.fn().mockResolvedValue({ number: 42, title: 'Test epic', body: 'Epic body' }),
}
vi.mocked(IssueTrackerFactory.create).mockReturnValue(mockIssueTracker as never)

await command.execute('42')

expect(mockTrack).toHaveBeenCalledWith('epic.planned', expect.objectContaining({
read_only_mode: true,
}))
})
})

describe('write access mode', () => {
it('should pass READ_ONLY_MODE: false when user has WRITE permission', async () => {
vi.mocked(githubUtils.getRepoPermission).mockResolvedValue('WRITE')

await command.execute()

expect(mockTemplateManager.getPrompt).toHaveBeenCalledWith(
'plan',
expect.objectContaining({ READ_ONLY_MODE: false })
)
})

it('should pass READ_ONLY_MODE: false when user has ADMIN permission', async () => {
vi.mocked(githubUtils.getRepoPermission).mockResolvedValue('ADMIN')

await command.execute()

expect(mockTemplateManager.getPrompt).toHaveBeenCalledWith(
'plan',
expect.objectContaining({ READ_ONLY_MODE: false })
)
})

it('should pass READ_ONLY_MODE: false when user has MAINTAIN permission', async () => {
vi.mocked(githubUtils.getRepoPermission).mockResolvedValue('MAINTAIN')

await command.execute()

expect(mockTemplateManager.getPrompt).toHaveBeenCalledWith(
'plan',
expect.objectContaining({ READ_ONLY_MODE: false })
)
})

it('should include all tools when user has write access', async () => {
vi.mocked(githubUtils.getRepoPermission).mockResolvedValue('WRITE')

await command.execute()

const launchCall = vi.mocked(claudeUtils.launchClaude).mock.calls[0]
const allowedTools = launchCall[1].allowedTools as string[]
expect(allowedTools).toContain('mcp__issue_management__create_child_issue')
expect(allowedTools).toContain('mcp__issue_management__create_dependency')
expect(allowedTools).toContain('mcp__issue_management__remove_dependency')
})
})

describe('permission check failure (fail-open)', () => {
it('should default to write access if permission check fails', async () => {
vi.mocked(githubUtils.getRepoPermission).mockRejectedValue(new Error('API error'))

await command.execute()

expect(mockTemplateManager.getPrompt).toHaveBeenCalledWith(
'plan',
expect.objectContaining({ READ_ONLY_MODE: false })
)
})

it('should include all tools when permission check fails', async () => {
vi.mocked(githubUtils.getRepoPermission).mockRejectedValue(new Error('API error'))

await command.execute()

const launchCall = vi.mocked(claudeUtils.launchClaude).mock.calls[0]
const allowedTools = launchCall[1].allowedTools as string[]
expect(allowedTools).toContain('mcp__issue_management__create_child_issue')
expect(allowedTools).toContain('mcp__issue_management__create_dependency')
expect(allowedTools).toContain('mcp__issue_management__remove_dependency')
})
})

describe('non-GitHub provider', () => {
beforeEach(() => {
// loadSettings must return a non-null value so getProviderName is actually called
// (when settings is null, provider defaults to 'github' via the ternary)
vi.mocked(SettingsManager).mockImplementation(() => ({
loadSettings: vi.fn().mockResolvedValue({}),
getPlanModel: vi.fn().mockReturnValue('opus'),
getPlanPlanner: vi.fn().mockReturnValue('claude'),
getPlanReviewer: vi.fn().mockReturnValue('none'),
}) as unknown as SettingsManager)
})

it('should skip permission check for Linear provider', async () => {
vi.mocked(IssueTrackerFactory.getProviderName).mockReturnValue('linear')

// Need a fresh command since we changed the SettingsManager mock
command = new PlanCommand(mockTemplateManager)
await command.execute()

expect(githubUtils.getRepoPermission).not.toHaveBeenCalled()
expect(mockTemplateManager.getPrompt).toHaveBeenCalledWith(
'plan',
expect.objectContaining({ READ_ONLY_MODE: false })
)
})

it('should skip permission check for Jira provider', async () => {
vi.mocked(IssueTrackerFactory.getProviderName).mockReturnValue('jira')

// Need a fresh command since we changed the SettingsManager mock
command = new PlanCommand(mockTemplateManager)
await command.execute()

expect(githubUtils.getRepoPermission).not.toHaveBeenCalled()
expect(mockTemplateManager.getPrompt).toHaveBeenCalledWith(
'plan',
expect.objectContaining({ READ_ONLY_MODE: false })
)
})
})
})
34 changes: 29 additions & 5 deletions src/commands/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { needsFirstRunSetup, launchFirstRunSetup } from '../utils/first-run-setu
import type { IssueProvider, ChildIssueResult, DependenciesResult } from '../mcp/types.js'
import { promptConfirmation, isInteractiveEnvironment } from '../utils/prompt.js'
import { TelemetryService } from '../lib/TelemetryService.js'
import { getRepoPermission } from '../utils/github.js'

// Define provider arrays for validation and dynamic flag generation
const PLANNER_PROVIDERS = ['claude', 'gemini', 'codex'] as const
Expand Down Expand Up @@ -268,8 +269,11 @@ export class PlanCommand {
// This will throw if no git remote is configured - offer to run 'il init' as fallback
logger.debug('Generating MCP config for issue management')
let mcpConfig: Record<string, unknown>[]
let resolvedRepo: string | undefined
try {
mcpConfig = await generateIssueManagementMcpConfig(undefined, undefined, provider, settings ?? undefined)
const mcpResult = await generateIssueManagementMcpConfig(undefined, undefined, provider, settings ?? undefined)
mcpConfig = mcpResult.configs
resolvedRepo = mcpResult.repo
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'

Expand All @@ -291,7 +295,9 @@ export class PlanCommand {
// Retry MCP config generation after init
logger.info(chalk.bold('Retrying planning session setup...'))
try {
mcpConfig = await generateIssueManagementMcpConfig(undefined, undefined, provider, settings ?? undefined)
const retryResult = await generateIssueManagementMcpConfig(undefined, undefined, provider, settings ?? undefined)
mcpConfig = retryResult.configs
resolvedRepo = retryResult.repo
} catch (retryError) {
const retryMessage = retryError instanceof Error ? retryError.message : 'Unknown error'
logger.error(`Failed to generate MCP config: ${retryMessage}`)
Expand Down Expand Up @@ -355,6 +361,22 @@ export class PlanCommand {
serverCount: mcpConfig.length,
})

// Check repository permission level for GitHub repos
let hasWriteAccess = true // default for non-GitHub or if check fails
if (provider === 'github') {
try {
const permission = await getRepoPermission(resolvedRepo)
hasWriteAccess = ['ADMIN', 'MAINTAIN', 'WRITE'].includes(permission)
if (!hasWriteAccess) {
logger.warn('You do not have write access to this repository. Running in read-only mode — the plan will be posted as a comment instead of creating child issues.')
}
} catch (error) {
logger.debug('Permission check failed, defaulting to write access', {
error: error instanceof Error ? error.message : 'Unknown error',
})
}
}

// Detect VS Code mode
const isVscodeMode = process.env.ILOOM_VSCODE === '1'
logger.debug('VS Code mode detection', { isVscodeMode })
Expand Down Expand Up @@ -389,6 +411,7 @@ export class PlanCommand {
PLANNER: effectivePlanner,
REVIEWER: effectiveReviewer,
HAS_REVIEWER: effectiveReviewer !== 'none',
READ_ONLY_MODE: !hasWriteAccess,
...providerFlags,
}
const architectPrompt = await this.templateManager.getPrompt('plan', templateVariables)
Expand All @@ -401,15 +424,15 @@ export class PlanCommand {
const allowedTools = [
// Issue management tools
'mcp__issue_management__create_issue',
'mcp__issue_management__create_child_issue',
...(hasWriteAccess ? ['mcp__issue_management__create_child_issue'] : []),
'mcp__issue_management__get_issue',
'mcp__issue_management__get_child_issues',
'mcp__issue_management__get_comment',
'mcp__issue_management__create_comment',
// Dependency management tools
'mcp__issue_management__create_dependency',
...(hasWriteAccess ? ['mcp__issue_management__create_dependency'] : []),
'mcp__issue_management__get_dependencies',
'mcp__issue_management__remove_dependency',
...(hasWriteAccess ? ['mcp__issue_management__remove_dependency'] : []),
// Codebase exploration tools (read-only)
'Read',
'Glob',
Expand Down Expand Up @@ -516,6 +539,7 @@ ${initialMessage}`
TelemetryService.getInstance().track('epic.planned', {
child_count: children.length,
tracker: provider,
read_only_mode: !hasWriteAccess,
})
} catch (error) {
logger.debug(`Telemetry epic.planned tracking failed: ${error instanceof Error ? error.message : error}`)
Expand Down
Loading