From e7a4514af2a398568833b2bfaf52cd2dcabcc725 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:05:02 +0000 Subject: [PATCH 1/2] Initial plan From 136e1ecb29d51feb024a6d8231404844fe90341e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:22:26 +0000 Subject: [PATCH 2/2] feat: add GitHub link and Push action to TUI metadata pane Co-authored-by: SorraTheOrc <250240+SorraTheOrc@users.noreply.github.com> --- src/tui/components/metadata-pane.ts | 11 ++ src/tui/constants.ts | 2 + src/tui/controller.ts | 104 +++++++++++- tests/tui/tui-50-50-layout.test.ts | 8 +- tests/tui/tui-github-metadata.test.ts | 235 ++++++++++++++++++++++++++ 5 files changed, 354 insertions(+), 6 deletions(-) create mode 100644 tests/tui/tui-github-metadata.test.ts diff --git a/src/tui/components/metadata-pane.ts b/src/tui/components/metadata-pane.ts index 3f53525..a019468 100644 --- a/src/tui/components/metadata-pane.ts +++ b/src/tui/components/metadata-pane.ts @@ -68,6 +68,8 @@ export class MetadataPaneComponent { assignee?: string; createdAt?: Date | string; updatedAt?: Date | string; + githubIssueNumber?: number; + githubRepo?: string; } | null, commentCount: number): void { if (!item) { this.box.setContent(''); @@ -82,6 +84,15 @@ export class MetadataPaneComponent { lines.push(`Assignee: ${item.assignee ?? ''}`); lines.push(`Created: ${MetadataPaneComponent.formatDate(item.createdAt)}`); lines.push(`Updated: ${MetadataPaneComponent.formatDate(item.updatedAt)}`); + + if (!item.githubRepo) { + lines.push('GitHub: (set githubRepo in config to enable)'); + } else if (item.githubIssueNumber) { + lines.push(`GitHub: ${item.githubRepo}#${item.githubIssueNumber} (G to open)`); + } else { + lines.push('GitHub: (G to push to GitHub)'); + } + this.box.setContent(lines.join('\n')); } diff --git a/src/tui/constants.ts b/src/tui/constants.ts index fdcb28b..ea30728 100644 --- a/src/tui/constants.ts +++ b/src/tui/constants.ts @@ -99,6 +99,7 @@ export const DEFAULT_SHORTCUTS = [ { keys: 'M', description: 'Move/reparent item' }, { keys: 'D', description: 'Toggle do-not-delegate' }, { keys: 'g', description: 'Delegate to Copilot' }, + { keys: 'G', description: 'Open/push GitHub issue' }, { keys: 'r/R', description: 'Toggle needs review' }, ], }, @@ -155,6 +156,7 @@ 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']; +export const KEY_GITHUB_PUSH = ['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 b18e89f..336ce27 100644 --- a/src/tui/controller.ts +++ b/src/tui/controller.ts @@ -36,11 +36,12 @@ 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, KEY_DELEGATE } 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, KEY_GITHUB_PUSH } 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'; +import { upsertIssuesFromWorkItems } from '../github-sync.js'; type Item = WorkItem; @@ -1266,6 +1267,15 @@ export class TuiController { } catch (_) {} }; + // Resolve github repo without throwing — returns null when not configured. + const tryGetGithubRepo = (): string | null => { + try { + return resolveGithubConfig({}).repo; + } catch (_) { + return null; + } + }; + const opencodeClient = new OpencodeClientImpl({ port: OPENCODE_SERVER_PORT, cwd: worklogRoot, @@ -1697,7 +1707,7 @@ export class TuiController { // Update metadata pane with current item's metadata if (metadataPaneComponent) { const commentCount = db ? db.getCommentsForWorkItem(node.item.id).length : 0; - metadataPaneComponent.updateFromItem(node.item, commentCount); + metadataPaneComponent.updateFromItem({ ...node.item, githubRepo: tryGetGithubRepo() ?? undefined }, commentCount); } } @@ -2985,7 +2995,9 @@ export class TuiController { }); // Delegate to GitHub Copilot (shortcut g) - screen.key(KEY_DELEGATE, async () => { + screen.key(KEY_DELEGATE, async (_ch: any, key: any) => { + // Ignore when shift is held — that is handled by KEY_GITHUB_PUSH ('G') + if (key?.shift) return; // 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; @@ -3097,6 +3109,92 @@ export class TuiController { } }); + // Open GitHub issue or push item to GitHub (shortcut G) + screen.key(KEY_GITHUB_PUSH, async (_ch: any, key: any) => { + // Only fire for shift+G (not plain g which is handled by KEY_DELEGATE) + if (!key?.shift) return; + if (!detailModal.hidden || helpMenu.isVisible() || !closeDialog.hidden || !updateDialog.hidden || !nextDialog.hidden) return; + if (state.moveMode) return; + + const item = getSelectedItem(); + if (!item) { + showToast('No item selected'); + return; + } + + // Resolve github config (null means not configured) + let githubConfig: { repo: string; labelPrefix: string } | null = null; + try { + githubConfig = resolveGithubConfig({}); + } catch (_) { + showToast('Set githubRepo in config or run: wl github --repo push'); + return; + } + + if (item.githubIssueNumber) { + // Item already has a GitHub mapping — open the issue URL in the browser + const url = `https://github.com/${githubConfig.repo}/issues/${item.githubIssueNumber}`; + try { + const openUrl = (await import('../utils/open-url.js')).default; + const ok = await openUrl(url, fsImpl as any); + if (!ok) { + // Fall back to clipboard + const clipResult = await copyToClipboard(url, { spawn: spawnImpl, writeOsc52: (s: string) => { try { (screen as any).program?.write?.(s); } catch (_) {} } }); + showToast(clipResult.success ? `URL copied: ${url}` : `Open failed: ${url}`); + } else { + showToast('Opening GitHub issue…'); + } + } catch (_) { + showToast(`GitHub: ${url}`); + } + return; + } + + // No mapping yet — push this item to GitHub + showToast(`Pushing to GitHub…`); + screen.render(); + + try { + const comments = db ? db.getCommentsForWorkItem(item.id) : []; + const { updatedItems, result } = await upsertIssuesFromWorkItems( + [item], + comments as any, + githubConfig, + ); + + // Persist the updated GitHub mapping fields back to the database. + // upsertItems is available on WorklogDatabase but may not be present in + // all test doubles, so use optional chaining to guard gracefully. + if (updatedItems.length > 0) { + (db as any).upsertItems?.(updatedItems); + } + + refreshFromDatabase(list.selected as number); + + const synced = result.syncedItems.find(s => s.id === item.id); + if (synced?.issueNumber) { + const url = `https://github.com/${githubConfig.repo}/issues/${synced.issueNumber}`; + showToast(`Pushed: ${githubConfig.repo}#${synced.issueNumber}`); + try { + const openUrl = (await import('../utils/open-url.js')).default; + const ok = await openUrl(url, fsImpl as any); + if (!ok) { + const clipResult = await copyToClipboard(url, { spawn: spawnImpl, writeOsc52: (s: string) => { try { (screen as any).program?.write?.(s); } catch (_) {} } }); + if (clipResult.success) showToast('URL copied to clipboard'); + } + } catch (_) { + // ignore browser open errors + } + } else if (result.errors.length > 0) { + showToast(`Push failed: ${result.errors[0]}`); + } else { + showToast('Push complete (no changes)'); + } + } catch (err: any) { + showToast(`Push failed: ${err?.message || 'Unknown error'}`); + } + }); + // 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/tui/tui-50-50-layout.test.ts b/tests/tui/tui-50-50-layout.test.ts index 6797865..1c23d08 100644 --- a/tests/tui/tui-50-50-layout.test.ts +++ b/tests/tui/tui-50-50-layout.test.ts @@ -460,9 +460,11 @@ describe('TUI 50/50 split layout', () => { expect(capturedContent).toContain('Jan 1, 2024'); expect(capturedContent).toContain('Updated:'); expect(capturedContent).toContain('Jun 1, 2024'); + // GitHub row is always present (9th row) + expect(capturedContent).toContain('GitHub:'); // All rows should always be present (consistent layout) const lines = capturedContent.split('\n'); - expect(lines.length).toBe(8); + expect(lines.length).toBe(9); }); it('MetadataPaneComponent.updateFromItem clears content for null item', () => { @@ -516,9 +518,9 @@ describe('TUI 50/50 split layout', () => { assignee: '', }, 0); - // All 8 rows should always be present for consistent layout + // All 9 rows should always be present for consistent layout const lines = capturedContent.split('\n'); - expect(lines.length).toBe(8); + expect(lines.length).toBe(9); expect(capturedContent).toContain('Status:'); expect(capturedContent).toContain('Tags:'); expect(capturedContent).toContain('Assignee:'); diff --git a/tests/tui/tui-github-metadata.test.ts b/tests/tui/tui-github-metadata.test.ts new file mode 100644 index 0000000..db8b493 --- /dev/null +++ b/tests/tui/tui-github-metadata.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, vi } from 'vitest'; +import { MetadataPaneComponent } from '../../src/tui/components/metadata-pane.js'; +import { TuiController } from '../../src/tui/controller.js'; +import { createTuiTestContext } from '../test-utils.js'; + +// --------------------------------------------------------------------------- +// Helper: minimal mock box/screen for MetadataPaneComponent unit tests +// --------------------------------------------------------------------------- +function createMockMetadataPane() { + let capturedContent = ''; + const mockBox = { + setContent: vi.fn((c: string) => { capturedContent = c; }), + on: vi.fn(), + key: vi.fn(), + focus: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + destroy: vi.fn(), + removeAllListeners: vi.fn(), + style: {}, + }; + const mockBlessed = { box: vi.fn(() => mockBox) }; + const mockScreen = { on: vi.fn() }; + const comp = new MetadataPaneComponent({ parent: mockScreen as any, blessed: mockBlessed as any }).create(); + return { comp, getContent: () => capturedContent }; +} + +// --------------------------------------------------------------------------- +// Helper: build a TUI layout mock with an injectable metadataPaneComponent. +// Shared across integration tests to reduce duplication. +// --------------------------------------------------------------------------- +function buildLayoutWithMetadataMock(ctx: ReturnType, updateFromItemMock: ReturnType) { + (ctx as any).createLayout = () => ({ + screen: ctx.screen, + listComponent: { getList: () => ctx.blessed.list(), getFooter: () => ctx.blessed.box() }, + detailComponent: { getDetail: () => ctx.blessed.box(), getCopyIdButton: () => ctx.blessed.box() }, + metadataPaneComponent: { getBox: () => ctx.blessed.box(), updateFromItem: updateFromItemMock }, + toastComponent: { show: (m: string) => ctx.toast.show(m) }, + overlaysComponent: { detailOverlay: ctx.blessed.box(), closeOverlay: ctx.blessed.box(), updateOverlay: ctx.blessed.box() }, + dialogsComponent: { + detailModal: ctx.blessed.box(), detailClose: ctx.blessed.box(), + closeDialog: ctx.blessed.box(), closeDialogText: ctx.blessed.box(), closeDialogOptions: ctx.blessed.box(), + updateDialog: ctx.blessed.box(), updateDialogText: ctx.blessed.box(), updateDialogOptions: ctx.blessed.box(), + updateDialogStageOptions: ctx.blessed.box(), updateDialogStatusOptions: ctx.blessed.box(), + updateDialogPriorityOptions: ctx.blessed.box(), updateDialogComment: ctx.blessed.box(), + }, + helpMenu: { isVisible: () => false, show: () => {}, hide: () => {} }, + modalDialogs: { + selectList: async () => 0, editTextarea: async () => null, + confirmTextbox: async () => false, forceCleanup: () => {}, + messageBox: () => ({ update: () => {}, close: () => {} }), + }, + opencodeUi: { + serverStatusBox: ctx.blessed.box(), dialog: ctx.blessed.box(), textarea: ctx.blessed.box(), + suggestionHint: ctx.blessed.box(), sendButton: ctx.blessed.box(), cancelButton: ctx.blessed.box(), + ensureResponsePane: () => ctx.blessed.box(), + }, + nextDialog: { + overlay: ctx.blessed.box(), dialog: ctx.blessed.box(), close: ctx.blessed.box(), + text: ctx.blessed.box(), options: ctx.blessed.box(), + }, + }); +} + +// --------------------------------------------------------------------------- +// Unit tests: MetadataPaneComponent GitHub row rendering +// --------------------------------------------------------------------------- +describe('MetadataPaneComponent GitHub row', () => { + it('shows configure hint when githubRepo is not set', () => { + const { comp, getContent } = createMockMetadataPane(); + comp.updateFromItem({ status: 'open', priority: 'medium' }, 0); + + expect(getContent()).toContain('GitHub:'); + expect(getContent()).toContain('githubRepo'); + }); + + it('shows link with repo and issue number when item has a GitHub mapping', () => { + const { comp, getContent } = createMockMetadataPane(); + comp.updateFromItem({ + status: 'open', + priority: 'medium', + githubRepo: 'owner/repo', + githubIssueNumber: 42, + }, 0); + + const content = getContent(); + expect(content).toContain('GitHub:'); + expect(content).toContain('owner/repo#42'); + expect(content).toContain('G to open'); + }); + + it('shows push action when githubRepo is set but item has no issue number', () => { + const { comp, getContent } = createMockMetadataPane(); + comp.updateFromItem({ + status: 'open', + priority: 'medium', + githubRepo: 'owner/repo', + }, 0); + + const content = getContent(); + expect(content).toContain('GitHub:'); + expect(content).toContain('G to push'); + }); + + it('always renders exactly 9 rows regardless of GitHub state', () => { + const { comp, getContent } = createMockMetadataPane(); + + // With no github fields + comp.updateFromItem({ status: 'open' }, 0); + expect(getContent().split('\n').length).toBe(9); + + // With github mapping + comp.updateFromItem({ status: 'open', githubRepo: 'o/r', githubIssueNumber: 1 }, 0); + expect(getContent().split('\n').length).toBe(9); + + // With github configured but no mapping + comp.updateFromItem({ status: 'open', githubRepo: 'o/r' }, 0); + expect(getContent().split('\n').length).toBe(9); + }); + + it('clears content for null item', () => { + const { comp, getContent } = createMockMetadataPane(); + comp.updateFromItem(null, 0); + expect(getContent()).toBe(''); + }); +}); + +// --------------------------------------------------------------------------- +// Integration tests: controller passes github fields to updateFromItem +// --------------------------------------------------------------------------- +describe('TUI metadata pane receives GitHub fields', () => { + it('calls updateFromItem after start', async () => { + const ctx = createTuiTestContext(); + const updateFromItemMock = vi.fn(); + buildLayoutWithMetadataMock(ctx, updateFromItemMock); + + ctx.utils.createSampleItem({ tags: [] }); + + const controller = new TuiController(ctx as any, { blessed: ctx.blessed }); + await controller.start({}); + + // updateFromItem should have been called with the selected item + expect(updateFromItemMock).toHaveBeenCalled(); + }); + + it('passes githubIssueNumber from the item to updateFromItem', async () => { + const ctx = createTuiTestContext(); + const updateFromItemMock = vi.fn(); + buildLayoutWithMetadataMock(ctx, updateFromItemMock); + + const id = ctx.utils.createSampleItem({ tags: [] }); + // Manually set githubIssueNumber on the item in the in-memory store + const item = ctx.utils.getDatabase().get(id); + if (item) (item as any).githubIssueNumber = 77; + + const controller = new TuiController(ctx as any, { blessed: ctx.blessed }); + await controller.start({}); + + expect(updateFromItemMock).toHaveBeenCalled(); + // The first call's first argument should be the item object + const callArg = updateFromItemMock.mock.calls[0]?.[0]; + expect(callArg).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Integration tests: G key handler (KEY_GITHUB_PUSH) +// --------------------------------------------------------------------------- +describe('TUI G key (shift+G) GitHub action', () => { + it('shows no-item toast when nothing is selected', async () => { + const ctx = createTuiTestContext(); + // Override db to return empty list + ctx.utils.getDatabase = () => ({ + list: () => [], + getPrefix: () => undefined, + getCommentsForWorkItem: () => [], + update: () => ({}), + createComment: () => ({}), + get: () => null, + }); + + const controller = new TuiController(ctx as any, { blessed: ctx.blessed }); + await controller.start({}); + + // No items → start returns early with 'No work items found', G press is a no-op + ctx.screen.emit('keypress', 'G', { name: 'G', shift: true }); + await new Promise(resolve => setTimeout(resolve, 50)); + // Verify no crash occurred; toast may be empty or set from earlier + }); + + it('shows a toast when G is pressed with an item selected (no github config)', async () => { + const ctx = createTuiTestContext(); + const updateFromItemMock = vi.fn(); + buildLayoutWithMetadataMock(ctx, updateFromItemMock); + + ctx.utils.createSampleItem({ tags: [] }); + + const controller = new TuiController(ctx as any, { blessed: ctx.blessed }); + await controller.start({}); + + // Press G (shift+G); resolveGithubConfig will throw because no config in test env + ctx.screen.emit('keypress', 'G', { name: 'G', shift: true }); + await new Promise(resolve => setTimeout(resolve, 100)); + + // Toast should mention github or config + const msg = ctx.toast.lastMessage(); + expect(msg).toBeTruthy(); + expect(msg.toLowerCase()).toMatch(/github|config|repo|push|set/i); + }); + + it('does NOT trigger the G handler when shift is not pressed (plain g)', async () => { + const ctx = createTuiTestContext(); + const updateFromItemMock = vi.fn(); + buildLayoutWithMetadataMock(ctx, updateFromItemMock); + + ctx.utils.createSampleItem({ tags: [] }); + + const controller = new TuiController(ctx as any, { blessed: ctx.blessed }); + await controller.start({}); + + const toastBefore = ctx.toast.lastMessage(); + + // Press 'g' without shift — should NOT trigger GitHub push handler. + // The KEY_GITHUB_PUSH handler guards with !key.shift and returns early. + ctx.screen.emit('keypress', 'g', { name: 'g', shift: false }); + await new Promise(resolve => setTimeout(resolve, 100)); + + // If the GitHub push handler had fired, it would show a config hint toast. + // Since it's guarded against shift:false, the toast should NOT contain a + // "Set githubRepo" message. Any delegate-related toast is acceptable. + const msg = ctx.toast.lastMessage(); + expect(msg).not.toMatch(/Set githubRepo in config/); + }); +}); +