diff --git a/CLI.md b/CLI.md index ae70e1cc..3beb41ae 100644 --- a/CLI.md +++ b/CLI.md @@ -432,6 +432,7 @@ Subcommands: - `--force` — **Deprecated** alias for `--all`. Bypass the pre-filter and process all work items regardless of whether they changed since the last push. - `--no-update-timestamp` — Do not write the repository last-push timestamp after a successful push. Use this when you want to run a push but avoid advancing the "last pushed" watermark. - `import` — Import updates from GitHub Issues. Options: `--repo `, `--label-prefix `, `--since `, `--create-new`, `--prefix `. +- `delegate ` — Delegate a work item to GitHub Copilot. Pushes the item to GitHub, assigns the resulting issue to `@copilot`, and updates local status/assignee. Options: `--repo `, `--label-prefix `, `--force` (override the `do-not-delegate` tag). In the TUI, press **g** on a focused item for the same flow with a confirmation modal. Examples: diff --git a/TUI.md b/TUI.md index b04ceb60..88560b28 100644 --- a/TUI.md +++ b/TUI.md @@ -34,6 +34,8 @@ This document describes the interactive terminal UI shipped as the `wl tui` (or - / — search items - v — cycle needs-producer-review filter (on/off/all) - h — toggle help menu +- D — toggle do-not-delegate tag on selected item +- **g — delegate selected item to GitHub Copilot** (opens confirmation modal with optional Force override; see `wl github delegate` for CLI equivalent) - **m — move/reparent item** (see below) ### Move / Reparent Mode diff --git a/src/commands/github.ts b/src/commands/github.ts index 9f601f88..d3e2eafb 100644 --- a/src/commands/github.ts +++ b/src/commands/github.ts @@ -8,8 +8,9 @@ import { upsertIssuesFromWorkItems, importIssuesToWorkItems, GithubProgress, Syn import { loadConfig } from '../config.js'; import { displayConflictDetails } from './helpers.js'; import { createLogFileWriter, getWorklogLogPath, logConflictDetails } from '../logging.js'; +import { delegateWorkItem, type DelegateResult } from '../delegate-helper.js'; -function resolveGithubConfig(options: { repo?: string; labelPrefix?: string }) { +export function resolveGithubConfig(options: { repo?: string; labelPrefix?: string }) { const config = loadConfig(); const repo = options.repo || config?.githubRepo || getRepoFromGitRemote(); if (!repo) { @@ -457,30 +458,15 @@ export default function register(ctx: PluginContext): void { process.exit(1); } - // Guard rail: do-not-delegate tag - if (Array.isArray(item.tags) && item.tags.includes('do-not-delegate')) { - if (!options.force) { - const message = `Work item ${normalizedId} has a "do-not-delegate" tag. Use --force to override.`; - output.error(message, { - success: false, - error: 'do-not-delegate', - workItemId: normalizedId, - }); - process.exit(1); - } - if (!isJsonMode) { - console.log(`Warning: Work item ${normalizedId} has a "do-not-delegate" tag. Proceeding due to --force.`); - } - } - - // Guard rail: children warning + // CLI-specific guard rail: interactive children prompt + // (The helper handles children as a non-blocking warning, but the CLI + // gives the user a chance to abort in interactive mode.) const children = db.getChildren(normalizedId); if (children.length > 0) { const nonClosedChildren = children.filter( c => c.status !== 'completed' && c.status !== 'deleted' ); if (nonClosedChildren.length > 0) { - // In non-interactive mode (JSON or non-TTY), proceed with single item only const isInteractive = !isJsonMode && process.stdout.isTTY === true && process.stdin.isTTY === true; if (isInteractive) { const readline = await import('node:readline'); @@ -499,120 +485,91 @@ export default function register(ctx: PluginContext): void { } process.exit(0); } - } else { - // Non-interactive: proceed with single item, log warning - if (!isJsonMode) { - console.log( - `Warning: Work item ${normalizedId} has ${nonClosedChildren.length} open child item(s). ` + - `Delegating only the specified item.` - ); - } } } } - // Guard rails passed — delegate flow placeholder - // The actual push + assign + local state update is wired in WL-0MM8LXODU1DA2PON + // Resolve GitHub config and delegate via shared helper + let result: DelegateResult; try { const githubConfig = resolveGithubConfig({ repo: (options as any).repo, labelPrefix: (options as any).labelPrefix }); - // Push the work item to GitHub (smart sync) - const comments = db.getAllComments(); - const { updatedItems } = await upsertIssuesFromWorkItems( - [item], - comments.filter(c => c.workItemId === item.id), + result = await delegateWorkItem( + db, githubConfig, - () => {} // no progress rendering for single-item push + normalizedId, + { force: options.force }, ); - if (updatedItems.length > 0) { - db.upsertItems(updatedItems); + } catch (error) { + const message = `Delegation failed: ${(error as Error).message}`; + output.error(message, { + success: false, + error: (error as Error).message, + workItemId: normalizedId, + }); + process.exit(1); + return; // unreachable, but satisfies TS that result is assigned + } + + // Print warnings (children, force-override) in non-JSON mode + if (!isJsonMode && result.warnings) { + for (const w of result.warnings) { + console.log(`Warning: ${w}`); } + } - // Resolve the GitHub issue number (may have been set by the push) - const refreshedItem = db.get(normalizedId); - const issueNumber = refreshedItem?.githubIssueNumber ?? item.githubIssueNumber; - if (!issueNumber) { - const message = `Failed to resolve GitHub issue number for ${normalizedId} after push.`; + if (!result.success) { + // Map helper error keys to CLI output + if (result.error === 'do-not-delegate') { + const message = `Work item ${normalizedId} has a "do-not-delegate" tag. Use --force to override.`; output.error(message, { success: false, - error: message, + error: 'do-not-delegate', workItemId: normalizedId, }); process.exit(1); } - // Assign the issue to copilot - const { assignGithubIssueAsync } = await import('../github.js'); - const assignResult = await assignGithubIssueAsync(githubConfig, issueNumber, '@copilot'); - - if (!assignResult.ok) { - // Assignment failed: do NOT update local state, add comment, re-push - const failureMessage = `Failed to assign @copilot to GitHub issue #${issueNumber}: ${assignResult.error}. Local state was not updated.`; - db.createComment({ - workItemId: normalizedId, - author: 'wl-delegate', - comment: failureMessage, - }); - // Re-push to restore consistency after comment - const refreshedComments = db.getAllComments(); - await upsertIssuesFromWorkItems( - [db.get(normalizedId)!], - refreshedComments.filter(c => c.workItemId === normalizedId), - githubConfig, - () => {} - ); + // Assignment failure — helper already added comment and re-pushed + if (result.pushed && result.assigned === false && result.issueNumber) { + const failureMessage = + `Failed to assign @copilot to GitHub issue #${result.issueNumber}: ${result.error}. Local state was not updated.`; output.error(failureMessage, { success: false, - error: assignResult.error, + error: result.error, workItemId: normalizedId, - issueNumber, - issueUrl: `https://github.com/${githubConfig.repo}/issues/${issueNumber}`, + issueNumber: result.issueNumber, + issueUrl: result.issueUrl, pushed: true, assigned: false, }); process.exit(1); } - // Assignment succeeded: update local state - db.update(normalizedId, { - status: 'in-progress' as any, - assignee: '@github-copilot', - stage: 'in_progress', - }); - - // Re-push to sync updated status/stage labels to GitHub - const postAssignComments = db.getAllComments(); - await upsertIssuesFromWorkItems( - [db.get(normalizedId)!], - postAssignComments.filter(c => c.workItemId === normalizedId), - githubConfig, - () => {} - ); - - const issueUrl = `https://github.com/${githubConfig.repo}/issues/${issueNumber}`; - - if (isJsonMode) { - output.json({ - success: true, - workItemId: normalizedId, - issueNumber, - issueUrl, - pushed: true, - assigned: true, - }); - } else { - console.log(`Pushing to GitHub... done.`); - console.log(`Assigning to @copilot... done.`); - console.log(`Done. Issue: ${issueUrl}`); - } - } catch (error) { - const message = `Delegation failed: ${(error as Error).message}`; + // Generic failure (push error, issue number resolution, etc.) + const message = `Delegation failed: ${result.error}`; output.error(message, { success: false, - error: (error as Error).message, + error: result.error, workItemId: normalizedId, }); process.exit(1); } + + // Success path + if (isJsonMode) { + output.json({ + success: true, + workItemId: normalizedId, + issueNumber: result.issueNumber, + issueUrl: result.issueUrl, + pushed: true, + assigned: true, + }); + } else { + console.log(`Pushing to GitHub... done.`); + console.log(`Assigning to @copilot... done.`); + console.log(`Done. Issue: ${result.issueUrl}`); + } }); } diff --git a/src/delegate-helper.ts b/src/delegate-helper.ts new file mode 100644 index 00000000..58821413 --- /dev/null +++ b/src/delegate-helper.ts @@ -0,0 +1,260 @@ +/** + * Delegate orchestration helper — shared by CLI and TUI. + * + * Extracts the delegate flow (guard rails -> push -> assign -> local state + * update) from the CLI action handler into a reusable async function that + * returns a structured result. Never calls `process.exit()` or writes to + * `console.log`. + */ + +import type { WorkItem, Comment } from './types.js'; +import type { GithubConfig } from './github.js'; + +// --------------------------------------------------------------------------- +// Public result / option types +// --------------------------------------------------------------------------- + +/** Structured result returned by `delegateWorkItem`. */ +export interface DelegateResult { + success: boolean; + workItemId: string; + issueNumber?: number; + issueUrl?: string; + pushed?: boolean; + assigned?: boolean; + /** Human-readable error key or message when `success` is false. */ + error?: string; + /** Warning messages that were produced but did not prevent delegation. */ + warnings?: string[]; +} + +/** Options accepted by `delegateWorkItem`. */ +export interface DelegateOptions { + /** Override the do-not-delegate tag guard rail. */ + force?: boolean; + /** Optional callback invoked at each major step of the delegate flow. */ + onProgress?: (step: string) => void; +} + +// --------------------------------------------------------------------------- +// Minimal DB interface (avoids coupling to full WorklogDatabase) +// --------------------------------------------------------------------------- + +/** + * Subset of `WorklogDatabase` that `delegateWorkItem` depends on. This + * allows the TUI and tests to pass any object that satisfies the contract + * without importing the full database module. + */ +export interface DelegateDb { + get(id: string): WorkItem | null; + getAll(): WorkItem[]; + getAllComments(): Comment[]; + getChildren(parentId: string): WorkItem[]; + update(id: string, input: Record): WorkItem | null; + upsertItems(items: WorkItem[]): void; + createComment(input: { + workItemId: string; + author: string; + comment: string; + }): Comment | null; +} + +// --------------------------------------------------------------------------- +// Dependency type declarations (avoids top-level import of heavy modules) +// --------------------------------------------------------------------------- + +type UpsertFn = typeof import('./github-sync.js').upsertIssuesFromWorkItems; +type AssignFn = typeof import('./github.js').assignGithubIssueAsync; + +// --------------------------------------------------------------------------- +// Core helper +// --------------------------------------------------------------------------- + +/** + * Execute the full delegate flow for a single work item: + * + * 1. Resolve item from DB (guard: not-found) + * 2. Guard rail: do-not-delegate tag + * 3. Guard rail: open children warning (non-blocking) + * 4. Push item to GitHub via upsert + * 5. Resolve GitHub issue number + * 6. Assign @copilot + * 7. On failure: add comment, re-push + * 8. On success: update local state, re-push labels + * + * The function never throws under normal operation -- all error paths + * return `{ success: false, error: ... }`. + */ +export async function delegateWorkItem( + db: DelegateDb, + githubConfig: GithubConfig, + itemId: string, + options: DelegateOptions = {}, + /** Optional override for upsertIssuesFromWorkItems (useful for testing). */ + _upsertFn?: UpsertFn, + /** Optional override for assignGithubIssueAsync (useful for testing). */ + _assignFn?: AssignFn, +): Promise { + const warnings: string[] = []; + const progress = options.onProgress ?? (() => {}); + + // ------------------------------------------------------------------ + // 1. Resolve work item + // ------------------------------------------------------------------ + const item = db.get(itemId); + if (!item) { + return { + success: false, + workItemId: itemId, + error: 'not-found', + }; + } + + // ------------------------------------------------------------------ + // 2. Guard rail: do-not-delegate tag + // ------------------------------------------------------------------ + if (Array.isArray(item.tags) && item.tags.includes('do-not-delegate')) { + if (!options.force) { + return { + success: false, + workItemId: itemId, + error: 'do-not-delegate', + }; + } + warnings.push( + `Work item ${itemId} has a "do-not-delegate" tag. Proceeding due to --force.`, + ); + } + + // ------------------------------------------------------------------ + // 3. Guard rail: open children warning (non-blocking) + // ------------------------------------------------------------------ + const children = db.getChildren(itemId); + if (children.length > 0) { + const nonClosedChildren = children.filter( + (c) => c.status !== 'completed' && c.status !== 'deleted', + ); + if (nonClosedChildren.length > 0) { + warnings.push( + `Work item ${itemId} has ${nonClosedChildren.length} open child item(s). Delegating only the specified item.`, + ); + } + } + + // ------------------------------------------------------------------ + // 4. Push item to GitHub + // ------------------------------------------------------------------ + try { + progress('Pushing to GitHub...'); + const upsert: UpsertFn = + _upsertFn ?? + (await import('./github-sync.js')).upsertIssuesFromWorkItems; + + const comments = db.getAllComments(); + const { updatedItems } = await upsert( + [item], + comments.filter((c) => c.workItemId === item.id), + githubConfig, + () => {}, // no progress for single-item push + ); + if (updatedItems.length > 0) { + db.upsertItems(updatedItems); + } + + // ---------------------------------------------------------------- + // 5. Resolve GitHub issue number + // ---------------------------------------------------------------- + const refreshedItem = db.get(itemId); + const issueNumber = + refreshedItem?.githubIssueNumber ?? item.githubIssueNumber; + if (!issueNumber) { + return { + success: false, + workItemId: itemId, + error: `Failed to resolve GitHub issue number for ${itemId} after push.`, + pushed: true, + assigned: false, + }; + } + + // ---------------------------------------------------------------- + // 6. Assign @copilot + // ---------------------------------------------------------------- + progress('Assigning @copilot...'); + const assign: AssignFn = + _assignFn ?? (await import('./github.js')).assignGithubIssueAsync; + + const assignResult = await assign(githubConfig, issueNumber, '@copilot'); + + const issueUrl = `https://github.com/${githubConfig.repo}/issues/${issueNumber}`; + + if (!assignResult.ok) { + // --------------------------------------------------------------- + // 7. Assignment failed -- add comment, re-push, do NOT update state + // --------------------------------------------------------------- + const failureMessage = + `Failed to assign @copilot to GitHub issue #${issueNumber}: ${assignResult.error}. Local state was not updated.`; + + db.createComment({ + workItemId: itemId, + author: 'wl-delegate', + comment: failureMessage, + }); + + // Re-push to sync the failure comment + const refreshedComments = db.getAllComments(); + await upsert( + [db.get(itemId)!], + refreshedComments.filter((c) => c.workItemId === itemId), + githubConfig, + () => {}, + ); + + return { + success: false, + workItemId: itemId, + issueNumber, + issueUrl, + pushed: true, + assigned: false, + error: assignResult.error, + warnings: warnings.length > 0 ? warnings : undefined, + }; + } + + // ---------------------------------------------------------------- + // 8. Assignment succeeded -- update local state and re-push labels + // ---------------------------------------------------------------- + progress('Updating local state...'); + db.update(itemId, { + status: 'in-progress', + assignee: '@github-copilot', + stage: 'in_progress', + }); + + const postAssignComments = db.getAllComments(); + await upsert( + [db.get(itemId)!], + postAssignComments.filter((c) => c.workItemId === itemId), + githubConfig, + () => {}, + ); + + return { + success: true, + workItemId: itemId, + issueNumber, + issueUrl, + pushed: true, + assigned: true, + warnings: warnings.length > 0 ? warnings : undefined, + }; + } catch (error) { + return { + success: false, + workItemId: itemId, + error: (error as Error).message, + warnings: warnings.length > 0 ? warnings : undefined, + }; + } +} diff --git a/src/tui/components/modals.ts b/src/tui/components/modals.ts index 4db567db..a41970a4 100644 --- a/src/tui/components/modals.ts +++ b/src/tui/components/modals.ts @@ -427,6 +427,78 @@ export class ModalDialogsComponent { }); } + // -- Non-interactive message box (status / progress) ----------------------- + + /** + * Show a non-interactive message box with dynamic content. + * + * Returns an imperative handle with `update(message)` and `close()` methods. + * The caller controls the lifecycle — the dialog stays open until `close()` + * is called. Pressing Escape also closes the dialog. + * + * Useful for displaying multi-step progress (e.g., "Pushing to GitHub…" + * then "Assigning @copilot…"). + */ + messageBox(options: { + title: string; + message: string; + width?: string | number; + height?: string | number; + }): { update: (message: string) => void; close: () => void } { + const overlay = this.createOverlay(); + const dialog = this.blessedImpl.box({ + parent: this.screen, + top: 'center', + left: 'center', + width: options.width || '60%', + height: options.height || 7, + label: ` ${options.title} `, + border: { type: 'line' }, + tags: true, + mouse: true, + clickable: true, + style: { border: { fg: 'cyan' } }, + }) as BlessedBox; + + const text = this.blessedImpl.box({ + parent: dialog, + top: 1, + left: 2, + width: '100%-4', + height: '100%-3', + content: options.message, + tags: true, + }) as BlessedBox; + + let closed = false; + const cleanup = () => { + if (closed) return; + closed = true; + this.destroyWidgets([text, dialog, overlay]); + if (this.activeCleanup === cleanup) this.activeCleanup = null; + }; + this.activeCleanup = cleanup; + + dialog.key(KEY_ESCAPE, () => { cleanup(); }); + overlay.on('click', () => { cleanup(); }); + + overlay.setFront(); + dialog.setFront(); + dialog.focus(); + this.screen.render(); + + return { + update: (message: string) => { + if (closed) return; + try { + text.setContent(message); + this.screen.render(); + } catch (_) {} + }, + close: () => { cleanup(); }, + }; + } + // -- Private helpers ------------------------------------------------------- private createOverlay(): BlessedBox { diff --git a/src/tui/constants.ts b/src/tui/constants.ts index 48df25f4..fdcb28b7 100644 --- a/src/tui/constants.ts +++ b/src/tui/constants.ts @@ -98,6 +98,7 @@ export const DEFAULT_SHORTCUTS = [ { keys: 'U', description: 'Update selected item' }, { keys: 'M', description: 'Move/reparent item' }, { keys: 'D', description: 'Toggle do-not-delegate' }, + { keys: 'g', description: 'Delegate to Copilot' }, { keys: 'r/R', description: 'Toggle needs review' }, ], }, @@ -153,6 +154,7 @@ export const KEY_FILTER_PLAN_COMPLETED = ['p', 'P']; export const KEY_TOGGLE_DO_NOT_DELEGATE = ['d', 'D']; export const KEY_TOGGLE_NEEDS_REVIEW = ['r', 'R']; export const KEY_MOVE = ['m', 'M']; +export const KEY_DELEGATE = ['g']; // Composite keys often used in help menu / close handlers export const KEY_MENU_CLOSE = ['escape', 'q']; diff --git a/src/tui/controller.ts b/src/tui/controller.ts index 35e08aa0..5937c087 100644 --- a/src/tui/controller.ts +++ b/src/tui/controller.ts @@ -36,9 +36,11 @@ import ChordHandler from './chords.js'; import { stripAnsi, stripTags, decorateIdsForClick, extractIdFromLine, extractIdAtColumn, stripTagsAndAnsiWithMap, wrapPlainLineWithMap } from './id-utils.js'; import { AVAILABLE_COMMANDS, MIN_INPUT_HEIGHT, MAX_INPUT_LINES, FOOTER_HEIGHT, OPENCODE_SERVER_PORT, KEY_NAV_RIGHT, KEY_NAV_LEFT, KEY_TOGGLE_EXPAND, KEY_QUIT, KEY_ESCAPE, KEY_TOGGLE_HELP, KEY_CHORD_PREFIX, KEY_CHORD_FOLLOWUPS, KEY_OPEN_OPENCODE, KEY_OPEN_SEARCH, - KEY_TAB, KEY_SHIFT_TAB, KEY_LEFT_SINGLE, KEY_RIGHT_SINGLE, KEY_CS, KEY_ENTER, KEY_LINEFEED, KEY_J, KEY_K, KEY_COPY_ID, KEY_PARENT_PREVIEW, KEY_CLOSE_ITEM, KEY_UPDATE_ITEM, KEY_REFRESH, KEY_FIND_NEXT, KEY_FILTER_IN_PROGRESS, KEY_FILTER_OPEN, KEY_FILTER_BLOCKED, KEY_FILTER_NEEDS_REVIEW, KEY_FILTER_INTAKE_COMPLETED, KEY_FILTER_PLAN_COMPLETED, KEY_MENU_CLOSE, KEY_TOGGLE_DO_NOT_DELEGATE, KEY_TOGGLE_NEEDS_REVIEW, KEY_MOVE } from './constants.js'; + KEY_TAB, KEY_SHIFT_TAB, KEY_LEFT_SINGLE, KEY_RIGHT_SINGLE, KEY_CS, KEY_ENTER, KEY_LINEFEED, KEY_J, KEY_K, KEY_COPY_ID, KEY_PARENT_PREVIEW, KEY_CLOSE_ITEM, KEY_UPDATE_ITEM, KEY_REFRESH, KEY_FIND_NEXT, KEY_FILTER_IN_PROGRESS, KEY_FILTER_OPEN, KEY_FILTER_BLOCKED, KEY_FILTER_NEEDS_REVIEW, KEY_FILTER_INTAKE_COMPLETED, KEY_FILTER_PLAN_COMPLETED, KEY_MENU_CLOSE, KEY_TOGGLE_DO_NOT_DELEGATE, KEY_TOGGLE_NEEDS_REVIEW, KEY_MOVE, KEY_DELEGATE } from './constants.js'; import { theme } from '../theme.js'; import { initAutocomplete, type AutocompleteInstance } from './opencode-autocomplete.js'; +import { delegateWorkItem, type DelegateResult, type DelegateDb } from '../delegate-helper.js'; +import { resolveGithubConfig } from '../commands/github.js'; type Item = WorkItem; @@ -2980,6 +2982,126 @@ export class TuiController { } }); + // Delegate to GitHub Copilot (shortcut g) + screen.key(KEY_DELEGATE, async () => { + // Guard: suppress when overlays are visible or in move mode + if (!detailModal.hidden || helpMenu.isVisible() || !closeDialog.hidden || !updateDialog.hidden || !nextDialog.hidden) return; + if (!opencodeDialog.hidden) return; + if (state.moveMode) return; + + const item = getSelectedItem(); + if (!item) { + showToast('No item selected'); + return; + } + + // Build modal choices depending on do-not-delegate status + const hasDoNotDelegate = Array.isArray(item.tags) && item.tags.includes('do-not-delegate'); + const choices = hasDoNotDelegate + ? ['Delegate (ignoring Do Not Delegate flag)', 'Cancel'] + : ['Delegate', 'Cancel']; + + const titleStr = item.title.length > 50 + ? item.title.slice(0, 47) + '...' + : item.title; + + const message = hasDoNotDelegate + ? `{yellow-fg}⚠ Item has do-not-delegate tag.{/yellow-fg}\n\n${titleStr}` + : `Delegate to GitHub Copilot?\n\n${titleStr}`; + + const cancelIndex = choices.length - 1; + const choiceIdx = await modalDialogs.selectList({ + title: 'Delegate to Copilot', + message, + items: choices, + defaultIndex: 0, + cancelIndex, + height: hasDoNotDelegate ? 12 : 10, + }); + + if (choiceIdx === cancelIndex) return; + + const force = hasDoNotDelegate; + + // Open a status dialog to show progress during delegation + const statusDialog = modalDialogs.messageBox({ + title: 'Delegating to Copilot', + message: 'Preparing to delegate...', + }); + + try { + const githubConfig = resolveGithubConfig({}); + const result: DelegateResult = await delegateWorkItem( + db as DelegateDb, + githubConfig, + item.id, + { + force, + onProgress: (step: string) => { statusDialog.update(step); }, + }, + ); + + statusDialog.close(); + + if (result.success) { + // Refresh the list to show updated status/assignee + refreshFromDatabase(list.selected as number); + const url = result.issueUrl || `Issue #${result.issueNumber || '?'}`; + showToast(`Delegated: ${url}`); + + // Offer to open the issue in the browser + if (result.issueUrl) { + const openIdx = await modalDialogs.selectList({ + title: 'Delegation Successful', + message: `Delegated to GitHub Copilot.\n\n${url}`, + items: ['Open in Browser', 'Close'], + defaultIndex: 0, + cancelIndex: 1, + height: 10, + }); + if (openIdx === 0) { + try { + const { exec } = await import('child_process'); + const platform = process.platform; + const cmd = platform === 'darwin' + ? `open "${result.issueUrl}"` + : platform === 'win32' + ? `powershell.exe Start "${result.issueUrl}"` + : `xdg-open "${result.issueUrl}"`; + exec(cmd, (err) => { + if (err) showToast('Could not open browser'); + }); + } catch { + showToast('Could not open browser'); + } + } + } + } else { + // Show error dialog with full detail + showToast('Delegation failed'); + await modalDialogs.selectList({ + title: 'Delegation Failed', + message: `{red-fg}${result.error || 'Unknown error'}{/red-fg}`, + items: ['OK'], + defaultIndex: 0, + cancelIndex: 0, + height: 10, + }); + } + } catch (err: any) { + statusDialog.close(); + showToast('Delegation failed'); + await modalDialogs.selectList({ + title: 'Delegation Failed', + message: `{red-fg}${err?.message || 'Unknown error'}{/red-fg}`, + items: ['OK'], + defaultIndex: 0, + cancelIndex: 0, + height: 10, + }); + } + }); + // Toggle needs producer review flag (shortcut r) screen.key(KEY_TOGGLE_NEEDS_REVIEW, () => { if (!detailModal.hidden || helpMenu.isVisible() || !closeDialog.hidden || !updateDialog.hidden || !nextDialog.hidden) return; diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 958e7886..2b122bb9 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -223,7 +223,7 @@ export function createTuiTestContext() { updateDialog: makeBox(), updateDialogText: makeBox(), updateDialogOptions: makeBox(), updateDialogStageOptions: makeBox(), updateDialogStatusOptions: makeBox(), updateDialogPriorityOptions: makeBox(), updateDialogComment: makeBox(), }, helpMenu: { isVisible: () => false, show: () => {}, hide: () => {} }, - modalDialogs: { selectList: async () => 0, editTextarea: async () => null, confirmTextbox: async () => false, forceCleanup: () => {} }, + modalDialogs: { selectList: async () => 0, editTextarea: async () => null, confirmTextbox: async () => false, forceCleanup: () => {}, messageBox: () => ({ update: () => {}, close: () => {} }) }, opencodeUi: { serverStatusBox: makeBox(), dialog: makeBox(), textarea: makeBox(), suggestionHint: makeBox(), sendButton: makeBox(), cancelButton: makeBox(), ensureResponsePane: () => makeBox() }, nextDialog: { overlay: makeBox(), dialog: makeBox(), close: makeBox(), text: makeBox(), options: makeBox() }, }; diff --git a/tests/tui/delegate-shortcut.test.ts b/tests/tui/delegate-shortcut.test.ts new file mode 100644 index 00000000..ddbf4a6b --- /dev/null +++ b/tests/tui/delegate-shortcut.test.ts @@ -0,0 +1,295 @@ +/** + * Tests for the TUI 'g' key delegate shortcut. + * + * Covers: + * - g key triggers delegate flow when item is focused + * - No-op toast when no item is selected + * - Guard rails: do-not-delegate tag blocks without Force + * - Force override proceeds when item has do-not-delegate + * - Delegate failure shows error toast and error dialog + * - Status dialog shown with progress updates during delegation + * - Non-delegated items preserved after delegation + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks — must be hoisted before any module imports that trigger controller +// --------------------------------------------------------------------------- + +// Mock delegate-helper so no real GitHub calls happen +const mockDelegateWorkItem = vi.hoisted(() => + vi.fn(async () => ({ + success: true, + workItemId: 'WL-TEST-1', + issueNumber: 42, + issueUrl: 'https://github.com/test-owner/test-repo/issues/42', + pushed: true, + assigned: true, + })), +); + +vi.mock('../../src/delegate-helper.js', () => ({ + delegateWorkItem: mockDelegateWorkItem, +})); + +// Mock resolveGithubConfig to avoid needing a real git remote +vi.mock('../../src/commands/github.js', () => ({ + resolveGithubConfig: () => ({ repo: 'test-owner/test-repo', labelPrefix: 'wl:' }), +})); + +import { TuiController } from '../../src/tui/controller.js'; +import { createTuiTestContext } from '../test-utils.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Emit a keypress and wait for async handlers to settle. */ +async function pressKey(ctx: any, key: string) { + ctx.screen.emit('keypress', key, { name: key }); + // Allow microtasks (async key handlers, selectList promise) to resolve + await new Promise((r) => setTimeout(r, 20)); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('TUI g key delegate shortcut', () => { + beforeEach(() => { + mockDelegateWorkItem.mockClear(); + mockDelegateWorkItem.mockResolvedValue({ + success: true, + workItemId: 'WL-TEST-1', + issueNumber: 42, + issueUrl: 'https://github.com/test-owner/test-repo/issues/42', + pushed: true, + assigned: true, + } as any); + }); + + it('is a no-op when the TUI has no work items (controller exits early)', async () => { + const ctx = createTuiTestContext(); + const controller = new TuiController(ctx as any, { blessed: ctx.blessed }); + // No items created — controller.start returns early without registering key handlers + await controller.start({}); + await pressKey(ctx, 'g'); + // No key handler registered, so delegateWorkItem should not be called + expect(mockDelegateWorkItem).not.toHaveBeenCalled(); + }); + + it('calls delegateWorkItem on confirm (selectList returns 0 = Delegate)', async () => { + const ctx = createTuiTestContext(); + const controller = new TuiController(ctx as any, { blessed: ctx.blessed }); + const id = ctx.utils.createSampleItem({ tags: [] }); + await controller.start({}); + + // Default selectList mock returns 0 (Delegate) + await pressKey(ctx, 'g'); + + expect(mockDelegateWorkItem).toHaveBeenCalledTimes(1); + const args = mockDelegateWorkItem.mock.calls[0] as any[]; + expect(args[2]).toBe(id); + expect(args[3]).toEqual(expect.objectContaining({ force: false })); + expect(typeof args[3].onProgress).toBe('function'); + }); + + it('shows success toast with issue URL after delegation', async () => { + const ctx = createTuiTestContext(); + const controller = new TuiController(ctx as any, { blessed: ctx.blessed }); + ctx.utils.createSampleItem({ tags: [] }); + await controller.start({}); + + await pressKey(ctx, 'g'); + + expect(ctx.toast.lastMessage()).toMatch(/Delegated:/); + expect(ctx.toast.lastMessage()).toContain('https://github.com/test-owner/test-repo/issues/42'); + }); + + it('offers to open the issue in the browser after successful delegation', async () => { + const ctx = createTuiTestContext(); + const selectListCalls: any[] = []; + const layout = (ctx as any).createLayout(); + const origSelectList = layout.modalDialogs.selectList; + layout.modalDialogs.selectList = async (opts: any) => { + selectListCalls.push(opts); + // Return 1 (Close) for all dialogs to avoid triggering browser open + return opts.title === 'Delegation Successful' ? 1 : origSelectList(opts); + }; + + const controller = new TuiController(ctx as any, { blessed: ctx.blessed }); + ctx.utils.createSampleItem({ tags: [] }); + await controller.start({}); + + await pressKey(ctx, 'g'); + + // Verify the "Open in Browser" dialog was shown + const browserDialog = selectListCalls.find((c: any) => c.title === 'Delegation Successful'); + expect(browserDialog).toBeDefined(); + expect(browserDialog.items).toEqual(['Open in Browser', 'Close']); + expect(browserDialog.message).toContain('https://github.com/test-owner/test-repo/issues/42'); + }); + + it('shows failure toast and error dialog when delegate returns error', async () => { + mockDelegateWorkItem.mockResolvedValue({ + success: false, + workItemId: 'WL-TEST-1', + error: 'do-not-delegate', + } as any); + + const ctx = createTuiTestContext(); + // Track selectList calls to verify error dialog + const selectListCalls: any[] = []; + const layout = (ctx as any).createLayout(); + const origSelectList = layout.modalDialogs.selectList; + layout.modalDialogs.selectList = async (opts: any) => { + selectListCalls.push(opts); + return origSelectList(opts); + }; + + const controller = new TuiController(ctx as any, { blessed: ctx.blessed }); + ctx.utils.createSampleItem({ tags: [] }); + await controller.start({}); + + await pressKey(ctx, 'g'); + + // Toast shows short failure message + expect(ctx.toast.lastMessage()).toBe('Delegation failed'); + // Error dialog was opened with full error detail + // selectList is called twice: once for confirmation (returns 0), once for error dialog (returns 0 = OK) + const errorDialog = selectListCalls.find((c: any) => c.title === 'Delegation Failed'); + expect(errorDialog).toBeDefined(); + expect(errorDialog.message).toContain('do-not-delegate'); + }); + + it('opens status dialog with progress during delegation', async () => { + const ctx = createTuiTestContext(); + // Track messageBox calls + const messageBoxCalls: any[] = []; + const messageBoxUpdates: string[] = []; + const layout = (ctx as any).createLayout(); + layout.modalDialogs.messageBox = (opts: any) => { + messageBoxCalls.push(opts); + return { + update: (msg: string) => { messageBoxUpdates.push(msg); }, + close: () => {}, + }; + }; + + // Make delegateWorkItem call the onProgress callback + (mockDelegateWorkItem as any).mockImplementation(async (_db: any, _cfg: any, _id: any, opts: any) => { + if (opts?.onProgress) { + opts.onProgress('Pushing to GitHub...'); + opts.onProgress('Assigning @copilot...'); + } + return { + success: true, + workItemId: 'WL-TEST-1', + issueNumber: 42, + issueUrl: 'https://github.com/test-owner/test-repo/issues/42', + pushed: true, + assigned: true, + }; + }); + + const controller = new TuiController(ctx as any, { blessed: ctx.blessed }); + ctx.utils.createSampleItem({ tags: [] }); + await controller.start({}); + + await pressKey(ctx, 'g'); + + // messageBox was opened for status + expect(messageBoxCalls.length).toBe(1); + expect(messageBoxCalls[0].title).toBe('Delegating to Copilot'); + // Progress updates were sent + expect(messageBoxUpdates).toContain('Pushing to GitHub...'); + expect(messageBoxUpdates).toContain('Assigning @copilot...'); + }); + + it('cancels when selectList returns cancel index', async () => { + const ctx = createTuiTestContext(); + // Override selectList to return index 1 (Cancel) — choices are ['Delegate', 'Cancel'] + (ctx as any).createLayout().modalDialogs.selectList = async () => 1; + const controller = new TuiController(ctx as any, { blessed: ctx.blessed }); + ctx.utils.createSampleItem({ tags: [] }); + await controller.start({}); + + await pressKey(ctx, 'g'); + + expect(mockDelegateWorkItem).not.toHaveBeenCalled(); + }); + + it('delegates with force=true when item has do-not-delegate tag', async () => { + const ctx = createTuiTestContext(); + // For do-not-delegate items, choices are + // ['Delegate (ignoring Do Not Delegate flag)', 'Cancel'] + // selectList returns 0 by default which confirms delegation with force + const controller = new TuiController(ctx as any, { blessed: ctx.blessed }); + const id = ctx.utils.createSampleItem({ tags: ['do-not-delegate'] }); + await controller.start({}); + + await pressKey(ctx, 'g'); + + expect(mockDelegateWorkItem).toHaveBeenCalledTimes(1); + const args2 = mockDelegateWorkItem.mock.calls[0] as any[]; + expect(args2[2]).toBe(id); + expect(args2[3]).toEqual(expect.objectContaining({ force: true })); + }); + + it('cancels do-not-delegate item when Cancel is selected', async () => { + const ctx = createTuiTestContext(); + // Override selectList to return index 1 (Cancel) + (ctx as any).createLayout().modalDialogs.selectList = async () => 1; + const controller = new TuiController(ctx as any, { blessed: ctx.blessed }); + ctx.utils.createSampleItem({ tags: ['do-not-delegate'] }); + await controller.start({}); + + await pressKey(ctx, 'g'); + + expect(mockDelegateWorkItem).not.toHaveBeenCalled(); + }); + + it('does not trigger during move mode', async () => { + const ctx = createTuiTestContext(); + const controller = new TuiController(ctx as any, { blessed: ctx.blessed }); + ctx.utils.createSampleItem({ tags: [] }); + await controller.start({}); + + // Enter move mode by pressing 'M' first + ctx.screen.emit('keypress', 'M', { name: 'm' }); + await new Promise((r) => setTimeout(r, 10)); + + // Now press 'g' — should be suppressed + await pressKey(ctx, 'g'); + + expect(mockDelegateWorkItem).not.toHaveBeenCalled(); + }); + + it('shows error dialog when delegateWorkItem throws', async () => { + // Make delegateWorkItem throw to simulate unexpected error + mockDelegateWorkItem.mockRejectedValue(new Error('GitHub repo not configured')); + + const ctx = createTuiTestContext(); + // Track selectList calls to verify error dialog + const selectListCalls: any[] = []; + const layout = (ctx as any).createLayout(); + const origSelectList = layout.modalDialogs.selectList; + layout.modalDialogs.selectList = async (opts: any) => { + selectListCalls.push(opts); + return origSelectList(opts); + }; + + const controller = new TuiController(ctx as any, { blessed: ctx.blessed }); + ctx.utils.createSampleItem({ tags: [] }); + await controller.start({}); + + await pressKey(ctx, 'g'); + + // Toast shows short failure message + expect(ctx.toast.lastMessage()).toBe('Delegation failed'); + // Error dialog was opened with full error detail + const errorDialog = selectListCalls.find((c: any) => c.title === 'Delegation Failed'); + expect(errorDialog).toBeDefined(); + expect(errorDialog.message).toContain('GitHub repo not configured'); + }); +});