diff --git a/extensions/feature-orchestrator/README.md b/extensions/feature-orchestrator/README.md new file mode 100644 index 00000000..62c7acbd --- /dev/null +++ b/extensions/feature-orchestrator/README.md @@ -0,0 +1,51 @@ +# Feature Orchestrator Extension + +VS Code extension for the AI-driven feature development pipeline. Provides the dashboard UI, +feature detail panel, and design review system. + +For the full developer guide, see [AI Driven Development Guide](../../AI%20Driven%20Development%20Guide.md). + +## What This Extension Provides + +- **Dashboard sidebar** (rocket icon) — metrics, active/completed features, open PRs +- **Feature detail panel** — artifacts, PBI table with dispatch buttons, PR actions, phase durations +- **Design review system** — inline comments via gutter icons + status bar submit button +- **Manual artifact entry** — add design specs, PBIs, or PRs via + buttons +- **Live refresh** — fetches latest PBI status from ADO and PR status from GitHub +- **Auto-completion** — detects when all PBIs are resolved and marks feature as done + +## Installation + +Run the setup script (builds and installs automatically): +```powershell +.\scripts\setup-ai-orchestrator.ps1 +``` + +Or build manually: +```bash +cd extensions/feature-orchestrator +npm install +npm run compile +npx @vscode/vsce package --no-dependencies --allow-missing-repository +code --install-extension feature-orchestrator-latest.vsix --force +``` + +## Architecture + +| File | Purpose | +|------|---------| +| `extension.ts` | Entry point — registers dashboard, commands | +| `dashboard.ts` | Sidebar webview — feature cards, metrics, open PRs | +| `featureDetail.ts` | Detail panel — artifacts, durations, dispatch, iterate, checkout | +| `designReview.ts` | CodeLens-based design review commenting | +| `tools.ts` | CLI helpers — `runCommand`, `switchGhAccount` | + +## State + +Feature state is stored at `~/.android-auth-orchestrator/state.json` (per-developer, not in repo). +Managed by `.github/hooks/state-utils.js`. + +## Works Without This Extension + +The entire pipeline (agents, prompt files, hooks, state) works without this extension. +It only provides the visual dashboard and review UI. diff --git a/extensions/feature-orchestrator/package-lock.json b/extensions/feature-orchestrator/package-lock.json new file mode 100644 index 00000000..4187e18f --- /dev/null +++ b/extensions/feature-orchestrator/package-lock.json @@ -0,0 +1,58 @@ +{ + "name": "feature-orchestrator", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "feature-orchestrator", + "version": "0.2.0", + "devDependencies": { + "@types/node": "^20.0.0", + "@types/vscode": "^1.100.0", + "typescript": "^5.5.0" + }, + "engines": { + "vscode": "^1.100.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.35", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", + "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.109.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.109.0.tgz", + "integrity": "sha512-0Pf95rnwEIwDbmXGC08r0B4TQhAbsHQ5UyTIgVgoieDe4cOnf92usuR5dEczb6bTKEp7ziZH4TV1TRGPPCExtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/extensions/feature-orchestrator/package.json b/extensions/feature-orchestrator/package.json new file mode 100644 index 00000000..82942be1 --- /dev/null +++ b/extensions/feature-orchestrator/package.json @@ -0,0 +1,119 @@ +{ + "name": "feature-orchestrator", + "displayName": "Android Auth Feature Orchestrator", + "description": "AI-driven feature development orchestrator for the Android Auth multi-repo project. Dashboard + @orchestrator chat participant.", + "version": "0.3.0", + "publisher": "AzureAD", + "engines": { + "vscode": "^1.100.0" + }, + "categories": [ + "AI" + ], + "activationEvents": ["onLanguage:markdown"], + "main": "./out/extension.js", + "contributes": { + "viewsContainers": { + "activitybar": [ + { + "id": "feature-orchestrator", + "title": "Feature Orchestrator", + "icon": "$(rocket)" + } + ] + }, + "views": { + "feature-orchestrator": [ + { + "type": "webview", + "id": "orchestrator.dashboard", + "name": "Dashboard" + } + ] + }, + "commands": [ + { + "command": "orchestrator.refreshDashboard", + "title": "Refresh Dashboard", + "icon": "$(refresh)", + "category": "Orchestrator" + }, + { + "command": "orchestrator.newFeature", + "title": "New Feature", + "icon": "$(add)", + "category": "Orchestrator" + }, + { + "command": "orchestrator.openFeatureDetail", + "title": "Open Feature Detail", + "category": "Orchestrator" + }, + { + "command": "orchestrator.submitDesignReview", + "title": "Submit Review Comments (Current File)", + "icon": "$(comment)", + "category": "Orchestrator" + }, + { + "command": "orchestrator.submitAllReviews", + "title": "Submit All Review Comments", + "icon": "$(comment-discussion)", + "category": "Orchestrator" + }, + { + "command": "orchestrator.clearReviewComments", + "title": "Clear Review Comments (Current File)", + "category": "Orchestrator" + }, + { + "command": "orchestrator.reviewComment.add", + "title": "Add Comment" + }, + { + "command": "orchestrator.reviewComment.delete", + "title": "Delete Comment", + "icon": "$(trash)" + } + ], + "menus": { + "view/title": [ + { + "command": "orchestrator.refreshDashboard", + "when": "view == orchestrator.dashboard", + "group": "navigation" + }, + { + "command": "orchestrator.newFeature", + "when": "view == orchestrator.dashboard", + "group": "navigation" + } + ], + "comments/commentThread/context": [ + { + "command": "orchestrator.reviewComment.add", + "group": "inline", + "when": "commentController == orchestrator.designReview" + } + ], + "comments/comment/title": [ + { + "command": "orchestrator.reviewComment.delete", + "group": "inline", + "when": "commentController == orchestrator.designReview && comment == reviewComment" + } + ] + } + }, + "scripts": { + "vscode:prepublish": "npm run compile", + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./", + "lint": "eslint src --ext ts" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/vscode": "^1.100.0", + "typescript": "^5.5.0" + } +} diff --git a/extensions/feature-orchestrator/src/dashboard.ts b/extensions/feature-orchestrator/src/dashboard.ts new file mode 100644 index 00000000..15269f11 --- /dev/null +++ b/extensions/feature-orchestrator/src/dashboard.ts @@ -0,0 +1,628 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { runCommand, switchGhAccount } from './tools'; + +interface OpenPr { + repo: string; + number: number; + title: string; + url: string; + isDraft: boolean; + author: string; + createdAt: string; +} + +interface FeatureState { + id: string; + name: string; + prompt: string; + step: string; + designDocPath?: string; + designPrUrl?: string; + pbis: Array<{ adoId: number; title: string; targetRepo: string; status: string }>; + agentSessions: Array<{ repo: string; prNumber: number; prUrl: string; status: string }>; + startedAt: number; + updatedAt: number; + pendingAction?: { + completedAgent: string; + nextStep: string; + timestamp: number; + }; +} + +interface OrchestratorState { + version: number; + features: FeatureState[]; + lastUpdated: number; +} + +export class DashboardViewProvider implements vscode.WebviewViewProvider { + public static readonly viewType = 'orchestrator.dashboard'; + + private view?: vscode.WebviewView; + private refreshInterval?: NodeJS.Timeout; + private fileWatcher?: vscode.FileSystemWatcher; + private cachedOpenPrs: OpenPr[] = []; + private prsFetching = false; + private prsLastFetched = 0; + private prsEverFetched = false; + + constructor(private readonly context: vscode.ExtensionContext) {} + + resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken + ): void { + this.view = webviewView; + webviewView.webview.options = { enableScripts: true }; + + webviewView.webview.onDidReceiveMessage(async (message) => { + switch (message.command) { + case 'refresh': + await this.refresh(); + this.fetchOpenPrsInBackground(); + break; + case 'openUrl': + vscode.env.openExternal(vscode.Uri.parse(message.url)); + break; + case 'openAgent': { + // Open a new chat with a prompt file + await vscode.commands.executeCommand('workbench.action.chat.newChat'); + const query = `/${message.promptFile || 'feature-continue'} ${message.context || ''}`.trim(); + vscode.commands.executeCommand('workbench.action.chat.open', { query }); + break; + } + case 'openFeatureDetail': + vscode.commands.executeCommand('orchestrator.openFeatureDetail', message.featureId); + break; + case 'removeFeature': + this.removeFeatureFromState(message.featureId); + await this.refresh(); + break; + } + }); + + this.refresh(); + + // Fetch open PRs in background on initial load + this.fetchOpenPrsInBackground(); + + // Auto-refresh every 30 seconds (state only, not PRs) + this.refreshInterval = setInterval(() => this.refresh(), 30000); + + // Watch state file for changes (hooks write to it) + const stateFilePath = this.getStateFilePath(); + if (stateFilePath) { + const stateDir = path.dirname(stateFilePath); + // Ensure the directory exists so the watcher can be set up + if (!fs.existsSync(stateDir)) { + fs.mkdirSync(stateDir, { recursive: true }); + } + const pattern = new vscode.RelativePattern( + vscode.Uri.file(stateDir), + 'state.json' + ); + this.fileWatcher = vscode.workspace.createFileSystemWatcher(pattern); + this.fileWatcher.onDidChange(() => { + this.refresh(); + this.checkForPendingAction(); + }); + this.fileWatcher.onDidCreate(() => { + this.refresh(); + this.checkForPendingAction(); + }); + } + + webviewView.onDidDispose(() => { + if (this.refreshInterval) { clearInterval(this.refreshInterval); } + this.fileWatcher?.dispose(); + }); + } + + async refresh(): Promise { + if (!this.view) { return; } + + const state = this.readStateFile(); + + // Auto-completion detection: check all non-done features + const completedStates = new Set(['done', 'resolved', 'closed', 'removed']); + let stateChanged = false; + for (const feature of state.features) { + if (feature.step === 'done') { continue; } + const allPbis = (feature as any).artifacts?.pbis || []; + const allPrs = (feature as any).artifacts?.agentPrs || feature.agentSessions || []; + + let shouldComplete = false; + if (allPbis.length > 0) { + shouldComplete = allPbis.every((p: any) => completedStates.has((p.status || '').toLowerCase())); + } else if (allPrs.length > 0) { + shouldComplete = allPrs.every((pr: any) => (pr.status || '').toLowerCase() === 'merged'); + } + + if (shouldComplete) { + feature.step = 'done'; + feature.updatedAt = Date.now(); + stateChanged = true; + vscode.window.showInformationMessage( + `🎉 Feature "${feature.name}" is complete! All work items are resolved.`, + 'View Feature' + ).then(selection => { + if (selection === 'View Feature') { + vscode.commands.executeCommand('orchestrator.openFeatureDetail', feature.id); + } + }); + } + } + if (stateChanged) { + const filePath = this.getStateFilePath(); + if (filePath) { + state.lastUpdated = Date.now(); + fs.writeFileSync(filePath, JSON.stringify(state, null, 2), 'utf-8'); + } + } + + this.view.webview.html = this.getHtml(state); + } + + /** + * Check if a subagent just completed and show a clickable notification + * with a button to proceed to the next pipeline step. + * + * The SubagentStop hook writes a `pendingAction` field to the active feature + * in orchestrator-state.json. We consume it here and clear it after showing + * the notification to avoid duplicate prompts. + */ + private checkForPendingAction(): void { + const state = this.readStateFile(); + const feature = state.features?.find(f => f.pendingAction); + if (!feature?.pendingAction) { return; } + + const action = feature.pendingAction; + const staleMs = Date.now() - (action.timestamp || 0); + if (staleMs > 60000) { + // Ignore stale actions older than 60 seconds + return; + } + + // Map next step to a notification message + button label + chat prompt + const stepActions: Record = { + 'design_review': { + message: `✅ Design spec written for "${feature.name}". Ready to plan PBIs.`, + button: '📋 Plan PBIs', + promptFile: 'feature-plan', + }, + 'plan_review': { + message: `✅ PBI plan created for "${feature.name}". Review and create in ADO.`, + button: '✅ Create in ADO', + promptFile: 'feature-backlog', + }, + 'backlog_review': { + message: `✅ PBIs backlogged in ADO for "${feature.name}". Ready to dispatch.`, + button: '🚀 Dispatch to Agent', + promptFile: 'feature-dispatch', + }, + 'monitoring': { + message: `✅ PBIs dispatched for "${feature.name}". Agents are working.`, + button: '📡 Check Status', + promptFile: 'feature-status', + }, + }; + + const cfg = stepActions[action.nextStep]; + if (!cfg) { return; } + + // Show VS Code notification with clickable button + vscode.window.showInformationMessage(cfg.message, cfg.button).then(async selection => { + if (selection === cfg.button) { + await vscode.commands.executeCommand('workbench.action.chat.newChat'); + vscode.commands.executeCommand('workbench.action.chat.open', { + query: `/${cfg.promptFile} Feature: "${feature.name}"`, + }); + } + }); + + // Clear the pendingAction so we don't show duplicate notifications + delete feature.pendingAction; + const filePath = this.getStateFilePath(); + if (filePath) { + state.lastUpdated = Date.now(); + fs.writeFileSync(filePath, JSON.stringify(state, null, 2), 'utf-8'); + } + } + + private getStateFilePath(): string | null { + return path.join(os.homedir(), '.android-auth-orchestrator', 'state.json'); + } + + private readStateFile(): OrchestratorState { + const filePath = this.getStateFilePath(); + if (!filePath || !fs.existsSync(filePath)) { + return { version: 1, features: [], lastUpdated: 0 }; + } + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return { version: 1, features: [], lastUpdated: 0 }; + } + } + + private removeFeatureFromState(featureId: string): void { + const filePath = this.getStateFilePath(); + if (!filePath) { return; } + const state = this.readStateFile(); + state.features = state.features.filter(f => f.id !== featureId); + state.lastUpdated = Date.now(); + fs.writeFileSync(filePath, JSON.stringify(state, null, 2), 'utf-8'); + } + + private async fetchAllAgentPrs(): Promise { + // No-op — Agent PRs are tracked per-feature in artifacts + } + + /** + * Fetch open PRs authored by the user or by copilot-swe-agent across all repos. + * Runs in background and updates the cached list, then re-renders. + */ + private async fetchOpenPrsInBackground(): Promise { + // Don't double-fetch + if (this.prsFetching) { return; } + this.prsFetching = true; + + // Re-render immediately to show loading state + await this.refresh(); + + try { + const repos = [ + { slug: 'AzureAD/microsoft-authentication-library-common-for-android', label: 'common' }, + { slug: 'AzureAD/microsoft-authentication-library-for-android', label: 'msal' }, + { slug: 'identity-authnz-teams/ad-accounts-for-android', label: 'broker' }, + ]; + + const allPrs: OpenPr[] = []; + + // Group by org to minimize account switches + const orgRepos: Record> = {}; + for (const repo of repos) { + const org = repo.slug.split('/')[0]; + if (!orgRepos[org]) { orgRepos[org] = []; } + orgRepos[org].push(repo); + } + + for (const [, repoList] of Object.entries(orgRepos)) { + // Discover the GitHub username for this org + let ghUsername = ''; + try { + await switchGhAccount(repoList[0].slug); + const statusOutput = await runCommand('gh api user --jq .login', undefined, 10000).catch(() => ''); + ghUsername = statusOutput.trim(); + } catch { + continue; + } + + for (const repo of repoList) { + try { + // Fetch user's own PRs + const userPrsJson = await runCommand( + `gh pr list --repo "${repo.slug}" --author "@me" --state open --limit 10 --json number,title,url,isDraft,author,createdAt`, + undefined, 15000 + ).catch(() => '[]'); + + // Fetch agent PRs assigned to this user (agent PRs are assigned to the triggering user) + const agentPrsJson = ghUsername + ? await runCommand( + `gh pr list --repo "${repo.slug}" --author "copilot-swe-agent[bot]" --assignee "${ghUsername}" --state open --limit 10 --json number,title,url,isDraft,author,createdAt`, + undefined, 15000 + ).catch(() => '[]') + : '[]'; + + for (const json of [userPrsJson, agentPrsJson]) { + try { + const prs = JSON.parse(json); + for (const pr of prs) { + // Dedupe by number+repo + if (!allPrs.some(p => p.number === pr.number && p.repo === repo.label)) { + allPrs.push({ + repo: repo.label, + number: pr.number, + title: pr.title || '', + url: pr.url || '', + isDraft: pr.isDraft || false, + author: pr.author?.login || '', + createdAt: pr.createdAt || '', + }); + } + } + } catch { /* skip parse errors */ } + } + } catch { /* skip repo errors */ } + } + } + + // Sort by most recent first + allPrs.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + this.cachedOpenPrs = allPrs; + this.prsLastFetched = Date.now(); + this.prsEverFetched = true; + + // Re-render with fresh PR data + await this.refresh(); + } catch (e) { + console.error('[Dashboard] Failed to fetch open PRs:', e); + } finally { + this.prsFetching = false; + } + } + + private getHtml(state: OrchestratorState): string { + const stepConfig: Record = { + 'idle': { icon: '⏳', label: 'Ready', nextPromptFile: 'feature-design', nextLabel: '▶ Start Design', nextContext: '' }, + 'designing': { icon: '📝', label: 'Writing Design...' }, + 'design_review': { icon: '👀', label: 'Awaiting Design Approval', nextPromptFile: 'feature-plan', nextLabel: '📋 Approve → Plan PBIs', nextContext: '' }, + 'planning': { icon: '📋', label: 'Planning PBIs...' }, + 'plan_review': { icon: '👀', label: 'Awaiting Plan Approval', nextPromptFile: 'feature-backlog', nextLabel: '✅ Approve → Backlog in ADO', nextContext: '' }, + 'backlogging': { icon: '📝', label: 'Adding to Backlog...' }, + 'backlog_review': { icon: '👀', label: 'PBIs Backlogged — Review', nextPromptFile: 'feature-dispatch', nextLabel: '🚀 Dispatch to Agent', nextContext: '' }, + 'dispatching': { icon: '🚀', label: 'Dispatching...' }, + 'monitoring': { icon: '📡', label: 'Agents Working', nextPromptFile: 'feature-status', nextLabel: '👁 Check Status', nextContext: '' }, + 'done': { icon: '✅', label: 'Complete' }, + }; + + // Normalize step names + const stepAliases: Record = { + 'designed': 'design_review', 'design_complete': 'design_review', + 'planned': 'plan_review', 'plan_complete': 'plan_review', + 'backlogged': 'backlog_review', 'created': 'backlog_review', 'creating': 'backlogging', 'create_review': 'backlog_review', + 'dispatched': 'monitoring', 'dispatch_complete': 'monitoring', + 'complete': 'done', 'completed': 'done', + }; + + // Split features into active vs completed + const activeFeatures = state.features.filter(f => { + const ns = stepAliases[f.step] || f.step; + return ns !== 'done'; + }); + const completedFeatures = state.features.filter(f => { + const ns = stepAliases[f.step] || f.step; + return ns === 'done'; + }); + + // Compute metrics + const totalFeatures = state.features.length; + const totalPbis = state.features.reduce((sum, f) => { + return sum + ((f as any).artifacts?.pbis?.length || f.pbis?.length || 0); + }, 0); + const totalPrs = state.features.reduce((sum, f) => { + return sum + ((f as any).artifacts?.agentPrs?.length || f.agentSessions?.length || 0); + }, 0); + const mergedPrs = state.features.reduce((sum, f) => { + const prs = (f as any).artifacts?.agentPrs || f.agentSessions || []; + return sum + prs.filter((pr: any) => (pr.status || pr.state || '').toLowerCase() === 'merged').length; + }, 0); + + const metricsHtml = totalFeatures > 0 + ? `
+
${activeFeatures.length}Active
+
${completedFeatures.length}Done
+
${totalPbis}PBIs
+
${mergedPrs}/${totalPrs}PRs Merged
+
` + : ''; + + // Render a feature card + const renderCard = (f: FeatureState, compact: boolean = false) => { + const normalizedStep = stepAliases[f.step] || f.step; + const cfg = stepConfig[normalizedStep] || stepConfig['idle']; + const progressSteps = ['designing', 'design_review', 'planning', 'plan_review', 'backlogging', 'backlog_review', 'dispatching', 'monitoring', 'done']; + const currentIdx = progressSteps.indexOf(normalizedStep); + + const progressDots = progressSteps.map((_s, i) => + `
` + ).join(''); + + // Artifact summary + const artifacts = (f as any).artifacts; + const pbiCount = artifacts?.pbis?.length || f.pbis?.length || 0; + const prCount = artifacts?.agentPrs?.length || f.agentSessions?.length || 0; + const hasDesign = !!artifacts?.design || !!f.designDocPath; + const artifactPills: string[] = []; + if (hasDesign) { artifactPills.push('📄 Design'); } + if (pbiCount > 0) { artifactPills.push(`📋 ${pbiCount} PBI${pbiCount > 1 ? 's' : ''}`); } + if (prCount > 0) { artifactPills.push(`🤖 ${prCount} PR${prCount > 1 ? 's' : ''}`); } + const artifactSummary = artifactPills.length > 0 + ? `
${artifactPills.join(' · ')}
` + : ''; + + if (compact) { + // Completed feature card — minimal + return ` +
+
+ + ${this.escapeHtml(f.name)} + +
+ ${artifactSummary} +
${this.timeAgo(f.updatedAt)}
+
`; + } + + // Active feature card — full + let actionContext = `Feature: "${f.name}"`; + if (normalizedStep === 'monitoring') { + const fPrs = (f as any).artifacts?.agentPrs || f.agentSessions || []; + if (fPrs.length > 0) { + const prList = fPrs.map((pr: any) => `${pr.repo || ''} #${pr.prNumber || pr.number || '?'}`).join(', '); + actionContext += `. Tracked PRs: ${prList}`; + } + } + + const actionBtn = cfg.nextPromptFile + ? `` + : `
${cfg.icon} ${cfg.label}
`; + + return ` +
+
+ ${cfg.icon} + ${this.escapeHtml(f.name)} + +
+
${progressDots}
+ ${actionBtn} + ${artifactSummary} +
${this.timeAgo(f.updatedAt)}
+
`; + }; + + const activeFeaturesHtml = activeFeatures.length === 0 + ? `
+
🚀
+

No active features

+

Click + above or type /feature-design in chat

+
` + : activeFeatures.map(f => renderCard(f)).join(''); + + const completedFeaturesHtml = completedFeatures.length > 0 + ? completedFeatures.map(f => renderCard(f, true)).join('') + : '

No completed features yet

'; + + // Build "My Open PRs" section + let openPrsHtml: string; + if (this.prsFetching || !this.prsEverFetched) { + openPrsHtml = '
Loading PRs...
'; + } else if (this.cachedOpenPrs.length === 0) { + openPrsHtml = '

No open PRs

'; + } else { + openPrsHtml = this.cachedOpenPrs.map(pr => { + const age = this.timeAgo(new Date(pr.createdAt).getTime()); + const draftBadge = pr.isDraft ? 'Draft ' : ''; + const isAgent = pr.author === 'app/copilot-swe-agent' || pr.author === 'copilot-swe-agent[bot]' || pr.author === 'copilot-swe-agent'; + const agentBadge = isAgent ? 'AI ' : ''; + return `
+ + ${pr.repo} #${pr.number} + ${agentBadge}${draftBadge}${this.escapeHtml(pr.title).substring(0, 40)} + ${age} +
`; + }).join(''); + } + + return ` + + + ${metricsHtml} +

Active Features

+ ${activeFeaturesHtml} +
+

