diff --git a/.github/hooks/state-utils.js b/.github/hooks/state-utils.js index d9a027c2..0e49b5c1 100644 --- a/.github/hooks/state-utils.js +++ b/.github/hooks/state-utils.js @@ -14,6 +14,7 @@ * node .github/hooks/state-utils.js set-design → sets design artifact * node .github/hooks/state-utils.js add-pbi → adds a PBI artifact * node .github/hooks/state-utils.js add-agent-pr → adds an agent PR artifact + * node .github/hooks/state-utils.js add-timeline → adds a timeline event * * State file schema: * { @@ -41,6 +42,9 @@ * "agentSessions": [ * { "repo": "AzureAD/...", "prNumber": 2916, "prUrl": "https://...", "sessionUrl": "https://...", "status": "in_progress" } * ], + * "timeline": [ + * { "ts": 1740000000000, "agent": "design-writer", "action": "Created design spec", "phase": "designing" } + * ], * "startedAt": 1740000000000, * "updatedAt": 1740000000000 * } @@ -55,8 +59,9 @@ const path = require('path'); const os = require('os'); // State file lives in user's home directory (not workspace root) -const STATE_DIR = path.join(os.homedir(), '.android-auth-orchestrator'); +const STATE_DIR = path.join(os.homedir(), '.feature-orchestrator'); const STATE_FILE = path.join(STATE_DIR, 'state.json'); +const LEGACY_STATE_FILE = path.join(os.homedir(), '.android-auth-orchestrator', 'state.json'); function ensureStateDir() { if (!fs.existsSync(STATE_DIR)) { @@ -65,11 +70,15 @@ function ensureStateDir() { } function readState() { - if (!fs.existsSync(STATE_FILE)) { + // Try new path first, fall back to legacy + const filePath = fs.existsSync(STATE_FILE) ? STATE_FILE + : fs.existsSync(LEGACY_STATE_FILE) ? LEGACY_STATE_FILE + : STATE_FILE; + if (!fs.existsSync(filePath)) { return { version: 1, features: [], lastUpdated: Date.now() }; } try { - return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')); + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); } catch { return { version: 1, features: [], lastUpdated: Date.now() }; } @@ -142,6 +151,32 @@ switch (command) { // Record phase timestamp for duration tracking if (!feature.phaseTimestamps) { feature.phaseTimestamps = {}; } feature.phaseTimestamps[args[1]] = Date.now(); + // Auto-record timeline event for phase transition + if (!feature.timeline) { feature.timeline = []; } + const agentMap = { + 'designing': 'design-writer', 'design_review': 'design-writer', + 'planning': 'feature-planner', 'plan_review': 'feature-planner', + 'backlogging': 'pbi-creator', 'backlog_review': 'pbi-creator', + 'dispatching': 'agent-dispatcher', 'monitoring': 'agent-dispatcher', + 'done': 'orchestrator', + }; + const actionMap = { + 'designing': 'Started writing design spec', + 'design_review': 'Design spec ready for review', + 'planning': 'Started decomposing into PBIs', + 'plan_review': 'Plan ready for review', + 'backlogging': 'Creating work items in ADO', + 'backlog_review': 'Work items created — ready for dispatch', + 'dispatching': 'Dispatching PBIs to coding agent', + 'monitoring': 'Agents working on PRs', + 'done': 'Feature complete', + }; + feature.timeline.push({ + ts: Date.now(), + agent: agentMap[args[1]] || 'orchestrator', + action: actionMap[args[1]] || `Moved to ${args[1]}`, + phase: args[1], + }); writeState(state); console.log(JSON.stringify({ ok: true, id: args[0], step: args[1] })); } else { @@ -267,7 +302,27 @@ switch (command) { } break; } + case 'add-timeline': { + const state = readState(); + const feature = findFeature(state, args[0]); + if (feature) { + const event = JSON.parse(args[1]); + if (!feature.timeline) { feature.timeline = []; } + feature.timeline.push({ + ts: Date.now(), + agent: event.agent || 'orchestrator', + action: event.action || '', + phase: event.phase || feature.step || '', + }); + feature.updatedAt = Date.now(); + writeState(state); + console.log(JSON.stringify({ ok: true, timelineCount: feature.timeline.length })); + } else { + console.log(JSON.stringify({ ok: false, error: 'Feature not found' })); + } + break; + } default: - console.error('Usage: state-utils.js [args]'); + console.error('Usage: state-utils.js [args]'); process.exit(1); } diff --git a/extensions/feature-orchestrator/README.md b/extensions/feature-orchestrator/README.md index 62c7acbd..e905b377 100644 --- a/extensions/feature-orchestrator/README.md +++ b/extensions/feature-orchestrator/README.md @@ -3,7 +3,7 @@ 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). +For the full developer guide, see `AI Driven Development Guide.md` in the repository root. ## What This Extension Provides @@ -42,7 +42,7 @@ code --install-extension feature-orchestrator-latest.vsix --force ## State -Feature state is stored at `~/.android-auth-orchestrator/state.json` (per-developer, not in repo). +Feature state is stored at `~/.feature-orchestrator//state.json` (per-developer, not in repo). Managed by `.github/hooks/state-utils.js`. ## Works Without This Extension diff --git a/extensions/feature-orchestrator/package.json b/extensions/feature-orchestrator/package.json index 82942be1..56d8711a 100644 --- a/extensions/feature-orchestrator/package.json +++ b/extensions/feature-orchestrator/package.json @@ -1,9 +1,14 @@ { "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", + "displayName": "Feature Orchestrator", + "description": "AI-driven feature development orchestrator for multi-repo projects. Dashboard + @orchestrator chat participant.", + "version": "0.4.0", "publisher": "AzureAD", + "repository": { + "type": "git", + "url": "https://github.com/AzureAD/android-complete.git", + "directory": "extensions/feature-orchestrator" + }, "engines": { "vscode": "^1.100.0" }, @@ -108,8 +113,7 @@ "scripts": { "vscode:prepublish": "npm run compile", "compile": "tsc -p ./", - "watch": "tsc -watch -p ./", - "lint": "eslint src --ext ts" + "watch": "tsc -watch -p ./" }, "devDependencies": { "@types/node": "^20.0.0", diff --git a/extensions/feature-orchestrator/src/config.ts b/extensions/feature-orchestrator/src/config.ts new file mode 100644 index 00000000..298de565 --- /dev/null +++ b/extensions/feature-orchestrator/src/config.ts @@ -0,0 +1,232 @@ +// 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 fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +interface RepositoryConfig { + slug?: string; + host?: string; +} + +interface ModuleConfig { + repo?: string; +} + +interface OrchestratorConfig { + project?: { name?: string }; + repositories?: Record; + modules?: Record; + ado?: { org?: string; project?: string }; + design?: { docsPath?: string }; + github?: { configFile?: string }; +} + +let cachedConfig: OrchestratorConfig | null = null; + +export function getWorkspaceRoot(): string | undefined { + return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; +} + +export function getOrchestratorConfig(): OrchestratorConfig { + if (cachedConfig) { + return cachedConfig; + } + + const workspaceRoot = getWorkspaceRoot(); + if (!workspaceRoot) { + cachedConfig = {}; + return cachedConfig; + } + + const configPath = path.join(workspaceRoot, '.github', 'orchestrator-config.json'); + if (!fs.existsSync(configPath)) { + cachedConfig = {}; + return cachedConfig; + } + + try { + cachedConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + cachedConfig = {}; + } + + return cachedConfig || {}; +} + +export function getAdoConfig(): { org: string; project: string } { + const config = getOrchestratorConfig(); + return { + org: config.ado?.org || '', + project: config.ado?.project || '', + }; +} + +export function getAdoOrgUrl(): string { + const ado = getAdoConfig(); + return ado.org ? `https://dev.azure.com/${ado.org}` : ''; +} + +export function getAdoWorkItemUrl(id: string | number): string { + const ado = getAdoConfig(); + if (!ado.org || !ado.project) { + return ''; + } + return `https://dev.azure.com/${ado.org}/${ado.project}/_workitems/edit/${id}`; +} + +export function getDesignDocsPath(): string { + const docsPath = getOrchestratorConfig().design?.docsPath; + return docsPath || 'design-docs/'; +} + +export function getStateFilePath(): string { + const genericPath = path.join(os.homedir(), '.feature-orchestrator', 'state.json'); + const legacyPath = path.join(os.homedir(), '.android-auth-orchestrator', 'state.json'); + + // Prefer the new generic path if it exists + if (fs.existsSync(genericPath)) { + return genericPath; + } + + // Fall back to legacy path for backward compat + if (fs.existsSync(legacyPath)) { + return legacyPath; + } + + // Default to generic path for new installs + return genericPath; +} + +export function ensureStateDirectoryExists(): void { + const stateDir = path.dirname(getStateFilePath()); + if (!fs.existsSync(stateDir)) { + fs.mkdirSync(stateDir, { recursive: true }); + } +} + +export function getRepositoryMap(): Record { + const repos = getOrchestratorConfig().repositories || {}; + const result: Record = {}; + + for (const [key, repo] of Object.entries(repos)) { + if (repo.slug) { + result[key] = repo.slug; + } + } + + return result; +} + +export function getGithubRepositories(): Array<{ key: string; slug: string }> { + const repos = getOrchestratorConfig().repositories || {}; + const result: Array<{ key: string; slug: string }> = []; + + for (const [key, repo] of Object.entries(repos)) { + if ((repo.host || '').toLowerCase() === 'github' && repo.slug) { + result.push({ key, slug: repo.slug }); + } + } + + return result; +} + +export function getRepoSlugForModule(moduleOrRepo: string): string | undefined { + if (!moduleOrRepo) { + return undefined; + } + + if (moduleOrRepo.includes('/')) { + return moduleOrRepo; + } + + const config = getOrchestratorConfig(); + const repos = config.repositories || {}; + + if (repos[moduleOrRepo]?.slug) { + return repos[moduleOrRepo].slug; + } + + const moduleConfig = config.modules?.[moduleOrRepo]; + if (moduleConfig?.repo && repos[moduleConfig.repo]?.slug) { + return repos[moduleConfig.repo].slug; + } + + return undefined; +} + +export function getModuleRepoChoices(): Array<{ label: string; description: string; value: string }> { + const config = getOrchestratorConfig(); + const repos = config.repositories || {}; + const modules = config.modules || {}; + + const seen = new Set(); + const choices: Array<{ label: string; description: string; value: string }> = []; + + for (const [moduleName, moduleConfig] of Object.entries(modules)) { + const repoKey = moduleConfig.repo || moduleName; + const repoSlug = repos[repoKey]?.slug; + if (!repoSlug || seen.has(moduleName)) { + continue; + } + + seen.add(moduleName); + choices.push({ + label: moduleName, + description: repoSlug, + value: repoSlug, + }); + } + + if (choices.length > 0) { + return choices; + } + + for (const [repoKey, repoConfig] of Object.entries(repos)) { + if (repoConfig.slug) { + choices.push({ + label: repoKey, + description: repoConfig.slug, + value: repoConfig.slug, + }); + } + } + + return choices; +} + +export function getDeveloperLocalConfigPath(): string | undefined { + const workspaceRoot = getWorkspaceRoot(); + if (!workspaceRoot) { + return undefined; + } + + const configured = getOrchestratorConfig().github?.configFile; + if (configured) { + return path.join(workspaceRoot, configured); + } + + return path.join(workspaceRoot, '.github', 'developer-local.json'); +} diff --git a/extensions/feature-orchestrator/src/dashboard.ts b/extensions/feature-orchestrator/src/dashboard.ts index 15269f11..0b272cef 100644 --- a/extensions/feature-orchestrator/src/dashboard.ts +++ b/extensions/feature-orchestrator/src/dashboard.ts @@ -23,8 +23,8 @@ 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'; +import { ensureStateDirectoryExists, getGithubRepositories, getStateFilePath } from './config'; interface OpenPr { repo: string; @@ -120,9 +120,7 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { 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 }); - } + ensureStateDirectoryExists(); const pattern = new vscode.RelativePattern( vscode.Uri.file(stateDir), 'state.json' @@ -256,7 +254,7 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { } private getStateFilePath(): string | null { - return path.join(os.homedir(), '.android-auth-orchestrator', 'state.json'); + return getStateFilePath(); } private readStateFile(): OrchestratorState { @@ -297,11 +295,18 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { 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 repos = getGithubRepositories().map((repo) => ({ + slug: repo.slug, + label: repo.key, + })); + + if (repos.length === 0) { + this.cachedOpenPrs = []; + this.prsLastFetched = Date.now(); + this.prsEverFetched = true; + await this.refresh(); + return; + } const allPrs: OpenPr[] = []; @@ -441,14 +446,44 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { 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(''); + // Determine if dispatch is still in progress (some PBIs not yet dispatched) + const artifacts = (f as any).artifacts; + const allPbis = artifacts?.pbis || f.pbis || []; + const allPrs = artifacts?.agentPrs || f.agentSessions || []; + const resolvedStatuses = new Set(['resolved', 'done', 'closed', 'removed']); + const unresolvedPbis = allPbis.filter((p: any) => !resolvedStatuses.has((p.status || '').toLowerCase())); + const allDispatched = unresolvedPbis.length === 0 || allPrs.length >= unresolvedPbis.length; + + // Mini pipeline with agent icons + const stageInfo = [ + { icon: '📐', label: 'Design', startIdx: 0, endIdx: 2 }, + { icon: '🗂️', label: 'Plan', startIdx: 2, endIdx: 4 }, + { icon: '📋', label: 'Backlog', startIdx: 4, endIdx: 6 }, + { icon: '🚀', label: 'Dispatch', startIdx: 6, endIdx: 7 }, + { icon: '👁️', label: 'Monitor', startIdx: 7, endIdx: 8 }, + ]; + + const miniPipeline = stageInfo.map(s => { + let isDone = normalizedStep === 'done' || currentIdx >= s.endIdx; + let isActive = !isDone && currentIdx >= s.startIdx && currentIdx < s.endIdx; + + // When monitoring: if not all PBIs dispatched, Dispatch is still active + if (s.label === 'Dispatch' && normalizedStep === 'monitoring' && !allDispatched) { + isDone = false; + isActive = true; + } + if (isDone) { + return `
`; + } else if (isActive) { + return `
${s.icon}
`; + } else { + return `
${s.icon}
`; + } + }).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 pbiCount = allPbis.length; + const prCount = (artifacts?.agentPrs || f.agentSessions || []).length; const hasDesign = !!artifacts?.design || !!f.designDocPath; const artifactPills: string[] = []; if (hasDesign) { artifactPills.push('📄 Design'); } @@ -493,7 +528,7 @@ export class DashboardViewProvider implements vscode.WebviewViewProvider { ${this.escapeHtml(f.name)} -
${progressDots}
+
${miniPipeline}
${actionBtn} ${artifactSummary}
${this.timeAgo(f.updatedAt)}
@@ -558,6 +593,16 @@ h3 { margin: 14px 0 6px; font-size: 10px; text-transform: uppercase; letter-spac .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--vscode-widget-border); flex-shrink: 0; transition: all 0.3s; } .dot.active { background: var(--vscode-progressBar-background); box-shadow: 0 0 6px var(--vscode-progressBar-background); } .dot.done { background: #238636; } + +/* Mini pipeline stages */ +.mini-pipeline { display: flex; align-items: center; gap: 2px; margin: 8px 0; } +.mini-stage { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 50%; transition: all 0.3s; } +.mini-stage.done { background: #23863620; } +.mini-stage.active { background: var(--vscode-editorWidget-background); border: 1.5px solid var(--vscode-focusBorder); animation: borderBreath 3s ease-in-out infinite; } +.mini-stage.upcoming { opacity: 0.35; } +.mini-icon { font-size: 12px; line-height: 1; } +.mini-arrow { color: var(--vscode-descriptionForeground); font-size: 10px; opacity: 0.3; margin: 0 1px; } +@keyframes borderBreath { 0%, 100% { border-color: var(--vscode-focusBorder); } 50% { border-color: transparent; } } .action-btn { background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: none; border-radius: 4px; padding: 6px 12px; font-size: 11px; cursor: pointer; font-weight: 600; width: 100%; margin: 4px 0; } .action-btn:hover { background: var(--vscode-button-hoverBackground); } .step-status { font-size: 11px; color: var(--vscode-descriptionForeground); font-style: italic; text-align: center; padding: 4px 0; } diff --git a/extensions/feature-orchestrator/src/featureDetail.ts b/extensions/feature-orchestrator/src/featureDetail.ts index 462c9674..b3916a24 100644 --- a/extensions/feature-orchestrator/src/featureDetail.ts +++ b/extensions/feature-orchestrator/src/featureDetail.ts @@ -23,8 +23,16 @@ 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'; +import { + ensureStateDirectoryExists, + getAdoOrgUrl, + getAdoWorkItemUrl, + getDesignDocsPath, + getModuleRepoChoices, + getRepoSlugForModule, + getStateFilePath, +} from './config'; /** * Artifact types that can be tracked per feature. @@ -38,7 +46,7 @@ export interface DesignArtifact { export interface PbiArtifact { adoId: number; // AB# work item ID title: string; - targetRepo: string; // e.g. "AzureAD/microsoft-authentication-library-common-for-android" + targetRepo: string; // e.g. "owner/repository-name" module: string; // e.g. "common", "msal", "broker" adoUrl: string; // full ADO URL status: 'new' | 'committed' | 'active' | 'resolved' | 'closed'; @@ -61,9 +69,6 @@ export interface FeatureArtifacts { agentPrs: AgentPrArtifact[]; } -const ADO_ORG = 'IdentityDivision'; -const ADO_PROJECT = 'Engineering'; - /** * Opens a detail panel for a specific feature, showing all tracked artifacts. */ @@ -204,10 +209,8 @@ export class FeatureDetailPanel { }, 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 }); - } + ensureStateDirectoryExists(); + const stateDir = path.dirname(getStateFilePath()); const pattern = new vscode.RelativePattern(vscode.Uri.file(stateDir), 'state.json'); const watcher = vscode.workspace.createFileSystemWatcher(pattern); watcher.onDidChange(() => { @@ -221,17 +224,16 @@ export class FeatureDetailPanel { } private static readState(): any { - const filePath = path.join(os.homedir(), '.android-auth-orchestrator', 'state.json'); + const filePath = getStateFilePath(); 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 }); } + ensureStateDirectoryExists(); state.lastUpdated = Date.now(); - fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify(state, null, 2), 'utf-8'); + fs.writeFileSync(getStateFilePath(), JSON.stringify(state, null, 2), 'utf-8'); } /** @@ -245,13 +247,8 @@ export class FeatureDetailPanel { 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', - }; + // Repo slug mapping: module/repo aliases → full GitHub slugs + const resolveRepoSlug = (name: string): string => getRepoSlugForModule(name) || name; // --- Refresh Agent PRs from GitHub --- const agentPrs = feature.artifacts?.agentPrs || []; @@ -259,8 +256,8 @@ export class FeatureDetailPanel { // 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'; + const repoSlug = resolveRepoSlug(pr.repo); + const org = repoSlug.split('/')[0] || ''; if (!prsByOrg[org]) { prsByOrg[org] = []; } prsByOrg[org].push({ pr, repoSlug }); } @@ -275,6 +272,7 @@ export class FeatureDetailPanel { } for (const { pr, repoSlug } of prs) { + if (!repoSlug || !repoSlug.includes('/')) { continue; } try { const prNumber = pr.prNumber || pr.number; if (!prNumber) { continue; } @@ -322,13 +320,18 @@ export class FeatureDetailPanel { // Check if az CLI is available and authenticated (quick test) await runCommand('az account show --only-show-errors -o none', undefined, 5000); + const adoOrgUrl = getAdoOrgUrl(); + if (!adoOrgUrl) { + return; + } + 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`, + `az boards work-item show --id ${adoId} --org "${adoOrgUrl}" --only-show-errors -o json`, undefined, 15000 // 15s timeout ); const wiData = JSON.parse(json); @@ -421,7 +424,7 @@ export class FeatureDetailPanel { 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: '📄 Browse for local file', description: `Select a markdown file from ${getDesignDocsPath()}`, 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?' } @@ -435,8 +438,9 @@ export class FeatureDetailPanel { if (choice.value === 'file') { const folders = vscode.workspace.workspaceFolders; + const docsPath = getDesignDocsPath().replace(/[\\/]+$/, ''); const defaultUri = folders - ? vscode.Uri.file(path.join(folders[0].uri.fsPath, 'design-docs')) + ? vscode.Uri.file(path.join(folders[0].uri.fsPath, docsPath)) : undefined; const uris = await vscode.window.showOpenDialog({ @@ -465,7 +469,7 @@ export class FeatureDetailPanel { } 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/...', + placeHolder: 'https://dev.azure.com///_git//pullrequest/...', }); if (!prUrl) { return; } @@ -473,7 +477,7 @@ export class FeatureDetailPanel { // 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', + placeHolder: `${getDesignDocsPath()}[Platform] Feature Name/spec.md`, }); feature.artifacts.design = { docPath: docPath || '', @@ -525,8 +529,12 @@ export class FeatureDetailPanel { let module = ''; try { + const adoOrgUrl = getAdoOrgUrl(); + if (!adoOrgUrl) { + throw new Error('ADO organization is not configured.'); + } const json = await runCommand( - `az boards work-item show --id ${adoId} --org "https://dev.azure.com/IdentityDivision" --only-show-errors -o json`, + `az boards work-item show --id ${adoId} --org "${adoOrgUrl}" --only-show-errors -o json`, undefined, 15000 ); const wi = JSON.parse(json); @@ -561,7 +569,7 @@ export class FeatureDetailPanel { module, targetRepo: module, status: pbiStatus, - adoUrl: `https://dev.azure.com/IdentityDivision/Engineering/_workitems/edit/${adoId}`, + adoUrl: getAdoWorkItemUrl(adoId), }); // Also add to legacy pbis @@ -579,13 +587,14 @@ export class FeatureDetailPanel { * 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 moduleChoices = getModuleRepoChoices(); + if (moduleChoices.length === 0) { + vscode.window.showWarningMessage('No repositories are configured. Add repositories/modules to .github/orchestrator-config.json.'); + return; + } + 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' }, - ], + moduleChoices, { placeHolder: 'Which repo is the PR in?' } ); if (!repoChoice) { return; } @@ -676,21 +685,8 @@ export class FeatureDetailPanel { * 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 repoSlug = getRepoSlugForModule(repo) || repo; + const repoDir = repo.includes('/') ? repo.split('/').pop() || repo : repo; const folders = vscode.workspace.workspaceFolders; const cwd = folders ? path.join(folders[0].uri.fsPath, repoDir) : undefined; @@ -731,7 +727,7 @@ export class FeatureDetailPanel { 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}`, + adoUrl: p.adoUrl || getAdoWorkItemUrl(adoId), status: p.status || 'new', dependsOn, priority: p.priority, @@ -764,23 +760,34 @@ export class FeatureDetailPanel { 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' }, + { key: 'designing', label: 'Design', agentIcon: '📐', startIdx: 1, endIdx: 3 }, + { key: 'planning', label: 'Plan', agentIcon: '🗂️', startIdx: 3, endIdx: 5 }, + { key: 'backlogging', label: 'Backlog', agentIcon: '📋', startIdx: 5, endIdx: 7 }, + { key: 'dispatching', label: 'Dispatch', agentIcon: '🚀', startIdx: 7, endIdx: 8 }, + { key: 'monitoring', label: 'Monitor', agentIcon: '👁️', startIdx: 8, endIdx: 9 }, ]; const stageOrder = ['idle', 'designing', 'design_review', 'planning', 'plan_review', 'backlogging', 'backlog_review', 'dispatching', 'monitoring', 'done']; const currentIdx = stageOrder.indexOf(normalizedStep); + // Determine if dispatch is fully complete + const resolvedStatuses = new Set(['resolved', 'done', 'closed', 'removed']); + const unresolvedPbis = pbis.filter((p: any) => !resolvedStatuses.has((p.status || '').toLowerCase())); + const allDispatched = unresolvedPbis.length === 0 || agentPrs.length >= unresolvedPbis.length; + 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; + let isDone = isComplete || currentIdx >= stage.endIdx; + let isActive = !isDone && currentIdx >= stage.startIdx && currentIdx < stage.endIdx; + + // When monitoring: if not all PBIs dispatched, Dispatch is still active + if (stage.key === 'dispatching' && normalizedStep === 'monitoring' && !allDispatched) { + isDone = false; + isActive = true; + } + const cls = isDone ? 'stage done' : isActive ? 'stage active' : 'stage'; - return `
${isDone ? '✅' : isActive ? '🔵' : '○'} ${stage.label}
`; + const icon = isDone ? '✅' : stage.agentIcon; + return `
${icon} ${stage.label}
`; }).join('
'); // Phase duration tracking @@ -839,6 +846,41 @@ export class FeatureDetailPanel { ` : ''; + // Agent Timeline section + const timelineEvents: Array<{ ts: number; agent: string; action: string; phase: string }> = feature.timeline || []; + const agentIcons: Record = { + 'design-writer': '📐', 'codebase-researcher': '🔍', + 'feature-planner': '🗂️', 'pbi-creator': '📋', + 'agent-dispatcher': '🚀', 'orchestrator': '⚡', + }; + + const timelineHtml = timelineEvents.length > 0 + ? `
+
📜 Agent Timeline ${timelineEvents.length}
+
+
+ ${timelineEvents.map((evt, i) => { + const icon = agentIcons[evt.agent] || '🤖'; + const time = new Date(evt.ts); + const timeStr = time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const dateStr = time.toLocaleDateString([], { month: 'short', day: 'numeric' }); + const isLast = i === timelineEvents.length - 1; + const activeClass = isLast && normalizedStep !== 'done' ? ' timeline-active' : ''; + return `
+
${isLast ? '' : '
'}
+
${icon}
+
+
${escapeHtml(evt.agent)}
+
${escapeHtml(evt.action)}
+
+
${dateStr}
${timeStr}
+
`; + }).join('')} +
+
+
` + : ''; + // Design section const designHtml = design ? `
@@ -934,7 +976,7 @@ export class FeatureDetailPanel { ${pbis.map((p: any) => { const statusClass = (p.status || 'new').toLowerCase().replace(/\s/g, '-'); const depsCell = hasDeps - ? `${(p.dependsOn || []).map((d: string) => `AB#${d}`).join(', ') || '—'}` + ? `${(p.dependsOn || []).map((d: string) => `AB#${d}`).join(', ') || '—'}` : ''; const orderNum = pbiOrder.get(String(p.adoId)) || '—'; let actionCell: string; @@ -1073,12 +1115,16 @@ body { white-space: nowrap; } .stage.active { - color: var(--vscode-button-foreground); - background: var(--vscode-progressBar-background); + color: var(--vscode-foreground); + background: var(--vscode-editorWidget-background); + border: 1.5px solid var(--vscode-focusBorder); font-weight: 600; + animation: borderBreath 3s ease-in-out infinite; } .stage.done { color: #3fb950; font-weight: 500; } .stage-arrow { color: var(--vscode-descriptionForeground); font-size: 12px; } +.stage-agent-icon { font-size: 14px; } +@keyframes borderBreath { 0%, 100% { border-color: var(--vscode-focusBorder); } 50% { border-color: transparent; } } /* Status bar */ .status-bar { @@ -1249,6 +1295,61 @@ a:hover { text-decoration: underline; } .phase-bar-label { font-size: 10px; color: var(--vscode-descriptionForeground); } .phase-bar-value { font-size: 13px; font-weight: 700; color: var(--vscode-foreground); } +/* Agent Timeline */ +.timeline-card { margin-bottom: 16px; } +.timeline { display: flex; flex-direction: column; gap: 0; } +.timeline-item { + display: grid; + grid-template-columns: 20px 30px 1fr auto; + gap: 8px; + align-items: start; + padding: 8px 0; + position: relative; +} +.timeline-connector { + display: flex; + justify-content: center; + position: relative; + height: 100%; +} +.timeline-line { + position: absolute; + top: 28px; + width: 2px; + height: calc(100% + 8px); + background: var(--vscode-widget-border); +} +.timeline-dot { + font-size: 16px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; +} +.timeline-content { min-width: 0; } +.timeline-agent { + font-size: 11px; + font-weight: 600; + color: var(--vscode-foreground); + font-family: var(--vscode-editor-font-family, monospace); +} +.timeline-action { + font-size: 12px; + color: var(--vscode-descriptionForeground); + margin-top: 2px; +} +.timeline-time { + font-size: 10px; + color: var(--vscode-descriptionForeground); + text-align: right; + white-space: nowrap; + line-height: 1.3; +} +@keyframes timelinePulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.2); } +} + .section-title { font-size: 10px; text-transform: uppercase; @@ -1280,6 +1381,8 @@ a:hover { text-decoration: underline; } ${phaseDurationHtml} + ${timelineHtml} +
Artifacts
${designHtml} ${pbisHtml} diff --git a/extensions/feature-orchestrator/src/tools.ts b/extensions/feature-orchestrator/src/tools.ts index e5435550..ad47c54e 100644 --- a/extensions/feature-orchestrator/src/tools.ts +++ b/extensions/feature-orchestrator/src/tools.ts @@ -24,6 +24,7 @@ import * as vscode from 'vscode'; import * as cp from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; +import { getDeveloperLocalConfigPath } from './config'; /** * Run a terminal command and return the output. @@ -47,123 +48,101 @@ export function runCommand(command: string, cwd?: string, timeoutMs?: number): P }); } -/** - * 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 - } - } +function loadGhAccountMap(): Record { + const configPath = getDeveloperLocalConfigPath(); + if (!configPath || !fs.existsSync(configPath)) { + return {}; } - // 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 config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const accounts = config.github_accounts; + return accounts && typeof accounts === 'object' ? accounts : {}; + } catch { + return {}; + } +} - 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; - } +async function resolveGhAccountForRepo(repo: string): Promise { + const org = repo.split('/')[0] || ''; + const accountMap = loadGhAccountMap(); + + if (accountMap[repo]) { + return accountMap[repo]; + } + if (accountMap[org]) { + return accountMap[org]; + } + + try { + await runCommand('gh --version'); + } catch { + 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.' + ); } - if (accounts['AzureAD'] && accounts['identity-authnz-teams']) { - return accounts; + throw new Error('gh CLI not installed. Install it and run `gh auth login`, then retry.'); + } + + try { + const currentLogin = (await runCommand('gh api user --jq .login', undefined, 10000)).trim(); + if (currentLogin) { + return currentLogin; } } 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)', + const repoLabel = repo || 'target repository'; + const username = await vscode.window.showInputBox({ + prompt: `Enter your GitHub username for ${repoLabel}`, 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.'); + if (!username) { + throw new Error('GitHub username is required for dispatching. Please configure it.'); } - const accounts: Record = { - 'AzureAD': publicUser, - 'identity-authnz-teams': emuUser, - }; + const save = await vscode.window.showQuickPick(['Yes', 'No'], { + placeHolder: 'Save this mapping to .github/developer-local.json for next time?', + }); - // 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'); + if (save === 'Yes') { + const configPath = getDeveloperLocalConfigPath(); + if (configPath) { + const existing = loadGhAccountMap(); + existing[repo] = username; + const configDir = path.dirname(configPath); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + fs.writeFileSync(configPath, JSON.stringify({ github_accounts: existing }, null, 2) + '\n', 'utf-8'); } } - return accounts; + return username; } /** * 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]; + const account = await resolveGhAccountForRepo(repo); if (account) { await runCommand(`gh auth switch --user ${account}`); }