diff --git a/README.md b/README.md index 1545cac..31b2689 100644 --- a/README.md +++ b/README.md @@ -321,6 +321,26 @@ Enable automatic worktree creation for a project. When enabled, new `/opencode` - ๐Ÿš€ **Create PR button** โ€” easily create pull requests from worktree - โšก **Per-project setting** โ€” enable/disable independently for each project +### `/autocode` โ€” Toggle Automatic Passthrough Mode + +Enable automatic passthrough mode for a project. When enabled, every new thread the bot creates (via `/work` or `/opencode`) will already have passthrough mode on, so plain messages are sent to OpenCode without needing to run `/code` first. + +``` +/autocode +``` + +**How it works:** + +1. Run `/autocode` in a channel bound to a project +2. The setting toggles on/off for that project +3. New threads in that project skip the manual `/code` step + +**Features:** + +- ๐Ÿ“ฑ **Mobile-friendly** โ€” one less command to type per thread +- ๐Ÿงต **Thread-scoped** โ€” each new thread starts with passthrough enabled; `/code` still toggles it within a thread +- โšก **Per-project setting** โ€” enable/disable independently for each project + ### `/queue` โ€” Manage Message Queue Control the automated job queue for the current thread. diff --git a/src/__tests__/autocode.test.ts b/src/__tests__/autocode.test.ts new file mode 100644 index 0000000..05bb45d --- /dev/null +++ b/src/__tests__/autocode.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ChannelType } from 'discord.js'; + +vi.mock('../services/dataStore.js'); +vi.mock('../services/worktreeManager.js'); + +import { autocode } from '../commands/autocode.js'; +import { work } from '../commands/work.js'; +import { getOrCreateThread } from '../utils/threadHelper.js'; +import * as dataStore from '../services/dataStore.js'; +import * as worktreeManager from '../services/worktreeManager.js'; + +function makeInteraction(channelId: string, isThread = false, parentId?: string) { + return { + channelId, + channel: isThread + ? { isThread: () => true, parentId } + : { isThread: () => false }, + reply: vi.fn().mockResolvedValue(undefined), + } as any; +} + +describe('/autocode', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('replies with an error when no project is bound to the channel', async () => { + const interaction = makeInteraction('channel-1'); + vi.mocked(dataStore.getChannelBinding).mockReturnValue(undefined); + + await autocode.execute(interaction); + + expect(interaction.reply).toHaveBeenCalledOnce(); + const arg = interaction.reply.mock.calls[0][0]; + expect(arg.content).toContain('No project set'); + expect(dataStore.setProjectAutoPassthrough).not.toHaveBeenCalled(); + }); + + it('flips false โ†’ true and reports enabled', async () => { + const interaction = makeInteraction('channel-1'); + vi.mocked(dataStore.getChannelBinding).mockReturnValue('myproj'); + vi.mocked(dataStore.getProjectAutoPassthrough).mockReturnValue(false); + vi.mocked(dataStore.setProjectAutoPassthrough).mockReturnValue(true); + + await autocode.execute(interaction); + + expect(dataStore.setProjectAutoPassthrough).toHaveBeenCalledWith('myproj', true); + const arg = interaction.reply.mock.calls[0][0]; + expect(arg.content).toMatch(/enabled/); + expect(arg.content).toContain('myproj'); + }); + + it('flips true โ†’ false and reports disabled', async () => { + const interaction = makeInteraction('channel-1'); + vi.mocked(dataStore.getChannelBinding).mockReturnValue('myproj'); + vi.mocked(dataStore.getProjectAutoPassthrough).mockReturnValue(true); + vi.mocked(dataStore.setProjectAutoPassthrough).mockReturnValue(true); + + await autocode.execute(interaction); + + expect(dataStore.setProjectAutoPassthrough).toHaveBeenCalledWith('myproj', false); + const arg = interaction.reply.mock.calls[0][0]; + expect(arg.content).toMatch(/disabled/); + }); + + it('resolves to the parent channel binding when invoked from a thread', async () => { + const interaction = makeInteraction('thread-id', true, 'parent-channel'); + vi.mocked(dataStore.getChannelBinding).mockImplementation((id: string) => + id === 'parent-channel' ? 'myproj' : undefined + ); + vi.mocked(dataStore.getProjectAutoPassthrough).mockReturnValue(false); + vi.mocked(dataStore.setProjectAutoPassthrough).mockReturnValue(true); + + await autocode.execute(interaction); + + expect(dataStore.getChannelBinding).toHaveBeenCalledWith('parent-channel'); + expect(dataStore.setProjectAutoPassthrough).toHaveBeenCalledWith('myproj', true); + }); + + it('reports failure when setProjectAutoPassthrough returns false', async () => { + const interaction = makeInteraction('channel-1'); + vi.mocked(dataStore.getChannelBinding).mockReturnValue('ghostproj'); + vi.mocked(dataStore.getProjectAutoPassthrough).mockReturnValue(false); + vi.mocked(dataStore.setProjectAutoPassthrough).mockReturnValue(false); + + await autocode.execute(interaction); + + const arg = interaction.reply.mock.calls[0][0]; + expect(arg.content).toContain('not found'); + }); +}); + +describe('auto-passthrough seeding via /work', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(worktreeManager.sanitizeBranchName).mockImplementation((b: string) => b); + vi.mocked(worktreeManager.createWorktree).mockResolvedValue('/tmp/worktree'); + + vi.mocked(dataStore.getChannelProjectPath).mockReturnValue('/tmp/project'); + vi.mocked(dataStore.getWorktreeMappingByBranch).mockReturnValue(undefined); + vi.mocked(dataStore.setWorktreeMapping).mockReturnValue(undefined); + vi.mocked(dataStore.getChannelBinding).mockReturnValue('myproj'); + }); + + async function runWork() { + const threadsCreate = vi.fn().mockResolvedValue({ + id: 'new-thread-1', + send: vi.fn().mockResolvedValue(undefined), + }); + + const interaction = { + channelId: 'channel-1', + user: { id: 'user-1' }, + channel: { + isThread: () => false, + type: ChannelType.GuildText, + threads: { create: threadsCreate }, + }, + options: { getString: (n: string) => (n === 'branch' ? 'feat-x' : null) }, + reply: vi.fn().mockResolvedValue(undefined), + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + } as any; + + await work.execute(interaction); + } + + it('seeds passthrough on the new thread when project has autoPassthrough enabled', async () => { + vi.mocked(dataStore.getProjectAutoPassthrough).mockReturnValue(true); + await runWork(); + expect(dataStore.setPassthroughMode).toHaveBeenCalledWith('new-thread-1', true, 'user-1'); + }); + + it('does not seed passthrough when autoPassthrough is disabled', async () => { + vi.mocked(dataStore.getProjectAutoPassthrough).mockReturnValue(false); + await runWork(); + expect(dataStore.setPassthroughMode).not.toHaveBeenCalled(); + }); +}); + +describe('auto-passthrough seeding via /opencode (threadHelper)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + async function runHelper() { + const threadsCreate = vi.fn().mockResolvedValue({ id: 'opencode-thread-1' }); + const interaction = { + channelId: 'channel-1', + user: { id: 'user-1' }, + channel: { isThread: () => false, threads: { create: threadsCreate } }, + } as any; + return await getOrCreateThread(interaction, 'do a thing'); + } + + it('seeds passthrough when bound project has autoPassthrough enabled', async () => { + vi.mocked(dataStore.getChannelBinding).mockReturnValue('myproj'); + vi.mocked(dataStore.getProjectAutoPassthrough).mockReturnValue(true); + await runHelper(); + expect(dataStore.setPassthroughMode).toHaveBeenCalledWith('opencode-thread-1', true, 'user-1'); + }); + + it('does not seed when no project is bound', async () => { + vi.mocked(dataStore.getChannelBinding).mockReturnValue(undefined); + await runHelper(); + expect(dataStore.setPassthroughMode).not.toHaveBeenCalled(); + }); + + it('does not seed when autoPassthrough is disabled', async () => { + vi.mocked(dataStore.getChannelBinding).mockReturnValue('myproj'); + vi.mocked(dataStore.getProjectAutoPassthrough).mockReturnValue(false); + await runHelper(); + expect(dataStore.setPassthroughMode).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/autocode.ts b/src/commands/autocode.ts new file mode 100644 index 0000000..c6d807e --- /dev/null +++ b/src/commands/autocode.ts @@ -0,0 +1,54 @@ +import { + SlashCommandBuilder, + ChatInputCommandInteraction, + MessageFlags, + ThreadChannel +} from 'discord.js'; +import * as dataStore from '../services/dataStore.js'; +import type { Command } from './index.js'; + +function getParentChannelId(interaction: ChatInputCommandInteraction): string { + const channel = interaction.channel; + if (channel?.isThread()) { + return (channel as ThreadChannel).parentId ?? interaction.channelId; + } + return interaction.channelId; +} + +export const autocode: Command = { + data: new SlashCommandBuilder() + .setName('autocode') + .setDescription('Toggle automatic passthrough mode for new threads in this channel\'s project') as SlashCommandBuilder, + + async execute(interaction: ChatInputCommandInteraction) { + const channelId = getParentChannelId(interaction); + const projectAlias = dataStore.getChannelBinding(channelId); + + if (!projectAlias) { + await interaction.reply({ + content: 'โŒ No project set for this channel. Use `/use ` to bind a project first.', + flags: MessageFlags.Ephemeral + }); + return; + } + + const currentState = dataStore.getProjectAutoPassthrough(projectAlias); + const newState = !currentState; + + const success = dataStore.setProjectAutoPassthrough(projectAlias, newState); + if (!success) { + await interaction.reply({ + content: `โŒ Project "${projectAlias}" not found.`, + flags: MessageFlags.Ephemeral + }); + return; + } + + const emoji = newState ? 'โœ…' : 'โŒ'; + const status = newState ? 'enabled' : 'disabled'; + await interaction.reply({ + content: `${emoji} Auto-passthrough **${status}** for project **${projectAlias}**.\n\nNew threads will ${newState ? 'automatically enable' : 'NOT automatically enable'} passthrough mode (no slash command needed).`, + flags: MessageFlags.Ephemeral + }); + } +}; diff --git a/src/commands/index.ts b/src/commands/index.ts index 5745ab7..41dd88e 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -6,6 +6,7 @@ import { opencode } from './opencode.js'; import { work } from './work.js'; import { code } from './code.js'; import { autowork } from './autowork.js'; +import { autocode } from './autocode.js'; import { model } from './model.js'; import { setports } from './setports.js'; import { queue } from './queue.js'; @@ -28,6 +29,7 @@ commands.set(opencode.data.name, opencode); commands.set(work.data.name, work); commands.set(code.data.name, code); commands.set(autowork.data.name, autowork); +commands.set(autocode.data.name, autocode); commands.set(model.data.name, model); commands.set(setports.data.name, setports as Command); commands.set(queue.data.name, queue); diff --git a/src/commands/work.ts b/src/commands/work.ts index a1fce3a..8ac1556 100644 --- a/src/commands/work.ts +++ b/src/commands/work.ts @@ -87,6 +87,11 @@ export const work: Command = { createdAt: Date.now() }); + const alias = dataStore.getChannelBinding(i.channelId); + if (alias && dataStore.getProjectAutoPassthrough(alias)) { + dataStore.setPassthroughMode(thread.id, true, i.user.id); + } + const embed = new EmbedBuilder() .setTitle(`๐ŸŒณ Worktree: ${sanitizedBranch}`) .setDescription(description) diff --git a/src/services/dataStore.ts b/src/services/dataStore.ts index 6c0ecc8..f36bba2 100644 --- a/src/services/dataStore.ts +++ b/src/services/dataStore.ts @@ -244,6 +244,20 @@ export function getProjectAutoWorktree(alias: string): boolean { return project?.autoWorktree ?? false; } +export function setProjectAutoPassthrough(alias: string, enabled: boolean): boolean { + const data = loadData(); + const project = data.projects.find(p => p.alias === alias); + if (!project) return false; + project.autoPassthrough = enabled; + saveData(data); + return true; +} + +export function getProjectAutoPassthrough(alias: string): boolean { + const project = getProject(alias); + return project?.autoPassthrough ?? false; +} + // Queue Management export function getQueue(threadId: string): QueuedMessage[] { const data = loadData(); diff --git a/src/types/index.ts b/src/types/index.ts index 5160881..f6ae651 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,6 +4,7 @@ export interface ProjectConfig { alias: string; path: string; autoWorktree?: boolean; + autoPassthrough?: boolean; } export interface ChannelBinding { diff --git a/src/utils/threadHelper.ts b/src/utils/threadHelper.ts index eb17e18..e0704e6 100644 --- a/src/utils/threadHelper.ts +++ b/src/utils/threadHelper.ts @@ -1,15 +1,16 @@ import { ChatInputCommandInteraction, ThreadAutoArchiveDuration, TextChannel, ThreadChannel } from 'discord.js'; +import * as dataStore from '../services/dataStore.js'; export async function getOrCreateThread( interaction: ChatInputCommandInteraction, prompt: string ): Promise { const channel = interaction.channel; - + if (channel?.isThread()) { return channel; } - + if (channel && 'threads' in channel) { const threadName = prompt.slice(0, 50) + (prompt.length > 50 ? '...' : ''); const thread = await (channel as TextChannel).threads.create({ @@ -17,8 +18,14 @@ export async function getOrCreateThread( autoArchiveDuration: ThreadAutoArchiveDuration.OneDay, reason: 'OpenCode session' }); + + const alias = dataStore.getChannelBinding(interaction.channelId); + if (alias && dataStore.getProjectAutoPassthrough(alias)) { + dataStore.setPassthroughMode(thread.id, true, interaction.user.id); + } + return thread; } - + throw new Error('Cannot create thread in this channel.'); }