Completed

+ ${completedFeaturesHtml} +
+

My Open PRs

+ ${openPrsHtml} +
+ + +`; + } + + private escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + private escapeAttr(s: string): string { + return s.replace(/'/g, "\\'").replace(/"/g, '"'); + } + + private timeAgo(ts: number): string { + if (!ts) { return ''; } + const s = Math.floor((Date.now() - ts) / 1000); + if (s < 60) { return 'just now'; } + if (s < 3600) { return `${Math.floor(s / 60)}m ago`; } + if (s < 86400) { return `${Math.floor(s / 3600)}h ago`; } + return `${Math.floor(s / 86400)}d ago`; + } +} diff --git a/extensions/feature-orchestrator/src/designReview.ts b/extensions/feature-orchestrator/src/designReview.ts new file mode 100644 index 00000000..d8a7fbbd --- /dev/null +++ b/extensions/feature-orchestrator/src/designReview.ts @@ -0,0 +1,506 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Design Review v5 — Single reviews.json, scoped submit, proper state management. + * + * Architecture: + * - Comments stored in `.github/design-reviews/reviews.json` (dict keyed by relative spec path) + * - Status bar button submits comments for the CURRENT file only + * - Command palette "Submit All" submits all pending reviews + * - Agent removes entries from reviews.json after addressing them + * - Extension clears editor comments after submit + */ + +// ─── Types ──────────────────────────────────────────────────────── + +interface ReviewComment { + line: number; + text: string; + lineContent: string; +} + +interface ReviewsFile { + reviews: Record; // key = relative spec path +} + +// ─── Constants ─────────────────────────────────────────────────── + +const REVIEWS_DIR = '.github/design-reviews'; +const REVIEWS_FILENAME = 'reviews.json'; + +// ─── Main Controller ───────────────────────────────────────────── + +export class DesignReviewController implements vscode.Disposable { + private commentController: vscode.CommentController; + private allThreads: vscode.CommentThread[] = []; + private loadedKeys = new Set(); // "relPath::line::text" prevents duplicate restore + private statusBarItem: vscode.StatusBarItem; + private disposables: vscode.Disposable[] = []; + + constructor(private readonly context: vscode.ExtensionContext) { + this.commentController = vscode.comments.createCommentController( + 'orchestrator.designReview', + 'Design Review' + ); + this.commentController.commentingRangeProvider = { + provideCommentingRanges(document: vscode.TextDocument): vscode.Range[] { + if (document.languageId !== 'markdown') { return []; } + var ranges: vscode.Range[] = []; + for (var i = 0; i < document.lineCount; i++) { + ranges.push(new vscode.Range(i, 0, i, 0)); + } + return ranges; + } + }; + this.disposables.push(this.commentController); + + this.statusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right, 1000 + ); + this.statusBarItem.command = 'orchestrator.submitDesignReview'; + this.disposables.push(this.statusBarItem); + } + + register(): void { + this.disposables.push( + vscode.commands.registerCommand('orchestrator.reviewComment.add', (reply: vscode.CommentReply) => { + this.addComment(reply); + }) + ); + this.disposables.push( + vscode.commands.registerCommand('orchestrator.reviewComment.delete', (comment: EditorComment) => { + this.deleteComment(comment); + }) + ); + this.disposables.push( + vscode.commands.registerCommand('orchestrator.submitDesignReview', () => { + this.submitCurrentFile(); + }) + ); + this.disposables.push( + vscode.commands.registerCommand('orchestrator.submitAllReviews', () => { + this.submitAll(); + }) + ); + this.disposables.push( + vscode.commands.registerCommand('orchestrator.clearReviewComments', () => { + this.clearCurrentFile(); + }) + ); + this.disposables.push( + vscode.languages.registerCodeLensProvider( + { language: 'markdown' }, + new ReviewCodeLensProvider(this) + ) + ); + this.disposables.push( + vscode.window.onDidChangeActiveTextEditor(() => this.updateStatusBar()) + ); + this.disposables.push( + vscode.workspace.onDidOpenTextDocument(doc => { + if (doc.languageId === 'markdown') { this.restoreComments(doc); } + }) + ); + + // Restore for already-open docs + for (var doc of vscode.workspace.textDocuments) { + if (doc.languageId === 'markdown') { this.restoreComments(doc); } + } + + this.updateStatusBar(); + this.context.subscriptions.push(...this.disposables); + } + + // ─── Reviews file I/O ──────────────────────────────────────── + + private getReviewsFilePath(): string | null { + var root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!root) { return null; } + var dir = path.join(root, REVIEWS_DIR); + if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } + return path.join(dir, REVIEWS_FILENAME); + } + + private readReviews(): ReviewsFile { + var filePath = this.getReviewsFilePath(); + if (!filePath || !fs.existsSync(filePath)) { + return { reviews: {} }; + } + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return { reviews: {} }; + } + } + + private writeReviews(data: ReviewsFile): void { + var filePath = this.getReviewsFilePath(); + if (!filePath) { return; } + // Clean up empty entries + var keys = Object.keys(data.reviews); + for (var i = 0; i < keys.length; i++) { + if (data.reviews[keys[i]].length === 0) { + delete data.reviews[keys[i]]; + } + } + if (Object.keys(data.reviews).length === 0) { + // Delete file if no reviews remain + if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } + } else { + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); + } + } + + private getRelativePath(uri: vscode.Uri): string { + var root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; + return path.relative(root, uri.fsPath).replace(/\\/g, '/'); + } + + // ─── Persist current file's comments to reviews.json ───────── + + private persistForFile(docUri: vscode.Uri): void { + var relPath = this.getRelativePath(docUri); + var data = this.readReviews(); + var comments: ReviewComment[] = []; + + for (var i = 0; i < this.allThreads.length; i++) { + var thread = this.allThreads[i]; + if (thread.uri.toString() !== docUri.toString()) { continue; } + for (var j = 0; j < thread.comments.length; j++) { + var c = thread.comments[j] as EditorComment; + comments.push({ + line: c.line, + text: typeof c.body === 'string' ? c.body : (c.body as vscode.MarkdownString).value, + lineContent: c.lineContent, + }); + } + } + + data.reviews[relPath] = comments; + this.writeReviews(data); + } + + // ─── Add comment ───────────────────────────────────────────── + + private addComment(reply: vscode.CommentReply): void { + var thread = reply.thread; + var doc = vscode.workspace.textDocuments.find( + function(d) { return d.uri.toString() === thread.uri.toString(); } + ); + var line = thread.range?.start.line ?? 0; + var lineContent = doc ? doc.lineAt(line).text.trim() : ''; + + var comment = new EditorComment(reply.text, line, lineContent); + + thread.comments = [...thread.comments, comment]; + thread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed; + thread.label = thread.comments.length + ' comment' + (thread.comments.length > 1 ? 's' : ''); + + if (this.allThreads.indexOf(thread) === -1) { + this.allThreads.push(thread); + } + + var relPath = this.getRelativePath(thread.uri); + this.loadedKeys.add(relPath + '::' + line + '::' + reply.text); + + this.persistForFile(thread.uri); + this.updateStatusBar(); + } + + // ─── Delete comment ────────────────────────────────────────── + + private deleteComment(commentToDelete: EditorComment): void { + for (var i = 0; i < this.allThreads.length; i++) { + var thread = this.allThreads[i]; + var filtered = thread.comments.filter(function(c) { + return c !== commentToDelete; + }); + + if (filtered.length < thread.comments.length) { + var relPath = this.getRelativePath(thread.uri); + this.loadedKeys.delete(relPath + '::' + commentToDelete.line + '::' + + (typeof commentToDelete.body === 'string' ? commentToDelete.body : '')); + + if (filtered.length === 0) { + var uri = thread.uri; + thread.dispose(); + this.allThreads.splice(i, 1); + this.persistForFile(uri); + } else { + thread.comments = filtered; + thread.label = filtered.length + ' comment' + (filtered.length > 1 ? 's' : ''); + this.persistForFile(thread.uri); + } + this.updateStatusBar(); + return; + } + } + } + + // ─── Restore from reviews.json ─────────────────────────────── + + private restoreComments(document: vscode.TextDocument): void { + var data = this.readReviews(); + var relPath = this.getRelativePath(document.uri); + var comments = data.reviews[relPath]; + if (!comments || comments.length === 0) { return; } + + for (var i = 0; i < comments.length; i++) { + var rc = comments[i]; + var key = relPath + '::' + rc.line + '::' + rc.text; + if (this.loadedKeys.has(key)) { continue; } + this.loadedKeys.add(key); + + var line = Math.min(rc.line, document.lineCount - 1); + var thread = this.commentController.createCommentThread( + document.uri, + new vscode.Range(line, 0, line, 0), + [] + ); + + var comment = new EditorComment(rc.text, rc.line, rc.lineContent); + thread.comments = [comment]; + thread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed; + thread.label = '1 comment'; + this.allThreads.push(thread); + } + + this.updateStatusBar(); + } + + // ─── Submit: current file only ─────────────────────────────── + + private async submitCurrentFile(): Promise { + var editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showInformationMessage('Open a markdown file first.'); + return; + } + + var relPath = this.getRelativePath(editor.document.uri); + var data = this.readReviews(); + var comments = data.reviews[relPath]; + + if (!comments || comments.length === 0) { + vscode.window.showInformationMessage( + 'No review comments on this file. Click the + icon in the gutter to add.' + ); + return; + } + + // Build short prompt — comments stay in reviews.json for the agent to read + var prompt = 'Use the design-reviewer skill on `' + relPath + '`.'; + + // Clear editor comments for this file + this.clearThreadsForUri(editor.document.uri); + this.updateStatusBar(); + + // Open chat + await this.openChat(prompt); + } + + // ─── Submit: all files ─────────────────────────────────────── + + private async submitAll(): Promise { + var data = this.readReviews(); + var specPaths = Object.keys(data.reviews); + + if (specPaths.length === 0) { + vscode.window.showInformationMessage('No pending review comments.'); + return; + } + + var prompt = 'Use the design-reviewer skill.'; + + // Clear all editor comments + for (var i = 0; i < this.allThreads.length; i++) { + this.allThreads[i].dispose(); + } + this.allThreads = []; + this.loadedKeys.clear(); + this.updateStatusBar(); + + await this.openChat(prompt); + } + + // ─── Clear: current file ───────────────────────────────────── + + private clearCurrentFile(): void { + var editor = vscode.window.activeTextEditor; + if (!editor) { return; } + + var docUri = editor.document.uri; + this.clearThreadsForUri(docUri); + + // Remove from reviews.json + var relPath = this.getRelativePath(docUri); + var data = this.readReviews(); + delete data.reviews[relPath]; + this.writeReviews(data); + + this.updateStatusBar(); + vscode.window.showInformationMessage('Review comments cleared for this file.'); + } + + // ─── Helpers ───────────────────────────────────────────────── + + private clearThreadsForUri(docUri: vscode.Uri): void { + var relPath = this.getRelativePath(docUri); + var remaining: vscode.CommentThread[] = []; + + for (var i = 0; i < this.allThreads.length; i++) { + var thread = this.allThreads[i]; + if (thread.uri.toString() === docUri.toString()) { + // Remove loaded keys for this thread's comments + for (var j = 0; j < thread.comments.length; j++) { + var c = thread.comments[j] as EditorComment; + var text = typeof c.body === 'string' ? c.body : ''; + this.loadedKeys.delete(relPath + '::' + c.line + '::' + text); + } + thread.dispose(); + } else { + remaining.push(thread); + } + } + this.allThreads = remaining; + } + + private async openChat(prompt: string): Promise { + try { + await vscode.commands.executeCommand('workbench.action.chat.open', { + query: prompt, + }); + } catch { + await vscode.env.clipboard.writeText(prompt); + await vscode.commands.executeCommand('workbench.action.chat.open'); + vscode.window.showInformationMessage( + 'Review prompt copied to clipboard. Paste (Ctrl+V) in chat and send.' + ); + } + } + + // ─── Status Bar ────────────────────────────────────────────── + + private updateStatusBar(): void { + var editor = vscode.window.activeTextEditor; + if (editor && editor.document.languageId === 'markdown') { + var count = this.getCommentCountForUri(editor.document.uri); + if (count > 0) { + this.statusBarItem.text = '$(comment-discussion) ' + count + + ' Review Comment' + (count > 1 ? 's' : '') + ' \u2014 Click to Submit'; + this.statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground'); + this.statusBarItem.tooltip = 'Submit review comments for this file to chat'; + } else { + this.statusBarItem.text = '$(comment) Design Review'; + this.statusBarItem.backgroundColor = undefined; + this.statusBarItem.tooltip = 'Add review comments using the + icon in the gutter'; + } + this.statusBarItem.show(); + } else { + this.statusBarItem.hide(); + } + } + + private getCommentCountForUri(docUri: vscode.Uri): number { + var count = 0; + for (var i = 0; i < this.allThreads.length; i++) { + if (this.allThreads[i].uri.toString() === docUri.toString()) { + count += this.allThreads[i].comments.length; + } + } + return count; + } + + getTotalCommentCount(): number { + var count = 0; + for (var i = 0; i < this.allThreads.length; i++) { + count += this.allThreads[i].comments.length; + } + return count; + } + + getActiveFileCommentCount(): number { + var editor = vscode.window.activeTextEditor; + if (!editor) { return 0; } + return this.getCommentCountForUri(editor.document.uri); + } + + dispose(): void { + for (var i = 0; i < this.disposables.length; i++) { this.disposables[i].dispose(); } + for (var i = 0; i < this.allThreads.length; i++) { this.allThreads[i].dispose(); } + } +} + +// ─── Editor Comment class ──────────────────────────────────────── + +class EditorComment implements vscode.Comment { + body: string | vscode.MarkdownString; + mode = vscode.CommentMode.Preview; + author: vscode.CommentAuthorInformation = { name: 'You' }; + contextValue = 'reviewComment'; + + constructor( + body: string, + public readonly line: number, + public readonly lineContent: string, + ) { + this.body = body; + } +} + +// ─── CodeLens Provider ─────────────────────────────────────────── + +class ReviewCodeLensProvider implements vscode.CodeLensProvider { + constructor(private readonly controller: DesignReviewController) {} + + provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] { + if (document.languageId !== 'markdown') { return []; } + + var count = this.controller.getActiveFileCommentCount(); + var topRange = new vscode.Range(0, 0, 0, 0); + var lenses: vscode.CodeLens[] = []; + + if (count > 0) { + lenses.push(new vscode.CodeLens(topRange, { + title: '\u{1F4AC} ' + count + ' review comment' + (count > 1 ? 's' : '') + + ' \u2014 Submit Review to Chat', + command: 'orchestrator.submitDesignReview', + })); + lenses.push(new vscode.CodeLens(topRange, { + title: '\u{1F5D1} Clear Comments', + command: 'orchestrator.clearReviewComments', + })); + } else { + lenses.push(new vscode.CodeLens(topRange, { + title: '\u{1F4AC} Add review comments using the + icon in the gutter', + command: '', + })); + } + + return lenses; + } +} diff --git a/extensions/feature-orchestrator/src/extension.ts b/extensions/feature-orchestrator/src/extension.ts new file mode 100644 index 00000000..8e0e1988 --- /dev/null +++ b/extensions/feature-orchestrator/src/extension.ts @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +import * as vscode from 'vscode'; +import { DashboardViewProvider } from './dashboard'; +import { DesignReviewController } from './designReview'; +import { FeatureDetailPanel } from './featureDetail'; + +export function activate(context: vscode.ExtensionContext) { + // Register the sidebar dashboard (reads from orchestrator-state.json) + const dashboardProvider = new DashboardViewProvider(context); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider( + DashboardViewProvider.viewType, + dashboardProvider + ) + ); + + // Register the design review commenting system + const designReview = new DesignReviewController(context); + designReview.register(); + + // Commands + context.subscriptions.push( + vscode.commands.registerCommand('orchestrator.refreshDashboard', () => { + dashboardProvider.refresh(); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('orchestrator.newFeature', async () => { + const panel = vscode.window.createWebviewPanel( + 'orchestrator.newFeature', 'New Feature', + vscode.ViewColumn.Active, { enableScripts: true } + ); + panel.webview.html = getNewFeatureHtml(); + panel.webview.onDidReceiveMessage(async (message) => { + if (message.command === 'submit') { + panel.dispose(); + // Open chat with the design prompt file + await vscode.commands.executeCommand('workbench.action.chat.newChat'); + vscode.commands.executeCommand('workbench.action.chat.open', { + query: `/feature-design ${message.prompt}`, + }); + } + }); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('orchestrator.openFeatureDetail', (featureId: string) => { + FeatureDetailPanel.show(context, featureId); + }) + ); + + console.log('Feature Orchestrator extension activated'); +} + +function getNewFeatureHtml(): string { + return ` + +

