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
1 change: 1 addition & 0 deletions CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <owner/name>`, `--label-prefix <prefix>`, `--since <ISO timestamp>`, `--create-new`, `--prefix <prefix>`.
- `delegate <id>` — 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 <owner/name>`, `--label-prefix <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:

Expand Down
2 changes: 2 additions & 0 deletions TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
159 changes: 58 additions & 101 deletions src/commands/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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');
Expand All @@ -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}`);
}
});
}
Loading