Skip to content

Commit e342eab

Browse files
committed
WL-0MM8LX8RB0OVLJWB, WL-0MM8LXODU1DA2PON, WL-0MM8LXZ0M04W2YUF: Implement delegate subcommand with guard rails, push/assign flow, and output formatting
- Register `wl github delegate <id>` subcommand with --force, --json, --prefix options - Implement do-not-delegate tag guard rail with --force bypass - Implement children warning with TTY prompt; non-interactive mode proceeds silently - Wire push (upsertIssuesFromWorkItems) + assign (assignGithubIssueAsync) + local state update - On assignment failure: skip local state update, add comment, re-push for consistency - Support both human-readable progress output and structured --json output - Add 13 unit tests covering guard rails, success/failure paths, and output formatting
1 parent f8a6a4a commit e342eab

2 files changed

Lines changed: 536 additions & 0 deletions

File tree

src/commands/github.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,4 +435,175 @@ export default function register(ctx: PluginContext): void {
435435
process.exit(1);
436436
}
437437
});
438+
439+
githubCommand
440+
.command('delegate <id>')
441+
.description('Delegate a work item to GitHub Copilot coding agent')
442+
.option('--force', 'Bypass do-not-delegate tag guard rail', false)
443+
.option('--prefix <prefix>', 'Override the default prefix')
444+
.action(async (id: string, options: { force?: boolean; prefix?: string }) => {
445+
utils.requireInitialized();
446+
const db = utils.getDatabase(options.prefix);
447+
const isJsonMode = utils.isJsonMode();
448+
449+
// Resolve work item
450+
const normalizedId = utils.normalizeCliId(id, options.prefix) || id;
451+
const item = db.get(normalizedId);
452+
if (!item) {
453+
output.error(`Work item not found: ${normalizedId}`, {
454+
success: false,
455+
error: `Work item not found: ${normalizedId}`,
456+
});
457+
process.exit(1);
458+
}
459+
460+
// Guard rail: do-not-delegate tag
461+
if (Array.isArray(item.tags) && item.tags.includes('do-not-delegate')) {
462+
if (!options.force) {
463+
const message = `Work item ${normalizedId} has a "do-not-delegate" tag. Use --force to override.`;
464+
output.error(message, {
465+
success: false,
466+
error: 'do-not-delegate',
467+
workItemId: normalizedId,
468+
});
469+
process.exit(1);
470+
}
471+
if (!isJsonMode) {
472+
console.log(`Warning: Work item ${normalizedId} has a "do-not-delegate" tag. Proceeding due to --force.`);
473+
}
474+
}
475+
476+
// Guard rail: children warning
477+
const children = db.getChildren(normalizedId);
478+
if (children.length > 0) {
479+
const nonClosedChildren = children.filter(
480+
c => c.status !== 'completed' && c.status !== 'deleted'
481+
);
482+
if (nonClosedChildren.length > 0) {
483+
// In non-interactive mode (JSON or non-TTY), proceed with single item only
484+
const isInteractive = !isJsonMode && process.stdout.isTTY === true && process.stdin.isTTY === true;
485+
if (isInteractive) {
486+
const readline = await import('node:readline');
487+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
488+
const answer = await new Promise<string>(resolve => {
489+
rl.question(
490+
`Work item ${normalizedId} has ${nonClosedChildren.length} open child item(s). ` +
491+
`Only the specified item will be delegated. Continue? (y/N): `,
492+
resolve
493+
);
494+
});
495+
rl.close();
496+
if (answer.toLowerCase() !== 'y' && answer.toLowerCase() !== 'yes') {
497+
if (!isJsonMode) {
498+
console.log('Delegation cancelled.');
499+
}
500+
process.exit(0);
501+
}
502+
} else {
503+
// Non-interactive: proceed with single item, log warning
504+
if (!isJsonMode) {
505+
console.log(
506+
`Warning: Work item ${normalizedId} has ${nonClosedChildren.length} open child item(s). ` +
507+
`Delegating only the specified item.`
508+
);
509+
}
510+
}
511+
}
512+
}
513+
514+
// Guard rails passed — delegate flow placeholder
515+
// The actual push + assign + local state update is wired in WL-0MM8LXODU1DA2PON
516+
try {
517+
const githubConfig = resolveGithubConfig({ repo: (options as any).repo, labelPrefix: (options as any).labelPrefix });
518+
519+
// Push the work item to GitHub (smart sync)
520+
const items = db.getAll();
521+
const comments = db.getAllComments();
522+
const { updatedItems } = await upsertIssuesFromWorkItems(
523+
[item],
524+
comments.filter(c => c.workItemId === item.id),
525+
githubConfig,
526+
() => {} // no progress rendering for single-item push
527+
);
528+
if (updatedItems.length > 0) {
529+
db.import(updatedItems);
530+
}
531+
532+
// Resolve the GitHub issue number (may have been set by the push)
533+
const refreshedItem = db.get(normalizedId);
534+
const issueNumber = refreshedItem?.githubIssueNumber ?? item.githubIssueNumber;
535+
if (!issueNumber) {
536+
const message = `Failed to resolve GitHub issue number for ${normalizedId} after push.`;
537+
output.error(message, {
538+
success: false,
539+
error: message,
540+
workItemId: normalizedId,
541+
});
542+
process.exit(1);
543+
}
544+
545+
// Assign the issue to copilot
546+
const { assignGithubIssueAsync } = await import('../github.js');
547+
const assignResult = await assignGithubIssueAsync(githubConfig, issueNumber, 'copilot');
548+
549+
if (!assignResult.ok) {
550+
// Assignment failed: do NOT update local state, add comment, re-push
551+
const failureMessage = `Failed to assign copilot to GitHub issue #${issueNumber}: ${assignResult.error}`;
552+
db.createComment({
553+
workItemId: normalizedId,
554+
author: 'wl-delegate',
555+
comment: failureMessage,
556+
});
557+
// Re-push to restore consistency after comment
558+
const refreshedComments = db.getAllComments();
559+
await upsertIssuesFromWorkItems(
560+
[db.get(normalizedId)!],
561+
refreshedComments.filter(c => c.workItemId === normalizedId),
562+
githubConfig,
563+
() => {}
564+
);
565+
output.error(failureMessage, {
566+
success: false,
567+
error: assignResult.error,
568+
workItemId: normalizedId,
569+
issueNumber,
570+
issueUrl: `https://github.com/${githubConfig.repo}/issues/${issueNumber}`,
571+
pushed: true,
572+
assigned: false,
573+
});
574+
process.exit(1);
575+
}
576+
577+
// Assignment succeeded: update local state
578+
db.update(normalizedId, {
579+
status: 'in-progress' as any,
580+
assignee: '@github-copilot',
581+
});
582+
583+
const issueUrl = `https://github.com/${githubConfig.repo}/issues/${issueNumber}`;
584+
585+
if (isJsonMode) {
586+
output.json({
587+
success: true,
588+
workItemId: normalizedId,
589+
issueNumber,
590+
issueUrl,
591+
pushed: true,
592+
assigned: true,
593+
});
594+
} else {
595+
console.log(`Pushing to GitHub... done.`);
596+
console.log(`Assigning to copilot... done.`);
597+
console.log(`Done. Issue: ${issueUrl}`);
598+
}
599+
} catch (error) {
600+
const message = `Delegation failed: ${(error as Error).message}`;
601+
output.error(message, {
602+
success: false,
603+
error: (error as Error).message,
604+
workItemId: normalizedId,
605+
});
606+
process.exit(1);
607+
}
608+
});
438609
}

0 commit comments

Comments
 (0)