🚀 New Feature

+

Describe the feature. The design-author agent will research the codebase and create a detailed design spec.

+ + +
+ Examples:
+ Add retry logic to IPC calls · Implement certificate-based auth · Add PRT latency telemetry +
+ +`; +} + +export function deactivate() {} diff --git a/extensions/feature-orchestrator/src/featureDetail.ts b/extensions/feature-orchestrator/src/featureDetail.ts new file mode 100644 index 00000000..462c9674 --- /dev/null +++ b/extensions/feature-orchestrator/src/featureDetail.ts @@ -0,0 +1,1322 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { runCommand, switchGhAccount } from './tools'; + +/** + * Artifact types that can be tracked per feature. + */ +export interface DesignArtifact { + docPath: string; // workspace-relative path to the design doc + prUrl?: string; // ADO PR URL for the design review + status: 'draft' | 'in-review' | 'approved'; +} + +export interface PbiArtifact { + adoId: number; // AB# work item ID + title: string; + targetRepo: string; // e.g. "AzureAD/microsoft-authentication-library-common-for-android" + module: string; // e.g. "common", "msal", "broker" + adoUrl: string; // full ADO URL + status: 'new' | 'committed' | 'active' | 'resolved' | 'closed'; + priority?: number; + dependsOn?: number[]; // AB# IDs this PBI depends on + agentPr?: AgentPrArtifact; // linked PR from coding agent +} + +export interface AgentPrArtifact { + repo: string; + prNumber: number; + prUrl: string; + status: 'open' | 'merged' | 'closed' | 'draft'; + title?: string; +} + +export interface FeatureArtifacts { + design?: DesignArtifact; + pbis: PbiArtifact[]; + agentPrs: AgentPrArtifact[]; +} + +const ADO_ORG = 'IdentityDivision'; +const ADO_PROJECT = 'Engineering'; + +/** + * Opens a detail panel for a specific feature, showing all tracked artifacts. + */ +export class FeatureDetailPanel { + public static readonly viewType = 'orchestrator.featureDetail'; + private static panels: Map = new Map(); + + static show(context: vscode.ExtensionContext, featureId: string): void { + // Reuse existing panel for this feature if open + const existing = FeatureDetailPanel.panels.get(featureId); + if (existing) { + existing.reveal(vscode.ViewColumn.One); + return; + } + + const state = FeatureDetailPanel.readState(); + const feature = state.features?.find((f: any) => f.id === featureId); + if (!feature) { + vscode.window.showWarningMessage(`Feature "${featureId}" not found in state.`); + return; + } + + const panel = vscode.window.createWebviewPanel( + FeatureDetailPanel.viewType, + `Feature: ${feature.name}`, + vscode.ViewColumn.One, + { enableScripts: true, retainContextWhenHidden: true } + ); + + let autoRefreshInterval: NodeJS.Timeout | undefined; + + FeatureDetailPanel.panels.set(featureId, panel); + panel.onDidDispose(() => { + FeatureDetailPanel.panels.delete(featureId); + if (autoRefreshInterval) { clearInterval(autoRefreshInterval); } + }); + + panel.webview.html = FeatureDetailPanel.getHtml(feature); + + panel.webview.onDidReceiveMessage(async (message) => { + switch (message.command) { + case 'openUrl': + vscode.env.openExternal(vscode.Uri.parse(message.url)); + break; + case 'openFile': { + const folders = vscode.workspace.workspaceFolders; + if (folders) { + const uri = vscode.Uri.file(path.join(folders[0].uri.fsPath, message.path)); + vscode.commands.executeCommand('vscode.open', uri); + } + break; + } + case 'continueInChat': { + const freshState2 = FeatureDetailPanel.readState(); + const feat = freshState2.features?.find((f: any) => f.id === featureId); + const featureName = feat?.name || 'Unknown'; + const step = feat?.step || 'unknown'; + + // Map current step to the appropriate prompt file + const stepToPromptFile: Record = { + 'designing': 'feature-design', + 'design_review': 'feature-plan', + 'planning': 'feature-plan', + 'plan_review': 'feature-backlog', + 'backlogging': 'feature-backlog', + 'backlog_review': 'feature-dispatch', + 'dispatching': 'feature-dispatch', + 'monitoring': 'feature-status', + }; + const promptFile = stepToPromptFile[step] || 'feature-continue'; + + await vscode.commands.executeCommand('workbench.action.chat.newChat'); + vscode.commands.executeCommand('workbench.action.chat.open', { + query: `/${promptFile} Feature: "${featureName}"`, + }); + break; + } + case 'refresh': + // Fetch live statuses from GitHub, update state, and re-render + panel.webview.postMessage({ command: 'refreshing', status: true }); + try { + await FeatureDetailPanel.refreshLiveStatuses(featureId); + } catch (e) { + console.error('[FeatureDetail] Live refresh error:', e); + } + { + const freshState = FeatureDetailPanel.readState(); + const freshFeature = freshState.features?.find((f: any) => f.id === featureId); + if (freshFeature) { + panel.webview.html = FeatureDetailPanel.getHtml(freshFeature); + } + } + break; + + case 'addDesign': + await FeatureDetailPanel.handleAddDesign(featureId, panel); + break; + + case 'addPbi': + await FeatureDetailPanel.handleAddPbi(featureId, panel); + break; + + case 'addAgentPr': + await FeatureDetailPanel.handleAddAgentPr(featureId, panel); + break; + + case 'dispatchPbi': + await FeatureDetailPanel.handleDispatchPbi(featureId, message.adoId, panel); + break; + + case 'iteratePr': { + const iterateState = FeatureDetailPanel.readState(); + const iterateFeature = iterateState.features?.find((f: any) => f.id === featureId); + const featureName = iterateFeature?.name || ''; + await vscode.commands.executeCommand('workbench.action.chat.newChat'); + vscode.commands.executeCommand('workbench.action.chat.open', { + query: `/feature-pr-iterate Feature: "${featureName}", Repo: ${message.repo}, PR #${message.prNumber}`, + }); + break; + } + + case 'checkoutPr': + await FeatureDetailPanel.handleCheckoutPr(message.repo, message.prNumber); + break; + } + }); + + // Periodic auto-refresh every 5 minutes (fetches live PR + PBI statuses) + autoRefreshInterval = setInterval(async () => { + try { + await FeatureDetailPanel.refreshLiveStatuses(featureId); + const updated = FeatureDetailPanel.readState(); + const updatedFeature = updated.features?.find((f: any) => f.id === featureId); + if (updatedFeature) { + panel.webview.html = FeatureDetailPanel.getHtml(updatedFeature); + } + } catch { /* silent */ } + }, 300000); // 5 minutes + + // Watch for state file changes + const stateDir = path.join(os.homedir(), '.android-auth-orchestrator'); + if (!fs.existsSync(stateDir)) { + fs.mkdirSync(stateDir, { recursive: true }); + } + const pattern = new vscode.RelativePattern(vscode.Uri.file(stateDir), 'state.json'); + const watcher = vscode.workspace.createFileSystemWatcher(pattern); + watcher.onDidChange(() => { + const updated = FeatureDetailPanel.readState(); + const updatedFeature = updated.features?.find((f: any) => f.id === featureId); + if (updatedFeature) { + panel.webview.html = FeatureDetailPanel.getHtml(updatedFeature); + } + }); + panel.onDidDispose(() => watcher.dispose()); + } + + private static readState(): any { + const filePath = path.join(os.homedir(), '.android-auth-orchestrator', 'state.json'); + if (!fs.existsSync(filePath)) { return { features: [] }; } + try { return JSON.parse(fs.readFileSync(filePath, 'utf-8')); } + catch { return { features: [] }; } + } + + private static writeState(state: any): void { + const dir = path.join(os.homedir(), '.android-auth-orchestrator'); + if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } + state.lastUpdated = Date.now(); + fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify(state, null, 2), 'utf-8'); + } + + /** + * Fetch live PR statuses from GitHub and PBI statuses from ADO, + * then write updated data back to state.json. + */ + private static async refreshLiveStatuses(featureId: string): Promise { + const state = FeatureDetailPanel.readState(); + const feature = state.features?.find((f: any) => f.id === featureId); + if (!feature) { return; } + + let changed = false; + + // Repo slug mapping: short names → full GitHub slugs + const repoSlugs: Record = { + 'common': 'AzureAD/microsoft-authentication-library-common-for-android', + 'msal': 'AzureAD/microsoft-authentication-library-for-android', + 'adal': 'AzureAD/azure-activedirectory-library-for-android', + 'broker': 'identity-authnz-teams/ad-accounts-for-android', + }; + + // --- Refresh Agent PRs from GitHub --- + const agentPrs = feature.artifacts?.agentPrs || []; + if (agentPrs.length > 0) { + // Group PRs by org to minimize gh account switches + const prsByOrg: Record = {}; + for (const pr of agentPrs) { + const repoSlug = repoSlugs[pr.repo] || pr.repo; + const org = repoSlug.split('/')[0] || 'AzureAD'; + if (!prsByOrg[org]) { prsByOrg[org] = []; } + prsByOrg[org].push({ pr, repoSlug }); + } + + for (const [org, prs] of Object.entries(prsByOrg)) { + try { + // Switch account once per org + await switchGhAccount(`${org}/dummy`); + } catch { + console.error(`[FeatureDetail] Failed to switch gh account for ${org}`); + continue; + } + + for (const { pr, repoSlug } of prs) { + try { + const prNumber = pr.prNumber || pr.number; + if (!prNumber) { continue; } + + const json = await runCommand( + `gh pr view ${prNumber} --repo "${repoSlug}" --json state,title,url,comments,reviews`, + undefined, 15000 // 15s timeout + ); + const prData = JSON.parse(json); + + const stateMap: Record = { + 'OPEN': 'open', 'MERGED': 'merged', 'CLOSED': 'closed', + }; + const newStatus = stateMap[prData.state] || prData.state?.toLowerCase() || pr.status; + + if (newStatus !== pr.status || prData.title !== pr.title) { + pr.status = newStatus; + if (prData.title) { pr.title = prData.title; } + if (prData.url) { pr.prUrl = prData.url; } + changed = true; + } + + // Count review comments (from reviews + comments) + const reviews = prData.reviews || []; + const comments = prData.comments || []; + const totalComments = reviews.length + comments.length; + // Count resolved: reviews/comments with state RESOLVED or DISMISSED + const resolvedComments = reviews.filter((r: any) => r.state === 'APPROVED' || r.state === 'DISMISSED').length; + if (pr.totalComments !== totalComments || pr.resolvedComments !== resolvedComments) { + pr.totalComments = totalComments; + pr.resolvedComments = resolvedComments; + changed = true; + } + } catch (e) { + console.error(`[FeatureDetail] Failed to refresh PR #${pr.prNumber}:`, e); + } + } + } + } + + // --- Refresh PBI statuses from ADO via az CLI --- + const pbis = feature.artifacts?.pbis || []; + if (pbis.length > 0) { + try { + // Check if az CLI is available and authenticated (quick test) + await runCommand('az account show --only-show-errors -o none', undefined, 5000); + + for (const pbi of pbis) { + try { + const adoId = pbi.adoId; + if (!adoId) { continue; } + + const json = await runCommand( + `az boards work-item show --id ${adoId} --org "https://dev.azure.com/IdentityDivision" --only-show-errors -o json`, + undefined, 15000 // 15s timeout + ); + const wiData = JSON.parse(json); + const fields = wiData.fields || {}; + + const newState = fields['System.State']; + if (newState && newState !== pbi.status) { + pbi.status = newState; + changed = true; + } + // Also update title if it changed + const newTitle = fields['System.Title']; + if (newTitle && newTitle !== pbi.title) { + pbi.title = newTitle; + changed = true; + } + } catch (e) { + console.error(`[FeatureDetail] Failed to refresh PBI AB#${pbi.adoId}:`, e); + } + } + + // Also sync to legacy pbis array + if (changed && feature.pbis) { + for (const legacyPbi of feature.pbis) { + const artPbi = pbis.find((p: any) => p.adoId === legacyPbi.adoId); + if (artPbi) { + legacyPbi.status = artPbi.status; + legacyPbi.title = artPbi.title; + } + } + } + } catch { + // az CLI not available or not authenticated — skip PBI refresh silently + console.log('[FeatureDetail] az CLI not available, skipping PBI status refresh'); + } + } + + if (changed) { + feature.updatedAt = Date.now(); + FeatureDetailPanel.writeState(state); + } + + // --- Auto-completion detection --- + // If all PBIs are Done/Resolved (primary), or no PBIs but all PRs merged (secondary), + // auto-advance the feature to "done" and notify the user. + if (feature.step !== 'done') { + const allPbis = feature.artifacts?.pbis || []; + const allPrs = feature.artifacts?.agentPrs || []; + const completedStates = new Set(['done', 'resolved', 'closed', 'removed']); + + let shouldComplete = false; + + if (allPbis.length > 0) { + // Primary: all PBIs are in a completed state + shouldComplete = allPbis.every((p: any) => + completedStates.has((p.status || '').toLowerCase()) + ); + } else if (allPrs.length > 0) { + // Secondary: no PBIs tracked, but all PRs are merged + shouldComplete = allPrs.every((pr: any) => + (pr.status || '').toLowerCase() === 'merged' + ); + } + + if (shouldComplete) { + feature.step = 'done'; + feature.updatedAt = Date.now(); + FeatureDetailPanel.writeState(state); + + vscode.window.showInformationMessage( + `🎉 Feature "${feature.name}" is complete! All work items are resolved.`, + 'View Details' + ).then(selection => { + if (selection === 'View Details') { + FeatureDetailPanel.show( + undefined as any, // context not needed for existing panel + featureId + ); + } + }); + } + } + } + + // ---- Manual artifact entry handlers ---- + + /** + * Let user manually add a design spec — browse for a local file or paste an ADO PR URL. + */ + private static async handleAddDesign(featureId: string, panel: vscode.WebviewPanel): Promise { + const choice = await vscode.window.showQuickPick( + [ + { label: '📄 Browse for local file', description: 'Select a markdown file from design-docs/', value: 'file' }, + { label: '🔗 Enter ADO PR URL', description: 'Paste a design review PR link', value: 'pr' }, + ], + { placeHolder: 'How do you want to add the design spec?' } + ); + if (!choice) { return; } + + const state = FeatureDetailPanel.readState(); + const feature = state.features?.find((f: any) => f.id === featureId); + if (!feature) { return; } + if (!feature.artifacts) { feature.artifacts = { pbis: [], agentPrs: [] }; } + + if (choice.value === 'file') { + const folders = vscode.workspace.workspaceFolders; + const defaultUri = folders + ? vscode.Uri.file(path.join(folders[0].uri.fsPath, 'design-docs')) + : undefined; + + const uris = await vscode.window.showOpenDialog({ + defaultUri, + canSelectMany: false, + filters: { 'Markdown': ['md'] }, + title: 'Select Design Spec', + }); + if (!uris || uris.length === 0) { return; } + + // Make path workspace-relative + let docPath = uris[0].fsPath; + if (folders) { + const wsRoot = folders[0].uri.fsPath; + if (docPath.startsWith(wsRoot)) { + docPath = docPath.substring(wsRoot.length + 1).replace(/\\/g, '/'); + } + } + + feature.artifacts.design = { + docPath, + status: feature.artifacts.design?.status || 'approved', + prUrl: feature.artifacts.design?.prUrl, + }; + feature.designDocPath = docPath; + } else { + const prUrl = await vscode.window.showInputBox({ + prompt: 'Paste the ADO PR URL for the design review', + placeHolder: 'https://dev.azure.com/IdentityDivision/Engineering/_git/AuthLibrariesApiReview/pullrequest/...', + }); + if (!prUrl) { return; } + + if (!feature.artifacts.design) { + // Ask for the doc path too + const docPath = await vscode.window.showInputBox({ + prompt: 'Design doc path (workspace-relative, or leave empty)', + placeHolder: 'design-docs/[Android] Feature Name/spec.md', + }); + feature.artifacts.design = { + docPath: docPath || '', + prUrl, + status: 'in-review', + }; + if (docPath) { feature.designDocPath = docPath; } + } else { + feature.artifacts.design.prUrl = prUrl; + } + feature.designPrUrl = prUrl; + } + + feature.updatedAt = Date.now(); + FeatureDetailPanel.writeState(state); + // Re-render + const fresh = FeatureDetailPanel.readState().features?.find((f: any) => f.id === featureId); + if (fresh) { panel.webview.html = FeatureDetailPanel.getHtml(fresh); } + } + + /** + * Let user manually add a PBI by AB# ID — fetches details from ADO. + */ + private static async handleAddPbi(featureId: string, panel: vscode.WebviewPanel): Promise { + const idInput = await vscode.window.showInputBox({ + prompt: 'Enter the ADO work item ID (AB# number)', + placeHolder: 'e.g., 3531353', + validateInput: (v) => /^\d+$/.test(v.replace(/^AB#/i, '')) ? null : 'Enter a numeric ID (e.g., 3531353)', + }); + if (!idInput) { return; } + + const adoId = parseInt(idInput.replace(/^AB#/i, ''), 10); + + const state = FeatureDetailPanel.readState(); + const feature = state.features?.find((f: any) => f.id === featureId); + if (!feature) { return; } + if (!feature.artifacts) { feature.artifacts = { pbis: [], agentPrs: [] }; } + if (!feature.artifacts.pbis) { feature.artifacts.pbis = []; } + + // Check for duplicate + if (feature.artifacts.pbis.some((p: any) => p.adoId === adoId)) { + vscode.window.showInformationMessage(`AB#${adoId} is already tracked.`); + return; + } + + // Try to fetch details from ADO + let title = `Work Item ${adoId}`; + let pbiStatus = 'New'; + let module = ''; + + try { + const json = await runCommand( + `az boards work-item show --id ${adoId} --org "https://dev.azure.com/IdentityDivision" --only-show-errors -o json`, + undefined, 15000 + ); + const wi = JSON.parse(json); + const fields = wi.fields || {}; + title = fields['System.Title'] || title; + pbiStatus = fields['System.State'] || pbiStatus; + // Try to infer module from tags or area path + const tags: string = fields['System.Tags'] || ''; + const areaPath: string = fields['System.AreaPath'] || ''; + if (areaPath.includes('Broker')) { module = 'broker'; } + else if (areaPath.includes('MSAL')) { module = 'msal'; } + else if (areaPath.includes('Common') || areaPath.includes('common')) { module = 'common'; } + else if (tags.toLowerCase().includes('common')) { module = 'common'; } + } catch { + // az CLI not available — ask user for details + const userTitle = await vscode.window.showInputBox({ + prompt: `Could not fetch from ADO. Enter the PBI title:`, + placeHolder: 'PBI title', + }); + if (userTitle) { title = userTitle; } + + const userModule = await vscode.window.showQuickPick( + ['common', 'msal', 'broker', 'adal'], + { placeHolder: 'Which repo/module?' } + ); + if (userModule) { module = userModule; } + } + + feature.artifacts.pbis.push({ + adoId, + title, + module, + targetRepo: module, + status: pbiStatus, + adoUrl: `https://dev.azure.com/IdentityDivision/Engineering/_workitems/edit/${adoId}`, + }); + + // Also add to legacy pbis + if (!feature.pbis) { feature.pbis = []; } + feature.pbis.push({ adoId, title, targetRepo: module, status: pbiStatus }); + + feature.updatedAt = Date.now(); + FeatureDetailPanel.writeState(state); + const fresh = FeatureDetailPanel.readState().features?.find((f: any) => f.id === featureId); + if (fresh) { panel.webview.html = FeatureDetailPanel.getHtml(fresh); } + vscode.window.showInformationMessage(`Added AB#${adoId}: ${title}`); + } + + /** + * Let user manually add an agent PR by repo + PR number — fetches details from GitHub. + */ + private static async handleAddAgentPr(featureId: string, panel: vscode.WebviewPanel): Promise { + const repoChoice = await vscode.window.showQuickPick( + [ + { label: 'common', description: 'AzureAD/microsoft-authentication-library-common-for-android', value: 'AzureAD/microsoft-authentication-library-common-for-android' }, + { label: 'msal', description: 'AzureAD/microsoft-authentication-library-for-android', value: 'AzureAD/microsoft-authentication-library-for-android' }, + { label: 'broker', description: 'identity-authnz-teams/ad-accounts-for-android', value: 'identity-authnz-teams/ad-accounts-for-android' }, + { label: 'adal', description: 'AzureAD/azure-activedirectory-library-for-android', value: 'AzureAD/azure-activedirectory-library-for-android' }, + ], + { placeHolder: 'Which repo is the PR in?' } + ); + if (!repoChoice) { return; } + + const prInput = await vscode.window.showInputBox({ + prompt: 'Enter the PR number', + placeHolder: 'e.g., 2922', + validateInput: (v) => /^\d+$/.test(v.replace(/^#/, '')) ? null : 'Enter a numeric PR number', + }); + if (!prInput) { return; } + + const prNumber = parseInt(prInput.replace(/^#/, ''), 10); + + const state = FeatureDetailPanel.readState(); + const feature = state.features?.find((f: any) => f.id === featureId); + if (!feature) { return; } + if (!feature.artifacts) { feature.artifacts = { pbis: [], agentPrs: [] }; } + if (!feature.artifacts.agentPrs) { feature.artifacts.agentPrs = []; } + + // Check for duplicate + if (feature.artifacts.agentPrs.some((p: any) => p.prNumber === prNumber && (p.repo === repoChoice.label || p.repo === repoChoice.value))) { + vscode.window.showInformationMessage(`PR #${prNumber} in ${repoChoice.label} is already tracked.`); + return; + } + + // Try to fetch details from GitHub + let prTitle = `PR #${prNumber}`; + let prStatus = 'open'; + let prUrl = `https://github.com/${repoChoice.value}/pull/${prNumber}`; + + try { + await switchGhAccount(repoChoice.value); + const json = await runCommand( + `gh pr view ${prNumber} --repo "${repoChoice.value}" --json state,title,url`, + undefined, 15000 + ); + const prData = JSON.parse(json); + const stateMap: Record = { 'OPEN': 'open', 'MERGED': 'merged', 'CLOSED': 'closed' }; + prTitle = prData.title || prTitle; + prStatus = stateMap[prData.state] || prData.state?.toLowerCase() || prStatus; + prUrl = prData.url || prUrl; + } catch { + // gh CLI not available — use defaults + const userTitle = await vscode.window.showInputBox({ + prompt: 'Could not fetch from GitHub. Enter the PR title:', + placeHolder: 'PR title', + }); + if (userTitle) { prTitle = userTitle; } + } + + feature.artifacts.agentPrs.push({ + repo: repoChoice.label, + prNumber, + prUrl, + status: prStatus, + title: prTitle, + }); + + feature.updatedAt = Date.now(); + FeatureDetailPanel.writeState(state); + const fresh = FeatureDetailPanel.readState().features?.find((f: any) => f.id === featureId); + if (fresh) { panel.webview.html = FeatureDetailPanel.getHtml(fresh); } + vscode.window.showInformationMessage(`Added PR #${prNumber}: ${prTitle}`); + } + + /** + * Dispatch a specific PBI to Copilot coding agent via prompt file. + */ + private static async handleDispatchPbi(featureId: string, adoId: number, panel: vscode.WebviewPanel): Promise { + const state = FeatureDetailPanel.readState(); + const feature = state.features?.find((f: any) => f.id === featureId); + if (!feature) { return; } + + const pbi = feature.artifacts?.pbis?.find((p: any) => p.adoId === adoId); + if (!pbi) { + vscode.window.showWarningMessage(`PBI AB#${adoId} not found in feature state.`); + return; + } + + // Open a new chat with the dispatch prompt, scoped to this specific PBI + await vscode.commands.executeCommand('workbench.action.chat.newChat'); + vscode.commands.executeCommand('workbench.action.chat.open', { + query: `/feature-dispatch Feature: "${feature.name}". Dispatch ONLY PBI AB#${adoId}: "${pbi.title}" to repo "${pbi.module || pbi.targetRepo}".`, + }); + } + + /** + * Checkout a PR branch locally in the corresponding repo directory. + */ + private static async handleCheckoutPr(repo: string, prNumber: number): Promise { + const repoSlugs: Record = { + 'common': 'AzureAD/microsoft-authentication-library-common-for-android', + 'msal': 'AzureAD/microsoft-authentication-library-for-android', + 'broker': 'identity-authnz-teams/ad-accounts-for-android', + 'adal': 'AzureAD/azure-activedirectory-library-for-android', + }; + const repoDirs: Record = { + 'common': 'common', + 'msal': 'msal', + 'broker': 'broker', + 'adal': 'adal', + }; + + const repoSlug = repoSlugs[repo] || repo; + const repoDir = repoDirs[repo] || repo; + const folders = vscode.workspace.workspaceFolders; + const cwd = folders ? path.join(folders[0].uri.fsPath, repoDir) : undefined; + + try { + await switchGhAccount(repoSlug); + const terminal = vscode.window.createTerminal({ + name: `PR #${prNumber} (${repo})`, + cwd, + }); + terminal.show(); + terminal.sendText(`gh pr checkout ${prNumber} --repo "${repoSlug}"`); + vscode.window.showInformationMessage(`Checking out PR #${prNumber} in ${repo}/`); + } catch (e) { + vscode.window.showErrorMessage(`Failed to checkout PR #${prNumber}: ${e}`); + } + } + + private static getHtml(feature: any): string { + const artifacts: FeatureArtifacts = feature.artifacts || { pbis: [], agentPrs: [] }; + const design = artifacts.design; + const rawPbis: any[] = artifacts.pbis || feature.pbis || []; + const agentPrs = artifacts.agentPrs || feature.agentSessions || []; + + // Normalize PBI fields — state may use different field names depending on how it was written + const pbis = rawPbis.map((p: any) => { + // adoId can be: p.adoId (number), p.id ("AB#12345"), or missing + let adoId: string | number = p.adoId || p.id || '?'; + if (typeof adoId === 'string') { + adoId = adoId.replace(/^AB#/, ''); // strip "AB#" prefix for URL building + } + // dependsOn can be: array of numbers, array of "AB#NNN" strings, or missing + let dependsOn: string[] = []; + if (Array.isArray(p.dependsOn)) { + dependsOn = p.dependsOn.map((d: any) => String(d).replace(/^AB#/, '')); + } + return { + adoId, + displayId: p.id || (p.adoId ? `AB#${p.adoId}` : '?'), // what to show in UI + title: p.title || '', + module: p.module || p.repo || p.targetRepo || '', + adoUrl: p.adoUrl || `https://dev.azure.com/${ADO_ORG}/${ADO_PROJECT}/_workitems/edit/${adoId}`, + status: p.status || 'new', + dependsOn, + priority: p.priority, + }; + }); + + const stepConfig: Record = { + 'idle': { icon: '⏳', label: 'Ready', color: '#8b949e' }, + 'designing': { icon: '📝', label: 'Writing Design', color: '#58a6ff' }, + 'design_review': { icon: '👀', label: 'Design Review', color: '#d29922' }, + 'planning': { icon: '📋', label: 'Planning PBIs', color: '#58a6ff' }, + 'plan_review': { icon: '👀', label: 'Plan Review', color: '#d29922' }, + 'backlogging': { icon: '📝', label: 'Adding to Backlog', color: '#58a6ff' }, + 'backlog_review': { icon: '👀', label: 'Backlog Review', color: '#d29922' }, + 'dispatching': { icon: '🚀', label: 'Dispatching', color: '#58a6ff' }, + 'monitoring': { icon: '📡', label: 'Monitoring Agents', color: '#3fb950' }, + 'done': { icon: '✅', label: 'Complete', color: '#3fb950' }, + }; + + // Normalize step names: map aliases/past-tense/legacy names to canonical keys + const stepAliases: Record = { + 'designed': 'design_review', 'design_complete': 'design_review', + 'planned': 'plan_review', 'plan_complete': 'plan_review', + 'backlogged': 'backlog_review', 'created': 'backlog_review', 'creating': 'backlogging', 'create_review': 'backlog_review', + 'dispatched': 'monitoring', 'dispatch_complete': 'monitoring', + 'complete': 'done', 'completed': 'done', + }; + const normalizedStep = stepAliases[feature.step] || feature.step; + + const cfg = stepConfig[normalizedStep] || stepConfig['idle']; + + const pipelineStages = [ + { key: 'designing', label: 'Design' }, + { key: 'planning', label: 'Plan' }, + { key: 'backlogging', label: 'Backlog' }, + { key: 'dispatching', label: 'Dispatch' }, + { key: 'monitoring', label: 'Monitor' }, + ]; + const stageOrder = ['idle', 'designing', 'design_review', 'planning', 'plan_review', 'backlogging', 'backlog_review', 'dispatching', 'monitoring', 'done']; + const currentIdx = stageOrder.indexOf(normalizedStep); + + const pipelineHtml = pipelineStages.map((stage, i) => { + // Each stage maps to 2 entries in stageOrder (active + review), roughly at i*2+1 + const stageIdx = i * 2 + 1; + const isComplete = normalizedStep === 'done'; + const isActive = !isComplete && currentIdx >= stageIdx && currentIdx < stageIdx + 2; + const isDone = isComplete || currentIdx >= stageIdx + 2; + const cls = isDone ? 'stage done' : isActive ? 'stage active' : 'stage'; + return `
${isDone ? '✅' : isActive ? '🔵' : '○'} ${stage.label}
`; + }).join('
'); + + // Phase duration tracking + const phaseTs: Record = feature.phaseTimestamps || {}; + const phaseSequence = [ + { key: 'designing', label: 'Design' }, + { key: 'design_review', label: 'Review' }, + { key: 'planning', label: 'Plan' }, + { key: 'plan_review', label: 'Review' }, + { key: 'backlogging', label: 'Backlog' }, + { key: 'backlog_review', label: 'Review' }, + { key: 'dispatching', label: 'Dispatch' }, + { key: 'monitoring', label: 'Monitor' }, + { key: 'done', label: 'Done' }, + ]; + + const formatDuration = (ms: number): string => { + if (ms < 60000) { return `${Math.floor(ms / 1000)}s`; } + if (ms < 3600000) { return `${Math.floor(ms / 60000)}m`; } + if (ms < 86400000) { return `${Math.round(ms / 3600000 * 10) / 10}h`; } + return `${Math.round(ms / 86400000 * 10) / 10}d`; + }; + + // Build phase durations: time between consecutive phase timestamps + const phaseDurations: Array<{ label: string; duration: string }> = []; + let totalDuration = 0; + for (let i = 0; i < phaseSequence.length; i++) { + const ts = phaseTs[phaseSequence[i].key]; + if (!ts) { continue; } + // Find the next phase that has a timestamp + let nextTs: number | null = null; + for (let j = i + 1; j < phaseSequence.length; j++) { + if (phaseTs[phaseSequence[j].key]) { + nextTs = phaseTs[phaseSequence[j].key]; + break; + } + } + if (nextTs) { + const dur = nextTs - ts; + totalDuration += dur; + phaseDurations.push({ label: phaseSequence[i].label, duration: formatDuration(dur) }); + } else if (normalizedStep !== 'done') { + // Current active phase — show elapsed time + const dur = Date.now() - ts; + totalDuration += dur; + phaseDurations.push({ label: phaseSequence[i].label, duration: `${formatDuration(dur)}...` }); + } + } + + const phaseDurationHtml = phaseDurations.length > 0 + ? `
+
Phase Durations${totalDuration > 0 ? ` (Total: ${formatDuration(totalDuration)})` : ''}
+
+ ${phaseDurations.map(p => `
${p.label}${p.duration}
`).join('')} +
+
` + : ''; + + // Design section + const designHtml = design + ? `
+
📄 Design Spec
+
+ ${design.docPath ? `` : ''} + ${design.prUrl ? `` : ''} +
Status: ${design.status}
+
+
` + : (feature.designDocPath + ? `
+
📄 Design Spec
+
+ + ${feature.designPrUrl ? `` : ''} +
+
` + : '
📄 Design Spec

Not yet created

'); + + // PBIs section + const hasDeps = pbis.some((p: any) => p.dependsOn && p.dependsOn.length > 0); + + // Compute topological order for implementation sequence + const pbiOrder = new Map(); + if (pbis.length > 0) { + // Build adjacency: each PBI's dependencies + const resolved = new Set(); + const remaining = pbis.map((p: any) => ({ id: String(p.adoId), deps: (p.dependsOn || []).map(String) })); + let order = 1; + let maxIter = pbis.length + 1; // safety limit + while (remaining.length > 0 && maxIter-- > 0) { + const ready = remaining.filter(r => r.deps.every((d: string) => resolved.has(d))); + if (ready.length === 0) { + // Cycle or unresolved deps — assign remaining in original order + for (const r of remaining) { pbiOrder.set(r.id, order++); } + break; + } + for (const r of ready) { + pbiOrder.set(r.id, order++); + resolved.add(r.id); + remaining.splice(remaining.indexOf(r), 1); + } + } + } + + // Determine which PBIs are dispatchable + // A PBI is dispatchable if: + // 1. Status is "Committed" (case-insensitive) + // 2. Not already dispatched (no matching agent PR by adoId in title/body, or no dispatched flag) + // 3. All dependencies are resolved/done/merged (or no dependencies) + const agentPrTitles = agentPrs.map((pr: any) => (pr.title || '').toLowerCase()); + const resolvedStates = new Set(['resolved', 'done', 'closed', 'removed']); + + const isDispatched = (p: any): boolean => { + if (p.dispatched) { return true; } + // Check if any agent PR references this PBI's AB# ID + const idStr = String(p.adoId); + return agentPrTitles.some((t: string) => t.includes(`ab#${idStr}`) || t.includes(idStr)); + }; + + const areDepsResolved = (p: any): boolean => { + if (!p.dependsOn || p.dependsOn.length === 0) { return true; } + return p.dependsOn.every((depId: string) => { + const depPbi = pbis.find((d: any) => String(d.adoId) === String(depId)); + if (!depPbi) { return true; } // Unknown dep — assume resolved + return resolvedStates.has((depPbi.status || '').toLowerCase()); + }); + }; + + const isDispatchable = (p: any): boolean => { + return (p.status || '').toLowerCase() === 'committed' + && !isDispatched(p) + && areDepsResolved(p); + }; + + const getBlockingDeps = (p: any): string[] => { + if (!p.dependsOn || p.dependsOn.length === 0) { return []; } + return p.dependsOn.filter((depId: string) => { + const depPbi = pbis.find((d: any) => String(d.adoId) === String(depId)); + if (!depPbi) { return false; } + return !resolvedStates.has((depPbi.status || '').toLowerCase()); + }); + }; + + const pbisHtml = pbis.length > 0 + ? `
+
📋 Product Backlog Items ${pbis.length}
+
+ + ${hasDeps ? '' : ''} + + ${pbis.map((p: any) => { + const statusClass = (p.status || 'new').toLowerCase().replace(/\s/g, '-'); + const depsCell = hasDeps + ? `` + : ''; + const orderNum = pbiOrder.get(String(p.adoId)) || '—'; + let actionCell: string; + if (isDispatchable(p)) { + actionCell = ``; + } else if (isDispatched(p)) { + actionCell = ''; + } else { + const blocking = getBlockingDeps(p); + if (blocking.length > 0) { + const blockingList = blocking.map((d: string) => `AB#${d}`).join(', '); + actionCell = ``; + } else { + actionCell = ''; + } + } + return ` + + + + + ${depsCell} + + ${actionCell} + `; + }).join('')} + +
OrderAB#TitleRepoDepends OnStatusAction
${(p.dependsOn || []).map((d: string) => `AB#${d}`).join(', ') || '—'}✅ Dispatched🔒 Blocked
${orderNum}${escapeHtml(String(p.displayId))}${escapeHtml(p.title || '')}${escapeHtml(p.module || '')}${escapeHtml(p.status || 'new')}
+
+
` + : '
📋 Product Backlog Items

No PBIs yet

'; + + // Agent PRs section + const prsHtml = agentPrs.length > 0 + ? `
+
🤖 Agent Pull Requests ${agentPrs.length}
+
+ + + + ${agentPrs.map((pr: any) => { + const prUrl = pr.prUrl || pr.url || '#'; + const statusColor: Record = { open: '#3fb950', merged: '#8957e5', closed: '#da3633', draft: '#8b949e' }; + const status = (pr.status || pr.state || 'open').toLowerCase(); + const totalComments = pr.totalComments ?? '—'; + const resolvedComments = pr.resolvedComments ?? 0; + const commentsDisplay = totalComments === '—' ? '—' + : `${resolvedComments}/${totalComments}`; + const prNum = pr.prNumber || pr.number || ''; + const prRepo = pr.repo || ''; + const isOpen = status === 'open' || status === 'draft'; + const actionBtns = isOpen + ? ` + ` + : ''; + return ` + + + + + + + `; + }).join('')} + +
PRRepoTitleCommentsStatusAction
#${prNum}${escapeHtml(prRepo)}${escapeHtml(pr.title || '')}${commentsDisplay}${status}${actionBtns}
+
+
` + : '
🤖 Agent Pull Requests

No agent PRs yet

'; + + const timeAgo = (ts: number) => { + if (!ts) return 'unknown'; + const s = Math.floor((Date.now() - ts) / 1000); + if (s < 60) return 'just now'; + if (s < 3600) return `${Math.floor(s / 60)}m ago`; + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; + return `${Math.floor(s / 86400)}d ago`; + }; + + return ` + + +
+
${cfg.icon}
+
+

