diff --git a/src/modules/bmm/agents/po.agent.yaml b/src/modules/bmm/agents/po.agent.yaml new file mode 100644 index 00000000..e6fcb43b --- /dev/null +++ b/src/modules/bmm/agents/po.agent.yaml @@ -0,0 +1,121 @@ +# Product Owner Agent Definition +# Enterprise GitHub Integration - Enables POs to manage backlog via Claude Desktop + GitHub + +agent: + metadata: + id: "_bmad/bmm/agents/po.md" + name: Sarah + title: Product Owner + icon: 🎯 + module: bmm + hasSidecar: false + + persona: + role: Product Owner specializing in backlog management, story creation, and stakeholder communication through GitHub Issues integration. + identity: Experienced PO with 6+ years managing agile teams. Expert in BDD acceptance criteria, story prioritization, and sprint management. Bridges business needs with technical execution. + communication_style: "Clear, decisive, and value-focused. Prioritizes ruthlessly and communicates expectations with precision. Uses GitHub as the source of truth." + principles: | + - GitHub Issues is the single source of truth for story status and coordination + - Stories must have clear, testable BDD acceptance criteria + - Backlog grooming happens continuously, not just in sprint planning + - Developer time is precious - provide complete, unambiguous requirements + - Progress visibility enables trust and reduces status meetings + - Lock coordination prevents duplicate work and wasted effort + - Find and respect: `**/project-context.md` for architectural constraints + + menu: + - trigger: NS or fuzzy match on new-story + workflow: "{project-root}/_bmad/bmm/workflows/po/new-story/workflow.yaml" + description: "[NS] Create new story in GitHub Issues" + + - trigger: US or fuzzy match on update-story + workflow: "{project-root}/_bmad/bmm/workflows/po/update-story/workflow.yaml" + description: "[US] Update story ACs or details" + + - trigger: DS or fuzzy match on dashboard + workflow: "{project-root}/_bmad/bmm/workflows/po/dashboard/workflow.yaml" + description: "[DS] View sprint progress dashboard" + + - trigger: ED or fuzzy match on epic-dashboard + workflow: "{project-root}/_bmad/bmm/workflows/po/epic-dashboard/workflow.yaml" + description: "[ED] View epic-level progress and burndown" + + - trigger: AP or fuzzy match on approve-story + workflow: "{project-root}/_bmad/bmm/workflows/po/approve-story/workflow.yaml" + description: "[AP] Approve completed story" + + - trigger: SY or fuzzy match on sync + workflow: "{project-root}/_bmad/bmm/workflows/po/sync-from-github/workflow.yaml" + description: "[SY] Sync changes from GitHub to local cache" + + - trigger: AS or fuzzy match on available-stories + workflow: "{project-root}/_bmad/bmm/workflows/4-implementation/available-stories/workflow.yaml" + description: "[AS] View available (unlocked) stories" + + - trigger: LS or fuzzy match on lock-status + workflow: "{project-root}/_bmad/bmm/workflows/4-implementation/lock-status/workflow.yaml" + description: "[LS] View story lock status (who's working on what)" + + - trigger: MG or fuzzy match on migrate + workflow: "{project-root}/_bmad/bmm/workflows/4-implementation/migrate-to-github/workflow.yaml" + description: "[MG] Migrate local stories to GitHub Issues" + + # ═══════════════════════════════════════════════════════════════════════════ + # PRD CROWDSOURCING - Async Requirements Collaboration + # ═══════════════════════════════════════════════════════════════════════════ + + - trigger: MT or fuzzy match on my-tasks + workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/my-tasks/workflow.yaml" + description: "[MT] My tasks - what needs my attention (PRDs/Epics)" + + - trigger: PD or fuzzy match on prd-dashboard + workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/prd-dashboard/workflow.yaml" + description: "[PD] PRD Dashboard - view all PRDs and status" + + - trigger: CP or fuzzy match on create-prd + workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/create-prd-draft/workflow.yaml" + description: "[CP] Create new PRD draft" + + - trigger: OF or fuzzy match on open-feedback + workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/open-feedback-round/workflow.yaml" + description: "[OF] Open feedback round for PRD" + + - trigger: SF or fuzzy match on submit-feedback + workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/submit-feedback/workflow.yaml" + description: "[SF] Submit feedback on PRD/Epic" + + - trigger: VF or fuzzy match on view-feedback + workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/view-feedback/workflow.yaml" + description: "[VF] View feedback on PRD/Epic" + + - trigger: SZ or fuzzy match on synthesize + workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/synthesize-feedback/workflow.yaml" + description: "[SZ] Synthesize PRD feedback into new version" + + - trigger: RS or fuzzy match on request-signoff + workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/request-signoff/workflow.yaml" + description: "[RS] Request stakeholder sign-off" + + - trigger: SO or fuzzy match on signoff + workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/submit-signoff/workflow.yaml" + description: "[SO] Submit your sign-off decision" + + # ═══════════════════════════════════════════════════════════════════════════ + # EPIC CROWDSOURCING - Story Breakdown Collaboration + # ═══════════════════════════════════════════════════════════════════════════ + + - trigger: CE or fuzzy match on create-epic + workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/create-epic-draft/workflow.yaml" + description: "[CE] Create epic from approved PRD" + + - trigger: OE or fuzzy match on open-epic-feedback + workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/open-epic-feedback/workflow.yaml" + description: "[OE] Open feedback round for epic" + + - trigger: SE or fuzzy match on synthesize-epic + workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/synthesize-epic-feedback/workflow.yaml" + description: "[SE] Synthesize epic feedback" + + - trigger: EDD or fuzzy match on epic-crowdsource-dashboard + workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/epic-dashboard/workflow.yaml" + description: "[EDD] Epic Dashboard - view all epics with PRD lineage" diff --git a/src/modules/bmm/agents/stakeholder.agent.yaml b/src/modules/bmm/agents/stakeholder.agent.yaml new file mode 100644 index 00000000..3c2ed838 --- /dev/null +++ b/src/modules/bmm/agents/stakeholder.agent.yaml @@ -0,0 +1,76 @@ +# Stakeholder Agent Definition +# Lightweight agent for team members providing feedback on PRDs/Epics + +agent: + metadata: + id: "_bmad/bmm/agents/stakeholder.md" + name: Reviewer + title: Stakeholder + icon: 👤 + module: bmm + hasSidecar: false + + persona: + role: Team member providing feedback on product requirements and signing off on documents. Focuses on your specific expertise area. + identity: Experienced team member with domain expertise. You review PRDs and Epics through the lens of your specialty (engineering, security, UX, QA, etc.). + communication_style: "Focused on your pending tasks and quick actions. Provides clear, actionable feedback." + principles: | + - Review documents through the lens of your expertise area + - Provide specific, actionable feedback with clear rationale + - Flag concerns early - blocking issues should be raised as soon as identified + - Sign off only when you're confident the document meets requirements + - Respect deadlines - timely feedback keeps the team moving + - Find and respect: `**/project-context.md` for architectural constraints + + menu: + # ═══════════════════════════════════════════════════════════════════════════ + # PRIMARY ACTIONS - What needs my attention + # ═══════════════════════════════════════════════════════════════════════════ + + - trigger: MT or fuzzy match on my-tasks + workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/my-tasks/workflow.yaml" + description: "[MT] My tasks - what PRDs/Epics need my input" + + # ═══════════════════════════════════════════════════════════════════════════ + # FEEDBACK ACTIONS + # ═══════════════════════════════════════════════════════════════════════════ + + - trigger: SF or fuzzy match on submit-feedback + workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/submit-feedback/workflow.yaml" + description: "[SF] Submit feedback on a PRD or Epic" + + - trigger: VF or fuzzy match on view-feedback + workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/view-feedback/workflow.yaml" + description: "[VF] View feedback on a PRD or Epic" + + # ═══════════════════════════════════════════════════════════════════════════ + # SIGN-OFF ACTIONS + # ═══════════════════════════════════════════════════════════════════════════ + + - trigger: SO or fuzzy match on signoff + workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/submit-signoff/workflow.yaml" + description: "[SO] Submit your sign-off decision on a document" + + # ═══════════════════════════════════════════════════════════════════════════ + # VISIBILITY - Read-only dashboards + # ═══════════════════════════════════════════════════════════════════════════ + + - trigger: PD or fuzzy match on prd-dashboard + workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/prd-dashboard/workflow.yaml" + description: "[PD] PRD Dashboard - view all PRDs and status" + + - trigger: ED or fuzzy match on epic-dashboard + workflow: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/epic-dashboard/workflow.yaml" + description: "[ED] Epic Dashboard - view all epics with PRD lineage" + + # ═══════════════════════════════════════════════════════════════════════════ + # STORY VISIBILITY (if also working on implementation) + # ═══════════════════════════════════════════════════════════════════════════ + + - trigger: AS or fuzzy match on available-stories + workflow: "{project-root}/_bmad/bmm/workflows/4-implementation/available-stories/workflow.yaml" + description: "[AS] View available (unlocked) stories" + + - trigger: LS or fuzzy match on lock-status + workflow: "{project-root}/_bmad/bmm/workflows/4-implementation/lock-status/workflow.yaml" + description: "[LS] View story lock status (who's working on what)" diff --git a/src/modules/bmm/data/README.md b/src/modules/bmm/data/README.md index 17408d05..831fc149 100644 --- a/src/modules/bmm/data/README.md +++ b/src/modules/bmm/data/README.md @@ -19,6 +19,24 @@ BMAD documentation standards and guidelines. Used by: - Various documentation workflows - Standards validation and review processes +### `github-integration-config.md` + +Configuration guide for enterprise GitHub integration. Documents: + +- Story locking and unlock workflows for team coordination +- Real-time progress sync between local cache and GitHub Issues +- PRD & Epic crowdsourcing for async stakeholder collaboration +- Notification channels (GitHub mentions, Slack webhooks, email) +- Sign-off configuration and threshold types +- Cache architecture and performance optimization + +Used by: + +- PO agent for backlog management +- Stakeholder agent for feedback and sign-off +- Developer workflows for story checkout/unlock +- All crowdsourcing workflows (`my-tasks`, `submit-feedback`, etc.) + ## Purpose Separates module-specific data from core workflow implementations, maintaining clean architecture: diff --git a/src/modules/bmm/data/github-integration-config.md b/src/modules/bmm/data/github-integration-config.md new file mode 100644 index 00000000..3beb1c1c --- /dev/null +++ b/src/modules/bmm/data/github-integration-config.md @@ -0,0 +1,313 @@ +# GitHub Integration Configuration Guide + +This document explains how to configure BMAD's enterprise GitHub integration for team coordination. + +## Overview + +The GitHub integration enables: +- **Story Locking** - Prevents duplicate work when multiple developers work in parallel +- **Real-time Progress Sync** - POs see task completion in GitHub Issues within seconds +- **Epic Context Pre-fetching** - Fast LLM access to related stories via local cache +- **PO Workflows** - Product Owners manage backlog via Claude Desktop + GitHub + +## Prerequisites + +1. **GitHub MCP Server** - Must be configured in Claude settings +2. **Repository Access** - Token with `repo` scope (read/write issues) +3. **Issues Enabled** - GitHub Issues must be enabled for the repository + +## Configuration + +### During Installation + +When running BMAD installation, you'll be prompted for GitHub integration settings: + +``` +Enable GitHub Integration for enterprise team coordination? [y/N] +> y + +GitHub username or organization name: +> myorg + +GitHub repository name: +> myproject + +Story lock timeout in hours (default: 8): +> 8 + +Minutes before cache is stale (default: 5): +> 5 + +Scrum Master usernames (comma-separated): +> alice-sm,bob-sm +``` + +### Manual Configuration + +Add to your `_bmad/bmm/config.yaml`: + +```yaml +# GitHub Integration for Enterprise Teams +github_integration_enabled: true + +github_owner: "myorg" # GitHub username or org +github_repo: "myproject" # Repository name + +github_lock_timeout_hours: 8 # Lock duration (workday) +github_cache_staleness_minutes: 5 + +github_scrum_masters: "alice-sm,bob-sm" # Can force-unlock +``` + +## How It Works + +### Three-Tier Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ TIER 1: GitHub Issues (Source of Truth) │ +│ - Centralized coordination │ +│ - Story assignment = lock │ +│ - Real-time status labels │ +└────────────┬────────────────────────────────────────────────┘ + │ Smart Sync (incremental) +┌────────────┴────────────────────────────────────────────────┐ +│ TIER 2: Local Cache (Performance) │ +│ - Fast LLM Read tool access (<100ms) │ +│ - Full 12-section story content │ +│ - Epic context pre-fetch │ +└────────────┬────────────────────────────────────────────────┘ + │ Committed after completion +┌────────────┴────────────────────────────────────────────────┐ +│ TIER 3: Git Repository (Audit Trail) │ +│ - Historical story files │ +│ - Implementation code │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Story Locking Flow + +1. **Developer A** runs `/checkout-story story_key=2-5-auth` +2. System assigns GitHub Issue to Developer A +3. System adds `status:in-progress` label +4. System creates local lock file with 8-hour timeout +5. System pre-fetches all Epic 2 stories to cache + +6. **Developer B** tries `/checkout-story story_key=2-5-auth` +7. System sees issue assigned to Developer A +8. Returns error: "Story locked by @developerA" + +### Progress Sync Flow + +1. Developer completes task 3 of 10 +2. Workflow marks task `[x]` in story file +3. Workflow updates sprint-status.yaml with progress +4. **NEW:** Workflow posts comment to GitHub Issue: + ``` + 📊 Task 3/10 complete (30%) + > Implement OAuth token refresh + + _Progress synced at 2026-01-08T15:30:00Z_ + ``` +5. PO sees progress in GitHub within seconds + +## Workflows + +### For Developers + +| Command | Description | +|---------|-------------| +| `/checkout-story story_key=X-Y-slug` | Lock story for development | +| `/unlock-story story_key=X-Y-slug` | Release lock when done/blocked | +| `/available-stories` | See unlocked stories | +| `/lock-status` | View who's working on what | + +### For Scrum Masters + +| Command | Description | +|---------|-------------| +| `/lock-status` | View all locks, identify stale ones | +| `/unlock-story story_key=X-Y-slug --force` | Force-unlock stale story | + +### For Product Owners + +| Command | Description | +|---------|-------------| +| `/new-story` | Create story in GitHub Issues | +| `/update-story` | Modify ACs | +| `/dashboard` | Sprint progress overview | +| `/approve-story` | Sign off completed work | + +### PRD Crowdsourcing (Async Requirements) + +| Command | Description | +|---------|-------------| +| `/my-tasks` | View PRDs/Epics needing your attention | +| `/prd-dashboard` | View all PRDs and their status | +| `/create-prd` | Create new PRD draft | +| `/open-feedback` | Open feedback round for PRD | +| `/submit-feedback` | Submit feedback on PRD/Epic | +| `/view-feedback` | View all feedback on PRD/Epic | +| `/synthesize` | LLM synthesizes feedback into new version | +| `/request-signoff` | Request stakeholder sign-off | +| `/signoff` | Submit your sign-off decision | + +### Epic Crowdsourcing (Story Breakdown) + +| Command | Description | +|---------|-------------| +| `/create-epic` | Create epic from approved PRD | +| `/open-epic-feedback` | Open feedback round for epic | +| `/synthesize-epic` | Synthesize epic feedback | +| `/epic-dashboard` | View epics with PRD lineage | + +## Cache Location + +Stories are cached in: `{output_folder}/cache/stories/` + +Cache metadata in: `{output_folder}/cache/.bmad-cache-meta.json` + +## Lock Files + +Local locks stored in: `.bmad/locks/{story_key}.lock` + +Lock file format: +```yaml +story_key: 2-5-auth +github_issue: 105 +locked_by: developer-username +locked_at: 2026-01-08T10:00:00Z +timeout_at: 2026-01-08T18:00:00Z +last_heartbeat: 2026-01-08T12:30:00Z +epic_number: 2 +``` + +## Troubleshooting + +### "GitHub MCP not accessible" + +1. Verify GitHub MCP is configured in Claude settings +2. Check token has `repo` scope +3. Test with: `mcp__github__get_me()` + +### "Story not found in GitHub" + +Run `/migrate-to-github` to sync local stories to GitHub Issues. + +### "Lock stolen" + +Another user was assigned in GitHub UI. Coordinate with your team. + +### Stale locks blocking sprint + +Scrum Master can force-unlock: +``` +/unlock-story story_key=2-5-auth --force reason="Developer unavailable" +``` + +## Performance + +| Operation | Without Cache | With Cache | Improvement | +|-----------|---------------|------------|-------------| +| Read story | 2-3 seconds | <100ms | 20-30x faster | +| Epic context | 16 seconds | 650ms | 25x faster | +| API calls/hour | 500+ | <50 | 90% reduction | + +## Security + +- Lock verification before each task prevents unauthorized changes +- GitHub assignment is source of truth (verified against) +- Scrum Master override requires explicit permission +- All operations use authenticated GitHub MCP + +## PRD & Epic Crowdsourcing + +GitHub Integration enables async stakeholder collaboration on requirements: + +### How It Works + +``` +┌─────────────────────────────────────────────────────────────┐ +│ CROWDSOURCE HIERARCHY │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 📄 PRD (Product Requirements) │ +│ └── Feedback on: vision, goals, FRs, NFRs │ +│ Sign-off: All key stakeholders │ +│ │ +│ 📦 EPIC (Feature Breakdown) │ +│ └── Feedback on: scope, story split, priorities │ +│ Sign-off: Tech Lead + PO + domain expert │ +│ │ +│ 📝 STORY (Implementation Detail) │ +│ └── Refinement: Developer adjusts during checkout │ +│ Major issues escalate to epic revision │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Storage Architecture + +PRD content lives in markdown files, coordination happens via GitHub Issues: + +| Artifact | Where It Lives | Can Close? | +|----------|---------------|------------| +| PRD Document | `docs/prd/{prd-key}.md` | Never (it's a doc) | +| Review Round | Issue: "PRD Review v2" | ✅ Yes, when round ends | +| Feedback Item | Issue: linked to review | ✅ Yes, when processed | +| Epic Document | `docs/epics/epic-{n}.md` | Never (it's a doc) | + +### Feedback Types + +| Type | Emoji | Description | +|------|-------|-------------| +| clarification | 📋 | Something is unclear | +| concern | ⚠️ | Potential issue/risk | +| suggestion | 💡 | Improvement idea | +| addition | ➕ | Missing requirement | +| priority | 🔢 | Disagree with prioritization | +| scope | 📐 | Epic scope concern | +| dependency | 🔗 | Blocking relationship | +| technical_risk | 🔧 | Architectural concern | +| story_split | ✂️ | Different breakdown suggested | + +### Sign-off Configuration + +PRDs support flexible sign-off thresholds: + +```yaml +signoff_config: + threshold_type: "required_approvers" # or "count" or "percentage" + required: ["@po", "@tech-lead", "@security"] + optional: ["@ux", "@qa"] + minimum_optional: 1 + block_threshold: 1 +``` + +### Notification Channels + +Configure notifications in `module.yaml`: + +```yaml +prd_notifications: + github_mentions: + enabled: true # Always on as baseline + + slack: + enabled: false + webhook_url: "" + channel: "#prd-updates" + + email: + enabled: false + smtp_config: "" +``` + +### Workflow Example + +1. **PO creates PRD** → Markdown file + Draft status +2. **Open feedback round** → Creates GitHub Issue, notifies stakeholders +3. **Stakeholders submit feedback** → Linked issues with labels +4. **PO synthesizes feedback** → LLM merges input, resolves conflicts +5. **Request sign-off** → Updates status, tracks approvals +6. **All signed off** → PRD approved, ready for epic breakdown diff --git a/src/modules/bmm/lib/cache/cache-manager.js b/src/modules/bmm/lib/cache/cache-manager.js new file mode 100644 index 00000000..bdc330a1 --- /dev/null +++ b/src/modules/bmm/lib/cache/cache-manager.js @@ -0,0 +1,918 @@ +/** + * BMAD Enterprise Cache Manager + * + * Provides fast local caching for BMAD stories with GitHub as source of truth. + * Enables instant LLM Read tool access (<100ms vs 2-3s API calls). + * + * Architecture: + * - GitHub Issues = source of truth (coordination) + * - Local cache = performance optimization + * - Git repository = audit trail + * + * @module cache-manager + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +/** + * Cache metadata structure stored in .bmad-cache-meta.json + * Tracks sync state, staleness, and lock information per story, PRD, and Epic + */ +const CACHE_META_FILENAME = '.bmad-cache-meta.json'; +const DEFAULT_STALENESS_THRESHOLD_MINUTES = 5; + +/** + * Document types supported by the cache + */ +const DOCUMENT_TYPES = { + story: 'story', + prd: 'prd', + epic: 'epic' +}; + +/** + * CacheManager class - handles local story caching with GitHub sync + */ +class CacheManager { + /** + * @param {Object} config - Configuration object + * @param {string} config.cacheDir - Directory for cached story files + * @param {number} config.stalenessThresholdMinutes - Minutes before cache is considered stale + * @param {Object} config.github - GitHub configuration (owner, repo) + */ + constructor(config) { + this.cacheDir = config.cacheDir; + this.stalenessThresholdMinutes = config.stalenessThresholdMinutes || DEFAULT_STALENESS_THRESHOLD_MINUTES; + this.github = config.github || {}; + this.metaPath = path.join(this.cacheDir, CACHE_META_FILENAME); + + // Ensure cache directory exists + this._ensureCacheDir(); + } + + /** + * Ensure cache directory exists + * @private + */ + _ensureCacheDir() { + if (!fs.existsSync(this.cacheDir)) { + fs.mkdirSync(this.cacheDir, { recursive: true }); + } + + // Create subdirectories for each document type + const subdirs = ['stories', 'prds', 'epics']; + for (const subdir of subdirs) { + const dirPath = path.join(this.cacheDir, subdir); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + } + } + + /** + * Load cache metadata + * @returns {Object} Cache metadata object + */ + loadMeta() { + if (!fs.existsSync(this.metaPath)) { + return this._initializeMeta(); + } + + try { + const content = fs.readFileSync(this.metaPath, 'utf8'); + const meta = JSON.parse(content); + return this._migrateMeta(meta); + } catch (error) { + console.error(`Warning: Failed to parse cache meta, reinitializing: ${error.message}`); + return this._initializeMeta(); + } + } + + /** + * Initialize empty cache metadata + * @private + * @returns {Object} Fresh metadata object + */ + _initializeMeta() { + const meta = { + version: '2.0.0', + last_sync: null, + github_owner: this.github.owner || null, + github_repo: this.github.repo || null, + stories: {}, + prds: {}, + epics: {} + }; + this.saveMeta(meta); + return meta; + } + + /** + * Migrate metadata from v1 to v2 if needed + * @private + * @param {Object} meta - Metadata object to migrate + * @returns {Object} Migrated metadata + */ + _migrateMeta(meta) { + // If already v2 or higher, no migration needed + if (meta.version && meta.version.startsWith('2.')) { + return meta; + } + + // Add PRD and Epic sections if missing + if (!meta.prds) { + meta.prds = {}; + } + if (!meta.epics) { + meta.epics = {}; + } + meta.version = '2.0.0'; + + this.saveMeta(meta); + return meta; + } + + /** + * Save cache metadata atomically + * @param {Object} meta - Metadata object to save + */ + saveMeta(meta) { + const tempPath = `${this.metaPath}.tmp`; + + // Atomic write: write to temp file, then rename + fs.writeFileSync(tempPath, JSON.stringify(meta, null, 2), 'utf8'); + fs.renameSync(tempPath, this.metaPath); + } + + /** + * Get path for a cached story file + * @param {string} storyKey - Story identifier (e.g., "2-5-auth") + * @returns {string} Full path to cached story file + */ + getStoryPath(storyKey) { + return path.join(this.cacheDir, 'stories', `${storyKey}.md`); + } + + /** + * Read story from cache with staleness check + * @param {string} storyKey - Story identifier + * @param {Object} options - Options + * @param {boolean} options.ignoreStale - Return content even if stale + * @returns {Object|null} { content, meta, isStale } or null if not cached + */ + readStory(storyKey, options = {}) { + const storyPath = this.getStoryPath(storyKey); + const meta = this.loadMeta(); + const storyMeta = meta.stories[storyKey]; + + if (!fs.existsSync(storyPath)) { + return null; + } + + const content = fs.readFileSync(storyPath, 'utf8'); + const isStale = this.isStale(storyKey); + + if (isStale && !options.ignoreStale) { + return { + content, + meta: storyMeta, + isStale: true, + warning: `Story cache is stale (>${this.stalenessThresholdMinutes} min old). Sync recommended.` + }; + } + + return { + content, + meta: storyMeta, + isStale + }; + } + + /** + * Write story to cache with metadata update + * @param {string} storyKey - Story identifier + * @param {string} content - Story file content + * @param {Object} storyMeta - Metadata from GitHub issue + * @param {number} storyMeta.github_issue - Issue number + * @param {string} storyMeta.github_updated_at - Last update timestamp from GitHub + * @param {string} storyMeta.locked_by - Username who has the story locked (optional) + * @param {string} storyMeta.locked_until - Lock expiration timestamp (optional) + */ + writeStory(storyKey, content, storyMeta = {}) { + const storyPath = this.getStoryPath(storyKey); + const tempPath = `${storyPath}.tmp`; + + // Calculate content hash for change detection + const contentHash = crypto.createHash('sha256').update(content).digest('hex'); + + // Atomic write + fs.writeFileSync(tempPath, content, 'utf8'); + fs.renameSync(tempPath, storyPath); + + // Update metadata + const meta = this.loadMeta(); + meta.stories[storyKey] = { + github_issue: storyMeta.github_issue || meta.stories[storyKey]?.github_issue, + github_updated_at: storyMeta.github_updated_at || new Date().toISOString(), + cache_timestamp: new Date().toISOString(), + local_hash: contentHash, + locked_by: storyMeta.locked_by || null, + locked_until: storyMeta.locked_until || null + }; + + this.saveMeta(meta); + + return { + storyKey, + path: storyPath, + hash: contentHash, + timestamp: meta.stories[storyKey].cache_timestamp + }; + } + + /** + * Invalidate cache for a story (force refresh on next access) + * @param {string} storyKey - Story identifier + */ + invalidate(storyKey) { + const meta = this.loadMeta(); + + if (meta.stories[storyKey]) { + // Mark as stale by setting old timestamp + meta.stories[storyKey].cache_timestamp = '1970-01-01T00:00:00Z'; + this.saveMeta(meta); + } + } + + /** + * Invalidate all cached stories + */ + invalidateAll() { + const meta = this.loadMeta(); + + for (const storyKey of Object.keys(meta.stories)) { + meta.stories[storyKey].cache_timestamp = '1970-01-01T00:00:00Z'; + } + + meta.last_sync = null; + this.saveMeta(meta); + } + + /** + * Check if a story's cache is stale + * @param {string} storyKey - Story identifier + * @returns {boolean} True if cache is stale or missing + */ + isStale(storyKey) { + const meta = this.loadMeta(); + const storyMeta = meta.stories[storyKey]; + + if (!storyMeta || !storyMeta.cache_timestamp) { + return true; + } + + const cacheTime = new Date(storyMeta.cache_timestamp); + const now = new Date(); + const ageMinutes = (now - cacheTime) / (1000 * 60); + + return ageMinutes > this.stalenessThresholdMinutes; + } + + /** + * Get cache age in minutes + * @param {string} storyKey - Story identifier + * @returns {number|null} Age in minutes or null if not cached + */ + getCacheAge(storyKey) { + const meta = this.loadMeta(); + const storyMeta = meta.stories[storyKey]; + + if (!storyMeta || !storyMeta.cache_timestamp) { + return null; + } + + const cacheTime = new Date(storyMeta.cache_timestamp); + const now = new Date(); + return Math.floor((now - cacheTime) / (1000 * 60)); + } + + /** + * Get last global sync timestamp + * @returns {string|null} ISO timestamp of last sync + */ + getLastSync() { + const meta = this.loadMeta(); + return meta.last_sync; + } + + /** + * Update last sync timestamp + * @param {string} timestamp - ISO timestamp (defaults to now) + */ + updateLastSync(timestamp = new Date().toISOString()) { + const meta = this.loadMeta(); + meta.last_sync = timestamp; + this.saveMeta(meta); + } + + /** + * Get all cached story keys + * @returns {string[]} Array of story keys + */ + listCachedStories() { + const meta = this.loadMeta(); + return Object.keys(meta.stories); + } + + /** + * Get stories that need refresh (stale or missing) + * @returns {string[]} Array of stale story keys + */ + getStaleStories() { + const meta = this.loadMeta(); + return Object.keys(meta.stories).filter(key => this.isStale(key)); + } + + /** + * Update lock information for a story + * @param {string} storyKey - Story identifier + * @param {Object} lockInfo - Lock information + * @param {string} lockInfo.locked_by - Username + * @param {string} lockInfo.locked_until - Expiration timestamp + */ + updateLock(storyKey, lockInfo) { + const meta = this.loadMeta(); + + if (!meta.stories[storyKey]) { + meta.stories[storyKey] = {}; + } + + meta.stories[storyKey].locked_by = lockInfo.locked_by; + meta.stories[storyKey].locked_until = lockInfo.locked_until; + + this.saveMeta(meta); + } + + /** + * Clear lock information for a story + * @param {string} storyKey - Story identifier + */ + clearLock(storyKey) { + const meta = this.loadMeta(); + + if (meta.stories[storyKey]) { + meta.stories[storyKey].locked_by = null; + meta.stories[storyKey].locked_until = null; + this.saveMeta(meta); + } + } + + /** + * Get lock status for a story + * @param {string} storyKey - Story identifier + * @returns {Object|null} Lock info or null if not locked + */ + getLockStatus(storyKey) { + const meta = this.loadMeta(); + const storyMeta = meta.stories[storyKey]; + + if (!storyMeta || !storyMeta.locked_by) { + return null; + } + + // Check if lock expired + if (storyMeta.locked_until) { + const expiry = new Date(storyMeta.locked_until); + if (expiry < new Date()) { + return { expired: true, previously_locked_by: storyMeta.locked_by }; + } + } + + return { + locked_by: storyMeta.locked_by, + locked_until: storyMeta.locked_until, + expired: false + }; + } + + /** + * Get all locked stories + * @returns {Object[]} Array of { storyKey, locked_by, locked_until } + */ + getLockedStories() { + const meta = this.loadMeta(); + const locked = []; + + for (const [storyKey, storyMeta] of Object.entries(meta.stories)) { + if (storyMeta.locked_by) { + const expiry = storyMeta.locked_until ? new Date(storyMeta.locked_until) : null; + const expired = expiry && expiry < new Date(); + + locked.push({ + storyKey, + locked_by: storyMeta.locked_by, + locked_until: storyMeta.locked_until, + expired + }); + } + } + + return locked; + } + + /** + * Check if content has changed from cached version + * @param {string} storyKey - Story identifier + * @param {string} newContent - New content to compare + * @returns {boolean} True if content differs + */ + hasContentChanged(storyKey, newContent) { + const meta = this.loadMeta(); + const storyMeta = meta.stories[storyKey]; + + if (!storyMeta || !storyMeta.local_hash) { + return true; + } + + const newHash = crypto.createHash('sha256').update(newContent).digest('hex'); + return newHash !== storyMeta.local_hash; + } + + /** + * Delete a story from cache + * @param {string} storyKey - Story identifier + */ + deleteStory(storyKey) { + const storyPath = this.getStoryPath(storyKey); + + if (fs.existsSync(storyPath)) { + fs.unlinkSync(storyPath); + } + + const meta = this.loadMeta(); + delete meta.stories[storyKey]; + this.saveMeta(meta); + } + + /** + * Get cache statistics + * @returns {Object} Cache statistics + */ + getStats() { + const meta = this.loadMeta(); + const storyCount = Object.keys(meta.stories).length; + const staleCount = this.getStaleStories().length; + const lockedCount = this.getLockedStories().filter(s => !s.expired).length; + + let totalSize = 0; + const storiesDir = path.join(this.cacheDir, 'stories'); + + if (fs.existsSync(storiesDir)) { + const files = fs.readdirSync(storiesDir); + for (const file of files) { + const stats = fs.statSync(path.join(storiesDir, file)); + totalSize += stats.size; + } + } + + return { + story_count: storyCount, + stale_count: staleCount, + locked_count: lockedCount, + fresh_count: storyCount - staleCount, + total_size_bytes: totalSize, + total_size_kb: Math.round(totalSize / 1024), + last_sync: meta.last_sync, + staleness_threshold_minutes: this.stalenessThresholdMinutes + }; + } + + // ============ PRD Methods ============ + + /** + * Get path for a cached PRD file + * @param {string} prdKey - PRD identifier (e.g., "user-auth") + * @returns {string} Full path to cached PRD file + */ + getPrdPath(prdKey) { + return path.join(this.cacheDir, 'prds', `${prdKey}.md`); + } + + /** + * Read PRD from cache with staleness check + * @param {string} prdKey - PRD identifier + * @param {Object} options - Options + * @param {boolean} options.ignoreStale - Return content even if stale + * @returns {Object|null} { content, meta, isStale } or null if not cached + */ + readPrd(prdKey, options = {}) { + const prdPath = this.getPrdPath(prdKey); + const meta = this.loadMeta(); + const prdMeta = meta.prds[prdKey]; + + if (!fs.existsSync(prdPath)) { + return null; + } + + const content = fs.readFileSync(prdPath, 'utf8'); + const isStale = this._isDocumentStale(prdMeta); + + if (isStale && !options.ignoreStale) { + return { + content, + meta: prdMeta, + isStale: true, + warning: `PRD cache is stale (>${this.stalenessThresholdMinutes} min old). Sync recommended.` + }; + } + + return { content, meta: prdMeta, isStale }; + } + + /** + * Write PRD to cache with metadata update + * @param {string} prdKey - PRD identifier + * @param {string} content - PRD markdown content + * @param {Object} prdMeta - Metadata + * @param {number} prdMeta.review_issue - Review round issue number + * @param {number} prdMeta.version - PRD version number + * @param {string} prdMeta.status - PRD status (draft, feedback, synthesis, signoff, approved) + * @param {string[]} prdMeta.stakeholders - Array of stakeholder usernames + */ + writePrd(prdKey, content, prdMeta = {}) { + const prdPath = this.getPrdPath(prdKey); + const tempPath = `${prdPath}.tmp`; + + const contentHash = crypto.createHash('sha256').update(content).digest('hex'); + + // Atomic write + fs.writeFileSync(tempPath, content, 'utf8'); + fs.renameSync(tempPath, prdPath); + + // Update metadata + const meta = this.loadMeta(); + meta.prds[prdKey] = { + review_issue: prdMeta.review_issue || meta.prds[prdKey]?.review_issue, + version: prdMeta.version || meta.prds[prdKey]?.version || 1, + status: prdMeta.status || meta.prds[prdKey]?.status || 'draft', + stakeholders: prdMeta.stakeholders || meta.prds[prdKey]?.stakeholders || [], + owner: prdMeta.owner || meta.prds[prdKey]?.owner, + feedback_deadline: prdMeta.feedback_deadline || meta.prds[prdKey]?.feedback_deadline, + signoff_deadline: prdMeta.signoff_deadline || meta.prds[prdKey]?.signoff_deadline, + cache_timestamp: new Date().toISOString(), + local_hash: contentHash + }; + + this.saveMeta(meta); + + return { + prdKey, + path: prdPath, + hash: contentHash, + timestamp: meta.prds[prdKey].cache_timestamp + }; + } + + /** + * Update PRD status + * @param {string} prdKey - PRD identifier + * @param {string} status - New status + */ + updatePrdStatus(prdKey, status) { + const meta = this.loadMeta(); + + if (!meta.prds[prdKey]) { + throw new Error(`PRD not found in cache: ${prdKey}`); + } + + meta.prds[prdKey].status = status; + meta.prds[prdKey].cache_timestamp = new Date().toISOString(); + this.saveMeta(meta); + } + + /** + * Get all cached PRD keys + * @returns {string[]} Array of PRD keys + */ + listCachedPrds() { + const meta = this.loadMeta(); + return Object.keys(meta.prds); + } + + /** + * Get PRDs by status + * @param {string} status - Filter by status (draft, feedback, synthesis, signoff, approved) + * @returns {Object[]} Array of { prdKey, meta } + */ + getPrdsByStatus(status) { + const meta = this.loadMeta(); + return Object.entries(meta.prds) + .filter(([_, prdMeta]) => prdMeta.status === status) + .map(([prdKey, prdMeta]) => ({ prdKey, meta: prdMeta })); + } + + /** + * Get PRDs needing attention from a user + * @param {string} username - GitHub username + * @returns {Object} { pendingFeedback: [], pendingSignoff: [] } + */ + getPrdsNeedingAttention(username) { + const meta = this.loadMeta(); + const normalizedUser = username.replace('@', ''); + + const pendingFeedback = []; + const pendingSignoff = []; + + for (const [prdKey, prdMeta] of Object.entries(meta.prds)) { + const isStakeholder = prdMeta.stakeholders?.some(s => + s.replace('@', '') === normalizedUser + ); + + if (!isStakeholder) continue; + + if (prdMeta.status === 'feedback') { + pendingFeedback.push({ prdKey, meta: prdMeta }); + } else if (prdMeta.status === 'signoff') { + pendingSignoff.push({ prdKey, meta: prdMeta }); + } + } + + return { pendingFeedback, pendingSignoff }; + } + + /** + * Delete a PRD from cache + * @param {string} prdKey - PRD identifier + */ + deletePrd(prdKey) { + const prdPath = this.getPrdPath(prdKey); + + if (fs.existsSync(prdPath)) { + fs.unlinkSync(prdPath); + } + + const meta = this.loadMeta(); + delete meta.prds[prdKey]; + this.saveMeta(meta); + } + + // ============ Epic Methods ============ + + /** + * Get path for a cached Epic file + * @param {string} epicKey - Epic identifier (e.g., "2") + * @returns {string} Full path to cached Epic file + */ + getEpicPath(epicKey) { + return path.join(this.cacheDir, 'epics', `epic-${epicKey}.md`); + } + + /** + * Read Epic from cache with staleness check + * @param {string} epicKey - Epic identifier + * @param {Object} options - Options + * @param {boolean} options.ignoreStale - Return content even if stale + * @returns {Object|null} { content, meta, isStale } or null if not cached + */ + readEpic(epicKey, options = {}) { + const epicPath = this.getEpicPath(epicKey); + const meta = this.loadMeta(); + const epicMeta = meta.epics[epicKey]; + + if (!fs.existsSync(epicPath)) { + return null; + } + + const content = fs.readFileSync(epicPath, 'utf8'); + const isStale = this._isDocumentStale(epicMeta); + + if (isStale && !options.ignoreStale) { + return { + content, + meta: epicMeta, + isStale: true, + warning: `Epic cache is stale (>${this.stalenessThresholdMinutes} min old). Sync recommended.` + }; + } + + return { content, meta: epicMeta, isStale }; + } + + /** + * Write Epic to cache with metadata update + * @param {string} epicKey - Epic identifier + * @param {string} content - Epic markdown content + * @param {Object} epicMeta - Metadata + * @param {number} epicMeta.github_issue - Epic GitHub issue number + * @param {string} epicMeta.prd_key - Source PRD key (lineage) + * @param {number} epicMeta.version - Epic version number + * @param {string} epicMeta.status - Epic status + * @param {string[]} epicMeta.stories - Array of story keys in this epic + */ + writeEpic(epicKey, content, epicMeta = {}) { + const epicPath = this.getEpicPath(epicKey); + const tempPath = `${epicPath}.tmp`; + + const contentHash = crypto.createHash('sha256').update(content).digest('hex'); + + // Atomic write + fs.writeFileSync(tempPath, content, 'utf8'); + fs.renameSync(tempPath, epicPath); + + // Update metadata + const meta = this.loadMeta(); + meta.epics[epicKey] = { + github_issue: epicMeta.github_issue || meta.epics[epicKey]?.github_issue, + prd_key: epicMeta.prd_key || meta.epics[epicKey]?.prd_key, + version: epicMeta.version || meta.epics[epicKey]?.version || 1, + status: epicMeta.status || meta.epics[epicKey]?.status || 'draft', + stories: epicMeta.stories || meta.epics[epicKey]?.stories || [], + review_issue: epicMeta.review_issue || meta.epics[epicKey]?.review_issue, + stakeholders: epicMeta.stakeholders || meta.epics[epicKey]?.stakeholders || [], + feedback_deadline: epicMeta.feedback_deadline || meta.epics[epicKey]?.feedback_deadline, + cache_timestamp: new Date().toISOString(), + local_hash: contentHash + }; + + this.saveMeta(meta); + + return { + epicKey, + path: epicPath, + hash: contentHash, + timestamp: meta.epics[epicKey].cache_timestamp + }; + } + + /** + * Update Epic status + * @param {string} epicKey - Epic identifier + * @param {string} status - New status + */ + updateEpicStatus(epicKey, status) { + const meta = this.loadMeta(); + + if (!meta.epics[epicKey]) { + throw new Error(`Epic not found in cache: ${epicKey}`); + } + + meta.epics[epicKey].status = status; + meta.epics[epicKey].cache_timestamp = new Date().toISOString(); + this.saveMeta(meta); + } + + /** + * Get all cached Epic keys + * @returns {string[]} Array of Epic keys + */ + listCachedEpics() { + const meta = this.loadMeta(); + return Object.keys(meta.epics); + } + + /** + * Get Epics by PRD (lineage tracking) + * @param {string} prdKey - PRD key to filter by + * @returns {Object[]} Array of { epicKey, meta } + */ + getEpicsByPrd(prdKey) { + const meta = this.loadMeta(); + return Object.entries(meta.epics) + .filter(([_, epicMeta]) => epicMeta.prd_key === prdKey) + .map(([epicKey, epicMeta]) => ({ epicKey, meta: epicMeta })); + } + + /** + * Get Epics needing attention from a user + * @param {string} username - GitHub username + * @returns {Object} { pendingFeedback: [] } + */ + getEpicsNeedingAttention(username) { + const meta = this.loadMeta(); + const normalizedUser = username.replace('@', ''); + + const pendingFeedback = []; + + for (const [epicKey, epicMeta] of Object.entries(meta.epics)) { + const isStakeholder = epicMeta.stakeholders?.some(s => + s.replace('@', '') === normalizedUser + ); + + if (!isStakeholder) continue; + + if (epicMeta.status === 'feedback') { + pendingFeedback.push({ epicKey, meta: epicMeta }); + } + } + + return { pendingFeedback }; + } + + /** + * Delete an Epic from cache + * @param {string} epicKey - Epic identifier + */ + deleteEpic(epicKey) { + const epicPath = this.getEpicPath(epicKey); + + if (fs.existsSync(epicPath)) { + fs.unlinkSync(epicPath); + } + + const meta = this.loadMeta(); + delete meta.epics[epicKey]; + this.saveMeta(meta); + } + + // ============ Generic Document Methods ============ + + /** + * Check if a document's cache is stale + * @private + * @param {Object} docMeta - Document metadata + * @returns {boolean} True if stale or missing + */ + _isDocumentStale(docMeta) { + if (!docMeta || !docMeta.cache_timestamp) { + return true; + } + + const cacheTime = new Date(docMeta.cache_timestamp); + const now = new Date(); + const ageMinutes = (now - cacheTime) / (1000 * 60); + + return ageMinutes > this.stalenessThresholdMinutes; + } + + /** + * Get unified "my tasks" for a user across PRDs and Epics + * @param {string} username - GitHub username + * @returns {Object} { prds: { pendingFeedback, pendingSignoff }, epics: { pendingFeedback } } + */ + getMyTasks(username) { + return { + prds: this.getPrdsNeedingAttention(username), + epics: this.getEpicsNeedingAttention(username) + }; + } + + /** + * Get extended cache statistics including PRDs and Epics + * @returns {Object} Extended cache statistics + */ + getExtendedStats() { + const meta = this.loadMeta(); + const baseStats = this.getStats(); + + // Calculate PRD stats + const prdCount = Object.keys(meta.prds).length; + const prdsByStatus = {}; + for (const prdMeta of Object.values(meta.prds)) { + prdsByStatus[prdMeta.status] = (prdsByStatus[prdMeta.status] || 0) + 1; + } + + // Calculate Epic stats + const epicCount = Object.keys(meta.epics).length; + const epicsByStatus = {}; + for (const epicMeta of Object.values(meta.epics)) { + epicsByStatus[epicMeta.status] = (epicsByStatus[epicMeta.status] || 0) + 1; + } + + // Calculate total size including PRDs and Epics + let prdSize = 0; + let epicSize = 0; + + const prdsDir = path.join(this.cacheDir, 'prds'); + if (fs.existsSync(prdsDir)) { + const files = fs.readdirSync(prdsDir); + for (const file of files) { + const stats = fs.statSync(path.join(prdsDir, file)); + prdSize += stats.size; + } + } + + const epicsDir = path.join(this.cacheDir, 'epics'); + if (fs.existsSync(epicsDir)) { + const files = fs.readdirSync(epicsDir); + for (const file of files) { + const stats = fs.statSync(path.join(epicsDir, file)); + epicSize += stats.size; + } + } + + return { + ...baseStats, + prd_count: prdCount, + prds_by_status: prdsByStatus, + prd_size_kb: Math.round(prdSize / 1024), + epic_count: epicCount, + epics_by_status: epicsByStatus, + epic_size_kb: Math.round(epicSize / 1024), + total_size_kb: baseStats.total_size_kb + Math.round(prdSize / 1024) + Math.round(epicSize / 1024) + }; + } +} + +module.exports = { CacheManager, CACHE_META_FILENAME, DOCUMENT_TYPES }; diff --git a/src/modules/bmm/lib/cache/index.js b/src/modules/bmm/lib/cache/index.js new file mode 100644 index 00000000..2b44528f --- /dev/null +++ b/src/modules/bmm/lib/cache/index.js @@ -0,0 +1,44 @@ +/** + * BMAD Enterprise Cache System + * + * Provides fast local caching for BMAD stories with GitHub as source of truth. + * + * Usage: + * ```javascript + * const { CacheManager, SyncEngine } = require('./lib/cache'); + * + * const cache = new CacheManager({ + * cacheDir: '/path/to/cache', + * stalenessThresholdMinutes: 5, + * github: { owner: 'myorg', repo: 'myrepo' } + * }); + * + * const sync = new SyncEngine({ + * cacheManager: cache, + * github: { owner: 'myorg', repo: 'myrepo' }, + * githubClient: async (method, params) => { ... } + * }); + * + * // Incremental sync + * await sync.incrementalSync(); + * + * // Read story from cache + * const story = cache.readStory('2-5-auth'); + * + * // Pre-fetch epic context + * await sync.preFetchEpic(2); + * ``` + * + * @module cache + */ + +const { CacheManager, CACHE_META_FILENAME } = require('./cache-manager'); +const { SyncEngine, RETRY_BACKOFF_MS, MAX_RETRIES } = require('./sync-engine'); + +module.exports = { + CacheManager, + SyncEngine, + CACHE_META_FILENAME, + RETRY_BACKOFF_MS, + MAX_RETRIES +}; diff --git a/src/modules/bmm/lib/cache/sync-engine.js b/src/modules/bmm/lib/cache/sync-engine.js new file mode 100644 index 00000000..4ad38c97 --- /dev/null +++ b/src/modules/bmm/lib/cache/sync-engine.js @@ -0,0 +1,659 @@ +/** + * BMAD Enterprise Sync Engine + * + * Handles synchronization between GitHub Issues (source of truth) + * and local cache (performance optimization). + * + * Features: + * - Incremental sync (only fetch changed stories) + * - Epic pre-fetch (batch load for context) + * - Retry with exponential backoff + * - Write verification + * + * @module sync-engine + */ + +const { CacheManager } = require('./cache-manager'); + +/** + * Retry configuration matching migrate-to-github patterns + */ +const RETRY_BACKOFF_MS = [1000, 3000, 9000]; // 1s, 3s, 9s +const MAX_RETRIES = 3; + +/** + * SyncEngine class - handles GitHub <-> Cache synchronization + */ +class SyncEngine { + /** + * @param {Object} config - Configuration object + * @param {CacheManager} config.cacheManager - Cache manager instance + * @param {Object} config.github - GitHub configuration + * @param {string} config.github.owner - Repository owner + * @param {string} config.github.repo - Repository name + * @param {Function} config.githubClient - GitHub MCP client function + */ + constructor(config) { + this.cache = config.cacheManager; + this.github = config.github; + this.githubClient = config.githubClient; + this.syncInProgress = false; + } + + /** + * Sleep utility for retry backoff + * @private + */ + async _sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + /** + * Retry operation with exponential backoff + * @private + */ + async _retryWithBackoff(operation, operationName = 'operation') { + let lastError; + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + return await operation(); + } catch (error) { + lastError = error; + + if (attempt < MAX_RETRIES) { + const backoffMs = RETRY_BACKOFF_MS[attempt]; + console.log(`⚠️ ${operationName} failed, retry ${attempt + 1}/${MAX_RETRIES} in ${backoffMs}ms: ${error.message}`); + await this._sleep(backoffMs); + } + } + } + + throw new Error(`${operationName} failed after ${MAX_RETRIES} retries: ${lastError.message}`); + } + + /** + * Parse story key from GitHub issue + * @private + */ + _extractStoryKey(issue) { + // Look for story: label first + const storyLabel = issue.labels?.find(l => + (typeof l === 'string' ? l : l.name)?.startsWith('story:') + ); + + if (storyLabel) { + const labelName = typeof storyLabel === 'string' ? storyLabel : storyLabel.name; + return labelName.replace('story:', ''); + } + + // Fallback: extract from title "Story X-Y-name: ..." + const titleMatch = issue.title?.match(/Story\s+(\d+-\d+-[a-zA-Z0-9-]+)/i); + if (titleMatch) { + return titleMatch[1]; + } + + return null; + } + + /** + * Convert GitHub issue to story file content + * @private + */ + _convertIssueToStoryContent(issue) { + const storyKey = this._extractStoryKey(issue); + const lines = []; + + // Extract sections from issue body + lines.push(`# Story ${storyKey}: ${issue.title.replace(/Story\s+[\d-]+[a-zA-Z-]+:\s*/i, '')}`); + lines.push(''); + lines.push(`**GitHub Issue:** #${issue.number}`); + lines.push(`**Status:** ${this._extractStatus(issue)}`); + lines.push(`**Assignee:** ${issue.assignee?.login || 'Unassigned'}`); + lines.push(`**Last Updated:** ${issue.updated_at}`); + lines.push(''); + + // Include original body + if (issue.body) { + lines.push(issue.body); + } + + lines.push(''); + lines.push('---'); + lines.push(`_Synced from GitHub at ${new Date().toISOString()}_`); + + return lines.join('\n'); + } + + /** + * Extract status from issue labels + * @private + */ + _extractStatus(issue) { + const statusLabel = issue.labels?.find(l => { + const name = typeof l === 'string' ? l : l.name; + return name?.startsWith('status:'); + }); + + if (statusLabel) { + const name = typeof statusLabel === 'string' ? statusLabel : statusLabel.name; + return name.replace('status:', ''); + } + + return issue.state === 'closed' ? 'done' : 'backlog'; + } + + /** + * Incremental sync - fetch only stories changed since last sync + * This is the primary sync method, called every 5 minutes or on-demand + * + * @param {Object} options - Sync options + * @param {boolean} options.force - Force full sync even if cache is fresh + * @returns {Object} Sync result { updated: [], unchanged: [], errors: [] } + */ + async incrementalSync(options = {}) { + if (this.syncInProgress) { + console.log('⏳ Sync already in progress, skipping...'); + return { skipped: true, reason: 'sync_in_progress' }; + } + + this.syncInProgress = true; + const result = { updated: [], unchanged: [], errors: [], startTime: new Date() }; + + try { + const lastSync = options.force ? null : this.cache.getLastSync(); + + console.log(`🔄 Starting incremental sync...`); + console.log(` Last sync: ${lastSync || 'never'}`); + + // Build search query for changed stories + let query = `repo:${this.github.owner}/${this.github.repo} label:type:story`; + + if (lastSync) { + // Only fetch stories updated since last sync + const since = new Date(lastSync).toISOString().split('T')[0]; + query += ` updated:>=${since}`; + } + + // Search for updated stories (single API call) + const searchResult = await this._retryWithBackoff( + async () => this.githubClient('search_issues', { query }), + 'Search for updated stories' + ); + + const issues = searchResult.items || []; + console.log(` Found ${issues.length} stories to sync`); + + // Sync each updated story + for (const issue of issues) { + const storyKey = this._extractStoryKey(issue); + + if (!storyKey) { + console.log(` ⚠️ Skipping issue #${issue.number} - no story key found`); + result.errors.push({ issue: issue.number, error: 'No story key' }); + continue; + } + + try { + await this.syncStory(storyKey, issue); + result.updated.push(storyKey); + } catch (error) { + console.log(` ❌ Failed to sync ${storyKey}: ${error.message}`); + result.errors.push({ storyKey, error: error.message }); + } + } + + // Update last sync timestamp + this.cache.updateLastSync(); + + result.endTime = new Date(); + result.duration = result.endTime - result.startTime; + + console.log(`✅ Sync complete: ${result.updated.length} updated, ${result.errors.length} errors`); + + return result; + + } finally { + this.syncInProgress = false; + } + } + + /** + * Full sync - fetch all stories (initial cache population) + * + * @returns {Object} Sync result + */ + async fullSync() { + console.log('🔄 Starting full sync (initial cache population)...'); + + // Invalidate all and force sync + this.cache.invalidateAll(); + return this.incrementalSync({ force: true }); + } + + /** + * Sync a single story from GitHub to cache + * + * @param {string} storyKey - Story identifier + * @param {Object} issue - Optional pre-fetched issue object + * @returns {Object} Sync result + */ + async syncStory(storyKey, issue = null) { + // Fetch issue if not provided + if (!issue) { + const searchResult = await this._retryWithBackoff( + async () => this.githubClient('search_issues', { + query: `repo:${this.github.owner}/${this.github.repo} label:story:${storyKey}` + }), + `Fetch story ${storyKey}` + ); + + const issues = searchResult.items || []; + if (issues.length === 0) { + throw new Error(`Story ${storyKey} not found in GitHub`); + } + + issue = issues[0]; + } + + // Convert to story content + const content = this._convertIssueToStoryContent(issue); + + // Check if content actually changed + if (!this.cache.hasContentChanged(storyKey, content)) { + console.log(` ⏭️ ${storyKey} unchanged`); + return { storyKey, status: 'unchanged' }; + } + + // Write to cache + const writeResult = this.cache.writeStory(storyKey, content, { + github_issue: issue.number, + github_updated_at: issue.updated_at, + locked_by: issue.assignee?.login || null, + locked_until: issue.assignee ? this._calculateLockExpiry() : null + }); + + console.log(` ✅ ${storyKey} synced (Issue #${issue.number})`); + + return { storyKey, status: 'updated', issue: issue.number }; + } + + /** + * Calculate lock expiry (8 hours from now) + * @private + */ + _calculateLockExpiry() { + const expiry = new Date(); + expiry.setHours(expiry.getHours() + 8); + return expiry.toISOString(); + } + + /** + * Pre-fetch all stories in an epic (batch operation for context) + * Called on story checkout to give LLM full epic context + * + * @param {string|number} epicNumber - Epic number to pre-fetch + * @returns {Object} Pre-fetch result + */ + async preFetchEpic(epicNumber) { + console.log(`📦 Pre-fetching Epic ${epicNumber} for context...`); + + const result = { epicNumber, stories: [], errors: [] }; + + // Single API call for all stories in epic + const searchResult = await this._retryWithBackoff( + async () => this.githubClient('search_issues', { + query: `repo:${this.github.owner}/${this.github.repo} label:epic:${epicNumber} label:type:story` + }), + `Pre-fetch Epic ${epicNumber}` + ); + + const issues = searchResult.items || []; + console.log(` Found ${issues.length} stories in Epic ${epicNumber}`); + + // Cache all stories in epic + for (const issue of issues) { + const storyKey = this._extractStoryKey(issue); + + if (!storyKey) { + continue; + } + + try { + await this.syncStory(storyKey, issue); + result.stories.push(storyKey); + } catch (error) { + result.errors.push({ storyKey, error: error.message }); + } + } + + console.log(`✅ Epic ${epicNumber} pre-fetched: ${result.stories.length} stories cached`); + + return result; + } + + /** + * Push local changes to GitHub (write-through) + * Used after task completion to update GitHub issue + * + * @param {string} storyKey - Story identifier + * @param {Object} update - Update data + * @param {string} update.comment - Comment to add + * @param {string[]} update.addLabels - Labels to add + * @param {string[]} update.removeLabels - Labels to remove + * @param {string} update.assignee - New assignee (optional) + * @returns {Object} Update result + */ + async pushToGitHub(storyKey, update) { + const meta = this.cache.loadMeta(); + const storyMeta = meta.stories[storyKey]; + + if (!storyMeta || !storyMeta.github_issue) { + throw new Error(`Story ${storyKey} not synced - no GitHub issue number`); + } + + const issueNumber = storyMeta.github_issue; + + // Add comment if provided + if (update.comment) { + await this._retryWithBackoff( + async () => this.githubClient('add_issue_comment', { + owner: this.github.owner, + repo: this.github.repo, + issue_number: issueNumber, + body: update.comment + }), + `Add comment to issue #${issueNumber}` + ); + } + + // Update labels if provided + if (update.addLabels || update.removeLabels) { + // First get current issue + const issue = await this.githubClient('issue_read', { + method: 'get', + owner: this.github.owner, + repo: this.github.repo, + issue_number: issueNumber + }); + + let labels = issue.labels?.map(l => typeof l === 'string' ? l : l.name) || []; + + // Remove labels + if (update.removeLabels) { + labels = labels.filter(l => !update.removeLabels.includes(l)); + } + + // Add labels + if (update.addLabels) { + for (const label of update.addLabels) { + if (!labels.includes(label)) { + labels.push(label); + } + } + } + + await this._retryWithBackoff( + async () => this.githubClient('issue_write', { + method: 'update', + owner: this.github.owner, + repo: this.github.repo, + issue_number: issueNumber, + labels + }), + `Update labels on issue #${issueNumber}` + ); + } + + // Verify write succeeded + await this._sleep(1000); // GitHub eventual consistency + + const verify = await this.githubClient('issue_read', { + method: 'get', + owner: this.github.owner, + repo: this.github.repo, + issue_number: issueNumber + }); + + console.log(`✅ GitHub issue #${issueNumber} updated and verified`); + + return { + storyKey, + issueNumber, + verified: true + }; + } + + /** + * Sync progress update to GitHub + * Called after each task completion in dev-story workflow + * + * @param {string} storyKey - Story identifier + * @param {Object} progress - Progress data + * @param {number} progress.taskNum - Current task number + * @param {number} progress.totalTasks - Total tasks + * @param {string} progress.taskDescription - Task description + * @param {number} progress.percentage - Completion percentage + */ + async syncProgress(storyKey, progress) { + const comment = + `📊 **Task ${progress.taskNum}/${progress.totalTasks} complete** (${progress.percentage}%)\n\n` + + `> ${progress.taskDescription}\n\n` + + `_Progress synced at ${new Date().toISOString()}_`; + + return this.pushToGitHub(storyKey, { comment }); + } + + /** + * Assign story to user (lock acquisition) + * + * @param {string} storyKey - Story identifier + * @param {string} username - GitHub username + * @returns {Object} Assignment result + */ + async assignStory(storyKey, username) { + const meta = this.cache.loadMeta(); + const storyMeta = meta.stories[storyKey]; + + if (!storyMeta || !storyMeta.github_issue) { + // Need to find the issue first + const searchResult = await this.githubClient('search_issues', { + query: `repo:${this.github.owner}/${this.github.repo} label:story:${storyKey}` + }); + + if (!searchResult.items?.length) { + throw new Error(`Story ${storyKey} not found in GitHub`); + } + + storyMeta.github_issue = searchResult.items[0].number; + } + + const issueNumber = storyMeta.github_issue; + + // Assign user and update status label + await this._retryWithBackoff( + async () => this.githubClient('issue_write', { + method: 'update', + owner: this.github.owner, + repo: this.github.repo, + issue_number: issueNumber, + assignees: [username] + }), + `Assign issue #${issueNumber} to ${username}` + ); + + // Update status label to in-progress + await this.pushToGitHub(storyKey, { + addLabels: ['status:in-progress'], + removeLabels: ['status:backlog', 'status:ready-for-dev'], + comment: `🔒 **Story locked by @${username}**\n\nLock expires in 8 hours.` + }); + + // Update cache + const lockExpiry = this._calculateLockExpiry(); + this.cache.updateLock(storyKey, { + locked_by: username, + locked_until: lockExpiry + }); + + // Verify assignment + await this._sleep(1000); + + const verify = await this.githubClient('issue_read', { + method: 'get', + owner: this.github.owner, + repo: this.github.repo, + issue_number: issueNumber + }); + + if (!verify.assignees?.some(a => a.login === username)) { + throw new Error('Assignment verification failed'); + } + + console.log(`✅ Story ${storyKey} assigned to @${username}`); + + return { + storyKey, + issueNumber, + assignee: username, + lockExpiry + }; + } + + /** + * Unassign story (lock release) + * + * @param {string} storyKey - Story identifier + * @param {string} reason - Reason for unlock (optional) + * @returns {Object} Unassignment result + */ + async unassignStory(storyKey, reason = null) { + const meta = this.cache.loadMeta(); + const storyMeta = meta.stories[storyKey]; + + if (!storyMeta || !storyMeta.github_issue) { + throw new Error(`Story ${storyKey} not synced - cannot unassign`); + } + + const issueNumber = storyMeta.github_issue; + + // Remove assignees + await this._retryWithBackoff( + async () => this.githubClient('issue_write', { + method: 'update', + owner: this.github.owner, + repo: this.github.repo, + issue_number: issueNumber, + assignees: [] + }), + `Unassign issue #${issueNumber}` + ); + + // Update status label + await this.pushToGitHub(storyKey, { + addLabels: ['status:ready-for-dev'], + removeLabels: ['status:in-progress'], + comment: `🔓 **Story unlocked**${reason ? `\n\nReason: ${reason}` : ''}` + }); + + // Clear cache lock + this.cache.clearLock(storyKey); + + console.log(`✅ Story ${storyKey} unlocked`); + + return { storyKey, issueNumber, unlocked: true }; + } + + /** + * Check if story is available (not locked by another user) + * + * @param {string} storyKey - Story identifier + * @param {string} currentUser - Current user's username + * @returns {Object} Availability info + */ + async checkAvailability(storyKey, currentUser) { + // First check cache (fast) + const cacheLock = this.cache.getLockStatus(storyKey); + + if (cacheLock && !cacheLock.expired && cacheLock.locked_by !== currentUser) { + return { + available: false, + locked_by: cacheLock.locked_by, + locked_until: cacheLock.locked_until, + source: 'cache' + }; + } + + // Verify with GitHub (source of truth) + const searchResult = await this.githubClient('search_issues', { + query: `repo:${this.github.owner}/${this.github.repo} label:story:${storyKey}` + }); + + if (!searchResult.items?.length) { + return { available: false, error: 'Story not found in GitHub' }; + } + + const issue = searchResult.items[0]; + + if (issue.assignee && issue.assignee.login !== currentUser) { + // Update cache with GitHub truth + this.cache.updateLock(storyKey, { + locked_by: issue.assignee.login, + locked_until: this._calculateLockExpiry() + }); + + return { + available: false, + locked_by: issue.assignee.login, + github_issue: issue.number, + source: 'github' + }; + } + + return { + available: true, + github_issue: issue.number + }; + } + + /** + * Get all available (unlocked) stories + * + * @param {Object} options - Filter options + * @param {string} options.epicNumber - Filter by epic + * @param {string} options.status - Filter by status + * @returns {Object[]} Array of available stories + */ + async getAvailableStories(options = {}) { + let query = `repo:${this.github.owner}/${this.github.repo} label:type:story no:assignee`; + + if (options.epicNumber) { + query += ` label:epic:${options.epicNumber}`; + } + + if (options.status) { + query += ` label:status:${options.status}`; + } else { + // Default: ready-for-dev or backlog + query += ` (label:status:ready-for-dev OR label:status:backlog)`; + } + + const searchResult = await this._retryWithBackoff( + async () => this.githubClient('search_issues', { query }), + 'Search for available stories' + ); + + const stories = (searchResult.items || []).map(issue => ({ + storyKey: this._extractStoryKey(issue), + title: issue.title, + issueNumber: issue.number, + status: this._extractStatus(issue), + labels: issue.labels?.map(l => typeof l === 'string' ? l : l.name) || [], + url: issue.html_url + })).filter(s => s.storyKey); // Filter out any without valid story keys + + return stories; + } +} + +module.exports = { SyncEngine, RETRY_BACKOFF_MS, MAX_RETRIES }; diff --git a/src/modules/bmm/lib/crowdsource/feedback-manager.js b/src/modules/bmm/lib/crowdsource/feedback-manager.js new file mode 100644 index 00000000..8771c728 --- /dev/null +++ b/src/modules/bmm/lib/crowdsource/feedback-manager.js @@ -0,0 +1,414 @@ +/** + * Feedback Manager - Generic feedback operations for PRD and Epic crowdsourcing + * + * Handles creation, querying, and status updates of feedback issues. + * Works with both PRDs and Epics through a common interface. + */ + +const FEEDBACK_TYPES = { + clarification: { + label: 'feedback-type:clarification', + emoji: '📋', + description: 'Something is unclear or needs more detail' + }, + concern: { + label: 'feedback-type:concern', + emoji: '⚠️', + description: 'Potential issue, risk, or problem' + }, + suggestion: { + label: 'feedback-type:suggestion', + emoji: '💡', + description: 'Improvement idea or alternative approach' + }, + addition: { + label: 'feedback-type:addition', + emoji: '➕', + description: 'Missing requirement or feature' + }, + priority: { + label: 'feedback-type:priority', + emoji: '🔢', + description: 'Disagree with prioritization or ordering' + }, + // Epic-specific types + scope: { + label: 'feedback-type:scope', + emoji: '📐', + description: 'Epic scope is too large or should be split' + }, + dependency: { + label: 'feedback-type:dependency', + emoji: '🔗', + description: 'Dependency or blocking relationship' + }, + technical_risk: { + label: 'feedback-type:technical-risk', + emoji: '🔧', + description: 'Technical or architectural concern' + }, + story_split: { + label: 'feedback-type:story-split', + emoji: '✂️', + description: 'Suggest different story breakdown' + } +}; + +const FEEDBACK_STATUS = { + new: 'feedback-status:new', + reviewed: 'feedback-status:reviewed', + incorporated: 'feedback-status:incorporated', + deferred: 'feedback-status:deferred' +}; + +const PRIORITY_LEVELS = { + high: 'priority:high', + medium: 'priority:medium', + low: 'priority:low' +}; + +class FeedbackManager { + constructor(githubConfig) { + this.owner = githubConfig.owner; + this.repo = githubConfig.repo; + } + + /** + * Create a new feedback issue linked to a review round + */ + async createFeedback({ + reviewIssueNumber, + documentKey, // prd:user-auth or epic:2 + documentType, // 'prd' or 'epic' + section, // e.g., 'User Stories', 'FR-3' + feedbackType, // 'clarification', 'concern', etc. + priority, // 'high', 'medium', 'low' + title, // Brief title + content, // Detailed feedback + suggestedChange, // Optional proposed change + rationale, // Why this matters + submittedBy // @username + }) { + const typeConfig = FEEDBACK_TYPES[feedbackType]; + if (!typeConfig) { + throw new Error(`Unknown feedback type: ${feedbackType}`); + } + + const labels = [ + `type:${documentType}-feedback`, + `${documentType}:${documentKey.split(':')[1]}`, + `linked-review:${reviewIssueNumber}`, + `feedback-section:${section.toLowerCase().replace(/\s+/g, '-')}`, + typeConfig.label, + FEEDBACK_STATUS.new, + PRIORITY_LEVELS[priority] || PRIORITY_LEVELS.medium + ]; + + const body = this._formatFeedbackBody({ + reviewIssueNumber, + documentKey, + section, + feedbackType, + typeConfig, + priority, + content, + suggestedChange, + rationale, + submittedBy + }); + + // Create the feedback issue + const issue = await this._createIssue({ + title: `${typeConfig.emoji} Feedback: ${title}`, + body, + labels + }); + + // Add comment to review issue linking to this feedback + await this._addLinkComment(reviewIssueNumber, issue.number, title, feedbackType, submittedBy); + + return { + feedbackId: issue.number, + url: issue.html_url, + documentKey, + section, + feedbackType, + status: 'new' + }; + } + + /** + * Query all feedback for a document or review round + */ + async getFeedback({ + documentKey, // Optional: filter by document + reviewIssueNumber, // Optional: filter by review round + documentType, // 'prd' or 'epic' + status, // Optional: filter by status + section, // Optional: filter by section + feedbackType // Optional: filter by type + }) { + let query = `repo:${this.owner}/${this.repo} type:issue is:open`; + query += ` label:type:${documentType}-feedback`; + + if (documentKey) { + const key = documentKey.includes(':') ? documentKey.split(':')[1] : documentKey; + query += ` label:${documentType}:${key}`; + } + + if (reviewIssueNumber) { + query += ` label:linked-review:${reviewIssueNumber}`; + } + + if (status) { + query += ` label:${FEEDBACK_STATUS[status] || status}`; + } + + if (section) { + query += ` label:feedback-section:${section.toLowerCase().replace(/\s+/g, '-')}`; + } + + if (feedbackType) { + const typeConfig = FEEDBACK_TYPES[feedbackType]; + if (typeConfig) { + query += ` label:${typeConfig.label}`; + } + } + + const results = await this._searchIssues(query); + + return results.map(issue => this._parseFeedbackIssue(issue)); + } + + /** + * Group feedback by section for synthesis + */ + async getFeedbackBySection(documentKey, documentType) { + const allFeedback = await this.getFeedback({ documentKey, documentType }); + + const bySection = {}; + for (const fb of allFeedback) { + if (!bySection[fb.section]) { + bySection[fb.section] = []; + } + bySection[fb.section].push(fb); + } + + return bySection; + } + + /** + * Group feedback by type for analysis + */ + async getFeedbackByType(documentKey, documentType) { + const allFeedback = await this.getFeedback({ documentKey, documentType }); + + const byType = {}; + for (const fb of allFeedback) { + if (!byType[fb.feedbackType]) { + byType[fb.feedbackType] = []; + } + byType[fb.feedbackType].push(fb); + } + + return byType; + } + + /** + * Detect conflicts (multiple feedback on same section with different opinions) + */ + async detectConflicts(documentKey, documentType) { + const bySection = await this.getFeedbackBySection(documentKey, documentType); + const conflicts = []; + + for (const [section, feedbackList] of Object.entries(bySection)) { + if (feedbackList.length < 2) continue; + + // Check for opposing views on the same topic + const concerns = feedbackList.filter(f => f.feedbackType === 'concern'); + const suggestions = feedbackList.filter(f => f.feedbackType === 'suggestion'); + + if (concerns.length > 1 || (concerns.length >= 1 && suggestions.length >= 1)) { + conflicts.push({ + section, + feedbackItems: feedbackList, + conflictType: 'multiple_opinions', + summary: `${feedbackList.length} stakeholders have input on ${section}` + }); + } + } + + return conflicts; + } + + /** + * Update feedback status + */ + async updateFeedbackStatus(feedbackIssueNumber, newStatus, resolution = null) { + const statusLabel = FEEDBACK_STATUS[newStatus]; + if (!statusLabel) { + throw new Error(`Unknown status: ${newStatus}`); + } + + // Get current labels + const issue = await this._getIssue(feedbackIssueNumber); + const currentLabels = issue.labels.map(l => l.name); + + // Remove old status labels, add new one + const newLabels = currentLabels + .filter(l => !l.startsWith('feedback-status:')) + .concat([statusLabel]); + + await this._updateIssue(feedbackIssueNumber, { labels: newLabels }); + + // Add resolution comment if provided + if (resolution) { + await this._addComment(feedbackIssueNumber, + `**Status Updated: ${newStatus}**\n\n${resolution}` + ); + } + + // Close issue if incorporated or deferred + if (newStatus === 'incorporated' || newStatus === 'deferred') { + await this._closeIssue(feedbackIssueNumber, + newStatus === 'incorporated' ? 'completed' : 'not_planned' + ); + } + + return { feedbackId: feedbackIssueNumber, status: newStatus }; + } + + /** + * Get feedback statistics for a document + */ + async getStats(documentKey, documentType) { + const allFeedback = await this.getFeedback({ documentKey, documentType }); + + const stats = { + total: allFeedback.length, + byType: {}, + byStatus: {}, + bySection: {}, + byPriority: {}, + submitters: new Set() + }; + + for (const fb of allFeedback) { + // By type + stats.byType[fb.feedbackType] = (stats.byType[fb.feedbackType] || 0) + 1; + + // By status + stats.byStatus[fb.status] = (stats.byStatus[fb.status] || 0) + 1; + + // By section + stats.bySection[fb.section] = (stats.bySection[fb.section] || 0) + 1; + + // By priority + stats.byPriority[fb.priority] = (stats.byPriority[fb.priority] || 0) + 1; + + // Unique submitters + stats.submitters.add(fb.submittedBy); + } + + stats.submitterCount = stats.submitters.size; + stats.submitters = Array.from(stats.submitters); + + return stats; + } + + // ============ Private Methods ============ + + _formatFeedbackBody({ reviewIssueNumber, documentKey, section, feedbackType, typeConfig, priority, content, suggestedChange, rationale, submittedBy }) { + let body = `# ${typeConfig.emoji} Feedback: ${feedbackType.charAt(0).toUpperCase() + feedbackType.slice(1)}\n\n`; + body += `**Review:** #${reviewIssueNumber}\n`; + body += `**Document:** \`${documentKey}\`\n`; + body += `**Section:** ${section}\n`; + body += `**Type:** ${typeConfig.description}\n`; + body += `**Priority:** ${priority}\n\n`; + body += `---\n\n`; + body += `## Feedback\n\n${content}\n\n`; + + if (suggestedChange) { + body += `## Suggested Change\n\n${suggestedChange}\n\n`; + } + + if (rationale) { + body += `## Context/Rationale\n\n${rationale}\n\n`; + } + + body += `---\n\n`; + body += `_Submitted by @${submittedBy} on ${new Date().toISOString().split('T')[0]}_\n`; + + return body; + } + + _parseFeedbackIssue(issue) { + const labels = issue.labels.map(l => l.name); + + return { + id: issue.number, + url: issue.html_url, + title: issue.title.replace(/^[^\s]+\s+Feedback:\s*/, ''), + section: this._extractLabel(labels, 'feedback-section:'), + feedbackType: this._extractLabel(labels, 'feedback-type:'), + status: this._extractLabel(labels, 'feedback-status:'), + priority: this._extractLabel(labels, 'priority:'), + submittedBy: issue.user?.login, + createdAt: issue.created_at, + updatedAt: issue.updated_at, + body: issue.body + }; + } + + _extractLabel(labels, prefix) { + const label = labels.find(l => l.startsWith(prefix)); + return label ? label.replace(prefix, '') : null; + } + + async _addLinkComment(reviewIssueNumber, feedbackIssueNumber, title, feedbackType, submittedBy) { + const typeConfig = FEEDBACK_TYPES[feedbackType]; + const comment = `${typeConfig.emoji} **New Feedback** from @${submittedBy}\n\n` + + `**${title}** → #${feedbackIssueNumber}\n` + + `Type: ${feedbackType}`; + + await this._addComment(reviewIssueNumber, comment); + } + + // GitHub API wrappers (to be called via MCP) + async _createIssue({ title, body, labels }) { + // This would be: mcp__github__issue_write({ method: 'create', ... }) + throw new Error('_createIssue must be implemented by caller via GitHub MCP'); + } + + async _getIssue(issueNumber) { + // This would be: mcp__github__issue_read({ method: 'get', ... }) + throw new Error('_getIssue must be implemented by caller via GitHub MCP'); + } + + async _updateIssue(issueNumber, updates) { + // This would be: mcp__github__issue_write({ method: 'update', ... }) + throw new Error('_updateIssue must be implemented by caller via GitHub MCP'); + } + + async _closeIssue(issueNumber, reason) { + // This would be: mcp__github__issue_write({ method: 'update', state: 'closed', ... }) + throw new Error('_closeIssue must be implemented by caller via GitHub MCP'); + } + + async _addComment(issueNumber, body) { + // This would be: mcp__github__add_issue_comment({ ... }) + throw new Error('_addComment must be implemented by caller via GitHub MCP'); + } + + async _searchIssues(query) { + // This would be: mcp__github__search_issues({ query }) + throw new Error('_searchIssues must be implemented by caller via GitHub MCP'); + } +} + +module.exports = { + FeedbackManager, + FEEDBACK_TYPES, + FEEDBACK_STATUS, + PRIORITY_LEVELS +}; diff --git a/src/modules/bmm/lib/crowdsource/index.js b/src/modules/bmm/lib/crowdsource/index.js new file mode 100644 index 00000000..64007656 --- /dev/null +++ b/src/modules/bmm/lib/crowdsource/index.js @@ -0,0 +1,28 @@ +/** + * Crowdsource Module - Shared infrastructure for PRD and Epic crowdsourcing + * + * This module provides generic feedback collection, LLM synthesis, + * and sign-off management that works for both PRDs and Epics. + */ + +const { FeedbackManager, FEEDBACK_TYPES, FEEDBACK_STATUS, PRIORITY_LEVELS } = require('./feedback-manager'); +const { SynthesisEngine, SYNTHESIS_PROMPTS } = require('./synthesis-engine'); +const { SignoffManager, SIGNOFF_STATUS, THRESHOLD_TYPES, DEFAULT_CONFIG } = require('./signoff-manager'); + +module.exports = { + // Feedback Management + FeedbackManager, + FEEDBACK_TYPES, + FEEDBACK_STATUS, + PRIORITY_LEVELS, + + // Synthesis Engine + SynthesisEngine, + SYNTHESIS_PROMPTS, + + // Sign-off Management + SignoffManager, + SIGNOFF_STATUS, + THRESHOLD_TYPES, + DEFAULT_CONFIG +}; diff --git a/src/modules/bmm/lib/crowdsource/signoff-manager.js b/src/modules/bmm/lib/crowdsource/signoff-manager.js new file mode 100644 index 00000000..6b5ab154 --- /dev/null +++ b/src/modules/bmm/lib/crowdsource/signoff-manager.js @@ -0,0 +1,457 @@ +/** + * Sign-off Manager - Configurable sign-off logic for PRDs and Epics + * + * Supports three threshold types: + * - count: Minimum number of approvals needed + * - percentage: Percentage of stakeholders must approve + * - required_approvers: Specific people must approve + minimum optional + */ + +const SIGNOFF_STATUS = { + pending: 'signoff:pending', + approved: 'signoff:approved', + approved_with_note: 'signoff:approved-with-note', + blocked: 'signoff:blocked' +}; + +const THRESHOLD_TYPES = { + count: 'count', + percentage: 'percentage', + required_approvers: 'required_approvers' +}; + +const DEFAULT_CONFIG = { + threshold_type: THRESHOLD_TYPES.count, + minimum_approvals: 2, + approval_percentage: 66, + required: [], + optional: [], + minimum_optional: 0, + allow_blocks: true, + block_threshold: 1 +}; + +class SignoffManager { + constructor(githubConfig) { + this.owner = githubConfig.owner; + this.repo = githubConfig.repo; + } + + /** + * Request sign-off from stakeholders + */ + async requestSignoff({ + documentKey, + documentType, // 'prd' or 'epic' + reviewIssueNumber, + stakeholders, // Array of @usernames + deadline, // ISO date string + config = {} // Sign-off configuration + }) { + const signoffConfig = { ...DEFAULT_CONFIG, ...config }; + + // Validate configuration + this._validateConfig(signoffConfig, stakeholders); + + // Update the review issue to signoff status + const labels = [ + `type:${documentType}-review`, + `${documentType}:${documentKey.split(':')[1]}`, + 'review-status:signoff' + ]; + + // Build stakeholder checklist + const checklist = stakeholders.map(user => + `- [ ] @${user.replace('@', '')} - ⏳ Pending` + ).join('\n'); + + const body = this._formatSignoffRequestBody({ + documentKey, + documentType, + stakeholders, + deadline, + config: signoffConfig, + checklist + }); + + // Add comment to review issue + await this._addComment(reviewIssueNumber, body); + + return { + reviewIssueNumber, + documentKey, + stakeholders, + deadline, + config: signoffConfig, + status: 'signoff_requested' + }; + } + + /** + * Submit a sign-off decision + */ + async submitSignoff({ + reviewIssueNumber, + documentKey, + documentType, + user, + decision, // 'approved' | 'approved_with_note' | 'blocked' + note = null, // Optional note or blocking reason + feedbackIssueNumber = null // If blocked, link to feedback issue + }) { + if (!Object.keys(SIGNOFF_STATUS).includes(decision)) { + throw new Error(`Invalid decision: ${decision}. Must be one of: ${Object.keys(SIGNOFF_STATUS).join(', ')}`); + } + + const emoji = this._getDecisionEmoji(decision); + const statusText = this._getDecisionText(decision); + + let comment = `### ${emoji} Sign-off from @${user.replace('@', '')}\n\n`; + comment += `**Decision:** ${statusText}\n`; + comment += `**Date:** ${new Date().toISOString().split('T')[0]}\n`; + + if (note) { + comment += `\n**Note:**\n${note}\n`; + } + + if (decision === 'blocked' && feedbackIssueNumber) { + comment += `\n**Blocking Issue:** #${feedbackIssueNumber}\n`; + } + + await this._addComment(reviewIssueNumber, comment); + + // Store signoff in labels for queryability + await this._addSignoffLabel(reviewIssueNumber, user, decision); + + return { + reviewIssueNumber, + user, + decision, + note, + timestamp: new Date().toISOString() + }; + } + + /** + * Get all sign-offs for a review + */ + async getSignoffs(reviewIssueNumber) { + const issue = await this._getIssue(reviewIssueNumber); + const labels = issue.labels.map(l => l.name); + + // Parse signoff labels: signoff-{user}-{status} + const signoffs = []; + for (const label of labels) { + const match = label.match(/^signoff-(.+)-(approved|approved-with-note|blocked|pending)$/); + if (match) { + signoffs.push({ + user: match[1], + status: match[2].replace(/-/g, '_'), + label: label + }); + } + } + + return signoffs; + } + + /** + * Calculate sign-off status based on configuration + */ + calculateStatus(signoffs, stakeholders, config = DEFAULT_CONFIG) { + const approvals = signoffs.filter(s => + s.status === 'approved' || s.status === 'approved_with_note' + ); + const blocks = signoffs.filter(s => s.status === 'blocked'); + const pending = stakeholders.filter(user => + !signoffs.some(s => s.user === user.replace('@', '')) + ); + + // Check for blockers first + if (config.allow_blocks && blocks.length >= config.block_threshold) { + return { + status: 'blocked', + blockers: blocks.map(b => b.user), + message: `Blocked by ${blocks.length} stakeholder(s)` + }; + } + + switch (config.threshold_type) { + case THRESHOLD_TYPES.count: + return this._calculateCountStatus(approvals, config, pending); + + case THRESHOLD_TYPES.percentage: + return this._calculatePercentageStatus(approvals, stakeholders, config, pending); + + case THRESHOLD_TYPES.required_approvers: + return this._calculateRequiredApproversStatus(approvals, config, pending); + + default: + throw new Error(`Unknown threshold type: ${config.threshold_type}`); + } + } + + /** + * Check if document is fully approved + */ + isApproved(signoffs, stakeholders, config = DEFAULT_CONFIG) { + const status = this.calculateStatus(signoffs, stakeholders, config); + return status.status === 'approved'; + } + + /** + * Get sign-off progress summary + */ + getProgressSummary(signoffs, stakeholders, config = DEFAULT_CONFIG) { + const status = this.calculateStatus(signoffs, stakeholders, config); + + const approvalCount = signoffs.filter(s => + s.status === 'approved' || s.status === 'approved_with_note' + ).length; + + const blockCount = signoffs.filter(s => s.status === 'blocked').length; + + const pendingUsers = stakeholders.filter(user => + !signoffs.some(s => s.user === user.replace('@', '')) + ); + + return { + ...status, + total_stakeholders: stakeholders.length, + approved_count: approvalCount, + blocked_count: blockCount, + pending_count: pendingUsers.length, + pending_users: pendingUsers, + progress_percent: Math.round((approvalCount / stakeholders.length) * 100) + }; + } + + /** + * Send reminder to pending stakeholders + */ + async sendReminder(reviewIssueNumber, pendingUsers, deadline) { + const mentions = pendingUsers.map(u => `@${u.replace('@', '')}`).join(', '); + + const comment = `### ⏰ Reminder: Sign-off Needed\n\n` + + `${mentions}\n\n` + + `Your sign-off is still pending for this review.\n` + + `**Deadline:** ${deadline}\n\n` + + `Please review and submit your decision.`; + + await this._addComment(reviewIssueNumber, comment); + + return { reminded: pendingUsers, deadline }; + } + + /** + * Extend sign-off deadline + */ + async extendDeadline(reviewIssueNumber, newDeadline, reason = null) { + let comment = `### 📅 Deadline Extended\n\n`; + comment += `**New Deadline:** ${newDeadline}\n`; + + if (reason) { + comment += `**Reason:** ${reason}\n`; + } + + await this._addComment(reviewIssueNumber, comment); + + return { reviewIssueNumber, newDeadline }; + } + + // ============ Private Methods ============ + + _validateConfig(config, stakeholders) { + if (config.threshold_type === THRESHOLD_TYPES.count) { + if (config.minimum_approvals > stakeholders.length) { + throw new Error( + `minimum_approvals (${config.minimum_approvals}) cannot exceed stakeholder count (${stakeholders.length})` + ); + } + } + + if (config.threshold_type === THRESHOLD_TYPES.required_approvers) { + const allRequired = config.required.every(r => + stakeholders.some(s => s.replace('@', '') === r.replace('@', '')) + ); + if (!allRequired) { + throw new Error('All required approvers must be in stakeholder list'); + } + } + } + + _calculateCountStatus(approvals, config, pending) { + if (approvals.length >= config.minimum_approvals) { + return { status: 'approved', message: 'Minimum approvals reached' }; + } + + return { + status: 'pending', + needed: config.minimum_approvals - approvals.length, + pending_users: pending, + message: `Need ${config.minimum_approvals - approvals.length} more approval(s)` + }; + } + + _calculatePercentageStatus(approvals, stakeholders, config, pending) { + const percent = (approvals.length / stakeholders.length) * 100; + + if (percent >= config.approval_percentage) { + return { + status: 'approved', + message: `${Math.round(percent)}% approved (threshold: ${config.approval_percentage}%)` + }; + } + + const needed = Math.ceil((config.approval_percentage / 100) * stakeholders.length) - approvals.length; + return { + status: 'pending', + current_percent: Math.round(percent), + needed_percent: config.approval_percentage, + needed: needed, + pending_users: pending, + message: `${Math.round(percent)}% approved, need ${config.approval_percentage}%` + }; + } + + _calculateRequiredApproversStatus(approvals, config, pending) { + const approvedUsers = approvals.map(a => a.user); + + // Check required approvers + const missingRequired = config.required.filter(r => + !approvedUsers.includes(r.replace('@', '')) + ); + + if (missingRequired.length > 0) { + return { + status: 'pending', + missing_required: missingRequired, + pending_users: pending, + message: `Waiting for required approvers: ${missingRequired.join(', ')}` + }; + } + + // Check optional approvers + const optionalApproved = approvals.filter(a => + config.optional.some(o => o.replace('@', '') === a.user) + ).length; + + if (optionalApproved < config.minimum_optional) { + const neededOptional = config.minimum_optional - optionalApproved; + const pendingOptional = config.optional.filter(o => + !approvedUsers.includes(o.replace('@', '')) + ); + + return { + status: 'pending', + optional_needed: neededOptional, + pending_optional: pendingOptional, + pending_users: pending, + message: `Need ${neededOptional} more optional approver(s)` + }; + } + + return { status: 'approved', message: 'All required + minimum optional approvers satisfied' }; + } + + _getDecisionEmoji(decision) { + switch (decision) { + case 'approved': return '✅'; + case 'approved_with_note': return '✅📝'; + case 'blocked': return '🚫'; + default: return '⏳'; + } + } + + _getDecisionText(decision) { + switch (decision) { + case 'approved': return 'Approved'; + case 'approved_with_note': return 'Approved with Note'; + case 'blocked': return 'Blocked'; + default: return 'Pending'; + } + } + + _formatSignoffRequestBody({ documentKey, documentType, stakeholders, deadline, config, checklist }) { + let body = `## ✍️ Sign-off Requested\n\n`; + body += `**Document:** \`${documentKey}\`\n`; + body += `**Type:** ${documentType.toUpperCase()}\n`; + body += `**Deadline:** ${deadline}\n\n`; + + body += `### Sign-off Configuration\n`; + body += `- **Threshold:** ${this._formatThreshold(config)}\n`; + if (config.allow_blocks) { + body += `- **Block Threshold:** ${config.block_threshold} block(s) will halt approval\n`; + } + body += '\n'; + + body += `### Stakeholder Status\n\n`; + body += checklist; + body += '\n\n'; + + body += `---\n\n`; + body += `**To sign off:**\n`; + body += `- ✅ **Approve**: Comment with \`/signoff approve\`\n`; + body += `- ✅📝 **Approve with Note**: Comment with \`/signoff approve-note: [your note]\`\n`; + body += `- 🚫 **Block**: Comment with \`/signoff block: [reason]\`\n`; + + return body; + } + + _formatThreshold(config) { + switch (config.threshold_type) { + case THRESHOLD_TYPES.count: + return `${config.minimum_approvals} approval(s) required`; + case THRESHOLD_TYPES.percentage: + return `${config.approval_percentage}% must approve`; + case THRESHOLD_TYPES.required_approvers: + return `Required: ${config.required.join(', ')} + ${config.minimum_optional} optional`; + default: + return 'Unknown'; + } + } + + async _addSignoffLabel(issueNumber, user, decision) { + // Normalize user and decision for label + const normalizedUser = user.replace('@', '').replace(/[^a-zA-Z0-9-]/g, '-'); + const normalizedDecision = decision.replace(/_/g, '-'); + const label = `signoff-${normalizedUser}-${normalizedDecision}`; + + // Get current labels + const issue = await this._getIssue(issueNumber); + const currentLabels = issue.labels.map(l => l.name); + + // Remove any existing signoff label for this user + const newLabels = currentLabels.filter(l => + !l.startsWith(`signoff-${normalizedUser}-`) + ); + + // Add new signoff label + newLabels.push(label); + + await this._updateIssue(issueNumber, { labels: newLabels }); + } + + // GitHub API wrappers (to be called via MCP) + async _getIssue(issueNumber) { + // This would be: mcp__github__issue_read({ method: 'get', ... }) + throw new Error('_getIssue must be implemented by caller via GitHub MCP'); + } + + async _updateIssue(issueNumber, updates) { + // This would be: mcp__github__issue_write({ method: 'update', ... }) + throw new Error('_updateIssue must be implemented by caller via GitHub MCP'); + } + + async _addComment(issueNumber, body) { + // This would be: mcp__github__add_issue_comment({ ... }) + throw new Error('_addComment must be implemented by caller via GitHub MCP'); + } +} + +module.exports = { + SignoffManager, + SIGNOFF_STATUS, + THRESHOLD_TYPES, + DEFAULT_CONFIG +}; diff --git a/src/modules/bmm/lib/crowdsource/synthesis-engine.js b/src/modules/bmm/lib/crowdsource/synthesis-engine.js new file mode 100644 index 00000000..0b209b10 --- /dev/null +++ b/src/modules/bmm/lib/crowdsource/synthesis-engine.js @@ -0,0 +1,405 @@ +/** + * Synthesis Engine - LLM-powered feedback synthesis with conflict resolution + * + * Groups feedback by section, identifies conflicts/tensions, + * and generates proposed resolutions with rationale. + */ + +const SYNTHESIS_PROMPTS = { + prd: { + grouping: `Analyze the following feedback items for a PRD section and group them by theme: + +SECTION: {{section}} + +FEEDBACK ITEMS: +{{feedbackItems}} + +Group these into themes and identify: +1. Common requests (multiple people asking for similar things) +2. Conflicts (opposing viewpoints) +3. Quick wins (low-effort, high-value changes) +4. Major changes (significant scope implications) + +Format your response as JSON.`, + + resolution: `You are helping synthesize stakeholder feedback on a PRD. + +SECTION: {{section}} +ORIGINAL TEXT: +{{originalText}} + +CONFLICT DETECTED: +{{conflictDescription}} + +FEEDBACK FROM STAKEHOLDERS: +{{feedbackDetails}} + +Propose a resolution that: +1. Addresses the core concerns of all parties +2. Maintains product coherence +3. Is actionable and specific + +Provide: +- proposed_text: The updated section text +- rationale: Why this resolution works (2-3 sentences) +- trade_offs: What compromises were made +- confidence: high/medium/low + +Format as JSON.`, + + merge: `Incorporate the following approved feedback into the PRD section: + +SECTION: {{section}} +ORIGINAL TEXT: +{{originalText}} + +FEEDBACK TO INCORPORATE: +{{feedbackToIncorporate}} + +Generate the updated section text that: +1. Addresses all feedback points +2. Maintains consistent tone and format +3. Is clear and actionable + +Return the complete updated section text.` + }, + + epic: { + grouping: `Analyze the following feedback items for an Epic and group them by theme: + +EPIC: {{epicKey}} + +FEEDBACK ITEMS: +{{feedbackItems}} + +Group these into: +1. Scope concerns (too big, should split) +2. Story split suggestions +3. Dependency/blocking issues +4. Technical risks +5. Missing stories +6. Priority questions + +Format your response as JSON.`, + + storySplit: `Based on stakeholder feedback, suggest how to split this epic into stories: + +EPIC: {{epicKey}} +EPIC DESCRIPTION: +{{epicDescription}} + +CURRENT STORIES: +{{currentStories}} + +FEEDBACK SUGGESTING CHANGES: +{{feedbackItems}} + +Propose an updated story breakdown that: +1. Addresses the split/scope concerns +2. Maintains logical grouping +3. Respects dependencies +4. Keeps stories appropriately sized (3-8 tasks each) + +Format as JSON with: +- stories: Array of { key, title, description, tasks_estimate } +- changes_made: What changed from original +- rationale: Why this split works better` + } +}; + +class SynthesisEngine { + constructor(options = {}) { + this.documentType = options.documentType || 'prd'; // 'prd' or 'epic' + } + + /** + * Analyze feedback and generate a synthesis report + */ + async analyzeFeedback(feedbackBySection, originalDocument) { + const analysis = { + sections: {}, + conflicts: [], + themes: [], + suggestedChanges: [], + summary: {} + }; + + for (const [section, feedbackList] of Object.entries(feedbackBySection)) { + const sectionAnalysis = await this._analyzeSection( + section, + feedbackList, + originalDocument[section] + ); + + analysis.sections[section] = sectionAnalysis; + + if (sectionAnalysis.conflicts.length > 0) { + analysis.conflicts.push(...sectionAnalysis.conflicts.map(c => ({ + ...c, + section + }))); + } + + analysis.suggestedChanges.push(...sectionAnalysis.suggestedChanges.map(c => ({ + ...c, + section + }))); + } + + // Generate overall summary + analysis.summary = this._generateSummary(analysis); + + return analysis; + } + + /** + * Analyze a single section's feedback + */ + async _analyzeSection(section, feedbackList, originalText) { + const result = { + feedbackCount: feedbackList.length, + byType: this._groupByType(feedbackList), + themes: [], + conflicts: [], + suggestedChanges: [] + }; + + // Identify conflicts (multiple feedback on same aspect) + result.conflicts = this._identifyConflicts(feedbackList); + + // Group into themes + result.themes = this._identifyThemes(feedbackList); + + // Generate suggested changes for non-conflicting feedback + const nonConflicting = feedbackList.filter( + f => !result.conflicts.some(c => c.feedbackIds.includes(f.id)) + ); + + for (const feedback of nonConflicting) { + result.suggestedChanges.push({ + feedbackId: feedback.id, + type: feedback.feedbackType, + priority: feedback.priority, + description: feedback.title, + suggestedChange: feedback.suggestedChange, + submittedBy: feedback.submittedBy + }); + } + + return result; + } + + /** + * Identify conflicts in feedback + */ + _identifyConflicts(feedbackList) { + const conflicts = []; + + // Group by topic/keywords + const byTopic = {}; + for (const fb of feedbackList) { + const keywords = this._extractKeywords(fb.title + ' ' + (fb.body || '')); + for (const kw of keywords) { + if (!byTopic[kw]) byTopic[kw] = []; + byTopic[kw].push(fb); + } + } + + // Find topics with multiple conflicting opinions + for (const [topic, items] of Object.entries(byTopic)) { + if (items.length < 2) continue; + + // Check if they have different suggestions + const uniqueSuggestions = new Set(items.map(i => i.suggestedChange).filter(Boolean)); + if (uniqueSuggestions.size > 1) { + conflicts.push({ + topic, + feedbackIds: items.map(i => i.id), + stakeholders: items.map(i => ({ user: i.submittedBy, position: i.title })), + description: `Conflicting views on ${topic}` + }); + } + } + + return conflicts; + } + + /** + * Identify common themes in feedback + */ + _identifyThemes(feedbackList) { + const themes = {}; + + for (const fb of feedbackList) { + const keywords = this._extractKeywords(fb.title); + for (const kw of keywords) { + if (!themes[kw]) { + themes[kw] = { keyword: kw, count: 0, feedbackIds: [], types: new Set() }; + } + themes[kw].count++; + themes[kw].feedbackIds.push(fb.id); + themes[kw].types.add(fb.feedbackType); + } + } + + // Return themes mentioned by multiple people + return Object.values(themes) + .filter(t => t.count >= 2) + .map(t => ({ + ...t, + types: Array.from(t.types) + })) + .sort((a, b) => b.count - a.count); + } + + /** + * Generate resolution proposal for a conflict + */ + generateConflictResolution(conflict, originalText, feedbackDetails) { + // This returns a prompt for the LLM to process + const prompt = SYNTHESIS_PROMPTS[this.documentType].resolution + .replace('{{section}}', conflict.section || 'Unknown') + .replace('{{originalText}}', originalText || 'N/A') + .replace('{{conflictDescription}}', conflict.description) + .replace('{{feedbackDetails}}', JSON.stringify(feedbackDetails, null, 2)); + + return { + prompt, + conflict, + // The LLM response should be parsed into: + expectedFormat: { + proposed_text: 'string', + rationale: 'string', + trade_offs: 'string[]', + confidence: 'high|medium|low' + } + }; + } + + /** + * Generate merge prompt for incorporating feedback + */ + generateMergePrompt(section, originalText, approvedFeedback) { + const feedbackText = approvedFeedback.map(f => + `- ${f.feedbackType}: ${f.title}\n Change: ${f.suggestedChange || 'Address the concern'}` + ).join('\n\n'); + + return SYNTHESIS_PROMPTS[this.documentType].merge + .replace('{{section}}', section) + .replace('{{originalText}}', originalText) + .replace('{{feedbackToIncorporate}}', feedbackText); + } + + /** + * Generate story split prompt for epics + */ + generateStorySplitPrompt(epicKey, epicDescription, currentStories, feedback) { + if (this.documentType !== 'epic') { + throw new Error('Story split is only available for epics'); + } + + return SYNTHESIS_PROMPTS.epic.storySplit + .replace('{{epicKey}}', epicKey) + .replace('{{epicDescription}}', epicDescription) + .replace('{{currentStories}}', JSON.stringify(currentStories, null, 2)) + .replace('{{feedbackItems}}', JSON.stringify(feedback, null, 2)); + } + + /** + * Generate synthesis summary + */ + _generateSummary(analysis) { + const totalFeedback = Object.values(analysis.sections) + .reduce((sum, s) => sum + s.feedbackCount, 0); + + const allTypes = {}; + for (const section of Object.values(analysis.sections)) { + for (const [type, count] of Object.entries(section.byType)) { + allTypes[type] = (allTypes[type] || 0) + count; + } + } + + return { + totalFeedback, + sectionsWithFeedback: Object.keys(analysis.sections).length, + conflictCount: analysis.conflicts.length, + themeCount: analysis.themes ? analysis.themes.length : 0, + changeCount: analysis.suggestedChanges.length, + feedbackByType: allTypes, + needsAttention: analysis.conflicts.length > 0 + }; + } + + /** + * Group feedback by type + */ + _groupByType(feedbackList) { + const byType = {}; + for (const fb of feedbackList) { + byType[fb.feedbackType] = (byType[fb.feedbackType] || 0) + 1; + } + return byType; + } + + /** + * Extract keywords from text for theme detection + */ + _extractKeywords(text) { + if (!text) return []; + + // Simple keyword extraction - can be enhanced + const stopWords = new Set([ + 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', + 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', + 'would', 'could', 'should', 'may', 'might', 'must', 'shall', + 'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from', + 'this', 'that', 'these', 'those', 'it', 'its', 'and', 'or', + 'but', 'not', 'no', 'if', 'then', 'else', 'when', 'where', + 'why', 'how', 'what', 'which', 'who', 'whom', 'whose' + ]); + + return text + .toLowerCase() + .replace(/[^\w\s]/g, ' ') + .split(/\s+/) + .filter(word => word.length > 3 && !stopWords.has(word)) + .slice(0, 10); // Limit to top 10 keywords + } + + /** + * Format synthesis results for display + */ + formatForDisplay(analysis) { + let output = ''; + + output += `## Synthesis Analysis\n\n`; + output += `**Total Feedback:** ${analysis.summary.totalFeedback}\n`; + output += `**Sections with Feedback:** ${analysis.summary.sectionsWithFeedback}\n`; + output += `**Conflicts Detected:** ${analysis.summary.conflictCount}\n`; + output += `**Suggested Changes:** ${analysis.summary.changeCount}\n\n`; + + if (analysis.summary.needsAttention) { + output += `### ⚠️ Conflicts Requiring Resolution\n\n`; + for (const conflict of analysis.conflicts) { + output += `**${conflict.section}**: ${conflict.description}\n`; + for (const stakeholder of conflict.stakeholders) { + output += ` - @${stakeholder.user}: "${stakeholder.position}"\n`; + } + output += '\n'; + } + } + + output += `### By Section\n\n`; + for (const [section, data] of Object.entries(analysis.sections)) { + output += `**${section}** (${data.feedbackCount} items)\n`; + for (const [type, count] of Object.entries(data.byType)) { + output += ` - ${type}: ${count}\n`; + } + output += '\n'; + } + + return output; + } +} + +module.exports = { SynthesisEngine, SYNTHESIS_PROMPTS }; diff --git a/src/modules/bmm/lib/notifications/email-notifier.js b/src/modules/bmm/lib/notifications/email-notifier.js new file mode 100644 index 00000000..8c214db4 --- /dev/null +++ b/src/modules/bmm/lib/notifications/email-notifier.js @@ -0,0 +1,578 @@ +/** + * Email Notifier - Optional email notification integration + * + * Sends email notifications for important events. + * Supports SMTP and common email service providers. + */ + +const EMAIL_TEMPLATES = { + feedback_round_opened: { + subject: '📣 [{{document_type}}:{{document_key}}] Feedback Requested', + html: ` + + + + + + +
+
+

📣 Feedback Requested

+
+
+
+
Document: {{document_type}}:{{document_key}}
+
Version: v{{version}}
+
Deadline: {{deadline}}
+
+ +

Please review the document and provide your feedback by {{deadline}}.

+ +

+ View Document +

+
+ +
+ + +`, + text: ` +📣 FEEDBACK REQUESTED + +Document: {{document_type}}:{{document_key}} +Version: v{{version}} +Deadline: {{deadline}} + +Please review the document and provide your feedback by {{deadline}}. + +View Document: {{document_url}} + +--- +PRD Crowdsourcing System +` + }, + + signoff_requested: { + subject: '✍️ [{{document_type}}:{{document_key}}] Sign-off Requested', + html: ` + + + + + + +
+
+

✍️ Sign-off Requested

+
+
+
+
Document: {{document_type}}:{{document_key}}
+
Version: v{{version}}
+
Deadline: {{deadline}}
+
+ +

Please review the document and provide your sign-off decision by {{deadline}}.

+ +
+

Sign-off Options:

+
+ ✅ Approve - Sign off without concerns +
+
+ ✅📝 Approve with Note - Sign off with a minor note +
+
+ 🚫 Block - Cannot approve, has blocking concern +
+
+ +

+ Review & Sign Off +

+
+ +
+ + +`, + text: ` +✍️ SIGN-OFF REQUESTED + +Document: {{document_type}}:{{document_key}} +Version: v{{version}} +Deadline: {{deadline}} + +Please review the document and provide your sign-off decision by {{deadline}}. + +Sign-off Options: +- ✅ Approve - Sign off without concerns +- ✅📝 Approve with Note - Sign off with a minor note +- 🚫 Block - Cannot approve, has blocking concern + +Review & Sign Off: {{document_url}} + +--- +PRD Crowdsourcing System +` + }, + + document_approved: { + subject: '✅ [{{document_type}}:{{document_key}}] Document Approved!', + html: ` + + + + + + +
+
+

✅ Document Approved!

+
+
+
🎉
+ +
+
Document: {{document_type}}:{{document_key}}
+
Title: {{title}}
+
Final Version: v{{version}}
+
Approvals: {{approval_count}}/{{stakeholder_count}}
+
+ +

All required sign-offs have been received. This document is now approved and ready for implementation!

+ +

+ View Approved Document +

+
+ +
+ + +`, + text: ` +✅ DOCUMENT APPROVED! 🎉 + +Document: {{document_type}}:{{document_key}} +Title: {{title}} +Final Version: v{{version}} +Approvals: {{approval_count}}/{{stakeholder_count}} + +All required sign-offs have been received. This document is now approved and ready for implementation! + +View Approved Document: {{document_url}} + +--- +PRD Crowdsourcing System +` + }, + + document_blocked: { + subject: '🚫 [{{document_type}}:{{document_key}}] Document Blocked', + html: ` + + + + + + +
+
+

🚫 Document Blocked

+
+
+
+
Document: {{document_type}}:{{document_key}}
+
Blocked by: {{user}}
+
+ +
+ Blocking Reason: +

{{reason}}

+
+ +

⚠️ This blocking concern must be resolved before the document can be approved.

+ +

+ View Blocking Issue +

+
+ +
+ + +`, + text: ` +🚫 DOCUMENT BLOCKED + +Document: {{document_type}}:{{document_key}} +Blocked by: {{user}} + +Blocking Reason: +{{reason}} + +⚠️ This blocking concern must be resolved before the document can be approved. + +View Blocking Issue: {{feedback_url}} + +--- +PRD Crowdsourcing System +` + }, + + reminder: { + subject: '⏰ [{{document_type}}:{{document_key}}] Reminder: {{action_needed}}', + html: ` + + + + + + +
+
+

⏰ Reminder: Action Needed

+
+
+
+ {{time_remaining}} remaining until deadline +
+ +
+
Document: {{document_type}}:{{document_key}}
+
Action: {{action_needed}}
+
Deadline: {{deadline}}
+
+ +

Please complete your {{action_needed}} by {{deadline}}.

+ +

+ Take Action +

+
+ +
+ + +`, + text: ` +⏰ REMINDER: ACTION NEEDED + +{{time_remaining}} remaining until deadline + +Document: {{document_type}}:{{document_key}} +Action: {{action_needed}} +Deadline: {{deadline}} + +Please complete your {{action_needed}} by {{deadline}}. + +Take Action: {{document_url}} + +--- +PRD Crowdsourcing System +` + } +}; + +class EmailNotifier { + /** + * Create a new EmailNotifier + * @param {Object} config - Configuration object + * @param {string} config.provider - Email provider ('smtp', 'sendgrid', 'ses', etc.) + * @param {Object} config.smtp - SMTP configuration (if provider is 'smtp') + * @param {string} config.apiKey - API key (for sendgrid, ses, etc.) + * @param {string} config.fromAddress - Sender email address + * @param {string} config.fromName - Sender name + */ + constructor(config) { + this.provider = config.provider || 'smtp'; + this.smtp = config.smtp; + this.apiKey = config.apiKey; + this.fromAddress = config.fromAddress || 'noreply@example.com'; + this.fromName = config.fromName || 'PRD Crowdsourcing'; + this.enabled = !!(config.smtp || config.apiKey); + + // User email lookup (should be configured externally) + this.userEmails = config.userEmails || {}; + } + + /** + * Check if email notifications are enabled + * @returns {boolean} + */ + isEnabled() { + return this.enabled; + } + + /** + * Send a notification via email + * @param {string} eventType - Type of notification event + * @param {Object} data - Event data + * @param {Object} options - Additional options + * @returns {Object} Notification result + */ + async send(eventType, data, options = {}) { + if (!this.enabled) { + return { + success: false, + channel: 'email', + error: 'Email notifications not enabled' + }; + } + + const template = EMAIL_TEMPLATES[eventType]; + if (!template) { + return { + success: false, + channel: 'email', + error: `Unknown notification event type: ${eventType}` + }; + } + + // Get recipient emails + const recipients = options.recipients || []; + if (data.users) { + recipients.push(...data.users.map(u => this.userEmails[u]).filter(Boolean)); + } + + if (recipients.length === 0) { + return { + success: false, + channel: 'email', + error: 'No recipients specified' + }; + } + + const subject = this._renderTemplate(template.subject, data); + const html = this._renderTemplate(template.html, data); + const text = this._renderTemplate(template.text, data); + + try { + await this._sendEmail({ + to: recipients, + subject, + html, + text + }); + + return { + success: true, + channel: 'email', + recipientCount: recipients.length + }; + } catch (error) { + return { + success: false, + channel: 'email', + error: error.message + }; + } + } + + /** + * Send a custom email + * @param {string[]} recipients - Email addresses + * @param {string} subject - Email subject + * @param {string} body - Email body (HTML or text) + * @param {Object} options - Additional options + * @returns {Object} Notification result + */ + async sendCustom(recipients, subject, body, options = {}) { + if (!this.enabled) { + return { + success: false, + channel: 'email', + error: 'Email notifications not enabled' + }; + } + + try { + await this._sendEmail({ + to: recipients, + subject, + html: options.html ? body : undefined, + text: options.html ? undefined : body + }); + + return { + success: true, + channel: 'email', + recipientCount: recipients.length + }; + } catch (error) { + return { + success: false, + channel: 'email', + error: error.message + }; + } + } + + /** + * Get email address for a username + * @param {string} username - GitHub username + * @returns {string|null} Email address or null if not found + */ + getEmailForUser(username) { + return this.userEmails[username] || null; + } + + /** + * Set email address for a username + * @param {string} username - GitHub username + * @param {string} email - Email address + */ + setEmailForUser(username, email) { + this.userEmails[username] = email; + } + + /** + * Send email via configured provider + * @private + */ + async _sendEmail({ to, subject, html, text }) { + // Note: In a real implementation, this would use nodemailer, sendgrid, ses, etc. + // For the workflow engine, this will be handled by the runtime + + const emailPayload = { + from: { + name: this.fromName, + address: this.fromAddress + }, + to: Array.isArray(to) ? to : [to], + subject, + html, + text + }; + + switch (this.provider) { + case 'smtp': + return await this._sendViaSMTP(emailPayload); + case 'sendgrid': + return await this._sendViaSendGrid(emailPayload); + case 'ses': + return await this._sendViaSES(emailPayload); + default: + throw new Error(`Unknown email provider: ${this.provider}`); + } + } + + /** + * Send via SMTP + * @private + */ + async _sendViaSMTP(payload) { + // Placeholder - would use nodemailer in real implementation + console.log('[EMAIL] Would send via SMTP:', payload.subject, 'to', payload.to.join(', ')); + return { messageId: `smtp-${Date.now()}` }; + } + + /** + * Send via SendGrid + * @private + */ + async _sendViaSendGrid(payload) { + // Placeholder - would use @sendgrid/mail in real implementation + console.log('[EMAIL] Would send via SendGrid:', payload.subject, 'to', payload.to.join(', ')); + return { messageId: `sg-${Date.now()}` }; + } + + /** + * Send via AWS SES + * @private + */ + async _sendViaSES(payload) { + // Placeholder - would use @aws-sdk/client-ses in real implementation + console.log('[EMAIL] Would send via SES:', payload.subject, 'to', payload.to.join(', ')); + return { messageId: `ses-${Date.now()}` }; + } + + /** + * Render a template with data + * @private + */ + _renderTemplate(template, data) { + let result = template; + + // Simple mustache-like replacement + result = result.replace(/\{\{(\w+)\}\}/g, (match, key) => { + return data[key] !== undefined ? String(data[key]) : match; + }); + + return result; + } +} + +module.exports = { + EmailNotifier, + EMAIL_TEMPLATES +}; diff --git a/src/modules/bmm/lib/notifications/github-notifier.js b/src/modules/bmm/lib/notifications/github-notifier.js new file mode 100644 index 00000000..881fc42d --- /dev/null +++ b/src/modules/bmm/lib/notifications/github-notifier.js @@ -0,0 +1,382 @@ +/** + * GitHub Notifier - Baseline notification via GitHub @mentions + * + * This is the primary notification channel that's always available. + * Uses GitHub Issues and comments to notify stakeholders via @mentions. + */ + +const NOTIFICATION_TEMPLATES = { + feedback_round_opened: { + subject: '📣 Feedback Requested', + template: `## 📣 Feedback Round Open + +{{mentions}} + +**Document:** {{document_type}}:{{document_key}} +**Version:** v{{version}} +**Deadline:** {{deadline}} + +Please review and provide your feedback by {{deadline}}. + +--- + +[View Document]({{document_url}}) +{{#if actions}} +**Quick Actions:** +{{actions}} +{{/if}} + +_Notification from PRD Crowdsourcing System_` + }, + + feedback_submitted: { + subject: '💬 New Feedback', + template: `## 💬 New Feedback Submitted + +**From:** @{{user}} +**Document:** {{document_type}}:{{document_key}} +**Type:** {{feedback_type}} +**Section:** {{section}} + +--- + +{{summary}} + +--- + +[View Feedback #{{feedback_issue}}]({{feedback_url}}) + +_Notification from PRD Crowdsourcing System_` + }, + + synthesis_complete: { + subject: '🔄 Synthesis Complete', + template: `## 🔄 Synthesis Complete + +**Document:** {{document_type}}:{{document_key}} +**Version:** v{{old_version}} → v{{new_version}} +**Feedback Processed:** {{feedback_count}} items + +--- + +### Summary of Changes + +{{summary}} + +--- + +[View Updated Document]({{document_url}}) + +_Notification from PRD Crowdsourcing System_` + }, + + signoff_requested: { + subject: '✍️ Sign-off Requested', + template: `## ✍️ Sign-off Requested + +{{mentions}} + +**Document:** {{document_type}}:{{document_key}} +**Version:** v{{version}} +**Deadline:** {{deadline}} + +Please review and provide your sign-off decision by {{deadline}}. + +### How to Sign Off + +- ✅ **Approve**: Comment with \`/signoff approve\` +- ✅📝 **Approve with Note**: Comment with \`/signoff approve-note: [your note]\` +- 🚫 **Block**: Comment with \`/signoff block: [reason]\` + +--- + +[View Document]({{document_url}}) + +_Notification from PRD Crowdsourcing System_` + }, + + signoff_received: { + subject: '{{emoji}} Sign-off Received', + template: `## {{emoji}} Sign-off from @{{user}} + +**Decision:** {{decision}} +**Document:** {{document_type}}:{{document_key}} +**Progress:** {{progress_current}}/{{progress_total}} approvals + +{{#if note}} +**Note:** +{{note}} +{{/if}} + +--- + +[View Review Issue #{{review_issue}}]({{review_url}}) + +_Notification from PRD Crowdsourcing System_` + }, + + document_approved: { + subject: '✅ Document Approved', + template: `## ✅ Document Approved! + +**Document:** {{document_type}}:{{document_key}} +**Title:** {{title}} +**Final Version:** v{{version}} +**Approvals:** {{approval_count}}/{{stakeholder_count}} + +All required sign-offs have been received. This document is now approved and ready for implementation. + +--- + +[View Approved Document]({{document_url}}) + +_Notification from PRD Crowdsourcing System_` + }, + + document_blocked: { + subject: '🚫 Document Blocked', + template: `## 🚫 Document Blocked + +**Document:** {{document_type}}:{{document_key}} +**Blocked by:** @{{user}} + +### Blocking Reason + +{{reason}} + +--- + +This blocking concern must be resolved before the document can be approved. + +{{#if feedback_issue}} +[View Blocking Issue #{{feedback_issue}}]({{feedback_url}}) +{{/if}} + +_Notification from PRD Crowdsourcing System_` + }, + + reminder: { + subject: '⏰ Reminder', + template: `## ⏰ Reminder: Action Needed + +{{mentions}} + +**Document:** {{document_type}}:{{document_key}} +**Action:** {{action_needed}} +**Deadline:** {{deadline}} ({{time_remaining}}) + +Please complete your {{action_needed}} by {{deadline}}. + +--- + +[View Document]({{document_url}}) + +_Notification from PRD Crowdsourcing System_` + }, + + deadline_extended: { + subject: '📅 Deadline Extended', + template: `## 📅 Deadline Extended + +**Document:** {{document_type}}:{{document_key}} +**Previous Deadline:** {{old_deadline}} +**New Deadline:** {{new_deadline}} + +{{#if reason}} +**Reason:** {{reason}} +{{/if}} + +--- + +[View Document]({{document_url}}) + +_Notification from PRD Crowdsourcing System_` + } +}; + +class GitHubNotifier { + /** + * Create a new GitHubNotifier + * @param {Object} config - Configuration object + * @param {string} config.owner - Repository owner + * @param {string} config.repo - Repository name + * @param {Object} config.github - GitHub MCP client + */ + constructor(config) { + this.owner = config.owner; + this.repo = config.repo; + this.github = config.github; + } + + /** + * Send a notification via GitHub + * @param {string} eventType - Type of notification event + * @param {Object} data - Event data + * @param {Object} options - Additional options + * @returns {Object} Notification result + */ + async send(eventType, data, options = {}) { + const template = NOTIFICATION_TEMPLATES[eventType]; + if (!template) { + throw new Error(`Unknown notification event type: ${eventType}`); + } + + const message = this._renderTemplate(template.template, data); + + // Determine where to post the notification + if (options.issueNumber) { + // Post as comment on existing issue + return await this._postComment(options.issueNumber, message); + } else if (options.createIssue) { + // Create a new issue + return await this._createIssue( + this._renderTemplate(template.subject, data), + message, + options.labels || [] + ); + } else if (data.review_issue) { + // Default to review issue if available + return await this._postComment(data.review_issue, message); + } + + // If no target specified, return the message for manual handling + return { + success: true, + channel: 'github', + message, + note: 'No target issue specified, message returned for manual handling' + }; + } + + /** + * Send a reminder to pending users + * @param {number} issueNumber - Issue to post reminder on + * @param {string[]} users - Users to remind + * @param {Object} data - Reminder data + * @returns {Object} Notification result + */ + async sendReminder(issueNumber, users, data) { + const reminderData = { + ...data, + mentions: users.map(u => `@${u}`).join(' ') + }; + + return await this.send('reminder', reminderData, { issueNumber }); + } + + /** + * Notify stakeholders via @mentions in issue body or comment + * @param {string[]} users - Users to notify + * @param {string} message - Notification message + * @param {number} issueNumber - Issue to post on + * @returns {Object} Notification result + */ + async notifyStakeholders(users, message, issueNumber) { + const mentions = users.map(u => `@${u}`).join(' '); + const fullMessage = `${mentions}\n\n${message}`; + + return await this._postComment(issueNumber, fullMessage); + } + + /** + * Post a comment on an issue + * @private + */ + async _postComment(issueNumber, body) { + try { + const result = await this.github.addIssueComment({ + owner: this.owner, + repo: this.repo, + issue_number: issueNumber, + body + }); + + return { + success: true, + channel: 'github', + type: 'comment', + issueNumber, + commentId: result.id + }; + } catch (error) { + return { + success: false, + channel: 'github', + error: error.message + }; + } + } + + /** + * Create a new issue + * @private + */ + async _createIssue(title, body, labels) { + try { + const result = await this.github.createIssue({ + owner: this.owner, + repo: this.repo, + title, + body, + labels + }); + + return { + success: true, + channel: 'github', + type: 'issue', + issueNumber: result.number + }; + } catch (error) { + return { + success: false, + channel: 'github', + error: error.message + }; + } + } + + /** + * Render a template with data + * @private + */ + _renderTemplate(template, data) { + let result = template; + + // Simple mustache-like replacement + // Replace {{variable}} + result = result.replace(/\{\{(\w+)\}\}/g, (match, key) => { + return data[key] !== undefined ? String(data[key]) : match; + }); + + // Handle {{#if condition}}...{{/if}} + result = result.replace(/\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, (match, key, content) => { + return data[key] ? content : ''; + }); + + // Handle {{#each array}}...{{/each}} + result = result.replace(/\{\{#each\s+(\w+)\}\}([\s\S]*?)\{\{\/each\}\}/g, (match, key, content) => { + const arr = data[key]; + if (!Array.isArray(arr)) return ''; + return arr.map((item, index) => { + let itemContent = content; + if (typeof item === 'object') { + Object.entries(item).forEach(([k, v]) => { + itemContent = itemContent.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), String(v)); + }); + } else { + itemContent = itemContent.replace(/\{\{this\}\}/g, String(item)); + } + itemContent = itemContent.replace(/\{\{@index\}\}/g, String(index)); + return itemContent; + }).join(''); + }); + + return result; + } +} + +module.exports = { + GitHubNotifier, + NOTIFICATION_TEMPLATES +}; diff --git a/src/modules/bmm/lib/notifications/index.js b/src/modules/bmm/lib/notifications/index.js new file mode 100644 index 00000000..b438f38c --- /dev/null +++ b/src/modules/bmm/lib/notifications/index.js @@ -0,0 +1,57 @@ +/** + * Notifications Module + * + * Multi-channel notification system for PRD/Epic crowdsourcing. + * Supports GitHub @mentions (baseline), Slack webhooks, and Email. + * + * Usage: + * ```javascript + * const { NotificationService } = require('./notifications'); + * + * const notifier = new NotificationService({ + * github: { + * owner: 'myorg', + * repo: 'myrepo', + * github: githubMcpClient + * }, + * slack: { + * enabled: true, + * webhookUrl: 'https://hooks.slack.com/...', + * channel: '#prd-updates' + * }, + * email: { + * enabled: true, + * provider: 'smtp', + * smtp: { host: 'smtp.example.com', port: 587, ... }, + * fromAddress: 'prd-bot@example.com' + * } + * }); + * + * // Send notification + * await notifier.notifyFeedbackRoundOpened(document, stakeholders, deadline); + * ``` + */ + +const { NotificationService, NOTIFICATION_EVENTS, PRIORITY_BEHAVIOR } = require('./notification-service'); +const { GitHubNotifier, NOTIFICATION_TEMPLATES: GITHUB_TEMPLATES } = require('./github-notifier'); +const { SlackNotifier, SLACK_TEMPLATES } = require('./slack-notifier'); +const { EmailNotifier, EMAIL_TEMPLATES } = require('./email-notifier'); + +module.exports = { + // Main service + NotificationService, + + // Individual notifiers (for custom usage) + GitHubNotifier, + SlackNotifier, + EmailNotifier, + + // Constants + NOTIFICATION_EVENTS, + PRIORITY_BEHAVIOR, + + // Templates (for customization) + GITHUB_TEMPLATES, + SLACK_TEMPLATES, + EMAIL_TEMPLATES +}; diff --git a/src/modules/bmm/lib/notifications/notification-service.js b/src/modules/bmm/lib/notifications/notification-service.js new file mode 100644 index 00000000..f8074841 --- /dev/null +++ b/src/modules/bmm/lib/notifications/notification-service.js @@ -0,0 +1,422 @@ +/** + * Notification Service - Multi-channel notification orchestration + * + * Coordinates notifications across GitHub, Slack, and Email channels. + * GitHub @mentions are always used as baseline; Slack and Email are optional. + */ + +const { GitHubNotifier } = require('./github-notifier'); +const { SlackNotifier } = require('./slack-notifier'); +const { EmailNotifier } = require('./email-notifier'); + +/** + * Notification event types and their default channels + */ +const NOTIFICATION_EVENTS = { + feedback_round_opened: { + description: 'PRD/Epic is open for feedback', + defaultChannels: ['github', 'slack', 'email'], + priority: 'normal' + }, + feedback_submitted: { + description: 'New feedback submitted', + defaultChannels: ['github', 'slack'], + priority: 'normal' + }, + synthesis_complete: { + description: 'Feedback synthesis completed', + defaultChannels: ['github', 'slack'], + priority: 'normal' + }, + signoff_requested: { + description: 'Sign-off requested from stakeholders', + defaultChannels: ['github', 'slack', 'email'], + priority: 'high' + }, + signoff_received: { + description: 'Sign-off decision received', + defaultChannels: ['github', 'slack'], + priority: 'normal' + }, + document_approved: { + description: 'Document fully approved', + defaultChannels: ['github', 'slack', 'email'], + priority: 'high' + }, + document_blocked: { + description: 'Document blocked by stakeholder', + defaultChannels: ['github', 'slack', 'email'], + priority: 'urgent' + }, + reminder: { + description: 'Reminder for pending action', + defaultChannels: ['github', 'slack', 'email'], + priority: 'normal' + }, + deadline_extended: { + description: 'Deadline has been extended', + defaultChannels: ['github'], + priority: 'low' + } +}; + +/** + * Priority levels and their behavior + */ +const PRIORITY_BEHAVIOR = { + urgent: { + retryOnFailure: true, + maxRetries: 3, + allChannels: true // Send on all available channels + }, + high: { + retryOnFailure: true, + maxRetries: 2, + allChannels: false + }, + normal: { + retryOnFailure: false, + maxRetries: 1, + allChannels: false + }, + low: { + retryOnFailure: false, + maxRetries: 1, + allChannels: false + } +}; + +class NotificationService { + /** + * Create a new NotificationService + * @param {Object} config - Configuration object + * @param {Object} config.github - GitHub notifier config (required) + * @param {Object} config.slack - Slack notifier config (optional) + * @param {Object} config.email - Email notifier config (optional) + */ + constructor(config) { + // GitHub is always required and enabled + this.channels = { + github: new GitHubNotifier(config.github) + }; + + // Optional channels + if (config.slack?.enabled && config.slack?.webhookUrl) { + this.channels.slack = new SlackNotifier(config.slack); + } + + if (config.email?.enabled && (config.email?.smtp || config.email?.apiKey)) { + this.channels.email = new EmailNotifier(config.email); + } + + this.config = config; + } + + /** + * Get available notification channels + * @returns {string[]} Array of channel names + */ + getAvailableChannels() { + return Object.keys(this.channels); + } + + /** + * Check if a channel is available + * @param {string} channel - Channel name + * @returns {boolean} + */ + isChannelAvailable(channel) { + return !!this.channels[channel]; + } + + /** + * Send a notification across configured channels + * @param {string} eventType - Type of notification event + * @param {Object} data - Event data + * @param {Object} options - Additional options + * @returns {Object} Results from all channels + */ + async notify(eventType, data, options = {}) { + const eventConfig = NOTIFICATION_EVENTS[eventType]; + if (!eventConfig) { + throw new Error(`Unknown notification event type: ${eventType}`); + } + + // Determine which channels to use + let channels = options.channels || eventConfig.defaultChannels; + + // Filter to only available channels + channels = channels.filter(ch => this.isChannelAvailable(ch)); + + // For urgent priority, use all available channels + const priority = options.priority || eventConfig.priority; + const priorityBehavior = PRIORITY_BEHAVIOR[priority]; + + if (priorityBehavior.allChannels) { + channels = this.getAvailableChannels(); + } + + // Ensure GitHub is always included (baseline) + if (!channels.includes('github')) { + channels.unshift('github'); + } + + // Send to all channels + const results = await Promise.all( + channels.map(async (channel) => { + return await this._sendToChannel(channel, eventType, data, options, priorityBehavior); + }) + ); + + // Aggregate results + const aggregated = { + success: results.some(r => r.success), + eventType, + results: results.reduce((acc, r) => { + acc[r.channel] = r; + return acc; + }, {}) + }; + + return aggregated; + } + + /** + * Send a reminder to specific users + * @param {string} documentType - 'prd' or 'epic' + * @param {string} documentKey - Document key + * @param {string[]} users - Users to remind + * @param {Object} reminderData - Reminder data + * @returns {Object} Notification results + */ + async sendReminder(documentType, documentKey, users, reminderData) { + const data = { + document_type: documentType, + document_key: documentKey, + mentions: users.map(u => `@${u}`).join(' '), + users, + ...reminderData + }; + + return await this.notify('reminder', data); + } + + /** + * Notify about feedback round opening + * @param {Object} document - Document data + * @param {string[]} stakeholders - Stakeholders to notify + * @param {string} deadline - Deadline date + * @returns {Object} Notification results + */ + async notifyFeedbackRoundOpened(document, stakeholders, deadline) { + const data = { + document_type: document.type, + document_key: document.key, + title: document.title, + version: document.version, + deadline, + stakeholder_count: stakeholders.length, + mentions: stakeholders.map(s => `@${s}`).join(' '), + users: stakeholders, + document_url: document.url, + review_issue: document.reviewIssue + }; + + return await this.notify('feedback_round_opened', data); + } + + /** + * Notify about new feedback submission + * @param {Object} feedback - Feedback data + * @param {Object} document - Document data + * @returns {Object} Notification results + */ + async notifyFeedbackSubmitted(feedback, document) { + const data = { + document_type: document.type, + document_key: document.key, + user: feedback.submittedBy, + feedback_type: feedback.type, + section: feedback.section, + summary: feedback.summary || feedback.title, + feedback_issue: feedback.issueNumber, + feedback_url: feedback.url, + review_issue: document.reviewIssue + }; + + // Only notify PO (not all stakeholders) + return await this.notify('feedback_submitted', data, { + notifyOnly: [document.owner] + }); + } + + /** + * Notify about synthesis completion + * @param {Object} document - Document data + * @param {Object} synthesis - Synthesis results + * @returns {Object} Notification results + */ + async notifySynthesisComplete(document, synthesis) { + const data = { + document_type: document.type, + document_key: document.key, + old_version: synthesis.oldVersion, + new_version: synthesis.newVersion, + feedback_count: synthesis.feedbackCount, + conflicts_resolved: synthesis.conflictsResolved, + summary: synthesis.summary, + document_url: document.url, + review_issue: document.reviewIssue + }; + + return await this.notify('synthesis_complete', data); + } + + /** + * Notify about sign-off request + * @param {Object} document - Document data + * @param {string[]} stakeholders - Stakeholders to request sign-off from + * @param {string} deadline - Sign-off deadline + * @param {Object} config - Sign-off configuration + * @returns {Object} Notification results + */ + async notifySignoffRequested(document, stakeholders, deadline, config) { + const data = { + document_type: document.type, + document_key: document.key, + title: document.title, + version: document.version, + deadline, + approvals_needed: config.minimum_approvals || Math.ceil(stakeholders.length * 0.5), + mentions: stakeholders.map(s => `@${s}`).join(' '), + users: stakeholders, + document_url: document.url, + signoff_url: document.signoffUrl, + review_issue: document.reviewIssue + }; + + return await this.notify('signoff_requested', data); + } + + /** + * Notify about sign-off received + * @param {Object} signoff - Sign-off data + * @param {Object} document - Document data + * @param {Object} progress - Current progress + * @returns {Object} Notification results + */ + async notifySignoffReceived(signoff, document, progress) { + const emojis = { + approved: '✅', + 'approved-with-note': '✅📝', + blocked: '🚫' + }; + + const data = { + document_type: document.type, + document_key: document.key, + user: signoff.user, + decision: signoff.decision, + emoji: emojis[signoff.decision] || '❓', + note: signoff.note, + progress_current: progress.current, + progress_total: progress.total, + review_issue: document.reviewIssue, + review_url: document.reviewUrl + }; + + return await this.notify('signoff_received', data); + } + + /** + * Notify about document approval + * @param {Object} document - Document data + * @param {number} approvalCount - Number of approvals + * @param {number} stakeholderCount - Total stakeholders + * @returns {Object} Notification results + */ + async notifyDocumentApproved(document, approvalCount, stakeholderCount) { + const data = { + document_type: document.type, + document_key: document.key, + title: document.title, + version: document.version, + approval_count: approvalCount, + stakeholder_count: stakeholderCount, + document_url: document.url + }; + + return await this.notify('document_approved', data); + } + + /** + * Notify about document being blocked + * @param {Object} document - Document data + * @param {Object} block - Block data + * @returns {Object} Notification results + */ + async notifyDocumentBlocked(document, block) { + const data = { + document_type: document.type, + document_key: document.key, + user: block.user, + reason: block.reason, + feedback_issue: block.feedbackIssue, + feedback_url: block.feedbackUrl + }; + + return await this.notify('document_blocked', data); + } + + /** + * Send to a specific channel with retry logic + * @private + */ + async _sendToChannel(channel, eventType, data, options, priorityBehavior) { + const notifier = this.channels[channel]; + if (!notifier) { + return { + success: false, + channel, + error: 'Channel not available' + }; + } + + let lastError = null; + const maxRetries = priorityBehavior.retryOnFailure ? priorityBehavior.maxRetries : 1; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const result = await notifier.send(eventType, data, options); + result.channel = channel; + result.attempt = attempt; + + if (result.success) { + return result; + } + + lastError = result.error; + } catch (error) { + lastError = error.message; + } + + // Wait before retry (exponential backoff) + if (attempt < maxRetries) { + await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt - 1))); + } + } + + return { + success: false, + channel, + error: lastError, + attempts: maxRetries + }; + } +} + +module.exports = { + NotificationService, + NOTIFICATION_EVENTS, + PRIORITY_BEHAVIOR +}; diff --git a/src/modules/bmm/lib/notifications/slack-notifier.js b/src/modules/bmm/lib/notifications/slack-notifier.js new file mode 100644 index 00000000..efe0e694 --- /dev/null +++ b/src/modules/bmm/lib/notifications/slack-notifier.js @@ -0,0 +1,457 @@ +/** + * Slack Notifier - Optional Slack webhook integration + * + * Sends notifications to Slack channels via incoming webhooks. + * This is an optional notification channel that can be enabled in config. + */ + +const SLACK_TEMPLATES = { + feedback_round_opened: { + color: '#36a64f', // Green + title: '📣 Feedback Round Open', + blocks: (data) => [ + { + type: 'header', + text: { type: 'plain_text', text: '📣 Feedback Round Open', emoji: true } + }, + { + type: 'section', + fields: [ + { type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` }, + { type: 'mrkdwn', text: `*Version:*\nv${data.version}` }, + { type: 'mrkdwn', text: `*Deadline:*\n${data.deadline}` }, + { type: 'mrkdwn', text: `*Stakeholders:*\n${data.stakeholder_count}` } + ] + }, + { + type: 'section', + text: { type: 'mrkdwn', text: `Please review and provide feedback by *${data.deadline}*.` } + }, + { + type: 'actions', + elements: [ + { + type: 'button', + text: { type: 'plain_text', text: 'View Document', emoji: true }, + url: data.document_url, + style: 'primary' + } + ] + } + ] + }, + + feedback_submitted: { + color: '#1e90ff', // Blue + title: '💬 New Feedback Submitted', + blocks: (data) => [ + { + type: 'header', + text: { type: 'plain_text', text: '💬 New Feedback', emoji: true } + }, + { + type: 'section', + fields: [ + { type: 'mrkdwn', text: `*From:*\n${data.user}` }, + { type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` }, + { type: 'mrkdwn', text: `*Type:*\n${data.feedback_type}` }, + { type: 'mrkdwn', text: `*Section:*\n${data.section}` } + ] + }, + { + type: 'section', + text: { type: 'mrkdwn', text: `> ${data.summary.substring(0, 200)}${data.summary.length > 200 ? '...' : ''}` } + }, + { + type: 'actions', + elements: [ + { + type: 'button', + text: { type: 'plain_text', text: 'View Feedback', emoji: true }, + url: data.feedback_url + } + ] + } + ] + }, + + synthesis_complete: { + color: '#9932cc', // Purple + title: '🔄 Synthesis Complete', + blocks: (data) => [ + { + type: 'header', + text: { type: 'plain_text', text: '🔄 Synthesis Complete', emoji: true } + }, + { + type: 'section', + fields: [ + { type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` }, + { type: 'mrkdwn', text: `*Version:*\nv${data.old_version} → v${data.new_version}` }, + { type: 'mrkdwn', text: `*Feedback Processed:*\n${data.feedback_count} items` }, + { type: 'mrkdwn', text: `*Conflicts Resolved:*\n${data.conflicts_resolved || 0}` } + ] + }, + { + type: 'section', + text: { type: 'mrkdwn', text: data.summary.substring(0, 500) } + }, + { + type: 'actions', + elements: [ + { + type: 'button', + text: { type: 'plain_text', text: 'View Document', emoji: true }, + url: data.document_url, + style: 'primary' + } + ] + } + ] + }, + + signoff_requested: { + color: '#ffa500', // Orange + title: '✍️ Sign-off Requested', + blocks: (data) => [ + { + type: 'header', + text: { type: 'plain_text', text: '✍️ Sign-off Requested', emoji: true } + }, + { + type: 'section', + fields: [ + { type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` }, + { type: 'mrkdwn', text: `*Version:*\nv${data.version}` }, + { type: 'mrkdwn', text: `*Deadline:*\n${data.deadline}` }, + { type: 'mrkdwn', text: `*Approvals Needed:*\n${data.approvals_needed}` } + ] + }, + { + type: 'section', + text: { type: 'mrkdwn', text: 'Please review and provide your sign-off decision.' } + }, + { + type: 'actions', + elements: [ + { + type: 'button', + text: { type: 'plain_text', text: 'View Document', emoji: true }, + url: data.document_url, + style: 'primary' + }, + { + type: 'button', + text: { type: 'plain_text', text: 'Sign Off', emoji: true }, + url: data.signoff_url + } + ] + } + ] + }, + + signoff_received: { + color: (data) => data.decision === 'blocked' ? '#dc3545' : '#28a745', + title: (data) => `${data.emoji} Sign-off from ${data.user}`, + blocks: (data) => [ + { + type: 'header', + text: { type: 'plain_text', text: `${data.emoji} Sign-off Received`, emoji: true } + }, + { + type: 'section', + fields: [ + { type: 'mrkdwn', text: `*From:*\n${data.user}` }, + { type: 'mrkdwn', text: `*Decision:*\n${data.decision}` }, + { type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` }, + { type: 'mrkdwn', text: `*Progress:*\n${data.progress_current}/${data.progress_total}` } + ] + }, + ...(data.note ? [{ + type: 'section', + text: { type: 'mrkdwn', text: `*Note:* ${data.note}` } + }] : []), + { + type: 'actions', + elements: [ + { + type: 'button', + text: { type: 'plain_text', text: 'View Progress', emoji: true }, + url: data.review_url + } + ] + } + ] + }, + + document_approved: { + color: '#28a745', // Green + title: '✅ Document Approved!', + blocks: (data) => [ + { + type: 'header', + text: { type: 'plain_text', text: '✅ Document Approved!', emoji: true } + }, + { + type: 'section', + fields: [ + { type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` }, + { type: 'mrkdwn', text: `*Title:*\n${data.title}` }, + { type: 'mrkdwn', text: `*Version:*\nv${data.version}` }, + { type: 'mrkdwn', text: `*Approvals:*\n${data.approval_count}/${data.stakeholder_count}` } + ] + }, + { + type: 'section', + text: { type: 'mrkdwn', text: '🎉 All required sign-offs received. Ready for implementation!' } + }, + { + type: 'actions', + elements: [ + { + type: 'button', + text: { type: 'plain_text', text: 'View Document', emoji: true }, + url: data.document_url, + style: 'primary' + } + ] + } + ] + }, + + document_blocked: { + color: '#dc3545', // Red + title: '🚫 Document Blocked', + blocks: (data) => [ + { + type: 'header', + text: { type: 'plain_text', text: '🚫 Document Blocked', emoji: true } + }, + { + type: 'section', + fields: [ + { type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` }, + { type: 'mrkdwn', text: `*Blocked by:*\n${data.user}` } + ] + }, + { + type: 'section', + text: { type: 'mrkdwn', text: `*Reason:*\n${data.reason}` } + }, + { + type: 'section', + text: { type: 'mrkdwn', text: '⚠️ This blocking concern must be resolved before approval.' } + }, + ...(data.feedback_url ? [{ + type: 'actions', + elements: [ + { + type: 'button', + text: { type: 'plain_text', text: 'View Issue', emoji: true }, + url: data.feedback_url, + style: 'danger' + } + ] + }] : []) + ] + }, + + reminder: { + color: '#ffc107', // Yellow + title: '⏰ Reminder: Action Needed', + blocks: (data) => [ + { + type: 'header', + text: { type: 'plain_text', text: '⏰ Reminder: Action Needed', emoji: true } + }, + { + type: 'section', + fields: [ + { type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` }, + { type: 'mrkdwn', text: `*Action:*\n${data.action_needed}` }, + { type: 'mrkdwn', text: `*Deadline:*\n${data.deadline}` }, + { type: 'mrkdwn', text: `*Time Remaining:*\n${data.time_remaining}` } + ] + }, + { + type: 'section', + text: { type: 'mrkdwn', text: `Pending: ${data.pending_users?.join(', ') || 'Unknown'}` } + }, + { + type: 'actions', + elements: [ + { + type: 'button', + text: { type: 'plain_text', text: 'View Document', emoji: true }, + url: data.document_url, + style: 'primary' + } + ] + } + ] + } +}; + +class SlackNotifier { + /** + * Create a new SlackNotifier + * @param {Object} config - Configuration object + * @param {string} config.webhookUrl - Slack incoming webhook URL + * @param {string} config.channel - Default channel (optional, webhook may have default) + * @param {string} config.username - Bot username (optional) + * @param {string} config.iconEmoji - Bot icon emoji (optional) + */ + constructor(config) { + this.webhookUrl = config.webhookUrl; + this.channel = config.channel; + this.username = config.username || 'PRD Crowdsource Bot'; + this.iconEmoji = config.iconEmoji || ':clipboard:'; + this.enabled = !!config.webhookUrl; + } + + /** + * Check if Slack notifications are enabled + * @returns {boolean} + */ + isEnabled() { + return this.enabled; + } + + /** + * Send a notification via Slack + * @param {string} eventType - Type of notification event + * @param {Object} data - Event data + * @param {Object} options - Additional options + * @returns {Object} Notification result + */ + async send(eventType, data, options = {}) { + if (!this.enabled) { + return { + success: false, + channel: 'slack', + error: 'Slack notifications not enabled' + }; + } + + const template = SLACK_TEMPLATES[eventType]; + if (!template) { + return { + success: false, + channel: 'slack', + error: `Unknown notification event type: ${eventType}` + }; + } + + const payload = this._buildPayload(template, data, options); + + try { + const response = await this._sendWebhook(payload); + return { + success: true, + channel: 'slack', + eventType + }; + } catch (error) { + return { + success: false, + channel: 'slack', + error: error.message + }; + } + } + + /** + * Send a custom message to Slack + * @param {string} text - Message text + * @param {Object} options - Additional options (channel, attachments, blocks) + * @returns {Object} Notification result + */ + async sendCustom(text, options = {}) { + if (!this.enabled) { + return { + success: false, + channel: 'slack', + error: 'Slack notifications not enabled' + }; + } + + const payload = { + text, + channel: options.channel || this.channel, + username: this.username, + icon_emoji: this.iconEmoji, + ...options + }; + + try { + await this._sendWebhook(payload); + return { + success: true, + channel: 'slack' + }; + } catch (error) { + return { + success: false, + channel: 'slack', + error: error.message + }; + } + } + + /** + * Build Slack payload from template + * @private + */ + _buildPayload(template, data, options) { + const color = typeof template.color === 'function' + ? template.color(data) + : template.color; + + const title = typeof template.title === 'function' + ? template.title(data) + : template.title; + + const blocks = template.blocks(data); + + return { + channel: options.channel || this.channel, + username: this.username, + icon_emoji: this.iconEmoji, + text: title, // Fallback for notifications + attachments: [ + { + color, + fallback: title, + blocks + } + ] + }; + } + + /** + * Send webhook request + * @private + */ + async _sendWebhook(payload) { + // Note: In a real implementation, this would use fetch or axios + // For the workflow engine, this will be handled by the runtime + const response = await fetch(this.webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + throw new Error(`Slack webhook failed: ${response.status} ${response.statusText}`); + } + + return response; + } +} + +module.exports = { + SlackNotifier, + SLACK_TEMPLATES +}; diff --git a/src/modules/bmm/module.yaml b/src/modules/bmm/module.yaml index e6b8dee1..0357fc26 100644 --- a/src/modules/bmm/module.yaml +++ b/src/modules/bmm/module.yaml @@ -54,3 +54,44 @@ tea_use_playwright_utils: - "Are you using playwright-utils (@seontechnologies/playwright-utils) in your project?\nYou must install packages yourself, or use test architect's *framework command." default: false result: "{value}" + +# GitHub Integration for Enterprise Teams (Phase 1) +github_integration_enabled: + prompt: + - "Enable GitHub Integration for enterprise team coordination?" + - "This enables story locking, real-time progress sync, and PO workflows." + - "Requires: GitHub MCP configured, repository with Issues enabled." + default: false + result: "{value}" + +github_owner: + prompt: "GitHub username or organization name for your repository" + default: "" + result: "{value}" + condition: "github_integration_enabled == true" + +github_repo: + prompt: "GitHub repository name" + default: "{project_name}" + result: "{value}" + condition: "github_integration_enabled == true" + +github_lock_timeout_hours: + prompt: "Story lock timeout in hours (default: 8 = full workday)" + default: 8 + result: "{value}" + condition: "github_integration_enabled == true" + +github_cache_staleness_minutes: + prompt: "Minutes before cached story is considered stale (default: 5)" + default: 5 + result: "{value}" + condition: "github_integration_enabled == true" + +github_scrum_masters: + prompt: + - "GitHub usernames of Scrum Masters (comma-separated)" + - "These users can force-unlock stories locked by others." + default: "" + result: "{value}" + condition: "github_integration_enabled == true" diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/create-epic-draft/instructions.md b/src/modules/bmm/workflows/1-requirements/crowdsource/create-epic-draft/instructions.md new file mode 100644 index 00000000..00a30db7 --- /dev/null +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/create-epic-draft/instructions.md @@ -0,0 +1,600 @@ +# Create Epic Draft - From PRD to Implementation-Ready Epics + +The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml +You MUST have already loaded and processed: {installed_path}/workflow.yaml + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📦 CREATE EPIC FROM PRD +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + Call: mcp__github__get_me() + current_user = response.login + + + ❌ GitHub MCP not accessible + HALT + + + + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:type:prd-review label:review-status:approved is:closed" + }) + + + +❌ No approved PRDs found. + +You need an approved PRD to create epics from. +Use the PRD Dashboard [PD] to see PRD status. + + HALT + + + + approved_prds = response.items.map(issue => { + const labels = issue.labels.map(l => l.name) + const prd_key = labels.find(l => l.startsWith('prd:'))?.replace('prd:', '') + return { + key: prd_key, + title: issue.title.replace(/^(PRD Review|Sign-off):\s*/, '').replace(/\s+v\d+$/, ''), + issue_number: issue.number, + approved_date: issue.closed_at + } + }).filter(p => p.key) + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📄 APPROVED PRDs AVAILABLE +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +{{#each approved_prds}} +[{{@index + 1}}] prd:{{key}} - {{title}} + Approved: {{approved_date}} +{{/each}} + + + + + Select PRD to create epic from (1-{{approved_prds.length}}): + source_prd = approved_prds[parseInt(response) - 1].key + + + +📄 Source PRD: prd:{{source_prd}} + + + + + prd_path = `${docs_dir}/prd/${source_prd}.md` + Read prd_path + + + ❌ PRD document not found: {{prd_path}} + HALT + + + prd_content = file_content + + prd_title = extract_title(prd_content) + prd_version = extract_version(prd_content) + user_stories = extract_user_stories(prd_content) + functional_reqs = extract_functional_requirements(prd_content) + nfrs = extract_non_functional_requirements(prd_content) + constraints = extract_constraints(prd_content) + stakeholders_from_prd = extract_stakeholders(prd_content) + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📄 PRD SUMMARY +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Title:** {{prd_title}} +**Version:** v{{prd_version}} +**User Stories:** {{user_stories.length}} +**Functional Requirements:** {{functional_reqs.length}} +**Non-Functional Requirements:** {{nfrs.length}} + + + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:type:epic-review label:source-prd:{{source_prd}}" + }) + + + + existing_epics = response.items.map(issue => { + const labels = issue.labels.map(l => l.name) + return { + key: labels.find(l => l.startsWith('epic:'))?.replace('epic:', ''), + title: issue.title, + state: issue.state + } + }) + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +⚠️ EXISTING EPICS FROM THIS PRD +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +{{#each existing_epics}} + • epic:{{key}} - {{title}} ({{state}}) +{{/each}} + +Would you like to: +[1] Create additional epic (for different scope) +[2] View existing epics and exit +[3] Cancel + + + Choice: + + HALT + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +⚙️ EPIC CONFIGURATION +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + +Select which user stories this epic will implement: + +{{#each user_stories}} +[{{@index + 1}}] {{id}}: {{title}} + As a {{role}}, I want {{capability}} +{{/each}} + +Enter story numbers (comma-separated) or 'all': + + + Stories to include: + + if (response.toLowerCase() === 'all') { + selected_stories = user_stories + } else { + const indices = response.split(',').map(s => parseInt(s.trim()) - 1) + selected_stories = indices.map(i => user_stories[i]).filter(Boolean) + } + + + +Selected {{selected_stories.length}} user stories for this epic. + + + + + + // Generate suggested epic key + epic_number = (existing_epics?.length || 0) + 1 + suggested_key = `${source_prd}-epic-${epic_number}` + + // Generate suggested title based on selected stories + if (selected_stories.length === 1) { + suggested_title = selected_stories[0].title + } else { + suggested_title = `${prd_title} - Phase ${epic_number}` + } + + + +**Suggested Epic Key:** {{suggested_key}} +**Suggested Title:** {{suggested_title}} + + + + Epic title (or press Enter for suggested): + epic_title = response || suggested_title + + + Epic key (or press Enter for suggested): + epic_key = response || suggested_key + + + + + +PRD Stakeholders: {{stakeholders_from_prd.map(s => '@' + s).join(', ')}} + +Epic reviews typically involve: +- Tech Lead (scope/split decisions) +- PO (priority/acceptance) +- Domain experts (technical feasibility) + + + + + Epic stakeholders (comma-separated usernames, or 'same' for PRD stakeholders): + + if (response.toLowerCase() === 'same') { + stakeholders = stakeholders_from_prd + } else { + stakeholders = response.split(',').map(s => s.trim().replace('@', '')) + } + + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🤖 GENERATING STORY BREAKDOWN +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Analyzing selected user stories and generating implementation stories... + + + + // Use LLM to break down user stories into implementation stories + prompt = `Based on the following PRD user stories, generate a set of implementation stories for an epic. + +PRD Title: ${prd_title} +Epic Title: ${epic_title} + +Selected User Stories: +${selected_stories.map(s => `- ${s.id}: ${s.title}\n As a ${s.role}, I want ${s.capability}, so that ${s.benefit}`).join('\n')} + +Related Functional Requirements: +${functional_reqs.map(fr => `- ${fr.id}: ${fr.title}`).join('\n')} + +Non-Functional Requirements to consider: +${nfrs.map(nfr => `- ${nfr.id}: ${nfr.title}`).join('\n')} + +Generate 3-7 implementation stories that: +1. Are independently deliverable +2. Follow a logical implementation order +3. Include clear acceptance criteria +4. Reference the source user stories +5. Consider technical dependencies + +Output format: +--- +### Story 1: [Title] +**Source US:** [user story id] +**Description:** [brief description] +**Acceptance Criteria:** +- [ ] [criterion 1] +- [ ] [criterion 2] +**Dependencies:** [any dependencies] +**Estimated Complexity:** [S/M/L/XL] +---` + + // LLM generates story breakdown + generated_stories = await llm_generate(prompt) + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📝 PROPOSED STORY BREAKDOWN +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +{{generated_stories}} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Would you like to: +[1] Accept this breakdown +[2] Modify (add/remove/edit stories) +[3] Regenerate with different approach +[4] Cancel + + + Choice: + + Describe your modifications: + + // Regenerate with user feedback + modification_prompt = `${prompt}\n\nUser requested modifications:\n${response}\n\nPlease regenerate the story breakdown incorporating this feedback.` + generated_stories = await llm_generate(modification_prompt) + + +Updated story breakdown: + +{{generated_stories}} + + + + + What approach should be used? + + approach_prompt = `${prompt}\n\nApproach to use:\n${response}\n\nPlease regenerate using this approach.` + generated_stories = await llm_generate(approach_prompt) + + +Regenerated story breakdown: + +{{generated_stories}} + + + + + HALT + + + + + + epic_doc = `# Epic: ${epic_title} + +**Epic Key:** \`epic:${epic_key}\` +**Source PRD:** \`prd:${source_prd}\` (v${prd_version}) +**Version:** 1 +**Status:** Draft +**Created:** ${new Date().toISOString().split('T')[0]} +**Last Updated:** ${new Date().toISOString().split('T')[0]} + +--- + +## Metadata +| Field | Value | +|-------|-------| +| Product Owner | @${current_user} | +| Stakeholders | ${stakeholders.map(s => '@' + s).join(', ')} | +| Tech Lead | TBD | +| Feedback Deadline | TBD | +| Sign-off Deadline | TBD | + +--- + +## Overview + +This epic implements the following user stories from prd:${source_prd}: + +${selected_stories.map(s => `- **${s.id}:** ${s.title}`).join('\n')} + +--- + +## Goals + +${selected_stories.map((s, i) => `${i + 1}. ${s.capability}`).join('\n')} + +--- + +## Implementation Stories + +${generated_stories} + +--- + +## Dependencies + + + +- TBD + +--- + +## Technical Considerations + + + +### From NFRs: +${nfrs.map(nfr => `- ${nfr.title}`).join('\n')} + +### Constraints: +${constraints.map(c => `- ${c}`).join('\n')} + +--- + +## Out of Scope + + + +- TBD + +--- + +## Version History +| Version | Date | Changes | Feedback Incorporated | +|---------|------|---------|----------------------| +| 1 | ${new Date().toISOString().split('T')[0]} | Initial draft from PRD | - | + +--- + +## Sign-off Status +| Stakeholder | Status | Date | Notes | +|-------------|--------|------|-------| +${stakeholders.map(s => `| @${s} | ⏳ Pending | - | - |`).join('\n')} +` + + + epic_path = `${docs_dir}/epics/epic-${epic_key}.md` + Write epic_doc to epic_path + + +✅ Epic document created: {{epic_path}} + + + + + + issue_body = `# 📦 Epic Review: ${epic_title} + +**Epic Key:** \`epic:${epic_key}\` +**Source PRD:** prd:${source_prd} +**Version:** 1 +**Status:** 📝 Draft + +--- + +## Included Stories + +This epic implements user stories from the approved PRD: +${selected_stories.map(s => `- ${s.id}: ${s.title}`).join('\n')} + +--- + +## Document Link + +📄 [Epic Document](${epic_path}) + +--- + +## Story Breakdown + +${generated_stories} + +--- + +## Stakeholders + +${stakeholders.map(s => `- @${s}`).join('\n')} + +--- + +_Created by @${current_user} on ${new Date().toISOString().split('T')[0]}_ +_Ready for feedback round when PO opens it._` + + + Call: mcp__github__issue_write({ + method: 'create', + owner: "{{github_owner}}", + repo: "{{github_repo}}", + title: "Epic Review: {{epic_title}}", + body: issue_body, + labels: ['type:epic-review', `epic:${epic_key}`, `source-prd:${source_prd}`, 'version:1', 'review-status:draft'] + }) + + review_issue = response + + +✅ Review issue created: #{{review_issue.number}} + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ EPIC CREATED SUCCESSFULLY +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Epic:** epic:{{epic_key}} +**Title:** {{epic_title}} +**Source PRD:** prd:{{source_prd}} +**Document:** {{epic_path}} +**Review Issue:** #{{review_issue.number}} + +**Stories:** {{selected_stories.length}} user stories → implementation breakdown +**Stakeholders:** {{stakeholders.length}} + +--- + +**Next Steps:** +1. Review and refine the story breakdown +2. Assign Tech Lead +3. Open feedback round with: "Open feedback for epic:{{epic_key}}" +4. Once feedback is incorporated, request sign-off + +**Quick Actions:** +[OF] Open feedback round +[ED] View Epic Dashboard +[VF] View feedback + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + +## Helper Functions + +```javascript +function extract_title(content) { + const match = content.match(/^#\s+(PRD|Epic):\s*(.+)$/m); + return match ? match[2].trim() : 'Untitled'; +} + +function extract_version(content) { + const match = content.match(/\*\*Version:\*\*\s*(\d+)/); + return match ? match[1] : '1'; +} + +function extract_user_stories(content) { + const stories = []; + const usRegex = /###\s+(US\d+):\s*(.+)\n+As a (.+), I want (.+), so that (.+)/g; + let match; + while ((match = usRegex.exec(content)) !== null) { + stories.push({ + id: match[1], + title: match[2].trim(), + role: match[3].trim(), + capability: match[4].trim(), + benefit: match[5].trim() + }); + } + return stories; +} + +function extract_functional_requirements(content) { + const reqs = []; + const frRegex = /###\s+(FR\d+):\s*(.+)\n+([\s\S]*?)(?=###|$)/g; + let match; + while ((match = frRegex.exec(content)) !== null) { + reqs.push({ + id: match[1], + title: match[2].trim(), + description: match[3].trim() + }); + } + return reqs; +} + +function extract_non_functional_requirements(content) { + const nfrs = []; + const nfrRegex = /###\s+(NFR\d+):\s*(.+)/g; + let match; + while ((match = nfrRegex.exec(content)) !== null) { + nfrs.push({ + id: match[1], + title: match[2].trim() + }); + } + return nfrs; +} + +function extract_constraints(content) { + const section = content.match(/## Constraints\n+([\s\S]*?)(?=\n##|$)/); + if (!section) return []; + + return section[1] + .split('\n') + .filter(line => line.startsWith('-')) + .map(line => line.replace(/^-\s*/, '').trim()); +} + +function extract_stakeholders(content) { + const field = content.match(/\|\s*Stakeholders\s*\|\s*(.+?)\s*\|/); + if (!field) return []; + + return field[1] + .split(/[,\s]+/) + .filter(s => s.startsWith('@')) + .map(s => s.replace('@', '')); +} +``` + +## Natural Language Triggers + +This workflow responds to: +- "Create epic from PRD" +- "Break down PRD into epics" +- "Start epic from [prd]" +- Menu trigger: `CE` diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/create-epic-draft/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/create-epic-draft/workflow.yaml new file mode 100644 index 00000000..3e79fde2 --- /dev/null +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/create-epic-draft/workflow.yaml @@ -0,0 +1,23 @@ +name: create-epic-draft +description: "Create an epic from an approved PRD, splitting user stories into implementation-ready epics" +author: "BMad" +version: "1.0.0" + +config_source: "{project-root}/_bmad/bmm/config.yaml" +output_folder: "{config_source}:output_folder" +cache_dir: "{output_folder}/cache" +docs_dir: "{project-root}/docs" + +github: + owner: "{config_source}:github_owner" + repo: "{config_source}:github_repo" + +# Parameters +source_prd: "" # PRD key to create epic from +epic_key: "" # Optional: override generated epic key +stakeholders: [] # Override default stakeholders + +installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/create-epic-draft" +instructions: "{installed_path}/instructions.md" + +standalone: true diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/create-prd-draft/instructions.md b/src/modules/bmm/workflows/1-requirements/crowdsource/create-prd-draft/instructions.md new file mode 100644 index 00000000..9ba9e8f2 --- /dev/null +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/create-prd-draft/instructions.md @@ -0,0 +1,432 @@ +# Create PRD Draft - Start Async Requirements Collaboration + +The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml +You MUST have already loaded and processed: {installed_path}/workflow.yaml + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📝 CREATE PRD DRAFT +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + Call: mcp__github__get_me() + current_user = response.login + + + ❌ GitHub MCP not accessible - required for PRD coordination + HALT + + + + + +How would you like to create this PRD? + +[1] Start from scratch (guided prompts) +[2] Import from existing BMAD PRD workflow output +[3] Import from product brief document +[4] Use minimal template (fill in later) + + + + Choice (1-4): + creation_method = choice + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📋 BASIC INFORMATION +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + PRD Title (e.g., "User Authentication System"): + prd_title = response + + PRD Key (short identifier, e.g., "user-auth", no spaces): + prd_key = response.toLowerCase().replace(/\s+/g, '-') + + + Check if file exists at: {{docs_dir}}/{{prd_key}}.md + + +⚠️ A PRD with key "{{prd_key}}" already exists. +Would you like to: +[1] Choose a different key +[2] Create a new version of this PRD +[3] Cancel + + Choice: + + Goto step 2 + + + is_new_version = true + Load existing PRD and increment version + + + HALT + + + + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📌 VISION & PROBLEM +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + What is the vision for this product/feature? (1-2 sentences) + vision = response + + What problem does this solve? What pain points does it address? + problem_statement = response + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🎯 GOALS +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + List the primary goals (one per line, or comma-separated): + goals = parse_list(response) + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +👤 USER STORIES +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Enter user stories in format: "As a [role], I want [capability], so that [benefit]" +(Enter empty line when done) + + + user_stories = [] + + User Story (or press Enter to finish): + + break loop + + user_stories.push(response) + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📋 FUNCTIONAL REQUIREMENTS +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +List key functional requirements (one per line, or press Enter to skip for now): + + + Functional Requirements: + functional_reqs = parse_list(response) + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +⚙️ NON-FUNCTIONAL REQUIREMENTS +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +List non-functional requirements (performance, security, etc.): + + + Non-Functional Requirements: + non_functional_reqs = parse_list(response) + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🚫 CONSTRAINTS & OUT OF SCOPE +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + What constraints apply? (technical, timeline, budget, etc.): + constraints = parse_list(response) + + What is explicitly out of scope for this PRD?: + out_of_scope = parse_list(response) + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📥 IMPORT FROM BMAD PRD WORKFLOW +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Looking for existing PRD output in project... + + + Glob for: **/prd-output.md, **/prd-*.md in project + List found files for user selection + + Select file number to import (or path): + import_path = response + Read and parse imported PRD content + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📥 IMPORT FROM PRODUCT BRIEF +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + Path to product brief document: + brief_path = response + Read product brief and extract PRD sections using LLM + + + + + // Minimal template - just placeholders + vision = "[To be defined]" + problem_statement = "[To be defined]" + goals = ["[Goal 1]", "[Goal 2]"] + user_stories = [] + functional_reqs = [] + non_functional_reqs = [] + constraints = [] + out_of_scope = [] + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +👥 STAKEHOLDERS +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Who should review this PRD and provide feedback? +Enter GitHub usernames (one per line, with or without @): + + + stakeholders = [current_user] + + Stakeholder username (or press Enter to finish): + + break loop + + + username = response.replace('@', '') + if (!stakeholders.includes(username)) { + stakeholders.push(username) + } + + + + +Stakeholders: {{stakeholders.map(s => '@' + s).join(', ')}} + + + + + +prd_content = `# PRD: ${prd_title} + +**PRD Key:** \`prd:${prd_key}\` +**Version:** 1 +**Status:** Draft +**Created:** ${new Date().toISOString().split('T')[0]} +**Last Updated:** ${new Date().toISOString().split('T')[0]} + +--- + +## Metadata +| Field | Value | +|-------|-------| +| Product Owner | @${current_user} | +| Stakeholders | ${stakeholders.map(s => '@' + s).join(', ')} | +| Feedback Deadline | [To be set] | +| Sign-off Deadline | [To be set] | + +--- + +## Vision + +${vision} + +## Problem Statement + +${problem_statement} + +## Goals + +${goals.map((g, i) => `${i + 1}. ${g}`).join('\n')} + +## User Stories + +${user_stories.length > 0 + ? user_stories.map((s, i) => `### US${i + 1}: ${extract_story_title(s)}\n${s}\n`).join('\n') + : '*No user stories defined yet.*'} + +## Functional Requirements + +${functional_reqs.length > 0 + ? functional_reqs.map((r, i) => `### FR${i + 1}\n${r}\n`).join('\n') + : '*No functional requirements defined yet.*'} + +## Non-Functional Requirements + +${non_functional_reqs.length > 0 + ? non_functional_reqs.map((r, i) => `### NFR${i + 1}\n${r}\n`).join('\n') + : '*No non-functional requirements defined yet.*'} + +## Constraints + +${constraints.length > 0 + ? constraints.map(c => `- ${c}`).join('\n') + : '*No constraints defined.*'} + +## Out of Scope + +${out_of_scope.length > 0 + ? out_of_scope.map(c => `- ${c}`).join('\n') + : '*Nothing explicitly marked out of scope.*'} + +--- + +## Version History + +| Version | Date | Changes | Feedback Incorporated | +|---------|------|---------|----------------------| +| 1 | ${new Date().toISOString().split('T')[0]} | Initial draft | - | + +--- + +## Sign-off Status + +| Stakeholder | Status | Date | Notes | +|-------------|--------|------|-------| +${stakeholders.map(s => `| @${s} | ⏳ Pending | - | - |`).join('\n')} +` + + + + + + Ensure directory exists: {{docs_dir}} + + + + prd_path = {{docs_dir}}/{{prd_key}}.md + Write prd_content to prd_path + + + + + // Using CacheManager + cacheManager.writePrd(prd_key, prd_content, { + version: 1, + status: 'draft', + stakeholders: stakeholders, + owner: current_user + }) + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ PRD DRAFT CREATED +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Title:** {{prd_title}} +**Key:** prd:{{prd_key}} +**File:** {{prd_path}} +**Stakeholders:** {{stakeholders.length}} + +The PRD has been saved locally. It is NOT yet on GitHub. + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📌 NEXT STEPS +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Your PRD draft is ready. What would you like to do next? + +[1] Open feedback round now (notify stakeholders) +[2] Edit PRD further before sharing +[3] View PRD +[4] Done for now + + + + Choice: + + + +Opening feedback round for prd:{{prd_key}}... + + Load workflow: open-feedback-round with prd_key = prd_key + + + + +To edit the PRD, open: {{prd_path}} + +When ready, run the feedback round with: + "Open feedback for prd:{{prd_key}}" + or use menu trigger: OF + + Exit + + + + Read and display prd_path + Goto step 7 + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +PRD draft saved. Ready for feedback when you are! +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Exit + + + + + +## Helper Functions + +```javascript +// Parse comma or newline separated list +function parse_list(input) { + if (!input || input.trim() === '') return []; + return input + .split(/[,\n]/) + .map(s => s.trim()) + .filter(s => s.length > 0); +} + +// Extract title from user story +function extract_story_title(story) { + const match = story.match(/I want\s+(.+?),?\s+so that/i); + return match ? match[1].slice(0, 40) : 'User Story'; +} +``` + +## Natural Language Triggers + +This workflow responds to: +- "Create a new PRD" +- "Start a PRD for [feature]" +- "I need to write requirements for..." +- Menu trigger: `CP` diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/create-prd-draft/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/create-prd-draft/workflow.yaml new file mode 100644 index 00000000..31909e70 --- /dev/null +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/create-prd-draft/workflow.yaml @@ -0,0 +1,21 @@ +name: create-prd-draft +description: "Create a new PRD draft with stakeholder list - stored as markdown, coordinated via GitHub" +author: "BMad" +version: "1.0.0" + +config_source: "{project-root}/_bmad/bmm/config.yaml" +output_folder: "{config_source}:output_folder" +cache_dir: "{output_folder}/cache" +docs_dir: "{project-root}/docs/prd" + +github: + owner: "{config_source}:github_owner" + repo: "{config_source}:github_repo" + +# PRD creation options +import_from: "" # 'scratch', 'existing-prd', 'product-brief' + +installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/create-prd-draft" +instructions: "{installed_path}/instructions.md" + +standalone: true diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/epic-dashboard/instructions.md b/src/modules/bmm/workflows/1-requirements/crowdsource/epic-dashboard/instructions.md new file mode 100644 index 00000000..5c893b73 --- /dev/null +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/epic-dashboard/instructions.md @@ -0,0 +1,508 @@ +# Epic Dashboard - Central Epic Visibility with PRD Lineage + +The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml +You MUST have already loaded and processed: {installed_path}/workflow.yaml + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📦 EPIC DASHBOARD +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + Call: mcp__github__get_me() + current_user = response.login + + + ❌ GitHub MCP not accessible + HALT + + + + + +Loading epic data from GitHub... + + + + + query = "repo:{{github_owner}}/{{github_repo}} label:type:epic-review" + if (source_prd) { + query += ` label:source-prd:${source_prd}` + } + + + Call: mcp__github__search_issues({ + query: query + }) + review_issues = response.items || [] + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:type:epic-feedback is:open" + }) + feedback_issues = response.items || [] + + + + + epics = {} + status_counts = { draft: 0, feedback: 0, synthesis: 0, signoff: 0, approved: 0 } + prds_with_epics = {} + + // Process review issues to get epic status + for (issue of review_issues) { + const labels = issue.labels.map(l => l.name) + const epic_key = extract_label(labels, 'epic:') + const source_prd_key = extract_label(labels, 'source-prd:') + + if (!epic_key) continue + + if (!epics[epic_key]) { + epics[epic_key] = { + key: epic_key, + title: issue.title.replace(/^Epic Review:\s*/, '').replace(/\s+v\d+$/, ''), + source_prd: source_prd_key, + reviews: [], + feedback: [], + status: 'draft', + stories: 0, + last_activity: issue.updated_at, + issue_number: issue.number + } + } + + epics[epic_key].reviews.push(issue) + + // Track PRDs with epics + if (source_prd_key) { + if (!prds_with_epics[source_prd_key]) { + prds_with_epics[source_prd_key] = [] + } + if (!prds_with_epics[source_prd_key].includes(epic_key)) { + prds_with_epics[source_prd_key].push(epic_key) + } + } + + // Determine current status from most recent review + const review_status = extract_label(labels, 'review-status:') + if (review_status === 'open' && issue.state === 'open') { + epics[epic_key].status = 'feedback' + } else if (review_status === 'synthesis' && issue.state === 'open') { + epics[epic_key].status = 'synthesis' + } else if (review_status === 'signoff' && issue.state === 'open') { + epics[epic_key].status = 'signoff' + } else if (review_status === 'approved' || issue.state === 'closed') { + epics[epic_key].status = 'approved' + } else if (review_status === 'draft') { + epics[epic_key].status = 'draft' + } + + if (new Date(issue.updated_at) > new Date(epics[epic_key].last_activity)) { + epics[epic_key].last_activity = issue.updated_at + } + } + + // Attach feedback to epics + for (issue of feedback_issues) { + const labels = issue.labels.map(l => l.name) + const epic_key = extract_label(labels, 'epic:') + + if (epic_key && epics[epic_key]) { + epics[epic_key].feedback.push({ + id: issue.number, + title: issue.title, + type: extract_label(labels, 'feedback-type:'), + status: extract_label(labels, 'feedback-status:'), + submittedBy: issue.user?.login + }) + } + } + + // Count by status + for (epic of Object.values(epics)) { + status_counts[epic.status] = (status_counts[epic.status] || 0) + 1 + } + + epic_list = Object.values(epics).sort((a, b) => + new Date(b.last_activity) - new Date(a.last_activity) + ) + + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📦 EPIC PORTFOLIO DASHBOARD +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Status Summary:** + 📝 Draft: {{status_counts.draft}} epics + 💬 Feedback: {{status_counts.feedback}} epics (collecting input) + 🔄 Synthesis: {{status_counts.synthesis}} epics (being processed) + ✍️ Sign-off: {{status_counts.signoff}} epics (awaiting approval) + ✅ Approved: {{status_counts.approved}} epics + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Active Epics:** +┌──────────────────┬────────────────────────┬─────────┬──────────────┬──────────────┐ +│ Epic Key │ Title │ Status │ Source PRD │ Activity │ +├──────────────────┼────────────────────────┼─────────┼──────────────┼──────────────┤ +{{#each epic_list}} +│ epic:{{pad_right key 10}} │ {{pad_right title 22}} │ {{status_emoji status}} │ prd:{{pad_right source_prd 8}} │ {{time_ago last_activity}} │ +{{/each}} +└──────────────────┴────────────────────────┴─────────┴──────────────┴──────────────┘ + +{{#if source_prd}} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Filtered by PRD:** prd:{{source_prd}} +{{/if}} + +{{#if attention_needed}} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +⚠️ **Attention Needed:** +{{#each attention_items}} + • {{epic_key}} - {{message}} +{{/each}} +{{/if}} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**PRD Coverage:** +{{#each prds_with_epics}} + • prd:{{@key}} → {{this.length}} epic(s) +{{/each}} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + Goto step 4 (interactive menu) + + + + + + epic = epics[epic_key] + + + +❌ Epic not found: epic:{{epic_key}} + + epic_key = '' + Goto step 2 + + + + // Load epic document for story count + epic_path = `${docs_dir}/epics/epic-${epic_key}.md` + Read epic_path + epic_content = file_content || '' + + stories = extract_epic_stories(epic_content) + tech_lead = extract_tech_lead(epic_content) + dependencies = extract_dependencies(epic_content) + + // Get active review issue + active_review = epic.reviews.find(r => r.state === 'open') + + // Count feedback by status + new_feedback = epic.feedback.filter(f => f.status === 'new').length + reviewed_feedback = epic.feedback.filter(f => f.status === 'reviewed').length + + // Get stakeholders who haven't responded + if (active_review) { + stakeholders = active_review.assignees?.map(a => a.login) || [] + + // Parse sign-off labels + signoff_labels = active_review.labels + .map(l => l.name) + .filter(l => l.startsWith('signoff-')) + + signed_off = signoff_labels.map(l => { + const match = l.match(/^signoff-(.+)-(approved|approved-with-note|blocked)$/) + return match ? { user: match[1], status: match[2] } : null + }).filter(Boolean) + + pending_stakeholders = stakeholders.filter(s => + !signed_off.some(so => so.user === s) + ) + } + + // Count feedback by type + feedback_by_type = {} + for (f of epic.feedback) { + const type = f.type || 'general' + feedback_by_type[type] = (feedback_by_type[type] || 0) + 1 + } + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📦 EPIC DETAIL: epic:{{epic_key}} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Title:** {{epic.title}} +**Status:** {{status_emoji epic.status}} {{epic.status}} +**Source PRD:** prd:{{epic.source_prd}} +**Tech Lead:** {{tech_lead || 'TBD'}} +**Last Updated:** {{time_ago epic.last_activity}} +{{#if active_review}} +**Review Issue:** #{{active_review.number}} +{{/if}} + +━━━ STORY BREAKDOWN ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Total Stories:** {{stories.length}} + +{{#each stories}} + {{@index + 1}}. {{title}} ({{complexity || 'TBD'}}) +{{/each}} + +━━━ DEPENDENCIES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +{{#if dependencies.length}} +{{#each dependencies}} + • {{this}} +{{/each}} +{{else}} + None specified +{{/if}} + +━━━ FEEDBACK PROGRESS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Total Feedback:** {{epic.feedback.length}} items + ├── 🆕 New: {{new_feedback}} + ├── 👀 Reviewed: {{reviewed_feedback}} + └── ✅ Processed: {{epic.feedback.length - new_feedback - reviewed_feedback}} + +{{#if epic.feedback.length}} +**By Type:** +{{#each feedback_by_type}} + • {{@key}}: {{this}} +{{/each}} +{{/if}} + +{{#if (eq epic.status 'signoff')}} +━━━ SIGN-OFF PROGRESS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +{{#each signed_off}} + {{#if (eq status 'approved')}}✅{{/if}}{{#if (eq status 'approved-with-note')}}✅📝{{/if}}{{#if (eq status 'blocked')}}🚫{{/if}} @{{user}} - {{status}} +{{/each}} + +{{#each pending_stakeholders}} + ⏳ @{{this}} - Pending +{{/each}} + +**Progress:** {{signed_off.length}} / {{stakeholders.length}} +{{/if}} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + + +**Actions:** +[1-{{epic_list.length}}] View specific epic (enter number) +[C] Create new epic from PRD +[F] View feedback for an epic +[S] Synthesize epic feedback +[P] Filter by PRD +[R] Refresh +[B] Back to portfolio (if in detail view) +[Q] Quit + + + + Choice: + + + selected_epic = epic_list[parseInt(choice) - 1] + epic_key = selected_epic.key + Goto step 3 + + + + Load workflow: create-epic-draft + + + + Enter epic key: + Load workflow: view-feedback with document_key = response, document_type = 'epic' + + + + Enter epic key: + Load workflow: synthesize-epic-feedback with epic_key = response + + + + Enter PRD key to filter by (or 'all'): + source_prd = (response.toLowerCase() === 'all') ? '' : response + Goto step 1 + + + + Goto step 1 + + + + epic_key = '' + Goto step 2 + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Epic Dashboard closed. +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Exit + + + Goto step 4 + + + + +## Helper Functions + +```javascript +// Extract label value by prefix +function extract_label(labels, prefix) { + for (const label of labels) { + if (label.startsWith(prefix)) { + return label.replace(prefix, ''); + } + } + return null; +} + +// Get status emoji +function status_emoji(status) { + const emojis = { + draft: '📝', + feedback: '💬', + synthesis: '🔄', + signoff: '✍️', + approved: '✅' + }; + return emojis[status] || '❓'; +} + +// Format time ago +function time_ago(timestamp) { + const now = new Date(); + const then = new Date(timestamp); + const hours = Math.floor((now - then) / (1000 * 60 * 60)); + + if (hours < 1) return 'just now'; + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days === 1) return '1 day ago'; + if (days < 7) return `${days} days ago`; + return then.toISOString().split('T')[0]; +} + +// Pad string to right +function pad_right(str, length) { + if (!str) str = ''; + return (str + ' '.repeat(length)).slice(0, length); +} + +// Extract epic stories from content +function extract_epic_stories(content) { + const stories = []; + const storyRegex = /###\s+Story\s+\d+:\s*(.+)\n+([\s\S]*?)(?=###\s+Story|---|\n##|$)/gi; + let match; + while ((match = storyRegex.exec(content)) !== null) { + const title = match[1].trim(); + const body = match[2]; + const complexityMatch = body.match(/\*\*(?:Estimated )?Complexity:\*\*\s*(\w+)/i); + + stories.push({ + title: title, + complexity: complexityMatch ? complexityMatch[1] : null + }); + } + return stories; +} + +// Extract tech lead from content +function extract_tech_lead(content) { + const match = content.match(/\|\s*Tech Lead\s*\|\s*(@?\w+)\s*\|/); + return match ? match[1] : null; +} + +// Extract dependencies from content +function extract_dependencies(content) { + const section = content.match(/## Dependencies\n+([\s\S]*?)(?=\n##|$)/); + if (!section) return []; + + return section[1] + .split('\n') + .filter(line => line.startsWith('-')) + .map(line => line.replace(/^-\s*/, '').trim()) + .filter(line => line && line !== 'TBD'); +} + +// Find items needing attention +function find_attention_items(epics) { + const items = []; + + for (const epic of Object.values(epics)) { + const hours_since_activity = (Date.now() - new Date(epic.last_activity)) / (1000 * 60 * 60); + + // Check for stale feedback rounds + if (epic.status === 'feedback' && hours_since_activity > 72) { // 3 days + items.push({ + epic_key: `epic:${epic.key}`, + message: `No activity for ${Math.floor(hours_since_activity / 24)} days` + }); + } + + // Check for blocked sign-offs + if (epic.status === 'signoff') { + const blocked = epic.reviews.some(r => + r.labels.some(l => l.name.includes('-blocked')) + ); + if (blocked) { + items.push({ + epic_key: `epic:${epic.key}`, + message: 'Has blocking concerns' + }); + } + } + + // Check for epics with unprocessed feedback + const new_feedback = epic.feedback.filter(f => f.status === 'new').length; + if (new_feedback >= 5) { + items.push({ + epic_key: `epic:${epic.key}`, + message: `${new_feedback} unprocessed feedback items` + }); + } + } + + return items; +} +``` + +## Natural Language Triggers + +This workflow responds to: +- "Show epic dashboard" +- "What epics are in progress?" +- "Epic status" +- "View all epics" +- "Epics from PRD [key]" +- Menu trigger: `ED` diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/epic-dashboard/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/epic-dashboard/workflow.yaml new file mode 100644 index 00000000..17205d87 --- /dev/null +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/epic-dashboard/workflow.yaml @@ -0,0 +1,22 @@ +name: epic-dashboard +description: "Central visibility hub for tracking all epics with PRD lineage and story breakdown" +author: "BMad" +version: "1.0.0" + +config_source: "{project-root}/_bmad/bmm/config.yaml" +output_folder: "{config_source}:output_folder" +cache_dir: "{output_folder}/cache" +docs_dir: "{project-root}/docs" + +github: + owner: "{config_source}:github_owner" + repo: "{config_source}:github_repo" + +# Optional filter +epic_key: "" # Empty for all epics, or specific key for detail view +source_prd: "" # Filter by source PRD + +installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/epic-dashboard" +instructions: "{installed_path}/instructions.md" + +standalone: true diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/my-tasks/instructions.md b/src/modules/bmm/workflows/1-requirements/crowdsource/my-tasks/instructions.md new file mode 100644 index 00000000..4c9ed0fd --- /dev/null +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/my-tasks/instructions.md @@ -0,0 +1,336 @@ +# My Tasks - Unified Inbox for PRD & Epic Collaboration + +The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml +You MUST have already loaded and processed: {installed_path}/workflow.yaml + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📋 MY TASKS +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + Call: mcp__github__get_me() + current_user = response.login + + + ❌ GitHub MCP not accessible - cannot fetch tasks + HALT + + + +Checking tasks for @{{current_user}}... + + + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} type:issue label:type:prd-review label:review-status:open assignee:{{current_user}} is:open" + }) + prd_feedback_issues = response.items || [] + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} type:issue label:type:prd-review label:review-status:signoff assignee:{{current_user}} is:open" + }) + prd_signoff_issues = response.items || [] + + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} type:issue label:type:prd-review is:open mentions:{{current_user}}" + }) + prd_mentioned_issues = response.items || [] + + // Filter by status + prd_feedback_issues = prd_mentioned_issues.filter(i => + i.labels.some(l => l.name === 'review-status:open') + ) + prd_signoff_issues = prd_mentioned_issues.filter(i => + i.labels.some(l => l.name === 'review-status:signoff') + ) + + + + + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} type:issue label:type:epic-review label:review-status:open assignee:{{current_user}} is:open" + }) + epic_feedback_issues = response.items || [] + + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} type:issue label:type:epic-review label:review-status:open is:open mentions:{{current_user}}" + }) + epic_feedback_issues = response.items || [] + + + + + + +now = new Date() +all_tasks = [] + +// Process PRD feedback tasks +for (issue of prd_feedback_issues) { + deadline = extract_deadline(issue) + days_remaining = deadline ? days_until(deadline) : null + + all_tasks.push({ + type: 'prd-feedback', + issue: issue, + prd_key: extract_label(issue, 'prd:'), + title: issue.title.replace(/^PRD Review:\s*/, ''), + action: '💬 Give Feedback', + deadline: deadline, + days_remaining: days_remaining, + urgency: calculate_urgency(days_remaining) + }) +} + +// Process PRD sign-off tasks +for (issue of prd_signoff_issues) { + deadline = extract_deadline(issue) + days_remaining = deadline ? days_until(deadline) : null + + all_tasks.push({ + type: 'prd-signoff', + issue: issue, + prd_key: extract_label(issue, 'prd:'), + title: issue.title.replace(/^PRD Review:\s*/, ''), + action: '✍️ Sign-off', + deadline: deadline, + days_remaining: days_remaining, + urgency: calculate_urgency(days_remaining) + }) +} + +// Process Epic feedback tasks +for (issue of epic_feedback_issues) { + deadline = extract_deadline(issue) + days_remaining = deadline ? days_until(deadline) : null + + all_tasks.push({ + type: 'epic-feedback', + issue: issue, + epic_key: extract_label(issue, 'epic:'), + title: issue.title.replace(/^Epic Review:\s*/, ''), + action: '💬 Give Feedback', + deadline: deadline, + days_remaining: days_remaining, + urgency: calculate_urgency(days_remaining) + }) +} + +// Sort by urgency (urgent first, then deadline, then type) +all_tasks.sort((a, b) => { + if (a.urgency !== b.urgency) return a.urgency - b.urgency + if (a.days_remaining !== b.days_remaining) { + if (a.days_remaining === null) return 1 + if (b.days_remaining === null) return -1 + return a.days_remaining - b.days_remaining + } + return 0 +}) + +urgent_tasks = all_tasks.filter(t => t.urgency === 1) +pending_tasks = all_tasks.filter(t => t.urgency === 2) +no_deadline_tasks = all_tasks.filter(t => t.urgency === 3) + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ NO PENDING TASKS +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +You're all caught up! No PRDs or Epics are waiting for your input. + +**Other Actions:** +[PD] View PRD Dashboard +[ED] View Epic Dashboard +[DS] View Sprint Dashboard + + HALT + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📋 MY TASKS - @{{current_user}} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + +🔴 URGENT (Deadline Soon) +┌──────────────────┬────────────────────┬───────────────┐ +│ Document │ Action Needed │ Deadline │ +├──────────────────┼────────────────────┼───────────────┤ +{{#each urgent_tasks}} +│ {{pad_right document_key 16}} │ {{pad_right action 18}} │ {{format_deadline deadline days_remaining}} │ +{{/each}} +└──────────────────┴────────────────────┴───────────────┘ + + + + + +📋 PENDING +┌──────────────────┬────────────────────┬───────────────┐ +│ Document │ Action Needed │ Deadline │ +├──────────────────┼────────────────────┼───────────────┤ +{{#each pending_tasks}} +│ {{pad_right document_key 16}} │ {{pad_right action 18}} │ {{format_deadline deadline days_remaining}} │ +{{/each}} +└──────────────────┴────────────────────┴───────────────┘ + + + + + +📝 NO DEADLINE SET +{{#each no_deadline_tasks}} + • {{document_key}}: {{action}} +{{/each}} + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Quick Actions:** +{{#each all_tasks as |task index|}} +[{{add index 1}}] {{task.action}} on {{task.document_key}} +{{/each}} + +**Other Actions:** +[PD] View PRD Dashboard +[ED] View Epic Dashboard +[R] Refresh +[Q] Quit + + + + Choice (number or letter): + + + selected_task = all_tasks[parseInt(choice) - 1] + + + +Opening feedback submission for PRD: {{selected_task.prd_key}} + + Load workflow: submit-feedback with document_key = selected_task.prd_key, document_type = 'prd' + + + + +Opening sign-off for PRD: {{selected_task.prd_key}} + + Load workflow: submit-signoff with document_key = selected_task.prd_key, document_type = 'prd' + + + + +Opening feedback submission for Epic: {{selected_task.epic_key}} + + Load workflow: submit-feedback with document_key = selected_task.epic_key, document_type = 'epic' + + + + + Load workflow: prd-dashboard + + + + Load workflow: epic-dashboard + + + + Goto step 1 (refresh) + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +My Tasks closed. +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Exit + + + + + +## Helper Functions + +```javascript +// Extract deadline from issue body (looks for **Deadline:** pattern) +function extract_deadline(issue) { + const match = issue.body?.match(/\*\*Deadline:\*\*\s*(\d{4}-\d{2}-\d{2})/); + return match ? match[1] : null; +} + +// Extract label value by prefix +function extract_label(issue, prefix) { + for (const label of issue.labels) { + if (label.name.startsWith(prefix)) { + return label.name.replace(prefix, ''); + } + } + return issue.number.toString(); +} + +// Calculate days until deadline +function days_until(deadline) { + const target = new Date(deadline); + const now = new Date(); + const diff = target - now; + return Math.ceil(diff / (1000 * 60 * 60 * 24)); +} + +// Calculate urgency level (1 = urgent, 2 = pending, 3 = no deadline) +function calculate_urgency(days_remaining) { + if (days_remaining === null) return 3; + if (days_remaining <= 2) return 1; + return 2; +} + +// Format deadline for display +function format_deadline(deadline, days_remaining) { + if (!deadline) return 'No deadline'; + if (days_remaining <= 0) return '⚠️ OVERDUE!'; + if (days_remaining === 1) return 'Tomorrow!'; + return `${days_remaining} days`; +} + +// Pad string to right +function pad_right(str, length) { + return (str + ' '.repeat(length)).slice(0, length); +} +``` + +## Natural Language Triggers + +This workflow responds to: +- "What needs my attention?" +- "What PRDs need my input?" +- "What's waiting for me?" +- "My tasks" +- "Show my pending tasks" +- Menu trigger: `MT` diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/my-tasks/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/my-tasks/workflow.yaml new file mode 100644 index 00000000..401a379d --- /dev/null +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/my-tasks/workflow.yaml @@ -0,0 +1,17 @@ +name: my-tasks +description: "Unified inbox showing PRDs and Epics needing your feedback or sign-off" +author: "BMad" +version: "1.0.0" + +config_source: "{project-root}/_bmad/bmm/config.yaml" +output_folder: "{config_source}:output_folder" +cache_dir: "{output_folder}/cache" + +github: + owner: "{config_source}:github_owner" + repo: "{config_source}:github_repo" + +installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/my-tasks" +instructions: "{installed_path}/instructions.md" + +standalone: true diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/open-epic-feedback/instructions.md b/src/modules/bmm/workflows/1-requirements/crowdsource/open-epic-feedback/instructions.md new file mode 100644 index 00000000..10225161 --- /dev/null +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/open-epic-feedback/instructions.md @@ -0,0 +1,405 @@ +# Open Epic Feedback - Collect Stakeholder Input on Story Breakdown + +The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml +You MUST have already loaded and processed: {installed_path}/workflow.yaml + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +💬 OPEN EPIC FEEDBACK ROUND +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + Call: mcp__github__get_me() + current_user = response.login + + + ❌ GitHub MCP not accessible + HALT + + + + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:type:epic-review label:review-status:draft is:open" + }) + + + +❌ No draft epics found. + +Create an epic first with: "Create epic from PRD" + + HALT + + + + draft_epics = response.items.map(issue => { + const labels = issue.labels.map(l => l.name) + return { + key: labels.find(l => l.startsWith('epic:'))?.replace('epic:', ''), + title: issue.title.replace(/^Epic Review:\s*/, ''), + source_prd: labels.find(l => l.startsWith('source-prd:'))?.replace('source-prd:', ''), + issue_number: issue.number + } + }).filter(e => e.key) + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📦 DRAFT EPICS AVAILABLE +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +{{#each draft_epics}} +[{{@index + 1}}] epic:{{key}} - {{title}} + Source: prd:{{source_prd}} | Issue: #{{issue_number}} +{{/each}} + + + + + Select epic (1-{{draft_epics.length}}): + epic_key = draft_epics[parseInt(response) - 1].key + review_issue_number = draft_epics[parseInt(response) - 1].issue_number + + + +📦 Selected: epic:{{epic_key}} + + + + + epic_path = `${docs_dir}/epics/epic-${epic_key}.md` + Read epic_path + + + ❌ Epic document not found: {{epic_path}} + HALT + + + epic_content = file_content + + title = extract_title(epic_content) + version = extract_version(epic_content) + stakeholders = extract_stakeholders(epic_content) + source_prd = extract_source_prd(epic_content) + stories = extract_epic_stories(epic_content) + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📦 EPIC SUMMARY +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Title:** {{title}} +**Version:** v{{version}} +**Source PRD:** prd:{{source_prd}} +**Stories:** {{stories.length}} implementation stories +**Stakeholders:** {{stakeholders.length}} + + + + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:type:epic-review label:epic:{{epic_key}} is:open" + }) + + + ❌ No review issue found for epic:{{epic_key}} + HALT + + + review_issue = response.items[0] + review_issue_number = review_issue.number + + + + Call: mcp__github__issue_read({ + method: 'get', + owner: github_owner, + repo: github_repo, + issue_number: review_issue_number + }) + review_issue = response + + + +📋 Review Issue: #{{review_issue_number}} + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +⚙️ FEEDBACK ROUND CONFIGURATION +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Epic feedback focuses on: +- **Scope**: Is the epic size right? Should it be split/merged? +- **Story Breakdown**: Are stories well-defined and independent? +- **Dependencies**: Are technical dependencies captured? +- **Priority**: Is the story order correct? +- **Technical Risk**: Are there architecture concerns? + + + + Days until feedback deadline (default: {{feedback_days}}): + + feedback_days = parseInt(response) || feedback_days + deadline = new Date() + deadline.setDate(deadline.getDate() + feedback_days) + deadline_str = deadline.toISOString().split('T')[0] + + + +**Deadline:** {{deadline_str}} ({{feedback_days}} days from now) + +**Stakeholders to notify:** +{{#each stakeholders}} + • @{{this}} +{{/each}} + + + + Add additional stakeholders? (comma-separated or 'none'): + + if (response.toLowerCase() !== 'none' && response.trim()) { + additional = response.split(',').map(s => s.trim().replace('@', '')) + stakeholders = [...new Set([...stakeholders, ...additional])] + } + + + + + + updated_content = epic_content + .replace(/\*\*Status:\*\* .+/, '**Status:** Feedback') + .replace(/\| Feedback Deadline \| .+ \|/, `| Feedback Deadline | ${deadline_str} |`) + + + Write updated_content to epic_path + + +✅ Epic status updated to 'Feedback' + + + + + + // Get current labels + current_labels = review_issue.labels.map(l => l.name) + + // Update status label + new_labels = current_labels + .filter(l => !l.startsWith('review-status:')) + .concat(['review-status:open']) + + + Call: mcp__github__issue_write({ + method: 'update', + owner: "{{github_owner}}", + repo: "{{github_repo}}", + issue_number: review_issue_number, + labels: new_labels, + assignees: stakeholders + }) + + +✅ Review issue updated with stakeholders + + + + + + feedback_comment = `## 💬 Feedback Round Open + +${stakeholders.map(s => '@' + s).join(' ')} + +**Epic:** epic:${epic_key} +**Version:** v${version} +**Deadline:** ${deadline_str} +**Source PRD:** prd:${source_prd} + +--- + +## 📦 Story Breakdown + +${stories.map((s, i) => `${i + 1}. **${s.title}** (${s.complexity || 'TBD'})\n ${s.description || ''}`).join('\n\n')} + +--- + +## Feedback Types for Epics + +Please provide feedback on: + +- 🔍 **Scope**: Is this epic the right size? Should it be split or merged with another? +- 📝 **Story Breakdown**: Are stories well-defined, independent, and testable? +- 🔗 **Dependencies**: Are technical dependencies correctly identified? +- ⚡ **Priority**: Is the story order optimal for delivery? +- ⚠️ **Technical Risk**: Are there architectural or technical concerns? +- ➕ **Missing Stories**: Should additional stories be added? + +--- + +### How to Submit Feedback + +Reply with structured feedback or use the feedback workflow: + +\`\`\` +/feedback epic:${epic_key} +Section: [Story Breakdown / Dependencies / Technical Risk / etc.] +Type: [scope / dependency / priority / technical_risk / story_split / missing_story] +Feedback: [Your detailed feedback] +\`\`\` + +Or simply comment with your thoughts. + +--- + +_Feedback requested by @${current_user} on ${new Date().toISOString().split('T')[0]}_ +_Please respond by ${deadline_str}_` + + + Call: mcp__github__add_issue_comment({ + owner: "{{github_owner}}", + repo: "{{github_repo}}", + issue_number: review_issue_number, + body: feedback_comment + }) + + +✅ Feedback request posted + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ EPIC FEEDBACK ROUND OPENED +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Epic:** epic:{{epic_key}} +**Title:** {{title}} +**Review Issue:** #{{review_issue_number}} +**Deadline:** {{deadline_str}} +**Stakeholders:** {{stakeholders.length}} notified + +--- + +All stakeholders have been @mentioned and will receive +GitHub notifications. + +**Monitor progress with:** +- "View feedback for epic:{{epic_key}}" +- "Epic Dashboard" or [ED] + +**After collecting feedback:** +- "Synthesize feedback for epic:{{epic_key}}" + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + +## Helper Functions + +```javascript +function extract_title(content) { + const match = content.match(/^#\s+(PRD|Epic):\s*(.+)$/m); + return match ? match[2].trim() : 'Untitled'; +} + +function extract_version(content) { + const match = content.match(/\*\*Version:\*\*\s*(\d+)/); + return match ? match[1] : '1'; +} + +function extract_source_prd(content) { + const match = content.match(/\*\*Source PRD:\*\*\s*`?prd:([^`\s]+)`?/); + return match ? match[1] : null; +} + +function extract_stakeholders(content) { + const field = content.match(/\|\s*Stakeholders\s*\|\s*(.+?)\s*\|/); + if (!field) return []; + + return field[1] + .split(/[,\s]+/) + .filter(s => s.startsWith('@')) + .map(s => s.replace('@', '')); +} + +function extract_epic_stories(content) { + const stories = []; + // Match story sections in various formats + const storyRegex = /###\s+Story\s+\d+:\s*(.+)\n+([\s\S]*?)(?=###|---|\n##|$)/gi; + let match; + while ((match = storyRegex.exec(content)) !== null) { + const title = match[1].trim(); + const body = match[2]; + + // Extract complexity + const complexityMatch = body.match(/\*\*Estimated Complexity:\*\*\s*(\w+)/i); + + // Extract description + const descMatch = body.match(/\*\*Description:\*\*\s*(.+)/); + + stories.push({ + title: title, + description: descMatch ? descMatch[1].trim() : '', + complexity: complexityMatch ? complexityMatch[1] : null + }); + } + return stories; +} +``` + +## Epic-Specific Feedback Types + +```yaml +feedback_types: + scope_concern: + label: "Scope" + description: "Epic is too large/small, should be split/merged" + emoji: "🔍" + + story_split: + label: "Story Breakdown" + description: "Story needs to be split, combined, or redefined" + emoji: "📝" + + dependency: + label: "Dependency" + description: "Missing or incorrect dependency identification" + emoji: "🔗" + + priority_question: + label: "Priority" + description: "Story order or priority should change" + emoji: "⚡" + + technical_risk: + label: "Technical Risk" + description: "Architecture or technical feasibility concern" + emoji: "⚠️" + + missing_story: + label: "Missing Story" + description: "An additional story should be added" + emoji: "➕" +``` + +## Natural Language Triggers + +This workflow responds to: +- "Open feedback for epic" +- "Start epic feedback round" +- "Get feedback on epic" +- Menu trigger: `OE` diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/open-epic-feedback/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/open-epic-feedback/workflow.yaml new file mode 100644 index 00000000..ca36f1e0 --- /dev/null +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/open-epic-feedback/workflow.yaml @@ -0,0 +1,22 @@ +name: open-epic-feedback +description: "Open feedback round for an epic, focusing on scope, story breakdown, and technical feasibility" +author: "BMad" +version: "1.0.0" + +config_source: "{project-root}/_bmad/bmm/config.yaml" +output_folder: "{config_source}:output_folder" +cache_dir: "{output_folder}/cache" +docs_dir: "{project-root}/docs" + +github: + owner: "{config_source}:github_owner" + repo: "{config_source}:github_repo" + +# Parameters +epic_key: "" +feedback_days: 3 # Default deadline in days + +installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/open-epic-feedback" +instructions: "{installed_path}/instructions.md" + +standalone: true diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/open-feedback-round/instructions.md b/src/modules/bmm/workflows/1-requirements/crowdsource/open-feedback-round/instructions.md new file mode 100644 index 00000000..c28af0b1 --- /dev/null +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/open-feedback-round/instructions.md @@ -0,0 +1,406 @@ +# Open Feedback Round - Start Async Stakeholder Review + +The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml +You MUST have already loaded and processed: {installed_path}/workflow.yaml + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🔔 OPEN FEEDBACK ROUND +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + Call: mcp__github__get_me() + current_user = response.login + + + ❌ GitHub MCP not accessible - required for coordination + HALT + + + + + + Which document? Enter key (e.g., "user-auth" for PRD, "2" for Epic): + document_key = response + + + + doc_path = {{docs_dir}}/prd/{{document_key}}.md + document_type = 'prd' + doc_prefix = 'PRD' + doc_label_prefix = 'prd' + + + + doc_path = {{docs_dir}}/epics/epic-{{document_key}}.md + doc_prefix = 'Epic' + doc_label_prefix = 'epic' + + + + Read doc_path + + +❌ Document not found: {{doc_path}} + +Please ensure the {{document_type}} exists. Use: +- "Create PRD" (CP) to create a new PRD +- Check that the key is correct + + HALT + + doc_content = file_content + + + + + // Parse document metadata + title = extract_between(doc_content, '# PRD: ', '\n') || + extract_between(doc_content, '# Epic: ', '\n') || + document_key + version = extract_field(doc_content, 'Version') || '1' + status = extract_field(doc_content, 'Status') || 'draft' + stakeholders = extract_stakeholders(doc_content) + owner = extract_field(doc_content, 'Product Owner')?.replace('@', '') + + + + + +⚠️ This {{document_type}} is currently in status: {{status}} + +Feedback rounds can only be opened for documents in 'draft' or 'feedback' status. +Current status suggests this may already be in synthesis or sign-off. + + Continue anyway? (y/n): + + HALT + + + + +📄 Document: {{title}} +📌 Key: {{doc_label_prefix}}:{{document_key}} +📊 Version: {{version}} +👥 Stakeholders: {{stakeholders.length}} + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📅 FEEDBACK DEADLINE +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +How long should stakeholders have to provide feedback? + + + Days until deadline (default: 5): + + days = parseInt(response) || 5 + deadline = new Date() + deadline.setDate(deadline.getDate() + days) + deadline_str = deadline.toISOString().split('T')[0] + + + +Deadline: {{deadline_str}} ({{days}} days from now) + + + + + + // Build stakeholder checklist + checklist = stakeholders.map(s => + `- [ ] @${s.replace('@', '')} - ⏳ Pending feedback` + ).join('\n') + + // Build issue body + issue_body = `# 📣 ${doc_prefix} Review: ${title} v${version} + +**Document Key:** \`${doc_label_prefix}:${document_key}\` +**Version:** ${version} +**Owner:** @${owner || current_user} +**Status:** 🟡 Open for Feedback + +--- + +## 📅 Deadline + +**Feedback Due:** ${deadline_str} + +--- + +## 📋 Document Summary + +${extract_summary(doc_content)} + +--- + +## 👥 Stakeholder Feedback Status + +${checklist} + +--- + +## 📝 How to Provide Feedback + +1. Review the document: \`docs/${document_type}/${document_key}.md\` +2. For each piece of feedback, create a new comment or linked issue: + - **Clarification**: Something unclear → \`/feedback clarification\` + - **Concern**: Potential issue → \`/feedback concern\` + - **Suggestion**: Improvement idea → \`/feedback suggestion\` + - **Addition**: Missing requirement → \`/feedback addition\` + +Or use the workflow: "Submit feedback on ${doc_label_prefix}:${document_key}" + +--- + +## 🔄 Review Status + +- [ ] All stakeholders have provided feedback +- [ ] Feedback synthesized into new version +- [ ] Ready for sign-off + +--- + +_This review round was opened by @${current_user} on ${new Date().toISOString().split('T')[0]}_ +` + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:type:{{doc_label_prefix}}-review label:{{doc_label_prefix}}:{{document_key}} is:open" + }) + + + +⚠️ An open review round already exists for this document: + Issue #{{response.items[0].number}}: {{response.items[0].title}} + +Would you like to: +[1] Use existing review issue +[2] Close old and create new +[3] Cancel + + Choice: + + review_issue = response.items[0] + Goto step 4 (skip issue creation) + + + Call: mcp__github__issue_write({ + method: 'update', + owner: github_owner, + repo: github_repo, + issue_number: response.items[0].number, + state: 'closed', + state_reason: 'not_planned' + }) + + + HALT + + + + + + + labels = [ + `type:${doc_label_prefix}-review`, + `${doc_label_prefix}:${document_key}`, + `version:${version}`, + 'review-status:open' + ] + + + Call: mcp__github__issue_write({ + method: 'create', + owner: "{{github_owner}}", + repo: "{{github_repo}}", + title: "{{doc_prefix}} Review: {{title}} v{{version}}", + body: issue_body, + labels: labels, + assignees: stakeholders.map(s => s.replace('@', '')) + }) + + review_issue = response + + +✅ Review issue created: #{{review_issue.number}} + {{review_issue.html_url}} + + + + + + + + // Update the Status field in the document + updated_content = doc_content + .replace(/\*\*Status:\*\* .+/, '**Status:** Feedback') + .replace(/\| Feedback Deadline \| .+ \|/, `| Feedback Deadline | ${deadline_str} |`) + + Write updated_content to doc_path + + + + + if (document_type === 'prd') { + cacheManager.writePrd(document_key, updated_content, { + status: 'feedback', + review_issue: review_issue.number, + feedback_deadline: deadline_str + }) + } else { + cacheManager.writeEpic(document_key, updated_content, { + status: 'feedback', + review_issue: review_issue.number, + feedback_deadline: deadline_str + }) + } + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📨 STAKEHOLDER NOTIFICATION +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + mentions = stakeholders.map(s => `@${s.replace('@', '')}`).join(' ') + notification = `## 📣 Feedback Requested + +${mentions} + +You have been asked to review this ${doc_prefix}. + +**Deadline:** ${deadline_str} +**Document:** \`docs/${document_type}/${document_key}.md\` + +Please review and provide your feedback by creating linked feedback issues or comments. + +--- + +**Quick Actions:** +- View document in repo +- Use "Submit feedback" workflow +- Comment directly on this issue + +Thank you for your input! 🙏` + + + Call: mcp__github__add_issue_comment({ + owner: "{{github_owner}}", + repo: "{{github_repo}}", + issue_number: review_issue.number, + body: notification + }) + + + +✅ Stakeholders notified via GitHub @mentions + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ FEEDBACK ROUND OPENED +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Document:** {{title}} v{{version}} +**Review Issue:** #{{review_issue.number}} +**Deadline:** {{deadline_str}} +**Stakeholders Notified:** {{stakeholders.length}} + +The following stakeholders have been notified: +{{stakeholders.map(s => ' • @' + s.replace('@', '')).join('\n')}} + +--- + +**Next Steps:** +1. Stakeholders submit feedback via GitHub +2. Monitor progress with: "View feedback for {{doc_label_prefix}}:{{document_key}}" +3. When ready, synthesize with: "Synthesize feedback for {{doc_label_prefix}}:{{document_key}}" + +**Quick Commands:** +- [VF] View Feedback +- [SZ] Synthesize Feedback +- [PD] PRD Dashboard / [ED] Epic Dashboard + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + +## Helper Functions + +```javascript +// Extract text between markers +function extract_between(content, start, end) { + const startIdx = content.indexOf(start); + if (startIdx === -1) return null; + const endIdx = content.indexOf(end, startIdx + start.length); + return content.slice(startIdx + start.length, endIdx).trim(); +} + +// Extract field from markdown table or bold format +function extract_field(content, field) { + // Try bold format: **Field:** value + const boldMatch = content.match(new RegExp(`\\*\\*${field}:\\*\\*\\s*(.+?)(?:\\n|$)`)); + if (boldMatch) return boldMatch[1].trim(); + + // Try table format: | Field | value | + const tableMatch = content.match(new RegExp(`\\|\\s*${field}\\s*\\|\\s*(.+?)\\s*\\|`)); + if (tableMatch) return tableMatch[1].trim(); + + return null; +} + +// Extract stakeholders from document +function extract_stakeholders(content) { + const field = extract_field(content, 'Stakeholders'); + if (!field) return []; + + return field + .split(/[,\s]+/) + .filter(s => s.startsWith('@')) + .map(s => s.replace('@', '')); +} + +// Extract summary section from document +function extract_summary(content) { + // Try to get Vision + Problem Statement + const vision = extract_between(content, '## Vision', '##') || + extract_between(content, '## Vision', '\n---'); + const problem = extract_between(content, '## Problem Statement', '##') || + extract_between(content, '## Problem Statement', '\n---'); + + if (vision || problem) { + let summary = ''; + if (vision) summary += `**Vision:** ${vision.slice(0, 200)}...\n\n`; + if (problem) summary += `**Problem:** ${problem.slice(0, 200)}...`; + return summary; + } + + // Fallback: first 500 chars + return content.slice(0, 500) + '...'; +} +``` + +## Natural Language Triggers + +This workflow responds to: +- "Open feedback for [prd-key]" +- "Start feedback round for [document]" +- "Request feedback on PRD" +- Menu trigger: `OF` diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/open-feedback-round/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/open-feedback-round/workflow.yaml new file mode 100644 index 00000000..dff95982 --- /dev/null +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/open-feedback-round/workflow.yaml @@ -0,0 +1,22 @@ +name: open-feedback-round +description: "Open a feedback round for PRD or Epic - creates GitHub coordination issue" +author: "BMad" +version: "1.0.0" + +config_source: "{project-root}/_bmad/bmm/config.yaml" +output_folder: "{config_source}:output_folder" +cache_dir: "{output_folder}/cache" +docs_dir: "{project-root}/docs" + +github: + owner: "{config_source}:github_owner" + repo: "{config_source}:github_repo" + +# Parameters (can be passed in or prompted) +document_key: "" # e.g., "user-auth" for PRD +document_type: "prd" # "prd" or "epic" + +installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/open-feedback-round" +instructions: "{installed_path}/instructions.md" + +standalone: true diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/prd-dashboard/instructions.md b/src/modules/bmm/workflows/1-requirements/crowdsource/prd-dashboard/instructions.md new file mode 100644 index 00000000..4192e85e --- /dev/null +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/prd-dashboard/instructions.md @@ -0,0 +1,392 @@ +# PRD Dashboard - Central Visibility Hub + +The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml +You MUST have already loaded and processed: {installed_path}/workflow.yaml + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📊 PRD DASHBOARD +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + Call: mcp__github__get_me() + current_user = response.login + + + ❌ GitHub MCP not accessible + HALT + + + + + +Loading PRD data from GitHub... + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:type:prd-review" + }) + review_issues = response.items || [] + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:type:prd-feedback is:open" + }) + feedback_issues = response.items || [] + + + + + prds = {} + status_counts = { draft: 0, feedback: 0, synthesis: 0, signoff: 0, approved: 0 } + + // Process review issues to get PRD status + for (issue of review_issues) { + const labels = issue.labels.map(l => l.name) + const prd_key = extract_label(labels, 'prd:') + + if (!prd_key) continue + + if (!prds[prd_key]) { + prds[prd_key] = { + key: prd_key, + title: issue.title.replace(/^(PRD Review|Sign-off):\s*/, '').replace(/\s+v\d+$/, ''), + reviews: [], + feedback: [], + status: 'draft', + owner: null, + last_activity: issue.updated_at + } + } + + prds[prd_key].reviews.push(issue) + + // Determine current status from most recent review + const review_status = extract_label(labels, 'review-status:') + if (review_status === 'open' && issue.state === 'open') { + prds[prd_key].status = 'feedback' + } else if (review_status === 'signoff' && issue.state === 'open') { + prds[prd_key].status = 'signoff' + } else if (review_status === 'approved' || issue.state === 'closed') { + prds[prd_key].status = 'approved' + } + + if (new Date(issue.updated_at) > new Date(prds[prd_key].last_activity)) { + prds[prd_key].last_activity = issue.updated_at + } + } + + // Attach feedback to PRDs + for (issue of feedback_issues) { + const labels = issue.labels.map(l => l.name) + const prd_key = extract_label(labels, 'prd:') + + if (prd_key && prds[prd_key]) { + prds[prd_key].feedback.push({ + id: issue.number, + title: issue.title.replace(/^[^\s]+\s+Feedback:\s*/, ''), + type: extract_label(labels, 'feedback-type:'), + status: extract_label(labels, 'feedback-status:'), + submittedBy: issue.user?.login + }) + } + } + + // Count by status + for (prd of Object.values(prds)) { + status_counts[prd.status] = (status_counts[prd.status] || 0) + 1 + } + + prd_list = Object.values(prds).sort((a, b) => + new Date(b.last_activity) - new Date(a.last_activity) + ) + + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📊 PRD PORTFOLIO DASHBOARD +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Status Summary:** + 📝 Draft: {{status_counts.draft}} PRDs + 💬 Feedback: {{status_counts.feedback}} PRDs (collecting input) + 🔄 Synthesis: {{status_counts.synthesis}} PRDs (being processed) + ✍️ Sign-off: {{status_counts.signoff}} PRDs (awaiting approval) + ✅ Approved: {{status_counts.approved}} PRDs + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Active PRDs:** +┌──────────────────┬────────────────────────┬─────────┬──────────────┐ +│ PRD Key │ Title │ Status │ Activity │ +├──────────────────┼────────────────────────┼─────────┼──────────────┤ +{{#each prd_list}} +│ prd:{{pad_right key 12}} │ {{pad_right title 22}} │ {{status_emoji status}} │ {{time_ago last_activity}} │ +{{/each}} +└──────────────────┴────────────────────────┴─────────┴──────────────┘ + +{{#if attention_needed}} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +⚠️ **Attention Needed:** +{{#each attention_items}} + • {{prd_key}} - {{message}} +{{/each}} +{{/if}} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + Goto step 4 (interactive menu) + + + + + + prd = prds[prd_key] + + + +❌ PRD not found: prd:{{prd_key}} + + prd_key = '' + Goto step 2 + + + + // Get active review issue + active_review = prd.reviews.find(r => r.state === 'open') + + // Count feedback by status + new_feedback = prd.feedback.filter(f => f.status === 'new').length + reviewed_feedback = prd.feedback.filter(f => f.status === 'reviewed').length + + // Get stakeholders who haven't responded + if (active_review) { + stakeholders = active_review.assignees?.map(a => a.login) || [] + + // Parse sign-off labels + signoff_labels = active_review.labels + .map(l => l.name) + .filter(l => l.startsWith('signoff-')) + + signed_off = signoff_labels.map(l => { + const match = l.match(/^signoff-(.+)-(approved|approved-with-note|blocked)$/) + return match ? { user: match[1], status: match[2] } : null + }).filter(Boolean) + + pending_stakeholders = stakeholders.filter(s => + !signed_off.some(so => so.user === s) + ) + } + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📋 PRD DETAIL: prd:{{prd_key}} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Title:** {{prd.title}} +**Status:** {{status_emoji prd.status}} {{prd.status}} +**Last Updated:** {{time_ago prd.last_activity}} +{{#if active_review}} +**Review Issue:** #{{active_review.number}} +{{/if}} + +━━━ FEEDBACK PROGRESS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Total Feedback:** {{prd.feedback.length}} items + ├── 🆕 New: {{new_feedback}} + ├── 👀 Reviewed: {{reviewed_feedback}} + └── ✅ Processed: {{prd.feedback.length - new_feedback - reviewed_feedback}} + +{{#if prd.feedback.length}} +**By Type:** +{{#each feedback_by_type}} + • {{type}}: {{count}} +{{/each}} +{{/if}} + +{{#if prd.status == 'signoff'}} +━━━ SIGN-OFF PROGRESS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +{{#each signed_off}} + {{#if (eq status 'approved')}}✅{{/if}}{{#if (eq status 'approved-with-note')}}✅📝{{/if}}{{#if (eq status 'blocked')}}🚫{{/if}} @{{user}} - {{status}} +{{/each}} + +{{#each pending_stakeholders}} + ⏳ @{{this}} - Pending +{{/each}} + +**Progress:** {{signed_off.length}} / {{stakeholders.length}} +{{/if}} + +{{#if conflicts}} +━━━ CONFLICTS DETECTED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +{{#each conflicts}} +⚠️ **{{section}}** - Multiple stakeholders have input +{{#each items}} + • @{{submittedBy}}: "{{title}}" +{{/each}} +{{/each}} +{{/if}} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + + +**Actions:** +[1-{{prd_list.length}}] View specific PRD (enter number) +[C] Create new PRD +[F] View feedback for a PRD +[S] Synthesize feedback +[R] Refresh +[B] Back to portfolio (if in detail view) +[Q] Quit + + + + Choice: + + + selected_prd = prd_list[parseInt(choice) - 1] + prd_key = selected_prd.key + Goto step 3 + + + + Load workflow: create-prd-draft + + + + Enter PRD key: + Load workflow: view-feedback with document_key = response, document_type = 'prd' + + + + Enter PRD key: + Load workflow: synthesize-feedback with document_key = response, document_type = 'prd' + + + + Goto step 1 + + + + prd_key = '' + Goto step 2 + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +PRD Dashboard closed. +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Exit + + + Goto step 4 + + + + +## Helper Functions + +```javascript +// Extract label value by prefix +function extract_label(labels, prefix) { + for (const label of labels) { + if (label.startsWith(prefix)) { + return label.replace(prefix, ''); + } + } + return null; +} + +// Get status emoji +function status_emoji(status) { + const emojis = { + draft: '📝', + feedback: '💬', + synthesis: '🔄', + signoff: '✍️', + approved: '✅' + }; + return emojis[status] || '❓'; +} + +// Format time ago +function time_ago(timestamp) { + const now = new Date(); + const then = new Date(timestamp); + const hours = Math.floor((now - then) / (1000 * 60 * 60)); + + if (hours < 1) return 'just now'; + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days === 1) return '1 day ago'; + if (days < 7) return `${days} days ago`; + return then.toISOString().split('T')[0]; +} + +// Pad string to right +function pad_right(str, length) { + if (!str) str = ''; + return (str + ' '.repeat(length)).slice(0, length); +} + +// Find items needing attention +function find_attention_items(prds) { + const items = []; + + for (const prd of Object.values(prds)) { + // Check for stale feedback rounds + const hours_since_activity = (Date.now() - new Date(prd.last_activity)) / (1000 * 60 * 60); + + if (prd.status === 'feedback' && hours_since_activity > 120) { // 5 days + items.push({ + prd_key: `prd:${prd.key}`, + message: `No activity for ${Math.floor(hours_since_activity / 24)} days` + }); + } + + // Check for blocked sign-offs + if (prd.status === 'signoff') { + const blocked = prd.reviews.some(r => + r.labels.some(l => l.name.includes('-blocked')) + ); + if (blocked) { + items.push({ + prd_key: `prd:${prd.key}`, + message: 'Has blocking concerns' + }); + } + } + } + + return items; +} +``` + +## Natural Language Triggers + +This workflow responds to: +- "Show PRD dashboard" +- "What PRDs are in progress?" +- "PRD status" +- "View all PRDs" +- Menu trigger: `PD` diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/prd-dashboard/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/prd-dashboard/workflow.yaml new file mode 100644 index 00000000..8173ac68 --- /dev/null +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/prd-dashboard/workflow.yaml @@ -0,0 +1,21 @@ +name: prd-dashboard +description: "Central visibility hub for tracking all PRDs across the organization" +author: "BMad" +version: "1.0.0" + +config_source: "{project-root}/_bmad/bmm/config.yaml" +output_folder: "{config_source}:output_folder" +cache_dir: "{output_folder}/cache" +docs_dir: "{project-root}/docs" + +github: + owner: "{config_source}:github_owner" + repo: "{config_source}:github_repo" + +# Optional filter +prd_key: "" # Empty for all PRDs, or specific key for detail view + +installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/prd-dashboard" +instructions: "{installed_path}/instructions.md" + +standalone: true diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/request-signoff/instructions.md b/src/modules/bmm/workflows/1-requirements/crowdsource/request-signoff/instructions.md new file mode 100644 index 00000000..8ce0b1f1 --- /dev/null +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/request-signoff/instructions.md @@ -0,0 +1,343 @@ +# Request Sign-off - Final Stakeholder Approval + +The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml +You MUST have already loaded and processed: {installed_path}/workflow.yaml + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✍️ REQUEST SIGN-OFF +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + Call: mcp__github__get_me() + current_user = response.login + + + ❌ GitHub MCP not accessible + HALT + + + + + + Which document needs sign-off? Enter key: + document_key = response + + + + if (document_type === 'prd') { + doc_path = `${docs_dir}/prd/${document_key}.md` + doc_label = `prd:${document_key}` + review_label = 'type:prd-review' + } else { + doc_path = `${docs_dir}/epics/epic-${document_key}.md` + doc_label = `epic:${document_key}` + review_label = 'type:epic-review' + } + + + Read doc_path + + ❌ Document not found: {{doc_path}} + HALT + + doc_content = file_content + + title = extract_title(doc_content) + version = extract_version(doc_content) + stakeholders = extract_stakeholders(doc_content) + + + +📄 Document: {{title}} v{{version}} +👥 Stakeholders: {{stakeholders.length}} + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +⚙️ SIGN-OFF CONFIGURATION +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +How should sign-off be determined? + +[1] Count-based: Minimum number of approvals (e.g., 3 of 5 must approve) +[2] Percentage: Percentage must approve (e.g., 66% of stakeholders) +[3] Required + Optional: Specific people must approve + minimum optional + + + + Choice (1-3): + threshold_type = choice + + + Minimum approvals needed (out of {{stakeholders.length}}): + + signoff_config = { + threshold_type: 'count', + minimum_approvals: parseInt(response), + allow_blocks: true, + block_threshold: 1 + } + + + + + Percentage required (e.g., 66 for 66%): + + signoff_config = { + threshold_type: 'percentage', + approval_percentage: parseInt(response), + allow_blocks: true, + block_threshold: 1 + } + + + + + +Current stakeholders: {{stakeholders.map(s => '@' + s).join(', ')}} + +Enter REQUIRED approvers (must all approve): + + Required approvers (comma-separated usernames): + required_approvers = response.split(',').map(s => s.trim().replace('@', '')) + + +Remaining stakeholders can be optional. + + Minimum optional approvers needed: + + optional_approvers = stakeholders.filter(s => !required_approvers.includes(s)) + signoff_config = { + threshold_type: 'required_approvers', + required: required_approvers, + optional: optional_approvers, + minimum_optional: parseInt(response), + allow_blocks: true, + block_threshold: 1 + } + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📅 SIGN-OFF DEADLINE +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + Days until sign-off deadline (default: 3): + + days = parseInt(response) || 3 + deadline = new Date() + deadline.setDate(deadline.getDate() + days) + deadline_str = deadline.toISOString().split('T')[0] + + + +Deadline: {{deadline_str}} ({{days}} days from now) + + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:{{review_label}} label:{{doc_label}} is:open" + }) + + + review_issue = response.items[0] + +Found existing review issue: #{{review_issue.number}} + + + + + +No existing review issue found. Creating one... + + + // Create new review issue for sign-off + issue_body = `# ✍️ Sign-off Request: ${title} v${version} + +**Document Key:** \`${doc_label}\` +**Version:** ${version} +**Status:** 🟡 Awaiting Sign-off + +--- + +## 📅 Deadline + +**Sign-off Due:** ${deadline_str} + +--- + +## 👥 Stakeholder Status + +${stakeholders.map(s => `- [ ] @${s} - ⏳ Pending`).join('\n')} + +--- + +_Sign-off requested by @${current_user} on ${new Date().toISOString().split('T')[0]}_` + + + Call: mcp__github__issue_write({ + method: 'create', + owner: "{{github_owner}}", + repo: "{{github_repo}}", + title: "Sign-off: {{title}} v{{version}}", + body: issue_body, + labels: [review_label, doc_label, `version:${version}`, 'review-status:signoff'], + assignees: stakeholders + }) + + review_issue = response + + + + + // Get current labels and update status + current_labels = review_issue.labels.map(l => l.name) + new_labels = current_labels + .filter(l => !l.startsWith('review-status:')) + .concat(['review-status:signoff']) + + + Call: mcp__github__issue_write({ + method: 'update', + owner: "{{github_owner}}", + repo: "{{github_repo}}", + issue_number: review_issue.number, + labels: new_labels + }) + + + + + + // Format configuration for display + config_display = '' + if (signoff_config.threshold_type === 'count') { + config_display = `${signoff_config.minimum_approvals} approval(s) required` + } else if (signoff_config.threshold_type === 'percentage') { + config_display = `${signoff_config.approval_percentage}% must approve` + } else { + config_display = `Required: ${signoff_config.required.map(r => '@' + r).join(', ')}; Optional: ${signoff_config.minimum_optional} of ${signoff_config.optional.length}` + } + + signoff_comment = `## ✍️ Sign-off Requested + +${stakeholders.map(s => '@' + s).join(' ')} + +**Version:** v${version} +**Deadline:** ${deadline_str} +**Threshold:** ${config_display} + +--- + +### How to Sign Off + +Reply to this issue with one of the following: + +- ✅ **Approve**: \`/signoff approve\` - Sign off without concerns +- ✅📝 **Approve with Note**: \`/signoff approve-note: [your note]\` - Sign off with a minor note +- 🚫 **Block**: \`/signoff block: [reason]\` - Cannot approve, has blocking concern + +--- + +### Sign-off Status + +${stakeholders.map(s => `- [ ] @${s} - ⏳ Pending`).join('\n')} + +--- + +_Please review the document and provide your sign-off decision by ${deadline_str}._` + + + Call: mcp__github__add_issue_comment({ + owner: "{{github_owner}}", + repo: "{{github_repo}}", + issue_number: review_issue.number, + body: signoff_comment + }) + + +✅ Sign-off request posted to #{{review_issue.number}} + + + + + + updated_content = doc_content + .replace(/\*\*Status:\*\* .+/, '**Status:** Sign-off') + .replace(/\| Sign-off Deadline \| .+ \|/, `| Sign-off Deadline | ${deadline_str} |`) + + + Write updated_content to doc_path + + +✅ Document status updated to 'Sign-off' + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ SIGN-OFF REQUESTED +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Document:** {{title}} v{{version}} +**Review Issue:** #{{review_issue.number}} +**Deadline:** {{deadline_str}} +**Stakeholders:** {{stakeholders.length}} + +**Sign-off Configuration:** +{{config_display}} + +--- + +All stakeholders have been notified via GitHub @mentions. +Monitor progress with: "View sign-off status for {{doc_label}}" + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + +## Helper Functions + +```javascript +function extract_title(content) { + const match = content.match(/^#\s+(PRD|Epic):\s*(.+)$/m); + return match ? match[2].trim() : 'Untitled'; +} + +function extract_version(content) { + const match = content.match(/\*\*Version:\*\*\s*(\d+)/); + return match ? match[1] : '1'; +} + +function extract_stakeholders(content) { + const field = content.match(/\|\s*Stakeholders\s*\|\s*(.+?)\s*\|/); + if (!field) return []; + + return field[1] + .split(/[,\s]+/) + .filter(s => s.startsWith('@')) + .map(s => s.replace('@', '')); +} +``` + +## Natural Language Triggers + +This workflow responds to: +- "Request sign-off for [document]" +- "Start sign-off round" +- "Get approval on PRD" +- Menu trigger: `RS` diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/request-signoff/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/request-signoff/workflow.yaml new file mode 100644 index 00000000..0d44f0b4 --- /dev/null +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/request-signoff/workflow.yaml @@ -0,0 +1,22 @@ +name: request-signoff +description: "Request formal sign-off from stakeholders - final approval stage" +author: "BMad" +version: "1.0.0" + +config_source: "{project-root}/_bmad/bmm/config.yaml" +output_folder: "{config_source}:output_folder" +cache_dir: "{output_folder}/cache" +docs_dir: "{project-root}/docs" + +github: + owner: "{config_source}:github_owner" + repo: "{config_source}:github_repo" + +# Parameters +document_key: "" +document_type: "prd" + +installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/request-signoff" +instructions: "{installed_path}/instructions.md" + +standalone: true diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/submit-feedback/instructions.md b/src/modules/bmm/workflows/1-requirements/crowdsource/submit-feedback/instructions.md new file mode 100644 index 00000000..99bc4ce2 --- /dev/null +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/submit-feedback/instructions.md @@ -0,0 +1,432 @@ +# Submit Feedback - Structured Stakeholder Input + +The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml +You MUST have already loaded and processed: {installed_path}/workflow.yaml + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +💬 SUBMIT FEEDBACK +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + Call: mcp__github__get_me() + current_user = response.login + + + ❌ GitHub MCP not accessible + HALT + + + + + + +Which document are you providing feedback on? + +Enter the key (e.g., "user-auth" for PRD, "2" for Epic): + + Document key: + document_key = response + + + + + + // Try to find document + prd_path = `${docs_dir}/prd/${document_key}.md` + epic_path = `${docs_dir}/epics/epic-${document_key}.md` + + if (file_exists(prd_path)) { + document_type = 'prd' + doc_path = prd_path + } else if (file_exists(epic_path)) { + document_type = 'epic' + doc_path = epic_path + } else { + // Ask user + prompt_for_type = true + } + + + + Is this a [P]RD or [E]pic? + document_type = (response.toLowerCase().startsWith('p')) ? 'prd' : 'epic' + + + + + + + if (document_type === 'prd') { + doc_path = `${docs_dir}/prd/${document_key}.md` + doc_prefix = 'PRD' + doc_label = `prd:${document_key}` + review_label = 'type:prd-review' + feedback_label = 'type:prd-feedback' + } else { + doc_path = `${docs_dir}/epics/epic-${document_key}.md` + doc_prefix = 'Epic' + doc_label = `epic:${document_key}` + review_label = 'type:epic-review' + feedback_label = 'type:epic-feedback' + } + + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:{{review_label}} label:{{doc_label}} label:review-status:open is:open" + }) + + + +⚠️ No active feedback round found for {{doc_label}} + +The document may be: +- Still in draft (not yet open for feedback) +- Already past the feedback stage + +Would you like to: +[1] Submit feedback anyway (will be orphaned) +[2] View document +[3] Cancel + + Choice: + + Read and display doc_path + Goto step 1c + + + HALT + + review_issue_number = null + + + + review_issue = response.items[0] + review_issue_number = review_issue.number + +📋 Found active review: #{{review_issue_number}} + {{review_issue.title}} + + + + + + + Read doc_path + doc_content = file_content + + + // Extract sections for selection + sections = extract_sections(doc_content) + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📄 DOCUMENT SECTIONS +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Select the section your feedback relates to: + +{{#each sections as |section index|}} +[{{add index 1}}] {{section}} +{{/each}} +[0] General / Overall document + + + + Section number: + + section_idx = parseInt(response) + if (section_idx === 0) { + selected_section = 'General' + } else { + selected_section = sections[section_idx - 1] || 'General' + } + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🏷️ FEEDBACK TYPE +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +What type of feedback is this? + +[1] 📋 Clarification - Something is unclear or needs more detail +[2] ⚠️ Concern - Potential issue, risk, or problem +[3] 💡 Suggestion - Improvement idea or alternative approach +[4] ➕ Addition - Missing requirement or feature +[5] 🔢 Priority - Disagree with prioritization or ordering + + + + +[6] 📐 Scope - Epic scope is too large or should be split +[7] 🔗 Dependency - Dependency or blocking relationship +[8] 🔧 Technical Risk - Technical or architectural concern +[9] ✂️ Story Split - Suggest different story breakdown + + + + Feedback type (number): + + type_map = { + '1': { key: 'clarification', emoji: '📋', label: 'feedback-type:clarification' }, + '2': { key: 'concern', emoji: '⚠️', label: 'feedback-type:concern' }, + '3': { key: 'suggestion', emoji: '💡', label: 'feedback-type:suggestion' }, + '4': { key: 'addition', emoji: '➕', label: 'feedback-type:addition' }, + '5': { key: 'priority', emoji: '🔢', label: 'feedback-type:priority' }, + '6': { key: 'scope', emoji: '📐', label: 'feedback-type:scope' }, + '7': { key: 'dependency', emoji: '🔗', label: 'feedback-type:dependency' }, + '8': { key: 'technical_risk', emoji: '🔧', label: 'feedback-type:technical-risk' }, + '9': { key: 'story_split', emoji: '✂️', label: 'feedback-type:story-split' } + } + feedback_type = type_map[response] || type_map['3'] + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🎯 PRIORITY +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +How important is addressing this feedback? + +[1] 🔴 High - Critical, blocks progress or has significant impact +[2] 🟡 Medium - Important but not blocking +[3] 🟢 Low - Nice to have, minor improvement + + + + Priority (1-3, default 2): + + priority_map = { + '1': { key: 'high', label: 'priority:high' }, + '2': { key: 'medium', label: 'priority:medium' }, + '3': { key: 'low', label: 'priority:low' } + } + priority = priority_map[response] || priority_map['2'] + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📝 YOUR FEEDBACK +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + Brief title for your feedback (one line): + feedback_title = response + + Detailed feedback (describe your concern, question, or suggestion): + feedback_content = response + + +Would you like to suggest a specific change? (optional) + + Suggested change (or press Enter to skip): + suggested_change = response || null + + +Any additional context or rationale? (optional) + + Rationale (or press Enter to skip): + rationale = response || null + + + + + // Build issue body + issue_body = `# ${feedback_type.emoji} Feedback: ${feedback_type.key.charAt(0).toUpperCase() + feedback_type.key.slice(1)} + +**Review:** ${review_issue_number ? '#' + review_issue_number : 'N/A'} +**Document:** \`${doc_label}\` +**Section:** ${selected_section} +**Type:** ${feedback_type.key} +**Priority:** ${priority.key} + +--- + +## Feedback + +${feedback_content} +` + + if (suggested_change) { + issue_body += ` +## Suggested Change + +${suggested_change} +` + } + + if (rationale) { + issue_body += ` +## Context/Rationale + +${rationale} +` + } + + issue_body += ` +--- + +_Submitted by @${current_user} on ${new Date().toISOString().split('T')[0]}_ +` + + // Build labels + labels = [ + feedback_label, + doc_label, + `feedback-section:${selected_section.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '')}`, + feedback_type.label, + 'feedback-status:new', + priority.label + ] + + if (review_issue_number) { + labels.push(`linked-review:${review_issue_number}`) + } + + + Call: mcp__github__issue_write({ + method: 'create', + owner: "{{github_owner}}", + repo: "{{github_repo}}", + title: "{{feedback_type.emoji}} Feedback: {{feedback_title}}", + body: issue_body, + labels: labels + }) + + feedback_issue = response + + +✅ Feedback submitted: #{{feedback_issue.number}} + {{feedback_issue.html_url}} + + + + + + + link_comment = `${feedback_type.emoji} **New Feedback** from @${current_user} + +**${feedback_title}** → #${feedback_issue.number} +Type: ${feedback_type.key} | Priority: ${priority.key} | Section: ${selected_section}` + + + Call: mcp__github__add_issue_comment({ + owner: "{{github_owner}}", + repo: "{{github_repo}}", + issue_number: review_issue_number, + body: link_comment + }) + + +✅ Feedback linked to review issue #{{review_issue_number}} + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ FEEDBACK SUBMITTED +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Feedback:** {{feedback_title}} +**Issue:** #{{feedback_issue.number}} +**Type:** {{feedback_type.emoji}} {{feedback_type.key}} +**Section:** {{selected_section}} +**Priority:** {{priority.key}} + +Your feedback has been recorded and the PO will be notified. + +--- + +**Would you like to:** +[1] Submit more feedback on this document +[2] View all feedback for this document +[3] Return to My Tasks +[4] Done + + + + Choice: + + + Goto step 2 (submit more feedback) + + + + Load workflow: view-feedback with document_key, document_type + + + + Load workflow: my-tasks + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Thank you for your feedback! 🙏 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Exit + + + + + +## Helper Functions + +```javascript +// Extract section headers from document +function extract_sections(content) { + const sections = []; + const lines = content.split('\n'); + + for (const line of lines) { + // Match ## headers but not metadata sections + const match = line.match(/^##\s+(.+)$/); + if (match) { + const section = match[1].trim(); + // Skip metadata-like sections + if (!['Metadata', 'Version History', 'Sign-off Status'].includes(section)) { + sections.push(section); + } + } + + // Also capture ### subsections for detailed feedback + const subMatch = line.match(/^###\s+(FR\d+|NFR\d+|US\d+):\s*(.+)$/); + if (subMatch) { + sections.push(`${subMatch[1]}: ${subMatch[2].slice(0, 40)}`); + } + } + + return sections; +} + +// Check if file exists +function file_exists(path) { + // Implemented by runtime + return true; +} +``` + +## Natural Language Triggers + +This workflow responds to: +- "Submit feedback on [document]" +- "I have feedback for the auth PRD" +- "Give feedback on epic 2" +- "Provide input on [document]" +- Menu trigger: `SF` diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/submit-feedback/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/submit-feedback/workflow.yaml new file mode 100644 index 00000000..2c2644d5 --- /dev/null +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/submit-feedback/workflow.yaml @@ -0,0 +1,22 @@ +name: submit-feedback +description: "Submit structured feedback on a PRD or Epic - creates linked GitHub issue" +author: "BMad" +version: "1.0.0" + +config_source: "{project-root}/_bmad/bmm/config.yaml" +output_folder: "{config_source}:output_folder" +cache_dir: "{output_folder}/cache" +docs_dir: "{project-root}/docs" + +github: + owner: "{config_source}:github_owner" + repo: "{config_source}:github_repo" + +# Parameters (can be passed in or prompted) +document_key: "" # e.g., "user-auth" for PRD, "2" for Epic +document_type: "" # "prd" or "epic" - will auto-detect if empty + +installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/submit-feedback" +instructions: "{installed_path}/instructions.md" + +standalone: true diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/submit-signoff/instructions.md b/src/modules/bmm/workflows/1-requirements/crowdsource/submit-signoff/instructions.md new file mode 100644 index 00000000..05810e70 --- /dev/null +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/submit-signoff/instructions.md @@ -0,0 +1,427 @@ +# Submit Sign-off - Record Your Approval Decision + +The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml +You MUST have already loaded and processed: {installed_path}/workflow.yaml + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✍️ SUBMIT SIGN-OFF +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + Call: mcp__github__get_me() + current_user = response.login + + + ❌ GitHub MCP not accessible + HALT + + + + + + Which document are you signing off on? Enter key: + document_key = response + + + + if (document_type === 'prd') { + doc_path = `${docs_dir}/prd/${document_key}.md` + doc_label = `prd:${document_key}` + review_label = 'type:prd-review' + } else { + doc_path = `${docs_dir}/epics/epic-${document_key}.md` + doc_label = `epic:${document_key}` + review_label = 'type:epic-review' + } + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:{{review_label}} label:{{doc_label}} label:review-status:signoff is:open" + }) + + + +❌ No active sign-off request found for {{doc_label}} + +The document may be: +- Still in feedback stage +- Already approved +- Not yet created + +Use [MT] My Tasks to see what's pending for you. + + HALT + + + review_issue = response.items[0] + +📋 Found sign-off request: #{{review_issue.number}} + {{review_issue.title}} + + + + + Read doc_path + + ❌ Document not found: {{doc_path}} + HALT + + doc_content = file_content + + title = extract_title(doc_content) + version = extract_version(doc_content) + + + + + + + // Check if user already signed off + signoff_label_prefix = `signoff-${current_user}-` + existing_signoff = review_issue.labels.some(l => + l.name.startsWith(signoff_label_prefix) + ) + + + + +⚠️ You have already submitted a sign-off decision for this document. + +Would you like to: +[1] Change your decision +[2] View current status +[3] Cancel + + Choice: + + HALT + + +Proceeding to update your sign-off decision... + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📄 DOCUMENT SUMMARY +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Title:** {{title}} +**Version:** v{{version}} +**Key:** {{doc_label}} + +Would you like to view the full document before deciding? + + + View document? (y/n): + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📄 DOCUMENT CONTENT +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +{{doc_content}} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🗳️ YOUR DECISION +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Please select your sign-off decision: + +[1] ✅ APPROVE - I approve this document +[2] ✅📝 APPROVE WITH NOTE - I approve with a minor note/observation +[3] 🚫 BLOCK - I cannot approve, there is a blocking issue + + + + Decision (1-3): + + decision_map = { + '1': { key: 'approved', emoji: '✅', text: 'Approved' }, + '2': { key: 'approved_with_note', emoji: '✅📝', text: 'Approved with Note' }, + '3': { key: 'blocked', emoji: '🚫', text: 'Blocked' } + } + decision = decision_map[response] || decision_map['1'] + + + + + + Enter your note (this will be visible to all stakeholders): + note = response + + + + +⚠️ Blocking a document requires a clear reason. + +This will: +1. Prevent the document from being approved +2. Notify the PO and stakeholders +3. May trigger a new feedback round + + + Enter your blocking reason: + note = response + + +Would you like to create a formal feedback issue for this blocking concern? + + Create feedback issue? (y/n): + + create_feedback_issue = true + + + + + note = null + + + + + + // Build sign-off comment + signoff_comment = `### ${decision.emoji} Sign-off from @${current_user} + +**Decision:** ${decision.text} +**Date:** ${new Date().toISOString().split('T')[0]}` + + if (note) { + signoff_comment += ` + +**Note:** +${note}` + } + + + Call: mcp__github__add_issue_comment({ + owner: "{{github_owner}}", + repo: "{{github_repo}}", + issue_number: review_issue.number, + body: signoff_comment + }) + + + + // Get current labels + current_labels = review_issue.labels.map(l => l.name) + + // Remove any existing signoff label for this user + new_labels = current_labels.filter(l => + !l.startsWith(`signoff-${current_user}-`) + ) + + // Add new signoff label + decision_label = decision.key.replace(/_/g, '-') + new_labels.push(`signoff-${current_user}-${decision_label}`) + + + Call: mcp__github__issue_write({ + method: 'update', + owner: "{{github_owner}}", + repo: "{{github_repo}}", + issue_number: review_issue.number, + labels: new_labels + }) + + + + + feedback_body = `# 🚫 Blocking Concern + +**Document:** \`${doc_label}\` +**Review:** #${review_issue.number} +**Type:** Blocking concern requiring resolution + +--- + +## Concern + +${note} + +--- + +_Submitted by @${current_user} as part of sign-off for v${version}_` + + + Call: mcp__github__issue_write({ + method: 'create', + owner: "{{github_owner}}", + repo: "{{github_repo}}", + title: "🚫 Blocking: {{title}}", + body: feedback_body, + labels: ['type:{{document_type}}-feedback', doc_label, 'feedback-type:concern', 'priority:high', 'feedback-status:new', `linked-review:${review_issue.number}`] + }) + + +✅ Created feedback issue: #{{response.number}} + + + + +✅ Sign-off submitted: {{decision.emoji}} {{decision.text}} + + + + + +Checking if all required sign-offs are complete... + + + + // Refresh issue to get updated labels + Call: mcp__github__issue_read({ + method: 'get', + owner: github_owner, + repo: github_repo, + issue_number: review_issue.number + }) + + + + // Count sign-offs + labels = response.labels.map(l => l.name) + approved_count = labels.filter(l => + l.includes('-approved') || l.includes('-approved-with-note') + ).length + blocked_count = labels.filter(l => l.includes('-blocked')).length + + // Get stakeholder count from document + stakeholder_count = extract_stakeholders(doc_content).length + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📊 SIGN-OFF STATUS +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Approved:** {{approved_count}} / {{stakeholder_count}} +**Blocked:** {{blocked_count}} +**Pending:** {{stakeholder_count - approved_count - blocked_count}} + + + + + +⚠️ Document has {{blocked_count}} blocking concern(s). + Cannot be approved until resolved. + + + + + +🎉 ALL SIGN-OFFS RECEIVED! + +The document is ready to be marked as APPROVED. + + + Mark document as approved? (y/n): + + + // Update document status + updated_content = doc_content.replace(/\*\*Status:\*\* .+/, '**Status:** Approved') + + Write updated_content to doc_path + + + // Update review issue + final_labels = labels + .filter(l => !l.startsWith('review-status:')) + .concat(['review-status:approved']) + + + Call: mcp__github__issue_write({ + method: 'update', + owner: "{{github_owner}}", + repo: "{{github_repo}}", + issue_number: review_issue.number, + labels: final_labels, + state: 'closed', + state_reason: 'completed' + }) + + Call: mcp__github__add_issue_comment({ + owner: "{{github_owner}}", + repo: "{{github_repo}}", + issue_number: review_issue.number, + body: `## ✅ DOCUMENT APPROVED + +All stakeholders have signed off. This document is now approved and ready for implementation. + +**Final Version:** v${version} +**Approved:** ${new Date().toISOString().split('T')[0]} +**Approvals:** ${approved_count} / ${stakeholder_count}` + }) + + +✅ Document marked as APPROVED! + Review issue #{{review_issue.number}} closed. + + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ SIGN-OFF COMPLETE +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Your Decision:** {{decision.emoji}} {{decision.text}} +**Document:** {{title}} v{{version}} +**Review Issue:** #{{review_issue.number}} + +Thank you for your review! 🙏 + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + +## Helper Functions + +```javascript +function extract_title(content) { + const match = content.match(/^#\s+(PRD|Epic):\s*(.+)$/m); + return match ? match[2].trim() : 'Untitled'; +} + +function extract_version(content) { + const match = content.match(/\*\*Version:\*\*\s*(\d+)/); + return match ? match[1] : '1'; +} + +function extract_stakeholders(content) { + const field = content.match(/\|\s*Stakeholders\s*\|\s*(.+?)\s*\|/); + if (!field) return []; + + return field[1] + .split(/[,\s]+/) + .filter(s => s.startsWith('@')) + .map(s => s.replace('@', '')); +} +``` + +## Natural Language Triggers + +This workflow responds to: +- "Sign off on [document]" +- "Submit my sign-off" +- "Approve the PRD" +- "I approve [document]" +- Menu trigger: `SO` diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/submit-signoff/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/submit-signoff/workflow.yaml new file mode 100644 index 00000000..a9aedae4 --- /dev/null +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/submit-signoff/workflow.yaml @@ -0,0 +1,22 @@ +name: submit-signoff +description: "Submit sign-off decision on a PRD or Epic" +author: "BMad" +version: "1.0.0" + +config_source: "{project-root}/_bmad/bmm/config.yaml" +output_folder: "{config_source}:output_folder" +cache_dir: "{output_folder}/cache" +docs_dir: "{project-root}/docs" + +github: + owner: "{config_source}:github_owner" + repo: "{config_source}:github_repo" + +# Parameters +document_key: "" +document_type: "prd" + +installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/submit-signoff" +instructions: "{installed_path}/instructions.md" + +standalone: true diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-epic-feedback/instructions.md b/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-epic-feedback/instructions.md new file mode 100644 index 00000000..3b2e39fb --- /dev/null +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-epic-feedback/instructions.md @@ -0,0 +1,684 @@ +# Synthesize Epic Feedback - LLM-Powered Story Refinement + +The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml +You MUST have already loaded and processed: {installed_path}/workflow.yaml + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🔄 SYNTHESIZE EPIC FEEDBACK +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + Call: mcp__github__get_me() + current_user = response.login + + + ❌ GitHub MCP not accessible + HALT + + + + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:type:epic-review label:review-status:open is:open" + }) + + + +❌ No epics currently collecting feedback. + +Use [ED] Epic Dashboard to see all epics. + + HALT + + + + feedback_epics = response.items.map(issue => { + const labels = issue.labels.map(l => l.name) + return { + key: labels.find(l => l.startsWith('epic:'))?.replace('epic:', ''), + title: issue.title.replace(/^Epic Review:\s*/, ''), + source_prd: labels.find(l => l.startsWith('source-prd:'))?.replace('source-prd:', ''), + issue_number: issue.number + } + }).filter(e => e.key) + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📦 EPICS WITH ACTIVE FEEDBACK +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +{{#each feedback_epics}} +[{{@index + 1}}] epic:{{key}} - {{title}} + Source: prd:{{source_prd}} | Issue: #{{issue_number}} +{{/each}} + + + + + Select epic to synthesize (1-{{feedback_epics.length}}): + epic_key = feedback_epics[parseInt(response) - 1].key + review_issue_number = feedback_epics[parseInt(response) - 1].issue_number + + + + + + epic_path = `${docs_dir}/epics/epic-${epic_key}.md` + Read epic_path + + + ❌ Epic document not found: {{epic_path}} + HALT + + + epic_content = file_content + + title = extract_title(epic_content) + version = parseInt(extract_version(epic_content)) + source_prd = extract_source_prd(epic_content) + current_stories = extract_epic_stories(epic_content) + dependencies = extract_dependencies(epic_content) + + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:type:epic-feedback label:epic:{{epic_key}} label:feedback-status:new" + }) + + + +⚠️ No new feedback found for epic:{{epic_key}} + +All feedback may have been processed already. +Check the Epic Dashboard [ED] for status. + + + Continue anyway to create new version? (y/n): + + HALT + + feedback_items = [] + + + + + feedback_items = response.items.map(issue => { + const labels = issue.labels.map(l => l.name) + return { + id: issue.number, + title: issue.title, + body: issue.body, + type: labels.find(l => l.startsWith('feedback-type:'))?.replace('feedback-type:', ''), + section: labels.find(l => l.startsWith('feedback-section:'))?.replace('feedback-section:', ''), + submittedBy: issue.user?.login, + created: issue.created_at + } + }) + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📋 SYNTHESIS INPUT +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Epic:** epic:{{epic_key}} +**Title:** {{title}} +**Version:** v{{version}} +**Stories:** {{current_stories.length}} +**Feedback Items:** {{feedback_items.length}} + + + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:type:epic-review label:epic:{{epic_key}} is:open" + }) + + review_issue = response.items[0] + + + current_labels = review_issue.labels.map(l => l.name) + new_labels = current_labels + .filter(l => !l.startsWith('review-status:')) + .concat(['review-status:synthesis']) + + + Call: mcp__github__issue_write({ + method: 'update', + owner: "{{github_owner}}", + repo: "{{github_repo}}", + issue_number: review_issue.number, + labels: new_labels + }) + + +🔒 Epic locked for synthesis (review-status:synthesis) + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🔍 ANALYZING FEEDBACK +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + // Group feedback by type + feedback_by_type = { + scope: [], + story_split: [], + dependency: [], + priority: [], + technical_risk: [], + missing_story: [] + } + + for (item of feedback_items) { + const type = item.type || 'general' + if (!feedback_by_type[type]) feedback_by_type[type] = [] + feedback_by_type[type].push(item) + } + + // Identify story-specific feedback + story_feedback = {} + for (item of feedback_items) { + if (item.section && item.section.startsWith('story')) { + const story_id = item.section + if (!story_feedback[story_id]) story_feedback[story_id] = [] + story_feedback[story_id].push(item) + } + } + + + +**Feedback by Type:** + 🔍 Scope: {{feedback_by_type.scope.length}} + 📝 Story Split: {{feedback_by_type.story_split.length}} + 🔗 Dependencies: {{feedback_by_type.dependency.length}} + ⚡ Priority: {{feedback_by_type.priority.length}} + ⚠️ Technical Risk: {{feedback_by_type.technical_risk.length}} + ➕ Missing Stories: {{feedback_by_type.missing_story.length}} + + + + + + + conflicts = [] + + // Check for conflicting scope feedback + if (feedback_by_type.scope.length > 1) { + const split_requests = feedback_by_type.scope.filter(f => + f.body?.toLowerCase().includes('split') || f.body?.toLowerCase().includes('too large') + ) + const merge_requests = feedback_by_type.scope.filter(f => + f.body?.toLowerCase().includes('merge') || f.body?.toLowerCase().includes('too small') + ) + + if (split_requests.length > 0 && merge_requests.length > 0) { + conflicts.push({ + type: 'scope_direction', + description: 'Conflicting feedback on epic size', + items: [...split_requests, ...merge_requests] + }) + } + } + + // Check for conflicting priority feedback + if (feedback_by_type.priority.length > 1) { + conflicts.push({ + type: 'priority_order', + description: 'Multiple priority reordering suggestions', + items: feedback_by_type.priority + }) + } + + // Check for conflicting story changes + for ([story_id, items] of Object.entries(story_feedback)) { + if (items.length > 1) { + conflicts.push({ + type: 'story_conflict', + story: story_id, + description: `Multiple feedback on ${story_id}`, + items: items + }) + } + } + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +⚠️ CONFLICTS DETECTED +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +{{#each conflicts}} +**{{type}}:** {{description}} +{{#each items}} + • @{{submittedBy}}: "{{title}}" +{{/each}} + +{{/each}} + +These will be analyzed and resolved by the synthesis engine. + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🤖 GENERATING SYNTHESIS +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Processing {{feedback_items.length}} feedback items... + + + + synthesis_prompt = `You are synthesizing stakeholder feedback for an epic. Your goal is to: +1. Incorporate valid feedback into story definitions +2. Resolve conflicts with clear rationale +3. Maintain story independence and testability +4. Ensure technical feasibility + +## Epic Context + +**Title:** ${title} +**Source PRD:** prd:${source_prd} + +## Current Stories + +${current_stories.map((s, i) => `### Story ${i + 1}: ${s.title} +${s.description || ''} +**Complexity:** ${s.complexity || 'TBD'} +**Acceptance Criteria:** +${s.acceptance_criteria || 'TBD'} +`).join('\n')} + +## Dependencies + +${dependencies.join('\n') || 'None specified'} + +## Feedback to Process + +${feedback_items.map(f => `### Feedback #${f.id} from @${f.submittedBy} +**Type:** ${f.type || 'general'} +**Section:** ${f.section || 'general'} +**Content:** +${f.body} +`).join('\n---\n')} + +${conflicts.length > 0 ? ` +## Conflicts to Resolve + +${conflicts.map(c => `**${c.type}:** ${c.description} +Conflicting feedback: +${c.items.map(i => `- @${i.submittedBy}: ${i.title}`).join('\n')} +`).join('\n')} +` : ''} + +## Your Task + +Generate an updated epic with: + +1. **Story Updates:** For each story, show: + - Original version + - Proposed changes + - Which feedback drove the change + - Confidence level (high/medium/low) + +2. **New Stories:** If missing_story feedback is valid, add new stories + +3. **Story Removals/Merges:** If story_split feedback suggests it + +4. **Dependency Updates:** Based on dependency feedback + +5. **Conflict Resolutions:** For each conflict, explain your resolution and rationale + +6. **Deferred Feedback:** Any feedback not incorporated and why + +Output format: +--- +## Summary +[Brief overview of changes] + +## Story Changes +[For each modified story] + +## New Stories +[Any new stories added] + +## Removed/Merged Stories +[Any stories removed or merged] + +## Dependency Updates +[Changes to dependencies] + +## Conflict Resolutions +[How conflicts were resolved] + +## Deferred Feedback +[Feedback not incorporated and why] +---` + + synthesis_result = await llm_generate(synthesis_prompt) + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📝 SYNTHESIS RESULT +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +{{synthesis_result}} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + +**Review the proposed changes above.** + +Options: +[1] Accept all changes +[2] Accept with modifications +[3] Reject and keep current version +[4] View detailed diff + + + + Choice: + + + +Changes rejected. Epic remains at v{{version}}. + + + + new_labels = current_labels + .filter(l => !l.startsWith('review-status:')) + .concat(['review-status:open']) + + + Call: mcp__github__issue_write({ + method: 'update', + owner: "{{github_owner}}", + repo: "{{github_repo}}", + issue_number: review_issue.number, + labels: new_labels + }) + + HALT + + + + Describe your modifications: + + modification_prompt = `${synthesis_prompt} + +Previous synthesis: +${synthesis_result} + +User modifications requested: +${response} + +Please regenerate the synthesis incorporating these modifications.` + + synthesis_result = await llm_generate(modification_prompt) + + + +Updated synthesis: + +{{synthesis_result}} + + + + + + diff_prompt = `Generate a detailed diff showing: +1. Each story's original text vs proposed text +2. New sections added +3. Sections removed +4. Specific line-by-line changes + +Current epic content: +${epic_content} + +Proposed changes from synthesis: +${synthesis_result}` + + diff_output = await llm_generate(diff_prompt) + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📋 DETAILED DIFF +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +{{diff_output}} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + Accept these changes? (y/n): + + HALT + + + + + + + new_version = version + 1 + + update_prompt = `Generate the complete updated epic document in markdown format. + +Original epic: +${epic_content} + +Changes to apply: +${synthesis_result} + +Requirements: +1. Increment version to ${new_version} +2. Update "Last Updated" to today's date +3. Keep document structure intact +4. Add entry to Version History table +5. Update Status to "Feedback" (for next round) or "Ready for Sign-off" + +Output the complete markdown document.` + + updated_epic = await llm_generate(update_prompt) + + + + + Write updated_epic to epic_path + + +✅ Epic document updated: {{epic_path}} + Version: v{{version}} → v{{new_version}} + + + + + + // Mark processed feedback as incorporated + for (item of feedback_items) { + Call: mcp__github__issue_write({ + method: 'update', + owner: github_owner, + repo: github_repo, + issue_number: item.id, + labels: item.labels + .filter(l => !l.startsWith('feedback-status:')) + .concat(['feedback-status:incorporated']), + state: 'closed', + state_reason: 'completed' + }) + } + + + +✅ {{feedback_items.length}} feedback issues marked as incorporated + + + + + + synthesis_comment = `## 🔄 Synthesis Complete + +**Version:** v${version} → v${new_version} +**Feedback Processed:** ${feedback_items.length} items +**Conflicts Resolved:** ${conflicts.length} + +--- + +### Summary of Changes + +${synthesis_result.split('## Summary')[1]?.split('##')[0] || 'See updated epic document'} + +--- + +### Feedback Incorporated + +${feedback_items.map(f => `- ✅ #${f.id} (@${f.submittedBy}): ${f.title}`).join('\n')} + +--- + +_Synthesized by @${current_user} on ${new Date().toISOString().split('T')[0]}_` + + + Call: mcp__github__add_issue_comment({ + owner: "{{github_owner}}", + repo: "{{github_repo}}", + issue_number: review_issue.number, + body: synthesis_comment + }) + + + // Update version label and unlock + final_labels = new_labels + .filter(l => !l.startsWith('version:') && !l.startsWith('review-status:')) + .concat([`version:${new_version}`, 'review-status:open']) + + + Call: mcp__github__issue_write({ + method: 'update', + owner: "{{github_owner}}", + repo: "{{github_repo}}", + issue_number: review_issue.number, + labels: final_labels + }) + + +✅ Review issue updated with synthesis summary + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ EPIC SYNTHESIS COMPLETE +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Epic:** epic:{{epic_key}} +**Version:** v{{version}} → v{{new_version}} +**Feedback Processed:** {{feedback_items.length}} +**Conflicts Resolved:** {{conflicts.length}} + +**Document:** {{epic_path}} +**Review Issue:** #{{review_issue.number}} + +--- + +**Next Steps:** +- Review the updated epic document +- Open another feedback round if needed +- Or request sign-off when ready + +**Quick Actions:** +[OE] Open another feedback round +[RS] Request sign-off +[ED] Epic Dashboard + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + +## Helper Functions + +```javascript +function extract_title(content) { + const match = content.match(/^#\s+(PRD|Epic):\s*(.+)$/m); + return match ? match[2].trim() : 'Untitled'; +} + +function extract_version(content) { + const match = content.match(/\*\*Version:\*\*\s*(\d+)/); + return match ? match[1] : '1'; +} + +function extract_source_prd(content) { + const match = content.match(/\*\*Source PRD:\*\*\s*`?prd:([^`\s]+)`?/); + return match ? match[1] : null; +} + +function extract_epic_stories(content) { + const stories = []; + const storyRegex = /###\s+Story\s+(\d+):\s*(.+)\n+([\s\S]*?)(?=###\s+Story|---|\n##|$)/gi; + let match; + while ((match = storyRegex.exec(content)) !== null) { + const number = match[1]; + const title = match[2].trim(); + const body = match[3]; + + const complexityMatch = body.match(/\*\*(?:Estimated )?Complexity:\*\*\s*(\w+)/i); + const descMatch = body.match(/\*\*Description:\*\*\s*(.+)/); + const acMatch = body.match(/\*\*Acceptance Criteria:\*\*\s*([\s\S]*?)(?=\*\*|$)/); + + stories.push({ + number: number, + title: title, + description: descMatch ? descMatch[1].trim() : '', + complexity: complexityMatch ? complexityMatch[1] : null, + acceptance_criteria: acMatch ? acMatch[1].trim() : null + }); + } + return stories; +} + +function extract_dependencies(content) { + const section = content.match(/## Dependencies\n+([\s\S]*?)(?=\n##|$)/); + if (!section) return []; + + return section[1] + .split('\n') + .filter(line => line.startsWith('-')) + .map(line => line.replace(/^-\s*/, '').trim()) + .filter(line => line && line !== 'TBD'); +} +``` + +## Natural Language Triggers + +This workflow responds to: +- "Synthesize epic feedback" +- "Process feedback for epic" +- "Merge epic feedback" +- Menu trigger: `SE` diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-epic-feedback/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-epic-feedback/workflow.yaml new file mode 100644 index 00000000..127477cd --- /dev/null +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-epic-feedback/workflow.yaml @@ -0,0 +1,21 @@ +name: synthesize-epic-feedback +description: "LLM-powered synthesis of epic feedback with story split and dependency resolution" +author: "BMad" +version: "1.0.0" + +config_source: "{project-root}/_bmad/bmm/config.yaml" +output_folder: "{config_source}:output_folder" +cache_dir: "{output_folder}/cache" +docs_dir: "{project-root}/docs" + +github: + owner: "{config_source}:github_owner" + repo: "{config_source}:github_repo" + +# Parameters +epic_key: "" + +installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/synthesize-epic-feedback" +instructions: "{installed_path}/instructions.md" + +standalone: true diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-feedback/instructions.md b/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-feedback/instructions.md new file mode 100644 index 00000000..2066fa8e --- /dev/null +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-feedback/instructions.md @@ -0,0 +1,610 @@ +# Synthesize Feedback - LLM-Powered Conflict Resolution + +The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml +You MUST have already loaded and processed: {installed_path}/workflow.yaml + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🔄 SYNTHESIZE FEEDBACK +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +This workflow will: +1. Analyze all feedback for the document +2. Identify conflicts and themes +3. Generate proposed changes with rationale +4. Allow you to accept/modify/reject each change +5. Update the document with a new version + + + + Call: mcp__github__get_me() + current_user = response.login + + + ❌ GitHub MCP not accessible + HALT + + + + + + Which document? Enter key: + document_key = response + + + + if (document_type === 'prd') { + doc_path = `${docs_dir}/prd/${document_key}.md` + doc_label = `prd:${document_key}` + feedback_label = 'type:prd-feedback' + } else { + doc_path = `${docs_dir}/epics/epic-${document_key}.md` + doc_label = `epic:${document_key}` + feedback_label = 'type:epic-feedback' + } + + + Read doc_path + + ❌ Document not found: {{doc_path}} + HALT + + original_content = file_content + current_version = extract_version(original_content) + + +📄 Loaded: {{doc_label}} v{{current_version}} + + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:{{feedback_label}} label:{{doc_label}} label:feedback-status:new is:open" + }) + + feedback_issues = response.items || [] + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ NO NEW FEEDBACK TO PROCESS +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +All feedback has already been processed (reviewed, incorporated, or deferred). + +Would you like to: +[1] Re-process already-reviewed feedback +[2] View feedback history +[3] Return to dashboard + + Choice: + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:{{feedback_label}} label:{{doc_label}} is:open" + }) + feedback_issues = response.items || [] + + + Load workflow: view-feedback with document_key, document_type + + + HALT + + + + +📋 Found {{feedback_issues.length}} feedback items to process + + + + + + // Parse all feedback into structured format + all_feedback = [] + by_section = {} + + for (issue of feedback_issues) { + const labels = issue.labels.map(l => l.name) + + const fb = { + id: issue.number, + title: issue.title.replace(/^[^\s]+\s+Feedback:\s*/, ''), + section: extract_label(labels, 'feedback-section:') || 'General', + type: extract_label(labels, 'feedback-type:') || 'suggestion', + priority: extract_label(labels, 'priority:') || 'medium', + submittedBy: issue.user?.login, + body: issue.body, + suggestedChange: extract_suggested_change(issue.body) + } + + all_feedback.push(fb) + + if (!by_section[fb.section]) by_section[fb.section] = [] + by_section[fb.section].push(fb) + } + + sections_with_feedback = Object.keys(by_section) + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📊 FEEDBACK ANALYSIS +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Sections with Feedback:** {{sections_with_feedback.length}} +{{#each by_section as |items section|}} + • {{section}}: {{items.length}} item(s) +{{/each}} + +Processing each section... + + + + + proposed_changes = [] + section_index = 0 + + + section_index++ + section_feedback = by_section[section] + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📝 SECTION {{section_index}}/{{sections_with_feedback.length}}: {{section}} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Feedback Items:** {{section_feedback.length}} + +{{#each section_feedback}} +┌─ #{{id}}: {{title}} +│ Type: {{type}} | Priority: {{priority}} | By: @{{submittedBy}} +{{#if suggestedChange}} +│ 💡 Suggests: {{suggestedChange}} +{{/if}} +└──────────────────────────────────── +{{/each}} + + + + + original_section_text = extract_section(original_content, section) + + + + + + has_conflict = section_feedback.length >= 2 && + section_feedback.some(f => f.type === 'concern') && + section_feedback.some(f => f.type === 'suggestion' || f.type === 'concern') + + + + + +⚠️ CONFLICT DETECTED - Multiple stakeholders have different views + +Generating resolution proposal... + + + + // Build conflict resolution prompt + conflict_prompt = `You are helping resolve conflicting stakeholder feedback on a ${document_type.toUpperCase()}. + +SECTION: ${section} + +ORIGINAL TEXT: +${original_section_text || '[Section not found in document]'} + +CONFLICTING FEEDBACK: +${section_feedback.map(f => ` +- @${f.submittedBy} (${f.type}, ${f.priority}): "${f.title}" + ${f.suggestedChange ? 'Suggests: ' + f.suggestedChange : ''} +`).join('\n')} + +Propose a resolution that: +1. Addresses the core concerns of all parties +2. Maintains document coherence +3. Is actionable and specific + +Respond with: +1. PROPOSED_TEXT: The updated section text +2. RATIONALE: Why this resolution works (2-3 sentences) +3. TRADE_OFFS: What compromises were made +4. CONFIDENCE: high/medium/low` + + + +**Original Text:** +{{original_section_text || '[Section not found]'}} + +--- + +**🤖 AI-Proposed Resolution:** + +{{LLM processes conflict_prompt and generates resolution}} + + + + + + + // Non-conflicting feedback - straightforward merge + merge_prompt = `Incorporate the following feedback into this ${document_type.toUpperCase()} section: + +SECTION: ${section} + +ORIGINAL TEXT: +${original_section_text || '[Section not found]'} + +FEEDBACK TO INCORPORATE: +${section_feedback.map(f => ` +- ${f.type}: ${f.title} + ${f.suggestedChange ? 'Suggested change: ' + f.suggestedChange : 'Address this concern'} +`).join('\n')} + +Generate updated section text that: +1. Addresses all feedback points +2. Maintains consistent tone and format +3. Is clear and actionable + +Return the complete updated section text.` + + + +**Original Text:** +{{original_section_text || '[Section not found]'}} + +--- + +**🤖 AI-Proposed Update:** + +{{LLM processes merge_prompt and generates updated text}} + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +What would you like to do with this proposed change? + +[A] Accept as proposed +[M] Modify (I'll provide my version) +[R] Reject (keep original) +[S] Skip for now + + + + Decision for {{section}}: + + + + proposed_changes.push({ + section: section, + decision: 'accept', + newText: proposed_text, + feedbackIds: section_feedback.map(f => f.id) + }) + + ✅ Accepted + + + + Enter your modified text for this section: + + proposed_changes.push({ + section: section, + decision: 'modified', + newText: response, + feedbackIds: section_feedback.map(f => f.id) + }) + + ✅ Modified version saved + + + + + proposed_changes.push({ + section: section, + decision: 'reject', + newText: null, + feedbackIds: section_feedback.map(f => f.id) + }) + + ❌ Rejected - keeping original + + + + ⏭️ Skipped + + + + + + + accepted_changes = proposed_changes.filter(c => c.decision === 'accept' || c.decision === 'modified') + rejected_changes = proposed_changes.filter(c => c.decision === 'reject') + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📋 SYNTHESIS SUMMARY +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Accepted Changes:** {{accepted_changes.length}} +{{#each accepted_changes}} + ✅ {{section}} ({{decision}}) +{{/each}} + +**Rejected Changes:** {{rejected_changes.length}} +{{#each rejected_changes}} + ❌ {{section}} +{{/each}} + +--- + + + + + +No changes to apply. Workflow complete. + + HALT + + + Apply these changes to the document? (y/n): + + + +Changes not applied. You can re-run synthesis later. + + HALT + + + + + + new_version = parseInt(current_version) + 1 + updated_content = original_content + + // Apply each accepted change + for (change of accepted_changes) { + if (change.newText) { + updated_content = replace_section(updated_content, change.section, change.newText) + } + } + + // Update version and timestamp + updated_content = updated_content + .replace(/\*\*Version:\*\* \d+/, `**Version:** ${new_version}`) + .replace(/\*\*Last Updated:\*\* .+/, `**Last Updated:** ${new Date().toISOString().split('T')[0]}`) + .replace(/\*\*Status:\*\* .+/, '**Status:** Draft') + + // Add version history entry + version_entry = `| ${new_version} | ${new Date().toISOString().split('T')[0]} | Synthesized feedback from ${all_feedback.length} items | ${accepted_changes.map(c => c.section).join(', ')} |` + updated_content = updated_content.replace( + /(## Version History\n\n\|[^\n]+\|\n\|[^\n]+\|)/, + `$1\n${version_entry}` + ) + + + Write updated_content to doc_path + + +✅ Document updated to v{{new_version}} + + + + + +Updating feedback issue statuses... + + + + // Mark accepted feedback as incorporated + for (change of accepted_changes) { + for (id of change.feedbackIds) { + await update_feedback_status(id, 'incorporated') + } + } + + // Mark rejected feedback as reviewed + for (change of rejected_changes) { + for (id of change.feedbackIds) { + await update_feedback_status(id, 'reviewed') + } + } + + + +✅ {{accepted_changes.flatMap(c => c.feedbackIds).length}} feedback items marked as incorporated +✅ {{rejected_changes.flatMap(c => c.feedbackIds).length}} feedback items marked as reviewed + + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:type:{{document_type}}-review label:{{doc_label}} is:open" + }) + + + review_issue = response.items[0] + + + synthesis_comment = `## 🔄 Synthesis Complete + +**New Version:** v${new_version} +**Feedback Processed:** ${all_feedback.length} items +**Changes Applied:** ${accepted_changes.length} sections + +## Summary of Changes + +${accepted_changes.map(c => `- ✅ **${c.section}**: Updated based on ${c.feedbackIds.length} feedback item(s)`).join('\n')} + +${rejected_changes.length > 0 ? ` +## Feedback Not Incorporated + +${rejected_changes.map(c => `- ❌ **${c.section}**: Kept original`).join('\n')} +` : ''} + +--- + +The document has been updated. Review the changes and proceed to sign-off when ready. + +_Synthesis performed by @${current_user} on ${new Date().toISOString().split('T')[0]}_` + + + Call: mcp__github__add_issue_comment({ + owner: "{{github_owner}}", + repo: "{{github_repo}}", + issue_number: review_issue.number, + body: synthesis_comment + }) + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ SYNTHESIS COMPLETE +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Document:** {{doc_label}} +**New Version:** v{{new_version}} +**Changes Applied:** {{accepted_changes.length}} sections +**Feedback Processed:** {{all_feedback.length}} items + +--- + +**Next Steps:** +[1] Request sign-off from stakeholders +[2] Open another feedback round (for major changes) +[3] View updated document +[4] Done + + + + Choice: + + + Load workflow: request-signoff with document_key, document_type + + + + Load workflow: open-feedback-round with document_key, document_type + + + + Read and display doc_path + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Synthesis workflow complete. +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + +## Helper Functions + +```javascript +// Extract version from document +function extract_version(content) { + const match = content.match(/\*\*Version:\*\*\s*(\d+)/); + return match ? match[1] : '1'; +} + +// Extract label value by prefix +function extract_label(labels, prefix) { + for (const label of labels) { + if (label.startsWith(prefix)) { + return label.replace(prefix, ''); + } + } + return null; +} + +// Extract suggested change from issue body +function extract_suggested_change(body) { + if (!body) return null; + const match = body.match(/## Suggested Change\n\n([\s\S]*?)(?:\n##|$)/); + return match ? match[1].trim().slice(0, 150) : null; +} + +// Extract section content from document +function extract_section(content, sectionName) { + // Normalize section name for matching + const normalized = sectionName.toLowerCase().replace(/-/g, ' '); + + // Try to find the section + const regex = new RegExp(`^##\\s+${normalized}\\s*$`, 'im'); + const match = content.match(regex); + + if (!match) return null; + + const startIdx = match.index + match[0].length; + const nextSection = content.indexOf('\n## ', startIdx); + const endIdx = nextSection > -1 ? nextSection : content.length; + + return content.slice(startIdx, endIdx).trim(); +} + +// Replace section content in document +function replace_section(content, sectionName, newText) { + const normalized = sectionName.toLowerCase().replace(/-/g, ' '); + const regex = new RegExp(`(^##\\s+${normalized}\\s*$)([\\s\\S]*?)(?=\\n## |$)`, 'im'); + + return content.replace(regex, `$1\n\n${newText}\n\n`); +} + +// Update feedback status via GitHub +async function update_feedback_status(issueNumber, newStatus) { + // Get current labels + const issue = await mcp__github__issue_read({ + method: 'get', + owner: github_owner, + repo: github_repo, + issue_number: issueNumber + }); + + const labels = issue.labels + .map(l => l.name) + .filter(l => !l.startsWith('feedback-status:')); + + labels.push(`feedback-status:${newStatus}`); + + await mcp__github__issue_write({ + method: 'update', + owner: github_owner, + repo: github_repo, + issue_number: issueNumber, + labels: labels + }); + + // Close if incorporated or deferred + if (newStatus === 'incorporated' || newStatus === 'deferred') { + await mcp__github__issue_write({ + method: 'update', + owner: github_owner, + repo: github_repo, + issue_number: issueNumber, + state: 'closed', + state_reason: newStatus === 'incorporated' ? 'completed' : 'not_planned' + }); + } +} +``` + +## Natural Language Triggers + +This workflow responds to: +- "Synthesize feedback for [document]" +- "Process feedback on PRD" +- "Incorporate feedback into [document]" +- "Merge feedback" +- Menu trigger: `SZ` diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-feedback/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-feedback/workflow.yaml new file mode 100644 index 00000000..a98e48d3 --- /dev/null +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-feedback/workflow.yaml @@ -0,0 +1,22 @@ +name: synthesize-feedback +description: "LLM-powered synthesis of feedback into document updates with conflict resolution" +author: "BMad" +version: "1.0.0" + +config_source: "{project-root}/_bmad/bmm/config.yaml" +output_folder: "{config_source}:output_folder" +cache_dir: "{output_folder}/cache" +docs_dir: "{project-root}/docs" + +github: + owner: "{config_source}:github_owner" + repo: "{config_source}:github_repo" + +# Parameters +document_key: "" +document_type: "prd" # "prd" or "epic" + +installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/synthesize-feedback" +instructions: "{installed_path}/instructions.md" + +standalone: true diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/view-feedback/instructions.md b/src/modules/bmm/workflows/1-requirements/crowdsource/view-feedback/instructions.md new file mode 100644 index 00000000..d63d148f --- /dev/null +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/view-feedback/instructions.md @@ -0,0 +1,411 @@ +# View Feedback - Review All Stakeholder Input + +The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml +You MUST have already loaded and processed: {installed_path}/workflow.yaml + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +👁️ VIEW FEEDBACK +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + Call: mcp__github__get_me() + + + ❌ GitHub MCP not accessible + HALT + + + + + + Which document? Enter key (e.g., "user-auth" for PRD, "2" for Epic): + document_key = response + + + + Is this a [P]RD or [E]pic? + document_type = (response.toLowerCase().startsWith('p')) ? 'prd' : 'epic' + + + + doc_label = `${document_type}:${document_key}` + feedback_label = `type:${document_type}-feedback` + + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:{{feedback_label}} label:{{doc_label}} is:open" + }) + + feedback_issues = response.items || [] + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📭 NO FEEDBACK FOUND +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +No feedback has been submitted for {{doc_label}} yet. + +**Actions:** +[SF] Submit Feedback +[MT] My Tasks +[Q] Quit + + Choice: + + Load workflow: submit-feedback with document_key, document_type + + + Load workflow: my-tasks + + HALT + + + + + + // Parse feedback issues into structured data + all_feedback = [] + by_section = {} + by_type = {} + by_status = { new: [], reviewed: [], incorporated: [], deferred: [] } + conflicts = [] + + for (issue of feedback_issues) { + const labels = issue.labels.map(l => l.name) + + const fb = { + id: issue.number, + url: issue.html_url, + title: issue.title.replace(/^[^\s]+\s+Feedback:\s*/, ''), + section: extract_label(labels, 'feedback-section:') || 'General', + type: extract_label(labels, 'feedback-type:') || 'suggestion', + status: extract_label(labels, 'feedback-status:') || 'new', + priority: extract_label(labels, 'priority:') || 'medium', + submittedBy: issue.user?.login, + createdAt: issue.created_at, + body: issue.body + } + + all_feedback.push(fb) + + // Group by section + if (!by_section[fb.section]) by_section[fb.section] = [] + by_section[fb.section].push(fb) + + // Group by type + if (!by_type[fb.type]) by_type[fb.type] = [] + by_type[fb.type].push(fb) + + // Group by status + if (by_status[fb.status]) by_status[fb.status].push(fb) + } + + // Detect potential conflicts (multiple feedback on same section) + for (const [section, items] of Object.entries(by_section)) { + if (items.length >= 2) { + const concerns = items.filter(i => i.type === 'concern') + const suggestions = items.filter(i => i.type === 'suggestion') + + if (concerns.length > 1 || (concerns.length >= 1 && suggestions.length >= 1)) { + conflicts.push({ + section, + count: items.length, + items: items + }) + } + } + } + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📊 FEEDBACK SUMMARY: {{doc_label}} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Total Feedback:** {{all_feedback.length}} items + +**By Status:** + 🆕 New: {{by_status.new.length}} + 👀 Reviewed: {{by_status.reviewed.length}} + ✅ Incorporated: {{by_status.incorporated.length}} + ⏸️ Deferred: {{by_status.deferred.length}} + +**By Type:** +{{#each by_type as |items type|}} + {{get_type_emoji type}} {{type}}: {{items.length}} +{{/each}} + +**By Section:** +{{#each by_section as |items section|}} + • {{section}}: {{items.length}} item(s) +{{/each}} + +{{#if conflicts.length}} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +⚠️ POTENTIAL CONFLICTS DETECTED: {{conflicts.length}} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +{{#each conflicts}} +**{{section}}** - {{count}} stakeholders have input: +{{#each items}} + • @{{submittedBy}}: "{{title}}" +{{/each}} + +{{/each}} +{{/if}} + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**View Options:** +[1] View by Section +[2] View by Type +[3] View Conflicts Only +[4] View All Details +[5] Export to Markdown + +**Actions:** +[S] Synthesize Feedback (incorporate into document) +[R] Refresh +[Q] Quit + + + + Choice: + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📂 FEEDBACK BY SECTION +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +{{#each by_section as |items section|}} +## {{section}} ({{items.length}} items) + +{{#each items}} +┌──────────────────────────────────────────── +│ #{{id}}: {{title}} +│ Type: {{get_type_emoji type}} {{type}} | Priority: {{priority}} | Status: {{status}} +│ By: @{{submittedBy}} on {{format_date createdAt}} +└──────────────────────────────────────────── + +{{/each}} +{{/each}} + + Goto step 5 + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🏷️ FEEDBACK BY TYPE +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +{{#each by_type as |items type|}} +## {{get_type_emoji type}} {{type}} ({{items.length}} items) + +{{#each items}} +| #{{id}} | {{title}} | @{{submittedBy}} | {{section}} | +{{/each}} + +{{/each}} + + Goto step 5 + + + + + +✅ No conflicts detected! All feedback is non-overlapping. + + Goto step 5 + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +⚠️ CONFLICTS REQUIRING RESOLUTION +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +{{#each conflicts}} +## Conflict in: {{section}} + +Multiple stakeholders have provided feedback on this section: + +{{#each items}} +### @{{submittedBy}} - {{type}} +**{{title}}** + +{{extract_feedback body}} + +--- +{{/each}} + +**Suggested Resolution:** Use synthesis workflow to generate proposed resolution. + +{{/each}} + + Goto step 5 + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📋 ALL FEEDBACK DETAILS +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +{{#each all_feedback}} +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +┃ #{{id}}: {{title}} +┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +┃ Type: {{get_type_emoji type}} {{type}} +┃ Section: {{section}} +┃ Priority: {{priority}} +┃ Status: {{status}} +┃ By: @{{submittedBy}} +┃ Date: {{format_date createdAt}} +┃ URL: {{url}} +┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +┃ FEEDBACK: +┃ {{extract_feedback body}} +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +{{/each}} + + Goto step 5 + + + + + // Generate markdown export + export_content = `# Feedback Report: ${doc_label} + +Generated: ${new Date().toISOString()} +Total Feedback: ${all_feedback.length} + +## Summary + +| Type | Count | +|------|-------| +${Object.entries(by_type).map(([t, items]) => `| ${t} | ${items.length} |`).join('\n')} + +## By Section + +${Object.entries(by_section).map(([section, items]) => ` +### ${section} + +${items.map(fb => `- **${fb.title}** (${fb.type}, ${fb.priority}) - @${fb.submittedBy} #${fb.id}`).join('\n')} +`).join('\n')} + +## Conflicts + +${conflicts.length === 0 ? 'No conflicts detected.' : conflicts.map(c => ` +### ${c.section} + +${c.items.map(fb => `- @${fb.submittedBy}: "${fb.title}"`).join('\n')} +`).join('\n')} +` + export_path = `${cache_dir}/feedback-report-${document_key}.md` + + Write export_content to export_path + +✅ Exported to: {{export_path}} + + Goto step 5 + + + + +Opening synthesis workflow for {{doc_label}}... + + Load workflow: synthesize-feedback with document_key, document_type + + + + Goto step 2 + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +View Feedback closed. +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Exit + + + Goto step 5 + + + + +## Helper Functions + +```javascript +// Extract label value by prefix +function extract_label(labels, prefix) { + for (const label of labels) { + if (label.startsWith(prefix)) { + return label.replace(prefix, ''); + } + } + return null; +} + +// Get emoji for feedback type +function get_type_emoji(type) { + const emojis = { + clarification: '📋', + concern: '⚠️', + suggestion: '💡', + addition: '➕', + priority: '🔢', + scope: '📐', + dependency: '🔗', + 'technical-risk': '🔧', + 'story-split': '✂️' + }; + return emojis[type] || '📝'; +} + +// Format date for display +function format_date(isoDate) { + return new Date(isoDate).toISOString().split('T')[0]; +} + +// Extract feedback content from issue body +function extract_feedback(body) { + if (!body) return 'No details provided.'; + + // Try to extract the Feedback section + const match = body.match(/## Feedback\n\n([\s\S]*?)(?:\n##|$)/); + if (match) { + return match[1].trim().slice(0, 200) + (match[1].length > 200 ? '...' : ''); + } + + // Fallback to first 200 chars + return body.slice(0, 200) + (body.length > 200 ? '...' : ''); +} +``` + +## Natural Language Triggers + +This workflow responds to: +- "View feedback for [document]" +- "Show feedback on PRD" +- "What feedback has been submitted?" +- "See all feedback for [document]" +- Menu trigger: `VF` diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/view-feedback/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/view-feedback/workflow.yaml new file mode 100644 index 00000000..13786d31 --- /dev/null +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/view-feedback/workflow.yaml @@ -0,0 +1,21 @@ +name: view-feedback +description: "View all feedback for a PRD or Epic - grouped by section and type" +author: "BMad" +version: "1.0.0" + +config_source: "{project-root}/_bmad/bmm/config.yaml" +output_folder: "{config_source}:output_folder" +cache_dir: "{output_folder}/cache" + +github: + owner: "{config_source}:github_owner" + repo: "{config_source}:github_repo" + +# Parameters +document_key: "" +document_type: "" # "prd" or "epic" + +installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/view-feedback" +instructions: "{installed_path}/instructions.md" + +standalone: true diff --git a/src/modules/bmm/workflows/4-implementation/available-stories/instructions.md b/src/modules/bmm/workflows/4-implementation/available-stories/instructions.md new file mode 100644 index 00000000..a76fd4b5 --- /dev/null +++ b/src/modules/bmm/workflows/4-implementation/available-stories/instructions.md @@ -0,0 +1,161 @@ +# Available Stories - Find Unlocked Work + +The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml +You MUST have already loaded and processed: {installed_path}/workflow.yaml + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📋 AVAILABLE STORIES +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + Call: mcp__github__get_me() + + + +❌ CRITICAL: GitHub MCP not accessible + +Cannot list stories without GitHub API access. + +HALTING + + HALT + + + current_user = response.login + Connected as @{{current_user}} + + + + + + Build search query: + +query = "repo:{{github_owner}}/{{github_repo}} label:type:story no:assignee" + +# Add status filter +IF status is provided: + query += " label:status:{{status}}" +ELSE: + query += " (label:status:ready-for-dev OR label:status:backlog)" + +# Add epic filter +IF epic is provided: + query += " label:epic:{{epic}}" + +# Sort by most recently updated +query += " sort:updated-desc" + + + Call: mcp__github__search_issues({ query: query }) + + available_stories = response.items + + + + + Build locked query: + +locked_query = "repo:{{github_owner}}/{{github_repo}} label:type:story -no:assignee" + +IF epic is provided: + locked_query += " label:epic:{{epic}}" + + + Call: mcp__github__search_issues({ query: locked_query }) + locked_stories = response.items + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📦 AVAILABLE STORIES (Unlocked) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +{{#if epic}}Filter: Epic {{epic}}{{/if}} +{{#if status}}Filter: Status {{status}}{{/if}} + + + + + +No unlocked stories found. + +{{#if epic}} +All stories in Epic {{epic}} are either: +- Already locked by a developer +- Completed (status:done) +- Not yet created + +Try: +- /available-stories (no filter) +- /lock-status epic={{epic}} (see who has what) +{{else}} +All stories are currently locked or completed. + +Try: +- /lock-status (see who's working on what) +{{/if}} + + + + + Group stories by epic: + + +{{#each available_stories_by_epic}} +**Epic {{epic_number}}** +{{#each stories}} + {{@index + 1}}. {{story_key}} + Title: {{title}} + Status: {{status_label}} + Complexity: {{complexity_label}} + Issue: #{{issue_number}} + Checkout: /checkout-story story_key={{story_key}} + +{{/each}} +{{/each}} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +**Total Available:** {{available_stories.length}} stories +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🔒 LOCKED STORIES (Not Available) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +{{#each locked_stories}} + ~~{{story_key}}~~ - Locked by @{{assignee}} + Title: {{title}} + Issue: #{{issue_number}} + Since: {{updated_at}} + +{{/each}} + +**Total Locked:** {{locked_stories.length}} stories +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + +**Quick Actions:** +- Checkout a story: /checkout-story story_key=X-Y-slug +- Filter by epic: /available-stories epic=2 +- See all locks: /lock-status +- See your locks: /lock-status user={{current_user}} + + + + + diff --git a/src/modules/bmm/workflows/4-implementation/available-stories/workflow.yaml b/src/modules/bmm/workflows/4-implementation/available-stories/workflow.yaml new file mode 100644 index 00000000..e10b041c --- /dev/null +++ b/src/modules/bmm/workflows/4-implementation/available-stories/workflow.yaml @@ -0,0 +1,24 @@ +name: available-stories +description: "List all stories that are unlocked and available for checkout. Helps developers find work without conflicts." +author: "BMad" +version: "1.0.0" + +# Critical variables from config +config_source: "{project-root}/_bmad/bmm/config.yaml" +output_folder: "{config_source}:output_folder" + +# GitHub configuration +github: + owner: "{config_source}:github_integration.repository.owner" + repo: "{config_source}:github_integration.repository.repo" + +# Filter parameters +epic: "" # Optional: Filter by epic number (e.g., "2") +status: "" # Optional: Filter by status (default: ready-for-dev, backlog) +show_locked: false # Show locked stories too (grayed out) + +# Workflow components +installed_path: "{project-root}/_bmad/bmm/workflows/4-implementation/available-stories" +instructions: "{installed_path}/instructions.md" + +standalone: true diff --git a/src/modules/bmm/workflows/4-implementation/checkout-story/instructions.md b/src/modules/bmm/workflows/4-implementation/checkout-story/instructions.md new file mode 100644 index 00000000..9c472607 --- /dev/null +++ b/src/modules/bmm/workflows/4-implementation/checkout-story/instructions.md @@ -0,0 +1,404 @@ +# Checkout Story - Lock Story for Development + +The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml +You MUST have already loaded and processed: {installed_path}/workflow.yaml +TEAM COORDINATION: This workflow prevents duplicate work by locking stories + + + + + Verify prerequisites before attempting lock acquisition + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🔒 STORY CHECKOUT - Lock Acquisition +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + +❌ ERROR: story_key parameter required + +Usage: + /checkout-story story_key=2-5-auth + +Available stories: + Run /available-stories to see unlocked stories + +HALTING + + HALT + + + Validate story_key format: {{story_key}} + Expected format: {epic_number}-{story_number}-{slug} + Example: 2-5-auth, 3-1-user-profile + + + +⚠️ WARNING: story_key format may be non-standard + +Expected: {epic}-{story}-{slug} (e.g., "2-5-auth") +Received: {{story_key}} + +Proceeding anyway - will search GitHub for matching story... + + + + 📦 Story: {{story_key}} + + + + Test GitHub MCP connection: + Call: mcp__github__get_me() + + + +❌ CRITICAL: GitHub MCP not accessible + +Cannot checkout story without GitHub API access. +Story locking requires GitHub Issue assignment. + +Fix: +1. Ensure GitHub MCP is configured +2. Verify authentication token is valid +3. Check network connectivity + +HALTING + + HALT + + + Extract current user: {{current_user}} = response.login + ✅ GitHub connected as @{{current_user}} + + + + Count user's currently locked stories + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} assignee:{{current_user}} label:status:in-progress label:type:story" + }) + + current_lock_count = response.total_count or response.items.length + + + +⚠️ WARNING: Maximum locks reached + +You have {{current_lock_count}}/{{max_locks_per_user}} stories locked: +{{#each current_locks}} +- {{story_key}}: {{title}} +{{/each}} + +Either: +1. Complete a story: /dev-story story_file={{first_lock}} +2. Unlock a story: /unlock-story story_key={{first_lock_key}} + +HALTING (max_locks_per_user={{max_locks_per_user}}) + + HALT + + + 📊 Current locks: {{current_lock_count}}/{{max_locks_per_user}} + + + + + Verify story exists and is not locked by another developer + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🔍 Checking Story Availability +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:story:{{story_key}}" + }) + + + +❌ ERROR: Story not found in GitHub + +Story "{{story_key}}" does not exist in GitHub Issues. + +Options: +1. Check story key spelling +2. Run /available-stories to see available stories +3. If story exists locally but not in GitHub: + Run /migrate-to-github to sync stories + +HALTING + + HALT + + + issue = response.items[0] + issue_number = issue.number + issue_title = issue.title + current_assignee = issue.assignee?.login or null + + +📋 Found: Issue #{{issue_number}} + Title: {{issue_title}} + Status: {{extract_status_label(issue.labels)}} + Assignee: {{current_assignee or "None (available)"}} + + + + + + +❌ STORY LOCKED + +🔒 Story {{story_key}} is locked by @{{current_assignee}} + +Issue: #{{issue_number}} +Locked since: {{issue.updated_at}} + +Options: +1. Choose different story: /available-stories +2. Contact @{{current_assignee}} to coordinate +3. Ask Scrum Master to force-unlock if developer is unavailable: + /unlock-story story_key={{story_key}} --force + +HALTING - Cannot checkout locked story + + HALT + + + + +✅ Story already locked by you + +You already have this story checked out. +Lock will be refreshed. + +Issue: #{{issue_number}} + + Set refresh_mode = true + + + + ✅ Story is available for checkout + Set refresh_mode = false + + + + + + Acquire lock with retry logic and verification + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🔐 Acquiring Lock +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + Ensure lock directory exists: {{lock_dir}} + Create lock file: {{lock_dir}}/{{story_key}}.lock + + Lock file content: +```yaml +story_key: {{story_key}} +github_issue: {{issue_number}} +locked_by: {{current_user}} +locked_at: {{current_timestamp}} +timeout_at: {{current_timestamp + 8 hours}} +last_heartbeat: {{current_timestamp}} +epic_number: {{extract_epic_from_story_key(story_key)}} +``` + + + ✅ Local lock file created + + + + ATOMIC ASSIGNMENT with verification: + + +attempt = 0 +max_attempts = 4 # 1 initial + 3 retries +backoffs = [1000, 3000, 9000] # ms + +WHILE attempt < max_attempts: + TRY: + # 1. Assign issue to current user + Call: mcp__github__issue_write({ + method: "update", + owner: {{github_owner}}, + repo: {{github_repo}}, + issue_number: {{issue_number}}, + assignees: ["{{current_user}}"] + }) + + # 2. Update status label + Call: mcp__github__issue_write({ + method: "update", + owner: {{github_owner}}, + repo: {{github_repo}}, + issue_number: {{issue_number}}, + labels: [update: remove "status:backlog", "status:ready-for-dev"; add "status:in-progress"] + }) + + # 3. Add lock comment + Call: mcp__github__add_issue_comment({ + owner: {{github_owner}}, + repo: {{github_repo}}, + issue_number: {{issue_number}}, + body: "🔒 **Story locked by @{{current_user}}**\n\n" + + "Lock acquired at: {{current_timestamp}}\n" + + "Lock expires: {{timeout_at}}\n\n" + + "_This lock prevents duplicate work. Lock will auto-expire after 8 hours._" + }) + + # 4. Verify assignment (CRITICAL - read back) + sleep 1 second # GitHub eventual consistency + + verification = Call: mcp__github__issue_read({ + method: "get", + owner: {{github_owner}}, + repo: {{github_repo}}, + issue_number: {{issue_number}} + }) + + # Check verification + IF verification.assignees does not include {{current_user}}: + THROW "Assignment verification failed - issue not assigned" + + IF verification.labels does not include "status:in-progress": + THROW "Label verification failed - status not updated" + + # SUCCESS! + output: "✅ GitHub Issue assigned and verified" + BREAK + + CATCH error: + attempt++ + IF attempt < max_attempts: + sleep backoffs[attempt - 1] + output: "⚠️ Retry {{attempt}}/3 after error: {{error}}" + ELSE: + # ROLLBACK: Remove local lock file + delete {{lock_dir}}/{{story_key}}.lock + + output: "❌ FAILED to acquire lock after 3 retries" + output: "Error: {{error}}" + output: "" + output: "Local lock file removed (rollback)" + output: "" + output: "Possible causes:" + output: "- Network connectivity issues" + output: "- GitHub API rate limiting" + output: "- Race condition (another dev assigned first)" + output: "" + output: "Try again in a few minutes or check /available-stories" + HALT + + + + + Update cache meta with lock info: + +cache_meta = load {{cache_dir}}/.bmad-cache-meta.json + +cache_meta.stories[{{story_key}}] = { + github_issue: {{issue_number}}, + locked_by: "{{current_user}}", + locked_until: "{{timeout_at}}", + locked_at: "{{current_timestamp}}" +} + +save cache_meta + + + ✅ Cache metadata updated + + + + + + Skip to Step 4 + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📦 Pre-fetching Epic Context +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + epic_number = extract first segment from story_key + Example: "2-5-auth" → epic_number = 2 + + 📁 Epic {{epic_number}} + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:epic:{{epic_number}} label:type:story" + }) + + epic_stories = response.items + Found {{epic_stories.length}} stories in Epic {{epic_number}} + + + + For each story in epic_stories: + - Extract story_key from labels + - Convert issue body to story content + - Write to cache: {{cache_dir}}/stories/{story_key}.md + - Update cache metadata + + +📥 Cached {{epic_stories.length}} stories: +{{#each epic_stories}} + - {{story_key}}: {{title}} +{{/each}} + +These stories are now available via Read tool for fast LLM access. + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ CHECKOUT COMPLETE +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Story:** {{story_key}} +**Issue:** #{{issue_number}} +**Locked by:** @{{current_user}} +**Lock expires:** {{timeout_at}} (8 hours) + +**Cached Story File:** +{{cache_dir}}/stories/{{story_key}}.md + +{{#if epic_prefetch}} +**Epic Context:** +{{epic_stories.length}} related stories cached for context +{{/if}} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Next Steps:** +1. Start development: + /dev-story story_file={{cache_dir}}/stories/{{story_key}}.md + +2. Or use batch pipeline: + /super-dev-pipeline story_key={{story_key}} + +**Lock Management:** +- Lock auto-refreshes during implementation +- Lock auto-expires after 8 hours of inactivity +- Manual unlock: /unlock-story story_key={{story_key}} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + diff --git a/src/modules/bmm/workflows/4-implementation/checkout-story/workflow.yaml b/src/modules/bmm/workflows/4-implementation/checkout-story/workflow.yaml new file mode 100644 index 00000000..79babf81 --- /dev/null +++ b/src/modules/bmm/workflows/4-implementation/checkout-story/workflow.yaml @@ -0,0 +1,32 @@ +name: checkout-story +description: "Lock a story for development - prevents other developers from working on it. Essential for enterprise team coordination." +author: "BMad" +version: "1.0.0" + +# Critical variables from config +config_source: "{project-root}/_bmad/bmm/config.yaml" +output_folder: "{config_source}:output_folder" +cache_dir: "{output_folder}/cache" +lock_dir: "{project-root}/.bmad/locks" + +# GitHub configuration +github: + owner: "{config_source}:github_integration.repository.owner" + repo: "{config_source}:github_integration.repository.repo" + +# Locking settings +locking: + default_timeout_hours: 8 + heartbeat_interval_minutes: 30 + stale_threshold_minutes: 15 + max_locks_per_user: 3 + +# Workflow parameters +story_key: "" # Required: Story to checkout (e.g., "2-5-auth") +epic_prefetch: true # Pre-fetch all stories in the same epic for context + +# Workflow components +installed_path: "{project-root}/_bmad/bmm/workflows/4-implementation/checkout-story" +instructions: "{installed_path}/instructions.md" + +standalone: true diff --git a/src/modules/bmm/workflows/4-implementation/dev-story/instructions.xml b/src/modules/bmm/workflows/4-implementation/dev-story/instructions.xml index 3f0c54ed..822543f0 100644 --- a/src/modules/bmm/workflows/4-implementation/dev-story/instructions.xml +++ b/src/modules/bmm/workflows/4-implementation/dev-story/instructions.xml @@ -382,6 +382,79 @@ + + + + MUST verify lock before each implementation session + + Check local lock file: {{lock_dir}}/{{story_key}}.lock + + + +⚠️ No local lock found for story {{story_key}} + +You should checkout the story first to acquire a lock: + /checkout-story story_key={{story_key}} + +Proceeding without lock (single-developer mode)... + + + + + Read lock file and verify: + - locked_by matches current user + - timeout_at has not passed + + + +⚠️ Lock expired for story {{story_key}} + +Your lock has expired. Re-checkout to refresh: + /checkout-story story_key={{story_key}} + + HALT - Cannot proceed with expired lock + + + + +❌ Lock belongs to different user + +Story {{story_key}} is locked by @{{lock_owner}} +Your user: @{{current_user}} + +This should not happen - investigate lock state. + + HALT - Lock mismatch + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:story:{{story_key}}" + }) + + + +❌ LOCK STOLEN + +GitHub shows story assigned to @{{issue.assignee.login}} +Your local lock says @{{current_user}} + +The lock was reassigned in GitHub. HALTING. + +Options: +1. Coordinate with @{{issue.assignee.login}} +2. Ask SM to reassign: /checkout-story story_key={{story_key}} + + HALT - Lock verification failed + + + + Update lock file: last_heartbeat = now() + ✅ Lock verified for @{{current_user}} + + + + Load the FULL file: {{sprint_status}} Read all development_status entries to find {{story_key}} @@ -532,6 +605,57 @@ ✅ Sprint progress updated: {{story_key}} → {{checked_tasks}}/{{total_tasks}} ({{progress_pct}}%) + + + + Load cache metadata to get github_issue number + cache_meta = load {{cache_dir}}/.bmad-cache-meta.json + issue_number = cache_meta.stories[{{story_key}}].github_issue + + + SYNC with retry: + +attempt = 0 +max_attempts = 4 +backoffs = [1000, 3000, 9000] + +WHILE attempt < max_attempts: + TRY: + # Add progress comment to GitHub Issue + Call: mcp__github__add_issue_comment({ + owner: {{github_owner}}, + repo: {{github_repo}}, + issue_number: {{issue_number}}, + body: "📊 **Task {{checked_tasks}}/{{total_tasks}} complete** ({{progress_pct}}%)\n\n" + + "> {{task_description}}\n\n" + + "_Progress synced at {{timestamp}}_" + }) + + output: "✅ Progress synced to GitHub Issue #{{issue_number}}" + BREAK + + CATCH error: + attempt++ + IF attempt < max_attempts: + sleep backoffs[attempt - 1] + output: "⚠️ GitHub sync retry {{attempt}}/3: {{error}}" + ELSE: + output: "⚠️ GitHub sync failed (non-blocking): {{error}}" + output: " Progress tracking continues in sprint-status.yaml" + # Non-blocking - don't halt for sync failures + BREAK + + + + Update lock file: last_heartbeat = now() + + + + ℹ️ Story not synced to GitHub - local progress only + + + + Count total resolved review items in this session Add Change Log entry: "Addressed code review findings - {{resolved_count}} items resolved (Date: {{date}})" diff --git a/src/modules/bmm/workflows/4-implementation/dev-story/workflow.yaml b/src/modules/bmm/workflows/4-implementation/dev-story/workflow.yaml index 9c54c125..cf4e758f 100644 --- a/src/modules/bmm/workflows/4-implementation/dev-story/workflow.yaml +++ b/src/modules/bmm/workflows/4-implementation/dev-story/workflow.yaml @@ -22,6 +22,23 @@ implementation_artifacts: "{config_source}:implementation_artifacts" sprint_status: "{implementation_artifacts}/sprint-status.yaml" project_context: "**/project-context.md" +# GitHub Integration Settings (Enterprise) +github_integration: + enabled: "{config_source}:github_integration_enabled" + repository: + owner: "{config_source}:github_owner" + repo: "{config_source}:github_repo" + cache: + dir: "{output_folder}/cache" + staleness_minutes: "{config_source}:github_cache_staleness_minutes" + locking: + timeout_hours: "{config_source}:github_lock_timeout_hours" + dir: "{project-root}/.bmad/locks" + sync: + progress_updates: true # Sync task completion to GitHub + permissions: + scrum_masters: "{config_source}:github_scrum_masters" + # Autonomous mode settings (passed from parent workflow like batch-super-dev) auto_accept_gap_analysis: false # When true, skip gap analysis approval prompt diff --git a/src/modules/bmm/workflows/4-implementation/lock-status/instructions.md b/src/modules/bmm/workflows/4-implementation/lock-status/instructions.md new file mode 100644 index 00000000..b603b746 --- /dev/null +++ b/src/modules/bmm/workflows/4-implementation/lock-status/instructions.md @@ -0,0 +1,184 @@ +# Lock Status - View Story Assignments + +The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml +You MUST have already loaded and processed: {installed_path}/workflow.yaml + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🔐 LOCK STATUS - Team Story Assignments +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + Call: mcp__github__get_me() + + + +❌ CRITICAL: GitHub MCP not accessible + +HALTING + + HALT + + + current_user = response.login + Connected as @{{current_user}} + + + + + + Build search query: + +query = "repo:{{github_owner}}/{{github_repo}} label:type:story -no:assignee label:status:in-progress" + +IF user is provided: + query += " assignee:{{user}}" + +IF epic is provided: + query += " label:epic:{{epic}}" + +query += " sort:updated-desc" + + + Call: mcp__github__search_issues({ query: query }) + locked_stories = response.items + + + + For each locked story: + +for story in locked_stories: + updated_at = parse(story.updated_at) + age_minutes = (now - updated_at) / 60000 + + story.age_minutes = age_minutes + story.age_display = format_duration(age_minutes) + story.is_stale = age_minutes > stale_threshold_minutes + + # Extract story key from labels + story_label = story.labels.find(l => l.name.startsWith("story:")) + story.story_key = story_label?.name.replace("story:", "") or "unknown" + + # Extract epic + epic_label = story.labels.find(l => l.name.startsWith("epic:")) + story.epic = epic_label?.name.replace("epic:", "") or "?" + + + + + Group stories by assignee: + +locks_by_user = {} +stale_locks = [] + +for story in locked_stories: + assignee = story.assignee.login + + if not locks_by_user[assignee]: + locks_by_user[assignee] = [] + + locks_by_user[assignee].push(story) + + if story.is_stale: + stale_locks.push(story) + + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +ℹ️ No Active Locks +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +No stories are currently locked. +All stories are available for checkout. + +Find work: /available-stories +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Exit + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🔐 ACTIVE LOCKS +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +{{#if user}}Filtered by: @{{user}}{{/if}} +{{#if epic}}Filtered by: Epic {{epic}}{{/if}} + + + + Display locks grouped by user: + + +{{#each locks_by_user}} +**@{{@key}}** ({{this.length}} {{#if this.length == 1}}story{{else}}stories{{/if}}) +{{#each this}} + {{#if is_stale}}⚠️{{else}}🔒{{/if}} {{story_key}} - Epic {{epic}} + "{{title}}" + Issue: #{{number}} + Locked: {{age_display}} ago + {{#if is_stale}} + ⚠️ STALE (no activity for >{{stale_threshold_minutes}} min) + {{/if}} + +{{/each}} +{{/each}} + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +⚠️ STALE LOCKS (No Activity >{{stale_threshold_minutes}} min) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +{{#each stale_locks}} +- {{story_key}} locked by @{{assignee.login}} + Last activity: {{age_display}} ago + Issue: #{{number}} + + Force unlock (SM only): + /unlock-story story_key={{story_key}} --force reason="Stale lock" + +{{/each}} + +Scrum Masters can force-unlock stale stories to prevent blocking. +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📊 SUMMARY +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +**Total Locked:** {{locked_stories.length}} stories +**Developers Active:** {{Object.keys(locks_by_user).length}} +**Stale Locks:** {{stale_locks.length}} + +**Your Locks:** +{{#each locks_by_user[current_user]}} +- {{story_key}}: {{title}} +{{else}} +None +{{/each}} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Actions:** +- See available stories: /available-stories +- Checkout a story: /checkout-story story_key=X-Y-slug +- Unlock your story: /unlock-story story_key=X-Y-slug + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + diff --git a/src/modules/bmm/workflows/4-implementation/lock-status/workflow.yaml b/src/modules/bmm/workflows/4-implementation/lock-status/workflow.yaml new file mode 100644 index 00000000..7459a026 --- /dev/null +++ b/src/modules/bmm/workflows/4-implementation/lock-status/workflow.yaml @@ -0,0 +1,28 @@ +name: lock-status +description: "View current story lock status across the team. Shows who's working on what and identifies stale locks." +author: "BMad" +version: "1.0.0" + +# Critical variables from config +config_source: "{project-root}/_bmad/bmm/config.yaml" +output_folder: "{config_source}:output_folder" + +# GitHub configuration +github: + owner: "{config_source}:github_integration.repository.owner" + repo: "{config_source}:github_integration.repository.repo" + +# Lock settings +locking: + stale_threshold_minutes: "{config_source}:github_integration.locking.stale_threshold_minutes" + +# Filter parameters +user: "" # Optional: Filter by specific user +epic: "" # Optional: Filter by epic number +show_stale: true # Highlight stale locks + +# Workflow components +installed_path: "{project-root}/_bmad/bmm/workflows/4-implementation/lock-status" +instructions: "{installed_path}/instructions.md" + +standalone: true diff --git a/src/modules/bmm/workflows/4-implementation/super-dev-pipeline/steps/step-06-complete.md b/src/modules/bmm/workflows/4-implementation/super-dev-pipeline/steps/step-06-complete.md index d92f71a8..e631f9c8 100644 --- a/src/modules/bmm/workflows/4-implementation/super-dev-pipeline/steps/step-06-complete.md +++ b/src/modules/bmm/workflows/4-implementation/super-dev-pipeline/steps/step-06-complete.md @@ -250,8 +250,19 @@ Before proceeding: ## CRITICAL STEP COMPLETION -**ONLY WHEN** [commit created], -load and execute `{nextStepFile}` for summary generation. +**ONLY WHEN** [commit created]: + +1. **Check GitHub Integration:** + ``` + IF github_integration.enabled == true: + load and execute `{workflow_path}/steps/step-06b-sync-github.md` + ELSE: + load and execute `{nextStepFile}` for summary generation + ``` + +2. **Continue Pipeline:** + - If GitHub sync enabled: Create PR, update issue, then summary + - If GitHub sync disabled: Direct to summary generation --- diff --git a/src/modules/bmm/workflows/4-implementation/super-dev-pipeline/steps/step-06b-sync-github.md b/src/modules/bmm/workflows/4-implementation/super-dev-pipeline/steps/step-06b-sync-github.md new file mode 100644 index 00000000..b912cae8 --- /dev/null +++ b/src/modules/bmm/workflows/4-implementation/super-dev-pipeline/steps/step-06b-sync-github.md @@ -0,0 +1,313 @@ +--- +name: 'step-06b-sync-github' +description: 'Sync completion to GitHub - create PR, update issue, release lock' + +# Path Definitions +workflow_path: '{project-root}/_bmad/bmm/workflows/4-implementation/super-dev-pipeline' + +# File References +thisStepFile: '{workflow_path}/steps/step-06b-sync-github.md' +prevStepFile: '{workflow_path}/steps/step-06-complete.md' +nextStepFile: '{workflow_path}/steps/step-07-summary.md' + +# GitHub Integration +github_integration: "{config_source}:github_integration" + +# Role +role: sm +--- + +# Step 6b: Sync to GitHub (Enterprise Integration) + +## CONDITIONAL EXECUTION + +**Only execute if GitHub integration is enabled:** + +``` +IF github_integration.enabled != true: + SKIP this step + GOTO step-07-summary +``` + +## STEP GOAL + +Close the development loop with GitHub: +1. Create Pull Request linking to issue +2. Update issue status to in-review +3. Add completion comment to issue +4. Release story lock (optional - keep until approved) + +## EXECUTION SEQUENCE + +### 1. Load GitHub Context + +```javascript +// Load cache metadata +cache_meta = load {{cache_dir}}/.bmad-cache-meta.json + +story_meta = cache_meta.stories[{{story_key}}] +issue_number = story_meta.github_issue + +IF NOT issue_number: + output: "⚠️ Story not synced to GitHub - skipping PR creation" + output: "Run /migrate-to-github to sync, then create PR manually" + GOTO step-07-summary + +github_owner = {{github_integration.repository.owner}} +github_repo = {{github_integration.repository.repo}} +``` + +### 2. Create Pull Request + +**Get current branch:** +```bash +current_branch=$(git rev-parse --abbrev-ref HEAD) +echo "Current branch: $current_branch" +``` + +**Get commit information:** +```bash +commit_sha=$(git rev-parse HEAD) +commit_msg=$(git log -1 --pretty=format:"%s") +``` + +**Create PR via GitHub MCP:** +```javascript +// Generate PR body +pr_body = ` +## Story: ${story_key} + +Implements: #${issue_number} + +### Acceptance Criteria + +${format_acs_from_story(story_file)} + +### Implementation Summary + +${story.devAgentRecord?.summary || "See commit history for details."} + +### Changes + +${generate_file_list_from_story(story_file)} + +### Testing + +- [ ] All unit tests pass +- [ ] Integration tests pass +- [ ] Manual testing completed + +--- +Closes #${issue_number} +` + +// Create PR +pr = await mcp__github__create_pull_request({ + owner: github_owner, + repo: github_repo, + title: `Story ${story_key}: ${story.title}`, + body: pr_body, + head: current_branch, + base: "main", // Or detected default branch + draft: false +}) + +pr_number = pr.number +pr_url = pr.html_url + +output: "✅ PR #${pr_number} created" +output: " URL: ${pr_url}" +``` + +**Handle PR creation failure:** +```javascript +CATCH (error) { + IF error.message.includes("already exists"): + // PR already exists - find it + existing = await mcp__github__search_pull_requests({ + query: `repo:${github_owner}/${github_repo} head:${current_branch} is:open` + }) + IF existing.items.length > 0: + pr = existing.items[0] + pr_number = pr.number + pr_url = pr.html_url + output: "ℹ️ Using existing PR #${pr_number}" + ELSE: + output: "⚠️ Could not create PR: ${error.message}" + output: " You can create it manually in GitHub" + // Continue without PR - not a blocker +} +``` + +### 3. Update GitHub Issue + +**Add completion comment:** +```javascript +await mcp__github__add_issue_comment({ + owner: github_owner, + repo: github_repo, + issue_number: issue_number, + body: ` +✅ **Implementation Complete** + +**Commit:** \`${commit_sha.substring(0, 7)}\` +**Branch:** \`${current_branch}\` +${pr_number ? `**PR:** #${pr_number}` : ''} + +--- + +**Summary:** +${story.devAgentRecord?.summary || commit_msg} + +**Files Changed:** +${generate_file_list_markdown(story_file)} + +--- +_Completed by super-dev-pipeline at ${timestamp}_ +` +}) +``` + +**Update issue labels:** +```javascript +// Get current labels +issue = await mcp__github__issue_read({ + method: "get", + owner: github_owner, + repo: github_repo, + issue_number: issue_number +}) + +current_labels = issue.labels.map(l => l.name) + +// Update: remove in-progress, add in-review +new_labels = current_labels + .filter(l => l != "status:in-progress") + +IF NOT new_labels.includes("status:in-review"): + new_labels.push("status:in-review") + +await mcp__github__issue_write({ + method: "update", + owner: github_owner, + repo: github_repo, + issue_number: issue_number, + labels: new_labels +}) + +output: "✅ Issue #${issue_number} updated to in-review" +``` + +### 4. Update Cache Metadata + +```javascript +// Update cache with PR link +cache_meta.stories[story_key].pr_number = pr_number +cache_meta.stories[story_key].pr_url = pr_url +cache_meta.stories[story_key].completed_at = timestamp + +save_cache_meta(cache_meta) +``` + +### 5. Lock Decision + +**For batch mode:** Keep lock until PO approves +```javascript +IF batch_mode: + output: "ℹ️ Lock retained - will be released on PO approval" + // Lock stays with developer until /approve-story +``` + +**For interactive mode:** Offer choice +``` +Story implementation complete. + +[K] Keep lock (you'll address review feedback) +[R] Release lock (others can pick up review fixes) + +Choice: +``` + +```javascript +IF choice == 'R': + await release_lock(story_key) + output: "✅ Lock released" +ELSE: + output: "ℹ️ Lock retained until story approved" +``` + +### 6. Display Summary + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ GITHUB SYNC COMPLETE +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Story:** {{story_key}} +**Issue:** #{{issue_number}} → status:in-review +**PR:** #{{pr_number}} +**URL:** {{pr_url}} + +**What Happens Next:** +1. PO reviews PR in GitHub +2. PO runs /approve-story to sign off +3. PR merged, issue closed, lock released + +**View in GitHub:** +- Issue: https://github.com/{{github_owner}}/{{github_repo}}/issues/{{issue_number}} +- PR: {{pr_url}} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +### 7. Continue to Summary + +Load and execute `{nextStepFile}`. + +## ERROR HANDLING + +**Network failure:** +```javascript +TRY: + // All GitHub operations +CATCH (network_error): + output: "⚠️ GitHub sync failed - network issue" + output: " Changes committed locally" + output: " Sync manually when network restored:" + output: " - Create PR: gh pr create" + output: " - Update issue: Use GitHub UI" + // Continue to summary - local commit is safe +``` + +**API rate limit:** +```javascript +IF error.status == 403 AND error.message.includes("rate limit"): + output: "⚠️ GitHub API rate limited" + output: " Wait a few minutes and run /sync-to-github" + // Continue - not a blocker +``` + +## QUALITY GATE + +Before proceeding: +- [x] Commit created (from step-06) +- [ ] PR created or existing PR found +- [ ] Issue updated to in-review +- [ ] Completion comment added +- [ ] Cache metadata updated + +## SUCCESS METRICS + +### ✅ SUCCESS +- PR links to issue with "Closes #N" +- Issue status updated +- Developer notified via issue comment +- Cache reflects PR link + +### ⚠️ PARTIAL +- PR created but issue not updated +- Network issues (commit is safe) + +### ❌ FAILURE +- Critical GitHub operation fails repeatedly +- Should not block story completion (commit exists) diff --git a/src/modules/bmm/workflows/4-implementation/super-dev-pipeline/workflow.yaml b/src/modules/bmm/workflows/4-implementation/super-dev-pipeline/workflow.yaml index ff5c28ad..45ff3274 100644 --- a/src/modules/bmm/workflows/4-implementation/super-dev-pipeline/workflow.yaml +++ b/src/modules/bmm/workflows/4-implementation/super-dev-pipeline/workflow.yaml @@ -10,6 +10,20 @@ sprint_artifacts: "{config_source}:sprint_artifacts" communication_language: "{config_source}:communication_language" date: system-generated +# GitHub Enterprise Integration (optional - enables PR creation and issue sync) +github_integration: + enabled: "{config_source}:github_integration_enabled" + repository: + owner: "{config_source}:github_owner" + repo: "{config_source}:github_repo" + cache: + dir: "{output_folder}/cache" + staleness_minutes: "{config_source}:github_cache_staleness_minutes" + sync: + create_pr: true # Create PR linking to GitHub Issue + update_issue_status: true # Update issue to in-review + add_completion_comment: true # Add implementation summary to issue + # Workflow paths installed_path: "{project-root}/_bmad/bmm/workflows/4-implementation/super-dev-pipeline" steps_path: "{installed_path}/steps" @@ -154,6 +168,14 @@ steps: agent: sm quality_gate: false + - step: "6b" + file: "{steps_path}/step-06b-sync-github.md" + name: "Sync GitHub" + description: "Create PR, update issue status, add completion comment" + agent: sm + quality_gate: false + conditional: "github_integration.enabled == true" + - step: 7 file: "{steps_path}/step-07-summary.md" name: "Summary" diff --git a/src/modules/bmm/workflows/4-implementation/unlock-story/instructions.md b/src/modules/bmm/workflows/4-implementation/unlock-story/instructions.md new file mode 100644 index 00000000..e165f6cd --- /dev/null +++ b/src/modules/bmm/workflows/4-implementation/unlock-story/instructions.md @@ -0,0 +1,331 @@ +# Unlock Story - Release Story Lock + +The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml +You MUST have already loaded and processed: {installed_path}/workflow.yaml +TEAM COORDINATION: Releasing locks makes stories available for others + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🔓 STORY UNLOCK - Release Lock +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + +❌ ERROR: story_key parameter required + +Usage: + /unlock-story story_key=2-5-auth + /unlock-story story_key=2-5-auth reason="Blocked on design" + /unlock-story story_key=2-5-auth --force reason="Developer unavailable" + +HALTING + + HALT + + + 📦 Story: {{story_key}} + + + + Call: mcp__github__get_me() + + + +❌ CRITICAL: GitHub MCP not accessible + +Cannot unlock story without GitHub API access. + +HALTING + + HALT + + + current_user = response.login + ✅ GitHub connected as @{{current_user}} + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🔍 Checking Lock Status +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:story:{{story_key}}" + }) + + + +❌ ERROR: Story not found in GitHub + +Story "{{story_key}}" does not exist. + +HALTING + + HALT + + + issue = response.items[0] + issue_number = issue.number + current_assignee = issue.assignee?.login or null + + + + + +ℹ️ Story is not locked + +Story {{story_key}} has no assignee. +Nothing to unlock. + +Issue: #{{issue_number}} + + Exit (already unlocked) + + + + +❌ PERMISSION DENIED + +Story {{story_key}} is locked by @{{current_assignee}} + +You can only unlock stories you have checked out. + +Options: +1. Ask @{{current_assignee}} to unlock it +2. If you are a Scrum Master, use --force: + /unlock-story story_key={{story_key}} --force reason="Developer unavailable" + +HALTING + + HALT + + + + Verify current_user is in scrum_masters list + + + +❌ PERMISSION DENIED + +--force requires Scrum Master permissions. + +Current Scrum Masters: +{{#each scrum_masters}} +- @{{this}} +{{/each}} + +Your user: @{{current_user}} + +HALTING + + HALT + + + +⚠️ FORCE UNLOCK + +Scrum Master @{{current_user}} is unlocking story owned by @{{current_assignee}} + +{{#if reason}} +Reason: {{reason}} +{{else}} +WARNING: No reason provided. Consider adding: + /unlock-story story_key={{story_key}} --force reason="..." +{{/if}} + + + Set force_unlock = true + Set notify_owner = true + + + + ✅ You own this lock - proceeding with unlock + Set force_unlock = false + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🔐 Releasing Lock +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + ATOMIC UNLOCK with retry: + + +attempt = 0 +max_attempts = 4 + +WHILE attempt < max_attempts: + TRY: + # 1. Remove assignee + Call: mcp__github__issue_write({ + method: "update", + owner: {{github_owner}}, + repo: {{github_repo}}, + issue_number: {{issue_number}}, + assignees: [] + }) + + # 2. Update status label back to ready-for-dev + # Get current labels first + current_labels = issue.labels.map(l => l.name) + + # Remove in-progress, add ready-for-dev + new_labels = current_labels + .filter(l => l != "status:in-progress") + + # Only add ready-for-dev if story wasn't completed + IF NOT current_labels.includes("status:done"): + new_labels.push("status:ready-for-dev") + + Call: mcp__github__issue_write({ + method: "update", + owner: {{github_owner}}, + repo: {{github_repo}}, + issue_number: {{issue_number}}, + labels: new_labels + }) + + # 3. Add unlock comment + comment_body = "🔓 **Story unlocked**\n\n" + + IF force_unlock: + comment_body += "Unlocked by Scrum Master @{{current_user}}\n" + comment_body += "Previous owner: @{{current_assignee}}\n" + IF reason: + comment_body += "Reason: {{reason}}\n" + ELSE: + comment_body += "Released by @{{current_user}}\n" + IF reason: + comment_body += "Reason: {{reason}}\n" + + comment_body += "\n_Story is now available for checkout._" + + Call: mcp__github__add_issue_comment({ + owner: {{github_owner}}, + repo: {{github_repo}}, + issue_number: {{issue_number}}, + body: comment_body + }) + + # 4. Verify unlock + sleep 1 second + + verification = Call: mcp__github__issue_read({ + method: "get", + owner: {{github_owner}}, + repo: {{github_repo}}, + issue_number: {{issue_number}} + }) + + IF verification.assignees.length > 0: + THROW "Unlock verification failed - still has assignees" + + output: "✅ GitHub Issue unassigned and verified" + BREAK + + CATCH error: + attempt++ + IF attempt < max_attempts: + backoff = [1000, 3000, 9000][attempt - 1] + sleep backoff ms + output: "⚠️ Retry {{attempt}}/3: {{error}}" + ELSE: + output: "❌ FAILED to unlock after 3 retries: {{error}}" + output: "" + output: "The lock may still be active in GitHub." + output: "Try again or manually unassign in GitHub UI." + HALT + + + + + lock_file = {{lock_dir}}/{{story_key}}.lock + + + Delete lock_file + ✅ Local lock file removed + + + + ℹ️ No local lock file found (already removed or on different machine) + + + + + Update cache meta to clear lock: + +cache_meta = load {{cache_dir}}/.bmad-cache-meta.json + +IF cache_meta.stories[{{story_key}}]: + cache_meta.stories[{{story_key}}].locked_by = null + cache_meta.stories[{{story_key}}].locked_until = null + +save cache_meta + + + ✅ Cache metadata updated + + + + + +📧 Notification sent to @{{current_assignee}}: + +"Your lock on story {{story_key}} has been released by Scrum Master @{{current_user}}. +{{#if reason}}Reason: {{reason}}{{/if}} + +The story is now available for other developers. +If you were working on this, please coordinate with your team." + + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ UNLOCK COMPLETE +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Story:** {{story_key}} +**Issue:** #{{issue_number}} +**Previous Owner:** @{{current_assignee}} +**Status:** Available for checkout + +{{#if reason}} +**Reason:** {{reason}} +{{/if}} + +{{#if force_unlock}} +**Force Unlock:** Yes (by Scrum Master @{{current_user}}) +{{/if}} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Story is now available.** + +Other developers can checkout with: + /checkout-story story_key={{story_key}} + +View available stories: + /available-stories + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + diff --git a/src/modules/bmm/workflows/4-implementation/unlock-story/workflow.yaml b/src/modules/bmm/workflows/4-implementation/unlock-story/workflow.yaml new file mode 100644 index 00000000..ef7a8a3c --- /dev/null +++ b/src/modules/bmm/workflows/4-implementation/unlock-story/workflow.yaml @@ -0,0 +1,30 @@ +name: unlock-story +description: "Release a story lock, making it available for other developers. Use after completing or abandoning a story." +author: "BMad" +version: "1.0.0" + +# Critical variables from config +config_source: "{project-root}/_bmad/bmm/config.yaml" +output_folder: "{config_source}:output_folder" +cache_dir: "{output_folder}/cache" +lock_dir: "{project-root}/.bmad/locks" + +# GitHub configuration +github: + owner: "{config_source}:github_integration.repository.owner" + repo: "{config_source}:github_integration.repository.repo" + +# Permissions +permissions: + scrum_masters: "{config_source}:github_integration.permissions.scrum_masters" + +# Workflow parameters +story_key: "" # Required: Story to unlock (e.g., "2-5-auth") +force: false # Scrum Master only: Force unlock of another user's story +reason: "" # Optional: Reason for unlock (recommended for force unlocks) + +# Workflow components +installed_path: "{project-root}/_bmad/bmm/workflows/4-implementation/unlock-story" +instructions: "{installed_path}/instructions.md" + +standalone: true diff --git a/src/modules/bmm/workflows/po/approve-story/instructions.md b/src/modules/bmm/workflows/po/approve-story/instructions.md new file mode 100644 index 00000000..18c4a1fb --- /dev/null +++ b/src/modules/bmm/workflows/po/approve-story/instructions.md @@ -0,0 +1,246 @@ +# Approve Story - PO Sign-Off + +The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml +You MUST have already loaded and processed: {installed_path}/workflow.yaml +PO WORKFLOW: Final approval closes issue and releases lock + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ APPROVE STORY - PO Sign-Off +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + +❌ ERROR: story_key parameter required + +Usage: + /approve-story story_key=2-5-auth + +HALTING + + HALT + + + Call: mcp__github__get_me() + current_user = response.login + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:story:{{story_key}}" + }) + + + ❌ Story {{story_key}} not found + HALT + + + issue = response.items[0] + status = extract_status(issue.labels) + + + +⚠️ Story is not in review status + +Current status: {{status}} +Expected: in-review + +Stories should be marked "in-review" by developers when complete. + + + Proceed anyway? [y/N]: + + HALT + + + + Search for linked PR: + Call: mcp__github__search_pull_requests({ + query: "repo:{{github_owner}}/{{github_repo}} {{story_key}} OR closes:#{{issue.number}}" + }) + + pr = response.items[0] if exists + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📋 STORY REVIEW +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Story:** {{story_key}} +**Title:** {{issue.title}} +**Developer:** @{{issue.assignee?.login or "Unknown"}} +**Issue:** #{{issue.number}} +{{#if pr}} +**PR:** #{{pr.number}} ({{pr.state}}) +{{/if}} + +--- + +## Acceptance Criteria + +{{#each acceptance_criteria}} +{{@index + 1}}. {{title}} + - Given: {{given}} + - When: {{when}} + - Then: {{then}} + +{{/each}} + +--- + +## Implementation Summary + +{{#if pr}} +**PR Description:** +{{pr.body}} + +**Files Changed:** {{pr.changed_files}} +**Additions:** +{{pr.additions}} +**Deletions:** -{{pr.deletions}} +{{else}} +No linked PR found. Review implementation in issue comments. +{{/if}} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + +**Have you verified the acceptance criteria are met?** + +[A] Approve - All ACs satisfied, story complete +[R] Request Changes - Issues found, needs more work +[D] Defer - Need more time to review +[V] View Details - Show more information + +Choice: + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ Approving Story +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + Update labels: remove status:in-review, add status:done + Call: mcp__github__issue_write({ + method: "update", + owner: {{github_owner}}, + repo: {{github_repo}}, + issue_number: {{issue.number}}, + state: "closed", + state_reason: "completed", + labels: [update labels] + }) + + + + Call: mcp__github__add_issue_comment({ + owner: {{github_owner}}, + repo: {{github_repo}}, + issue_number: {{issue.number}}, + body: "✅ **Story Approved by PO @{{current_user}}**\n\n" + + "All acceptance criteria verified.\n" + + "Story complete.\n\n" + + "_Approved at {{timestamp}}_" + }) + + + + + Merge PR #{{pr.number}}? [Y/n]: + + + Call: mcp__github__merge_pull_request({ + owner: {{github_owner}}, + repo: {{github_repo}}, + pullNumber: {{pr.number}}, + merge_method: "squash" + }) + ✅ PR #{{pr.number}} merged + + + + + + Unassign developer from issue + Clear local lock file if exists + Update cache metadata + + + + + Update development_status[{{story_key}}] = "done" + ✅ Sprint status updated + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ STORY APPROVED +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Story:** {{story_key}} +**Status:** Done ✓ +**Issue:** #{{issue.number}} (Closed) +{{#if pr_merged}} +**PR:** #{{pr.number}} (Merged) +{{/if}} + +Developer @{{issue.assignee?.login}} has been notified. +Lock released - story complete! + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + What changes are needed? + Store feedback + + Call: mcp__github__add_issue_comment({ + owner: {{github_owner}}, + repo: {{github_repo}}, + issue_number: {{issue.number}}, + body: "🔄 **Changes Requested by PO @{{current_user}}**\n\n" + + "{{feedback}}\n\n" + + "Please address and update for re-review.\n\n" + + "_Feedback at {{timestamp}}_" + }) + + Update label: status:in-review → status:in-progress + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🔄 CHANGES REQUESTED +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Story returned to developer for updates. +Developer @{{issue.assignee?.login}} notified. + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + +Review deferred. Story remains in 'in-review' status. + + + + + Show full issue body and all comments + Goto step 2 for another choice + + + + diff --git a/src/modules/bmm/workflows/po/approve-story/workflow.yaml b/src/modules/bmm/workflows/po/approve-story/workflow.yaml new file mode 100644 index 00000000..4ccf503a --- /dev/null +++ b/src/modules/bmm/workflows/po/approve-story/workflow.yaml @@ -0,0 +1,21 @@ +name: approve-story +description: "Product Owner sign-off on completed story - verifies ACs and closes the loop" +author: "BMad" +version: "1.0.0" + +config_source: "{project-root}/_bmad/bmm/config.yaml" +output_folder: "{config_source}:output_folder" +cache_dir: "{output_folder}/cache" +implementation_artifacts: "{config_source}:implementation_artifacts" +sprint_status: "{implementation_artifacts}/sprint-status.yaml" + +github: + owner: "{config_source}:github_owner" + repo: "{config_source}:github_repo" + +story_key: "" # Required: Story to approve + +installed_path: "{project-root}/_bmad/bmm/workflows/po/approve-story" +instructions: "{installed_path}/instructions.md" + +standalone: true diff --git a/src/modules/bmm/workflows/po/dashboard/instructions.md b/src/modules/bmm/workflows/po/dashboard/instructions.md new file mode 100644 index 00000000..e87bf3de --- /dev/null +++ b/src/modules/bmm/workflows/po/dashboard/instructions.md @@ -0,0 +1,190 @@ +# Dashboard - Sprint Progress Overview + +The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml +You MUST have already loaded and processed: {installed_path}/workflow.yaml +PO WORKFLOW: Real-time visibility into sprint progress from GitHub + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📊 SPRINT DASHBOARD +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + Call: mcp__github__get_me() + + + ❌ GitHub MCP not accessible + HALT + + + Connected as @{{current_user}} + + + + + + Fetch backlog stories: + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:type:story label:status:backlog{{#if epic}} label:epic:{{epic}}{{/if}}" + }) + backlog_stories = response.items + + Fetch ready-for-dev stories: + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:type:story label:status:ready-for-dev{{#if epic}} label:epic:{{epic}}{{/if}}" + }) + ready_stories = response.items + + Fetch in-progress stories: + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:type:story label:status:in-progress{{#if epic}} label:epic:{{epic}}{{/if}}" + }) + in_progress_stories = response.items + + Fetch in-review stories: + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:type:story label:status:in-review{{#if epic}} label:epic:{{epic}}{{/if}}" + }) + review_stories = response.items + + Fetch done stories: + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:type:story label:status:done{{#if epic}} label:epic:{{epic}}{{/if}}" + }) + done_stories = response.items + + + + total_stories = all stories count + completed_count = done_stories.length + completion_pct = (completed_count / total_stories) * 100 + active_developers = unique assignees from in_progress_stories + blocked_count = count stories with label:blocked + + + + For each in_progress story: + - Get latest comment matching "Task X/Y complete" + - Extract progress percentage + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📊 SPRINT DASHBOARD +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +{{#if epic}}Filtered: Epic {{epic}}{{else}}All Epics{{/if}} +Generated: {{timestamp}} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +## 📈 Sprint Progress + +**Overall:** {{completion_pct}}% complete ({{completed_count}}/{{total_stories}} stories) + +``` +[{{progress_bar}}] {{completion_pct}}% +``` + +**By Status:** +- 📋 Backlog: {{backlog_stories.length}} +- ✅ Ready: {{ready_stories.length}} +- 🔧 In Progress: {{in_progress_stories.length}} +- 👀 In Review: {{review_stories.length}} +- ✓ Done: {{done_stories.length}} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +## 🔧 Active Work + +{{#each in_progress_stories}} +**@{{assignee.login}}** - {{story_key}} + "{{title}}" + Progress: {{progress_pct}}% ({{progress_tasks}}) + Issue: #{{number}} + Started: {{time_since(updated_at)}} ago + +{{else}} +No stories currently in progress. +{{/each}} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +## 👀 Awaiting Review + +{{#each review_stories}} +- {{story_key}}: "{{title}}" + Developer: @{{assignee.login}} + Issue: #{{number}} + {{#if has_pr}}PR: #{{pr_number}}{{/if}} + +{{else}} +No stories awaiting review. +{{/each}} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +## ✅ Ready for Development + +{{#each ready_stories}} +- {{story_key}}: "{{title}}" + Complexity: {{complexity_label}} + Issue: #{{number}} + Checkout: `/checkout-story story_key={{story_key}}` + +{{else}} +No stories ready for development. +{{/each}} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +## 👥 Developer Activity + +{{#each active_developers}} +**@{{login}}** - {{story_count}} {{#if story_count == 1}}story{{else}}stories{{/if}} +{{#each their_stories}} + - {{story_key}} ({{progress_pct}}%) +{{/each}} + +{{else}} +No active developers. +{{/each}} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +{{#if blocked_count > 0}} +## ⚠️ Blockers + +{{#each blocked_stories}} +- {{story_key}}: {{title}} + Blocker: {{blocker_description}} + Assigned: @{{assignee.login}} + +{{/each}} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +{{/if}} + +## 📊 Velocity (Last 7 Days) + +- Stories Completed: {{weekly_completed}} +- Avg Time to Complete: {{avg_completion_time}} +- Projected Sprint End: {{projected_completion}} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Quick Actions:** +- View in GitHub: `https://github.com/{{github_owner}}/{{github_repo}}/issues?q=is:issue+label:type:story` +- Create story: /new-story +- Approve story: /approve-story story_key=X-Y-slug +- Check locks: /lock-status + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + diff --git a/src/modules/bmm/workflows/po/dashboard/workflow.yaml b/src/modules/bmm/workflows/po/dashboard/workflow.yaml new file mode 100644 index 00000000..b39ed6bf --- /dev/null +++ b/src/modules/bmm/workflows/po/dashboard/workflow.yaml @@ -0,0 +1,22 @@ +name: dashboard +description: "View sprint progress dashboard with real-time GitHub data - story status, developer assignments, and blockers" +author: "BMad" +version: "1.0.0" + +# Critical variables from config +config_source: "{project-root}/_bmad/bmm/config.yaml" +output_folder: "{config_source}:output_folder" + +# GitHub configuration +github: + owner: "{config_source}:github_owner" + repo: "{config_source}:github_repo" + +# Filter parameters +epic: "" # Optional: Filter by specific epic + +# Workflow components +installed_path: "{project-root}/_bmad/bmm/workflows/po/dashboard" +instructions: "{installed_path}/instructions.md" + +standalone: true diff --git a/src/modules/bmm/workflows/po/epic-dashboard/instructions.md b/src/modules/bmm/workflows/po/epic-dashboard/instructions.md new file mode 100644 index 00000000..1e4cf3e6 --- /dev/null +++ b/src/modules/bmm/workflows/po/epic-dashboard/instructions.md @@ -0,0 +1,294 @@ +# Epic Dashboard - Enterprise Progress Visibility + +The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml +You MUST have already loaded and processed: {installed_path}/workflow.yaml + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📊 EPIC DASHBOARD +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + Call: mcp__github__get_me() + + + ❌ GitHub MCP not accessible - cannot fetch epic data + HALT + + + + + + Query all epics: + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:type:epic is:open" + }) + + + + Query specific epic: + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:epic:{{epic_key}}" + }) + + + epics = response.items + + + +No epics found{{#if epic_key}} for epic:{{epic_key}}{{/if}}. + +**Tip:** Create epics as GitHub Issues with label `type:epic` + + HALT + + + + + For each epic, fetch stories: + + + +for epic in epics: + epic_label = extract_epic_key(epic) # e.g., "epic:2" + + # Fetch all stories for this epic + stories_response = await mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:type:story label:{{epic_label}}" + }) + + epic.stories = stories_response.items + + # Calculate metrics + epic.metrics = { + total: epic.stories.length, + done: count_by_label(epic.stories, "status:done"), + in_review: count_by_label(epic.stories, "status:in-review"), + in_progress: count_by_label(epic.stories, "status:in-progress"), + backlog: count_by_label(epic.stories, "status:backlog"), + blocked: count_by_label(epic.stories, "priority:blocked") + } + + epic.metrics.progress = (epic.metrics.done / epic.metrics.total * 100).toFixed(0) + "%" + epic.metrics.active_work = epic.metrics.in_progress + epic.metrics.in_review + + + + + +for epic in epics: + epic.risks = [] + + # Check for stale in-progress stories (no update in 24h) + for story in epic.stories: + if has_label(story, "status:in-progress"): + hours_since_update = calculate_hours_since(story.updated_at) + if hours_since_update > 24: + epic.risks.push({ + story: story, + risk: "stale", + message: "No activity for " + hours_since_update + "h" + }) + + # Check for blocked stories + for story in epic.stories: + if has_label(story, "priority:blocked"): + epic.risks.push({ + story: story, + risk: "blocked", + message: "Story blocked - needs attention" + }) + + # Check for stories in review too long (>48h) + for story in epic.stories: + if has_label(story, "status:in-review"): + hours_since_update = calculate_hours_since(story.updated_at) + if hours_since_update > 48: + epic.risks.push({ + story: story, + risk: "review-delayed", + message: "In review for " + hours_since_update + "h" + }) + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📊 EPIC OVERVIEW +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +{{#each epics}} +┌─────────────────────────────────────────┐ +│ EPIC {{epic_key}}: {{title}} +├─────────────────────────────────────────┤ +│ Progress: [{{progress_bar}}] {{metrics.progress}} +│ +│ Stories: {{metrics.total}} total +│ ✅ Done: {{metrics.done}} +│ 👀 In Review: {{metrics.in_review}} +│ 🔨 In Progress: {{metrics.in_progress}} +│ 📋 Backlog: {{metrics.backlog}} +│ 🚫 Blocked: {{metrics.blocked}} +│ +{{#if risks.length}} +│ ⚠️ RISKS: {{risks.length}} +{{#each risks}} +│ • {{story.story_key}}: {{message}} +{{/each}} +{{/if}} +└─────────────────────────────────────────┘ + +{{/each}} + + + + + + + +for epic in epics: + # Get closed stories with timestamps + closed_stories = filter(epic.stories, has_label("status:done")) + + # Group by completion date + completion_by_date = {} + for story in closed_stories: + date = format_date(story.closed_at) + completion_by_date[date] = (completion_by_date[date] || 0) + 1 + + epic.burndown = { + total_scope: epic.metrics.total, + completed: epic.metrics.done, + remaining: epic.metrics.total - epic.metrics.done, + completion_history: completion_by_date + } + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📈 BURNDOWN +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +{{#each epics}} +**Epic {{epic_key}}:** {{burndown.completed}}/{{burndown.total_scope}} stories completed ({{burndown.remaining}} remaining) + +Recent Completions: +{{#each burndown.completion_history as |count date|}} + {{date}}: {{count}} {{#if (gt count 1)}}stories{{else}}story{{/if}} completed +{{/each}} + +{{/each}} + + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📋 STORY DETAILS +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +{{#each epics}} +## Epic {{epic_key}}: {{title}} + +{{#each stories}} +| {{story_key}} | {{title}} | {{status}} | @{{assignee.login or "-"}} | +{{/each}} + +{{/each}} + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Actions:** +[E] View specific Epic (enter epic key) +[D] Toggle story Details +[B] Toggle Burndown +[R] Refresh data +[Q] Quit + + + + Choice: + + + Enter epic key (e.g., 2): + Set epic_key = input + Goto step 1 (refetch with filter) + + + + Toggle show_details + Goto step 3 + + + + Toggle show_burndown + Goto step 3 + + + + Goto step 1 (refresh) + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Epic Dashboard closed. +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Exit + + + + + +## Helper Functions + +```javascript +// Extract epic key from issue labels +function extract_epic_key(epic) { + for (label of epic.labels) { + if (label.name.startsWith("epic:")) { + return label.name.replace("epic:", "") + } + } + return epic.number.toString() +} + +// Count stories with specific label +function count_by_label(stories, label_name) { + return stories.filter(s => + s.labels.some(l => l.name === label_name) + ).length +} + +// Check if story has label +function has_label(story, label_name) { + return story.labels.some(l => l.name === label_name) +} + +// Calculate hours since timestamp +function calculate_hours_since(timestamp) { + const diff = Date.now() - new Date(timestamp).getTime() + return Math.floor(diff / (1000 * 60 * 60)) +} + +// Generate ASCII progress bar +function generate_progress_bar(percent, width = 20) { + const filled = Math.floor(percent * width / 100) + const empty = width - filled + return '█'.repeat(filled) + '░'.repeat(empty) +} +``` diff --git a/src/modules/bmm/workflows/po/epic-dashboard/workflow.yaml b/src/modules/bmm/workflows/po/epic-dashboard/workflow.yaml new file mode 100644 index 00000000..184d75a9 --- /dev/null +++ b/src/modules/bmm/workflows/po/epic-dashboard/workflow.yaml @@ -0,0 +1,25 @@ +name: epic-dashboard +description: "View epic-level progress with drill-down to stories - comprehensive visibility for POs" +author: "BMad" +version: "1.0.0" + +config_source: "{project-root}/_bmad/bmm/config.yaml" +output_folder: "{config_source}:output_folder" +cache_dir: "{output_folder}/cache" + +github: + owner: "{config_source}:github_owner" + repo: "{config_source}:github_repo" + +# Optional: Filter to specific epic +epic_key: "" # e.g., "2" for epic 2, empty for all epics + +# Display options +show_details: false # Show individual story details +show_burndown: true # Show epic burndown metrics +show_risks: true # Highlight at-risk stories + +installed_path: "{project-root}/_bmad/bmm/workflows/po/epic-dashboard" +instructions: "{installed_path}/instructions.md" + +standalone: true diff --git a/src/modules/bmm/workflows/po/new-story/instructions.md b/src/modules/bmm/workflows/po/new-story/instructions.md new file mode 100644 index 00000000..a1ed89e3 --- /dev/null +++ b/src/modules/bmm/workflows/po/new-story/instructions.md @@ -0,0 +1,381 @@ +# New Story - Create Story in GitHub Issues + +The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml +You MUST have already loaded and processed: {installed_path}/workflow.yaml +PO WORKFLOW: Creates stories directly in GitHub as source of truth + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📝 NEW STORY - Create in GitHub Issues +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + Call: mcp__github__get_me() + + + +❌ CRITICAL: GitHub MCP not accessible + +Cannot create stories without GitHub API access. + +HALTING + + HALT + + + current_user = response.login + ✅ GitHub connected as @{{current_user}} + + + + Search for existing epics/milestones: + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:type:epic" + }) + + existing_epics = response.items.map(e => extract epic number from labels) + + +📁 Existing Epics: +{{#each existing_epics}} +- Epic {{number}}: {{title}} +{{else}} +No epics found - you may need to run /migrate-to-github first +{{/each}} + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📋 Story Details +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + +**Which epic does this story belong to?** + +{{#each existing_epics}} +[{{number}}] Epic {{number}}: {{title}} +{{/each}} +[N] New epic (will create) + +Enter epic number: + + + Store {{epic_number}} + + +**What is the story title?** + +Keep it concise and descriptive. +Example: "User password reset via email" + +Title: + + + Store {{story_title}} + + +**Write the user story:** + +Format: "As a [role], I want [capability], so that [benefit]" + +Example: "As a user, I want to reset my password via email, so that I can regain access to my account when I forget my credentials." + +User Story: + + + Store {{user_story}} + + +**Provide business context:** + +Why is this story important? What problem does it solve? +Include any relevant background that helps developers understand the need. + +Business Context: + + + Store {{business_context}} + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ Acceptance Criteria (BDD Format) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + +**Define acceptance criteria using BDD format:** + +Each AC should follow: +- **Given** [context/precondition] +- **When** [action/trigger] +- **Then** [expected outcome] + +Example: +``` +AC1: Password reset email sent +- Given: User exists and has verified email +- When: User clicks "Forgot Password" and enters email +- Then: Reset email sent within 30 seconds with valid link + +AC2: Reset link expires +- Given: Reset link was generated +- When: More than 1 hour has passed +- Then: Link shows expiry message, user must request new link +``` + +Enter your acceptance criteria (can enter multiple): + + + Store {{acceptance_criteria}} + + Parse and validate ACs have Given/When/Then structure + + + +⚠️ Acceptance criteria should follow Given/When/Then format. + +Let me help restructure these... + + Suggest BDD-formatted version of provided ACs + Use this restructured version? [Y/n] + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📊 Story Sizing +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + +**What is the complexity of this story?** + +[1] Micro - 1-2 tasks, <1 hour (simple bug fix, text change) +[2] Small - 3-5 tasks, 1-4 hours (single feature, limited scope) +[3] Medium - 6-10 tasks, 4-8 hours (multiple components, integration) +[4] Large - 11-15 tasks, 1-2 days (significant feature, cross-cutting) +[5] Epic-sized - Should be broken into smaller stories + +Complexity [1-5]: + + + Map to complexity label: + 1 → complexity:micro + 2 → complexity:small + 3 → complexity:medium + 4 → complexity:large + 5 → "Story is too large, should be split" + + + + +⚠️ This story seems too large for a single story. + +Consider breaking it into smaller stories, each focusing on a specific piece of functionality. + +Would you like help breaking this down? + + Continue anyway [C] or Break down [B]? + + + Help user identify sub-stories + HALT - Create sub-stories instead + + + + + + + Search for existing stories in this epic: + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:epic:{{epic_number}} label:type:story" + }) + + Extract story numbers from labels (e.g., story:2-5-auth → 5) + next_story_number = max(story_numbers) + 1 OR 1 if no stories + + + + Create slug from title: + slug = story_title.toLowerCase().replace(/[^a-z0-9]+/g, '-').substring(0, 20) + story_key = "{{epic_number}}-{{next_story_number}}-{{slug}}" + + +📎 Story Key: {{story_key}} + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🚀 Creating GitHub Issue +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + Format issue body: + +issue_body = """ +**Story Key:** `{{story_key}}` +**Epic:** {{epic_number}} +**Complexity:** {{complexity_label}} + +## User Story +{{user_story}} + +## Business Context +{{business_context}} + +## Acceptance Criteria + +{{#each acceptance_criteria}} +### AC{{@index + 1}}: {{title}} +- [ ] **Given:** {{given}} +- [ ] **When:** {{when}} +- [ ] **Then:** {{then}} + +{{/each}} + +## Tasks +_Tasks will be generated by developer during checkout_ + +## Definition of Done +- [ ] All acceptance criteria verified +- [ ] Unit tests written and passing +- [ ] Integration tests where applicable +- [ ] Code reviewed +- [ ] Documentation updated if needed + +--- +_Created via BMAD PO workflow_ +_Story file: `{{story_key}}.md`_ +""" + + + + + Create GitHub Issue: + +labels = [ + "type:story", + "story:{{story_key}}", + "epic:{{epic_number}}", + "status:backlog", + "{{complexity_label}}" +] + +Call: mcp__github__issue_write({ + method: "create", + owner: {{github_owner}}, + repo: {{github_repo}}, + title: "Story {{story_key}}: {{story_title}}", + body: issue_body, + labels: labels +}) + + + issue_number = response.number + issue_url = response.html_url + + + + Wait 1 second for GitHub eventual consistency + Call: mcp__github__issue_read({ + method: "get", + owner: {{github_owner}}, + repo: {{github_repo}}, + issue_number: {{issue_number}} + }) + + + +❌ Issue creation verification failed + +The issue may not have been created properly. +Please check GitHub directly. + + HALT + + + ✅ GitHub Issue #{{issue_number}} created and verified + + + + + + Create cache file: {{cache_dir}}/stories/{{story_key}}.md + Content = converted issue body to BMAD story format + ✅ Cached: {{cache_dir}}/stories/{{story_key}}.md + + + + Update {{cache_dir}}/.bmad-cache-meta.json: + +meta.stories[{{story_key}}] = { + github_issue: {{issue_number}}, + github_updated_at: now(), + cache_timestamp: now(), + locked_by: null, + locked_until: null +} + + + + + + Add story to sprint-status.yaml: + +development_status: + {{story_key}}: backlog # New story + + ✅ Added to sprint-status.yaml + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ STORY CREATED +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Story Key:** {{story_key}} +**Title:** {{story_title}} +**Epic:** {{epic_number}} +**Complexity:** {{complexity_label}} + +**GitHub Issue:** #{{issue_number}} +**URL:** {{issue_url}} + +**Local Cache:** {{cache_dir}}/stories/{{story_key}}.md +**Sprint Status:** backlog + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Next Steps:** +- View in GitHub: {{issue_url}} +- Mark ready for dev: Update label to `status:ready-for-dev` +- Developers can checkout: `/checkout-story story_key={{story_key}}` + +**Other Actions:** +- Create another story: /new-story +- View dashboard: /dashboard +- Update this story: /update-story story_key={{story_key}} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + diff --git a/src/modules/bmm/workflows/po/new-story/workflow.yaml b/src/modules/bmm/workflows/po/new-story/workflow.yaml new file mode 100644 index 00000000..5651a0ae --- /dev/null +++ b/src/modules/bmm/workflows/po/new-story/workflow.yaml @@ -0,0 +1,25 @@ +name: new-story +description: "Create a new story in GitHub Issues with proper BMAD format, labels, and epic assignment" +author: "BMad" +version: "1.0.0" + +# Critical variables from config +config_source: "{project-root}/_bmad/bmm/config.yaml" +output_folder: "{config_source}:output_folder" +cache_dir: "{output_folder}/cache" +implementation_artifacts: "{config_source}:implementation_artifacts" +sprint_status: "{implementation_artifacts}/sprint-status.yaml" + +# GitHub configuration +github: + owner: "{config_source}:github_owner" + repo: "{config_source}:github_repo" + +# Workflow components +installed_path: "{project-root}/_bmad/bmm/workflows/po/new-story" +instructions: "{installed_path}/instructions.md" + +# Story template reference +story_template: "{project-root}/_bmad/bmm/data/story-template.md" + +standalone: true diff --git a/src/modules/bmm/workflows/po/sync-from-github/instructions.md b/src/modules/bmm/workflows/po/sync-from-github/instructions.md new file mode 100644 index 00000000..d63d969d --- /dev/null +++ b/src/modules/bmm/workflows/po/sync-from-github/instructions.md @@ -0,0 +1,149 @@ +# Sync from GitHub - Update Local Cache + +The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml +You MUST have already loaded and processed: {installed_path}/workflow.yaml + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🔄 SYNC FROM GITHUB +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + Call: mcp__github__get_me() + + + ❌ GitHub MCP not accessible + HALT + + + Load cache metadata from {{cache_dir}}/.bmad-cache-meta.json + last_sync = cache_meta.last_sync + + +Last sync: {{last_sync or "Never"}} +Mode: {{#if full_sync}}Full sync{{else}}Incremental{{/if}} +{{#if epic}}Filter: Epic {{epic}}{{/if}} + + + + + + 🔄 Performing full sync... + Query all stories + + + + 🔄 Fetching stories updated since {{last_sync}}... + Query stories updated since last_sync + + + + 🔄 No previous sync - performing initial full sync... + Query all stories (first time sync) + + + + Build query: + +query = "repo:{{github_owner}}/{{github_repo}} label:type:story" + +IF last_sync AND NOT full_sync: + query += " updated:>={{last_sync_date}}" + +IF epic: + query += " label:epic:{{epic}}" + + + Call: mcp__github__search_issues({ query: query }) + updated_stories = response.items + + Found {{updated_stories.length}} stories to sync + + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ Cache is up to date + +No changes since last sync ({{last_sync}}) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Update last_sync timestamp + Exit + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📥 Syncing Stories +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + For each story in updated_stories: + + + +for issue in updated_stories: + story_key = extract_story_key(issue) + + IF NOT story_key: + output: "⚠️ Skipping issue #{{issue.number}} - no story key" + CONTINUE + + # Convert issue to story content + story_content = convert_issue_to_story(issue) + + # Write to cache + cache_path = {{cache_dir}}/stories/{{story_key}}.md + write_file(cache_path, story_content) + + # Update metadata + cache_meta.stories[story_key] = { + github_issue: issue.number, + github_updated_at: issue.updated_at, + cache_timestamp: now(), + locked_by: issue.assignee?.login, + locked_until: calculate_lock_expiry() if issue.assignee + } + + output: "✅ {{story_key}} synced (Issue #{{issue.number}})" + + + + + cache_meta.last_sync = now() + Save cache_meta to {{cache_dir}}/.bmad-cache-meta.json + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ SYNC COMPLETE +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Stories Synced:** {{updated_stories.length}} +**Cache Location:** {{cache_dir}}/stories/ +**Last Sync:** {{now}} + +**Synced Stories:** +{{#each updated_stories}} +- {{story_key}}: {{title}} (Issue #{{number}}) +{{/each}} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Next Steps:** +- View dashboard: /dashboard +- Available stories: /available-stories +- Create story: /new-story + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + diff --git a/src/modules/bmm/workflows/po/sync-from-github/workflow.yaml b/src/modules/bmm/workflows/po/sync-from-github/workflow.yaml new file mode 100644 index 00000000..cfaa1ce5 --- /dev/null +++ b/src/modules/bmm/workflows/po/sync-from-github/workflow.yaml @@ -0,0 +1,21 @@ +name: sync-from-github +description: "Pull latest changes from GitHub Issues to local cache - ensures local data is current" +author: "BMad" +version: "1.0.0" + +config_source: "{project-root}/_bmad/bmm/config.yaml" +output_folder: "{config_source}:output_folder" +cache_dir: "{output_folder}/cache" + +github: + owner: "{config_source}:github_owner" + repo: "{config_source}:github_repo" + +# Sync options +full_sync: false # Force full sync instead of incremental +epic: "" # Optional: Only sync specific epic + +installed_path: "{project-root}/_bmad/bmm/workflows/po/sync-from-github" +instructions: "{installed_path}/instructions.md" + +standalone: true diff --git a/src/modules/bmm/workflows/po/update-story/instructions.md b/src/modules/bmm/workflows/po/update-story/instructions.md new file mode 100644 index 00000000..cc2d37d5 --- /dev/null +++ b/src/modules/bmm/workflows/po/update-story/instructions.md @@ -0,0 +1,214 @@ +# Update Story - Modify Story in GitHub + +The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml +You MUST have already loaded and processed: {installed_path}/workflow.yaml +PO WORKFLOW: Updates notify developers if story is in progress + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✏️ UPDATE STORY +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + +❌ ERROR: story_key parameter required + +Usage: + /update-story story_key=2-5-auth + +HALTING + + HALT + + + Call: mcp__github__get_me() + current_user = response.login + + + + Call: mcp__github__search_issues({ + query: "repo:{{github_owner}}/{{github_repo}} label:story:{{story_key}}" + }) + + + ❌ Story {{story_key}} not found in GitHub + HALT + + + issue = response.items[0] + is_locked = issue.assignee != null + + +📋 Current Story: {{story_key}} + +**Title:** {{issue.title}} +**Status:** {{extract_status(issue.labels)}} +**Assignee:** {{issue.assignee?.login or "None"}} +**Issue:** #{{issue.number}} + +--- +**Current Body:** +{{issue.body}} +--- + + + + +⚠️ WARNING: Story is currently locked by @{{issue.assignee.login}} + +Changes will be synced and developer notified. +Consider discussing significant changes before updating. + + + + + + +**What would you like to update?** + +[1] Acceptance Criteria - Add, modify, or remove ACs +[2] Title - Change story title +[3] Business Context - Update background/requirements +[4] Status - Change status label +[5] Priority/Complexity - Update sizing +[6] Custom - Make any other changes + +Enter choice [1-6]: + + + + Current ACs: + Display current acceptance criteria from issue body + + +How would you like to modify ACs? + +[A] Add new AC +[M] Modify existing AC (specify number) +[R] Remove AC (specify number) +[W] Rewrite all ACs + +Choice: + + + Collect AC changes based on choice + + + + New title: + Store new_title + + + + Updated business context: + Store new_context + + + + +New status: + +[1] backlog +[2] ready-for-dev +[3] in-progress (not recommended - use /checkout-story) +[4] in-review +[5] done (not recommended - use /approve-story) + +Choice: + + Store new_status + + + + +New complexity: + +[1] micro +[2] small +[3] medium +[4] large + +Choice: + + Store new_complexity + + + + Describe the changes you want to make: + Parse and apply custom changes + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📝 Applying Updates +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + Build updated issue data based on changes + + Call: mcp__github__issue_write({ + method: "update", + owner: {{github_owner}}, + repo: {{github_repo}}, + issue_number: {{issue.number}}, + title: {{new_title or issue.title}}, + body: {{updated_body}}, + labels: {{updated_labels}} + }) + + ✅ Issue #{{issue.number}} updated + + + + + Call: mcp__github__add_issue_comment({ + owner: {{github_owner}}, + repo: {{github_repo}}, + issue_number: {{issue.number}}, + body: "📢 **Story Updated by PO @{{current_user}}**\n\n" + + "**Changes:**\n{{change_summary}}\n\n" + + "@{{issue.assignee.login}} - Please review these updates.\n\n" + + "_Updated at {{timestamp}}_" + }) + + 📧 Developer @{{issue.assignee.login}} notified of changes + + + + + Invalidate cache for {{story_key}} + Re-sync story to cache + ✅ Local cache updated + + + + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ STORY UPDATED +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +**Story:** {{story_key}} +**Issue:** #{{issue.number}} + +**Changes Applied:** +{{change_summary}} + +{{#if is_locked}} +**Developer Notified:** @{{issue.assignee.login}} +{{/if}} + +**View:** `https://github.com/{{github_owner}}/{{github_repo}}/issues/{{issue.number}}` + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + diff --git a/src/modules/bmm/workflows/po/update-story/workflow.yaml b/src/modules/bmm/workflows/po/update-story/workflow.yaml new file mode 100644 index 00000000..912def3a --- /dev/null +++ b/src/modules/bmm/workflows/po/update-story/workflow.yaml @@ -0,0 +1,19 @@ +name: update-story +description: "Update story ACs, requirements, or details in GitHub Issues with change notification" +author: "BMad" +version: "1.0.0" + +config_source: "{project-root}/_bmad/bmm/config.yaml" +output_folder: "{config_source}:output_folder" +cache_dir: "{output_folder}/cache" + +github: + owner: "{config_source}:github_owner" + repo: "{config_source}:github_repo" + +story_key: "" # Required: Story to update + +installed_path: "{project-root}/_bmad/bmm/workflows/po/update-story" +instructions: "{installed_path}/instructions.md" + +standalone: true diff --git a/test/README.md b/test/README.md index da2f2962..673dddbd 100644 --- a/test/README.md +++ b/test/README.md @@ -1,10 +1,35 @@ -# Agent Schema Validation Test Suite +# BMAD Test Suite -Comprehensive test coverage for the BMAD agent schema validation system. +Comprehensive test coverage for the BMAD framework including schema validation and module unit tests. ## Overview -This test suite validates the Zod-based schema validator (`tools/schema/agent.js`) that ensures all `*.agent.yaml` files conform to the BMAD agent specification. +This test suite includes: +- **Agent Schema Validation** - Validates `*.agent.yaml` files conform to specification +- **Crowdsource Library Tests** - FeedbackManager, SynthesisEngine, SignoffManager +- **Notification Service Tests** - Multi-channel notifications (GitHub, Slack, Email) +- **Cache Manager Tests** - PRD/Epic extensions for crowdsourcing + +## Quick Start + +```bash +# Run all tests +npm test + +# Run with coverage report +npm run test:coverage + +# Run specific test suites +npx vitest run test/unit/crowdsource/ +npx vitest run test/unit/notifications/ +npx vitest run test/unit/cache/ +``` + +--- + +# Agent Schema Validation + +Validates the Zod-based schema validator (`tools/schema/agent.js`) that ensures all `*.agent.yaml` files conform to the BMAD agent specification. ## Test Statistics @@ -293,3 +318,139 @@ All success criteria from the original task have been exceeded: - **Validator Implementation**: `tools/schema/agent.js` - **CLI Tool**: `tools/validate-agent-schema.js` - **Project Guidelines**: `CLAUDE.md` + +--- + +# Module Unit Tests + +Unit tests for BMAD library modules using Vitest. + +## Test Organization + +``` +test/unit/ +├── crowdsource/ # PRD/Epic crowdsourcing +│ ├── feedback-manager.test.js # Feedback creation, querying, conflict detection +│ ├── synthesis-engine.test.js # LLM synthesis, theme extraction +│ └── signoff-manager.test.js # Sign-off thresholds, approval tracking +├── notifications/ # Multi-channel notifications +│ ├── github-notifier.test.js # GitHub @mentions, issue comments +│ ├── slack-notifier.test.js # Slack webhook integration +│ ├── email-notifier.test.js # SMTP, SendGrid, SES providers +│ └── notification-service.test.js # Orchestration, retry logic +├── cache/ # Cache management +│ └── cache-manager-prd-epic.test.js # PRD/Epic extensions +├── config/ # Configuration tests +├── core/ # Core functionality +├── file-ops/ # File operations +├── transformations/ # Data transformations +└── utils/ # Utility functions +``` + +## Crowdsource Tests + +Tests for async stakeholder collaboration on PRDs and Epics: + +### FeedbackManager +- Feedback types and status constants +- Creating feedback issues with proper labels +- Querying feedback by section/type/status +- Conflict detection between stakeholders +- Statistics aggregation + +### SynthesisEngine +- LLM prompt templates for PRD/Epic synthesis +- Theme and keyword extraction +- Conflict identification and resolution +- Story split prompts for epics + +### SignoffManager +- Three threshold types: count, percentage, required_approvers +- Approval progress tracking +- Blocking behavior +- Deadline management + +## Notification Tests + +Tests for multi-channel notification delivery: + +### Notifiers +- **GitHub**: Template rendering, issue comments, @mentions +- **Slack**: Webhook payloads, Block Kit templates, dynamic colors +- **Email**: HTML/text templates, provider abstraction + +### NotificationService +- Channel orchestration (send to GitHub + Slack + Email) +- Priority-based behavior (urgent = retry on failure) +- Convenience methods for all event types +- Graceful degradation when channels disabled + +## Cache Tests + +Tests for PRD/Epic extensions to cache-manager: + +- Read/write PRD and Epic documents +- Metadata migration (v1 → v2) +- Status filtering and updates +- User task queries (`getMyTasks`, `getPrdsNeedingAttention`) +- Extended statistics with PRD/Epic counts +- Atomic file operations + +## Running Unit Tests + +```bash +# All unit tests +npx vitest run test/unit/ + +# With watch mode +npx vitest test/unit/ + +# Specific module +npx vitest run test/unit/crowdsource/ +npx vitest run test/unit/notifications/ + +# With coverage +npx vitest run test/unit/ --coverage +``` + +## Test Patterns + +### Testable Subclass Pattern + +For classes with GitHub MCP dependencies, tests use subclasses that allow mock injection: + +```javascript +class TestableFeedbackManager extends FeedbackManager { + constructor(config, mocks = {}) { + super(config); + this.mocks = mocks; + } + + async _createIssue(params) { + if (this.mocks.createIssue) return this.mocks.createIssue(params); + throw new Error('Mock not provided'); + } +} +``` + +### Global Fetch Mocking + +For Slack/Email tests that use `fetch`: + +```javascript +global.fetch = vi.fn(); +global.fetch.mockResolvedValue({ ok: true }); +``` + +### Module Mocking + +For NotificationService orchestration tests: + +```javascript +vi.mock('../path/to/github-notifier.js', () => ({ + GitHubNotifier: vi.fn().mockImplementation(() => ({ + isEnabled: () => true, + send: vi.fn().mockResolvedValue({ success: true }) + })) +})); +``` diff --git a/test/unit/cache/cache-manager-prd-epic.test.js b/test/unit/cache/cache-manager-prd-epic.test.js new file mode 100644 index 00000000..ec015ea7 --- /dev/null +++ b/test/unit/cache/cache-manager-prd-epic.test.js @@ -0,0 +1,699 @@ +/** + * Tests for CacheManager PRD and Epic Extensions + * + * Tests cover: + * - PRD read/write operations + * - Epic read/write operations with PRD lineage + * - Status updates and filtering + * - User task queries (getPrdsNeedingAttention, getEpicsNeedingAttention) + * - Extended statistics + * - Document staleness checking + * - Atomic file operations + * + * Uses real temporary directory for testing actual file I/O operations. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +// Import the CacheManager (CommonJS module) +const { CacheManager, DOCUMENT_TYPES, CACHE_META_FILENAME } = await import( + '../../../src/modules/bmm/lib/cache/cache-manager.js' +); + +describe('CacheManager PRD/Epic Extensions', () => { + let cacheManager; + let testCacheDir; + + beforeEach(() => { + // Create a real temporary directory for each test + testCacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bmad-cache-test-')); + + cacheManager = new CacheManager({ + cacheDir: testCacheDir, + stalenessThresholdMinutes: 5, + github: { owner: 'test-org', repo: 'test-repo' } + }); + }); + + afterEach(() => { + // Clean up the temporary directory + if (testCacheDir && fs.existsSync(testCacheDir)) { + fs.rmSync(testCacheDir, { recursive: true, force: true }); + } + }); + + // ============ DOCUMENT_TYPES Tests ============ + + describe('DOCUMENT_TYPES', () => { + it('should define all document types', () => { + expect(DOCUMENT_TYPES.story).toBe('story'); + expect(DOCUMENT_TYPES.prd).toBe('prd'); + expect(DOCUMENT_TYPES.epic).toBe('epic'); + }); + }); + + // ============ Directory Initialization Tests ============ + + describe('directory initialization', () => { + it('should create subdirectories for all document types', () => { + expect(fs.existsSync(path.join(testCacheDir, 'stories'))).toBe(true); + expect(fs.existsSync(path.join(testCacheDir, 'prds'))).toBe(true); + expect(fs.existsSync(path.join(testCacheDir, 'epics'))).toBe(true); + }); + + it('should create meta file on first access', () => { + cacheManager.loadMeta(); + expect(fs.existsSync(path.join(testCacheDir, CACHE_META_FILENAME))).toBe(true); + }); + }); + + // ============ Metadata Migration Tests ============ + + describe('metadata migration', () => { + it('should migrate v1 metadata to v2', () => { + // Write v1 metadata directly + const v1Meta = { + version: '1.0.0', + stories: { 'story-1': { github_issue: 10 } } + }; + fs.writeFileSync( + path.join(testCacheDir, CACHE_META_FILENAME), + JSON.stringify(v1Meta), + 'utf8' + ); + + // Create new manager to trigger migration + const manager = new CacheManager({ + cacheDir: testCacheDir, + github: {} + }); + + const meta = manager.loadMeta(); + + expect(meta.version).toBe('2.0.0'); + expect(meta.prds).toBeDefined(); + expect(meta.epics).toBeDefined(); + expect(meta.stories).toEqual({ 'story-1': { github_issue: 10 } }); + }); + + it('should not migrate already v2 metadata', () => { + // Write v2 metadata directly + const v2Meta = { + version: '2.0.0', + prds: { 'existing-prd': { status: 'approved' } }, + epics: { 'existing-epic': { status: 'draft' } }, + stories: {} + }; + fs.writeFileSync( + path.join(testCacheDir, CACHE_META_FILENAME), + JSON.stringify(v2Meta), + 'utf8' + ); + + const manager = new CacheManager({ + cacheDir: testCacheDir, + github: {} + }); + + const meta = manager.loadMeta(); + + expect(meta.prds['existing-prd'].status).toBe('approved'); + expect(meta.epics['existing-epic'].status).toBe('draft'); + }); + }); + + // ============ PRD Methods Tests ============ + + describe('PRD operations', () => { + describe('getPrdPath', () => { + it('should return correct path for PRD', () => { + const prdPath = cacheManager.getPrdPath('user-auth'); + expect(prdPath).toBe(path.join(testCacheDir, 'prds', 'user-auth.md')); + }); + }); + + describe('writePrd', () => { + it('should write PRD content and update metadata', () => { + const content = '# PRD: User Authentication\n\nThis is the content.'; + const prdMeta = { + review_issue: 100, + version: 1, + status: 'draft', + stakeholders: ['@alice', '@bob'], + owner: '@sarah' + }; + + const result = cacheManager.writePrd('user-auth', content, prdMeta); + + expect(result.prdKey).toBe('user-auth'); + expect(result.hash).toBeDefined(); + expect(result.hash.length).toBe(64); // SHA-256 hex + + // Verify file was written + const prdPath = cacheManager.getPrdPath('user-auth'); + expect(fs.existsSync(prdPath)).toBe(true); + expect(fs.readFileSync(prdPath, 'utf8')).toBe(content); + + // Verify metadata was updated + const meta = cacheManager.loadMeta(); + expect(meta.prds['user-auth'].status).toBe('draft'); + expect(meta.prds['user-auth'].stakeholders).toEqual(['@alice', '@bob']); + }); + + it('should preserve existing metadata when not provided', () => { + // First write with full metadata + cacheManager.writePrd('user-auth', 'Content v1', { + review_issue: 100, + version: 2, + status: 'feedback', + stakeholders: ['@alice'] + }); + + // Write with partial metadata + cacheManager.writePrd('user-auth', 'Content v2', { version: 3 }); + + // Verify metadata was merged + const meta = cacheManager.loadMeta(); + expect(meta.prds['user-auth'].version).toBe(3); + expect(meta.prds['user-auth'].review_issue).toBe(100); + expect(meta.prds['user-auth'].stakeholders).toEqual(['@alice']); + }); + }); + + describe('readPrd', () => { + it('should return null for non-existent PRD', () => { + const result = cacheManager.readPrd('non-existent'); + expect(result).toBeNull(); + }); + + it('should return PRD content with metadata', () => { + const content = '# PRD: User Auth'; + cacheManager.writePrd('user-auth', content, { + version: 1, + status: 'draft' + }); + + const result = cacheManager.readPrd('user-auth'); + + expect(result.content).toBe(content); + expect(result.meta.version).toBe(1); + expect(result.isStale).toBe(false); + }); + + it('should mark stale PRDs with warning', () => { + // Write PRD first + cacheManager.writePrd('user-auth', '# PRD Content', { status: 'draft' }); + + // Manually set old timestamp + const meta = cacheManager.loadMeta(); + meta.prds['user-auth'].cache_timestamp = '2020-01-01T00:00:00Z'; + cacheManager.saveMeta(meta); + + const result = cacheManager.readPrd('user-auth'); + + expect(result.isStale).toBe(true); + expect(result.warning).toContain('stale'); + }); + + it('should ignore staleness when option set', () => { + // Write PRD first + cacheManager.writePrd('user-auth', '# PRD Content', { status: 'draft' }); + + // Manually set old timestamp + const meta = cacheManager.loadMeta(); + meta.prds['user-auth'].cache_timestamp = '2020-01-01T00:00:00Z'; + cacheManager.saveMeta(meta); + + const result = cacheManager.readPrd('user-auth', { ignoreStale: true }); + + expect(result.isStale).toBe(true); + expect(result.warning).toBeUndefined(); + }); + }); + + describe('updatePrdStatus', () => { + it('should update PRD status', () => { + cacheManager.writePrd('user-auth', '# PRD', { status: 'draft' }); + + cacheManager.updatePrdStatus('user-auth', 'feedback'); + + const meta = cacheManager.loadMeta(); + expect(meta.prds['user-auth'].status).toBe('feedback'); + }); + + it('should throw error for non-existent PRD', () => { + expect(() => { + cacheManager.updatePrdStatus('non-existent', 'feedback'); + }).toThrow('PRD not found in cache: non-existent'); + }); + }); + + describe('listCachedPrds', () => { + it('should return all cached PRD keys', () => { + cacheManager.writePrd('user-auth', '# PRD 1', { status: 'draft' }); + cacheManager.writePrd('payments', '# PRD 2', { status: 'approved' }); + cacheManager.writePrd('mobile', '# PRD 3', { status: 'feedback' }); + + const prds = cacheManager.listCachedPrds(); + + expect(prds).toContain('user-auth'); + expect(prds).toContain('payments'); + expect(prds).toContain('mobile'); + expect(prds.length).toBe(3); + }); + }); + + describe('getPrdsByStatus', () => { + it('should filter PRDs by status', () => { + cacheManager.writePrd('user-auth', '# PRD 1', { status: 'feedback' }); + cacheManager.writePrd('payments', '# PRD 2', { status: 'approved' }); + cacheManager.writePrd('mobile', '# PRD 3', { status: 'feedback' }); + + const feedbackPrds = cacheManager.getPrdsByStatus('feedback'); + + expect(feedbackPrds).toHaveLength(2); + expect(feedbackPrds.map(p => p.prdKey)).toContain('user-auth'); + expect(feedbackPrds.map(p => p.prdKey)).toContain('mobile'); + }); + }); + + describe('getPrdsNeedingAttention', () => { + it('should find PRDs needing feedback from user', () => { + cacheManager.writePrd('user-auth', '# PRD 1', { + status: 'feedback', + stakeholders: ['@alice', '@bob'] + }); + cacheManager.writePrd('payments', '# PRD 2', { + status: 'signoff', + stakeholders: ['@alice', '@charlie'] + }); + cacheManager.writePrd('mobile', '# PRD 3', { + status: 'feedback', + stakeholders: ['@charlie'] + }); + + const tasks = cacheManager.getPrdsNeedingAttention('alice'); + + expect(tasks.pendingFeedback).toHaveLength(1); + expect(tasks.pendingFeedback[0].prdKey).toBe('user-auth'); + expect(tasks.pendingSignoff).toHaveLength(1); + expect(tasks.pendingSignoff[0].prdKey).toBe('payments'); + }); + + it('should handle @ prefix in username', () => { + cacheManager.writePrd('user-auth', '# PRD 1', { + status: 'feedback', + stakeholders: ['alice', 'bob'] + }); + + const tasks = cacheManager.getPrdsNeedingAttention('@alice'); + + expect(tasks.pendingFeedback).toHaveLength(1); + }); + }); + + describe('deletePrd', () => { + it('should delete PRD file and metadata', () => { + cacheManager.writePrd('user-auth', '# PRD', { status: 'draft' }); + const prdPath = cacheManager.getPrdPath('user-auth'); + + expect(fs.existsSync(prdPath)).toBe(true); + + cacheManager.deletePrd('user-auth'); + + expect(fs.existsSync(prdPath)).toBe(false); + expect(cacheManager.loadMeta().prds['user-auth']).toBeUndefined(); + }); + }); + }); + + // ============ Epic Methods Tests ============ + + describe('Epic operations', () => { + describe('getEpicPath', () => { + it('should return correct path for Epic', () => { + const epicPath = cacheManager.getEpicPath('2'); + expect(epicPath).toBe(path.join(testCacheDir, 'epics', 'epic-2.md')); + }); + }); + + describe('writeEpic', () => { + it('should write Epic content with PRD lineage', () => { + const content = '# Epic 2: Core Authentication'; + const epicMeta = { + github_issue: 50, + prd_key: 'user-auth', + version: 1, + status: 'draft', + stories: ['2-1-login', '2-2-logout'] + }; + + const result = cacheManager.writeEpic('2', content, epicMeta); + + expect(result.epicKey).toBe('2'); + expect(result.hash).toBeDefined(); + expect(result.hash.length).toBe(64); + + // Verify file was written + const epicPath = cacheManager.getEpicPath('2'); + expect(fs.existsSync(epicPath)).toBe(true); + expect(fs.readFileSync(epicPath, 'utf8')).toBe(content); + }); + + it('should track PRD lineage in metadata', () => { + cacheManager.writeEpic('2', 'Epic content', { + prd_key: 'user-auth', + status: 'draft' + }); + + const meta = cacheManager.loadMeta(); + expect(meta.epics['2'].prd_key).toBe('user-auth'); + }); + }); + + describe('readEpic', () => { + it('should return null for non-existent Epic', () => { + const result = cacheManager.readEpic('999'); + expect(result).toBeNull(); + }); + + it('should return Epic content with metadata', () => { + const content = '# Epic 2: Auth'; + cacheManager.writeEpic('2', content, { + prd_key: 'user-auth', + version: 1, + status: 'draft' + }); + + const result = cacheManager.readEpic('2'); + + expect(result.content).toBe(content); + expect(result.meta.prd_key).toBe('user-auth'); + expect(result.isStale).toBe(false); + }); + }); + + describe('updateEpicStatus', () => { + it('should update Epic status', () => { + cacheManager.writeEpic('2', '# Epic', { status: 'draft' }); + + cacheManager.updateEpicStatus('2', 'feedback'); + + const meta = cacheManager.loadMeta(); + expect(meta.epics['2'].status).toBe('feedback'); + }); + + it('should throw error for non-existent Epic', () => { + expect(() => { + cacheManager.updateEpicStatus('999', 'feedback'); + }).toThrow('Epic not found in cache: 999'); + }); + }); + + describe('listCachedEpics', () => { + it('should return all cached Epic keys', () => { + cacheManager.writeEpic('1', '# Epic 1', { status: 'approved' }); + cacheManager.writeEpic('2', '# Epic 2', { status: 'draft' }); + cacheManager.writeEpic('3', '# Epic 3', { status: 'feedback' }); + + const epics = cacheManager.listCachedEpics(); + + expect(epics).toContain('1'); + expect(epics).toContain('2'); + expect(epics).toContain('3'); + expect(epics.length).toBe(3); + }); + }); + + describe('getEpicsByPrd', () => { + it('should filter Epics by source PRD', () => { + cacheManager.writeEpic('1', '# Epic 1', { prd_key: 'user-auth', status: 'approved' }); + cacheManager.writeEpic('2', '# Epic 2', { prd_key: 'user-auth', status: 'draft' }); + cacheManager.writeEpic('3', '# Epic 3', { prd_key: 'payments', status: 'draft' }); + + const authEpics = cacheManager.getEpicsByPrd('user-auth'); + + expect(authEpics).toHaveLength(2); + expect(authEpics.map(e => e.epicKey)).toContain('1'); + expect(authEpics.map(e => e.epicKey)).toContain('2'); + }); + }); + + describe('getEpicsNeedingAttention', () => { + it('should find Epics needing feedback from user', () => { + cacheManager.writeEpic('1', '# Epic 1', { + status: 'feedback', + stakeholders: ['@alice', '@bob'] + }); + cacheManager.writeEpic('2', '# Epic 2', { + status: 'draft', + stakeholders: ['@alice'] + }); + cacheManager.writeEpic('3', '# Epic 3', { + status: 'feedback', + stakeholders: ['@charlie'] + }); + + const tasks = cacheManager.getEpicsNeedingAttention('alice'); + + expect(tasks.pendingFeedback).toHaveLength(1); + expect(tasks.pendingFeedback[0].epicKey).toBe('1'); + }); + }); + + describe('deleteEpic', () => { + it('should delete Epic file and metadata', () => { + cacheManager.writeEpic('2', '# Epic', { status: 'draft' }); + const epicPath = cacheManager.getEpicPath('2'); + + expect(fs.existsSync(epicPath)).toBe(true); + + cacheManager.deleteEpic('2'); + + expect(fs.existsSync(epicPath)).toBe(false); + expect(cacheManager.loadMeta().epics['2']).toBeUndefined(); + }); + }); + }); + + // ============ Unified Task Query Tests ============ + + describe('getMyTasks', () => { + it('should return combined PRD and Epic tasks', () => { + cacheManager.writePrd('user-auth', '# PRD 1', { + status: 'feedback', + stakeholders: ['@alice'] + }); + cacheManager.writePrd('payments', '# PRD 2', { + status: 'signoff', + stakeholders: ['@alice'] + }); + cacheManager.writeEpic('2', '# Epic 2', { + status: 'feedback', + stakeholders: ['@alice'] + }); + + const tasks = cacheManager.getMyTasks('alice'); + + expect(tasks.prds.pendingFeedback).toHaveLength(1); + expect(tasks.prds.pendingSignoff).toHaveLength(1); + expect(tasks.epics.pendingFeedback).toHaveLength(1); + }); + + it('should return empty arrays when user has no tasks', () => { + cacheManager.writePrd('user-auth', '# PRD 1', { + status: 'feedback', + stakeholders: ['@bob'] + }); + + const tasks = cacheManager.getMyTasks('alice'); + + expect(tasks.prds.pendingFeedback).toHaveLength(0); + expect(tasks.prds.pendingSignoff).toHaveLength(0); + expect(tasks.epics.pendingFeedback).toHaveLength(0); + }); + }); + + // ============ Extended Statistics Tests ============ + + describe('getExtendedStats', () => { + it('should return comprehensive statistics', () => { + cacheManager.writeStory('2-1-login', '# Story', { github_issue: 10 }); + cacheManager.writePrd('user-auth', '# PRD 1', { status: 'feedback' }); + cacheManager.writePrd('payments', '# PRD 2', { status: 'approved' }); + cacheManager.writePrd('mobile', '# PRD 3', { status: 'feedback' }); + cacheManager.writeEpic('1', '# Epic 1', { status: 'approved' }); + cacheManager.writeEpic('2', '# Epic 2', { status: 'draft' }); + + const stats = cacheManager.getExtendedStats(); + + expect(stats.story_count).toBe(1); + expect(stats.prd_count).toBe(3); + expect(stats.prds_by_status).toEqual({ + feedback: 2, + approved: 1 + }); + expect(stats.epic_count).toBe(2); + expect(stats.epics_by_status).toEqual({ + approved: 1, + draft: 1 + }); + expect(stats.prd_size_kb).toBeGreaterThanOrEqual(0); + expect(stats.epic_size_kb).toBeGreaterThanOrEqual(0); + }); + }); + + // ============ Document Staleness Tests ============ + + describe('_isDocumentStale', () => { + it('should return true for missing metadata', () => { + expect(cacheManager._isDocumentStale(null)).toBe(true); + expect(cacheManager._isDocumentStale({})).toBe(true); + }); + + it('should return true for old cache timestamp', () => { + const oldMeta = { + cache_timestamp: '2020-01-01T00:00:00Z' + }; + + expect(cacheManager._isDocumentStale(oldMeta)).toBe(true); + }); + + it('should return false for recent cache timestamp', () => { + const recentMeta = { + cache_timestamp: new Date().toISOString() + }; + + expect(cacheManager._isDocumentStale(recentMeta)).toBe(false); + }); + }); + + // ============ Atomic Write Tests ============ + + describe('atomic writes', () => { + it('should write PRD atomically (no temp files left)', () => { + cacheManager.writePrd('user-auth', '# Content', { status: 'draft' }); + + const prdPath = cacheManager.getPrdPath('user-auth'); + const tempPath = `${prdPath}.tmp`; + + expect(fs.existsSync(prdPath)).toBe(true); + expect(fs.existsSync(tempPath)).toBe(false); + }); + + it('should write Epic atomically (no temp files left)', () => { + cacheManager.writeEpic('2', '# Content', { status: 'draft' }); + + const epicPath = cacheManager.getEpicPath('2'); + const tempPath = `${epicPath}.tmp`; + + expect(fs.existsSync(epicPath)).toBe(true); + expect(fs.existsSync(tempPath)).toBe(false); + }); + + it('should save metadata atomically (no temp files left)', () => { + cacheManager.writePrd('user-auth', '# Content', { status: 'draft' }); + + const metaPath = path.join(testCacheDir, CACHE_META_FILENAME); + const tempPath = `${metaPath}.tmp`; + + expect(fs.existsSync(metaPath)).toBe(true); + expect(fs.existsSync(tempPath)).toBe(false); + }); + }); + + // ============ Edge Cases ============ + + describe('edge cases', () => { + it('should handle empty stakeholder arrays', () => { + cacheManager.writePrd('user-auth', '# PRD', { + status: 'feedback', + stakeholders: [] + }); + + const tasks = cacheManager.getPrdsNeedingAttention('alice'); + + expect(tasks.pendingFeedback).toHaveLength(0); + }); + + it('should handle missing stakeholders property', () => { + cacheManager.writePrd('user-auth', '# PRD', { status: 'feedback' }); + + const tasks = cacheManager.getPrdsNeedingAttention('alice'); + + expect(tasks.pendingFeedback).toHaveLength(0); + }); + + it('should handle PRDs with no status', () => { + cacheManager.writePrd('user-auth', '# PRD', { version: 1 }); + + // Status defaults to 'draft' in writePrd + const feedbackPrds = cacheManager.getPrdsByStatus('feedback'); + expect(feedbackPrds).toHaveLength(0); + + const draftPrds = cacheManager.getPrdsByStatus('draft'); + expect(draftPrds).toHaveLength(1); + }); + + it('should handle special characters in content', () => { + const content = '# PRD: Auth\n\n## Special chars: "quotes", , & ampersands'; + cacheManager.writePrd('user-auth', content, { status: 'draft' }); + + const result = cacheManager.readPrd('user-auth'); + expect(result.content).toBe(content); + }); + + it('should handle unicode content', () => { + const content = '# PRD: 认证系统\n\nUnicode: 日本語, 한국어, emoji 🚀'; + cacheManager.writePrd('unicode-prd', content, { status: 'draft' }); + + const result = cacheManager.readPrd('unicode-prd'); + expect(result.content).toBe(content); + }); + + it('should handle concurrent writes to different PRDs', () => { + // Write multiple PRDs in sequence (simulating concurrent writes) + for (let i = 0; i < 10; i++) { + cacheManager.writePrd(`prd-${i}`, `# PRD ${i}`, { status: 'draft' }); + } + + const prds = cacheManager.listCachedPrds(); + expect(prds.length).toBe(10); + + // Verify all are readable + for (let i = 0; i < 10; i++) { + const result = cacheManager.readPrd(`prd-${i}`); + expect(result.content).toBe(`# PRD ${i}`); + } + }); + }); + + // ============ Content Hash Tests ============ + + describe('content hashing', () => { + it('should generate consistent hash for same content', () => { + const content = '# PRD Content'; + const result1 = cacheManager.writePrd('prd-1', content, { status: 'draft' }); + const result2 = cacheManager.writePrd('prd-2', content, { status: 'draft' }); + + expect(result1.hash).toBe(result2.hash); + }); + + it('should generate different hash for different content', () => { + const result1 = cacheManager.writePrd('prd-1', '# Content A', { status: 'draft' }); + const result2 = cacheManager.writePrd('prd-2', '# Content B', { status: 'draft' }); + + expect(result1.hash).not.toBe(result2.hash); + }); + + it('should detect content changes via hasContentChanged (story method)', () => { + cacheManager.writeStory('story-1', '# Original', { github_issue: 10 }); + + expect(cacheManager.hasContentChanged('story-1', '# Original')).toBe(false); + expect(cacheManager.hasContentChanged('story-1', '# Modified')).toBe(true); + }); + }); +}); diff --git a/test/unit/crowdsource/feedback-manager.test.js b/test/unit/crowdsource/feedback-manager.test.js new file mode 100644 index 00000000..5f11ff1d --- /dev/null +++ b/test/unit/crowdsource/feedback-manager.test.js @@ -0,0 +1,1092 @@ +/** + * Tests for FeedbackManager - Generic feedback operations for PRD/Epic crowdsourcing + * + * Tests cover: + * - Constants and type definitions + * - Feedback creation with proper label generation + * - Feedback querying with various filters + * - Grouping by section and type + * - Conflict detection + * - Status updates and issue closing + * - Statistics generation + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + FeedbackManager, + FEEDBACK_TYPES, + FEEDBACK_STATUS, + PRIORITY_LEVELS +} from '../../../src/modules/bmm/lib/crowdsource/feedback-manager.js'; + +// Create a testable subclass that allows injecting mock implementations +class TestableFeedbackManager extends FeedbackManager { + constructor(githubConfig, mocks = {}) { + super(githubConfig); + this.mocks = mocks; + } + + async _createIssue(params) { + if (this.mocks.createIssue) { + return this.mocks.createIssue(params); + } + throw new Error('Mock not provided for _createIssue'); + } + + async _getIssue(issueNumber) { + if (this.mocks.getIssue) { + return this.mocks.getIssue(issueNumber); + } + throw new Error('Mock not provided for _getIssue'); + } + + async _updateIssue(issueNumber, updates) { + if (this.mocks.updateIssue) { + return this.mocks.updateIssue(issueNumber, updates); + } + throw new Error('Mock not provided for _updateIssue'); + } + + async _closeIssue(issueNumber, reason) { + if (this.mocks.closeIssue) { + return this.mocks.closeIssue(issueNumber, reason); + } + throw new Error('Mock not provided for _closeIssue'); + } + + async _addComment(issueNumber, body) { + if (this.mocks.addComment) { + return this.mocks.addComment(issueNumber, body); + } + throw new Error('Mock not provided for _addComment'); + } + + async _searchIssues(query) { + if (this.mocks.searchIssues) { + return this.mocks.searchIssues(query); + } + throw new Error('Mock not provided for _searchIssues'); + } +} + +describe('FeedbackManager', () => { + // ============ Constants Tests ============ + + describe('FEEDBACK_TYPES', () => { + it('should define all standard feedback types', () => { + const expectedTypes = [ + 'clarification', + 'concern', + 'suggestion', + 'addition', + 'priority' + ]; + + for (const type of expectedTypes) { + expect(FEEDBACK_TYPES[type]).toBeDefined(); + expect(FEEDBACK_TYPES[type].label).toMatch(/^feedback-type:/); + expect(FEEDBACK_TYPES[type].emoji).toBeTruthy(); + expect(FEEDBACK_TYPES[type].description).toBeTruthy(); + } + }); + + it('should define epic-specific feedback types', () => { + const epicTypes = ['scope', 'dependency', 'technical_risk', 'story_split']; + + for (const type of epicTypes) { + expect(FEEDBACK_TYPES[type]).toBeDefined(); + expect(FEEDBACK_TYPES[type].label).toMatch(/^feedback-type:/); + } + }); + + it('should have correct label formats', () => { + expect(FEEDBACK_TYPES.clarification.label).toBe('feedback-type:clarification'); + expect(FEEDBACK_TYPES.concern.label).toBe('feedback-type:concern'); + expect(FEEDBACK_TYPES.technical_risk.label).toBe('feedback-type:technical-risk'); + expect(FEEDBACK_TYPES.story_split.label).toBe('feedback-type:story-split'); + }); + + it('should have descriptive emojis for visual identification', () => { + expect(FEEDBACK_TYPES.clarification.emoji).toBe('📋'); + expect(FEEDBACK_TYPES.concern.emoji).toBe('⚠️'); + expect(FEEDBACK_TYPES.suggestion.emoji).toBe('💡'); + expect(FEEDBACK_TYPES.scope.emoji).toBe('📐'); + }); + }); + + describe('FEEDBACK_STATUS', () => { + it('should define all status values', () => { + expect(FEEDBACK_STATUS.new).toBe('feedback-status:new'); + expect(FEEDBACK_STATUS.reviewed).toBe('feedback-status:reviewed'); + expect(FEEDBACK_STATUS.incorporated).toBe('feedback-status:incorporated'); + expect(FEEDBACK_STATUS.deferred).toBe('feedback-status:deferred'); + }); + }); + + describe('PRIORITY_LEVELS', () => { + it('should define all priority levels', () => { + expect(PRIORITY_LEVELS.high).toBe('priority:high'); + expect(PRIORITY_LEVELS.medium).toBe('priority:medium'); + expect(PRIORITY_LEVELS.low).toBe('priority:low'); + }); + }); + + // ============ Constructor Tests ============ + + describe('constructor', () => { + it('should initialize with github config', () => { + const manager = new FeedbackManager({ + owner: 'test-org', + repo: 'test-repo' + }); + + expect(manager.owner).toBe('test-org'); + expect(manager.repo).toBe('test-repo'); + }); + }); + + // ============ createFeedback Tests ============ + + describe('createFeedback', () => { + let manager; + let mockCreateIssue; + let mockAddComment; + + beforeEach(() => { + mockCreateIssue = vi.fn().mockResolvedValue({ + number: 42, + html_url: 'https://github.com/test-org/test-repo/issues/42' + }); + mockAddComment = vi.fn().mockResolvedValue({}); + + manager = new TestableFeedbackManager( + { owner: 'test-org', repo: 'test-repo' }, + { createIssue: mockCreateIssue, addComment: mockAddComment } + ); + }); + + it('should create feedback with correct labels for PRD', async () => { + const result = await manager.createFeedback({ + reviewIssueNumber: 100, + documentKey: 'prd:user-auth', + documentType: 'prd', + section: 'User Stories', + feedbackType: 'clarification', + priority: 'high', + title: 'Unclear login flow', + content: 'The login flow description is ambiguous', + submittedBy: 'alice' + }); + + expect(mockCreateIssue).toHaveBeenCalledTimes(1); + const createCall = mockCreateIssue.mock.calls[0][0]; + + expect(createCall.title).toBe('📋 Feedback: Unclear login flow'); + expect(createCall.labels).toContain('type:prd-feedback'); + expect(createCall.labels).toContain('prd:user-auth'); + expect(createCall.labels).toContain('linked-review:100'); + expect(createCall.labels).toContain('feedback-section:user-stories'); + expect(createCall.labels).toContain('feedback-type:clarification'); + expect(createCall.labels).toContain('feedback-status:new'); + expect(createCall.labels).toContain('priority:high'); + + expect(result.feedbackId).toBe(42); + expect(result.status).toBe('new'); + }); + + it('should create feedback with correct labels for Epic', async () => { + const result = await manager.createFeedback({ + reviewIssueNumber: 200, + documentKey: 'epic:2', + documentType: 'epic', + section: 'Story Breakdown', + feedbackType: 'scope', + priority: 'medium', + title: 'Epic too large', + content: 'Should be split into smaller epics', + submittedBy: 'bob' + }); + + const createCall = mockCreateIssue.mock.calls[0][0]; + + expect(createCall.title).toBe('📐 Feedback: Epic too large'); + expect(createCall.labels).toContain('type:epic-feedback'); + expect(createCall.labels).toContain('epic:2'); + expect(createCall.labels).toContain('feedback-type:scope'); + }); + + it('should add link comment to review issue', async () => { + await manager.createFeedback({ + reviewIssueNumber: 100, + documentKey: 'prd:user-auth', + documentType: 'prd', + section: 'User Stories', + feedbackType: 'concern', + priority: 'high', + title: 'Security risk', + content: 'Missing security consideration', + submittedBy: 'security-team' + }); + + expect(mockAddComment).toHaveBeenCalledTimes(1); + const commentCall = mockAddComment.mock.calls[0]; + + expect(commentCall[0]).toBe(100); // review issue number + expect(commentCall[1]).toContain('@security-team'); + expect(commentCall[1]).toContain('Security risk'); + expect(commentCall[1]).toContain('#42'); // feedback issue number + }); + + it('should include suggested change and rationale in body when provided', async () => { + await manager.createFeedback({ + reviewIssueNumber: 100, + documentKey: 'prd:payments', + documentType: 'prd', + section: 'FR-3', + feedbackType: 'suggestion', + priority: 'medium', + title: 'Better error handling', + content: 'Need better error messages', + suggestedChange: 'Add user-friendly error codes', + rationale: 'Improves debugging for support team', + submittedBy: 'dev-lead' + }); + + const createCall = mockCreateIssue.mock.calls[0][0]; + + expect(createCall.body).toContain('## Suggested Change'); + expect(createCall.body).toContain('Add user-friendly error codes'); + expect(createCall.body).toContain('## Context/Rationale'); + expect(createCall.body).toContain('Improves debugging for support team'); + }); + + it('should throw error for unknown feedback type', async () => { + await expect(manager.createFeedback({ + reviewIssueNumber: 100, + documentKey: 'prd:test', + documentType: 'prd', + section: 'Test', + feedbackType: 'invalid-type', + priority: 'medium', + title: 'Test', + content: 'Test', + submittedBy: 'user' + })).rejects.toThrow('Unknown feedback type: invalid-type'); + }); + + it('should default to medium priority when invalid priority provided', async () => { + await manager.createFeedback({ + reviewIssueNumber: 100, + documentKey: 'prd:test', + documentType: 'prd', + section: 'Test', + feedbackType: 'clarification', + priority: 'invalid', + title: 'Test', + content: 'Test', + submittedBy: 'user' + }); + + const createCall = mockCreateIssue.mock.calls[0][0]; + expect(createCall.labels).toContain('priority:medium'); + }); + + it('should normalize section name for labels', async () => { + await manager.createFeedback({ + reviewIssueNumber: 100, + documentKey: 'prd:test', + documentType: 'prd', + section: 'Non Functional Requirements', + feedbackType: 'clarification', + priority: 'low', + title: 'Test', + content: 'Test', + submittedBy: 'user' + }); + + const createCall = mockCreateIssue.mock.calls[0][0]; + expect(createCall.labels).toContain('feedback-section:non-functional-requirements'); + }); + }); + + // ============ getFeedback Tests ============ + + describe('getFeedback', () => { + let manager; + let mockSearchIssues; + + beforeEach(() => { + mockSearchIssues = vi.fn().mockResolvedValue([ + { + number: 1, + html_url: 'https://github.com/test/repo/issues/1', + title: '📋 Feedback: Test feedback', + labels: [ + { name: 'type:prd-feedback' }, + { name: 'prd:user-auth' }, + { name: 'feedback-section:user-stories' }, + { name: 'feedback-type:clarification' }, + { name: 'feedback-status:new' }, + { name: 'priority:high' } + ], + user: { login: 'alice' }, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-02T00:00:00Z', + body: 'Test body' + } + ]); + + manager = new TestableFeedbackManager( + { owner: 'test-org', repo: 'test-repo' }, + { searchIssues: mockSearchIssues } + ); + }); + + it('should query feedback with document key filter', async () => { + await manager.getFeedback({ + documentKey: 'prd:user-auth', + documentType: 'prd' + }); + + expect(mockSearchIssues).toHaveBeenCalledTimes(1); + const query = mockSearchIssues.mock.calls[0][0]; + + expect(query).toContain('repo:test-org/test-repo'); + expect(query).toContain('type:issue'); + expect(query).toContain('is:open'); + expect(query).toContain('label:type:prd-feedback'); + expect(query).toContain('label:prd:user-auth'); + }); + + it('should query feedback with review issue filter', async () => { + await manager.getFeedback({ + reviewIssueNumber: 100, + documentType: 'prd' + }); + + const query = mockSearchIssues.mock.calls[0][0]; + expect(query).toContain('label:linked-review:100'); + }); + + it('should query feedback with status filter', async () => { + await manager.getFeedback({ + documentType: 'prd', + status: 'incorporated' + }); + + const query = mockSearchIssues.mock.calls[0][0]; + expect(query).toContain('label:feedback-status:incorporated'); + }); + + it('should query feedback with section filter', async () => { + await manager.getFeedback({ + documentType: 'epic', + section: 'Story Breakdown' + }); + + const query = mockSearchIssues.mock.calls[0][0]; + expect(query).toContain('label:feedback-section:story-breakdown'); + }); + + it('should query feedback with type filter', async () => { + await manager.getFeedback({ + documentType: 'prd', + feedbackType: 'concern' + }); + + const query = mockSearchIssues.mock.calls[0][0]; + expect(query).toContain('label:feedback-type:concern'); + }); + + it('should parse feedback issues correctly', async () => { + const results = await manager.getFeedback({ + documentType: 'prd', + documentKey: 'prd:user-auth' + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + id: 1, + url: 'https://github.com/test/repo/issues/1', + title: 'Test feedback', + section: 'user-stories', + feedbackType: 'clarification', + status: 'new', + priority: 'high', + submittedBy: 'alice' + }); + }); + + it('should handle document key with colon', async () => { + await manager.getFeedback({ + documentKey: 'prd:complex-key', + documentType: 'prd' + }); + + const query = mockSearchIssues.mock.calls[0][0]; + expect(query).toContain('label:prd:complex-key'); + }); + }); + + // ============ getFeedbackBySection Tests ============ + + describe('getFeedbackBySection', () => { + let manager; + let mockSearchIssues; + + beforeEach(() => { + mockSearchIssues = vi.fn().mockResolvedValue([ + { + number: 1, + html_url: 'url1', + title: '📋 Feedback: FB1', + labels: [ + { name: 'feedback-section:user-stories' }, + { name: 'feedback-type:clarification' }, + { name: 'feedback-status:new' }, + { name: 'priority:high' } + ], + user: { login: 'alice' }, + created_at: '2026-01-01', + updated_at: '2026-01-01' + }, + { + number: 2, + html_url: 'url2', + title: '💡 Feedback: FB2', + labels: [ + { name: 'feedback-section:user-stories' }, + { name: 'feedback-type:suggestion' }, + { name: 'feedback-status:new' }, + { name: 'priority:medium' } + ], + user: { login: 'bob' }, + created_at: '2026-01-01', + updated_at: '2026-01-01' + }, + { + number: 3, + html_url: 'url3', + title: '⚠️ Feedback: FB3', + labels: [ + { name: 'feedback-section:fr-3' }, + { name: 'feedback-type:concern' }, + { name: 'feedback-status:new' }, + { name: 'priority:high' } + ], + user: { login: 'charlie' }, + created_at: '2026-01-01', + updated_at: '2026-01-01' + } + ]); + + manager = new TestableFeedbackManager( + { owner: 'test-org', repo: 'test-repo' }, + { searchIssues: mockSearchIssues } + ); + }); + + it('should group feedback by section', async () => { + const bySection = await manager.getFeedbackBySection('prd:user-auth', 'prd'); + + expect(Object.keys(bySection)).toHaveLength(2); + expect(bySection['user-stories']).toHaveLength(2); + expect(bySection['fr-3']).toHaveLength(1); + }); + + it('should preserve feedback details in grouped results', async () => { + const bySection = await manager.getFeedbackBySection('prd:user-auth', 'prd'); + + expect(bySection['user-stories'][0].submittedBy).toBe('alice'); + expect(bySection['user-stories'][1].submittedBy).toBe('bob'); + }); + }); + + // ============ getFeedbackByType Tests ============ + + describe('getFeedbackByType', () => { + let manager; + let mockSearchIssues; + + beforeEach(() => { + mockSearchIssues = vi.fn().mockResolvedValue([ + { + number: 1, + html_url: 'url1', + title: '📋 Feedback: FB1', + labels: [ + { name: 'feedback-section:test' }, + { name: 'feedback-type:clarification' }, + { name: 'feedback-status:new' }, + { name: 'priority:high' } + ], + user: { login: 'alice' } + }, + { + number: 2, + html_url: 'url2', + title: '📋 Feedback: FB2', + labels: [ + { name: 'feedback-section:test2' }, + { name: 'feedback-type:clarification' }, + { name: 'feedback-status:new' }, + { name: 'priority:medium' } + ], + user: { login: 'bob' } + }, + { + number: 3, + html_url: 'url3', + title: '⚠️ Feedback: FB3', + labels: [ + { name: 'feedback-section:test' }, + { name: 'feedback-type:concern' }, + { name: 'feedback-status:new' }, + { name: 'priority:high' } + ], + user: { login: 'charlie' } + } + ]); + + manager = new TestableFeedbackManager( + { owner: 'test-org', repo: 'test-repo' }, + { searchIssues: mockSearchIssues } + ); + }); + + it('should group feedback by type', async () => { + const byType = await manager.getFeedbackByType('prd:user-auth', 'prd'); + + expect(Object.keys(byType)).toHaveLength(2); + expect(byType['clarification']).toHaveLength(2); + expect(byType['concern']).toHaveLength(1); + }); + }); + + // ============ detectConflicts Tests ============ + + describe('detectConflicts', () => { + let manager; + let mockSearchIssues; + + it('should detect conflicts when multiple concerns on same section', async () => { + mockSearchIssues = vi.fn().mockResolvedValue([ + { + number: 1, + html_url: 'url1', + title: '⚠️ Feedback: Timeout too short', + labels: [ + { name: 'feedback-section:fr-5' }, + { name: 'feedback-type:concern' }, + { name: 'feedback-status:new' }, + { name: 'priority:high' } + ], + user: { login: 'security' } + }, + { + number: 2, + html_url: 'url2', + title: '⚠️ Feedback: Timeout too long', + labels: [ + { name: 'feedback-section:fr-5' }, + { name: 'feedback-type:concern' }, + { name: 'feedback-status:new' }, + { name: 'priority:medium' } + ], + user: { login: 'ux-team' } + } + ]); + + manager = new TestableFeedbackManager( + { owner: 'test-org', repo: 'test-repo' }, + { searchIssues: mockSearchIssues } + ); + + const conflicts = await manager.detectConflicts('prd:user-auth', 'prd'); + + expect(conflicts).toHaveLength(1); + expect(conflicts[0].section).toBe('fr-5'); + expect(conflicts[0].conflictType).toBe('multiple_opinions'); + expect(conflicts[0].feedbackItems).toHaveLength(2); + }); + + it('should detect conflicts when concern and suggestion on same section', async () => { + mockSearchIssues = vi.fn().mockResolvedValue([ + { + number: 1, + html_url: 'url1', + title: '⚠️ Feedback: Risk', + labels: [ + { name: 'feedback-section:security' }, + { name: 'feedback-type:concern' }, + { name: 'feedback-status:new' }, + { name: 'priority:high' } + ], + user: { login: 'security' } + }, + { + number: 2, + html_url: 'url2', + title: '💡 Feedback: Improvement', + labels: [ + { name: 'feedback-section:security' }, + { name: 'feedback-type:suggestion' }, + { name: 'feedback-status:new' }, + { name: 'priority:medium' } + ], + user: { login: 'dev' } + } + ]); + + manager = new TestableFeedbackManager( + { owner: 'test-org', repo: 'test-repo' }, + { searchIssues: mockSearchIssues } + ); + + const conflicts = await manager.detectConflicts('prd:test', 'prd'); + + expect(conflicts).toHaveLength(1); + expect(conflicts[0].section).toBe('security'); + }); + + it('should not detect conflicts for single feedback on section', async () => { + mockSearchIssues = vi.fn().mockResolvedValue([ + { + number: 1, + html_url: 'url1', + title: '⚠️ Feedback: Single concern', + labels: [ + { name: 'feedback-section:fr-1' }, + { name: 'feedback-type:concern' }, + { name: 'feedback-status:new' }, + { name: 'priority:high' } + ], + user: { login: 'user1' } + } + ]); + + manager = new TestableFeedbackManager( + { owner: 'test-org', repo: 'test-repo' }, + { searchIssues: mockSearchIssues } + ); + + const conflicts = await manager.detectConflicts('prd:test', 'prd'); + + expect(conflicts).toHaveLength(0); + }); + + it('should not detect conflicts for multiple clarifications (not opposing)', async () => { + mockSearchIssues = vi.fn().mockResolvedValue([ + { + number: 1, + html_url: 'url1', + title: '📋 Feedback: Question 1', + labels: [ + { name: 'feedback-section:fr-1' }, + { name: 'feedback-type:clarification' }, + { name: 'feedback-status:new' }, + { name: 'priority:medium' } + ], + user: { login: 'user1' } + }, + { + number: 2, + html_url: 'url2', + title: '📋 Feedback: Question 2', + labels: [ + { name: 'feedback-section:fr-1' }, + { name: 'feedback-type:clarification' }, + { name: 'feedback-status:new' }, + { name: 'priority:low' } + ], + user: { login: 'user2' } + } + ]); + + manager = new TestableFeedbackManager( + { owner: 'test-org', repo: 'test-repo' }, + { searchIssues: mockSearchIssues } + ); + + const conflicts = await manager.detectConflicts('prd:test', 'prd'); + + expect(conflicts).toHaveLength(0); + }); + }); + + // ============ updateFeedbackStatus Tests ============ + + describe('updateFeedbackStatus', () => { + let manager; + let mockGetIssue; + let mockUpdateIssue; + let mockAddComment; + let mockCloseIssue; + + beforeEach(() => { + mockGetIssue = vi.fn().mockResolvedValue({ + number: 42, + labels: [ + { name: 'type:prd-feedback' }, + { name: 'feedback-status:new' }, + { name: 'priority:high' } + ] + }); + mockUpdateIssue = vi.fn().mockResolvedValue({}); + mockAddComment = vi.fn().mockResolvedValue({}); + mockCloseIssue = vi.fn().mockResolvedValue({}); + + manager = new TestableFeedbackManager( + { owner: 'test-org', repo: 'test-repo' }, + { + getIssue: mockGetIssue, + updateIssue: mockUpdateIssue, + addComment: mockAddComment, + closeIssue: mockCloseIssue + } + ); + }); + + it('should update status labels correctly', async () => { + await manager.updateFeedbackStatus(42, 'reviewed'); + + expect(mockUpdateIssue).toHaveBeenCalledTimes(1); + const updateCall = mockUpdateIssue.mock.calls[0]; + + expect(updateCall[0]).toBe(42); + expect(updateCall[1].labels).toContain('feedback-status:reviewed'); + expect(updateCall[1].labels).not.toContain('feedback-status:new'); + expect(updateCall[1].labels).toContain('type:prd-feedback'); + expect(updateCall[1].labels).toContain('priority:high'); + }); + + it('should add resolution comment when provided', async () => { + await manager.updateFeedbackStatus(42, 'incorporated', 'Added to PRD v2'); + + expect(mockAddComment).toHaveBeenCalledTimes(1); + expect(mockAddComment.mock.calls[0][0]).toBe(42); + expect(mockAddComment.mock.calls[0][1]).toContain('incorporated'); + expect(mockAddComment.mock.calls[0][1]).toContain('Added to PRD v2'); + }); + + it('should close issue when status is incorporated', async () => { + await manager.updateFeedbackStatus(42, 'incorporated'); + + expect(mockCloseIssue).toHaveBeenCalledTimes(1); + expect(mockCloseIssue.mock.calls[0][0]).toBe(42); + expect(mockCloseIssue.mock.calls[0][1]).toBe('completed'); + }); + + it('should close issue when status is deferred', async () => { + await manager.updateFeedbackStatus(42, 'deferred'); + + expect(mockCloseIssue).toHaveBeenCalledTimes(1); + expect(mockCloseIssue.mock.calls[0][0]).toBe(42); + expect(mockCloseIssue.mock.calls[0][1]).toBe('not_planned'); + }); + + it('should not close issue for reviewed status', async () => { + await manager.updateFeedbackStatus(42, 'reviewed'); + + expect(mockCloseIssue).not.toHaveBeenCalled(); + }); + + it('should throw error for unknown status', async () => { + await expect( + manager.updateFeedbackStatus(42, 'invalid-status') + ).rejects.toThrow('Unknown status: invalid-status'); + }); + + it('should return updated status info', async () => { + const result = await manager.updateFeedbackStatus(42, 'reviewed'); + + expect(result).toEqual({ + feedbackId: 42, + status: 'reviewed' + }); + }); + }); + + // ============ getStats Tests ============ + + describe('getStats', () => { + let manager; + let mockSearchIssues; + + beforeEach(() => { + mockSearchIssues = vi.fn().mockResolvedValue([ + { + number: 1, + html_url: 'url1', + title: '📋 Feedback: FB1', + labels: [ + { name: 'feedback-section:user-stories' }, + { name: 'feedback-type:clarification' }, + { name: 'feedback-status:new' }, + { name: 'priority:high' } + ], + user: { login: 'alice' } + }, + { + number: 2, + html_url: 'url2', + title: '⚠️ Feedback: FB2', + labels: [ + { name: 'feedback-section:user-stories' }, + { name: 'feedback-type:concern' }, + { name: 'feedback-status:reviewed' }, + { name: 'priority:high' } + ], + user: { login: 'bob' } + }, + { + number: 3, + html_url: 'url3', + title: '💡 Feedback: FB3', + labels: [ + { name: 'feedback-section:fr-3' }, + { name: 'feedback-type:suggestion' }, + { name: 'feedback-status:new' }, + { name: 'priority:medium' } + ], + user: { login: 'alice' } + } + ]); + + manager = new TestableFeedbackManager( + { owner: 'test-org', repo: 'test-repo' }, + { searchIssues: mockSearchIssues } + ); + }); + + it('should calculate total feedback count', async () => { + const stats = await manager.getStats('prd:user-auth', 'prd'); + + expect(stats.total).toBe(3); + }); + + it('should group stats by type', async () => { + const stats = await manager.getStats('prd:user-auth', 'prd'); + + expect(stats.byType).toEqual({ + clarification: 1, + concern: 1, + suggestion: 1 + }); + }); + + it('should group stats by status', async () => { + const stats = await manager.getStats('prd:user-auth', 'prd'); + + expect(stats.byStatus).toEqual({ + new: 2, + reviewed: 1 + }); + }); + + it('should group stats by section', async () => { + const stats = await manager.getStats('prd:user-auth', 'prd'); + + expect(stats.bySection).toEqual({ + 'user-stories': 2, + 'fr-3': 1 + }); + }); + + it('should group stats by priority', async () => { + const stats = await manager.getStats('prd:user-auth', 'prd'); + + expect(stats.byPriority).toEqual({ + high: 2, + medium: 1 + }); + }); + + it('should count unique submitters', async () => { + const stats = await manager.getStats('prd:user-auth', 'prd'); + + expect(stats.submitterCount).toBe(2); + expect(stats.submitters).toContain('alice'); + expect(stats.submitters).toContain('bob'); + }); + }); + + // ============ Private Method Tests ============ + + describe('_formatFeedbackBody', () => { + let manager; + + beforeEach(() => { + manager = new FeedbackManager({ owner: 'test', repo: 'test' }); + }); + + it('should format body with all required sections', () => { + const body = manager._formatFeedbackBody({ + reviewIssueNumber: 100, + documentKey: 'prd:test', + section: 'User Stories', + feedbackType: 'clarification', + typeConfig: FEEDBACK_TYPES.clarification, + priority: 'high', + content: 'This is unclear', + submittedBy: 'alice' + }); + + expect(body).toContain('# 📋 Feedback: Clarification'); + expect(body).toContain('**Review:** #100'); + expect(body).toContain('**Document:** `prd:test`'); + expect(body).toContain('**Section:** User Stories'); + expect(body).toContain('**Priority:** high'); + expect(body).toContain('## Feedback'); + expect(body).toContain('This is unclear'); + expect(body).toContain('@alice'); + }); + + it('should include suggested change when provided', () => { + const body = manager._formatFeedbackBody({ + reviewIssueNumber: 100, + documentKey: 'prd:test', + section: 'FR-1', + feedbackType: 'suggestion', + typeConfig: FEEDBACK_TYPES.suggestion, + priority: 'medium', + content: 'Could be improved', + suggestedChange: 'Use async/await pattern', + submittedBy: 'bob' + }); + + expect(body).toContain('## Suggested Change'); + expect(body).toContain('Use async/await pattern'); + }); + + it('should include rationale when provided', () => { + const body = manager._formatFeedbackBody({ + reviewIssueNumber: 100, + documentKey: 'prd:test', + section: 'NFR-1', + feedbackType: 'concern', + typeConfig: FEEDBACK_TYPES.concern, + priority: 'high', + content: 'Security risk', + rationale: 'OWASP Top 10 vulnerability', + submittedBy: 'security' + }); + + expect(body).toContain('## Context/Rationale'); + expect(body).toContain('OWASP Top 10 vulnerability'); + }); + }); + + describe('_parseFeedbackIssue', () => { + let manager; + + beforeEach(() => { + manager = new FeedbackManager({ owner: 'test', repo: 'test' }); + }); + + it('should parse issue into feedback object', () => { + const issue = { + number: 42, + html_url: 'https://github.com/test/repo/issues/42', + title: '📋 Feedback: Test feedback title', + labels: [ + { name: 'feedback-section:user-stories' }, + { name: 'feedback-type:clarification' }, + { name: 'feedback-status:new' }, + { name: 'priority:high' } + ], + user: { login: 'alice' }, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-02T00:00:00Z', + body: 'Test body content' + }; + + const parsed = manager._parseFeedbackIssue(issue); + + expect(parsed).toEqual({ + id: 42, + url: 'https://github.com/test/repo/issues/42', + title: 'Test feedback title', + section: 'user-stories', + feedbackType: 'clarification', + status: 'new', + priority: 'high', + submittedBy: 'alice', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-02T00:00:00Z', + body: 'Test body content' + }); + }); + + it('should strip emoji prefix from title', () => { + const issue = { + number: 1, + html_url: 'url', + title: '⚠️ Feedback: Important concern', + labels: [], + user: null + }; + + const parsed = manager._parseFeedbackIssue(issue); + expect(parsed.title).toBe('Important concern'); + }); + + it('should handle missing labels gracefully', () => { + const issue = { + number: 1, + html_url: 'url', + title: 'Feedback: Missing labels', + labels: [], + user: { login: 'user' } + }; + + const parsed = manager._parseFeedbackIssue(issue); + + expect(parsed.section).toBeNull(); + expect(parsed.feedbackType).toBeNull(); + expect(parsed.status).toBeNull(); + expect(parsed.priority).toBeNull(); + }); + }); + + describe('_extractLabel', () => { + let manager; + + beforeEach(() => { + manager = new FeedbackManager({ owner: 'test', repo: 'test' }); + }); + + it('should extract value from label with prefix', () => { + const labels = ['type:prd-feedback', 'feedback-type:concern', 'priority:high']; + + expect(manager._extractLabel(labels, 'feedback-type:')).toBe('concern'); + expect(manager._extractLabel(labels, 'priority:')).toBe('high'); + }); + + it('should return null when label not found', () => { + const labels = ['type:prd-feedback']; + + expect(manager._extractLabel(labels, 'feedback-type:')).toBeNull(); + }); + }); + + // ============ Error Handling Tests ============ + + describe('error handling', () => { + it('should throw when GitHub methods not implemented', async () => { + const manager = new FeedbackManager({ owner: 'test', repo: 'test' }); + + await expect(manager._createIssue({})).rejects.toThrow( + '_createIssue must be implemented by caller via GitHub MCP' + ); + + await expect(manager._getIssue(1)).rejects.toThrow( + '_getIssue must be implemented by caller via GitHub MCP' + ); + + await expect(manager._searchIssues('')).rejects.toThrow( + '_searchIssues must be implemented by caller via GitHub MCP' + ); + }); + }); +}); diff --git a/test/unit/crowdsource/signoff-manager.test.js b/test/unit/crowdsource/signoff-manager.test.js new file mode 100644 index 00000000..5d600ab2 --- /dev/null +++ b/test/unit/crowdsource/signoff-manager.test.js @@ -0,0 +1,1015 @@ +/** + * Tests for SignoffManager - Configurable sign-off logic for PRDs and Epics + * + * Tests cover: + * - Constants and default configuration + * - Sign-off request creation + * - Sign-off submission with various decisions + * - Three threshold types: count, percentage, required_approvers + * - Status calculation with blocking logic + * - Progress tracking and summaries + * - Reminder and deadline extension + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + SignoffManager, + SIGNOFF_STATUS, + THRESHOLD_TYPES, + DEFAULT_CONFIG +} from '../../../src/modules/bmm/lib/crowdsource/signoff-manager.js'; + +// Create a testable subclass that allows injecting mock implementations +class TestableSignoffManager extends SignoffManager { + constructor(githubConfig, mocks = {}) { + super(githubConfig); + this.mocks = mocks; + } + + async _getIssue(issueNumber) { + if (this.mocks.getIssue) { + return this.mocks.getIssue(issueNumber); + } + throw new Error('Mock not provided for _getIssue'); + } + + async _updateIssue(issueNumber, updates) { + if (this.mocks.updateIssue) { + return this.mocks.updateIssue(issueNumber, updates); + } + throw new Error('Mock not provided for _updateIssue'); + } + + async _addComment(issueNumber, body) { + if (this.mocks.addComment) { + return this.mocks.addComment(issueNumber, body); + } + throw new Error('Mock not provided for _addComment'); + } +} + +describe('SignoffManager', () => { + // ============ Constants Tests ============ + + describe('SIGNOFF_STATUS', () => { + it('should define all status values', () => { + expect(SIGNOFF_STATUS.pending).toBe('signoff:pending'); + expect(SIGNOFF_STATUS.approved).toBe('signoff:approved'); + expect(SIGNOFF_STATUS.approved_with_note).toBe('signoff:approved-with-note'); + expect(SIGNOFF_STATUS.blocked).toBe('signoff:blocked'); + }); + }); + + describe('THRESHOLD_TYPES', () => { + it('should define all threshold types', () => { + expect(THRESHOLD_TYPES.count).toBe('count'); + expect(THRESHOLD_TYPES.percentage).toBe('percentage'); + expect(THRESHOLD_TYPES.required_approvers).toBe('required_approvers'); + }); + }); + + describe('DEFAULT_CONFIG', () => { + it('should have sensible defaults', () => { + expect(DEFAULT_CONFIG.threshold_type).toBe('count'); + expect(DEFAULT_CONFIG.minimum_approvals).toBe(2); + expect(DEFAULT_CONFIG.approval_percentage).toBe(66); + expect(DEFAULT_CONFIG.required).toEqual([]); + expect(DEFAULT_CONFIG.optional).toEqual([]); + expect(DEFAULT_CONFIG.minimum_optional).toBe(0); + expect(DEFAULT_CONFIG.allow_blocks).toBe(true); + expect(DEFAULT_CONFIG.block_threshold).toBe(1); + }); + }); + + // ============ Constructor Tests ============ + + describe('constructor', () => { + it('should initialize with github config', () => { + const manager = new SignoffManager({ + owner: 'test-org', + repo: 'test-repo' + }); + + expect(manager.owner).toBe('test-org'); + expect(manager.repo).toBe('test-repo'); + }); + }); + + // ============ requestSignoff Tests ============ + + describe('requestSignoff', () => { + let manager; + let mockAddComment; + + beforeEach(() => { + mockAddComment = vi.fn().mockResolvedValue({}); + + manager = new TestableSignoffManager( + { owner: 'test-org', repo: 'test-repo' }, + { addComment: mockAddComment } + ); + }); + + it('should create sign-off request with stakeholder checklist', async () => { + const result = await manager.requestSignoff({ + documentKey: 'prd:user-auth', + documentType: 'prd', + reviewIssueNumber: 100, + stakeholders: ['alice', 'bob', 'charlie'], + deadline: '2026-01-15' + }); + + expect(mockAddComment).toHaveBeenCalledTimes(1); + const comment = mockAddComment.mock.calls[0][1]; + + expect(comment).toContain('✍️ Sign-off Requested'); + expect(comment).toContain('`prd:user-auth`'); + expect(comment).toContain('PRD'); + expect(comment).toContain('2026-01-15'); + expect(comment).toContain('@alice'); + expect(comment).toContain('@bob'); + expect(comment).toContain('@charlie'); + expect(comment).toContain('⏳ Pending'); + + expect(result.reviewIssueNumber).toBe(100); + expect(result.stakeholders).toHaveLength(3); + expect(result.status).toBe('signoff_requested'); + }); + + it('should merge custom config with defaults', async () => { + const result = await manager.requestSignoff({ + documentKey: 'prd:test', + documentType: 'prd', + reviewIssueNumber: 100, + stakeholders: ['alice', 'bob', 'charlie', 'dave', 'eve'], + deadline: '2026-01-15', + config: { + minimum_approvals: 5, + block_threshold: 2 + } + }); + + expect(result.config.minimum_approvals).toBe(5); + expect(result.config.block_threshold).toBe(2); + // Default values preserved + expect(result.config.threshold_type).toBe('count'); + expect(result.config.allow_blocks).toBe(true); + }); + + it('should format threshold description for count type', async () => { + await manager.requestSignoff({ + documentKey: 'prd:test', + documentType: 'prd', + reviewIssueNumber: 100, + stakeholders: ['alice', 'bob', 'charlie'], + deadline: '2026-01-15', + config: { threshold_type: 'count', minimum_approvals: 2 } + }); + + const comment = mockAddComment.mock.calls[0][1]; + expect(comment).toContain('2 approval(s) required'); + }); + + it('should format threshold description for percentage type', async () => { + await manager.requestSignoff({ + documentKey: 'prd:test', + documentType: 'prd', + reviewIssueNumber: 100, + stakeholders: ['alice', 'bob', 'charlie'], + deadline: '2026-01-15', + config: { threshold_type: 'percentage', approval_percentage: 75 } + }); + + const comment = mockAddComment.mock.calls[0][1]; + expect(comment).toContain('75% must approve'); + }); + + it('should format threshold description for required_approvers type', async () => { + await manager.requestSignoff({ + documentKey: 'prd:test', + documentType: 'prd', + reviewIssueNumber: 100, + stakeholders: ['alice', 'bob', 'charlie', 'dave'], + deadline: '2026-01-15', + config: { + threshold_type: 'required_approvers', + required: ['alice', 'bob'], + optional: ['charlie', 'dave'], + minimum_optional: 1 + } + }); + + const comment = mockAddComment.mock.calls[0][1]; + expect(comment).toContain('Required: alice, bob'); + expect(comment).toContain('1 optional'); + }); + + it('should include sign-off instructions', async () => { + await manager.requestSignoff({ + documentKey: 'prd:test', + documentType: 'prd', + reviewIssueNumber: 100, + stakeholders: ['alice', 'bob'], + deadline: '2026-01-15' + }); + + const comment = mockAddComment.mock.calls[0][1]; + expect(comment).toContain('/signoff approve'); + expect(comment).toContain('/signoff approve-note'); + expect(comment).toContain('/signoff block'); + }); + + it('should validate count threshold against stakeholder list', async () => { + await expect(manager.requestSignoff({ + documentKey: 'prd:test', + documentType: 'prd', + reviewIssueNumber: 100, + stakeholders: ['alice', 'bob'], + deadline: '2026-01-15', + config: { threshold_type: 'count', minimum_approvals: 5 } + })).rejects.toThrow('minimum_approvals (5) cannot exceed stakeholder count (2)'); + }); + + it('should validate required approvers are in stakeholder list', async () => { + await expect(manager.requestSignoff({ + documentKey: 'prd:test', + documentType: 'prd', + reviewIssueNumber: 100, + stakeholders: ['alice', 'bob'], + deadline: '2026-01-15', + config: { + threshold_type: 'required_approvers', + required: ['alice', 'charlie'] // charlie not in stakeholders + } + })).rejects.toThrow('All required approvers must be in stakeholder list'); + }); + + it('should handle @ prefix in stakeholder names', async () => { + await manager.requestSignoff({ + documentKey: 'prd:test', + documentType: 'prd', + reviewIssueNumber: 100, + stakeholders: ['@alice', '@bob'], + deadline: '2026-01-15' + }); + + const comment = mockAddComment.mock.calls[0][1]; + expect(comment).toContain('@alice'); + expect(comment).toContain('@bob'); + expect(comment).not.toContain('@@'); // Should not double the @ + }); + }); + + // ============ submitSignoff Tests ============ + + describe('submitSignoff', () => { + let manager; + let mockAddComment; + let mockGetIssue; + let mockUpdateIssue; + + beforeEach(() => { + mockAddComment = vi.fn().mockResolvedValue({}); + mockGetIssue = vi.fn().mockResolvedValue({ + labels: [{ name: 'type:prd-review' }, { name: 'review-status:signoff' }] + }); + mockUpdateIssue = vi.fn().mockResolvedValue({}); + + manager = new TestableSignoffManager( + { owner: 'test-org', repo: 'test-repo' }, + { + addComment: mockAddComment, + getIssue: mockGetIssue, + updateIssue: mockUpdateIssue + } + ); + }); + + it('should submit approved sign-off', async () => { + const result = await manager.submitSignoff({ + reviewIssueNumber: 100, + documentKey: 'prd:user-auth', + documentType: 'prd', + user: 'alice', + decision: 'approved' + }); + + expect(mockAddComment).toHaveBeenCalledTimes(1); + const comment = mockAddComment.mock.calls[0][1]; + + expect(comment).toContain('✅'); + expect(comment).toContain('@alice'); + expect(comment).toContain('Approved'); + + expect(result.decision).toBe('approved'); + expect(result.user).toBe('alice'); + expect(result.timestamp).toBeDefined(); + }); + + it('should submit approved with note', async () => { + await manager.submitSignoff({ + reviewIssueNumber: 100, + documentKey: 'prd:test', + documentType: 'prd', + user: 'bob', + decision: 'approved_with_note', + note: 'Please update docs before implementation' + }); + + const comment = mockAddComment.mock.calls[0][1]; + + expect(comment).toContain('✅📝'); + expect(comment).toContain('Approved with Note'); + expect(comment).toContain('Please update docs before implementation'); + }); + + it('should submit blocked sign-off with reason', async () => { + await manager.submitSignoff({ + reviewIssueNumber: 100, + documentKey: 'prd:test', + documentType: 'prd', + user: 'security', + decision: 'blocked', + note: 'Security review required', + feedbackIssueNumber: 42 + }); + + const comment = mockAddComment.mock.calls[0][1]; + + expect(comment).toContain('🚫'); + expect(comment).toContain('Blocked'); + expect(comment).toContain('Security review required'); + expect(comment).toContain('#42'); + }); + + it('should add signoff label to issue', async () => { + await manager.submitSignoff({ + reviewIssueNumber: 100, + documentKey: 'prd:test', + documentType: 'prd', + user: 'alice', + decision: 'approved' + }); + + expect(mockUpdateIssue).toHaveBeenCalledTimes(1); + const updateCall = mockUpdateIssue.mock.calls[0]; + + expect(updateCall[0]).toBe(100); + expect(updateCall[1].labels).toContain('signoff-alice-approved'); + }); + + it('should replace existing signoff label for user', async () => { + mockGetIssue.mockResolvedValue({ + labels: [ + { name: 'type:prd-review' }, + { name: 'signoff-alice-pending' } // Previous status + ] + }); + + await manager.submitSignoff({ + reviewIssueNumber: 100, + documentKey: 'prd:test', + documentType: 'prd', + user: 'alice', + decision: 'approved' + }); + + const updateCall = mockUpdateIssue.mock.calls[0]; + + expect(updateCall[1].labels).not.toContain('signoff-alice-pending'); + expect(updateCall[1].labels).toContain('signoff-alice-approved'); + }); + + it('should normalize user name for label', async () => { + await manager.submitSignoff({ + reviewIssueNumber: 100, + documentKey: 'prd:test', + documentType: 'prd', + user: '@alice', + decision: 'approved' + }); + + const updateCall = mockUpdateIssue.mock.calls[0]; + expect(updateCall[1].labels).toContain('signoff-alice-approved'); + }); + + it('should throw error for invalid decision', async () => { + await expect(manager.submitSignoff({ + reviewIssueNumber: 100, + documentKey: 'prd:test', + documentType: 'prd', + user: 'alice', + decision: 'invalid' + })).rejects.toThrow('Invalid decision: invalid'); + }); + }); + + // ============ getSignoffs Tests ============ + + describe('getSignoffs', () => { + let manager; + let mockGetIssue; + + beforeEach(() => { + mockGetIssue = vi.fn(); + + manager = new TestableSignoffManager( + { owner: 'test-org', repo: 'test-repo' }, + { getIssue: mockGetIssue } + ); + }); + + it('should parse signoff labels from issue', async () => { + mockGetIssue.mockResolvedValue({ + labels: [ + { name: 'type:prd-review' }, + { name: 'signoff-alice-approved' }, + { name: 'signoff-bob-approved-with-note' }, + { name: 'signoff-charlie-blocked' }, + { name: 'signoff-dave-pending' } + ] + }); + + const signoffs = await manager.getSignoffs(100); + + expect(signoffs).toHaveLength(4); + expect(signoffs).toContainEqual({ + user: 'alice', + status: 'approved', + label: 'signoff-alice-approved' + }); + expect(signoffs).toContainEqual({ + user: 'bob', + status: 'approved_with_note', + label: 'signoff-bob-approved-with-note' + }); + expect(signoffs).toContainEqual({ + user: 'charlie', + status: 'blocked', + label: 'signoff-charlie-blocked' + }); + expect(signoffs).toContainEqual({ + user: 'dave', + status: 'pending', + label: 'signoff-dave-pending' + }); + }); + + it('should return empty array when no signoff labels', async () => { + mockGetIssue.mockResolvedValue({ + labels: [ + { name: 'type:prd-review' }, + { name: 'review-status:signoff' } + ] + }); + + const signoffs = await manager.getSignoffs(100); + + expect(signoffs).toHaveLength(0); + }); + + it('should ignore non-signoff labels', async () => { + mockGetIssue.mockResolvedValue({ + labels: [ + { name: 'signoff-alice-approved' }, + { name: 'priority:high' }, + { name: 'type:prd-feedback' } + ] + }); + + const signoffs = await manager.getSignoffs(100); + + expect(signoffs).toHaveLength(1); + expect(signoffs[0].user).toBe('alice'); + }); + }); + + // ============ calculateStatus Tests - Count Threshold ============ + + describe('calculateStatus - count threshold', () => { + let manager; + + beforeEach(() => { + manager = new SignoffManager({ owner: 'test', repo: 'test' }); + }); + + it('should return approved when minimum approvals reached', () => { + const signoffs = [ + { user: 'alice', status: 'approved' }, + { user: 'bob', status: 'approved' } + ]; + const stakeholders = ['alice', 'bob', 'charlie']; + const config = { ...DEFAULT_CONFIG, minimum_approvals: 2 }; + + const status = manager.calculateStatus(signoffs, stakeholders, config); + + expect(status.status).toBe('approved'); + expect(status.message).toContain('Minimum approvals reached'); + }); + + it('should return pending when more approvals needed', () => { + const signoffs = [ + { user: 'alice', status: 'approved' } + ]; + const stakeholders = ['alice', 'bob', 'charlie']; + const config = { ...DEFAULT_CONFIG, minimum_approvals: 2 }; + + const status = manager.calculateStatus(signoffs, stakeholders, config); + + expect(status.status).toBe('pending'); + expect(status.needed).toBe(1); + expect(status.message).toContain('Need 1 more approval'); + }); + + it('should count approved_with_note as approval', () => { + const signoffs = [ + { user: 'alice', status: 'approved' }, + { user: 'bob', status: 'approved_with_note' } + ]; + const stakeholders = ['alice', 'bob', 'charlie']; + const config = { ...DEFAULT_CONFIG, minimum_approvals: 2 }; + + const status = manager.calculateStatus(signoffs, stakeholders, config); + + expect(status.status).toBe('approved'); + }); + + it('should return blocked when block threshold reached', () => { + const signoffs = [ + { user: 'alice', status: 'approved' }, + { user: 'bob', status: 'blocked' } + ]; + const stakeholders = ['alice', 'bob', 'charlie']; + const config = { ...DEFAULT_CONFIG, minimum_approvals: 2, block_threshold: 1 }; + + const status = manager.calculateStatus(signoffs, stakeholders, config); + + expect(status.status).toBe('blocked'); + expect(status.blockers).toContain('bob'); + }); + + it('should not block when allow_blocks is false', () => { + const signoffs = [ + { user: 'alice', status: 'approved' }, + { user: 'bob', status: 'approved' }, + { user: 'charlie', status: 'blocked' } + ]; + const stakeholders = ['alice', 'bob', 'charlie']; + const config = { ...DEFAULT_CONFIG, minimum_approvals: 2, allow_blocks: false }; + + const status = manager.calculateStatus(signoffs, stakeholders, config); + + expect(status.status).toBe('approved'); // Blocks ignored + }); + + it('should respect higher block_threshold', () => { + const signoffs = [ + { user: 'alice', status: 'approved' }, + { user: 'bob', status: 'approved' }, + { user: 'charlie', status: 'blocked' } + ]; + const stakeholders = ['alice', 'bob', 'charlie', 'dave']; + const config = { ...DEFAULT_CONFIG, minimum_approvals: 2, block_threshold: 2 }; + + const status = manager.calculateStatus(signoffs, stakeholders, config); + + expect(status.status).toBe('approved'); // Only 1 block, threshold is 2 + }); + }); + + // ============ calculateStatus Tests - Percentage Threshold ============ + + describe('calculateStatus - percentage threshold', () => { + let manager; + + beforeEach(() => { + manager = new SignoffManager({ owner: 'test', repo: 'test' }); + }); + + it('should return approved when percentage threshold met', () => { + const signoffs = [ + { user: 'alice', status: 'approved' }, + { user: 'bob', status: 'approved' } + ]; + const stakeholders = ['alice', 'bob', 'charlie']; // 2/3 = 66.67% + const config = { + ...DEFAULT_CONFIG, + threshold_type: 'percentage', + approval_percentage: 66 + }; + + const status = manager.calculateStatus(signoffs, stakeholders, config); + + expect(status.status).toBe('approved'); + expect(status.message).toContain('67%'); + expect(status.message).toContain('66%'); + }); + + it('should return pending when percentage not met', () => { + const signoffs = [ + { user: 'alice', status: 'approved' } + ]; + const stakeholders = ['alice', 'bob', 'charlie', 'dave']; // 1/4 = 25% + const config = { + ...DEFAULT_CONFIG, + threshold_type: 'percentage', + approval_percentage: 50 + }; + + const status = manager.calculateStatus(signoffs, stakeholders, config); + + expect(status.status).toBe('pending'); + expect(status.current_percent).toBe(25); + expect(status.needed_percent).toBe(50); + expect(status.needed).toBe(1); // Need 1 more to reach 50% + }); + + it('should calculate correctly for 100% threshold', () => { + const signoffs = [ + { user: 'alice', status: 'approved' }, + { user: 'bob', status: 'approved' } + ]; + const stakeholders = ['alice', 'bob', 'charlie']; + const config = { + ...DEFAULT_CONFIG, + threshold_type: 'percentage', + approval_percentage: 100 + }; + + const status = manager.calculateStatus(signoffs, stakeholders, config); + + expect(status.status).toBe('pending'); + expect(status.needed).toBe(1); + }); + }); + + // ============ calculateStatus Tests - Required Approvers Threshold ============ + + describe('calculateStatus - required_approvers threshold', () => { + let manager; + + beforeEach(() => { + manager = new SignoffManager({ owner: 'test', repo: 'test' }); + }); + + it('should return approved when all required + minimum optional approved', () => { + const signoffs = [ + { user: 'alice', status: 'approved' }, + { user: 'bob', status: 'approved' }, + { user: 'charlie', status: 'approved' } + ]; + const stakeholders = ['alice', 'bob', 'charlie', 'dave']; + const config = { + ...DEFAULT_CONFIG, + threshold_type: 'required_approvers', + required: ['alice', 'bob'], + optional: ['charlie', 'dave'], + minimum_optional: 1 + }; + + const status = manager.calculateStatus(signoffs, stakeholders, config); + + expect(status.status).toBe('approved'); + expect(status.message).toContain('All required + minimum optional'); + }); + + it('should return pending when required approver missing', () => { + const signoffs = [ + { user: 'alice', status: 'approved' }, + { user: 'charlie', status: 'approved' } + ]; + const stakeholders = ['alice', 'bob', 'charlie', 'dave']; + const config = { + ...DEFAULT_CONFIG, + threshold_type: 'required_approvers', + required: ['alice', 'bob'], + optional: ['charlie', 'dave'], + minimum_optional: 1 + }; + + const status = manager.calculateStatus(signoffs, stakeholders, config); + + expect(status.status).toBe('pending'); + expect(status.missing_required).toContain('bob'); + expect(status.message).toContain('bob'); + }); + + it('should return pending when optional threshold not met', () => { + const signoffs = [ + { user: 'alice', status: 'approved' }, + { user: 'bob', status: 'approved' } + // No optional approvers + ]; + const stakeholders = ['alice', 'bob', 'charlie', 'dave']; + const config = { + ...DEFAULT_CONFIG, + threshold_type: 'required_approvers', + required: ['alice', 'bob'], + optional: ['charlie', 'dave'], + minimum_optional: 1 + }; + + const status = manager.calculateStatus(signoffs, stakeholders, config); + + expect(status.status).toBe('pending'); + expect(status.optional_needed).toBe(1); + expect(status.message).toContain('optional approver'); + }); + + it('should handle @ prefix in required list', () => { + const signoffs = [ + { user: 'alice', status: 'approved' }, + { user: 'bob', status: 'approved' } + ]; + const stakeholders = ['@alice', '@bob']; + const config = { + ...DEFAULT_CONFIG, + threshold_type: 'required_approvers', + required: ['@alice', '@bob'], + optional: [], + minimum_optional: 0 + }; + + const status = manager.calculateStatus(signoffs, stakeholders, config); + + expect(status.status).toBe('approved'); + }); + }); + + // ============ isApproved Tests ============ + + describe('isApproved', () => { + let manager; + + beforeEach(() => { + manager = new SignoffManager({ owner: 'test', repo: 'test' }); + }); + + it('should return true when approved', () => { + const signoffs = [ + { user: 'alice', status: 'approved' }, + { user: 'bob', status: 'approved' } + ]; + + const approved = manager.isApproved(signoffs, ['alice', 'bob', 'charlie'], { + ...DEFAULT_CONFIG, + minimum_approvals: 2 + }); + + expect(approved).toBe(true); + }); + + it('should return false when pending', () => { + const signoffs = [ + { user: 'alice', status: 'approved' } + ]; + + const approved = manager.isApproved(signoffs, ['alice', 'bob', 'charlie'], { + ...DEFAULT_CONFIG, + minimum_approvals: 2 + }); + + expect(approved).toBe(false); + }); + + it('should return false when blocked', () => { + const signoffs = [ + { user: 'alice', status: 'approved' }, + { user: 'bob', status: 'blocked' } + ]; + + const approved = manager.isApproved(signoffs, ['alice', 'bob'], { + ...DEFAULT_CONFIG, + minimum_approvals: 1 + }); + + expect(approved).toBe(false); + }); + }); + + // ============ getProgressSummary Tests ============ + + describe('getProgressSummary', () => { + let manager; + + beforeEach(() => { + manager = new SignoffManager({ owner: 'test', repo: 'test' }); + }); + + it('should calculate progress summary', () => { + const signoffs = [ + { user: 'alice', status: 'approved' }, + { user: 'bob', status: 'approved_with_note' }, + { user: 'charlie', status: 'blocked' } + ]; + const stakeholders = ['alice', 'bob', 'charlie', 'dave', 'eve']; + + const summary = manager.getProgressSummary(signoffs, stakeholders, DEFAULT_CONFIG); + + expect(summary.total_stakeholders).toBe(5); + expect(summary.approved_count).toBe(2); + expect(summary.blocked_count).toBe(1); + expect(summary.pending_count).toBe(2); + expect(summary.pending_users).toContain('dave'); + expect(summary.pending_users).toContain('eve'); + expect(summary.progress_percent).toBe(40); // 2/5 = 40% + }); + + it('should include status info from calculateStatus', () => { + const signoffs = [ + { user: 'alice', status: 'approved' }, + { user: 'bob', status: 'approved' } + ]; + const stakeholders = ['alice', 'bob', 'charlie']; + + const summary = manager.getProgressSummary(signoffs, stakeholders, { + ...DEFAULT_CONFIG, + minimum_approvals: 2 + }); + + expect(summary.status).toBe('approved'); + expect(summary.message).toBeDefined(); + }); + + it('should handle @ prefix in stakeholder names', () => { + const signoffs = [ + { user: 'alice', status: 'approved' } + ]; + const stakeholders = ['@alice', '@bob']; + + const summary = manager.getProgressSummary(signoffs, stakeholders, DEFAULT_CONFIG); + + expect(summary.pending_users).toContain('@bob'); + expect(summary.pending_count).toBe(1); + }); + }); + + // ============ sendReminder Tests ============ + + describe('sendReminder', () => { + let manager; + let mockAddComment; + + beforeEach(() => { + mockAddComment = vi.fn().mockResolvedValue({}); + + manager = new TestableSignoffManager( + { owner: 'test-org', repo: 'test-repo' }, + { addComment: mockAddComment } + ); + }); + + it('should send reminder to pending users', async () => { + const result = await manager.sendReminder( + 100, + ['alice', 'bob'], + '2026-01-15' + ); + + expect(mockAddComment).toHaveBeenCalledTimes(1); + const comment = mockAddComment.mock.calls[0][1]; + + expect(comment).toContain('⏰ Reminder'); + expect(comment).toContain('@alice'); + expect(comment).toContain('@bob'); + expect(comment).toContain('2026-01-15'); + + expect(result.reminded).toEqual(['alice', 'bob']); + expect(result.deadline).toBe('2026-01-15'); + }); + + it('should handle @ prefix in user names', async () => { + await manager.sendReminder(100, ['@charlie'], '2026-01-20'); + + const comment = mockAddComment.mock.calls[0][1]; + expect(comment).toContain('@charlie'); + expect(comment).not.toContain('@@'); + }); + }); + + // ============ extendDeadline Tests ============ + + describe('extendDeadline', () => { + let manager; + let mockAddComment; + + beforeEach(() => { + mockAddComment = vi.fn().mockResolvedValue({}); + + manager = new TestableSignoffManager( + { owner: 'test-org', repo: 'test-repo' }, + { addComment: mockAddComment } + ); + }); + + it('should post deadline extension comment', async () => { + const result = await manager.extendDeadline(100, '2026-01-20'); + + expect(mockAddComment).toHaveBeenCalledTimes(1); + const comment = mockAddComment.mock.calls[0][1]; + + expect(comment).toContain('📅 Deadline Extended'); + expect(comment).toContain('2026-01-20'); + + expect(result.reviewIssueNumber).toBe(100); + expect(result.newDeadline).toBe('2026-01-20'); + }); + + it('should include reason when provided', async () => { + await manager.extendDeadline(100, '2026-01-25', 'Holiday period'); + + const comment = mockAddComment.mock.calls[0][1]; + + expect(comment).toContain('Holiday period'); + }); + }); + + // ============ Private Method Tests ============ + + describe('_getDecisionEmoji', () => { + let manager; + + beforeEach(() => { + manager = new SignoffManager({ owner: 'test', repo: 'test' }); + }); + + it('should return correct emoji for each decision', () => { + expect(manager._getDecisionEmoji('approved')).toBe('✅'); + expect(manager._getDecisionEmoji('approved_with_note')).toBe('✅📝'); + expect(manager._getDecisionEmoji('blocked')).toBe('🚫'); + expect(manager._getDecisionEmoji('pending')).toBe('⏳'); + expect(manager._getDecisionEmoji('unknown')).toBe('⏳'); + }); + }); + + describe('_getDecisionText', () => { + let manager; + + beforeEach(() => { + manager = new SignoffManager({ owner: 'test', repo: 'test' }); + }); + + it('should return correct text for each decision', () => { + expect(manager._getDecisionText('approved')).toBe('Approved'); + expect(manager._getDecisionText('approved_with_note')).toBe('Approved with Note'); + expect(manager._getDecisionText('blocked')).toBe('Blocked'); + expect(manager._getDecisionText('pending')).toBe('Pending'); + }); + }); + + describe('_formatThreshold', () => { + let manager; + + beforeEach(() => { + manager = new SignoffManager({ owner: 'test', repo: 'test' }); + }); + + it('should format count threshold', () => { + const config = { threshold_type: 'count', minimum_approvals: 3 }; + expect(manager._formatThreshold(config)).toBe('3 approval(s) required'); + }); + + it('should format percentage threshold', () => { + const config = { threshold_type: 'percentage', approval_percentage: 75 }; + expect(manager._formatThreshold(config)).toBe('75% must approve'); + }); + + it('should format required_approvers threshold', () => { + const config = { + threshold_type: 'required_approvers', + required: ['alice', 'bob'], + minimum_optional: 2 + }; + expect(manager._formatThreshold(config)).toBe('Required: alice, bob + 2 optional'); + }); + + it('should return Unknown for invalid threshold type', () => { + const config = { threshold_type: 'invalid' }; + expect(manager._formatThreshold(config)).toBe('Unknown'); + }); + }); + + // ============ Error Handling Tests ============ + + describe('error handling', () => { + it('should throw when GitHub methods not implemented', async () => { + const manager = new SignoffManager({ owner: 'test', repo: 'test' }); + + await expect(manager._getIssue(1)).rejects.toThrow( + '_getIssue must be implemented by caller via GitHub MCP' + ); + + await expect(manager._addComment(1, 'test')).rejects.toThrow( + '_addComment must be implemented by caller via GitHub MCP' + ); + }); + + it('should throw for unknown threshold type in calculateStatus', () => { + const manager = new SignoffManager({ owner: 'test', repo: 'test' }); + + expect(() => { + manager.calculateStatus([], ['alice'], { threshold_type: 'invalid' }); + }).toThrow('Unknown threshold type: invalid'); + }); + }); +}); diff --git a/test/unit/crowdsource/synthesis-engine.test.js b/test/unit/crowdsource/synthesis-engine.test.js new file mode 100644 index 00000000..75249769 --- /dev/null +++ b/test/unit/crowdsource/synthesis-engine.test.js @@ -0,0 +1,810 @@ +/** + * Tests for SynthesisEngine - LLM-powered feedback synthesis with conflict resolution + * + * Tests cover: + * - LLM prompt templates for PRD and Epic synthesis + * - Feedback analysis and section grouping + * - Conflict detection with keyword extraction + * - Theme identification + * - Prompt generation for conflict resolution and merging + * - Summary generation and formatting + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + SynthesisEngine, + SYNTHESIS_PROMPTS +} from '../../../src/modules/bmm/lib/crowdsource/synthesis-engine.js'; + +describe('SynthesisEngine', () => { + // ============ SYNTHESIS_PROMPTS Tests ============ + + describe('SYNTHESIS_PROMPTS', () => { + describe('PRD prompts', () => { + it('should have grouping prompt with placeholders', () => { + const prompt = SYNTHESIS_PROMPTS.prd.grouping; + + expect(prompt).toContain('{{section}}'); + expect(prompt).toContain('{{feedbackItems}}'); + expect(prompt).toContain('Common requests'); + expect(prompt).toContain('Conflicts'); + expect(prompt).toContain('Quick wins'); + expect(prompt).toContain('Major changes'); + expect(prompt).toContain('JSON'); + }); + + it('should have resolution prompt with placeholders', () => { + const prompt = SYNTHESIS_PROMPTS.prd.resolution; + + expect(prompt).toContain('{{section}}'); + expect(prompt).toContain('{{originalText}}'); + expect(prompt).toContain('{{conflictDescription}}'); + expect(prompt).toContain('{{feedbackDetails}}'); + expect(prompt).toContain('proposed_text'); + expect(prompt).toContain('rationale'); + expect(prompt).toContain('trade_offs'); + expect(prompt).toContain('confidence'); + }); + + it('should have merge prompt with placeholders', () => { + const prompt = SYNTHESIS_PROMPTS.prd.merge; + + expect(prompt).toContain('{{section}}'); + expect(prompt).toContain('{{originalText}}'); + expect(prompt).toContain('{{feedbackToIncorporate}}'); + }); + }); + + describe('Epic prompts', () => { + it('should have grouping prompt with epic-specific categories', () => { + const prompt = SYNTHESIS_PROMPTS.epic.grouping; + + expect(prompt).toContain('{{epicKey}}'); + expect(prompt).toContain('{{feedbackItems}}'); + expect(prompt).toContain('Scope concerns'); + expect(prompt).toContain('Story split suggestions'); + expect(prompt).toContain('Dependency'); + expect(prompt).toContain('Technical risks'); + expect(prompt).toContain('Missing stories'); + expect(prompt).toContain('Priority questions'); + }); + + it('should have storySplit prompt with placeholders', () => { + const prompt = SYNTHESIS_PROMPTS.epic.storySplit; + + expect(prompt).toContain('{{epicKey}}'); + expect(prompt).toContain('{{epicDescription}}'); + expect(prompt).toContain('{{currentStories}}'); + expect(prompt).toContain('{{feedbackItems}}'); + expect(prompt).toContain('stories'); + expect(prompt).toContain('changes_made'); + expect(prompt).toContain('rationale'); + }); + }); + }); + + // ============ Constructor Tests ============ + + describe('constructor', () => { + it('should default to PRD document type', () => { + const engine = new SynthesisEngine(); + expect(engine.documentType).toBe('prd'); + }); + + it('should accept document type option', () => { + const engine = new SynthesisEngine({ documentType: 'epic' }); + expect(engine.documentType).toBe('epic'); + }); + }); + + // ============ analyzeFeedback Tests ============ + + describe('analyzeFeedback', () => { + let engine; + + beforeEach(() => { + engine = new SynthesisEngine({ documentType: 'prd' }); + }); + + it('should analyze feedback by section', async () => { + const feedbackBySection = { + 'user-stories': [ + { + id: 1, + title: 'Add login flow', + feedbackType: 'suggestion', + priority: 'high', + submittedBy: 'alice', + body: 'Need login flow description' + } + ], + 'fr-3': [ + { + id: 2, + title: 'Timeout concern', + feedbackType: 'concern', + priority: 'high', + submittedBy: 'bob', + body: 'Session timeout too long' + } + ] + }; + + const originalDocument = { + 'user-stories': 'Current user story text', + 'fr-3': 'FR-3 original text' + }; + + const analysis = await engine.analyzeFeedback(feedbackBySection, originalDocument); + + expect(analysis.sections).toBeDefined(); + expect(Object.keys(analysis.sections)).toHaveLength(2); + expect(analysis.sections['user-stories'].feedbackCount).toBe(1); + expect(analysis.sections['fr-3'].feedbackCount).toBe(1); + }); + + it('should collect conflicts from all sections', async () => { + const feedbackBySection = { + 'security': [ + { + id: 1, + title: 'Short timeout', + feedbackType: 'concern', + priority: 'high', + submittedBy: 'security', + body: 'timeout should be 15 min', + suggestedChange: '15 minute timeout' + }, + { + id: 2, + title: 'Long timeout', + feedbackType: 'concern', + priority: 'medium', + submittedBy: 'ux', + body: 'timeout should be 30 min', + suggestedChange: '30 minute timeout' + } + ] + }; + + const analysis = await engine.analyzeFeedback(feedbackBySection, {}); + + expect(analysis.conflicts.length).toBeGreaterThanOrEqual(0); + // Conflicts are detected based on keyword matching + }); + + it('should generate summary statistics', async () => { + const feedbackBySection = { + 'section1': [ + { id: 1, title: 'FB1', feedbackType: 'clarification', submittedBy: 'user1' }, + { id: 2, title: 'FB2', feedbackType: 'concern', submittedBy: 'user2' } + ], + 'section2': [ + { id: 3, title: 'FB3', feedbackType: 'suggestion', submittedBy: 'user3' } + ] + }; + + const analysis = await engine.analyzeFeedback(feedbackBySection, {}); + + expect(analysis.summary.totalFeedback).toBe(3); + expect(analysis.summary.sectionsWithFeedback).toBe(2); + expect(analysis.summary.feedbackByType).toBeDefined(); + }); + }); + + // ============ _analyzeSection Tests ============ + + describe('_analyzeSection', () => { + let engine; + + beforeEach(() => { + engine = new SynthesisEngine({ documentType: 'prd' }); + }); + + it('should count feedback and group by type', async () => { + const feedbackList = [ + { id: 1, feedbackType: 'clarification', title: 'Q1' }, + { id: 2, feedbackType: 'clarification', title: 'Q2' }, + { id: 3, feedbackType: 'concern', title: 'C1' } + ]; + + const result = await engine._analyzeSection('test-section', feedbackList, ''); + + expect(result.feedbackCount).toBe(3); + expect(result.byType.clarification).toBe(2); + expect(result.byType.concern).toBe(1); + }); + + it('should generate suggested changes for non-conflicting feedback', async () => { + const feedbackList = [ + { + id: 1, + title: 'Add validation', + feedbackType: 'suggestion', + priority: 'high', + suggestedChange: 'Add input validation', + submittedBy: 'alice' + } + ]; + + const result = await engine._analyzeSection('test-section', feedbackList, ''); + + expect(result.suggestedChanges).toHaveLength(1); + expect(result.suggestedChanges[0].feedbackId).toBe(1); + expect(result.suggestedChanges[0].type).toBe('suggestion'); + expect(result.suggestedChanges[0].suggestedChange).toBe('Add input validation'); + }); + }); + + // ============ _identifyConflicts Tests ============ + + describe('_identifyConflicts', () => { + let engine; + + beforeEach(() => { + engine = new SynthesisEngine(); + }); + + it('should detect conflicts when same topic has different suggestions', () => { + const feedbackList = [ + { + id: 1, + title: 'timeout should be shorter', + body: 'Session timeout configuration', + suggestedChange: 'Set to 15 minutes' + }, + { + id: 2, + title: 'timeout should be longer', + body: 'Session timeout configuration', + suggestedChange: 'Set to 30 minutes' + } + ]; + + const conflicts = engine._identifyConflicts(feedbackList); + + expect(conflicts.length).toBeGreaterThan(0); + const timeoutConflict = conflicts.find(c => c.topic === 'timeout'); + expect(timeoutConflict).toBeDefined(); + expect(timeoutConflict.feedbackIds).toContain(1); + expect(timeoutConflict.feedbackIds).toContain(2); + }); + + it('should not detect conflict when suggestions are the same', () => { + const feedbackList = [ + { + id: 1, + title: 'auth improvement', + body: 'Authentication flow', + suggestedChange: 'Add OAuth' + }, + { + id: 2, + title: 'auth needed', + body: 'Authentication required', + suggestedChange: 'Add OAuth' + } + ]; + + const conflicts = engine._identifyConflicts(feedbackList); + + // Same suggestion = no conflict + const authConflict = conflicts.find(c => + c.feedbackIds.includes(1) && c.feedbackIds.includes(2) && + c.description.includes('Conflicting') + ); + expect(authConflict).toBeUndefined(); + }); + + it('should not detect conflict for single feedback item', () => { + const feedbackList = [ + { + id: 1, + title: 'unique topic here', + body: 'Only one feedback on this', + suggestedChange: 'Some change' + } + ]; + + const conflicts = engine._identifyConflicts(feedbackList); + expect(conflicts).toHaveLength(0); + }); + + it('should handle feedback without suggestedChange', () => { + const feedbackList = [ + { + id: 1, + title: 'question about feature', + body: 'What does this do?' + // No suggestedChange + }, + { + id: 2, + title: 'another question feature', + body: 'How does this work?' + // No suggestedChange + } + ]; + + // Should not throw, and no conflicts detected (no different suggestions) + const conflicts = engine._identifyConflicts(feedbackList); + expect(Array.isArray(conflicts)).toBe(true); + }); + }); + + // ============ _identifyThemes Tests ============ + + describe('_identifyThemes', () => { + let engine; + + beforeEach(() => { + engine = new SynthesisEngine(); + }); + + it('should identify themes mentioned by multiple people', () => { + const feedbackList = [ + { id: 1, title: 'authentication needs work', feedbackType: 'concern' }, + { id: 2, title: 'authentication is unclear', feedbackType: 'clarification' }, + { id: 3, title: 'completely different topic', feedbackType: 'suggestion' } + ]; + + const themes = engine._identifyThemes(feedbackList); + + const authTheme = themes.find(t => t.keyword === 'authentication'); + expect(authTheme).toBeDefined(); + expect(authTheme.count).toBe(2); + expect(authTheme.feedbackIds).toContain(1); + expect(authTheme.feedbackIds).toContain(2); + }); + + it('should track feedback types for each theme', () => { + const feedbackList = [ + { id: 1, title: 'security concern here', feedbackType: 'concern' }, + { id: 2, title: 'security suggestion', feedbackType: 'suggestion' } + ]; + + const themes = engine._identifyThemes(feedbackList); + + const securityTheme = themes.find(t => t.keyword === 'security'); + expect(securityTheme).toBeDefined(); + expect(securityTheme.types).toContain('concern'); + expect(securityTheme.types).toContain('suggestion'); + }); + + it('should sort themes by count descending', () => { + const feedbackList = [ + { id: 1, title: 'rare topic', feedbackType: 'concern' }, + { id: 2, title: 'common topic', feedbackType: 'concern' }, + { id: 3, title: 'common topic again', feedbackType: 'suggestion' }, + { id: 4, title: 'common topic still', feedbackType: 'clarification' } + ]; + + const themes = engine._identifyThemes(feedbackList); + + if (themes.length > 0) { + // First theme should have highest count + for (let i = 1; i < themes.length; i++) { + expect(themes[i - 1].count).toBeGreaterThanOrEqual(themes[i].count); + } + } + }); + + it('should filter out themes with count < 2', () => { + const feedbackList = [ + { id: 1, title: 'unique topic alpha', feedbackType: 'concern' }, + { id: 2, title: 'unique topic beta', feedbackType: 'suggestion' }, + { id: 3, title: 'unique topic gamma', feedbackType: 'clarification' } + ]; + + const themes = engine._identifyThemes(feedbackList); + + // All unique words should be filtered out (count < 2) + for (const theme of themes) { + expect(theme.count).toBeGreaterThanOrEqual(2); + } + }); + }); + + // ============ _extractKeywords Tests ============ + + describe('_extractKeywords', () => { + let engine; + + beforeEach(() => { + engine = new SynthesisEngine(); + }); + + it('should extract meaningful keywords from text', () => { + const keywords = engine._extractKeywords('The authentication flow needs improvement'); + + expect(keywords).toContain('authentication'); + expect(keywords).toContain('flow'); + expect(keywords).toContain('needs'); + expect(keywords).toContain('improvement'); + }); + + it('should filter out stop words', () => { + const keywords = engine._extractKeywords('The user should be able to login'); + + expect(keywords).not.toContain('the'); + expect(keywords).not.toContain('should'); + expect(keywords).not.toContain('be'); + expect(keywords).not.toContain('to'); + }); + + it('should filter out short words (length <= 3)', () => { + const keywords = engine._extractKeywords('API is not working'); + + expect(keywords).not.toContain('api'); + expect(keywords).not.toContain('is'); + expect(keywords).not.toContain('not'); + expect(keywords).toContain('working'); + }); + + it('should convert to lowercase', () => { + const keywords = engine._extractKeywords('SECURITY Authentication'); + + expect(keywords).toContain('security'); + expect(keywords).toContain('authentication'); + expect(keywords).not.toContain('SECURITY'); + }); + + it('should remove punctuation', () => { + const keywords = engine._extractKeywords('User-authentication, session.timeout!'); + + // Should normalize punctuation + const hasAuth = keywords.some(k => k.includes('auth')); + expect(hasAuth).toBe(true); + }); + + it('should handle null/undefined input', () => { + expect(engine._extractKeywords(null)).toEqual([]); + expect(engine._extractKeywords(undefined)).toEqual([]); + expect(engine._extractKeywords('')).toEqual([]); + }); + + it('should limit to 10 keywords', () => { + const longText = 'authentication authorization validation configuration implementation documentation optimization visualization serialization deserialization normalization denormalization extra words here'; + + const keywords = engine._extractKeywords(longText); + + expect(keywords.length).toBeLessThanOrEqual(10); + }); + }); + + // ============ generateConflictResolution Tests ============ + + describe('generateConflictResolution', () => { + it('should generate resolution prompt for PRD', () => { + const engine = new SynthesisEngine({ documentType: 'prd' }); + + const conflict = { + section: 'FR-5', + description: 'Conflicting views on session timeout' + }; + + const result = engine.generateConflictResolution( + conflict, + 'Session timeout is 30 minutes.', + [ + { user: 'security', position: '15 minutes for security' }, + { user: 'ux', position: '30 minutes for usability' } + ] + ); + + expect(result.prompt).toContain('FR-5'); + expect(result.prompt).toContain('Session timeout is 30 minutes'); + expect(result.prompt).toContain('Conflicting views on session timeout'); + expect(result.conflict).toEqual(conflict); + expect(result.expectedFormat).toHaveProperty('proposed_text'); + expect(result.expectedFormat).toHaveProperty('rationale'); + expect(result.expectedFormat).toHaveProperty('trade_offs'); + expect(result.expectedFormat).toHaveProperty('confidence'); + }); + + it('should throw error for Epic (no resolution prompt available)', () => { + const engine = new SynthesisEngine({ documentType: 'epic' }); + + const conflict = { + section: 'Story Breakdown', + description: 'Disagreement on story granularity' + }; + + // Epic prompts only have grouping and storySplit, not resolution + expect(() => { + engine.generateConflictResolution( + conflict, + 'Epic contains 5 stories', + [] + ); + }).toThrow(); + }); + + it('should handle missing originalText', () => { + const engine = new SynthesisEngine({ documentType: 'prd' }); + + const conflict = { + section: 'New Section', + description: 'Need new content' + }; + + const result = engine.generateConflictResolution(conflict, null, []); + + expect(result.prompt).toContain('N/A'); + }); + }); + + // ============ generateMergePrompt Tests ============ + + describe('generateMergePrompt', () => { + let engine; + + beforeEach(() => { + engine = new SynthesisEngine({ documentType: 'prd' }); + }); + + it('should generate merge prompt with feedback details', () => { + const approvedFeedback = [ + { + feedbackType: 'suggestion', + title: 'Add error handling', + suggestedChange: 'Include try-catch blocks' + }, + { + feedbackType: 'addition', + title: 'Missing validation', + suggestedChange: 'Add input validation' + } + ]; + + const prompt = engine.generateMergePrompt( + 'FR-3', + 'Original function implementation', + approvedFeedback + ); + + expect(prompt).toContain('FR-3'); + expect(prompt).toContain('Original function implementation'); + expect(prompt).toContain('suggestion: Add error handling'); + expect(prompt).toContain('Include try-catch blocks'); + expect(prompt).toContain('addition: Missing validation'); + expect(prompt).toContain('Add input validation'); + }); + + it('should handle feedback without suggestedChange', () => { + const approvedFeedback = [ + { + feedbackType: 'concern', + title: 'Security risk' + // No suggestedChange + } + ]; + + const prompt = engine.generateMergePrompt('Security', 'Current text', approvedFeedback); + + expect(prompt).toContain('concern: Security risk'); + expect(prompt).toContain('Address the concern'); + }); + }); + + // ============ generateStorySplitPrompt Tests ============ + + describe('generateStorySplitPrompt', () => { + it('should generate story split prompt for epic', () => { + const engine = new SynthesisEngine({ documentType: 'epic' }); + + const prompt = engine.generateStorySplitPrompt( + 'epic:2', + 'Authentication epic for user login and session management', + [ + { key: '2-1', title: 'Login Form' }, + { key: '2-2', title: 'Session Management' } + ], + [ + { id: 1, title: 'Story 2-2 too large', suggestedChange: 'Split into 3 stories' } + ] + ); + + expect(prompt).toContain('epic:2'); + expect(prompt).toContain('Authentication epic'); + expect(prompt).toContain('2-1'); + expect(prompt).toContain('Login Form'); + expect(prompt).toContain('Story 2-2 too large'); + }); + + it('should throw error when called for PRD', () => { + const engine = new SynthesisEngine({ documentType: 'prd' }); + + expect(() => { + engine.generateStorySplitPrompt('prd:1', 'desc', [], []); + }).toThrow('Story split is only available for epics'); + }); + }); + + // ============ _generateSummary Tests ============ + + describe('_generateSummary', () => { + let engine; + + beforeEach(() => { + engine = new SynthesisEngine(); + }); + + it('should calculate total feedback count', () => { + const analysis = { + sections: { + 'section1': { feedbackCount: 3, byType: { concern: 2, suggestion: 1 } }, + 'section2': { feedbackCount: 2, byType: { clarification: 2 } } + }, + conflicts: [], + suggestedChanges: [] + }; + + const summary = engine._generateSummary(analysis); + + expect(summary.totalFeedback).toBe(5); + }); + + it('should count sections with feedback', () => { + const analysis = { + sections: { + 'section1': { feedbackCount: 1, byType: {} }, + 'section2': { feedbackCount: 2, byType: {} }, + 'section3': { feedbackCount: 1, byType: {} } + }, + conflicts: [], + suggestedChanges: [] + }; + + const summary = engine._generateSummary(analysis); + + expect(summary.sectionsWithFeedback).toBe(3); + }); + + it('should aggregate feedback by type across sections', () => { + const analysis = { + sections: { + 'section1': { feedbackCount: 2, byType: { concern: 1, suggestion: 1 } }, + 'section2': { feedbackCount: 2, byType: { concern: 1, clarification: 1 } } + }, + conflicts: [], + suggestedChanges: [] + }; + + const summary = engine._generateSummary(analysis); + + expect(summary.feedbackByType.concern).toBe(2); + expect(summary.feedbackByType.suggestion).toBe(1); + expect(summary.feedbackByType.clarification).toBe(1); + }); + + it('should set needsAttention when conflicts exist', () => { + const analysisWithConflicts = { + sections: {}, + conflicts: [{ section: 'test', description: 'conflict' }], + suggestedChanges: [] + }; + + const analysisWithoutConflicts = { + sections: {}, + conflicts: [], + suggestedChanges: [] + }; + + expect(engine._generateSummary(analysisWithConflicts).needsAttention).toBe(true); + expect(engine._generateSummary(analysisWithoutConflicts).needsAttention).toBe(false); + }); + + it('should count conflicts and changes', () => { + const analysis = { + sections: {}, + conflicts: [{ id: 1 }, { id: 2 }], + suggestedChanges: [{ id: 1 }, { id: 2 }, { id: 3 }] + }; + + const summary = engine._generateSummary(analysis); + + expect(summary.conflictCount).toBe(2); + expect(summary.changeCount).toBe(3); + }); + }); + + // ============ _groupByType Tests ============ + + describe('_groupByType', () => { + let engine; + + beforeEach(() => { + engine = new SynthesisEngine(); + }); + + it('should count feedback by type', () => { + const feedbackList = [ + { feedbackType: 'concern' }, + { feedbackType: 'concern' }, + { feedbackType: 'suggestion' }, + { feedbackType: 'clarification' } + ]; + + const byType = engine._groupByType(feedbackList); + + expect(byType.concern).toBe(2); + expect(byType.suggestion).toBe(1); + expect(byType.clarification).toBe(1); + }); + + it('should handle empty list', () => { + const byType = engine._groupByType([]); + expect(byType).toEqual({}); + }); + }); + + // ============ formatForDisplay Tests ============ + + describe('formatForDisplay', () => { + let engine; + + beforeEach(() => { + engine = new SynthesisEngine(); + }); + + it('should format analysis as markdown', () => { + const analysis = { + summary: { + totalFeedback: 5, + sectionsWithFeedback: 2, + conflictCount: 1, + changeCount: 3, + needsAttention: true + }, + sections: { + 'user-stories': { feedbackCount: 3, byType: { concern: 2, suggestion: 1 } }, + 'fr-3': { feedbackCount: 2, byType: { clarification: 2 } } + }, + conflicts: [ + { + section: 'user-stories', + description: 'Timeout conflict', + stakeholders: [ + { user: 'security', position: '15 min' }, + { user: 'ux', position: '30 min' } + ] + } + ] + }; + + const output = engine.formatForDisplay(analysis); + + expect(output).toContain('## Synthesis Analysis'); + expect(output).toContain('**Total Feedback:** 5'); + expect(output).toContain('**Sections with Feedback:** 2'); + expect(output).toContain('**Conflicts Detected:** 1'); + expect(output).toContain('**Suggested Changes:** 3'); + expect(output).toContain('⚠️ Conflicts Requiring Resolution'); + expect(output).toContain('user-stories'); + expect(output).toContain('@security'); + expect(output).toContain('@ux'); + expect(output).toContain('### By Section'); + }); + + it('should not show conflicts section when none exist', () => { + const analysis = { + summary: { + totalFeedback: 1, + sectionsWithFeedback: 1, + conflictCount: 0, + changeCount: 1, + needsAttention: false + }, + sections: { + 'test': { feedbackCount: 1, byType: { suggestion: 1 } } + }, + conflicts: [] + }; + + const output = engine.formatForDisplay(analysis); + + expect(output).not.toContain('⚠️ Conflicts Requiring Resolution'); + }); + }); +}); diff --git a/test/unit/notifications/email-notifier.test.js b/test/unit/notifications/email-notifier.test.js new file mode 100644 index 00000000..eba326bd --- /dev/null +++ b/test/unit/notifications/email-notifier.test.js @@ -0,0 +1,578 @@ +/** + * Tests for EmailNotifier - Email notification integration + * + * Tests cover: + * - Email templates with HTML and text formats + * - Template rendering + * - Enable/disable behavior based on config + * - Recipient lookup from userEmails mapping + * - Multiple email providers (SMTP, SendGrid, SES) + * - Error handling + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + EmailNotifier, + EMAIL_TEMPLATES +} from '../../../src/modules/bmm/lib/notifications/email-notifier.js'; + +describe('EmailNotifier', () => { + // ============ EMAIL_TEMPLATES Tests ============ + + describe('EMAIL_TEMPLATES', () => { + it('should define all required event types', () => { + const expectedTypes = [ + 'feedback_round_opened', + 'signoff_requested', + 'document_approved', + 'document_blocked', + 'reminder' + ]; + + for (const type of expectedTypes) { + expect(EMAIL_TEMPLATES[type]).toBeDefined(); + expect(EMAIL_TEMPLATES[type].subject).toBeTruthy(); + expect(EMAIL_TEMPLATES[type].html).toBeTruthy(); + expect(EMAIL_TEMPLATES[type].text).toBeTruthy(); + } + }); + + it('should have placeholders in subject lines', () => { + expect(EMAIL_TEMPLATES.feedback_round_opened.subject).toContain('{{document_type}}'); + expect(EMAIL_TEMPLATES.feedback_round_opened.subject).toContain('{{document_key}}'); + }); + + it('should have matching placeholders in HTML and text', () => { + const template = EMAIL_TEMPLATES.document_approved; + + // Both should contain key placeholders + expect(template.html).toContain('{{document_type}}'); + expect(template.html).toContain('{{document_key}}'); + expect(template.html).toContain('{{title}}'); + expect(template.html).toContain('{{version}}'); + + expect(template.text).toContain('{{document_type}}'); + expect(template.text).toContain('{{document_key}}'); + expect(template.text).toContain('{{title}}'); + expect(template.text).toContain('{{version}}'); + }); + + it('should have valid HTML structure', () => { + const template = EMAIL_TEMPLATES.signoff_requested; + + expect(template.html).toContain(''); + expect(template.html).toContain(''); + expect(template.html).toContain(''); + expect(template.html).toContain(''); + expect(template.html).toContain(''); + }); + + it('should have styled content in HTML templates', () => { + const template = EMAIL_TEMPLATES.feedback_round_opened; + + expect(template.html).toContain('