Skip to content
Merged
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
7 changes: 4 additions & 3 deletions src/github-throttler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,10 @@ export class TokenBucketThrottler {
// start full
this.tokens = this.burst;
this.lastRefill = this.clock.now();
// Enable debug when explicit env var set or when the process is started
// with a `--verbose` flag (useful when running test runner with `--verbose`).
this.debug = Boolean(process.env.WL_GITHUB_THROTTLER_DEBUG) || (Array.isArray(process.argv) && process.argv.includes('--verbose'));
// Enable throttler debug logging only when explicitly requested.
// Tying this to global `--verbose` causes console.debug output to interfere
// with full-screen TUI rendering during GitHub push operations.
this.debug = Boolean(process.env.WL_GITHUB_THROTTLER_DEBUG);
}

schedule<T>(fn: () => Promise<T> | T): Promise<T> {
Expand Down
6 changes: 5 additions & 1 deletion src/tui/components/metadata-pane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,12 @@ export class MetadataPaneComponent {
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)`);
// Only show the issue number in the metadata pane; repo is implied by config
// Make the text explicit about interaction so controller can wire key/click handlers
lines.push(`GitHub: #${item.githubIssueNumber} (G to open)`);
} else {
// Show a visual affordance that pushing is available; controller will
// handle the actual push logic and keyboard/mouse interactions.
lines.push('GitHub: (G to push to GitHub)');
}

Expand Down
4 changes: 3 additions & 1 deletion src/tui/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,9 @@ 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'];
// Include both cases because blessed may normalize key.name to lowercase while
// still exposing uppercase intent via the raw `ch` argument.
export const KEY_GITHUB_PUSH = ['g', 'G'];

// Composite keys often used in help menu / close handlers
export const KEY_MENU_CLOSE = ['escape', 'q'];
Expand Down
130 changes: 71 additions & 59 deletions src/tui/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2773,6 +2773,14 @@ export class TuiController {
} catch (err) {
debugLog(`ChordHandler.feed threw: ${(err as any)?.message ?? String(err)}`);
}

// Some terminals/blessed combinations report Shift+g as raw ch='G'
// without setting key.shift in downstream `screen.key` handlers.
// Handle it directly here so the GitHub shortcut is reliable.
if (_ch === 'G') {
void handleGithubPushShortcut(_ch, key);
return false;
}

// No legacy pending-state fallback: chordHandler.feed handles all
// Ctrl-W prefixes and their follow-ups. If chordHandler didn't
Expand Down Expand Up @@ -2996,6 +3004,13 @@ export class TuiController {

// Delegate to GitHub Copilot (shortcut g)
screen.key(KEY_DELEGATE, async (_ch: any, key: any) => {
// If the raw character is uppercase 'G', treat it as the GitHub push
// shortcut and do not handle it here. Blessed may report shift via
// the raw char (`ch`) rather than `key.shift`/`key.name`.
if (_ch === 'G') return;
// Only handle plain 'g' key events. If key.name is present and not 'g'
// then ignore (this avoids other key ambiguities).
if (key && key.name && key.name !== 'g') return;
// 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
Expand Down Expand Up @@ -3110,9 +3125,9 @@ 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;
async function handleGithubPushShortcut(_ch: any, key: any): Promise<void> {
const isUppercaseG = _ch === 'G' || key?.shift || key?.full === 'G';
if (!isUppercaseG) return;
if (!detailModal.hidden || helpMenu.isVisible() || !closeDialog.hidden || !updateDialog.hidden || !nextDialog.hidden) return;
if (state.moveMode) return;

Expand All @@ -3122,77 +3137,74 @@ export class TuiController {
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 <owner/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}`;
const helperModule = await import('./github-action-helper');
await (helperModule as any).default({
item,
screen,
db,
showToast,
fsImpl,
spawnImpl,
copyToClipboard,
resolveGithubConfig,
upsertIssuesFromWorkItems,
list,
refreshFromDatabase,
});
} catch (_e) {
// Resolve github config (null means not configured)
let githubConfig: { repo: string; labelPrefix: string } | null = null;
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…');
}
githubConfig = resolveGithubConfig({});
} 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);
showToast('Set githubRepo in config or run: wl github --repo <owner/repo> push');
return;
}

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}`);
if (item.githubIssueNumber) {
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) {
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');
showToast(clipResult.success ? `URL copied: ${url}` : `Open failed: ${url}`);
} else {
showToast('Opening GitHub issue…');
}
} catch (_) {
// ignore browser open errors
showToast(`GitHub: ${url}`);
}
} else if (result.errors.length > 0) {
showToast(`Push failed: ${result.errors[0]}`);
} else {
showToast('Push complete (no changes)');
return;
}

showToast('Pushing to GitHub…');
screen.render();
try {
const comments = db ? db.getCommentsForWorkItem(item.id) : [];
const { updatedItems, result } = await upsertIssuesFromWorkItems([item], comments as any, githubConfig);
if (updatedItems.length > 0) {
(db as any).upsertItems?.(updatedItems);
}
refreshFromDatabase(list.selected as number);
const synced = result.syncedItems.find((s: any) => s.id === item.id);
if (synced?.issueNumber) {
const url = `https://github.com/${githubConfig.repo}/issues/${synced.issueNumber}`;
showToast(`Pushed: ${githubConfig.repo}#${synced.issueNumber}`);
} 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'}`);
}
} catch (err: any) {
showToast(`Push failed: ${err?.message || 'Unknown error'}`);
}
}

screen.key(KEY_GITHUB_PUSH, async (_ch: any, key: any) => {
await handleGithubPushShortcut(_ch, key);
});

// Toggle needs producer review flag (shortcut r)
Expand Down
107 changes: 107 additions & 0 deletions src/tui/github-action-helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { copyToClipboard } from '../clipboard.js';

export default async function githubActionHelper(opts: {
item: any;
screen?: any;
db?: any;
showToast: (s: string) => void;
fsImpl?: any;
spawnImpl?: any;
copyToClipboard?: typeof copyToClipboard;
resolveGithubConfig: (o: any) => { repo: string; labelPrefix?: string } | null;
upsertIssuesFromWorkItems: (items: any[], comments: any[], cfg: any) => Promise<any>;
list?: any;
refreshFromDatabase?: (idx?: number) => void;
}): Promise<void> {
const {
item,
screen,
db,
showToast,
fsImpl,
spawnImpl,
copyToClipboard: copyFn = copyToClipboard,
resolveGithubConfig,
upsertIssuesFromWorkItems,
list,
refreshFromDatabase,
} = opts;

let githubConfig: { repo: string; labelPrefix?: string } | null = null;
try {
githubConfig = resolveGithubConfig({});
} catch (e) {
showToast('Set githubRepo in config or run: wl github --repo <owner/repo> push');
return;
}

if (item.githubIssueNumber) {
const url = `https://github.com/${githubConfig!.repo}/issues/${item.githubIssueNumber}`;
try {
const openUrlMod = await import('../utils/open-url.js');
const openUrl = (openUrlMod as any).default;
const ok = await openUrl(url, fsImpl);
if (!ok) {
const clipResult = await copyFn(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 (e) {
showToast(`GitHub: ${url}`);
}
return;
}

showToast('Pushing to GitHub…');
try { screen?.render?.(); } catch (_) {}

try {
const comments = db ? db.getCommentsForWorkItem(item.id) : [];
const { updatedItems, result } = await upsertIssuesFromWorkItems([item], comments, githubConfig);

if (updatedItems && updatedItems.length > 0) {
if (db && typeof db.upsertItems === 'function') db.upsertItems(updatedItems);
}

try { refreshFromDatabase && refreshFromDatabase(list?.selected ?? 0); } catch (_) {}

const synced = result && result.syncedItems ? result.syncedItems.find((s: any) => s.id === item.id) : null;
if (synced && synced.issueNumber) {
const url = `https://github.com/${githubConfig!.repo}/issues/${synced.issueNumber}`;
showToast(`Pushed: ${githubConfig!.repo}#${synced.issueNumber}`);
try {
const openUrlMod = await import('../utils/open-url.js');
const openUrl = (openUrlMod as any).default;
const ok = await openUrl(url, fsImpl);
if (!ok) {
const clipResult = await copyFn(url, { spawn: spawnImpl, writeOsc52: (s: string) => { try { if (screen && screen.program && typeof screen.program.write === 'function') screen.program.write(s); } catch (_) {} } });
if (clipResult.success) showToast('URL copied to clipboard');
}
} catch (_) {}
} else if (item.githubIssueNumber) {
// Mapping might have existed already or sync returned no explicit item;
// still try to open the known mapped URL.
const url = `https://github.com/${githubConfig!.repo}/issues/${item.githubIssueNumber}`;
try {
const openUrlMod = await import('../utils/open-url.js');
const openUrl = (openUrlMod as any).default;
const ok = await openUrl(url, fsImpl);
if (!ok) {
const clipResult = await copyFn(url, { spawn: spawnImpl, writeOsc52: (s: string) => { try { if (screen && screen.program && typeof screen.program.write === 'function') screen.program.write(s); } catch (_) {} } });
if (clipResult.success) showToast('URL copied to clipboard');
} else {
showToast('Opening GitHub issue…');
}
} catch (_) {
showToast(`GitHub: ${url}`);
}
} else if (result && result.errors && 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'}`);
}
}
Loading
Loading