${escapeHtml(feature.name)}

+
Started ${timeAgo(feature.startedAt)} · Updated ${timeAgo(feature.updatedAt)}
+
+
+ + +
+
+ + ${feature.prompt ? `
${escapeHtml(feature.prompt)}
` : ''} + +
${pipelineHtml}
+ +
+
${cfg.icon} ${cfg.label}
+
+ + ${phaseDurationHtml} + +
Artifacts
+ ${designHtml} + ${pbisHtml} + ${prsHtml} + + +`; + } +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function escapeAttr(s: string): string { + return s.replace(/'/g, "\\'").replace(/"/g, '"').replace(/\\/g, '\\\\'); +} diff --git a/extensions/feature-orchestrator/src/tools.ts b/extensions/feature-orchestrator/src/tools.ts new file mode 100644 index 00000000..e5435550 --- /dev/null +++ b/extensions/feature-orchestrator/src/tools.ts @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +import * as vscode from 'vscode'; +import * as cp from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Run a terminal command and return the output. + */ +export function runCommand(command: string, cwd?: string, timeoutMs?: number): Promise { + return new Promise((resolve, reject) => { + const options: cp.ExecOptions = { + cwd: cwd ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath, + maxBuffer: 1024 * 1024, // 1MB + timeout: timeoutMs ?? 60000, + encoding: 'utf-8', + }; + + cp.exec(command, options, (error, stdout, stderr) => { + if (error) { + reject(new Error(`Command failed: ${command}\n${stderr?.toString() || error.message}`)); + return; + } + resolve(stdout?.toString().trim() ?? ''); + }); + }); +} + +/** + * Resolve GitHub account mapping for the current developer. + * Discovery sequence: + * 1. .github/developer-local.json + * 2. gh auth status (parse logged-in accounts) + * 3. Prompt the developer (via VS Code input box) + */ +async function resolveGhAccounts(): Promise> { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + + // Step 1: Check .github/developer-local.json + if (workspaceRoot) { + const configPath = path.join(workspaceRoot, '.github', 'developer-local.json'); + if (fs.existsSync(configPath)) { + try { + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const accounts = config.github_accounts; + if (accounts?.AzureAD && accounts?.['identity-authnz-teams']) { + return accounts; + } + } catch { + // Fall through to discovery + } + } + } + + // Step 2: Discover from gh auth status + try { + // First verify gh is installed + try { + await runCommand('gh --version'); + } catch { + // gh not installed — offer to install + const install = await vscode.window.showWarningMessage( + 'GitHub CLI (gh) is not installed. It\'s required for dispatching PBIs to Copilot coding agent.', + 'Install now', + 'I\'ll install manually' + ); + if (install === 'Install now') { + const terminal = vscode.window.createTerminal('Install gh CLI'); + terminal.show(); + if (process.platform === 'win32') { + terminal.sendText('winget install --id GitHub.cli -e --accept-source-agreements --accept-package-agreements'); + } else if (process.platform === 'darwin') { + terminal.sendText('brew install gh'); + } else { + terminal.sendText('echo "Please install gh CLI: https://cli.github.com"'); + } + vscode.window.showInformationMessage( + 'Installing gh CLI... After installation, run `gh auth login` in a terminal, then retry.' + ); + } + throw new Error('gh CLI not installed. Install it and run `gh auth login`, then retry.'); + } + + const status = await runCommand('gh auth status 2>&1'); + const accounts: Record = {}; + for (const line of status.split('\n')) { + const match = line.match(/account\s+(\S+)/); + if (match) { + const username = match[1]; + if (username.includes('_')) { + accounts['identity-authnz-teams'] = username; + } else { + accounts['AzureAD'] = username; + } + } + } + if (accounts['AzureAD'] && accounts['identity-authnz-teams']) { + return accounts; + } + } catch { + // Fall through to prompt + } + + // Step 3: Prompt the developer + const publicUser = await vscode.window.showInputBox({ + prompt: 'Enter your public GitHub username (for AzureAD/* repos like common, msal)', + placeHolder: 'e.g., myusername', + }); + const emuUser = await vscode.window.showInputBox({ + prompt: 'Enter your GitHub EMU username (for identity-authnz-teams/* repos like broker)', + placeHolder: 'e.g., myusername_microsoft', + }); + + if (!publicUser || !emuUser) { + throw new Error('GitHub usernames are required for dispatching. Please configure them.'); + } + + const accounts: Record = { + 'AzureAD': publicUser, + 'identity-authnz-teams': emuUser, + }; + + // Offer to save + if (workspaceRoot) { + const save = await vscode.window.showQuickPick(['Yes', 'No'], { + placeHolder: 'Save to .github/developer-local.json for next time?', + }); + if (save === 'Yes') { + const configPath = path.join(workspaceRoot, '.github', 'developer-local.json'); + const config = { github_accounts: accounts }; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); + } + } + + return accounts; +} + +/** + * Switch the gh CLI to the correct account for a given repo. + */ +export async function switchGhAccount(repo: string): Promise { + const org = repo.split('/')[0]; + const accountMap = await resolveGhAccounts(); + + const account = accountMap[org]; + if (account) { + await runCommand(`gh auth switch --user ${account}`); + } +} + +/** + * Check the status of Copilot agent PRs for a repo. + */ +export async function getAgentPRs(repo: string): Promise { + await switchGhAccount(repo); + + return runCommand( + `gh pr list --repo "${repo}" --author "copilot-swe-agent[bot]" --state all --limit 10 --json number,title,state,createdAt,url` + ); +} diff --git a/extensions/feature-orchestrator/tsconfig.json b/extensions/feature-orchestrator/tsconfig.json new file mode 100644 index 00000000..f06cd30b --- /dev/null +++ b/extensions/feature-orchestrator/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2022", + "outDir": "out", + "rootDir": "src", + "lib": ["ES2022"], + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src"], + "exclude": ["node_modules", "out"] +}