feat: add PRD/Epic crowdsourcing and GitHub integration for team collaboration
Add comprehensive async collaboration system for PRD/Epic development: PRD & Epic Crowdsourcing: - Create, open feedback rounds, synthesize, and sign-off workflows - LLM-powered synthesis engine for conflict resolution - Configurable sign-off thresholds (count, percentage, required approvers) - Unified "my-tasks" view across PRDs and Epics Story Coordination: - Story locking/checkout for preventing concurrent work - Lock status and unlock workflows - Available stories view with lock status Multi-Channel Notifications: - GitHub @mentions, Slack webhooks, Email (SMTP/SendGrid/SES) - Event-driven notifications for feedback/signoff requests - Priority-based retry logic for urgent notifications Cache System Extensions: - PRD and Epic document caching with metadata migration - Staleness detection and atomic file operations - User task queries and extended statistics GitHub integration is optional (disabled by default) - existing local-only projects continue to work unchanged. Includes 673 passing tests with comprehensive coverage for all new modules. Note: Library files use CommonJS for Node.js compatibility. ESLint rules for ES modules may need configuration adjustment.
This commit is contained in:
parent
bfd40d53eb
commit
1adf1ce195
|
|
@ -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"
|
||||||
|
|
@ -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)"
|
||||||
|
|
@ -19,6 +19,24 @@ BMAD documentation standards and guidelines. Used by:
|
||||||
- Various documentation workflows
|
- Various documentation workflows
|
||||||
- Standards validation and review processes
|
- 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
|
## Purpose
|
||||||
|
|
||||||
Separates module-specific data from core workflow implementations, maintaining clean architecture:
|
Separates module-specific data from core workflow implementations, maintaining clean architecture:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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: `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||||
|
.header { background: #4CAF50; color: white; padding: 20px; border-radius: 8px 8px 0 0; }
|
||||||
|
.content { background: #f9f9f9; padding: 20px; border: 1px solid #ddd; border-top: none; }
|
||||||
|
.footer { padding: 15px; font-size: 12px; color: #666; text-align: center; }
|
||||||
|
.button { display: inline-block; padding: 12px 24px; background: #4CAF50; color: white; text-decoration: none; border-radius: 4px; }
|
||||||
|
.meta { background: #fff; padding: 15px; border-radius: 4px; margin: 15px 0; }
|
||||||
|
.meta-item { margin: 8px 0; }
|
||||||
|
.label { font-weight: bold; color: #555; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1 style="margin:0;">📣 Feedback Requested</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="meta">
|
||||||
|
<div class="meta-item"><span class="label">Document:</span> {{document_type}}:{{document_key}}</div>
|
||||||
|
<div class="meta-item"><span class="label">Version:</span> v{{version}}</div>
|
||||||
|
<div class="meta-item"><span class="label">Deadline:</span> {{deadline}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Please review the document and provide your feedback by <strong>{{deadline}}</strong>.</p>
|
||||||
|
|
||||||
|
<p style="text-align:center; margin: 25px 0;">
|
||||||
|
<a href="{{document_url}}" class="button">View Document</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>PRD Crowdsourcing System | <a href="{{unsubscribe_url}}">Unsubscribe</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
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: `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||||
|
.header { background: #FF9800; color: white; padding: 20px; border-radius: 8px 8px 0 0; }
|
||||||
|
.content { background: #f9f9f9; padding: 20px; border: 1px solid #ddd; border-top: none; }
|
||||||
|
.footer { padding: 15px; font-size: 12px; color: #666; text-align: center; }
|
||||||
|
.button { display: inline-block; padding: 12px 24px; background: #FF9800; color: white; text-decoration: none; border-radius: 4px; }
|
||||||
|
.meta { background: #fff; padding: 15px; border-radius: 4px; margin: 15px 0; }
|
||||||
|
.meta-item { margin: 8px 0; }
|
||||||
|
.label { font-weight: bold; color: #555; }
|
||||||
|
.signoff-options { background: #fff; padding: 15px; border-radius: 4px; margin: 15px 0; }
|
||||||
|
.option { margin: 10px 0; padding: 10px; border-left: 3px solid #ddd; }
|
||||||
|
.option.approve { border-color: #4CAF50; }
|
||||||
|
.option.block { border-color: #f44336; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1 style="margin:0;">✍️ Sign-off Requested</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="meta">
|
||||||
|
<div class="meta-item"><span class="label">Document:</span> {{document_type}}:{{document_key}}</div>
|
||||||
|
<div class="meta-item"><span class="label">Version:</span> v{{version}}</div>
|
||||||
|
<div class="meta-item"><span class="label">Deadline:</span> {{deadline}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Please review the document and provide your sign-off decision by <strong>{{deadline}}</strong>.</p>
|
||||||
|
|
||||||
|
<div class="signoff-options">
|
||||||
|
<h3>Sign-off Options:</h3>
|
||||||
|
<div class="option approve">
|
||||||
|
<strong>✅ Approve</strong> - Sign off without concerns
|
||||||
|
</div>
|
||||||
|
<div class="option approve">
|
||||||
|
<strong>✅📝 Approve with Note</strong> - Sign off with a minor note
|
||||||
|
</div>
|
||||||
|
<div class="option block">
|
||||||
|
<strong>🚫 Block</strong> - Cannot approve, has blocking concern
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="text-align:center; margin: 25px 0;">
|
||||||
|
<a href="{{document_url}}" class="button">Review & Sign Off</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>PRD Crowdsourcing System | <a href="{{unsubscribe_url}}">Unsubscribe</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
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: `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||||
|
.header { background: #4CAF50; color: white; padding: 20px; border-radius: 8px 8px 0 0; }
|
||||||
|
.content { background: #f9f9f9; padding: 20px; border: 1px solid #ddd; border-top: none; }
|
||||||
|
.footer { padding: 15px; font-size: 12px; color: #666; text-align: center; }
|
||||||
|
.button { display: inline-block; padding: 12px 24px; background: #4CAF50; color: white; text-decoration: none; border-radius: 4px; }
|
||||||
|
.meta { background: #fff; padding: 15px; border-radius: 4px; margin: 15px 0; }
|
||||||
|
.meta-item { margin: 8px 0; }
|
||||||
|
.label { font-weight: bold; color: #555; }
|
||||||
|
.celebration { text-align: center; font-size: 48px; margin: 20px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1 style="margin:0;">✅ Document Approved!</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="celebration">🎉</div>
|
||||||
|
|
||||||
|
<div class="meta">
|
||||||
|
<div class="meta-item"><span class="label">Document:</span> {{document_type}}:{{document_key}}</div>
|
||||||
|
<div class="meta-item"><span class="label">Title:</span> {{title}}</div>
|
||||||
|
<div class="meta-item"><span class="label">Final Version:</span> v{{version}}</div>
|
||||||
|
<div class="meta-item"><span class="label">Approvals:</span> {{approval_count}}/{{stakeholder_count}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>All required sign-offs have been received. This document is now <strong>approved</strong> and ready for implementation!</p>
|
||||||
|
|
||||||
|
<p style="text-align:center; margin: 25px 0;">
|
||||||
|
<a href="{{document_url}}" class="button">View Approved Document</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>PRD Crowdsourcing System | <a href="{{unsubscribe_url}}">Unsubscribe</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
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: `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||||
|
.header { background: #f44336; color: white; padding: 20px; border-radius: 8px 8px 0 0; }
|
||||||
|
.content { background: #f9f9f9; padding: 20px; border: 1px solid #ddd; border-top: none; }
|
||||||
|
.footer { padding: 15px; font-size: 12px; color: #666; text-align: center; }
|
||||||
|
.button { display: inline-block; padding: 12px 24px; background: #f44336; color: white; text-decoration: none; border-radius: 4px; }
|
||||||
|
.meta { background: #fff; padding: 15px; border-radius: 4px; margin: 15px 0; }
|
||||||
|
.meta-item { margin: 8px 0; }
|
||||||
|
.label { font-weight: bold; color: #555; }
|
||||||
|
.reason { background: #ffebee; padding: 15px; border-radius: 4px; border-left: 4px solid #f44336; margin: 15px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1 style="margin:0;">🚫 Document Blocked</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="meta">
|
||||||
|
<div class="meta-item"><span class="label">Document:</span> {{document_type}}:{{document_key}}</div>
|
||||||
|
<div class="meta-item"><span class="label">Blocked by:</span> {{user}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="reason">
|
||||||
|
<strong>Blocking Reason:</strong>
|
||||||
|
<p>{{reason}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>⚠️ This blocking concern must be resolved before the document can be approved.</p>
|
||||||
|
|
||||||
|
<p style="text-align:center; margin: 25px 0;">
|
||||||
|
<a href="{{feedback_url}}" class="button">View Blocking Issue</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>PRD Crowdsourcing System | <a href="{{unsubscribe_url}}">Unsubscribe</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
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: `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||||
|
.header { background: #FFC107; color: #333; padding: 20px; border-radius: 8px 8px 0 0; }
|
||||||
|
.content { background: #f9f9f9; padding: 20px; border: 1px solid #ddd; border-top: none; }
|
||||||
|
.footer { padding: 15px; font-size: 12px; color: #666; text-align: center; }
|
||||||
|
.button { display: inline-block; padding: 12px 24px; background: #FFC107; color: #333; text-decoration: none; border-radius: 4px; }
|
||||||
|
.meta { background: #fff; padding: 15px; border-radius: 4px; margin: 15px 0; }
|
||||||
|
.meta-item { margin: 8px 0; }
|
||||||
|
.label { font-weight: bold; color: #555; }
|
||||||
|
.urgency { background: #fff3cd; padding: 10px 15px; border-radius: 4px; border-left: 4px solid #FFC107; margin: 15px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1 style="margin:0;">⏰ Reminder: Action Needed</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="urgency">
|
||||||
|
<strong>{{time_remaining}}</strong> remaining until deadline
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meta">
|
||||||
|
<div class="meta-item"><span class="label">Document:</span> {{document_type}}:{{document_key}}</div>
|
||||||
|
<div class="meta-item"><span class="label">Action:</span> {{action_needed}}</div>
|
||||||
|
<div class="meta-item"><span class="label">Deadline:</span> {{deadline}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Please complete your {{action_needed}} by <strong>{{deadline}}</strong>.</p>
|
||||||
|
|
||||||
|
<p style="text-align:center; margin: 25px 0;">
|
||||||
|
<a href="{{document_url}}" class="button">Take Action</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>PRD Crowdsourcing System | <a href="{{unsubscribe_url}}">Unsubscribe</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
@ -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."
|
- "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
|
default: false
|
||||||
result: "{value}"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,600 @@
|
||||||
|
# Create Epic Draft - From PRD to Implementation-Ready Epics
|
||||||
|
|
||||||
|
<critical>The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml</critical>
|
||||||
|
<critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical>
|
||||||
|
|
||||||
|
<workflow>
|
||||||
|
|
||||||
|
<step n="0" goal="Pre-Flight Checks">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📦 CREATE EPIC FROM PRD
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__get_me()</action>
|
||||||
|
<action>current_user = response.login</action>
|
||||||
|
|
||||||
|
<check if="API call fails">
|
||||||
|
<output>❌ GitHub MCP not accessible</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="1" goal="Select Source PRD">
|
||||||
|
<check if="source_prd is empty">
|
||||||
|
<substep n="1a" title="List approved PRDs">
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:type:prd-review label:review-status:approved is:closed"
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<check if="response.items.length == 0">
|
||||||
|
<output>
|
||||||
|
❌ No approved PRDs found.
|
||||||
|
|
||||||
|
You need an approved PRD to create epics from.
|
||||||
|
Use the PRD Dashboard [PD] to see PRD status.
|
||||||
|
</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
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)
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📄 APPROVED PRDs AVAILABLE
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
{{#each approved_prds}}
|
||||||
|
[{{@index + 1}}] prd:{{key}} - {{title}}
|
||||||
|
Approved: {{approved_date}}
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
</output>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<ask>Select PRD to create epic from (1-{{approved_prds.length}}):</ask>
|
||||||
|
<action>source_prd = approved_prds[parseInt(response) - 1].key</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
📄 Source PRD: prd:{{source_prd}}
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="2" goal="Load PRD Document">
|
||||||
|
<action>prd_path = `${docs_dir}/prd/${source_prd}.md`</action>
|
||||||
|
<action>Read prd_path</action>
|
||||||
|
|
||||||
|
<check if="file not found">
|
||||||
|
<output>❌ PRD document not found: {{prd_path}}</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>prd_content = file_content</action>
|
||||||
|
<action>
|
||||||
|
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)
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📄 PRD SUMMARY
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
**Title:** {{prd_title}}
|
||||||
|
**Version:** v{{prd_version}}
|
||||||
|
**User Stories:** {{user_stories.length}}
|
||||||
|
**Functional Requirements:** {{functional_reqs.length}}
|
||||||
|
**Non-Functional Requirements:** {{nfrs.length}}
|
||||||
|
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="3" goal="Check Existing Epics">
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:type:epic-review label:source-prd:{{source_prd}}"
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<check if="response.items.length > 0">
|
||||||
|
<action>
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
⚠️ 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
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Choice:</ask>
|
||||||
|
<check if="response == '2' OR response == '3'">
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="4" goal="Epic Configuration">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
⚙️ EPIC CONFIGURATION
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<substep n="4a" title="Select User Stories for Epic">
|
||||||
|
<output>
|
||||||
|
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':
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Stories to include:</ask>
|
||||||
|
<action>
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Selected {{selected_stories.length}} user stories for this epic.
|
||||||
|
</output>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="4b" title="Epic title and key">
|
||||||
|
<action>
|
||||||
|
// 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}`
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
**Suggested Epic Key:** {{suggested_key}}
|
||||||
|
**Suggested Title:** {{suggested_title}}
|
||||||
|
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Epic title (or press Enter for suggested):</ask>
|
||||||
|
<action>epic_title = response || suggested_title</action>
|
||||||
|
|
||||||
|
<check if="epic_key is empty">
|
||||||
|
<ask>Epic key (or press Enter for suggested):</ask>
|
||||||
|
<action>epic_key = response || suggested_key</action>
|
||||||
|
</check>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="4c" title="Epic stakeholders">
|
||||||
|
<output>
|
||||||
|
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)
|
||||||
|
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<check if="stakeholders.length == 0">
|
||||||
|
<ask>Epic stakeholders (comma-separated usernames, or 'same' for PRD stakeholders):</ask>
|
||||||
|
<action>
|
||||||
|
if (response.toLowerCase() === 'same') {
|
||||||
|
stakeholders = stakeholders_from_prd
|
||||||
|
} else {
|
||||||
|
stakeholders = response.split(',').map(s => s.trim().replace('@', ''))
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
</check>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="5" goal="Generate Story Breakdown">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🤖 GENERATING STORY BREAKDOWN
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
Analyzing selected user stories and generating implementation stories...
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
// 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)
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📝 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
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Choice:</ask>
|
||||||
|
<check if="response == '2'">
|
||||||
|
<ask>Describe your modifications:</ask>
|
||||||
|
<action>
|
||||||
|
// 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)
|
||||||
|
</action>
|
||||||
|
<output>
|
||||||
|
Updated story breakdown:
|
||||||
|
|
||||||
|
{{generated_stories}}
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="response == '3'">
|
||||||
|
<ask>What approach should be used?</ask>
|
||||||
|
<action>
|
||||||
|
approach_prompt = `${prompt}\n\nApproach to use:\n${response}\n\nPlease regenerate using this approach.`
|
||||||
|
generated_stories = await llm_generate(approach_prompt)
|
||||||
|
</action>
|
||||||
|
<output>
|
||||||
|
Regenerated story breakdown:
|
||||||
|
|
||||||
|
{{generated_stories}}
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="response == '4'">
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="6" goal="Create Epic Document">
|
||||||
|
<action>
|
||||||
|
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
|
||||||
|
|
||||||
|
<!-- List any dependencies on other epics, external systems, or teams -->
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Considerations
|
||||||
|
|
||||||
|
<!-- Notes on architecture, performance, security, etc. -->
|
||||||
|
|
||||||
|
### From NFRs:
|
||||||
|
${nfrs.map(nfr => `- ${nfr.title}`).join('\n')}
|
||||||
|
|
||||||
|
### Constraints:
|
||||||
|
${constraints.map(c => `- ${c}`).join('\n')}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
<!-- What is explicitly NOT included in this epic -->
|
||||||
|
|
||||||
|
- 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')}
|
||||||
|
`
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>epic_path = `${docs_dir}/epics/epic-${epic_key}.md`</action>
|
||||||
|
<action>Write epic_doc to epic_path</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
✅ Epic document created: {{epic_path}}
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="7" goal="Create GitHub Review Issue">
|
||||||
|
<action>
|
||||||
|
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._`
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>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']
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<action>review_issue = response</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
✅ Review issue created: #{{review_issue.number}}
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="8" goal="Summary">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
✅ 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
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
</workflow>
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,432 @@
|
||||||
|
# Create PRD Draft - Start Async Requirements Collaboration
|
||||||
|
|
||||||
|
<critical>The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml</critical>
|
||||||
|
<critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical>
|
||||||
|
|
||||||
|
<workflow>
|
||||||
|
|
||||||
|
<step n="0" goal="Pre-Flight Checks">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📝 CREATE PRD DRAFT
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__get_me()</action>
|
||||||
|
<action>current_user = response.login</action>
|
||||||
|
|
||||||
|
<check if="API call fails">
|
||||||
|
<output>❌ GitHub MCP not accessible - required for PRD coordination</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="1" goal="Choose Creation Method">
|
||||||
|
<output>
|
||||||
|
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)
|
||||||
|
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Choice (1-4):</ask>
|
||||||
|
<action>creation_method = choice</action>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="2" goal="Gather Basic Information">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📋 BASIC INFORMATION
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>PRD Title (e.g., "User Authentication System"):</ask>
|
||||||
|
<action>prd_title = response</action>
|
||||||
|
|
||||||
|
<ask>PRD Key (short identifier, e.g., "user-auth", no spaces):</ask>
|
||||||
|
<action>prd_key = response.toLowerCase().replace(/\s+/g, '-')</action>
|
||||||
|
|
||||||
|
<substep n="2a" title="Check for existing PRD">
|
||||||
|
<action>Check if file exists at: {{docs_dir}}/{{prd_key}}.md</action>
|
||||||
|
<check if="file exists">
|
||||||
|
<output>
|
||||||
|
⚠️ 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
|
||||||
|
</output>
|
||||||
|
<ask>Choice:</ask>
|
||||||
|
<check if="choice == 1">
|
||||||
|
<action>Goto step 2</action>
|
||||||
|
</check>
|
||||||
|
<check if="choice == 2">
|
||||||
|
<action>is_new_version = true</action>
|
||||||
|
<action>Load existing PRD and increment version</action>
|
||||||
|
</check>
|
||||||
|
<check if="choice == 3">
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
</check>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="3" goal="Gather Content (Method-Dependent)">
|
||||||
|
<check if="creation_method == 1">
|
||||||
|
<substep n="3a" title="Guided prompts - Vision">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📌 VISION & PROBLEM
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>What is the vision for this product/feature? (1-2 sentences)</ask>
|
||||||
|
<action>vision = response</action>
|
||||||
|
|
||||||
|
<ask>What problem does this solve? What pain points does it address?</ask>
|
||||||
|
<action>problem_statement = response</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="3b" title="Guided prompts - Goals">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🎯 GOALS
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>List the primary goals (one per line, or comma-separated):</ask>
|
||||||
|
<action>goals = parse_list(response)</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="3c" title="Guided prompts - User Stories">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
👤 USER STORIES
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
Enter user stories in format: "As a [role], I want [capability], so that [benefit]"
|
||||||
|
(Enter empty line when done)
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>user_stories = []</action>
|
||||||
|
<loop>
|
||||||
|
<ask>User Story (or press Enter to finish):</ask>
|
||||||
|
<check if="response is empty">
|
||||||
|
<action>break loop</action>
|
||||||
|
</check>
|
||||||
|
<action>user_stories.push(response)</action>
|
||||||
|
</loop>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="3d" title="Guided prompts - Requirements">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📋 FUNCTIONAL REQUIREMENTS
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
List key functional requirements (one per line, or press Enter to skip for now):
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Functional Requirements:</ask>
|
||||||
|
<action>functional_reqs = parse_list(response)</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
⚙️ NON-FUNCTIONAL REQUIREMENTS
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
List non-functional requirements (performance, security, etc.):
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Non-Functional Requirements:</ask>
|
||||||
|
<action>non_functional_reqs = parse_list(response)</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="3e" title="Guided prompts - Constraints & Out of Scope">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🚫 CONSTRAINTS & OUT OF SCOPE
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>What constraints apply? (technical, timeline, budget, etc.):</ask>
|
||||||
|
<action>constraints = parse_list(response)</action>
|
||||||
|
|
||||||
|
<ask>What is explicitly out of scope for this PRD?:</ask>
|
||||||
|
<action>out_of_scope = parse_list(response)</action>
|
||||||
|
</substep>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="creation_method == 2">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📥 IMPORT FROM BMAD PRD WORKFLOW
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
Looking for existing PRD output in project...
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>Glob for: **/prd-output.md, **/prd-*.md in project</action>
|
||||||
|
<action>List found files for user selection</action>
|
||||||
|
|
||||||
|
<ask>Select file number to import (or path):</ask>
|
||||||
|
<action>import_path = response</action>
|
||||||
|
<action>Read and parse imported PRD content</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="creation_method == 3">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📥 IMPORT FROM PRODUCT BRIEF
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Path to product brief document:</ask>
|
||||||
|
<action>brief_path = response</action>
|
||||||
|
<action>Read product brief and extract PRD sections using LLM</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="creation_method == 4">
|
||||||
|
<action>
|
||||||
|
// 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 = []
|
||||||
|
</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="4" goal="Define Stakeholders">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
👥 STAKEHOLDERS
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
Who should review this PRD and provide feedback?
|
||||||
|
Enter GitHub usernames (one per line, with or without @):
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>stakeholders = [current_user]</action>
|
||||||
|
<loop>
|
||||||
|
<ask>Stakeholder username (or press Enter to finish):</ask>
|
||||||
|
<check if="response is empty">
|
||||||
|
<action>break loop</action>
|
||||||
|
</check>
|
||||||
|
<action>
|
||||||
|
username = response.replace('@', '')
|
||||||
|
if (!stakeholders.includes(username)) {
|
||||||
|
stakeholders.push(username)
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
</loop>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Stakeholders: {{stakeholders.map(s => '@' + s).join(', ')}}
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="5" goal="Generate PRD Markdown">
|
||||||
|
<action>
|
||||||
|
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')}
|
||||||
|
`
|
||||||
|
</action>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="6" goal="Save PRD and Update Cache">
|
||||||
|
<substep n="6a" title="Ensure docs directory exists">
|
||||||
|
<action>Ensure directory exists: {{docs_dir}}</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="6b" title="Write PRD file">
|
||||||
|
<action>prd_path = {{docs_dir}}/{{prd_key}}.md</action>
|
||||||
|
<action>Write prd_content to prd_path</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="6c" title="Update local cache">
|
||||||
|
<action>
|
||||||
|
// Using CacheManager
|
||||||
|
cacheManager.writePrd(prd_key, prd_content, {
|
||||||
|
version: 1,
|
||||||
|
status: 'draft',
|
||||||
|
stakeholders: stakeholders,
|
||||||
|
owner: current_user
|
||||||
|
})
|
||||||
|
</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
✅ 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.
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="7" goal="Next Steps">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📌 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
|
||||||
|
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Choice:</ask>
|
||||||
|
|
||||||
|
<check if="choice == 1">
|
||||||
|
<output>
|
||||||
|
Opening feedback round for prd:{{prd_key}}...
|
||||||
|
</output>
|
||||||
|
<action>Load workflow: open-feedback-round with prd_key = prd_key</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 2">
|
||||||
|
<output>
|
||||||
|
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
|
||||||
|
</output>
|
||||||
|
<action>Exit</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 3">
|
||||||
|
<action>Read and display prd_path</action>
|
||||||
|
<action>Goto step 7</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 4">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
PRD draft saved. Ready for feedback when you are!
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
<action>Exit</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
</workflow>
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,508 @@
|
||||||
|
# Epic Dashboard - Central Epic Visibility with PRD Lineage
|
||||||
|
|
||||||
|
<critical>The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml</critical>
|
||||||
|
<critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical>
|
||||||
|
|
||||||
|
<workflow>
|
||||||
|
|
||||||
|
<step n="0" goal="Pre-Flight Checks">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📦 EPIC DASHBOARD
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__get_me()</action>
|
||||||
|
<action>current_user = response.login</action>
|
||||||
|
|
||||||
|
<check if="API call fails">
|
||||||
|
<output>❌ GitHub MCP not accessible</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="1" goal="Fetch All Epic Data">
|
||||||
|
<output>
|
||||||
|
Loading epic data from GitHub...
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<substep n="1a" title="Fetch epic review issues">
|
||||||
|
<action>
|
||||||
|
query = "repo:{{github_owner}}/{{github_repo}} label:type:epic-review"
|
||||||
|
if (source_prd) {
|
||||||
|
query += ` label:source-prd:${source_prd}`
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: query
|
||||||
|
})</action>
|
||||||
|
<action>review_issues = response.items || []</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="1b" title="Fetch epic feedback">
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:type:epic-feedback is:open"
|
||||||
|
})</action>
|
||||||
|
<action>feedback_issues = response.items || []</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="1c" title="Process epic data">
|
||||||
|
<action>
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
</action>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="2" goal="Display Portfolio Overview">
|
||||||
|
<check if="epic_key is empty">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📦 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}}
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>Goto step 4 (interactive menu)</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="3" goal="Display Epic Detail View">
|
||||||
|
<check if="epic_key is not empty">
|
||||||
|
<action>epic = epics[epic_key]</action>
|
||||||
|
|
||||||
|
<check if="!epic">
|
||||||
|
<output>
|
||||||
|
❌ Epic not found: epic:{{epic_key}}
|
||||||
|
</output>
|
||||||
|
<action>epic_key = ''</action>
|
||||||
|
<action>Goto step 2</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📦 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}}
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="4" goal="Interactive Menu">
|
||||||
|
<output>
|
||||||
|
**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
|
||||||
|
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Choice:</ask>
|
||||||
|
|
||||||
|
<check if="choice is number AND choice <= epic_list.length">
|
||||||
|
<action>selected_epic = epic_list[parseInt(choice) - 1]</action>
|
||||||
|
<action>epic_key = selected_epic.key</action>
|
||||||
|
<action>Goto step 3</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'C'">
|
||||||
|
<action>Load workflow: create-epic-draft</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'F'">
|
||||||
|
<ask>Enter epic key:</ask>
|
||||||
|
<action>Load workflow: view-feedback with document_key = response, document_type = 'epic'</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'S'">
|
||||||
|
<ask>Enter epic key:</ask>
|
||||||
|
<action>Load workflow: synthesize-epic-feedback with epic_key = response</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'P'">
|
||||||
|
<ask>Enter PRD key to filter by (or 'all'):</ask>
|
||||||
|
<action>source_prd = (response.toLowerCase() === 'all') ? '' : response</action>
|
||||||
|
<action>Goto step 1</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'R'">
|
||||||
|
<action>Goto step 1</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'B'">
|
||||||
|
<action>epic_key = ''</action>
|
||||||
|
<action>Goto step 2</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'Q'">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
Epic Dashboard closed.
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
<action>Exit</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>Goto step 4</action>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
</workflow>
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,336 @@
|
||||||
|
# My Tasks - Unified Inbox for PRD & Epic Collaboration
|
||||||
|
|
||||||
|
<critical>The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml</critical>
|
||||||
|
<critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical>
|
||||||
|
|
||||||
|
<workflow>
|
||||||
|
|
||||||
|
<step n="0" goal="Pre-Flight Checks">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📋 MY TASKS
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__get_me()</action>
|
||||||
|
<action>current_user = response.login</action>
|
||||||
|
|
||||||
|
<check if="API call fails">
|
||||||
|
<output>❌ GitHub MCP not accessible - cannot fetch tasks</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Checking tasks for @{{current_user}}...
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="1" goal="Fetch PRD Tasks">
|
||||||
|
<substep n="1a" title="Query PRDs awaiting feedback">
|
||||||
|
<action>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"
|
||||||
|
})</action>
|
||||||
|
<action>prd_feedback_issues = response.items || []</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="1b" title="Query PRDs awaiting your sign-off">
|
||||||
|
<action>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"
|
||||||
|
})</action>
|
||||||
|
<action>prd_signoff_issues = response.items || []</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="1c" title="Alternative: Query by stakeholder label if no assignee">
|
||||||
|
<check if="prd_feedback_issues.length == 0 AND prd_signoff_issues.length == 0">
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} type:issue label:type:prd-review is:open mentions:{{current_user}}"
|
||||||
|
})</action>
|
||||||
|
<action>prd_mentioned_issues = response.items || []</action>
|
||||||
|
<action>
|
||||||
|
// 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')
|
||||||
|
)
|
||||||
|
</action>
|
||||||
|
</check>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="2" goal="Fetch Epic Tasks">
|
||||||
|
<substep n="2a" title="Query Epics awaiting feedback">
|
||||||
|
<action>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"
|
||||||
|
})</action>
|
||||||
|
<action>epic_feedback_issues = response.items || []</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="2b" title="Alternative: Query by mentions">
|
||||||
|
<check if="epic_feedback_issues.length == 0">
|
||||||
|
<action>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}}"
|
||||||
|
})</action>
|
||||||
|
<action>epic_feedback_issues = response.items || []</action>
|
||||||
|
</check>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="3" goal="Calculate Urgency">
|
||||||
|
<action>
|
||||||
|
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)
|
||||||
|
</action>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="4" goal="Display Task List">
|
||||||
|
<check if="all_tasks.length == 0">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
✅ 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
|
||||||
|
</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📋 MY TASKS - @{{current_user}}
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<check if="urgent_tasks.length > 0">
|
||||||
|
<output>
|
||||||
|
🔴 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}}
|
||||||
|
└──────────────────┴────────────────────┴───────────────┘
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="pending_tasks.length > 0">
|
||||||
|
<output>
|
||||||
|
📋 PENDING
|
||||||
|
┌──────────────────┬────────────────────┬───────────────┐
|
||||||
|
│ Document │ Action Needed │ Deadline │
|
||||||
|
├──────────────────┼────────────────────┼───────────────┤
|
||||||
|
{{#each pending_tasks}}
|
||||||
|
│ {{pad_right document_key 16}} │ {{pad_right action 18}} │ {{format_deadline deadline days_remaining}} │
|
||||||
|
{{/each}}
|
||||||
|
└──────────────────┴────────────────────┴───────────────┘
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="no_deadline_tasks.length > 0">
|
||||||
|
<output>
|
||||||
|
📝 NO DEADLINE SET
|
||||||
|
{{#each no_deadline_tasks}}
|
||||||
|
• {{document_key}}: {{action}}
|
||||||
|
{{/each}}
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="5" goal="Quick Actions Menu">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Choice (number or letter):</ask>
|
||||||
|
|
||||||
|
<check if="choice is number AND choice <= all_tasks.length">
|
||||||
|
<action>selected_task = all_tasks[parseInt(choice) - 1]</action>
|
||||||
|
|
||||||
|
<check if="selected_task.type == 'prd-feedback'">
|
||||||
|
<output>
|
||||||
|
Opening feedback submission for PRD: {{selected_task.prd_key}}
|
||||||
|
</output>
|
||||||
|
<action>Load workflow: submit-feedback with document_key = selected_task.prd_key, document_type = 'prd'</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="selected_task.type == 'prd-signoff'">
|
||||||
|
<output>
|
||||||
|
Opening sign-off for PRD: {{selected_task.prd_key}}
|
||||||
|
</output>
|
||||||
|
<action>Load workflow: submit-signoff with document_key = selected_task.prd_key, document_type = 'prd'</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="selected_task.type == 'epic-feedback'">
|
||||||
|
<output>
|
||||||
|
Opening feedback submission for Epic: {{selected_task.epic_key}}
|
||||||
|
</output>
|
||||||
|
<action>Load workflow: submit-feedback with document_key = selected_task.epic_key, document_type = 'epic'</action>
|
||||||
|
</check>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'PD'">
|
||||||
|
<action>Load workflow: prd-dashboard</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'ED'">
|
||||||
|
<action>Load workflow: epic-dashboard</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'R'">
|
||||||
|
<action>Goto step 1 (refresh)</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'Q'">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
My Tasks closed.
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
<action>Exit</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
</workflow>
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,405 @@
|
||||||
|
# Open Epic Feedback - Collect Stakeholder Input on Story Breakdown
|
||||||
|
|
||||||
|
<critical>The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml</critical>
|
||||||
|
<critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical>
|
||||||
|
|
||||||
|
<workflow>
|
||||||
|
|
||||||
|
<step n="0" goal="Pre-Flight Checks">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
💬 OPEN EPIC FEEDBACK ROUND
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__get_me()</action>
|
||||||
|
<action>current_user = response.login</action>
|
||||||
|
|
||||||
|
<check if="API call fails">
|
||||||
|
<output>❌ GitHub MCP not accessible</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="1" goal="Select Epic">
|
||||||
|
<check if="epic_key is empty">
|
||||||
|
<substep n="1a" title="List draft epics">
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:type:epic-review label:review-status:draft is:open"
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<check if="response.items.length == 0">
|
||||||
|
<output>
|
||||||
|
❌ No draft epics found.
|
||||||
|
|
||||||
|
Create an epic first with: "Create epic from PRD"
|
||||||
|
</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
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)
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📦 DRAFT EPICS AVAILABLE
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
{{#each draft_epics}}
|
||||||
|
[{{@index + 1}}] epic:{{key}} - {{title}}
|
||||||
|
Source: prd:{{source_prd}} | Issue: #{{issue_number}}
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
</output>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<ask>Select epic (1-{{draft_epics.length}}):</ask>
|
||||||
|
<action>epic_key = draft_epics[parseInt(response) - 1].key</action>
|
||||||
|
<action>review_issue_number = draft_epics[parseInt(response) - 1].issue_number</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
📦 Selected: epic:{{epic_key}}
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="2" goal="Load Epic Document">
|
||||||
|
<action>epic_path = `${docs_dir}/epics/epic-${epic_key}.md`</action>
|
||||||
|
<action>Read epic_path</action>
|
||||||
|
|
||||||
|
<check if="file not found">
|
||||||
|
<output>❌ Epic document not found: {{epic_path}}</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>epic_content = file_content</action>
|
||||||
|
<action>
|
||||||
|
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)
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📦 EPIC SUMMARY
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
**Title:** {{title}}
|
||||||
|
**Version:** v{{version}}
|
||||||
|
**Source PRD:** prd:{{source_prd}}
|
||||||
|
**Stories:** {{stories.length}} implementation stories
|
||||||
|
**Stakeholders:** {{stakeholders.length}}
|
||||||
|
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="3" goal="Find or Validate Review Issue">
|
||||||
|
<check if="review_issue_number is empty">
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:type:epic-review label:epic:{{epic_key}} is:open"
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<check if="response.items.length == 0">
|
||||||
|
<output>❌ No review issue found for epic:{{epic_key}}</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>review_issue = response.items[0]</action>
|
||||||
|
<action>review_issue_number = review_issue.number</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="review_issue is empty">
|
||||||
|
<action>Call: mcp__github__issue_read({
|
||||||
|
method: 'get',
|
||||||
|
owner: github_owner,
|
||||||
|
repo: github_repo,
|
||||||
|
issue_number: review_issue_number
|
||||||
|
})</action>
|
||||||
|
<action>review_issue = response</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
📋 Review Issue: #{{review_issue_number}}
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="4" goal="Configure Feedback Round">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
⚙️ 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?
|
||||||
|
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Days until feedback deadline (default: {{feedback_days}}):</ask>
|
||||||
|
<action>
|
||||||
|
feedback_days = parseInt(response) || feedback_days
|
||||||
|
deadline = new Date()
|
||||||
|
deadline.setDate(deadline.getDate() + feedback_days)
|
||||||
|
deadline_str = deadline.toISOString().split('T')[0]
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
**Deadline:** {{deadline_str}} ({{feedback_days}} days from now)
|
||||||
|
|
||||||
|
**Stakeholders to notify:**
|
||||||
|
{{#each stakeholders}}
|
||||||
|
• @{{this}}
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Add additional stakeholders? (comma-separated or 'none'):</ask>
|
||||||
|
<action>
|
||||||
|
if (response.toLowerCase() !== 'none' && response.trim()) {
|
||||||
|
additional = response.split(',').map(s => s.trim().replace('@', ''))
|
||||||
|
stakeholders = [...new Set([...stakeholders, ...additional])]
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="5" goal="Update Epic Document">
|
||||||
|
<action>
|
||||||
|
updated_content = epic_content
|
||||||
|
.replace(/\*\*Status:\*\* .+/, '**Status:** Feedback')
|
||||||
|
.replace(/\| Feedback Deadline \| .+ \|/, `| Feedback Deadline | ${deadline_str} |`)
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>Write updated_content to epic_path</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
✅ Epic status updated to 'Feedback'
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="6" goal="Update Review Issue">
|
||||||
|
<action>
|
||||||
|
// 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'])
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__issue_write({
|
||||||
|
method: 'update',
|
||||||
|
owner: "{{github_owner}}",
|
||||||
|
repo: "{{github_repo}}",
|
||||||
|
issue_number: review_issue_number,
|
||||||
|
labels: new_labels,
|
||||||
|
assignees: stakeholders
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
✅ Review issue updated with stakeholders
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="7" goal="Post Feedback Request">
|
||||||
|
<action>
|
||||||
|
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}_`
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__add_issue_comment({
|
||||||
|
owner: "{{github_owner}}",
|
||||||
|
repo: "{{github_repo}}",
|
||||||
|
issue_number: review_issue_number,
|
||||||
|
body: feedback_comment
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
✅ Feedback request posted
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="8" goal="Summary">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
✅ 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}}"
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
</workflow>
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,406 @@
|
||||||
|
# Open Feedback Round - Start Async Stakeholder Review
|
||||||
|
|
||||||
|
<critical>The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml</critical>
|
||||||
|
<critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical>
|
||||||
|
|
||||||
|
<workflow>
|
||||||
|
|
||||||
|
<step n="0" goal="Pre-Flight Checks">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🔔 OPEN FEEDBACK ROUND
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__get_me()</action>
|
||||||
|
<action>current_user = response.login</action>
|
||||||
|
|
||||||
|
<check if="API call fails">
|
||||||
|
<output>❌ GitHub MCP not accessible - required for coordination</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="1" goal="Identify Document">
|
||||||
|
<check if="document_key is empty">
|
||||||
|
<ask>Which document? Enter key (e.g., "user-auth" for PRD, "2" for Epic):</ask>
|
||||||
|
<action>document_key = response</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="document_type is empty OR document_type == 'prd'">
|
||||||
|
<action>doc_path = {{docs_dir}}/prd/{{document_key}}.md</action>
|
||||||
|
<action>document_type = 'prd'</action>
|
||||||
|
<action>doc_prefix = 'PRD'</action>
|
||||||
|
<action>doc_label_prefix = 'prd'</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="document_type == 'epic'">
|
||||||
|
<action>doc_path = {{docs_dir}}/epics/epic-{{document_key}}.md</action>
|
||||||
|
<action>doc_prefix = 'Epic'</action>
|
||||||
|
<action>doc_label_prefix = 'epic'</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<substep n="1a" title="Load document">
|
||||||
|
<action>Read doc_path</action>
|
||||||
|
<check if="file not found">
|
||||||
|
<output>
|
||||||
|
❌ 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
|
||||||
|
</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
<action>doc_content = file_content</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="1b" title="Extract metadata from document">
|
||||||
|
<action>
|
||||||
|
// 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('@', '')
|
||||||
|
</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<check if="status != 'draft' AND status != 'feedback'">
|
||||||
|
<output>
|
||||||
|
⚠️ 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.
|
||||||
|
</output>
|
||||||
|
<ask>Continue anyway? (y/n):</ask>
|
||||||
|
<check if="response != 'y'">
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
📄 Document: {{title}}
|
||||||
|
📌 Key: {{doc_label_prefix}}:{{document_key}}
|
||||||
|
📊 Version: {{version}}
|
||||||
|
👥 Stakeholders: {{stakeholders.length}}
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="2" goal="Set Feedback Deadline">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📅 FEEDBACK DEADLINE
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
How long should stakeholders have to provide feedback?
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Days until deadline (default: 5):</ask>
|
||||||
|
<action>
|
||||||
|
days = parseInt(response) || 5
|
||||||
|
deadline = new Date()
|
||||||
|
deadline.setDate(deadline.getDate() + days)
|
||||||
|
deadline_str = deadline.toISOString().split('T')[0]
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Deadline: {{deadline_str}} ({{days}} days from now)
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="3" goal="Create GitHub Review Issue">
|
||||||
|
<action>
|
||||||
|
// 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]}_
|
||||||
|
`
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<substep n="3a" title="Check for existing open review">
|
||||||
|
<action>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"
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<check if="response.items.length > 0">
|
||||||
|
<output>
|
||||||
|
⚠️ 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
|
||||||
|
</output>
|
||||||
|
<ask>Choice:</ask>
|
||||||
|
<check if="choice == 1">
|
||||||
|
<action>review_issue = response.items[0]</action>
|
||||||
|
<action>Goto step 4 (skip issue creation)</action>
|
||||||
|
</check>
|
||||||
|
<check if="choice == 2">
|
||||||
|
<action>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'
|
||||||
|
})</action>
|
||||||
|
</check>
|
||||||
|
<check if="choice == 3">
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
</check>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="3b" title="Create review issue">
|
||||||
|
<action>
|
||||||
|
labels = [
|
||||||
|
`type:${doc_label_prefix}-review`,
|
||||||
|
`${doc_label_prefix}:${document_key}`,
|
||||||
|
`version:${version}`,
|
||||||
|
'review-status:open'
|
||||||
|
]
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>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('@', ''))
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<action>review_issue = response</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
✅ Review issue created: #{{review_issue.number}}
|
||||||
|
{{review_issue.html_url}}
|
||||||
|
</output>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="4" goal="Update Document Status">
|
||||||
|
<substep n="4a" title="Update status in document">
|
||||||
|
<action>
|
||||||
|
// Update the Status field in the document
|
||||||
|
updated_content = doc_content
|
||||||
|
.replace(/\*\*Status:\*\* .+/, '**Status:** Feedback')
|
||||||
|
.replace(/\| Feedback Deadline \| .+ \|/, `| Feedback Deadline | ${deadline_str} |`)
|
||||||
|
</action>
|
||||||
|
<action>Write updated_content to doc_path</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="4b" title="Update cache">
|
||||||
|
<action>
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="5" goal="Notify Stakeholders">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📨 STAKEHOLDER NOTIFICATION
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<substep n="5a" title="Create notification comment">
|
||||||
|
<action>
|
||||||
|
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! 🙏`
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__add_issue_comment({
|
||||||
|
owner: "{{github_owner}}",
|
||||||
|
repo: "{{github_repo}}",
|
||||||
|
issue_number: review_issue.number,
|
||||||
|
body: notification
|
||||||
|
})</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
✅ Stakeholders notified via GitHub @mentions
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="6" goal="Summary">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
✅ 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
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
</workflow>
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,392 @@
|
||||||
|
# PRD Dashboard - Central Visibility Hub
|
||||||
|
|
||||||
|
<critical>The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml</critical>
|
||||||
|
<critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical>
|
||||||
|
|
||||||
|
<workflow>
|
||||||
|
|
||||||
|
<step n="0" goal="Pre-Flight Checks">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📊 PRD DASHBOARD
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__get_me()</action>
|
||||||
|
<action>current_user = response.login</action>
|
||||||
|
|
||||||
|
<check if="API call fails">
|
||||||
|
<output>❌ GitHub MCP not accessible</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="1" goal="Fetch All PRD Data">
|
||||||
|
<output>
|
||||||
|
Loading PRD data from GitHub...
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<substep n="1a" title="Fetch PRD review issues">
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:type:prd-review"
|
||||||
|
})</action>
|
||||||
|
<action>review_issues = response.items || []</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="1b" title="Fetch PRD feedback">
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:type:prd-feedback is:open"
|
||||||
|
})</action>
|
||||||
|
<action>feedback_issues = response.items || []</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="1c" title="Process PRD data">
|
||||||
|
<action>
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
</action>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="2" goal="Display Portfolio Overview">
|
||||||
|
<check if="prd_key is empty">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📊 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}}
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>Goto step 4 (interactive menu)</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="3" goal="Display PRD Detail View">
|
||||||
|
<check if="prd_key is not empty">
|
||||||
|
<action>prd = prds[prd_key]</action>
|
||||||
|
|
||||||
|
<check if="!prd">
|
||||||
|
<output>
|
||||||
|
❌ PRD not found: prd:{{prd_key}}
|
||||||
|
</output>
|
||||||
|
<action>prd_key = ''</action>
|
||||||
|
<action>Goto step 2</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
// 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📋 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}}
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="4" goal="Interactive Menu">
|
||||||
|
<output>
|
||||||
|
**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
|
||||||
|
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Choice:</ask>
|
||||||
|
|
||||||
|
<check if="choice is number AND choice <= prd_list.length">
|
||||||
|
<action>selected_prd = prd_list[parseInt(choice) - 1]</action>
|
||||||
|
<action>prd_key = selected_prd.key</action>
|
||||||
|
<action>Goto step 3</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'C'">
|
||||||
|
<action>Load workflow: create-prd-draft</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'F'">
|
||||||
|
<ask>Enter PRD key:</ask>
|
||||||
|
<action>Load workflow: view-feedback with document_key = response, document_type = 'prd'</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'S'">
|
||||||
|
<ask>Enter PRD key:</ask>
|
||||||
|
<action>Load workflow: synthesize-feedback with document_key = response, document_type = 'prd'</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'R'">
|
||||||
|
<action>Goto step 1</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'B'">
|
||||||
|
<action>prd_key = ''</action>
|
||||||
|
<action>Goto step 2</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'Q'">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
PRD Dashboard closed.
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
<action>Exit</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>Goto step 4</action>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
</workflow>
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,343 @@
|
||||||
|
# Request Sign-off - Final Stakeholder Approval
|
||||||
|
|
||||||
|
<critical>The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml</critical>
|
||||||
|
<critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical>
|
||||||
|
|
||||||
|
<workflow>
|
||||||
|
|
||||||
|
<step n="0" goal="Pre-Flight Checks">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
✍️ REQUEST SIGN-OFF
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__get_me()</action>
|
||||||
|
<action>current_user = response.login</action>
|
||||||
|
|
||||||
|
<check if="API call fails">
|
||||||
|
<output>❌ GitHub MCP not accessible</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="1" goal="Identify Document">
|
||||||
|
<check if="document_key is empty">
|
||||||
|
<ask>Which document needs sign-off? Enter key:</ask>
|
||||||
|
<action>document_key = response</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>Read doc_path</action>
|
||||||
|
<check if="file not found">
|
||||||
|
<output>❌ Document not found: {{doc_path}}</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
<action>doc_content = file_content</action>
|
||||||
|
<action>
|
||||||
|
title = extract_title(doc_content)
|
||||||
|
version = extract_version(doc_content)
|
||||||
|
stakeholders = extract_stakeholders(doc_content)
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
📄 Document: {{title}} v{{version}}
|
||||||
|
👥 Stakeholders: {{stakeholders.length}}
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="2" goal="Configure Sign-off Requirements">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
⚙️ 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
|
||||||
|
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Choice (1-3):</ask>
|
||||||
|
<action>threshold_type = choice</action>
|
||||||
|
|
||||||
|
<check if="threshold_type == 1">
|
||||||
|
<ask>Minimum approvals needed (out of {{stakeholders.length}}):</ask>
|
||||||
|
<action>
|
||||||
|
signoff_config = {
|
||||||
|
threshold_type: 'count',
|
||||||
|
minimum_approvals: parseInt(response),
|
||||||
|
allow_blocks: true,
|
||||||
|
block_threshold: 1
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="threshold_type == 2">
|
||||||
|
<ask>Percentage required (e.g., 66 for 66%):</ask>
|
||||||
|
<action>
|
||||||
|
signoff_config = {
|
||||||
|
threshold_type: 'percentage',
|
||||||
|
approval_percentage: parseInt(response),
|
||||||
|
allow_blocks: true,
|
||||||
|
block_threshold: 1
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="threshold_type == 3">
|
||||||
|
<output>
|
||||||
|
Current stakeholders: {{stakeholders.map(s => '@' + s).join(', ')}}
|
||||||
|
|
||||||
|
Enter REQUIRED approvers (must all approve):
|
||||||
|
</output>
|
||||||
|
<ask>Required approvers (comma-separated usernames):</ask>
|
||||||
|
<action>required_approvers = response.split(',').map(s => s.trim().replace('@', ''))</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Remaining stakeholders can be optional.
|
||||||
|
</output>
|
||||||
|
<ask>Minimum optional approvers needed:</ask>
|
||||||
|
<action>
|
||||||
|
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
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="3" goal="Set Sign-off Deadline">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📅 SIGN-OFF DEADLINE
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Days until sign-off deadline (default: 3):</ask>
|
||||||
|
<action>
|
||||||
|
days = parseInt(response) || 3
|
||||||
|
deadline = new Date()
|
||||||
|
deadline.setDate(deadline.getDate() + days)
|
||||||
|
deadline_str = deadline.toISOString().split('T')[0]
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Deadline: {{deadline_str}} ({{days}} days from now)
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="4" goal="Find or Create Review Issue">
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:{{review_label}} label:{{doc_label}} is:open"
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<check if="response.items.length > 0">
|
||||||
|
<action>review_issue = response.items[0]</action>
|
||||||
|
<output>
|
||||||
|
Found existing review issue: #{{review_issue.number}}
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="response.items.length == 0">
|
||||||
|
<output>
|
||||||
|
No existing review issue found. Creating one...
|
||||||
|
</output>
|
||||||
|
<action>
|
||||||
|
// 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]}_`
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>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
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<action>review_issue = response</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<substep n="4a" title="Update review issue to signoff status">
|
||||||
|
<action>
|
||||||
|
// 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'])
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__issue_write({
|
||||||
|
method: 'update',
|
||||||
|
owner: "{{github_owner}}",
|
||||||
|
repo: "{{github_repo}}",
|
||||||
|
issue_number: review_issue.number,
|
||||||
|
labels: new_labels
|
||||||
|
})</action>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="5" goal="Post Sign-off Request Comment">
|
||||||
|
<action>
|
||||||
|
// 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}._`
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__add_issue_comment({
|
||||||
|
owner: "{{github_owner}}",
|
||||||
|
repo: "{{github_repo}}",
|
||||||
|
issue_number: review_issue.number,
|
||||||
|
body: signoff_comment
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
✅ Sign-off request posted to #{{review_issue.number}}
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="6" goal="Update Document Status">
|
||||||
|
<action>
|
||||||
|
updated_content = doc_content
|
||||||
|
.replace(/\*\*Status:\*\* .+/, '**Status:** Sign-off')
|
||||||
|
.replace(/\| Sign-off Deadline \| .+ \|/, `| Sign-off Deadline | ${deadline_str} |`)
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>Write updated_content to doc_path</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
✅ Document status updated to 'Sign-off'
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="7" goal="Summary">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
✅ 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}}"
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
</workflow>
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,432 @@
|
||||||
|
# Submit Feedback - Structured Stakeholder Input
|
||||||
|
|
||||||
|
<critical>The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml</critical>
|
||||||
|
<critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical>
|
||||||
|
|
||||||
|
<workflow>
|
||||||
|
|
||||||
|
<step n="0" goal="Pre-Flight Checks">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
💬 SUBMIT FEEDBACK
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__get_me()</action>
|
||||||
|
<action>current_user = response.login</action>
|
||||||
|
|
||||||
|
<check if="API call fails">
|
||||||
|
<output>❌ GitHub MCP not accessible</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="1" goal="Identify Document">
|
||||||
|
<check if="document_key is empty">
|
||||||
|
<output>
|
||||||
|
Which document are you providing feedback on?
|
||||||
|
|
||||||
|
Enter the key (e.g., "user-auth" for PRD, "2" for Epic):
|
||||||
|
</output>
|
||||||
|
<ask>Document key:</ask>
|
||||||
|
<action>document_key = response</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<substep n="1a" title="Auto-detect document type">
|
||||||
|
<check if="document_type is empty">
|
||||||
|
<action>
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<check if="prompt_for_type">
|
||||||
|
<ask>Is this a [P]RD or [E]pic?</ask>
|
||||||
|
<action>document_type = (response.toLowerCase().startsWith('p')) ? 'prd' : 'epic'</action>
|
||||||
|
</check>
|
||||||
|
</check>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="1b" title="Set type-specific variables">
|
||||||
|
<action>
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="1c" title="Find active review issue">
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:{{review_label}} label:{{doc_label}} label:review-status:open is:open"
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<check if="response.items.length == 0">
|
||||||
|
<output>
|
||||||
|
⚠️ 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
|
||||||
|
</output>
|
||||||
|
<ask>Choice:</ask>
|
||||||
|
<check if="choice == 2">
|
||||||
|
<action>Read and display doc_path</action>
|
||||||
|
<action>Goto step 1c</action>
|
||||||
|
</check>
|
||||||
|
<check if="choice == 3">
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
<action>review_issue_number = null</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="response.items.length > 0">
|
||||||
|
<action>review_issue = response.items[0]</action>
|
||||||
|
<action>review_issue_number = review_issue.number</action>
|
||||||
|
<output>
|
||||||
|
📋 Found active review: #{{review_issue_number}}
|
||||||
|
{{review_issue.title}}
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="2" goal="Load Document and Show Sections">
|
||||||
|
<action>Read doc_path</action>
|
||||||
|
<action>doc_content = file_content</action>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
// Extract sections for selection
|
||||||
|
sections = extract_sections(doc_content)
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📄 DOCUMENT SECTIONS
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
Select the section your feedback relates to:
|
||||||
|
|
||||||
|
{{#each sections as |section index|}}
|
||||||
|
[{{add index 1}}] {{section}}
|
||||||
|
{{/each}}
|
||||||
|
[0] General / Overall document
|
||||||
|
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Section number:</ask>
|
||||||
|
<action>
|
||||||
|
section_idx = parseInt(response)
|
||||||
|
if (section_idx === 0) {
|
||||||
|
selected_section = 'General'
|
||||||
|
} else {
|
||||||
|
selected_section = sections[section_idx - 1] || 'General'
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="3" goal="Choose Feedback Type">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🏷️ 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
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<check if="document_type == 'epic'">
|
||||||
|
<output>
|
||||||
|
[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
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<ask>Feedback type (number):</ask>
|
||||||
|
<action>
|
||||||
|
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']
|
||||||
|
</action>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="4" goal="Set Priority">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🎯 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
|
||||||
|
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Priority (1-3, default 2):</ask>
|
||||||
|
<action>
|
||||||
|
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']
|
||||||
|
</action>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="5" goal="Gather Feedback Content">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📝 YOUR FEEDBACK
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Brief title for your feedback (one line):</ask>
|
||||||
|
<action>feedback_title = response</action>
|
||||||
|
|
||||||
|
<ask>Detailed feedback (describe your concern, question, or suggestion):</ask>
|
||||||
|
<action>feedback_content = response</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Would you like to suggest a specific change? (optional)
|
||||||
|
</output>
|
||||||
|
<ask>Suggested change (or press Enter to skip):</ask>
|
||||||
|
<action>suggested_change = response || null</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Any additional context or rationale? (optional)
|
||||||
|
</output>
|
||||||
|
<ask>Rationale (or press Enter to skip):</ask>
|
||||||
|
<action>rationale = response || null</action>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="6" goal="Create Feedback Issue">
|
||||||
|
<action>
|
||||||
|
// 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}`)
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>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
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<action>feedback_issue = response</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
✅ Feedback submitted: #{{feedback_issue.number}}
|
||||||
|
{{feedback_issue.html_url}}
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="7" goal="Link to Review Issue">
|
||||||
|
<check if="review_issue_number">
|
||||||
|
<action>
|
||||||
|
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}`
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__add_issue_comment({
|
||||||
|
owner: "{{github_owner}}",
|
||||||
|
repo: "{{github_repo}}",
|
||||||
|
issue_number: review_issue_number,
|
||||||
|
body: link_comment
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
✅ Feedback linked to review issue #{{review_issue_number}}
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="8" goal="Next Steps">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
✅ 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
|
||||||
|
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Choice:</ask>
|
||||||
|
|
||||||
|
<check if="choice == 1">
|
||||||
|
<action>Goto step 2 (submit more feedback)</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 2">
|
||||||
|
<action>Load workflow: view-feedback with document_key, document_type</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 3">
|
||||||
|
<action>Load workflow: my-tasks</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 4">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
Thank you for your feedback! 🙏
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
<action>Exit</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
</workflow>
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,427 @@
|
||||||
|
# Submit Sign-off - Record Your Approval Decision
|
||||||
|
|
||||||
|
<critical>The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml</critical>
|
||||||
|
<critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical>
|
||||||
|
|
||||||
|
<workflow>
|
||||||
|
|
||||||
|
<step n="0" goal="Pre-Flight Checks">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
✍️ SUBMIT SIGN-OFF
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__get_me()</action>
|
||||||
|
<action>current_user = response.login</action>
|
||||||
|
|
||||||
|
<check if="API call fails">
|
||||||
|
<output>❌ GitHub MCP not accessible</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="1" goal="Identify Document">
|
||||||
|
<check if="document_key is empty">
|
||||||
|
<ask>Which document are you signing off on? Enter key:</ask>
|
||||||
|
<action>document_key = response</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<substep n="1a" title="Find review issue">
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:{{review_label}} label:{{doc_label}} label:review-status:signoff is:open"
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<check if="response.items.length == 0">
|
||||||
|
<output>
|
||||||
|
❌ 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.
|
||||||
|
</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>review_issue = response.items[0]</action>
|
||||||
|
<output>
|
||||||
|
📋 Found sign-off request: #{{review_issue.number}}
|
||||||
|
{{review_issue.title}}
|
||||||
|
</output>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="1b" title="Load document">
|
||||||
|
<action>Read doc_path</action>
|
||||||
|
<check if="file not found">
|
||||||
|
<output>❌ Document not found: {{doc_path}}</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
<action>doc_content = file_content</action>
|
||||||
|
<action>
|
||||||
|
title = extract_title(doc_content)
|
||||||
|
version = extract_version(doc_content)
|
||||||
|
</action>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="2" goal="Check Existing Sign-off">
|
||||||
|
<action>
|
||||||
|
// 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)
|
||||||
|
)
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<check if="existing_signoff">
|
||||||
|
<output>
|
||||||
|
⚠️ 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
|
||||||
|
</output>
|
||||||
|
<ask>Choice:</ask>
|
||||||
|
<check if="choice == 2 OR choice == 3">
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
<output>
|
||||||
|
Proceeding to update your sign-off decision...
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="3" goal="Show Document Summary">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📄 DOCUMENT SUMMARY
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
**Title:** {{title}}
|
||||||
|
**Version:** v{{version}}
|
||||||
|
**Key:** {{doc_label}}
|
||||||
|
|
||||||
|
Would you like to view the full document before deciding?
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>View document? (y/n):</ask>
|
||||||
|
<check if="response == 'y'">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📄 DOCUMENT CONTENT
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
{{doc_content}}
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="4" goal="Get Sign-off Decision">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🗳️ 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
|
||||||
|
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Decision (1-3):</ask>
|
||||||
|
<action>
|
||||||
|
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']
|
||||||
|
</action>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="5" goal="Get Note or Reason">
|
||||||
|
<check if="decision.key == 'approved_with_note'">
|
||||||
|
<ask>Enter your note (this will be visible to all stakeholders):</ask>
|
||||||
|
<action>note = response</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="decision.key == 'blocked'">
|
||||||
|
<output>
|
||||||
|
⚠️ 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
|
||||||
|
|
||||||
|
</output>
|
||||||
|
<ask>Enter your blocking reason:</ask>
|
||||||
|
<action>note = response</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Would you like to create a formal feedback issue for this blocking concern?
|
||||||
|
</output>
|
||||||
|
<ask>Create feedback issue? (y/n):</ask>
|
||||||
|
<check if="response == 'y'">
|
||||||
|
<action>create_feedback_issue = true</action>
|
||||||
|
</check>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="decision.key == 'approved'">
|
||||||
|
<action>note = null</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="6" goal="Submit Sign-off">
|
||||||
|
<action>
|
||||||
|
// 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}`
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__add_issue_comment({
|
||||||
|
owner: "{{github_owner}}",
|
||||||
|
repo: "{{github_repo}}",
|
||||||
|
issue_number: review_issue.number,
|
||||||
|
body: signoff_comment
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<substep n="6a" title="Add sign-off label">
|
||||||
|
<action>
|
||||||
|
// 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}`)
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__issue_write({
|
||||||
|
method: 'update',
|
||||||
|
owner: "{{github_owner}}",
|
||||||
|
repo: "{{github_repo}}",
|
||||||
|
issue_number: review_issue.number,
|
||||||
|
labels: new_labels
|
||||||
|
})</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<check if="create_feedback_issue">
|
||||||
|
<action>
|
||||||
|
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}_`
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>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}`]
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
✅ Created feedback issue: #{{response.number}}
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
✅ Sign-off submitted: {{decision.emoji}} {{decision.text}}
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="7" goal="Check Approval Status">
|
||||||
|
<output>
|
||||||
|
Checking if all required sign-offs are complete...
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
// Refresh issue to get updated labels
|
||||||
|
Call: mcp__github__issue_read({
|
||||||
|
method: 'get',
|
||||||
|
owner: github_owner,
|
||||||
|
repo: github_repo,
|
||||||
|
issue_number: review_issue.number
|
||||||
|
})
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
// 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
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📊 SIGN-OFF STATUS
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
**Approved:** {{approved_count}} / {{stakeholder_count}}
|
||||||
|
**Blocked:** {{blocked_count}}
|
||||||
|
**Pending:** {{stakeholder_count - approved_count - blocked_count}}
|
||||||
|
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<check if="blocked_count > 0">
|
||||||
|
<output>
|
||||||
|
⚠️ Document has {{blocked_count}} blocking concern(s).
|
||||||
|
Cannot be approved until resolved.
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="approved_count == stakeholder_count AND blocked_count == 0">
|
||||||
|
<output>
|
||||||
|
🎉 ALL SIGN-OFFS RECEIVED!
|
||||||
|
|
||||||
|
The document is ready to be marked as APPROVED.
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Mark document as approved? (y/n):</ask>
|
||||||
|
<check if="response == 'y'">
|
||||||
|
<action>
|
||||||
|
// Update document status
|
||||||
|
updated_content = doc_content.replace(/\*\*Status:\*\* .+/, '**Status:** Approved')
|
||||||
|
</action>
|
||||||
|
<action>Write updated_content to doc_path</action>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
// Update review issue
|
||||||
|
final_labels = labels
|
||||||
|
.filter(l => !l.startsWith('review-status:'))
|
||||||
|
.concat(['review-status:approved'])
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>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'
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<action>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}`
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
✅ Document marked as APPROVED!
|
||||||
|
Review issue #{{review_issue.number}} closed.
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="8" goal="Summary">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
✅ SIGN-OFF COMPLETE
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
**Your Decision:** {{decision.emoji}} {{decision.text}}
|
||||||
|
**Document:** {{title}} v{{version}}
|
||||||
|
**Review Issue:** #{{review_issue.number}}
|
||||||
|
|
||||||
|
Thank you for your review! 🙏
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
</workflow>
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,684 @@
|
||||||
|
# Synthesize Epic Feedback - LLM-Powered Story Refinement
|
||||||
|
|
||||||
|
<critical>The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml</critical>
|
||||||
|
<critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical>
|
||||||
|
|
||||||
|
<workflow>
|
||||||
|
|
||||||
|
<step n="0" goal="Pre-Flight Checks">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🔄 SYNTHESIZE EPIC FEEDBACK
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__get_me()</action>
|
||||||
|
<action>current_user = response.login</action>
|
||||||
|
|
||||||
|
<check if="API call fails">
|
||||||
|
<output>❌ GitHub MCP not accessible</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="1" goal="Select Epic">
|
||||||
|
<check if="epic_key is empty">
|
||||||
|
<substep n="1a" title="List epics with feedback">
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:type:epic-review label:review-status:open is:open"
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<check if="response.items.length == 0">
|
||||||
|
<output>
|
||||||
|
❌ No epics currently collecting feedback.
|
||||||
|
|
||||||
|
Use [ED] Epic Dashboard to see all epics.
|
||||||
|
</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
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)
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📦 EPICS WITH ACTIVE FEEDBACK
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
{{#each feedback_epics}}
|
||||||
|
[{{@index + 1}}] epic:{{key}} - {{title}}
|
||||||
|
Source: prd:{{source_prd}} | Issue: #{{issue_number}}
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
</output>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<ask>Select epic to synthesize (1-{{feedback_epics.length}}):</ask>
|
||||||
|
<action>epic_key = feedback_epics[parseInt(response) - 1].key</action>
|
||||||
|
<action>review_issue_number = feedback_epics[parseInt(response) - 1].issue_number</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="2" goal="Load Epic and Feedback">
|
||||||
|
<substep n="2a" title="Load epic document">
|
||||||
|
<action>epic_path = `${docs_dir}/epics/epic-${epic_key}.md`</action>
|
||||||
|
<action>Read epic_path</action>
|
||||||
|
|
||||||
|
<check if="file not found">
|
||||||
|
<output>❌ Epic document not found: {{epic_path}}</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>epic_content = file_content</action>
|
||||||
|
<action>
|
||||||
|
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)
|
||||||
|
</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="2b" title="Fetch feedback issues">
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:type:epic-feedback label:epic:{{epic_key}} label:feedback-status:new"
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<check if="response.items.length == 0">
|
||||||
|
<output>
|
||||||
|
⚠️ No new feedback found for epic:{{epic_key}}
|
||||||
|
|
||||||
|
All feedback may have been processed already.
|
||||||
|
Check the Epic Dashboard [ED] for status.
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Continue anyway to create new version? (y/n):</ask>
|
||||||
|
<check if="response != 'y'">
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
<action>feedback_items = []</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="response.items.length > 0">
|
||||||
|
<action>
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</action>
|
||||||
|
</check>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📋 SYNTHESIS INPUT
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
**Epic:** epic:{{epic_key}}
|
||||||
|
**Title:** {{title}}
|
||||||
|
**Version:** v{{version}}
|
||||||
|
**Stories:** {{current_stories.length}}
|
||||||
|
**Feedback Items:** {{feedback_items.length}}
|
||||||
|
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="3" goal="Update Status to Synthesis">
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:type:epic-review label:epic:{{epic_key}} is:open"
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<action>review_issue = response.items[0]</action>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
current_labels = review_issue.labels.map(l => l.name)
|
||||||
|
new_labels = current_labels
|
||||||
|
.filter(l => !l.startsWith('review-status:'))
|
||||||
|
.concat(['review-status:synthesis'])
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__issue_write({
|
||||||
|
method: 'update',
|
||||||
|
owner: "{{github_owner}}",
|
||||||
|
repo: "{{github_repo}}",
|
||||||
|
issue_number: review_issue.number,
|
||||||
|
labels: new_labels
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
🔒 Epic locked for synthesis (review-status:synthesis)
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="4" goal="Analyze Feedback">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🔍 ANALYZING FEEDBACK
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
**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}}
|
||||||
|
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="5" goal="Detect Conflicts">
|
||||||
|
<action>
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<check if="conflicts.length > 0">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
⚠️ CONFLICTS DETECTED
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
{{#each conflicts}}
|
||||||
|
**{{type}}:** {{description}}
|
||||||
|
{{#each items}}
|
||||||
|
• @{{submittedBy}}: "{{title}}"
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
These will be analyzed and resolved by the synthesis engine.
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="6" goal="Generate Synthesis">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🤖 GENERATING SYNTHESIS
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
Processing {{feedback_items.length}} feedback items...
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
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)
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📝 SYNTHESIS RESULT
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
{{synthesis_result}}
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="7" goal="Review and Approve Changes">
|
||||||
|
<output>
|
||||||
|
**Review the proposed changes above.**
|
||||||
|
|
||||||
|
Options:
|
||||||
|
[1] Accept all changes
|
||||||
|
[2] Accept with modifications
|
||||||
|
[3] Reject and keep current version
|
||||||
|
[4] View detailed diff
|
||||||
|
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Choice:</ask>
|
||||||
|
|
||||||
|
<check if="response == '3'">
|
||||||
|
<output>
|
||||||
|
Changes rejected. Epic remains at v{{version}}.
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
new_labels = current_labels
|
||||||
|
.filter(l => !l.startsWith('review-status:'))
|
||||||
|
.concat(['review-status:open'])
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__issue_write({
|
||||||
|
method: 'update',
|
||||||
|
owner: "{{github_owner}}",
|
||||||
|
repo: "{{github_repo}}",
|
||||||
|
issue_number: review_issue.number,
|
||||||
|
labels: new_labels
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="response == '2'">
|
||||||
|
<ask>Describe your modifications:</ask>
|
||||||
|
<action>
|
||||||
|
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)
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Updated synthesis:
|
||||||
|
|
||||||
|
{{synthesis_result}}
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="response == '4'">
|
||||||
|
<action>
|
||||||
|
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)
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📋 DETAILED DIFF
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
{{diff_output}}
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Accept these changes? (y/n):</ask>
|
||||||
|
<check if="response != 'y'">
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="8" goal="Generate Updated Epic">
|
||||||
|
<action>
|
||||||
|
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)
|
||||||
|
</action>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="9" goal="Save Updated Epic">
|
||||||
|
<action>Write updated_epic to epic_path</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
✅ Epic document updated: {{epic_path}}
|
||||||
|
Version: v{{version}} → v{{new_version}}
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="10" goal="Update Feedback Issues">
|
||||||
|
<action>
|
||||||
|
// 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'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
✅ {{feedback_items.length}} feedback issues marked as incorporated
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="11" goal="Update Review Issue">
|
||||||
|
<action>
|
||||||
|
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]}_`
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__add_issue_comment({
|
||||||
|
owner: "{{github_owner}}",
|
||||||
|
repo: "{{github_repo}}",
|
||||||
|
issue_number: review_issue.number,
|
||||||
|
body: synthesis_comment
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
// 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'])
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__issue_write({
|
||||||
|
method: 'update',
|
||||||
|
owner: "{{github_owner}}",
|
||||||
|
repo: "{{github_repo}}",
|
||||||
|
issue_number: review_issue.number,
|
||||||
|
labels: final_labels
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
✅ Review issue updated with synthesis summary
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="12" goal="Summary">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
✅ 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
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
</workflow>
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,610 @@
|
||||||
|
# Synthesize Feedback - LLM-Powered Conflict Resolution
|
||||||
|
|
||||||
|
<critical>The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml</critical>
|
||||||
|
<critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical>
|
||||||
|
|
||||||
|
<workflow>
|
||||||
|
|
||||||
|
<step n="0" goal="Pre-Flight Checks">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🔄 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
|
||||||
|
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__get_me()</action>
|
||||||
|
<action>current_user = response.login</action>
|
||||||
|
|
||||||
|
<check if="API call fails">
|
||||||
|
<output>❌ GitHub MCP not accessible</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="1" goal="Identify Document and Load Content">
|
||||||
|
<check if="document_key is empty">
|
||||||
|
<ask>Which document? Enter key:</ask>
|
||||||
|
<action>document_key = response</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>Read doc_path</action>
|
||||||
|
<check if="file not found">
|
||||||
|
<output>❌ Document not found: {{doc_path}}</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
<action>original_content = file_content</action>
|
||||||
|
<action>current_version = extract_version(original_content)</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
📄 Loaded: {{doc_label}} v{{current_version}}
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="2" goal="Fetch All New Feedback">
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:{{feedback_label}} label:{{doc_label}} label:feedback-status:new is:open"
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<action>feedback_issues = response.items || []</action>
|
||||||
|
|
||||||
|
<check if="feedback_issues.length == 0">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
✅ 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
|
||||||
|
</output>
|
||||||
|
<ask>Choice:</ask>
|
||||||
|
<check if="choice == 1">
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:{{feedback_label}} label:{{doc_label}} is:open"
|
||||||
|
})</action>
|
||||||
|
<action>feedback_issues = response.items || []</action>
|
||||||
|
</check>
|
||||||
|
<check if="choice == 2">
|
||||||
|
<action>Load workflow: view-feedback with document_key, document_type</action>
|
||||||
|
</check>
|
||||||
|
<check if="choice == 3">
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
📋 Found {{feedback_issues.length}} feedback items to process
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="3" goal="Parse and Analyze Feedback">
|
||||||
|
<action>
|
||||||
|
// 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)
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📊 FEEDBACK ANALYSIS
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
**Sections with Feedback:** {{sections_with_feedback.length}}
|
||||||
|
{{#each by_section as |items section|}}
|
||||||
|
• {{section}}: {{items.length}} item(s)
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
Processing each section...
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="4" goal="Process Each Section">
|
||||||
|
<action>proposed_changes = []</action>
|
||||||
|
<action>section_index = 0</action>
|
||||||
|
|
||||||
|
<loop for="section of sections_with_feedback">
|
||||||
|
<action>section_index++</action>
|
||||||
|
<action>section_feedback = by_section[section]</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📝 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}}
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<substep n="4a" title="Extract original section text">
|
||||||
|
<action>
|
||||||
|
original_section_text = extract_section(original_content, section)
|
||||||
|
</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="4b" title="Check for conflicts">
|
||||||
|
<action>
|
||||||
|
has_conflict = section_feedback.length >= 2 &&
|
||||||
|
section_feedback.some(f => f.type === 'concern') &&
|
||||||
|
section_feedback.some(f => f.type === 'suggestion' || f.type === 'concern')
|
||||||
|
</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<check if="has_conflict">
|
||||||
|
<output>
|
||||||
|
⚠️ CONFLICT DETECTED - Multiple stakeholders have different views
|
||||||
|
|
||||||
|
Generating resolution proposal...
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
// 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`
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
**Original Text:**
|
||||||
|
{{original_section_text || '[Section not found]'}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🤖 AI-Proposed Resolution:**
|
||||||
|
|
||||||
|
{{LLM processes conflict_prompt and generates resolution}}
|
||||||
|
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="!has_conflict">
|
||||||
|
<action>
|
||||||
|
// 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.`
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
**Original Text:**
|
||||||
|
{{original_section_text || '[Section not found]'}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🤖 AI-Proposed Update:**
|
||||||
|
|
||||||
|
{{LLM processes merge_prompt and generates updated text}}
|
||||||
|
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Decision for {{section}}:</ask>
|
||||||
|
|
||||||
|
<check if="choice == 'A'">
|
||||||
|
<action>
|
||||||
|
proposed_changes.push({
|
||||||
|
section: section,
|
||||||
|
decision: 'accept',
|
||||||
|
newText: proposed_text,
|
||||||
|
feedbackIds: section_feedback.map(f => f.id)
|
||||||
|
})
|
||||||
|
</action>
|
||||||
|
<output>✅ Accepted</output>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'M'">
|
||||||
|
<ask>Enter your modified text for this section:</ask>
|
||||||
|
<action>
|
||||||
|
proposed_changes.push({
|
||||||
|
section: section,
|
||||||
|
decision: 'modified',
|
||||||
|
newText: response,
|
||||||
|
feedbackIds: section_feedback.map(f => f.id)
|
||||||
|
})
|
||||||
|
</action>
|
||||||
|
<output>✅ Modified version saved</output>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'R'">
|
||||||
|
<action>
|
||||||
|
proposed_changes.push({
|
||||||
|
section: section,
|
||||||
|
decision: 'reject',
|
||||||
|
newText: null,
|
||||||
|
feedbackIds: section_feedback.map(f => f.id)
|
||||||
|
})
|
||||||
|
</action>
|
||||||
|
<output>❌ Rejected - keeping original</output>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'S'">
|
||||||
|
<output>⏭️ Skipped</output>
|
||||||
|
</check>
|
||||||
|
</loop>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="5" goal="Review All Changes">
|
||||||
|
<action>
|
||||||
|
accepted_changes = proposed_changes.filter(c => c.decision === 'accept' || c.decision === 'modified')
|
||||||
|
rejected_changes = proposed_changes.filter(c => c.decision === 'reject')
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📋 SYNTHESIS SUMMARY
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
**Accepted Changes:** {{accepted_changes.length}}
|
||||||
|
{{#each accepted_changes}}
|
||||||
|
✅ {{section}} ({{decision}})
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
**Rejected Changes:** {{rejected_changes.length}}
|
||||||
|
{{#each rejected_changes}}
|
||||||
|
❌ {{section}}
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<check if="accepted_changes.length == 0">
|
||||||
|
<output>
|
||||||
|
No changes to apply. Workflow complete.
|
||||||
|
</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<ask>Apply these changes to the document? (y/n):</ask>
|
||||||
|
|
||||||
|
<check if="response != 'y'">
|
||||||
|
<output>
|
||||||
|
Changes not applied. You can re-run synthesis later.
|
||||||
|
</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="6" goal="Apply Changes to Document">
|
||||||
|
<action>
|
||||||
|
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}`
|
||||||
|
)
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>Write updated_content to doc_path</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
✅ Document updated to v{{new_version}}
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="7" goal="Update Feedback Status">
|
||||||
|
<output>
|
||||||
|
Updating feedback issue statuses...
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
// 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
✅ {{accepted_changes.flatMap(c => c.feedbackIds).length}} feedback items marked as incorporated
|
||||||
|
✅ {{rejected_changes.flatMap(c => c.feedbackIds).length}} feedback items marked as reviewed
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="8" goal="Update Review Issue">
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:type:{{document_type}}-review label:{{doc_label}} is:open"
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<check if="response.items.length > 0">
|
||||||
|
<action>review_issue = response.items[0]</action>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
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]}_`
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__add_issue_comment({
|
||||||
|
owner: "{{github_owner}}",
|
||||||
|
repo: "{{github_repo}}",
|
||||||
|
issue_number: review_issue.number,
|
||||||
|
body: synthesis_comment
|
||||||
|
})</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="9" goal="Next Steps">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
✅ 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
|
||||||
|
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Choice:</ask>
|
||||||
|
|
||||||
|
<check if="choice == 1">
|
||||||
|
<action>Load workflow: request-signoff with document_key, document_type</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 2">
|
||||||
|
<action>Load workflow: open-feedback-round with document_key, document_type</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 3">
|
||||||
|
<action>Read and display doc_path</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
Synthesis workflow complete.
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
</workflow>
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,411 @@
|
||||||
|
# View Feedback - Review All Stakeholder Input
|
||||||
|
|
||||||
|
<critical>The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml</critical>
|
||||||
|
<critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical>
|
||||||
|
|
||||||
|
<workflow>
|
||||||
|
|
||||||
|
<step n="0" goal="Pre-Flight Checks">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
👁️ VIEW FEEDBACK
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__get_me()</action>
|
||||||
|
|
||||||
|
<check if="API call fails">
|
||||||
|
<output>❌ GitHub MCP not accessible</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="1" goal="Identify Document">
|
||||||
|
<check if="document_key is empty">
|
||||||
|
<ask>Which document? Enter key (e.g., "user-auth" for PRD, "2" for Epic):</ask>
|
||||||
|
<action>document_key = response</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="document_type is empty">
|
||||||
|
<ask>Is this a [P]RD or [E]pic?</ask>
|
||||||
|
<action>document_type = (response.toLowerCase().startsWith('p')) ? 'prd' : 'epic'</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
doc_label = `${document_type}:${document_key}`
|
||||||
|
feedback_label = `type:${document_type}-feedback`
|
||||||
|
</action>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="2" goal="Fetch All Feedback">
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:{{feedback_label}} label:{{doc_label}} is:open"
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<action>feedback_issues = response.items || []</action>
|
||||||
|
|
||||||
|
<check if="feedback_issues.length == 0">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📭 NO FEEDBACK FOUND
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
No feedback has been submitted for {{doc_label}} yet.
|
||||||
|
|
||||||
|
**Actions:**
|
||||||
|
[SF] Submit Feedback
|
||||||
|
[MT] My Tasks
|
||||||
|
[Q] Quit
|
||||||
|
</output>
|
||||||
|
<ask>Choice:</ask>
|
||||||
|
<check if="choice == 'SF'">
|
||||||
|
<action>Load workflow: submit-feedback with document_key, document_type</action>
|
||||||
|
</check>
|
||||||
|
<check if="choice == 'MT'">
|
||||||
|
<action>Load workflow: my-tasks</action>
|
||||||
|
</check>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="3" goal="Parse and Group Feedback">
|
||||||
|
<action>
|
||||||
|
// 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="4" goal="Display Summary">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📊 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}}
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="5" goal="Display Options">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Choice:</ask>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="6" goal="Handle Choice">
|
||||||
|
<check if="choice == 1">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📂 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}}
|
||||||
|
</output>
|
||||||
|
<action>Goto step 5</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 2">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🏷️ 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}}
|
||||||
|
</output>
|
||||||
|
<action>Goto step 5</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 3">
|
||||||
|
<check if="conflicts.length == 0">
|
||||||
|
<output>
|
||||||
|
✅ No conflicts detected! All feedback is non-overlapping.
|
||||||
|
</output>
|
||||||
|
<action>Goto step 5</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
⚠️ 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}}
|
||||||
|
</output>
|
||||||
|
<action>Goto step 5</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 4">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📋 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}}
|
||||||
|
</output>
|
||||||
|
<action>Goto step 5</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 5">
|
||||||
|
<action>
|
||||||
|
// 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`
|
||||||
|
</action>
|
||||||
|
<action>Write export_content to export_path</action>
|
||||||
|
<output>
|
||||||
|
✅ Exported to: {{export_path}}
|
||||||
|
</output>
|
||||||
|
<action>Goto step 5</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'S'">
|
||||||
|
<output>
|
||||||
|
Opening synthesis workflow for {{doc_label}}...
|
||||||
|
</output>
|
||||||
|
<action>Load workflow: synthesize-feedback with document_key, document_type</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'R'">
|
||||||
|
<action>Goto step 2</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'Q'">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
View Feedback closed.
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
<action>Exit</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>Goto step 5</action>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
</workflow>
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
# Available Stories - Find Unlocked Work
|
||||||
|
|
||||||
|
<critical>The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml</critical>
|
||||||
|
<critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical>
|
||||||
|
|
||||||
|
<workflow>
|
||||||
|
|
||||||
|
<step n="0" goal="Pre-Flight Checks">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📋 AVAILABLE STORIES
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<substep n="0a" title="Verify GitHub MCP access">
|
||||||
|
<action>Call: mcp__github__get_me()</action>
|
||||||
|
|
||||||
|
<check if="API call fails">
|
||||||
|
<output>
|
||||||
|
❌ CRITICAL: GitHub MCP not accessible
|
||||||
|
|
||||||
|
Cannot list stories without GitHub API access.
|
||||||
|
|
||||||
|
HALTING
|
||||||
|
</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>current_user = response.login</action>
|
||||||
|
<output>Connected as @{{current_user}}</output>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="1" goal="Fetch Available Stories">
|
||||||
|
<substep n="1a" title="Search for unlocked stories">
|
||||||
|
<action>Build search query:</action>
|
||||||
|
<action>
|
||||||
|
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"
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__search_issues({ query: query })</action>
|
||||||
|
|
||||||
|
<action>available_stories = response.items</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="1b" title="Fetch locked stories (if show_locked)">
|
||||||
|
<check if="show_locked == true">
|
||||||
|
<action>Build locked query:</action>
|
||||||
|
<action>
|
||||||
|
locked_query = "repo:{{github_owner}}/{{github_repo}} label:type:story -no:assignee"
|
||||||
|
|
||||||
|
IF epic is provided:
|
||||||
|
locked_query += " label:epic:{{epic}}"
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__search_issues({ query: locked_query })</action>
|
||||||
|
<action>locked_stories = response.items</action>
|
||||||
|
</check>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="2" goal="Display Results">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📦 AVAILABLE STORIES (Unlocked)
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
{{#if epic}}Filter: Epic {{epic}}{{/if}}
|
||||||
|
{{#if status}}Filter: Status {{status}}{{/if}}
|
||||||
|
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<check if="available_stories.length == 0">
|
||||||
|
<output>
|
||||||
|
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}}
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="available_stories.length > 0">
|
||||||
|
<action>Group stories by epic:</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
{{#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
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="show_locked == true AND locked_stories.length > 0">
|
||||||
|
<output>
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🔒 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
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
|
||||||
|
**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}}
|
||||||
|
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
</workflow>
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,404 @@
|
||||||
|
# Checkout Story - Lock Story for Development
|
||||||
|
|
||||||
|
<critical>The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml</critical>
|
||||||
|
<critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical>
|
||||||
|
<critical>TEAM COORDINATION: This workflow prevents duplicate work by locking stories</critical>
|
||||||
|
|
||||||
|
<workflow>
|
||||||
|
|
||||||
|
<step n="0" goal="Pre-Flight Checks">
|
||||||
|
<critical>Verify prerequisites before attempting lock acquisition</critical>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🔒 STORY CHECKOUT - Lock Acquisition
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<substep n="0a" title="Validate story_key parameter">
|
||||||
|
<check if="story_key is empty or not provided">
|
||||||
|
<output>
|
||||||
|
❌ ERROR: story_key parameter required
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
/checkout-story story_key=2-5-auth
|
||||||
|
|
||||||
|
Available stories:
|
||||||
|
Run /available-stories to see unlocked stories
|
||||||
|
|
||||||
|
HALTING
|
||||||
|
</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>Validate story_key format: {{story_key}}</action>
|
||||||
|
<action>Expected format: {epic_number}-{story_number}-{slug}</action>
|
||||||
|
<action>Example: 2-5-auth, 3-1-user-profile</action>
|
||||||
|
|
||||||
|
<check if="format invalid">
|
||||||
|
<output>
|
||||||
|
⚠️ 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...
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<output>📦 Story: {{story_key}}</output>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="0b" title="Verify GitHub MCP access">
|
||||||
|
<action>Test GitHub MCP connection:</action>
|
||||||
|
<action>Call: mcp__github__get_me()</action>
|
||||||
|
|
||||||
|
<check if="API call fails">
|
||||||
|
<output>
|
||||||
|
❌ 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
|
||||||
|
</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>Extract current user: {{current_user}} = response.login</action>
|
||||||
|
<output>✅ GitHub connected as @{{current_user}}</output>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="0c" title="Check user's current locks">
|
||||||
|
<action>Count user's currently locked stories</action>
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} assignee:{{current_user}} label:status:in-progress label:type:story"
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<action>current_lock_count = response.total_count or response.items.length</action>
|
||||||
|
|
||||||
|
<check if="current_lock_count >= max_locks_per_user">
|
||||||
|
<output>
|
||||||
|
⚠️ 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}})
|
||||||
|
</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<output>📊 Current locks: {{current_lock_count}}/{{max_locks_per_user}}</output>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="1" goal="Check Story Availability">
|
||||||
|
<critical>Verify story exists and is not locked by another developer</critical>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🔍 Checking Story Availability
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<substep n="1a" title="Search for story in GitHub">
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:story:{{story_key}}"
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<check if="no results found">
|
||||||
|
<output>
|
||||||
|
❌ 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
|
||||||
|
</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>issue = response.items[0]</action>
|
||||||
|
<action>issue_number = issue.number</action>
|
||||||
|
<action>issue_title = issue.title</action>
|
||||||
|
<action>current_assignee = issue.assignee?.login or null</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
📋 Found: Issue #{{issue_number}}
|
||||||
|
Title: {{issue_title}}
|
||||||
|
Status: {{extract_status_label(issue.labels)}}
|
||||||
|
Assignee: {{current_assignee or "None (available)"}}
|
||||||
|
</output>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="1b" title="Check if already locked">
|
||||||
|
<check if="current_assignee exists AND current_assignee != current_user">
|
||||||
|
<output>
|
||||||
|
❌ 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
|
||||||
|
</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="current_assignee == current_user">
|
||||||
|
<output>
|
||||||
|
✅ Story already locked by you
|
||||||
|
|
||||||
|
You already have this story checked out.
|
||||||
|
Lock will be refreshed.
|
||||||
|
|
||||||
|
Issue: #{{issue_number}}
|
||||||
|
</output>
|
||||||
|
<action>Set refresh_mode = true</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="current_assignee is null">
|
||||||
|
<output>✅ Story is available for checkout</output>
|
||||||
|
<action>Set refresh_mode = false</action>
|
||||||
|
</check>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="2" goal="Acquire Lock (Atomic Operation)">
|
||||||
|
<critical>Acquire lock with retry logic and verification</critical>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🔐 Acquiring Lock
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<substep n="2a" title="Create local lock file">
|
||||||
|
<action>Ensure lock directory exists: {{lock_dir}}</action>
|
||||||
|
<action>Create lock file: {{lock_dir}}/{{story_key}}.lock</action>
|
||||||
|
|
||||||
|
<action>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)}}
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>✅ Local lock file created</output>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="2b" title="Assign GitHub Issue (with retry)">
|
||||||
|
<action>ATOMIC ASSIGNMENT with verification:</action>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
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
|
||||||
|
</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="2c" title="Update cache metadata">
|
||||||
|
<action>Update cache meta with lock info:</action>
|
||||||
|
<action>
|
||||||
|
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
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>✅ Cache metadata updated</output>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="3" goal="Pre-fetch Epic Context">
|
||||||
|
<check if="epic_prefetch == false">
|
||||||
|
<action>Skip to Step 4</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📦 Pre-fetching Epic Context
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<substep n="3a" title="Extract epic number">
|
||||||
|
<action>epic_number = extract first segment from story_key</action>
|
||||||
|
<action>Example: "2-5-auth" → epic_number = 2</action>
|
||||||
|
|
||||||
|
<output>📁 Epic {{epic_number}}</output>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="3b" title="Fetch all stories in epic">
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:epic:{{epic_number}} label:type:story"
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<action>epic_stories = response.items</action>
|
||||||
|
<output>Found {{epic_stories.length}} stories in Epic {{epic_number}}</output>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="3c" title="Cache all epic stories">
|
||||||
|
<action>For each story in epic_stories:</action>
|
||||||
|
<action> - Extract story_key from labels</action>
|
||||||
|
<action> - Convert issue body to story content</action>
|
||||||
|
<action> - Write to cache: {{cache_dir}}/stories/{story_key}.md</action>
|
||||||
|
<action> - Update cache metadata</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
📥 Cached {{epic_stories.length}} stories:
|
||||||
|
{{#each epic_stories}}
|
||||||
|
- {{story_key}}: {{title}}
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
These stories are now available via Read tool for fast LLM access.
|
||||||
|
</output>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="4" goal="Checkout Complete">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
✅ 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}}
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
</workflow>
|
||||||
|
|
@ -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
|
||||||
|
|
@ -382,6 +382,79 @@
|
||||||
</step>
|
</step>
|
||||||
|
|
||||||
<step n="4" goal="Mark story in-progress" tag="sprint-status">
|
<step n="4" goal="Mark story in-progress" tag="sprint-status">
|
||||||
|
<!-- GITHUB INTEGRATION: Verify lock before starting -->
|
||||||
|
<check if="{{github_integration.enabled}} == true">
|
||||||
|
<substep n="4a" title="Verify GitHub lock held">
|
||||||
|
<critical>MUST verify lock before each implementation session</critical>
|
||||||
|
|
||||||
|
<action>Check local lock file: {{lock_dir}}/{{story_key}}.lock</action>
|
||||||
|
|
||||||
|
<check if="local lock file does not exist">
|
||||||
|
<output>
|
||||||
|
⚠️ 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)...
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="local lock file exists">
|
||||||
|
<action>Read lock file and verify:</action>
|
||||||
|
<action> - locked_by matches current user</action>
|
||||||
|
<action> - timeout_at has not passed</action>
|
||||||
|
|
||||||
|
<check if="lock expired">
|
||||||
|
<output>
|
||||||
|
⚠️ Lock expired for story {{story_key}}
|
||||||
|
|
||||||
|
Your lock has expired. Re-checkout to refresh:
|
||||||
|
/checkout-story story_key={{story_key}}
|
||||||
|
</output>
|
||||||
|
<action>HALT - Cannot proceed with expired lock</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="locked_by != current_user">
|
||||||
|
<output>
|
||||||
|
❌ Lock belongs to different user
|
||||||
|
|
||||||
|
Story {{story_key}} is locked by @{{lock_owner}}
|
||||||
|
Your user: @{{current_user}}
|
||||||
|
|
||||||
|
This should not happen - investigate lock state.
|
||||||
|
</output>
|
||||||
|
<action>HALT - Lock mismatch</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<!-- Verify with GitHub (source of truth) -->
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:story:{{story_key}}"
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<check if="issue.assignee != current_user">
|
||||||
|
<output>
|
||||||
|
❌ 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}}
|
||||||
|
</output>
|
||||||
|
<action>HALT - Lock verification failed</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<!-- Refresh heartbeat -->
|
||||||
|
<action>Update lock file: last_heartbeat = now()</action>
|
||||||
|
<output>✅ Lock verified for @{{current_user}}</output>
|
||||||
|
</check>
|
||||||
|
</substep>
|
||||||
|
</check>
|
||||||
|
|
||||||
<check if="{{sprint_status}} file exists">
|
<check if="{{sprint_status}} file exists">
|
||||||
<action>Load the FULL file: {{sprint_status}}</action>
|
<action>Load the FULL file: {{sprint_status}}</action>
|
||||||
<action>Read all development_status entries to find {{story_key}}</action>
|
<action>Read all development_status entries to find {{story_key}}</action>
|
||||||
|
|
@ -532,6 +605,57 @@
|
||||||
<output>✅ Sprint progress updated: {{story_key}} → {{checked_tasks}}/{{total_tasks}} ({{progress_pct}}%)</output>
|
<output>✅ Sprint progress updated: {{story_key}} → {{checked_tasks}}/{{total_tasks}} ({{progress_pct}}%)</output>
|
||||||
</check>
|
</check>
|
||||||
|
|
||||||
|
<!-- GITHUB INTEGRATION: Sync progress to GitHub Issue (NEW) -->
|
||||||
|
<check if="{{github_integration.enabled}} == true AND {{github_integration.sync.progress_updates}} == true">
|
||||||
|
<substep n="8b" title="Sync progress to GitHub">
|
||||||
|
<action>Load cache metadata to get github_issue number</action>
|
||||||
|
<action>cache_meta = load {{cache_dir}}/.bmad-cache-meta.json</action>
|
||||||
|
<action>issue_number = cache_meta.stories[{{story_key}}].github_issue</action>
|
||||||
|
|
||||||
|
<check if="issue_number exists">
|
||||||
|
<action>SYNC with retry:</action>
|
||||||
|
<action>
|
||||||
|
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
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<!-- Refresh lock heartbeat -->
|
||||||
|
<action>Update lock file: last_heartbeat = now()</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="issue_number does not exist">
|
||||||
|
<output>ℹ️ Story not synced to GitHub - local progress only</output>
|
||||||
|
</check>
|
||||||
|
</substep>
|
||||||
|
</check>
|
||||||
|
|
||||||
<check if="review_continuation == true and {{resolved_review_items}} is not empty">
|
<check if="review_continuation == true and {{resolved_review_items}} is not empty">
|
||||||
<action>Count total resolved review items in this session</action>
|
<action>Count total resolved review items in this session</action>
|
||||||
<action>Add Change Log entry: "Addressed code review findings - {{resolved_count}} items resolved (Date: {{date}})"</action>
|
<action>Add Change Log entry: "Addressed code review findings - {{resolved_count}} items resolved (Date: {{date}})"</action>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,23 @@ implementation_artifacts: "{config_source}:implementation_artifacts"
|
||||||
sprint_status: "{implementation_artifacts}/sprint-status.yaml"
|
sprint_status: "{implementation_artifacts}/sprint-status.yaml"
|
||||||
project_context: "**/project-context.md"
|
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)
|
# Autonomous mode settings (passed from parent workflow like batch-super-dev)
|
||||||
auto_accept_gap_analysis: false # When true, skip gap analysis approval prompt
|
auto_accept_gap_analysis: false # When true, skip gap analysis approval prompt
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,184 @@
|
||||||
|
# Lock Status - View Story Assignments
|
||||||
|
|
||||||
|
<critical>The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml</critical>
|
||||||
|
<critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical>
|
||||||
|
|
||||||
|
<workflow>
|
||||||
|
|
||||||
|
<step n="0" goal="Pre-Flight Checks">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🔐 LOCK STATUS - Team Story Assignments
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<substep n="0a" title="Verify GitHub MCP access">
|
||||||
|
<action>Call: mcp__github__get_me()</action>
|
||||||
|
|
||||||
|
<check if="API call fails">
|
||||||
|
<output>
|
||||||
|
❌ CRITICAL: GitHub MCP not accessible
|
||||||
|
|
||||||
|
HALTING
|
||||||
|
</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>current_user = response.login</action>
|
||||||
|
<output>Connected as @{{current_user}}</output>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="1" goal="Fetch All Locked Stories">
|
||||||
|
<substep n="1a" title="Search for assigned stories">
|
||||||
|
<action>Build search query:</action>
|
||||||
|
<action>
|
||||||
|
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"
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__search_issues({ query: query })</action>
|
||||||
|
<action>locked_stories = response.items</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="1b" title="Analyze lock freshness">
|
||||||
|
<action>For each locked story:</action>
|
||||||
|
<action>
|
||||||
|
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 "?"
|
||||||
|
</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="1c" title="Group by developer">
|
||||||
|
<action>Group stories by assignee:</action>
|
||||||
|
<action>
|
||||||
|
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)
|
||||||
|
</action>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="2" goal="Display Lock Status">
|
||||||
|
<check if="locked_stories.length == 0">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
ℹ️ No Active Locks
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
No stories are currently locked.
|
||||||
|
All stories are available for checkout.
|
||||||
|
|
||||||
|
Find work: /available-stories
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
<action>Exit</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🔐 ACTIVE LOCKS
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
{{#if user}}Filtered by: @{{user}}{{/if}}
|
||||||
|
{{#if epic}}Filtered by: Epic {{epic}}{{/if}}
|
||||||
|
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>Display locks grouped by user:</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
{{#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}}
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<check if="stale_locks.length > 0 AND show_stale == true">
|
||||||
|
<output>
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
⚠️ 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.
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📊 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
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
</workflow>
|
||||||
|
|
@ -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
|
||||||
|
|
@ -250,8 +250,19 @@ Before proceeding:
|
||||||
|
|
||||||
## CRITICAL STEP COMPLETION
|
## CRITICAL STEP COMPLETION
|
||||||
|
|
||||||
**ONLY WHEN** [commit created],
|
**ONLY WHEN** [commit created]:
|
||||||
load and execute `{nextStepFile}` for summary generation.
|
|
||||||
|
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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -10,6 +10,20 @@ sprint_artifacts: "{config_source}:sprint_artifacts"
|
||||||
communication_language: "{config_source}:communication_language"
|
communication_language: "{config_source}:communication_language"
|
||||||
date: system-generated
|
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
|
# Workflow paths
|
||||||
installed_path: "{project-root}/_bmad/bmm/workflows/4-implementation/super-dev-pipeline"
|
installed_path: "{project-root}/_bmad/bmm/workflows/4-implementation/super-dev-pipeline"
|
||||||
steps_path: "{installed_path}/steps"
|
steps_path: "{installed_path}/steps"
|
||||||
|
|
@ -154,6 +168,14 @@ steps:
|
||||||
agent: sm
|
agent: sm
|
||||||
quality_gate: false
|
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
|
- step: 7
|
||||||
file: "{steps_path}/step-07-summary.md"
|
file: "{steps_path}/step-07-summary.md"
|
||||||
name: "Summary"
|
name: "Summary"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,331 @@
|
||||||
|
# Unlock Story - Release Story Lock
|
||||||
|
|
||||||
|
<critical>The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml</critical>
|
||||||
|
<critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical>
|
||||||
|
<critical>TEAM COORDINATION: Releasing locks makes stories available for others</critical>
|
||||||
|
|
||||||
|
<workflow>
|
||||||
|
|
||||||
|
<step n="0" goal="Pre-Flight Checks">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🔓 STORY UNLOCK - Release Lock
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<substep n="0a" title="Validate story_key parameter">
|
||||||
|
<check if="story_key is empty">
|
||||||
|
<output>
|
||||||
|
❌ 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
|
||||||
|
</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<output>📦 Story: {{story_key}}</output>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="0b" title="Verify GitHub MCP access">
|
||||||
|
<action>Call: mcp__github__get_me()</action>
|
||||||
|
|
||||||
|
<check if="API call fails">
|
||||||
|
<output>
|
||||||
|
❌ CRITICAL: GitHub MCP not accessible
|
||||||
|
|
||||||
|
Cannot unlock story without GitHub API access.
|
||||||
|
|
||||||
|
HALTING
|
||||||
|
</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>current_user = response.login</action>
|
||||||
|
<output>✅ GitHub connected as @{{current_user}}</output>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="1" goal="Verify Story Lock Status">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🔍 Checking Lock Status
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<substep n="1a" title="Find story in GitHub">
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:story:{{story_key}}"
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<check if="no results found">
|
||||||
|
<output>
|
||||||
|
❌ ERROR: Story not found in GitHub
|
||||||
|
|
||||||
|
Story "{{story_key}}" does not exist.
|
||||||
|
|
||||||
|
HALTING
|
||||||
|
</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>issue = response.items[0]</action>
|
||||||
|
<action>issue_number = issue.number</action>
|
||||||
|
<action>current_assignee = issue.assignee?.login or null</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="1b" title="Check lock ownership">
|
||||||
|
<check if="current_assignee is null">
|
||||||
|
<output>
|
||||||
|
ℹ️ Story is not locked
|
||||||
|
|
||||||
|
Story {{story_key}} has no assignee.
|
||||||
|
Nothing to unlock.
|
||||||
|
|
||||||
|
Issue: #{{issue_number}}
|
||||||
|
</output>
|
||||||
|
<action>Exit (already unlocked)</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="current_assignee != current_user AND force != true">
|
||||||
|
<output>
|
||||||
|
❌ 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
|
||||||
|
</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="current_assignee != current_user AND force == true">
|
||||||
|
<action>Verify current_user is in scrum_masters list</action>
|
||||||
|
|
||||||
|
<check if="current_user not in scrum_masters">
|
||||||
|
<output>
|
||||||
|
❌ PERMISSION DENIED
|
||||||
|
|
||||||
|
--force requires Scrum Master permissions.
|
||||||
|
|
||||||
|
Current Scrum Masters:
|
||||||
|
{{#each scrum_masters}}
|
||||||
|
- @{{this}}
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
Your user: @{{current_user}}
|
||||||
|
|
||||||
|
HALTING
|
||||||
|
</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
⚠️ 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}}
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>Set force_unlock = true</action>
|
||||||
|
<action>Set notify_owner = true</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="current_assignee == current_user">
|
||||||
|
<output>✅ You own this lock - proceeding with unlock</output>
|
||||||
|
<action>Set force_unlock = false</action>
|
||||||
|
</check>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="2" goal="Release Lock">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🔐 Releasing Lock
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<substep n="2a" title="Remove GitHub assignment">
|
||||||
|
<action>ATOMIC UNLOCK with retry:</action>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
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
|
||||||
|
</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="2b" title="Remove local lock file">
|
||||||
|
<action>lock_file = {{lock_dir}}/{{story_key}}.lock</action>
|
||||||
|
|
||||||
|
<check if="lock_file exists">
|
||||||
|
<action>Delete lock_file</action>
|
||||||
|
<output>✅ Local lock file removed</output>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="lock_file does not exist">
|
||||||
|
<output>ℹ️ No local lock file found (already removed or on different machine)</output>
|
||||||
|
</check>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="2c" title="Update cache metadata">
|
||||||
|
<action>Update cache meta to clear lock:</action>
|
||||||
|
<action>
|
||||||
|
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
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<output>✅ Cache metadata updated</output>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="2d" title="Notify previous owner (if force unlock)">
|
||||||
|
<check if="notify_owner == true">
|
||||||
|
<output>
|
||||||
|
📧 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."
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="3" goal="Unlock Complete">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
✅ 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
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
</workflow>
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
# Approve Story - PO Sign-Off
|
||||||
|
|
||||||
|
<critical>The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml</critical>
|
||||||
|
<critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical>
|
||||||
|
<critical>PO WORKFLOW: Final approval closes issue and releases lock</critical>
|
||||||
|
|
||||||
|
<workflow>
|
||||||
|
|
||||||
|
<step n="0" goal="Pre-Flight Checks">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
✅ APPROVE STORY - PO Sign-Off
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<check if="story_key is empty">
|
||||||
|
<output>
|
||||||
|
❌ ERROR: story_key parameter required
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
/approve-story story_key=2-5-auth
|
||||||
|
|
||||||
|
HALTING
|
||||||
|
</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__get_me()</action>
|
||||||
|
<action>current_user = response.login</action>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="1" goal="Fetch Story and PR">
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:story:{{story_key}}"
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<check if="no results">
|
||||||
|
<output>❌ Story {{story_key}} not found</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>issue = response.items[0]</action>
|
||||||
|
<action>status = extract_status(issue.labels)</action>
|
||||||
|
|
||||||
|
<check if="status != 'in-review'">
|
||||||
|
<output>
|
||||||
|
⚠️ Story is not in review status
|
||||||
|
|
||||||
|
Current status: {{status}}
|
||||||
|
Expected: in-review
|
||||||
|
|
||||||
|
Stories should be marked "in-review" by developers when complete.
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Proceed anyway? [y/N]:</ask>
|
||||||
|
<check if="not confirmed">
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>Search for linked PR:</action>
|
||||||
|
<action>Call: mcp__github__search_pull_requests({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} {{story_key}} OR closes:#{{issue.number}}"
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<action>pr = response.items[0] if exists</action>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="2" goal="Display Story for Review">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📋 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}}
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>
|
||||||
|
**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:
|
||||||
|
</ask>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="3" goal="Process Decision">
|
||||||
|
<check if="choice == 'A'">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
✅ Approving Story
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<substep n="3a" title="Update issue status">
|
||||||
|
<action>Update labels: remove status:in-review, add status:done</action>
|
||||||
|
<action>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]
|
||||||
|
})</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="3b" title="Add approval comment">
|
||||||
|
<action>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}}_"
|
||||||
|
})</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="3c" title="Merge PR if exists">
|
||||||
|
<check if="pr exists AND pr.state == 'open'">
|
||||||
|
<ask>Merge PR #{{pr.number}}? [Y/n]:</ask>
|
||||||
|
|
||||||
|
<check if="confirmed">
|
||||||
|
<action>Call: mcp__github__merge_pull_request({
|
||||||
|
owner: {{github_owner}},
|
||||||
|
repo: {{github_repo}},
|
||||||
|
pullNumber: {{pr.number}},
|
||||||
|
merge_method: "squash"
|
||||||
|
})</action>
|
||||||
|
<output>✅ PR #{{pr.number}} merged</output>
|
||||||
|
</check>
|
||||||
|
</check>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="3d" title="Release lock">
|
||||||
|
<action>Unassign developer from issue</action>
|
||||||
|
<action>Clear local lock file if exists</action>
|
||||||
|
<action>Update cache metadata</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="3e" title="Update sprint status">
|
||||||
|
<check if="{{sprint_status}} file exists">
|
||||||
|
<action>Update development_status[{{story_key}}] = "done"</action>
|
||||||
|
<output>✅ Sprint status updated</output>
|
||||||
|
</check>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
✅ 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!
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'R'">
|
||||||
|
<ask>What changes are needed?</ask>
|
||||||
|
<action>Store feedback</action>
|
||||||
|
|
||||||
|
<action>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}}_"
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<action>Update label: status:in-review → status:in-progress</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🔄 CHANGES REQUESTED
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
Story returned to developer for updates.
|
||||||
|
Developer @{{issue.assignee?.login}} notified.
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'D'">
|
||||||
|
<output>
|
||||||
|
Review deferred. Story remains in 'in-review' status.
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'V'">
|
||||||
|
<action>Show full issue body and all comments</action>
|
||||||
|
<action>Goto step 2 for another choice</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
</workflow>
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
# Dashboard - Sprint Progress Overview
|
||||||
|
|
||||||
|
<critical>The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml</critical>
|
||||||
|
<critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical>
|
||||||
|
<critical>PO WORKFLOW: Real-time visibility into sprint progress from GitHub</critical>
|
||||||
|
|
||||||
|
<workflow>
|
||||||
|
|
||||||
|
<step n="0" goal="Pre-Flight Checks">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📊 SPRINT DASHBOARD
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<substep n="0a" title="Verify GitHub access">
|
||||||
|
<action>Call: mcp__github__get_me()</action>
|
||||||
|
|
||||||
|
<check if="API call fails">
|
||||||
|
<output>❌ GitHub MCP not accessible</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<output>Connected as @{{current_user}}</output>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="1" goal="Fetch Sprint Data">
|
||||||
|
<substep n="1a" title="Fetch all stories by status">
|
||||||
|
<action>Fetch backlog stories:</action>
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:type:story label:status:backlog{{#if epic}} label:epic:{{epic}}{{/if}}"
|
||||||
|
})</action>
|
||||||
|
<action>backlog_stories = response.items</action>
|
||||||
|
|
||||||
|
<action>Fetch ready-for-dev stories:</action>
|
||||||
|
<action>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}}"
|
||||||
|
})</action>
|
||||||
|
<action>ready_stories = response.items</action>
|
||||||
|
|
||||||
|
<action>Fetch in-progress stories:</action>
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:type:story label:status:in-progress{{#if epic}} label:epic:{{epic}}{{/if}}"
|
||||||
|
})</action>
|
||||||
|
<action>in_progress_stories = response.items</action>
|
||||||
|
|
||||||
|
<action>Fetch in-review stories:</action>
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:type:story label:status:in-review{{#if epic}} label:epic:{{epic}}{{/if}}"
|
||||||
|
})</action>
|
||||||
|
<action>review_stories = response.items</action>
|
||||||
|
|
||||||
|
<action>Fetch done stories:</action>
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:type:story label:status:done{{#if epic}} label:epic:{{epic}}{{/if}}"
|
||||||
|
})</action>
|
||||||
|
<action>done_stories = response.items</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="1b" title="Calculate metrics">
|
||||||
|
<action>total_stories = all stories count</action>
|
||||||
|
<action>completed_count = done_stories.length</action>
|
||||||
|
<action>completion_pct = (completed_count / total_stories) * 100</action>
|
||||||
|
<action>active_developers = unique assignees from in_progress_stories</action>
|
||||||
|
<action>blocked_count = count stories with label:blocked</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="1c" title="Extract progress from comments">
|
||||||
|
<action>For each in_progress story:</action>
|
||||||
|
<action> - Get latest comment matching "Task X/Y complete"</action>
|
||||||
|
<action> - Extract progress percentage</action>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="2" goal="Display Dashboard">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📊 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
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
</workflow>
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,294 @@
|
||||||
|
# Epic Dashboard - Enterprise Progress Visibility
|
||||||
|
|
||||||
|
<critical>The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml</critical>
|
||||||
|
<critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical>
|
||||||
|
|
||||||
|
<workflow>
|
||||||
|
|
||||||
|
<step n="0" goal="Pre-Flight Checks">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📊 EPIC DASHBOARD
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__get_me()</action>
|
||||||
|
|
||||||
|
<check if="API call fails">
|
||||||
|
<output>❌ GitHub MCP not accessible - cannot fetch epic data</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="1" goal="Fetch All Epics">
|
||||||
|
<check if="epic_key is empty">
|
||||||
|
<action>Query all epics:</action>
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:type:epic is:open"
|
||||||
|
})</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="epic_key is not empty">
|
||||||
|
<action>Query specific epic:</action>
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:epic:{{epic_key}}"
|
||||||
|
})</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>epics = response.items</action>
|
||||||
|
|
||||||
|
<check if="epics.length == 0">
|
||||||
|
<output>
|
||||||
|
No epics found{{#if epic_key}} for epic:{{epic_key}}{{/if}}.
|
||||||
|
|
||||||
|
**Tip:** Create epics as GitHub Issues with label `type:epic`
|
||||||
|
</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="2" goal="Aggregate Epic Metrics">
|
||||||
|
<action>For each epic, fetch stories:</action>
|
||||||
|
|
||||||
|
<substep n="2a" title="Fetch stories per epic">
|
||||||
|
<action>
|
||||||
|
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
|
||||||
|
</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="2b" title="Calculate risk indicators">
|
||||||
|
<action>
|
||||||
|
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"
|
||||||
|
})
|
||||||
|
</action>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="3" goal="Display Epic Overview">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📊 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}}
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="4" goal="Show Burndown (if enabled)">
|
||||||
|
<check if="show_burndown == true">
|
||||||
|
<substep n="4a" title="Calculate burndown">
|
||||||
|
<action>
|
||||||
|
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
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📈 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}}
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="5" goal="Show Detailed Story List (if enabled)">
|
||||||
|
<check if="show_details == true">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📋 STORY DETAILS
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
{{#each epics}}
|
||||||
|
## Epic {{epic_key}}: {{title}}
|
||||||
|
|
||||||
|
{{#each stories}}
|
||||||
|
| {{story_key}} | {{title}} | {{status}} | @{{assignee.login or "-"}} |
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
{{/each}}
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="6" goal="Interactive Menu">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
**Actions:**
|
||||||
|
[E] View specific Epic (enter epic key)
|
||||||
|
[D] Toggle story Details
|
||||||
|
[B] Toggle Burndown
|
||||||
|
[R] Refresh data
|
||||||
|
[Q] Quit
|
||||||
|
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>Choice:</ask>
|
||||||
|
|
||||||
|
<check if="choice == 'E'">
|
||||||
|
<ask>Enter epic key (e.g., 2):</ask>
|
||||||
|
<action>Set epic_key = input</action>
|
||||||
|
<action>Goto step 1 (refetch with filter)</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'D'">
|
||||||
|
<action>Toggle show_details</action>
|
||||||
|
<action>Goto step 3</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'B'">
|
||||||
|
<action>Toggle show_burndown</action>
|
||||||
|
<action>Goto step 3</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'R'">
|
||||||
|
<action>Goto step 1 (refresh)</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 'Q'">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
Epic Dashboard closed.
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
<action>Exit</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
</workflow>
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,381 @@
|
||||||
|
# New Story - Create Story in GitHub Issues
|
||||||
|
|
||||||
|
<critical>The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml</critical>
|
||||||
|
<critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical>
|
||||||
|
<critical>PO WORKFLOW: Creates stories directly in GitHub as source of truth</critical>
|
||||||
|
|
||||||
|
<workflow>
|
||||||
|
|
||||||
|
<step n="0" goal="Pre-Flight Checks">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📝 NEW STORY - Create in GitHub Issues
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<substep n="0a" title="Verify GitHub MCP access">
|
||||||
|
<action>Call: mcp__github__get_me()</action>
|
||||||
|
|
||||||
|
<check if="API call fails">
|
||||||
|
<output>
|
||||||
|
❌ CRITICAL: GitHub MCP not accessible
|
||||||
|
|
||||||
|
Cannot create stories without GitHub API access.
|
||||||
|
|
||||||
|
HALTING
|
||||||
|
</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>current_user = response.login</action>
|
||||||
|
<output>✅ GitHub connected as @{{current_user}}</output>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="0b" title="Load epic context">
|
||||||
|
<action>Search for existing epics/milestones:</action>
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:type:epic"
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<action>existing_epics = response.items.map(e => extract epic number from labels)</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
📁 Existing Epics:
|
||||||
|
{{#each existing_epics}}
|
||||||
|
- Epic {{number}}: {{title}}
|
||||||
|
{{else}}
|
||||||
|
No epics found - you may need to run /migrate-to-github first
|
||||||
|
{{/each}}
|
||||||
|
</output>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="1" goal="Gather Story Information">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📋 Story Details
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>
|
||||||
|
**Which epic does this story belong to?**
|
||||||
|
|
||||||
|
{{#each existing_epics}}
|
||||||
|
[{{number}}] Epic {{number}}: {{title}}
|
||||||
|
{{/each}}
|
||||||
|
[N] New epic (will create)
|
||||||
|
|
||||||
|
Enter epic number:
|
||||||
|
</ask>
|
||||||
|
|
||||||
|
<action>Store {{epic_number}}</action>
|
||||||
|
|
||||||
|
<ask>
|
||||||
|
**What is the story title?**
|
||||||
|
|
||||||
|
Keep it concise and descriptive.
|
||||||
|
Example: "User password reset via email"
|
||||||
|
|
||||||
|
Title:
|
||||||
|
</ask>
|
||||||
|
|
||||||
|
<action>Store {{story_title}}</action>
|
||||||
|
|
||||||
|
<ask>
|
||||||
|
**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:
|
||||||
|
</ask>
|
||||||
|
|
||||||
|
<action>Store {{user_story}}</action>
|
||||||
|
|
||||||
|
<ask>
|
||||||
|
**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:
|
||||||
|
</ask>
|
||||||
|
|
||||||
|
<action>Store {{business_context}}</action>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="2" goal="Define Acceptance Criteria (BDD)">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
✅ Acceptance Criteria (BDD Format)
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>
|
||||||
|
**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):
|
||||||
|
</ask>
|
||||||
|
|
||||||
|
<action>Store {{acceptance_criteria}}</action>
|
||||||
|
|
||||||
|
<action>Parse and validate ACs have Given/When/Then structure</action>
|
||||||
|
|
||||||
|
<check if="ACs don't follow BDD format">
|
||||||
|
<output>
|
||||||
|
⚠️ Acceptance criteria should follow Given/When/Then format.
|
||||||
|
|
||||||
|
Let me help restructure these...
|
||||||
|
</output>
|
||||||
|
<action>Suggest BDD-formatted version of provided ACs</action>
|
||||||
|
<ask>Use this restructured version? [Y/n]</ask>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="3" goal="Estimate Complexity">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📊 Story Sizing
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<ask>
|
||||||
|
**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]:
|
||||||
|
</ask>
|
||||||
|
|
||||||
|
<action>Map to complexity label:
|
||||||
|
1 → complexity:micro
|
||||||
|
2 → complexity:small
|
||||||
|
3 → complexity:medium
|
||||||
|
4 → complexity:large
|
||||||
|
5 → "Story is too large, should be split"
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<check if="complexity == 5">
|
||||||
|
<output>
|
||||||
|
⚠️ 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?
|
||||||
|
</output>
|
||||||
|
<ask>Continue anyway [C] or Break down [B]?</ask>
|
||||||
|
|
||||||
|
<check if="break down">
|
||||||
|
<action>Help user identify sub-stories</action>
|
||||||
|
<action>HALT - Create sub-stories instead</action>
|
||||||
|
</check>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="4" goal="Generate Story Key">
|
||||||
|
<substep n="4a" title="Determine next story number">
|
||||||
|
<action>Search for existing stories in this epic:</action>
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:epic:{{epic_number}} label:type:story"
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<action>Extract story numbers from labels (e.g., story:2-5-auth → 5)</action>
|
||||||
|
<action>next_story_number = max(story_numbers) + 1 OR 1 if no stories</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="4b" title="Generate story slug">
|
||||||
|
<action>Create slug from title:</action>
|
||||||
|
<action>slug = story_title.toLowerCase().replace(/[^a-z0-9]+/g, '-').substring(0, 20)</action>
|
||||||
|
<action>story_key = "{{epic_number}}-{{next_story_number}}-{{slug}}"</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
📎 Story Key: {{story_key}}
|
||||||
|
</output>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="5" goal="Create GitHub Issue">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🚀 Creating GitHub Issue
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<substep n="5a" title="Generate issue body">
|
||||||
|
<action>Format issue body:</action>
|
||||||
|
<action>
|
||||||
|
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`_
|
||||||
|
"""
|
||||||
|
</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="5b" title="Create issue with labels">
|
||||||
|
<action>Create GitHub Issue:</action>
|
||||||
|
<action>
|
||||||
|
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
|
||||||
|
})
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>issue_number = response.number</action>
|
||||||
|
<action>issue_url = response.html_url</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="5c" title="Verify creation">
|
||||||
|
<action>Wait 1 second for GitHub eventual consistency</action>
|
||||||
|
<action>Call: mcp__github__issue_read({
|
||||||
|
method: "get",
|
||||||
|
owner: {{github_owner}},
|
||||||
|
repo: {{github_repo}},
|
||||||
|
issue_number: {{issue_number}}
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<check if="verification fails">
|
||||||
|
<output>
|
||||||
|
❌ Issue creation verification failed
|
||||||
|
|
||||||
|
The issue may not have been created properly.
|
||||||
|
Please check GitHub directly.
|
||||||
|
</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<output>✅ GitHub Issue #{{issue_number}} created and verified</output>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="6" goal="Sync to Local Cache">
|
||||||
|
<substep n="6a" title="Create local story file">
|
||||||
|
<action>Create cache file: {{cache_dir}}/stories/{{story_key}}.md</action>
|
||||||
|
<action>Content = converted issue body to BMAD story format</action>
|
||||||
|
<output>✅ Cached: {{cache_dir}}/stories/{{story_key}}.md</output>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="6b" title="Update cache metadata">
|
||||||
|
<action>Update {{cache_dir}}/.bmad-cache-meta.json:</action>
|
||||||
|
<action>
|
||||||
|
meta.stories[{{story_key}}] = {
|
||||||
|
github_issue: {{issue_number}},
|
||||||
|
github_updated_at: now(),
|
||||||
|
cache_timestamp: now(),
|
||||||
|
locked_by: null,
|
||||||
|
locked_until: null
|
||||||
|
}
|
||||||
|
</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="6c" title="Update sprint-status.yaml">
|
||||||
|
<check if="{{sprint_status}} file exists">
|
||||||
|
<action>Add story to sprint-status.yaml:</action>
|
||||||
|
<action>
|
||||||
|
development_status:
|
||||||
|
{{story_key}}: backlog # New story
|
||||||
|
</action>
|
||||||
|
<output>✅ Added to sprint-status.yaml</output>
|
||||||
|
</check>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="7" goal="Completion">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
✅ 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}}
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
</workflow>
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
# Sync from GitHub - Update Local Cache
|
||||||
|
|
||||||
|
<critical>The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml</critical>
|
||||||
|
<critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical>
|
||||||
|
|
||||||
|
<workflow>
|
||||||
|
|
||||||
|
<step n="0" goal="Pre-Flight Checks">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
🔄 SYNC FROM GITHUB
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__get_me()</action>
|
||||||
|
|
||||||
|
<check if="API call fails">
|
||||||
|
<output>❌ GitHub MCP not accessible</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>Load cache metadata from {{cache_dir}}/.bmad-cache-meta.json</action>
|
||||||
|
<action>last_sync = cache_meta.last_sync</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Last sync: {{last_sync or "Never"}}
|
||||||
|
Mode: {{#if full_sync}}Full sync{{else}}Incremental{{/if}}
|
||||||
|
{{#if epic}}Filter: Epic {{epic}}{{/if}}
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="1" goal="Fetch Changes">
|
||||||
|
<check if="full_sync == true">
|
||||||
|
<output>🔄 Performing full sync...</output>
|
||||||
|
<action>Query all stories</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="full_sync != true AND last_sync exists">
|
||||||
|
<output>🔄 Fetching stories updated since {{last_sync}}...</output>
|
||||||
|
<action>Query stories updated since last_sync</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="full_sync != true AND last_sync is null">
|
||||||
|
<output>🔄 No previous sync - performing initial full sync...</output>
|
||||||
|
<action>Query all stories (first time sync)</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<substep n="1a" title="Search for updated stories">
|
||||||
|
<action>Build query:</action>
|
||||||
|
<action>
|
||||||
|
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}}"
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__search_issues({ query: query })</action>
|
||||||
|
<action>updated_stories = response.items</action>
|
||||||
|
|
||||||
|
<output>Found {{updated_stories.length}} stories to sync</output>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="2" goal="Sync Stories to Cache">
|
||||||
|
<check if="updated_stories.length == 0">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
✅ Cache is up to date
|
||||||
|
|
||||||
|
No changes since last sync ({{last_sync}})
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
<action>Update last_sync timestamp</action>
|
||||||
|
<action>Exit</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📥 Syncing Stories
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<action>For each story in updated_stories:</action>
|
||||||
|
|
||||||
|
<substep n="2a" title="Process each story">
|
||||||
|
<action>
|
||||||
|
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}})"
|
||||||
|
</action>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="2b" title="Update sync metadata">
|
||||||
|
<action>cache_meta.last_sync = now()</action>
|
||||||
|
<action>Save cache_meta to {{cache_dir}}/.bmad-cache-meta.json</action>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="3" goal="Sync Complete">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
✅ 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
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
</workflow>
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,214 @@
|
||||||
|
# Update Story - Modify Story in GitHub
|
||||||
|
|
||||||
|
<critical>The workflow execution engine is governed by: {project-root}/_bmad/core/tasks/workflow.xml</critical>
|
||||||
|
<critical>You MUST have already loaded and processed: {installed_path}/workflow.yaml</critical>
|
||||||
|
<critical>PO WORKFLOW: Updates notify developers if story is in progress</critical>
|
||||||
|
|
||||||
|
<workflow>
|
||||||
|
|
||||||
|
<step n="0" goal="Pre-Flight Checks">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
✏️ UPDATE STORY
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<check if="story_key is empty">
|
||||||
|
<output>
|
||||||
|
❌ ERROR: story_key parameter required
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
/update-story story_key=2-5-auth
|
||||||
|
|
||||||
|
HALTING
|
||||||
|
</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>Call: mcp__github__get_me()</action>
|
||||||
|
<action>current_user = response.login</action>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="1" goal="Fetch Current Story">
|
||||||
|
<action>Call: mcp__github__search_issues({
|
||||||
|
query: "repo:{{github_owner}}/{{github_repo}} label:story:{{story_key}}"
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<check if="no results">
|
||||||
|
<output>❌ Story {{story_key}} not found in GitHub</output>
|
||||||
|
<action>HALT</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<action>issue = response.items[0]</action>
|
||||||
|
<action>is_locked = issue.assignee != null</action>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
📋 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}}
|
||||||
|
---
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<check if="is_locked">
|
||||||
|
<output>
|
||||||
|
⚠️ WARNING: Story is currently locked by @{{issue.assignee.login}}
|
||||||
|
|
||||||
|
Changes will be synced and developer notified.
|
||||||
|
Consider discussing significant changes before updating.
|
||||||
|
</output>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="2" goal="Collect Updates">
|
||||||
|
<ask>
|
||||||
|
**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]:
|
||||||
|
</ask>
|
||||||
|
|
||||||
|
<check if="choice == 1">
|
||||||
|
<output>Current ACs:</output>
|
||||||
|
<action>Display current acceptance criteria from issue body</action>
|
||||||
|
|
||||||
|
<ask>
|
||||||
|
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:
|
||||||
|
</ask>
|
||||||
|
|
||||||
|
<action>Collect AC changes based on choice</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 2">
|
||||||
|
<ask>New title:</ask>
|
||||||
|
<action>Store new_title</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 3">
|
||||||
|
<ask>Updated business context:</ask>
|
||||||
|
<action>Store new_context</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 4">
|
||||||
|
<ask>
|
||||||
|
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:
|
||||||
|
</ask>
|
||||||
|
<action>Store new_status</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 5">
|
||||||
|
<ask>
|
||||||
|
New complexity:
|
||||||
|
|
||||||
|
[1] micro
|
||||||
|
[2] small
|
||||||
|
[3] medium
|
||||||
|
[4] large
|
||||||
|
|
||||||
|
Choice:
|
||||||
|
</ask>
|
||||||
|
<action>Store new_complexity</action>
|
||||||
|
</check>
|
||||||
|
|
||||||
|
<check if="choice == 6">
|
||||||
|
<ask>Describe the changes you want to make:</ask>
|
||||||
|
<action>Parse and apply custom changes</action>
|
||||||
|
</check>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="3" goal="Apply Updates">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
📝 Applying Updates
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
|
||||||
|
<substep n="3a" title="Update GitHub Issue">
|
||||||
|
<action>Build updated issue data based on changes</action>
|
||||||
|
|
||||||
|
<action>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}}
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<output>✅ Issue #{{issue.number}} updated</output>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="3b" title="Notify developer if locked">
|
||||||
|
<check if="is_locked">
|
||||||
|
<action>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}}_"
|
||||||
|
})</action>
|
||||||
|
|
||||||
|
<output>📧 Developer @{{issue.assignee.login}} notified of changes</output>
|
||||||
|
</check>
|
||||||
|
</substep>
|
||||||
|
|
||||||
|
<substep n="3c" title="Update local cache">
|
||||||
|
<action>Invalidate cache for {{story_key}}</action>
|
||||||
|
<action>Re-sync story to cache</action>
|
||||||
|
<output>✅ Local cache updated</output>
|
||||||
|
</substep>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
<step n="4" goal="Completion">
|
||||||
|
<output>
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
✅ 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}}`
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
</output>
|
||||||
|
</step>
|
||||||
|
|
||||||
|
</workflow>
|
||||||
|
|
@ -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
|
||||||
167
test/README.md
167
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
|
## 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
|
## Test Statistics
|
||||||
|
|
||||||
|
|
@ -293,3 +318,139 @@ All success criteria from the original task have been exceeded:
|
||||||
- **Validator Implementation**: `tools/schema/agent.js`
|
- **Validator Implementation**: `tools/schema/agent.js`
|
||||||
- **CLI Tool**: `tools/validate-agent-schema.js`
|
- **CLI Tool**: `tools/validate-agent-schema.js`
|
||||||
- **Project Guidelines**: `CLAUDE.md`
|
- **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 })
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -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", <tags>, & 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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('<!DOCTYPE html>');
|
||||||
|
expect(template.html).toContain('<html>');
|
||||||
|
expect(template.html).toContain('</html>');
|
||||||
|
expect(template.html).toContain('<body>');
|
||||||
|
expect(template.html).toContain('</body>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have styled content in HTML templates', () => {
|
||||||
|
const template = EMAIL_TEMPLATES.feedback_round_opened;
|
||||||
|
|
||||||
|
expect(template.html).toContain('<style>');
|
||||||
|
expect(template.html).toContain('class="button"');
|
||||||
|
expect(template.html).toContain('class="container"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have plain text format in text templates', () => {
|
||||||
|
const template = EMAIL_TEMPLATES.reminder;
|
||||||
|
|
||||||
|
// Text template should not contain HTML tags
|
||||||
|
expect(template.text).not.toContain('<div');
|
||||||
|
expect(template.text).not.toContain('<style');
|
||||||
|
expect(template.text).toContain('REMINDER');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ Constructor Tests ============
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('should initialize with SMTP config', () => {
|
||||||
|
const notifier = new EmailNotifier({
|
||||||
|
provider: 'smtp',
|
||||||
|
smtp: {
|
||||||
|
host: 'smtp.example.com',
|
||||||
|
port: 587
|
||||||
|
},
|
||||||
|
fromAddress: 'noreply@example.com',
|
||||||
|
fromName: 'PRD System'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(notifier.provider).toBe('smtp');
|
||||||
|
expect(notifier.smtp).toEqual({ host: 'smtp.example.com', port: 587 });
|
||||||
|
expect(notifier.fromAddress).toBe('noreply@example.com');
|
||||||
|
expect(notifier.fromName).toBe('PRD System');
|
||||||
|
expect(notifier.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize with API key config', () => {
|
||||||
|
const notifier = new EmailNotifier({
|
||||||
|
provider: 'sendgrid',
|
||||||
|
apiKey: 'SG.xxx',
|
||||||
|
fromAddress: 'noreply@example.com'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(notifier.provider).toBe('sendgrid');
|
||||||
|
expect(notifier.apiKey).toBe('SG.xxx');
|
||||||
|
expect(notifier.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default values', () => {
|
||||||
|
const notifier = new EmailNotifier({
|
||||||
|
smtp: { host: 'localhost' }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(notifier.provider).toBe('smtp');
|
||||||
|
expect(notifier.fromAddress).toBe('noreply@example.com');
|
||||||
|
expect(notifier.fromName).toBe('PRD Crowdsourcing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be disabled without SMTP or API key', () => {
|
||||||
|
const notifier = new EmailNotifier({});
|
||||||
|
|
||||||
|
expect(notifier.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept userEmails mapping', () => {
|
||||||
|
const notifier = new EmailNotifier({
|
||||||
|
smtp: { host: 'localhost' },
|
||||||
|
userEmails: {
|
||||||
|
'alice': 'alice@example.com',
|
||||||
|
'bob': 'bob@example.com'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(notifier.userEmails['alice']).toBe('alice@example.com');
|
||||||
|
expect(notifier.userEmails['bob']).toBe('bob@example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ isEnabled Tests ============
|
||||||
|
|
||||||
|
describe('isEnabled', () => {
|
||||||
|
it('should return true when SMTP configured', () => {
|
||||||
|
const notifier = new EmailNotifier({
|
||||||
|
smtp: { host: 'localhost' }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(notifier.isEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when API key configured', () => {
|
||||||
|
const notifier = new EmailNotifier({
|
||||||
|
apiKey: 'xxx'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(notifier.isEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when not configured', () => {
|
||||||
|
const notifier = new EmailNotifier({});
|
||||||
|
|
||||||
|
expect(notifier.isEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ send Tests ============
|
||||||
|
|
||||||
|
describe('send', () => {
|
||||||
|
let notifier;
|
||||||
|
let consoleSpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
notifier = new EmailNotifier({
|
||||||
|
provider: 'smtp',
|
||||||
|
smtp: { host: 'localhost', port: 587 },
|
||||||
|
fromAddress: 'noreply@example.com',
|
||||||
|
userEmails: {
|
||||||
|
'alice': 'alice@example.com',
|
||||||
|
'bob': 'bob@example.com'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error when not enabled', async () => {
|
||||||
|
const disabledNotifier = new EmailNotifier({});
|
||||||
|
|
||||||
|
const result = await disabledNotifier.send('feedback_round_opened', {});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('not enabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error for unknown event type', async () => {
|
||||||
|
const result = await notifier.send('unknown_event', {}, {
|
||||||
|
recipients: ['test@example.com']
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('Unknown notification event type');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error when no recipients', async () => {
|
||||||
|
const result = await notifier.send('feedback_round_opened', {
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'test'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('No recipients');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send email with direct recipients', async () => {
|
||||||
|
const result = await notifier.send('feedback_round_opened', {
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'user-auth',
|
||||||
|
version: 1,
|
||||||
|
deadline: '2026-01-15',
|
||||||
|
document_url: 'https://example.com/doc'
|
||||||
|
}, {
|
||||||
|
recipients: ['direct@example.com']
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.channel).toBe('email');
|
||||||
|
expect(result.recipientCount).toBe(1);
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('[EMAIL]'),
|
||||||
|
expect.stringContaining('Feedback Requested'),
|
||||||
|
expect.any(String),
|
||||||
|
expect.stringContaining('direct@example.com')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should lookup user emails from data.users', async () => {
|
||||||
|
const result = await notifier.send('signoff_requested', {
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'test',
|
||||||
|
version: 1,
|
||||||
|
deadline: '2026-01-15',
|
||||||
|
document_url: 'https://example.com/doc',
|
||||||
|
users: ['alice', 'bob']
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.recipientCount).toBe(2);
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.stringContaining('alice@example.com')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter out unknown users', async () => {
|
||||||
|
const result = await notifier.send('document_approved', {
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'test',
|
||||||
|
title: 'Test PRD',
|
||||||
|
version: 2,
|
||||||
|
approval_count: 3,
|
||||||
|
stakeholder_count: 3,
|
||||||
|
document_url: 'https://example.com/doc',
|
||||||
|
users: ['alice', 'unknown-user'] // unknown-user not in mapping
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.recipientCount).toBe(1); // Only alice
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render template with data', async () => {
|
||||||
|
await notifier.send('document_blocked', {
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'payments',
|
||||||
|
user: 'legal',
|
||||||
|
reason: 'Compliance review needed',
|
||||||
|
feedback_url: 'https://example.com/feedback/1'
|
||||||
|
}, {
|
||||||
|
recipients: ['test@example.com']
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.stringContaining('[prd:payments]'),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ sendCustom Tests ============
|
||||||
|
|
||||||
|
describe('sendCustom', () => {
|
||||||
|
let notifier;
|
||||||
|
let consoleSpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
notifier = new EmailNotifier({
|
||||||
|
provider: 'smtp',
|
||||||
|
smtp: { host: 'localhost' }
|
||||||
|
});
|
||||||
|
|
||||||
|
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error when not enabled', async () => {
|
||||||
|
const disabledNotifier = new EmailNotifier({});
|
||||||
|
|
||||||
|
const result = await disabledNotifier.sendCustom(['test@example.com'], 'Subject', 'Body');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('not enabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send custom email', async () => {
|
||||||
|
const result = await notifier.sendCustom(
|
||||||
|
['user1@example.com', 'user2@example.com'],
|
||||||
|
'Custom Subject',
|
||||||
|
'Custom body content'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.recipientCount).toBe(2);
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.stringContaining('Custom Subject'),
|
||||||
|
expect.anything(),
|
||||||
|
expect.stringContaining('user1@example.com, user2@example.com')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle HTML option', async () => {
|
||||||
|
const result = await notifier.sendCustom(
|
||||||
|
['test@example.com'],
|
||||||
|
'HTML Email',
|
||||||
|
'<h1>Hello</h1>',
|
||||||
|
{ html: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ getEmailForUser / setEmailForUser Tests ============
|
||||||
|
|
||||||
|
describe('user email management', () => {
|
||||||
|
let notifier;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
notifier = new EmailNotifier({
|
||||||
|
smtp: { host: 'localhost' },
|
||||||
|
userEmails: {
|
||||||
|
'existing': 'existing@example.com'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get email for known user', () => {
|
||||||
|
expect(notifier.getEmailForUser('existing')).toBe('existing@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for unknown user', () => {
|
||||||
|
expect(notifier.getEmailForUser('unknown')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set email for user', () => {
|
||||||
|
notifier.setEmailForUser('new-user', 'new@example.com');
|
||||||
|
|
||||||
|
expect(notifier.getEmailForUser('new-user')).toBe('new@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update existing user email', () => {
|
||||||
|
notifier.setEmailForUser('existing', 'updated@example.com');
|
||||||
|
|
||||||
|
expect(notifier.getEmailForUser('existing')).toBe('updated@example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ _renderTemplate Tests ============
|
||||||
|
|
||||||
|
describe('_renderTemplate', () => {
|
||||||
|
let notifier;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
notifier = new EmailNotifier({ smtp: { host: 'localhost' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace simple variables', () => {
|
||||||
|
const template = 'Hello {{name}}, your order is {{status}}';
|
||||||
|
const result = notifier._renderTemplate(template, {
|
||||||
|
name: 'Alice',
|
||||||
|
status: 'complete'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe('Hello Alice, your order is complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep placeholder when variable not found', () => {
|
||||||
|
const template = 'Document: {{document_key}}, Version: {{version}}';
|
||||||
|
const result = notifier._renderTemplate(template, {
|
||||||
|
document_key: 'test'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe('Document: test, Version: {{version}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle HTML content', () => {
|
||||||
|
const template = '<div class="title">{{title}}</div><p>{{content}}</p>';
|
||||||
|
const result = notifier._renderTemplate(template, {
|
||||||
|
title: 'Welcome',
|
||||||
|
content: 'This is the body'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe('<div class="title">Welcome</div><p>This is the body</p>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ Provider Tests ============
|
||||||
|
|
||||||
|
describe('email providers', () => {
|
||||||
|
let consoleSpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use SMTP provider', async () => {
|
||||||
|
const notifier = new EmailNotifier({
|
||||||
|
provider: 'smtp',
|
||||||
|
smtp: { host: 'smtp.example.com' }
|
||||||
|
});
|
||||||
|
|
||||||
|
await notifier.sendCustom(['test@example.com'], 'Test', 'Body');
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('SMTP'),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use SendGrid provider', async () => {
|
||||||
|
const notifier = new EmailNotifier({
|
||||||
|
provider: 'sendgrid',
|
||||||
|
apiKey: 'SG.xxx'
|
||||||
|
});
|
||||||
|
|
||||||
|
await notifier.sendCustom(['test@example.com'], 'Test', 'Body');
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('SendGrid'),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use SES provider', async () => {
|
||||||
|
const notifier = new EmailNotifier({
|
||||||
|
provider: 'ses',
|
||||||
|
apiKey: 'aws-key'
|
||||||
|
});
|
||||||
|
|
||||||
|
await notifier.sendCustom(['test@example.com'], 'Test', 'Body');
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('SES'),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for unknown provider', async () => {
|
||||||
|
const notifier = new EmailNotifier({
|
||||||
|
provider: 'unknown-provider',
|
||||||
|
apiKey: 'xxx'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await notifier.sendCustom(['test@example.com'], 'Test', 'Body');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('Unknown email provider');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ Integration Tests ============
|
||||||
|
|
||||||
|
describe('integration', () => {
|
||||||
|
let notifier;
|
||||||
|
let consoleSpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
notifier = new EmailNotifier({
|
||||||
|
provider: 'smtp',
|
||||||
|
smtp: { host: 'localhost' },
|
||||||
|
fromAddress: 'prd-bot@company.com',
|
||||||
|
fromName: 'PRD System',
|
||||||
|
userEmails: {
|
||||||
|
'po': 'po@company.com',
|
||||||
|
'tech-lead': 'tech@company.com',
|
||||||
|
'security': 'security@company.com'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send feedback_round_opened to stakeholders', async () => {
|
||||||
|
const result = await notifier.send('feedback_round_opened', {
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'user-auth',
|
||||||
|
version: 1,
|
||||||
|
deadline: '2026-01-15',
|
||||||
|
document_url: 'https://example.com/doc',
|
||||||
|
unsubscribe_url: 'https://example.com/unsubscribe',
|
||||||
|
users: ['po', 'tech-lead', 'security']
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.recipientCount).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send document_blocked with blocking details', async () => {
|
||||||
|
const result = await notifier.send('document_blocked', {
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'payments-v2',
|
||||||
|
user: 'security',
|
||||||
|
reason: 'PCI DSS compliance verification required before approval',
|
||||||
|
feedback_url: 'https://example.com/issues/42',
|
||||||
|
unsubscribe_url: 'https://example.com/unsubscribe'
|
||||||
|
}, {
|
||||||
|
recipients: ['po@company.com']
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
// Verify the subject was rendered
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.stringContaining('[prd:payments-v2]'),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send reminder with urgency', async () => {
|
||||||
|
const result = await notifier.send('reminder', {
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'mobile-app',
|
||||||
|
action_needed: 'sign-off',
|
||||||
|
deadline: '2026-01-10',
|
||||||
|
time_remaining: '24 hours',
|
||||||
|
document_url: 'https://example.com/doc',
|
||||||
|
unsubscribe_url: 'https://example.com/unsubscribe',
|
||||||
|
users: ['tech-lead']
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,476 @@
|
||||||
|
/**
|
||||||
|
* Tests for GitHubNotifier - Baseline notification via GitHub @mentions
|
||||||
|
*
|
||||||
|
* Tests cover:
|
||||||
|
* - Notification templates for all event types
|
||||||
|
* - Template rendering with variable substitution
|
||||||
|
* - Conditional rendering ({{#if}})
|
||||||
|
* - Array rendering ({{#each}})
|
||||||
|
* - Comment and issue creation
|
||||||
|
* - Error handling
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
GitHubNotifier,
|
||||||
|
NOTIFICATION_TEMPLATES
|
||||||
|
} from '../../../src/modules/bmm/lib/notifications/github-notifier.js';
|
||||||
|
|
||||||
|
describe('GitHubNotifier', () => {
|
||||||
|
// ============ NOTIFICATION_TEMPLATES Tests ============
|
||||||
|
|
||||||
|
describe('NOTIFICATION_TEMPLATES', () => {
|
||||||
|
it('should define all required event types', () => {
|
||||||
|
const expectedTypes = [
|
||||||
|
'feedback_round_opened',
|
||||||
|
'feedback_submitted',
|
||||||
|
'synthesis_complete',
|
||||||
|
'signoff_requested',
|
||||||
|
'signoff_received',
|
||||||
|
'document_approved',
|
||||||
|
'document_blocked',
|
||||||
|
'reminder',
|
||||||
|
'deadline_extended'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const type of expectedTypes) {
|
||||||
|
expect(NOTIFICATION_TEMPLATES[type]).toBeDefined();
|
||||||
|
expect(NOTIFICATION_TEMPLATES[type].subject).toBeTruthy();
|
||||||
|
expect(NOTIFICATION_TEMPLATES[type].template).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have placeholders in templates', () => {
|
||||||
|
const template = NOTIFICATION_TEMPLATES.feedback_round_opened.template;
|
||||||
|
|
||||||
|
expect(template).toContain('{{mentions}}');
|
||||||
|
expect(template).toContain('{{document_type}}');
|
||||||
|
expect(template).toContain('{{document_key}}');
|
||||||
|
expect(template).toContain('{{deadline}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have conditional blocks in relevant templates', () => {
|
||||||
|
const template = NOTIFICATION_TEMPLATES.signoff_received.template;
|
||||||
|
|
||||||
|
expect(template).toContain('{{#if note}}');
|
||||||
|
expect(template).toContain('{{/if}}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ Constructor Tests ============
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('should initialize with config', () => {
|
||||||
|
const mockGithub = { addIssueComment: vi.fn() };
|
||||||
|
const notifier = new GitHubNotifier({
|
||||||
|
owner: 'test-org',
|
||||||
|
repo: 'test-repo',
|
||||||
|
github: mockGithub
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(notifier.owner).toBe('test-org');
|
||||||
|
expect(notifier.repo).toBe('test-repo');
|
||||||
|
expect(notifier.github).toBe(mockGithub);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ send Tests ============
|
||||||
|
|
||||||
|
describe('send', () => {
|
||||||
|
let notifier;
|
||||||
|
let mockGithub;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGithub = {
|
||||||
|
addIssueComment: vi.fn().mockResolvedValue({ id: 123 }),
|
||||||
|
createIssue: vi.fn().mockResolvedValue({ number: 456 })
|
||||||
|
};
|
||||||
|
|
||||||
|
notifier = new GitHubNotifier({
|
||||||
|
owner: 'test-org',
|
||||||
|
repo: 'test-repo',
|
||||||
|
github: mockGithub
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for unknown event type', async () => {
|
||||||
|
await expect(
|
||||||
|
notifier.send('unknown_event', {})
|
||||||
|
).rejects.toThrow('Unknown notification event type: unknown_event');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should post comment when issueNumber provided', async () => {
|
||||||
|
const result = await notifier.send('feedback_round_opened', {
|
||||||
|
mentions: '@alice @bob',
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'user-auth',
|
||||||
|
version: 1,
|
||||||
|
deadline: '2026-01-15',
|
||||||
|
document_url: 'https://example.com/doc'
|
||||||
|
}, { issueNumber: 100 });
|
||||||
|
|
||||||
|
expect(mockGithub.addIssueComment).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockGithub.addIssueComment).toHaveBeenCalledWith({
|
||||||
|
owner: 'test-org',
|
||||||
|
repo: 'test-repo',
|
||||||
|
issue_number: 100,
|
||||||
|
body: expect.stringContaining('Feedback Round Open')
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.channel).toBe('github');
|
||||||
|
expect(result.type).toBe('comment');
|
||||||
|
expect(result.commentId).toBe(123);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create issue when createIssue option provided', async () => {
|
||||||
|
const result = await notifier.send('document_approved', {
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'user-auth',
|
||||||
|
title: 'User Authentication',
|
||||||
|
version: 2,
|
||||||
|
approval_count: 5,
|
||||||
|
stakeholder_count: 5,
|
||||||
|
document_url: 'https://example.com/doc'
|
||||||
|
}, { createIssue: true, labels: ['notification', 'approved'] });
|
||||||
|
|
||||||
|
expect(mockGithub.createIssue).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockGithub.createIssue).toHaveBeenCalledWith({
|
||||||
|
owner: 'test-org',
|
||||||
|
repo: 'test-repo',
|
||||||
|
title: expect.stringContaining('Document Approved'),
|
||||||
|
body: expect.stringContaining('User Authentication'),
|
||||||
|
labels: ['notification', 'approved']
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.type).toBe('issue');
|
||||||
|
expect(result.issueNumber).toBe(456);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use review_issue from data when no options specified', async () => {
|
||||||
|
await notifier.send('feedback_submitted', {
|
||||||
|
user: 'alice',
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'test',
|
||||||
|
feedback_type: 'concern',
|
||||||
|
section: 'FR-3',
|
||||||
|
summary: 'Security issue found',
|
||||||
|
feedback_issue: 42,
|
||||||
|
feedback_url: 'https://example.com/feedback/42',
|
||||||
|
review_issue: 100
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockGithub.addIssueComment).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockGithub.addIssueComment.mock.calls[0][0].issue_number).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return message when no target specified', async () => {
|
||||||
|
const result = await notifier.send('deadline_extended', {
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'test',
|
||||||
|
old_deadline: '2026-01-10',
|
||||||
|
new_deadline: '2026-01-20',
|
||||||
|
document_url: 'https://example.com/doc'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toBeTruthy();
|
||||||
|
expect(result.note).toContain('No target issue specified');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle GitHub API error', async () => {
|
||||||
|
mockGithub.addIssueComment.mockRejectedValue(new Error('API rate limit'));
|
||||||
|
|
||||||
|
const result = await notifier.send('reminder', {
|
||||||
|
mentions: '@alice',
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'test',
|
||||||
|
action_needed: 'feedback',
|
||||||
|
deadline: '2026-01-15',
|
||||||
|
time_remaining: '2 days',
|
||||||
|
document_url: 'https://example.com/doc'
|
||||||
|
}, { issueNumber: 100 });
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe('API rate limit');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ sendReminder Tests ============
|
||||||
|
|
||||||
|
describe('sendReminder', () => {
|
||||||
|
let notifier;
|
||||||
|
let mockGithub;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGithub = {
|
||||||
|
addIssueComment: vi.fn().mockResolvedValue({ id: 123 })
|
||||||
|
};
|
||||||
|
|
||||||
|
notifier = new GitHubNotifier({
|
||||||
|
owner: 'test-org',
|
||||||
|
repo: 'test-repo',
|
||||||
|
github: mockGithub
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format mentions and send reminder', async () => {
|
||||||
|
await notifier.sendReminder(100, ['alice', 'bob'], {
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'test',
|
||||||
|
action_needed: 'sign-off',
|
||||||
|
deadline: '2026-01-15',
|
||||||
|
time_remaining: '24 hours',
|
||||||
|
document_url: 'https://example.com/doc'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockGithub.addIssueComment).toHaveBeenCalledTimes(1);
|
||||||
|
const body = mockGithub.addIssueComment.mock.calls[0][0].body;
|
||||||
|
|
||||||
|
expect(body).toContain('@alice @bob');
|
||||||
|
expect(body).toContain('Reminder');
|
||||||
|
expect(body).toContain('sign-off');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ notifyStakeholders Tests ============
|
||||||
|
|
||||||
|
describe('notifyStakeholders', () => {
|
||||||
|
let notifier;
|
||||||
|
let mockGithub;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGithub = {
|
||||||
|
addIssueComment: vi.fn().mockResolvedValue({ id: 123 })
|
||||||
|
};
|
||||||
|
|
||||||
|
notifier = new GitHubNotifier({
|
||||||
|
owner: 'test-org',
|
||||||
|
repo: 'test-repo',
|
||||||
|
github: mockGithub
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format mentions and post message', async () => {
|
||||||
|
await notifier.notifyStakeholders(
|
||||||
|
['alice', 'bob', 'charlie'],
|
||||||
|
'Please review the updated document',
|
||||||
|
100
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockGithub.addIssueComment).toHaveBeenCalledTimes(1);
|
||||||
|
const body = mockGithub.addIssueComment.mock.calls[0][0].body;
|
||||||
|
|
||||||
|
expect(body).toContain('@alice @bob @charlie');
|
||||||
|
expect(body).toContain('Please review the updated document');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ _renderTemplate Tests ============
|
||||||
|
|
||||||
|
describe('_renderTemplate', () => {
|
||||||
|
let notifier;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
notifier = new GitHubNotifier({
|
||||||
|
owner: 'test',
|
||||||
|
repo: 'test',
|
||||||
|
github: {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace simple variables', () => {
|
||||||
|
const template = 'Hello {{name}}, welcome to {{place}}!';
|
||||||
|
const result = notifier._renderTemplate(template, {
|
||||||
|
name: 'Alice',
|
||||||
|
place: 'Wonderland'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe('Hello Alice, welcome to Wonderland!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep placeholder when variable not found', () => {
|
||||||
|
const template = 'Hello {{name}}, your id is {{id}}';
|
||||||
|
const result = notifier._renderTemplate(template, { name: 'Bob' });
|
||||||
|
|
||||||
|
expect(result).toBe('Hello Bob, your id is {{id}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle conditional blocks - true', () => {
|
||||||
|
const template = 'Start{{#if show}} visible{{/if}} end';
|
||||||
|
const result = notifier._renderTemplate(template, { show: true });
|
||||||
|
|
||||||
|
expect(result).toBe('Start visible end');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle conditional blocks - false', () => {
|
||||||
|
const template = 'Start{{#if show}} hidden{{/if}} end';
|
||||||
|
const result = notifier._renderTemplate(template, { show: false });
|
||||||
|
|
||||||
|
expect(result).toBe('Start end');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle conditional blocks - undefined', () => {
|
||||||
|
const template = 'Start{{#if show}} hidden{{/if}} end';
|
||||||
|
const result = notifier._renderTemplate(template, {});
|
||||||
|
|
||||||
|
expect(result).toBe('Start end');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle each blocks with objects', () => {
|
||||||
|
const template = 'Items:{{#each items}} {{name}}={{value}};{{/each}}';
|
||||||
|
const result = notifier._renderTemplate(template, {
|
||||||
|
items: [
|
||||||
|
{ name: 'a', value: 1 },
|
||||||
|
{ name: 'b', value: 2 }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe('Items: a=1; b=2;');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle each blocks with primitives', () => {
|
||||||
|
const template = 'List:{{#each items}} {{this}}{{/each}}';
|
||||||
|
const result = notifier._renderTemplate(template, {
|
||||||
|
items: ['apple', 'banana', 'cherry']
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe('List: apple banana cherry');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle each with @index', () => {
|
||||||
|
const template = '{{#each items}}{{@index}}.{{this}} {{/each}}';
|
||||||
|
const result = notifier._renderTemplate(template, {
|
||||||
|
items: ['a', 'b', 'c']
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe('0.a 1.b 2.c ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle each with non-array', () => {
|
||||||
|
const template = 'Items:{{#each items}} item{{/each}}';
|
||||||
|
const result = notifier._renderTemplate(template, {
|
||||||
|
items: 'not an array'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe('Items:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex template', () => {
|
||||||
|
const template = `
|
||||||
|
## {{title}}
|
||||||
|
|
||||||
|
**From:** @{{user}}
|
||||||
|
**Status:** {{status}}
|
||||||
|
|
||||||
|
{{#if note}}
|
||||||
|
**Note:** {{note}}
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
Items:
|
||||||
|
{{#each items}}
|
||||||
|
- {{name}}: {{value}}
|
||||||
|
{{/each}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = notifier._renderTemplate(template, {
|
||||||
|
title: 'Test',
|
||||||
|
user: 'alice',
|
||||||
|
status: 'approved',
|
||||||
|
note: 'Great work!',
|
||||||
|
items: [
|
||||||
|
{ name: 'Item 1', value: 'Value 1' },
|
||||||
|
{ name: 'Item 2', value: 'Value 2' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toContain('## Test');
|
||||||
|
expect(result).toContain('@alice');
|
||||||
|
expect(result).toContain('approved');
|
||||||
|
expect(result).toContain('Great work!');
|
||||||
|
expect(result).toContain('Item 1: Value 1');
|
||||||
|
expect(result).toContain('Item 2: Value 2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ Integration Tests ============
|
||||||
|
|
||||||
|
describe('integration', () => {
|
||||||
|
let notifier;
|
||||||
|
let mockGithub;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGithub = {
|
||||||
|
addIssueComment: vi.fn().mockResolvedValue({ id: 123 }),
|
||||||
|
createIssue: vi.fn().mockResolvedValue({ number: 456 })
|
||||||
|
};
|
||||||
|
|
||||||
|
notifier = new GitHubNotifier({
|
||||||
|
owner: 'test-org',
|
||||||
|
repo: 'test-repo',
|
||||||
|
github: mockGithub
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send feedback_round_opened notification', async () => {
|
||||||
|
await notifier.send('feedback_round_opened', {
|
||||||
|
mentions: '@alice @bob @charlie',
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'user-auth',
|
||||||
|
version: 1,
|
||||||
|
deadline: '2026-01-15',
|
||||||
|
document_url: 'https://github.com/org/repo/docs/prd/user-auth.md'
|
||||||
|
}, { issueNumber: 100 });
|
||||||
|
|
||||||
|
const body = mockGithub.addIssueComment.mock.calls[0][0].body;
|
||||||
|
|
||||||
|
expect(body).toContain('📣 Feedback Round Open');
|
||||||
|
expect(body).toContain('@alice @bob @charlie');
|
||||||
|
expect(body).toContain('prd:user-auth');
|
||||||
|
expect(body).toContain('v1');
|
||||||
|
expect(body).toContain('2026-01-15');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send signoff_received notification with note', async () => {
|
||||||
|
await notifier.send('signoff_received', {
|
||||||
|
emoji: '✅📝',
|
||||||
|
user: 'security-lead',
|
||||||
|
decision: 'Approved with Note',
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'payments',
|
||||||
|
progress_current: 3,
|
||||||
|
progress_total: 5,
|
||||||
|
note: 'Please update PCI compliance section before implementation',
|
||||||
|
review_issue: 200,
|
||||||
|
review_url: 'https://github.com/org/repo/issues/200'
|
||||||
|
}, { issueNumber: 200 });
|
||||||
|
|
||||||
|
const body = mockGithub.addIssueComment.mock.calls[0][0].body;
|
||||||
|
|
||||||
|
expect(body).toContain('✅📝');
|
||||||
|
expect(body).toContain('@security-lead');
|
||||||
|
expect(body).toContain('Approved with Note');
|
||||||
|
expect(body).toContain('3/5');
|
||||||
|
expect(body).toContain('PCI compliance');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send document_blocked notification', async () => {
|
||||||
|
await notifier.send('document_blocked', {
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'data-migration',
|
||||||
|
user: 'legal',
|
||||||
|
reason: 'GDPR compliance review required before proceeding',
|
||||||
|
feedback_issue: 42,
|
||||||
|
feedback_url: 'https://github.com/org/repo/issues/42'
|
||||||
|
}, { issueNumber: 100 });
|
||||||
|
|
||||||
|
const body = mockGithub.addIssueComment.mock.calls[0][0].body;
|
||||||
|
|
||||||
|
expect(body).toContain('🚫 Document Blocked');
|
||||||
|
expect(body).toContain('@legal');
|
||||||
|
expect(body).toContain('GDPR compliance');
|
||||||
|
expect(body).toContain('#42');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,766 @@
|
||||||
|
/**
|
||||||
|
* Tests for NotificationService - Multi-channel notification orchestration
|
||||||
|
*
|
||||||
|
* Tests cover:
|
||||||
|
* - Channel initialization based on config
|
||||||
|
* - Event routing with default channels
|
||||||
|
* - Priority-based behavior (retry, all channels)
|
||||||
|
* - Convenience methods for specific notification types
|
||||||
|
* - Error handling and aggregation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
NotificationService,
|
||||||
|
NOTIFICATION_EVENTS,
|
||||||
|
PRIORITY_BEHAVIOR
|
||||||
|
} from '../../../src/modules/bmm/lib/notifications/notification-service.js';
|
||||||
|
|
||||||
|
// Mock the notifier modules
|
||||||
|
vi.mock('../../../src/modules/bmm/lib/notifications/github-notifier.js', () => ({
|
||||||
|
GitHubNotifier: vi.fn().mockImplementation(() => ({
|
||||||
|
send: vi.fn().mockResolvedValue({ success: true, channel: 'github' })
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../src/modules/bmm/lib/notifications/slack-notifier.js', () => ({
|
||||||
|
SlackNotifier: vi.fn().mockImplementation(() => ({
|
||||||
|
send: vi.fn().mockResolvedValue({ success: true, channel: 'slack' })
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../src/modules/bmm/lib/notifications/email-notifier.js', () => ({
|
||||||
|
EmailNotifier: vi.fn().mockImplementation(() => ({
|
||||||
|
send: vi.fn().mockResolvedValue({ success: true, channel: 'email' })
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('NotificationService', () => {
|
||||||
|
// ============ Constants Tests ============
|
||||||
|
|
||||||
|
describe('NOTIFICATION_EVENTS', () => {
|
||||||
|
it('should define all event types', () => {
|
||||||
|
const expectedEvents = [
|
||||||
|
'feedback_round_opened',
|
||||||
|
'feedback_submitted',
|
||||||
|
'synthesis_complete',
|
||||||
|
'signoff_requested',
|
||||||
|
'signoff_received',
|
||||||
|
'document_approved',
|
||||||
|
'document_blocked',
|
||||||
|
'reminder',
|
||||||
|
'deadline_extended'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const event of expectedEvents) {
|
||||||
|
expect(NOTIFICATION_EVENTS[event]).toBeDefined();
|
||||||
|
expect(NOTIFICATION_EVENTS[event].description).toBeTruthy();
|
||||||
|
expect(NOTIFICATION_EVENTS[event].defaultChannels).toBeInstanceOf(Array);
|
||||||
|
expect(NOTIFICATION_EVENTS[event].priority).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have appropriate priorities for different events', () => {
|
||||||
|
expect(NOTIFICATION_EVENTS.document_blocked.priority).toBe('urgent');
|
||||||
|
expect(NOTIFICATION_EVENTS.signoff_requested.priority).toBe('high');
|
||||||
|
expect(NOTIFICATION_EVENTS.feedback_submitted.priority).toBe('normal');
|
||||||
|
expect(NOTIFICATION_EVENTS.deadline_extended.priority).toBe('low');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include all channels for important events', () => {
|
||||||
|
expect(NOTIFICATION_EVENTS.feedback_round_opened.defaultChannels).toContain('github');
|
||||||
|
expect(NOTIFICATION_EVENTS.feedback_round_opened.defaultChannels).toContain('slack');
|
||||||
|
expect(NOTIFICATION_EVENTS.feedback_round_opened.defaultChannels).toContain('email');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have minimal channels for low-priority events', () => {
|
||||||
|
expect(NOTIFICATION_EVENTS.deadline_extended.defaultChannels).toEqual(['github']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PRIORITY_BEHAVIOR', () => {
|
||||||
|
it('should define all priority levels', () => {
|
||||||
|
expect(PRIORITY_BEHAVIOR.urgent).toBeDefined();
|
||||||
|
expect(PRIORITY_BEHAVIOR.high).toBeDefined();
|
||||||
|
expect(PRIORITY_BEHAVIOR.normal).toBeDefined();
|
||||||
|
expect(PRIORITY_BEHAVIOR.low).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have retry settings based on priority', () => {
|
||||||
|
expect(PRIORITY_BEHAVIOR.urgent.retryOnFailure).toBe(true);
|
||||||
|
expect(PRIORITY_BEHAVIOR.urgent.maxRetries).toBe(3);
|
||||||
|
|
||||||
|
expect(PRIORITY_BEHAVIOR.high.retryOnFailure).toBe(true);
|
||||||
|
expect(PRIORITY_BEHAVIOR.high.maxRetries).toBe(2);
|
||||||
|
|
||||||
|
expect(PRIORITY_BEHAVIOR.normal.retryOnFailure).toBe(false);
|
||||||
|
expect(PRIORITY_BEHAVIOR.normal.maxRetries).toBe(1);
|
||||||
|
|
||||||
|
expect(PRIORITY_BEHAVIOR.low.retryOnFailure).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use all channels for urgent priority', () => {
|
||||||
|
expect(PRIORITY_BEHAVIOR.urgent.allChannels).toBe(true);
|
||||||
|
expect(PRIORITY_BEHAVIOR.high.allChannels).toBe(false);
|
||||||
|
expect(PRIORITY_BEHAVIOR.normal.allChannels).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ Constructor Tests ============
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('should always initialize GitHub channel', () => {
|
||||||
|
const service = new NotificationService({
|
||||||
|
github: { owner: 'test', repo: 'test' }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.channels.github).toBeDefined();
|
||||||
|
expect(service.isChannelAvailable('github')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize Slack when enabled with webhook', () => {
|
||||||
|
const service = new NotificationService({
|
||||||
|
github: { owner: 'test', repo: 'test' },
|
||||||
|
slack: {
|
||||||
|
enabled: true,
|
||||||
|
webhookUrl: 'https://hooks.slack.com/xxx'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.channels.slack).toBeDefined();
|
||||||
|
expect(service.isChannelAvailable('slack')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not initialize Slack without webhook', () => {
|
||||||
|
const service = new NotificationService({
|
||||||
|
github: { owner: 'test', repo: 'test' },
|
||||||
|
slack: { enabled: true } // No webhookUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.channels.slack).toBeUndefined();
|
||||||
|
expect(service.isChannelAvailable('slack')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize Email when enabled with SMTP', () => {
|
||||||
|
const service = new NotificationService({
|
||||||
|
github: { owner: 'test', repo: 'test' },
|
||||||
|
email: {
|
||||||
|
enabled: true,
|
||||||
|
smtp: { host: 'localhost' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.channels.email).toBeDefined();
|
||||||
|
expect(service.isChannelAvailable('email')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize Email when enabled with API key', () => {
|
||||||
|
const service = new NotificationService({
|
||||||
|
github: { owner: 'test', repo: 'test' },
|
||||||
|
email: {
|
||||||
|
enabled: true,
|
||||||
|
apiKey: 'SG.xxx'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.channels.email).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not initialize Email without config', () => {
|
||||||
|
const service = new NotificationService({
|
||||||
|
github: { owner: 'test', repo: 'test' },
|
||||||
|
email: { enabled: true } // No smtp or apiKey
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.channels.email).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ getAvailableChannels Tests ============
|
||||||
|
|
||||||
|
describe('getAvailableChannels', () => {
|
||||||
|
it('should return only GitHub when minimal config', () => {
|
||||||
|
const service = new NotificationService({
|
||||||
|
github: { owner: 'test', repo: 'test' }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.getAvailableChannels()).toEqual(['github']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return all channels when fully configured', () => {
|
||||||
|
const service = new NotificationService({
|
||||||
|
github: { owner: 'test', repo: 'test' },
|
||||||
|
slack: { enabled: true, webhookUrl: 'https://xxx' },
|
||||||
|
email: { enabled: true, smtp: { host: 'localhost' } }
|
||||||
|
});
|
||||||
|
|
||||||
|
const channels = service.getAvailableChannels();
|
||||||
|
expect(channels).toContain('github');
|
||||||
|
expect(channels).toContain('slack');
|
||||||
|
expect(channels).toContain('email');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ notify Tests ============
|
||||||
|
|
||||||
|
describe('notify', () => {
|
||||||
|
let service;
|
||||||
|
let mockGithubSend;
|
||||||
|
let mockSlackSend;
|
||||||
|
let mockEmailSend;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGithubSend = vi.fn().mockResolvedValue({ success: true, channel: 'github' });
|
||||||
|
mockSlackSend = vi.fn().mockResolvedValue({ success: true, channel: 'slack' });
|
||||||
|
mockEmailSend = vi.fn().mockResolvedValue({ success: true, channel: 'email' });
|
||||||
|
|
||||||
|
service = new NotificationService({
|
||||||
|
github: { owner: 'test', repo: 'test' },
|
||||||
|
slack: { enabled: true, webhookUrl: 'https://xxx' },
|
||||||
|
email: { enabled: true, smtp: { host: 'localhost' } }
|
||||||
|
});
|
||||||
|
|
||||||
|
service.channels.github.send = mockGithubSend;
|
||||||
|
service.channels.slack.send = mockSlackSend;
|
||||||
|
service.channels.email.send = mockEmailSend;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for unknown event type', async () => {
|
||||||
|
await expect(
|
||||||
|
service.notify('unknown_event', {})
|
||||||
|
).rejects.toThrow('Unknown notification event type: unknown_event');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send to default channels for event', async () => {
|
||||||
|
await service.notify('feedback_round_opened', {
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'test'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockGithubSend).toHaveBeenCalled();
|
||||||
|
expect(mockSlackSend).toHaveBeenCalled();
|
||||||
|
expect(mockEmailSend).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter to available channels only', async () => {
|
||||||
|
// Service with only GitHub
|
||||||
|
const minimalService = new NotificationService({
|
||||||
|
github: { owner: 'test', repo: 'test' }
|
||||||
|
});
|
||||||
|
minimalService.channels.github.send = mockGithubSend;
|
||||||
|
|
||||||
|
await minimalService.notify('feedback_round_opened', {});
|
||||||
|
|
||||||
|
expect(mockGithubSend).toHaveBeenCalled();
|
||||||
|
expect(mockSlackSend).not.toHaveBeenCalled();
|
||||||
|
expect(mockEmailSend).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should always include GitHub as baseline', async () => {
|
||||||
|
await service.notify('feedback_submitted', {
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'test'
|
||||||
|
}, { channels: ['slack'] }); // Explicitly only slack
|
||||||
|
|
||||||
|
// GitHub should still be included
|
||||||
|
expect(mockGithubSend).toHaveBeenCalled();
|
||||||
|
expect(mockSlackSend).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use all channels for urgent priority', async () => {
|
||||||
|
await service.notify('document_blocked', {
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'test',
|
||||||
|
user: 'security',
|
||||||
|
reason: 'Blocked'
|
||||||
|
});
|
||||||
|
|
||||||
|
// document_blocked is urgent, should use all available channels
|
||||||
|
expect(mockGithubSend).toHaveBeenCalled();
|
||||||
|
expect(mockSlackSend).toHaveBeenCalled();
|
||||||
|
expect(mockEmailSend).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect custom channels option', async () => {
|
||||||
|
await service.notify('deadline_extended', {
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'test'
|
||||||
|
}, { channels: ['github', 'slack'] });
|
||||||
|
|
||||||
|
expect(mockGithubSend).toHaveBeenCalled();
|
||||||
|
expect(mockSlackSend).toHaveBeenCalled();
|
||||||
|
expect(mockEmailSend).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should aggregate results from all channels', async () => {
|
||||||
|
const result = await service.notify('signoff_requested', {
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'test'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.eventType).toBe('signoff_requested');
|
||||||
|
expect(result.results.github).toBeDefined();
|
||||||
|
expect(result.results.slack).toBeDefined();
|
||||||
|
expect(result.results.email).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report success if any channel succeeds', async () => {
|
||||||
|
mockGithubSend.mockResolvedValue({ success: true, channel: 'github' });
|
||||||
|
mockSlackSend.mockResolvedValue({ success: false, channel: 'slack', error: 'Failed' });
|
||||||
|
mockEmailSend.mockResolvedValue({ success: false, channel: 'email', error: 'Failed' });
|
||||||
|
|
||||||
|
const result = await service.notify('feedback_round_opened', {});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report failure if all channels fail', async () => {
|
||||||
|
mockGithubSend.mockResolvedValue({ success: false, error: 'Failed' });
|
||||||
|
mockSlackSend.mockResolvedValue({ success: false, error: 'Failed' });
|
||||||
|
mockEmailSend.mockResolvedValue({ success: false, error: 'Failed' });
|
||||||
|
|
||||||
|
const result = await service.notify('feedback_round_opened', {});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ sendReminder Tests ============
|
||||||
|
|
||||||
|
describe('sendReminder', () => {
|
||||||
|
let service;
|
||||||
|
let notifySpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new NotificationService({
|
||||||
|
github: { owner: 'test', repo: 'test' }
|
||||||
|
});
|
||||||
|
|
||||||
|
notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format users as mentions', async () => {
|
||||||
|
await service.sendReminder('prd', 'user-auth', ['alice', 'bob'], {
|
||||||
|
action_needed: 'feedback',
|
||||||
|
deadline: '2026-01-15'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(notifySpy).toHaveBeenCalledWith('reminder', expect.objectContaining({
|
||||||
|
mentions: '@alice @bob',
|
||||||
|
users: ['alice', 'bob'],
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'user-auth'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ notifyFeedbackRoundOpened Tests ============
|
||||||
|
|
||||||
|
describe('notifyFeedbackRoundOpened', () => {
|
||||||
|
let service;
|
||||||
|
let notifySpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new NotificationService({
|
||||||
|
github: { owner: 'test', repo: 'test' }
|
||||||
|
});
|
||||||
|
|
||||||
|
notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format document data correctly', async () => {
|
||||||
|
await service.notifyFeedbackRoundOpened(
|
||||||
|
{
|
||||||
|
type: 'prd',
|
||||||
|
key: 'user-auth',
|
||||||
|
title: 'User Authentication',
|
||||||
|
version: 1,
|
||||||
|
url: 'https://example.com/doc',
|
||||||
|
reviewIssue: 100
|
||||||
|
},
|
||||||
|
['alice', 'bob', 'charlie'],
|
||||||
|
'2026-01-15'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(notifySpy).toHaveBeenCalledWith('feedback_round_opened', expect.objectContaining({
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'user-auth',
|
||||||
|
title: 'User Authentication',
|
||||||
|
version: 1,
|
||||||
|
deadline: '2026-01-15',
|
||||||
|
stakeholder_count: 3,
|
||||||
|
mentions: '@alice @bob @charlie',
|
||||||
|
users: ['alice', 'bob', 'charlie'],
|
||||||
|
document_url: 'https://example.com/doc',
|
||||||
|
review_issue: 100
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ notifyFeedbackSubmitted Tests ============
|
||||||
|
|
||||||
|
describe('notifyFeedbackSubmitted', () => {
|
||||||
|
let service;
|
||||||
|
let notifySpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new NotificationService({
|
||||||
|
github: { owner: 'test', repo: 'test' }
|
||||||
|
});
|
||||||
|
|
||||||
|
notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format feedback data correctly', async () => {
|
||||||
|
await service.notifyFeedbackSubmitted(
|
||||||
|
{
|
||||||
|
submittedBy: 'security',
|
||||||
|
type: 'concern',
|
||||||
|
section: 'FR-3',
|
||||||
|
summary: 'Security vulnerability identified',
|
||||||
|
issueNumber: 42,
|
||||||
|
url: 'https://example.com/issues/42'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'prd',
|
||||||
|
key: 'payments',
|
||||||
|
owner: 'product-owner',
|
||||||
|
reviewIssue: 100
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(notifySpy).toHaveBeenCalledWith(
|
||||||
|
'feedback_submitted',
|
||||||
|
expect.objectContaining({
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'payments',
|
||||||
|
user: 'security',
|
||||||
|
feedback_type: 'concern',
|
||||||
|
section: 'FR-3',
|
||||||
|
feedback_issue: 42
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
notifyOnly: ['product-owner']
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ notifySynthesisComplete Tests ============
|
||||||
|
|
||||||
|
describe('notifySynthesisComplete', () => {
|
||||||
|
let service;
|
||||||
|
let notifySpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new NotificationService({
|
||||||
|
github: { owner: 'test', repo: 'test' }
|
||||||
|
});
|
||||||
|
|
||||||
|
notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format synthesis data correctly', async () => {
|
||||||
|
await service.notifySynthesisComplete(
|
||||||
|
{
|
||||||
|
type: 'prd',
|
||||||
|
key: 'user-auth',
|
||||||
|
url: 'https://example.com/doc',
|
||||||
|
reviewIssue: 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oldVersion: 1,
|
||||||
|
newVersion: 2,
|
||||||
|
feedbackCount: 12,
|
||||||
|
conflictsResolved: 3,
|
||||||
|
summary: 'Incorporated security feedback and clarified auth flow'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(notifySpy).toHaveBeenCalledWith('synthesis_complete', expect.objectContaining({
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'user-auth',
|
||||||
|
old_version: 1,
|
||||||
|
new_version: 2,
|
||||||
|
feedback_count: 12,
|
||||||
|
conflicts_resolved: 3,
|
||||||
|
summary: expect.stringContaining('security feedback')
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ notifySignoffRequested Tests ============
|
||||||
|
|
||||||
|
describe('notifySignoffRequested', () => {
|
||||||
|
let service;
|
||||||
|
let notifySpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new NotificationService({
|
||||||
|
github: { owner: 'test', repo: 'test' }
|
||||||
|
});
|
||||||
|
|
||||||
|
notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format signoff request correctly', async () => {
|
||||||
|
await service.notifySignoffRequested(
|
||||||
|
{
|
||||||
|
type: 'prd',
|
||||||
|
key: 'payments',
|
||||||
|
title: 'Payments V2',
|
||||||
|
version: 2,
|
||||||
|
url: 'https://example.com/doc',
|
||||||
|
signoffUrl: 'https://example.com/signoff',
|
||||||
|
reviewIssue: 200
|
||||||
|
},
|
||||||
|
['alice', 'bob', 'charlie'],
|
||||||
|
'2026-01-20',
|
||||||
|
{ minimum_approvals: 2 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(notifySpy).toHaveBeenCalledWith('signoff_requested', expect.objectContaining({
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'payments',
|
||||||
|
title: 'Payments V2',
|
||||||
|
version: 2,
|
||||||
|
deadline: '2026-01-20',
|
||||||
|
approvals_needed: 2,
|
||||||
|
mentions: '@alice @bob @charlie',
|
||||||
|
users: ['alice', 'bob', 'charlie']
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate approvals_needed from stakeholder count when not specified', async () => {
|
||||||
|
await service.notifySignoffRequested(
|
||||||
|
{
|
||||||
|
type: 'prd',
|
||||||
|
key: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
version: 1
|
||||||
|
},
|
||||||
|
['a', 'b', 'c', 'd', 'e'],
|
||||||
|
'2026-01-20',
|
||||||
|
{} // No minimum_approvals
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(notifySpy).toHaveBeenCalledWith('signoff_requested', expect.objectContaining({
|
||||||
|
approvals_needed: 3 // ceil(5 * 0.5) = 3
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ notifySignoffReceived Tests ============
|
||||||
|
|
||||||
|
describe('notifySignoffReceived', () => {
|
||||||
|
let service;
|
||||||
|
let notifySpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new NotificationService({
|
||||||
|
github: { owner: 'test', repo: 'test' }
|
||||||
|
});
|
||||||
|
|
||||||
|
notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format approved signoff correctly', async () => {
|
||||||
|
await service.notifySignoffReceived(
|
||||||
|
{
|
||||||
|
user: 'alice',
|
||||||
|
decision: 'approved',
|
||||||
|
note: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'prd',
|
||||||
|
key: 'test',
|
||||||
|
reviewIssue: 100,
|
||||||
|
reviewUrl: 'https://example.com/issues/100'
|
||||||
|
},
|
||||||
|
{ current: 2, total: 3 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(notifySpy).toHaveBeenCalledWith('signoff_received', expect.objectContaining({
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'test',
|
||||||
|
user: 'alice',
|
||||||
|
decision: 'approved',
|
||||||
|
emoji: '✅',
|
||||||
|
progress_current: 2,
|
||||||
|
progress_total: 3
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format blocked signoff with correct emoji', async () => {
|
||||||
|
await service.notifySignoffReceived(
|
||||||
|
{
|
||||||
|
user: 'security',
|
||||||
|
decision: 'blocked',
|
||||||
|
note: 'Security concern'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'prd',
|
||||||
|
key: 'test',
|
||||||
|
reviewIssue: 100
|
||||||
|
},
|
||||||
|
{ current: 1, total: 3 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(notifySpy).toHaveBeenCalledWith('signoff_received', expect.objectContaining({
|
||||||
|
decision: 'blocked',
|
||||||
|
emoji: '🚫',
|
||||||
|
note: 'Security concern'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format approved-with-note signoff correctly', async () => {
|
||||||
|
await service.notifySignoffReceived(
|
||||||
|
{
|
||||||
|
user: 'bob',
|
||||||
|
decision: 'approved-with-note',
|
||||||
|
note: 'Minor concern'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'prd',
|
||||||
|
key: 'test',
|
||||||
|
reviewIssue: 100
|
||||||
|
},
|
||||||
|
{ current: 2, total: 3 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(notifySpy).toHaveBeenCalledWith('signoff_received', expect.objectContaining({
|
||||||
|
emoji: '✅📝',
|
||||||
|
note: 'Minor concern'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ notifyDocumentApproved Tests ============
|
||||||
|
|
||||||
|
describe('notifyDocumentApproved', () => {
|
||||||
|
let service;
|
||||||
|
let notifySpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new NotificationService({
|
||||||
|
github: { owner: 'test', repo: 'test' }
|
||||||
|
});
|
||||||
|
|
||||||
|
notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format approval data correctly', async () => {
|
||||||
|
await service.notifyDocumentApproved(
|
||||||
|
{
|
||||||
|
type: 'prd',
|
||||||
|
key: 'user-auth',
|
||||||
|
title: 'User Authentication',
|
||||||
|
version: 2,
|
||||||
|
url: 'https://example.com/doc'
|
||||||
|
},
|
||||||
|
3,
|
||||||
|
3
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(notifySpy).toHaveBeenCalledWith('document_approved', expect.objectContaining({
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'user-auth',
|
||||||
|
title: 'User Authentication',
|
||||||
|
version: 2,
|
||||||
|
approval_count: 3,
|
||||||
|
stakeholder_count: 3,
|
||||||
|
document_url: 'https://example.com/doc'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ notifyDocumentBlocked Tests ============
|
||||||
|
|
||||||
|
describe('notifyDocumentBlocked', () => {
|
||||||
|
let service;
|
||||||
|
let notifySpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new NotificationService({
|
||||||
|
github: { owner: 'test', repo: 'test' }
|
||||||
|
});
|
||||||
|
|
||||||
|
notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format block data correctly', async () => {
|
||||||
|
await service.notifyDocumentBlocked(
|
||||||
|
{
|
||||||
|
type: 'prd',
|
||||||
|
key: 'payments'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
user: 'legal',
|
||||||
|
reason: 'GDPR compliance review required',
|
||||||
|
feedbackIssue: 42,
|
||||||
|
feedbackUrl: 'https://example.com/issues/42'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(notifySpy).toHaveBeenCalledWith('document_blocked', expect.objectContaining({
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'payments',
|
||||||
|
user: 'legal',
|
||||||
|
reason: 'GDPR compliance review required',
|
||||||
|
feedback_issue: 42,
|
||||||
|
feedback_url: 'https://example.com/issues/42'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ Retry Logic Tests ============
|
||||||
|
|
||||||
|
describe('retry logic', () => {
|
||||||
|
let service;
|
||||||
|
let mockGithubSend;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGithubSend = vi.fn();
|
||||||
|
|
||||||
|
service = new NotificationService({
|
||||||
|
github: { owner: 'test', repo: 'test' }
|
||||||
|
});
|
||||||
|
|
||||||
|
service.channels.github.send = mockGithubSend;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should retry on failure for urgent priority', async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
mockGithubSend.mockImplementation(() => {
|
||||||
|
attempts++;
|
||||||
|
if (attempts < 3) {
|
||||||
|
return Promise.resolve({ success: false, error: 'Temporary failure' });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ success: true, channel: 'github' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.notify('document_blocked', {
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'test',
|
||||||
|
user: 'blocker',
|
||||||
|
reason: 'Issue'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(mockGithubSend).toHaveBeenCalledTimes(3);
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
it('should not retry for normal priority', async () => {
|
||||||
|
mockGithubSend.mockResolvedValue({ success: false, error: 'Failed' });
|
||||||
|
|
||||||
|
const result = await service.notify('deadline_extended', {
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'test'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.results.github.success).toBe(false);
|
||||||
|
expect(mockGithubSend).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,470 @@
|
||||||
|
/**
|
||||||
|
* Tests for SlackNotifier - Slack webhook integration
|
||||||
|
*
|
||||||
|
* Tests cover:
|
||||||
|
* - Slack block templates for all event types
|
||||||
|
* - Dynamic color and title functions
|
||||||
|
* - Payload building with blocks and attachments
|
||||||
|
* - Enable/disable behavior
|
||||||
|
* - Custom message sending
|
||||||
|
* - Webhook error handling
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
SlackNotifier,
|
||||||
|
SLACK_TEMPLATES
|
||||||
|
} from '../../../src/modules/bmm/lib/notifications/slack-notifier.js';
|
||||||
|
|
||||||
|
// Mock global fetch
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
|
||||||
|
describe('SlackNotifier', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
global.fetch.mockResolvedValue({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ SLACK_TEMPLATES Tests ============
|
||||||
|
|
||||||
|
describe('SLACK_TEMPLATES', () => {
|
||||||
|
it('should define all required event types', () => {
|
||||||
|
const expectedTypes = [
|
||||||
|
'feedback_round_opened',
|
||||||
|
'feedback_submitted',
|
||||||
|
'synthesis_complete',
|
||||||
|
'signoff_requested',
|
||||||
|
'signoff_received',
|
||||||
|
'document_approved',
|
||||||
|
'document_blocked',
|
||||||
|
'reminder'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const type of expectedTypes) {
|
||||||
|
expect(SLACK_TEMPLATES[type]).toBeDefined();
|
||||||
|
expect(SLACK_TEMPLATES[type].title).toBeTruthy();
|
||||||
|
expect(SLACK_TEMPLATES[type].blocks).toBeInstanceOf(Function);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate blocks for feedback_round_opened', () => {
|
||||||
|
const data = {
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'user-auth',
|
||||||
|
version: 1,
|
||||||
|
deadline: '2026-01-15',
|
||||||
|
stakeholder_count: 5,
|
||||||
|
document_url: 'https://example.com/doc'
|
||||||
|
};
|
||||||
|
|
||||||
|
const blocks = SLACK_TEMPLATES.feedback_round_opened.blocks(data);
|
||||||
|
|
||||||
|
expect(blocks).toBeInstanceOf(Array);
|
||||||
|
expect(blocks.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Check header block
|
||||||
|
const header = blocks.find(b => b.type === 'header');
|
||||||
|
expect(header).toBeDefined();
|
||||||
|
expect(header.text.text).toContain('Feedback');
|
||||||
|
|
||||||
|
// Check section with fields
|
||||||
|
const section = blocks.find(b => b.type === 'section' && b.fields);
|
||||||
|
expect(section).toBeDefined();
|
||||||
|
|
||||||
|
// Check actions block
|
||||||
|
const actions = blocks.find(b => b.type === 'actions');
|
||||||
|
expect(actions).toBeDefined();
|
||||||
|
expect(actions.elements[0].url).toBe('https://example.com/doc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have static color values where appropriate', () => {
|
||||||
|
expect(SLACK_TEMPLATES.feedback_round_opened.color).toBe('#36a64f');
|
||||||
|
expect(SLACK_TEMPLATES.document_blocked.color).toBe('#dc3545');
|
||||||
|
expect(SLACK_TEMPLATES.reminder.color).toBe('#ffc107');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have dynamic color for signoff_received', () => {
|
||||||
|
expect(typeof SLACK_TEMPLATES.signoff_received.color).toBe('function');
|
||||||
|
|
||||||
|
const approvedColor = SLACK_TEMPLATES.signoff_received.color({ decision: 'approved' });
|
||||||
|
const blockedColor = SLACK_TEMPLATES.signoff_received.color({ decision: 'blocked' });
|
||||||
|
|
||||||
|
expect(approvedColor).toBe('#28a745'); // Green
|
||||||
|
expect(blockedColor).toBe('#dc3545'); // Red
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have dynamic title for signoff_received', () => {
|
||||||
|
expect(typeof SLACK_TEMPLATES.signoff_received.title).toBe('function');
|
||||||
|
|
||||||
|
const title = SLACK_TEMPLATES.signoff_received.title({
|
||||||
|
emoji: '✅',
|
||||||
|
user: 'alice'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(title).toContain('✅');
|
||||||
|
expect(title).toContain('alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle optional note in signoff_received blocks', () => {
|
||||||
|
const dataWithNote = {
|
||||||
|
emoji: '✅📝',
|
||||||
|
user: 'bob',
|
||||||
|
decision: 'approved',
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'test',
|
||||||
|
progress_current: 2,
|
||||||
|
progress_total: 3,
|
||||||
|
note: 'Minor concern noted',
|
||||||
|
review_url: 'https://example.com'
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataWithoutNote = { ...dataWithNote, note: null };
|
||||||
|
|
||||||
|
const blocksWithNote = SLACK_TEMPLATES.signoff_received.blocks(dataWithNote);
|
||||||
|
const blocksWithoutNote = SLACK_TEMPLATES.signoff_received.blocks(dataWithoutNote);
|
||||||
|
|
||||||
|
// With note should have more blocks
|
||||||
|
expect(blocksWithNote.length).toBeGreaterThan(blocksWithoutNote.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should truncate long summaries in feedback_submitted', () => {
|
||||||
|
const longSummary = 'A'.repeat(300);
|
||||||
|
const data = {
|
||||||
|
user: 'alice',
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'test',
|
||||||
|
feedback_type: 'concern',
|
||||||
|
section: 'FR-1',
|
||||||
|
summary: longSummary,
|
||||||
|
feedback_url: 'https://example.com'
|
||||||
|
};
|
||||||
|
|
||||||
|
const blocks = SLACK_TEMPLATES.feedback_submitted.blocks(data);
|
||||||
|
const summaryBlock = blocks.find(b =>
|
||||||
|
b.type === 'section' && b.text?.text?.startsWith('>')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(summaryBlock.text.text.length).toBeLessThan(250);
|
||||||
|
expect(summaryBlock.text.text).toContain('...');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ Constructor Tests ============
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('should initialize with webhook URL', () => {
|
||||||
|
const notifier = new SlackNotifier({
|
||||||
|
webhookUrl: 'https://hooks.slack.com/services/xxx',
|
||||||
|
channel: '#prd-updates'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(notifier.webhookUrl).toBe('https://hooks.slack.com/services/xxx');
|
||||||
|
expect(notifier.channel).toBe('#prd-updates');
|
||||||
|
expect(notifier.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default values', () => {
|
||||||
|
const notifier = new SlackNotifier({
|
||||||
|
webhookUrl: 'https://hooks.slack.com/services/xxx'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(notifier.username).toBe('PRD Crowdsource Bot');
|
||||||
|
expect(notifier.iconEmoji).toBe(':clipboard:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be disabled without webhook URL', () => {
|
||||||
|
const notifier = new SlackNotifier({});
|
||||||
|
|
||||||
|
expect(notifier.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ isEnabled Tests ============
|
||||||
|
|
||||||
|
describe('isEnabled', () => {
|
||||||
|
it('should return true when webhook configured', () => {
|
||||||
|
const notifier = new SlackNotifier({
|
||||||
|
webhookUrl: 'https://hooks.slack.com/services/xxx'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(notifier.isEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when not configured', () => {
|
||||||
|
const notifier = new SlackNotifier({});
|
||||||
|
|
||||||
|
expect(notifier.isEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ send Tests ============
|
||||||
|
|
||||||
|
describe('send', () => {
|
||||||
|
let notifier;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
notifier = new SlackNotifier({
|
||||||
|
webhookUrl: 'https://hooks.slack.com/services/xxx',
|
||||||
|
channel: '#prd-updates'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error when not enabled', async () => {
|
||||||
|
const disabledNotifier = new SlackNotifier({});
|
||||||
|
|
||||||
|
const result = await disabledNotifier.send('feedback_round_opened', {});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('not enabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error for unknown event type', async () => {
|
||||||
|
const result = await notifier.send('unknown_event', {});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('Unknown notification event type');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send webhook with correct payload', async () => {
|
||||||
|
const data = {
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'user-auth',
|
||||||
|
version: 1,
|
||||||
|
deadline: '2026-01-15',
|
||||||
|
stakeholder_count: 5,
|
||||||
|
document_url: 'https://example.com/doc'
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await notifier.send('feedback_round_opened', data);
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
'https://hooks.slack.com/services/xxx',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = JSON.parse(global.fetch.mock.calls[0][1].body);
|
||||||
|
|
||||||
|
expect(payload.channel).toBe('#prd-updates');
|
||||||
|
expect(payload.username).toBe('PRD Crowdsource Bot');
|
||||||
|
expect(payload.attachments).toHaveLength(1);
|
||||||
|
expect(payload.attachments[0].color).toBe('#36a64f');
|
||||||
|
expect(payload.attachments[0].blocks).toBeInstanceOf(Array);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.channel).toBe('slack');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle webhook error', async () => {
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
statusText: 'Internal Server Error'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await notifier.send('document_approved', {
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
version: 1,
|
||||||
|
approval_count: 3,
|
||||||
|
stakeholder_count: 3,
|
||||||
|
document_url: 'https://example.com'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('500');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom channel from options', async () => {
|
||||||
|
await notifier.send('reminder', {
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'test',
|
||||||
|
action_needed: 'feedback',
|
||||||
|
deadline: '2026-01-15',
|
||||||
|
time_remaining: '2 days',
|
||||||
|
document_url: 'https://example.com'
|
||||||
|
}, { channel: '#urgent-prd' });
|
||||||
|
|
||||||
|
const payload = JSON.parse(global.fetch.mock.calls[0][1].body);
|
||||||
|
expect(payload.channel).toBe('#urgent-prd');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ sendCustom Tests ============
|
||||||
|
|
||||||
|
describe('sendCustom', () => {
|
||||||
|
let notifier;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
notifier = new SlackNotifier({
|
||||||
|
webhookUrl: 'https://hooks.slack.com/services/xxx',
|
||||||
|
channel: '#general'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error when not enabled', async () => {
|
||||||
|
const disabledNotifier = new SlackNotifier({});
|
||||||
|
|
||||||
|
const result = await disabledNotifier.sendCustom('Hello');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('not enabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send custom message', async () => {
|
||||||
|
const result = await notifier.sendCustom('Custom notification message');
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const payload = JSON.parse(global.fetch.mock.calls[0][1].body);
|
||||||
|
expect(payload.text).toBe('Custom notification message');
|
||||||
|
expect(payload.channel).toBe('#general');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow channel override', async () => {
|
||||||
|
await notifier.sendCustom('Test', { channel: '#testing' });
|
||||||
|
|
||||||
|
const payload = JSON.parse(global.fetch.mock.calls[0][1].body);
|
||||||
|
expect(payload.channel).toBe('#testing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle webhook error', async () => {
|
||||||
|
global.fetch.mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
|
const result = await notifier.sendCustom('Test');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe('Network error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ _buildPayload Tests ============
|
||||||
|
|
||||||
|
describe('_buildPayload', () => {
|
||||||
|
let notifier;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
notifier = new SlackNotifier({
|
||||||
|
webhookUrl: 'https://hooks.slack.com/services/xxx',
|
||||||
|
channel: '#default',
|
||||||
|
username: 'TestBot',
|
||||||
|
iconEmoji: ':robot:'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should build payload with static color', () => {
|
||||||
|
const template = SLACK_TEMPLATES.feedback_round_opened;
|
||||||
|
const data = {
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'test',
|
||||||
|
version: 1,
|
||||||
|
deadline: '2026-01-15',
|
||||||
|
stakeholder_count: 3,
|
||||||
|
document_url: 'https://example.com'
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = notifier._buildPayload(template, data, {});
|
||||||
|
|
||||||
|
expect(payload.channel).toBe('#default');
|
||||||
|
expect(payload.username).toBe('TestBot');
|
||||||
|
expect(payload.icon_emoji).toBe(':robot:');
|
||||||
|
expect(payload.text).toBe('📣 Feedback Round Open');
|
||||||
|
expect(payload.attachments[0].color).toBe('#36a64f');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should build payload with dynamic color', () => {
|
||||||
|
const template = SLACK_TEMPLATES.signoff_received;
|
||||||
|
const data = {
|
||||||
|
emoji: '🚫',
|
||||||
|
user: 'alice',
|
||||||
|
decision: 'blocked',
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'test',
|
||||||
|
progress_current: 2,
|
||||||
|
progress_total: 5,
|
||||||
|
review_url: 'https://example.com'
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = notifier._buildPayload(template, data, {});
|
||||||
|
|
||||||
|
expect(payload.attachments[0].color).toBe('#dc3545'); // Red for blocked
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should build payload with dynamic title', () => {
|
||||||
|
const template = SLACK_TEMPLATES.signoff_received;
|
||||||
|
const data = {
|
||||||
|
emoji: '✅',
|
||||||
|
user: 'bob',
|
||||||
|
decision: 'approved',
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'test',
|
||||||
|
progress_current: 3,
|
||||||
|
progress_total: 3,
|
||||||
|
review_url: 'https://example.com'
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = notifier._buildPayload(template, data, {});
|
||||||
|
|
||||||
|
expect(payload.text).toContain('bob');
|
||||||
|
expect(payload.attachments[0].fallback).toContain('bob');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ Integration Tests ============
|
||||||
|
|
||||||
|
describe('integration', () => {
|
||||||
|
let notifier;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
notifier = new SlackNotifier({
|
||||||
|
webhookUrl: 'https://hooks.slack.com/services/xxx',
|
||||||
|
channel: '#prd-notifications'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send document_blocked notification', async () => {
|
||||||
|
await notifier.send('document_blocked', {
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'payments-v2',
|
||||||
|
user: 'legal-team',
|
||||||
|
reason: 'Compliance review required',
|
||||||
|
feedback_url: 'https://example.com/feedback/123'
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = JSON.parse(global.fetch.mock.calls[0][1].body);
|
||||||
|
|
||||||
|
expect(payload.attachments[0].color).toBe('#dc3545');
|
||||||
|
expect(payload.attachments[0].blocks).toBeInstanceOf(Array);
|
||||||
|
|
||||||
|
// Find blocking reason in blocks
|
||||||
|
const reasonBlock = payload.attachments[0].blocks.find(
|
||||||
|
b => b.type === 'section' && b.text?.text?.includes('Compliance')
|
||||||
|
);
|
||||||
|
expect(reasonBlock).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send synthesis_complete notification', async () => {
|
||||||
|
await notifier.send('synthesis_complete', {
|
||||||
|
document_type: 'prd',
|
||||||
|
document_key: 'user-auth',
|
||||||
|
old_version: 1,
|
||||||
|
new_version: 2,
|
||||||
|
feedback_count: 12,
|
||||||
|
conflicts_resolved: 3,
|
||||||
|
summary: 'Incorporated 12 feedback items including session timeout resolution',
|
||||||
|
document_url: 'https://example.com/doc'
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = JSON.parse(global.fetch.mock.calls[0][1].body);
|
||||||
|
|
||||||
|
expect(payload.attachments[0].color).toBe('#9932cc'); // Purple
|
||||||
|
expect(payload.text).toContain('Synthesis Complete');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue