style: apply formatting fixes from lint-staged

Auto-formatted files during pre-commit hook:
- Library files (cache, crowdsource, notifications)
- Workflow YAML files
- Test files

No functional changes.
This commit is contained in:
Jonah Schulte 2026-01-08 22:10:56 -05:00
parent 5e6e6abd20
commit 044e7eb2e0
31 changed files with 1268 additions and 1297 deletions

View File

@ -29,7 +29,7 @@ const DEFAULT_STALENESS_THRESHOLD_MINUTES = 5;
const DOCUMENT_TYPES = { const DOCUMENT_TYPES = {
story: 'story', story: 'story',
prd: 'prd', prd: 'prd',
epic: 'epic' epic: 'epic',
}; };
/** /**
@ -103,7 +103,7 @@ class CacheManager {
github_repo: this.github.repo || null, github_repo: this.github.repo || null,
stories: {}, stories: {},
prds: {}, prds: {},
epics: {} epics: {},
}; };
this.saveMeta(meta); this.saveMeta(meta);
return meta; return meta;
@ -179,14 +179,14 @@ class CacheManager {
content, content,
meta: storyMeta, meta: storyMeta,
isStale: true, isStale: true,
warning: `Story cache is stale (>${this.stalenessThresholdMinutes} min old). Sync recommended.` warning: `Story cache is stale (>${this.stalenessThresholdMinutes} min old). Sync recommended.`,
}; };
} }
return { return {
content, content,
meta: storyMeta, meta: storyMeta,
isStale isStale,
}; };
} }
@ -219,7 +219,7 @@ class CacheManager {
cache_timestamp: new Date().toISOString(), cache_timestamp: new Date().toISOString(),
local_hash: contentHash, local_hash: contentHash,
locked_by: storyMeta.locked_by || null, locked_by: storyMeta.locked_by || null,
locked_until: storyMeta.locked_until || null locked_until: storyMeta.locked_until || null,
}; };
this.saveMeta(meta); this.saveMeta(meta);
@ -228,7 +228,7 @@ class CacheManager {
storyKey, storyKey,
path: storyPath, path: storyPath,
hash: contentHash, hash: contentHash,
timestamp: meta.stories[storyKey].cache_timestamp timestamp: meta.stories[storyKey].cache_timestamp,
}; };
} }
@ -332,7 +332,7 @@ class CacheManager {
*/ */
getStaleStories() { getStaleStories() {
const meta = this.loadMeta(); const meta = this.loadMeta();
return Object.keys(meta.stories).filter(key => this.isStale(key)); return Object.keys(meta.stories).filter((key) => this.isStale(key));
} }
/** /**
@ -393,7 +393,7 @@ class CacheManager {
return { return {
locked_by: storyMeta.locked_by, locked_by: storyMeta.locked_by,
locked_until: storyMeta.locked_until, locked_until: storyMeta.locked_until,
expired: false expired: false,
}; };
} }
@ -414,7 +414,7 @@ class CacheManager {
storyKey, storyKey,
locked_by: storyMeta.locked_by, locked_by: storyMeta.locked_by,
locked_until: storyMeta.locked_until, locked_until: storyMeta.locked_until,
expired expired,
}); });
} }
} }
@ -464,7 +464,7 @@ class CacheManager {
const meta = this.loadMeta(); const meta = this.loadMeta();
const storyCount = Object.keys(meta.stories).length; const storyCount = Object.keys(meta.stories).length;
const staleCount = this.getStaleStories().length; const staleCount = this.getStaleStories().length;
const lockedCount = this.getLockedStories().filter(s => !s.expired).length; const lockedCount = this.getLockedStories().filter((s) => !s.expired).length;
let totalSize = 0; let totalSize = 0;
const storiesDir = path.join(this.cacheDir, 'stories'); const storiesDir = path.join(this.cacheDir, 'stories');
@ -485,7 +485,7 @@ class CacheManager {
total_size_bytes: totalSize, total_size_bytes: totalSize,
total_size_kb: Math.round(totalSize / 1024), total_size_kb: Math.round(totalSize / 1024),
last_sync: meta.last_sync, last_sync: meta.last_sync,
staleness_threshold_minutes: this.stalenessThresholdMinutes staleness_threshold_minutes: this.stalenessThresholdMinutes,
}; };
} }
@ -524,7 +524,7 @@ class CacheManager {
content, content,
meta: prdMeta, meta: prdMeta,
isStale: true, isStale: true,
warning: `PRD cache is stale (>${this.stalenessThresholdMinutes} min old). Sync recommended.` warning: `PRD cache is stale (>${this.stalenessThresholdMinutes} min old). Sync recommended.`,
}; };
} }
@ -562,7 +562,7 @@ class CacheManager {
feedback_deadline: prdMeta.feedback_deadline || meta.prds[prdKey]?.feedback_deadline, feedback_deadline: prdMeta.feedback_deadline || meta.prds[prdKey]?.feedback_deadline,
signoff_deadline: prdMeta.signoff_deadline || meta.prds[prdKey]?.signoff_deadline, signoff_deadline: prdMeta.signoff_deadline || meta.prds[prdKey]?.signoff_deadline,
cache_timestamp: new Date().toISOString(), cache_timestamp: new Date().toISOString(),
local_hash: contentHash local_hash: contentHash,
}; };
this.saveMeta(meta); this.saveMeta(meta);
@ -571,7 +571,7 @@ class CacheManager {
prdKey, prdKey,
path: prdPath, path: prdPath,
hash: contentHash, hash: contentHash,
timestamp: meta.prds[prdKey].cache_timestamp timestamp: meta.prds[prdKey].cache_timestamp,
}; };
} }
@ -626,9 +626,7 @@ class CacheManager {
const pendingSignoff = []; const pendingSignoff = [];
for (const [prdKey, prdMeta] of Object.entries(meta.prds)) { for (const [prdKey, prdMeta] of Object.entries(meta.prds)) {
const isStakeholder = prdMeta.stakeholders?.some(s => const isStakeholder = prdMeta.stakeholders?.some((s) => s.replace('@', '') === normalizedUser);
s.replace('@', '') === normalizedUser
);
if (!isStakeholder) continue; if (!isStakeholder) continue;
@ -693,7 +691,7 @@ class CacheManager {
content, content,
meta: epicMeta, meta: epicMeta,
isStale: true, isStale: true,
warning: `Epic cache is stale (>${this.stalenessThresholdMinutes} min old). Sync recommended.` warning: `Epic cache is stale (>${this.stalenessThresholdMinutes} min old). Sync recommended.`,
}; };
} }
@ -733,7 +731,7 @@ class CacheManager {
stakeholders: epicMeta.stakeholders || meta.epics[epicKey]?.stakeholders || [], stakeholders: epicMeta.stakeholders || meta.epics[epicKey]?.stakeholders || [],
feedback_deadline: epicMeta.feedback_deadline || meta.epics[epicKey]?.feedback_deadline, feedback_deadline: epicMeta.feedback_deadline || meta.epics[epicKey]?.feedback_deadline,
cache_timestamp: new Date().toISOString(), cache_timestamp: new Date().toISOString(),
local_hash: contentHash local_hash: contentHash,
}; };
this.saveMeta(meta); this.saveMeta(meta);
@ -742,7 +740,7 @@ class CacheManager {
epicKey, epicKey,
path: epicPath, path: epicPath,
hash: contentHash, hash: contentHash,
timestamp: meta.epics[epicKey].cache_timestamp timestamp: meta.epics[epicKey].cache_timestamp,
}; };
} }
@ -796,9 +794,7 @@ class CacheManager {
const pendingFeedback = []; const pendingFeedback = [];
for (const [epicKey, epicMeta] of Object.entries(meta.epics)) { for (const [epicKey, epicMeta] of Object.entries(meta.epics)) {
const isStakeholder = epicMeta.stakeholders?.some(s => const isStakeholder = epicMeta.stakeholders?.some((s) => s.replace('@', '') === normalizedUser);
s.replace('@', '') === normalizedUser
);
if (!isStakeholder) continue; if (!isStakeholder) continue;
@ -854,7 +850,7 @@ class CacheManager {
getMyTasks(username) { getMyTasks(username) {
return { return {
prds: this.getPrdsNeedingAttention(username), prds: this.getPrdsNeedingAttention(username),
epics: this.getEpicsNeedingAttention(username) epics: this.getEpicsNeedingAttention(username),
}; };
} }
@ -910,7 +906,7 @@ class CacheManager {
epic_count: epicCount, epic_count: epicCount,
epics_by_status: epicsByStatus, epics_by_status: epicsByStatus,
epic_size_kb: Math.round(epicSize / 1024), epic_size_kb: Math.round(epicSize / 1024),
total_size_kb: baseStats.total_size_kb + Math.round(prdSize / 1024) + Math.round(epicSize / 1024) total_size_kb: baseStats.total_size_kb + Math.round(prdSize / 1024) + Math.round(epicSize / 1024),
}; };
} }
} }

View File

@ -40,5 +40,5 @@ module.exports = {
SyncEngine, SyncEngine,
CACHE_META_FILENAME, CACHE_META_FILENAME,
RETRY_BACKOFF_MS, RETRY_BACKOFF_MS,
MAX_RETRIES MAX_RETRIES,
}; };

View File

@ -45,7 +45,7 @@ class SyncEngine {
* @private * @private
*/ */
async _sleep(ms) { async _sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
/** /**
@ -78,9 +78,7 @@ class SyncEngine {
*/ */
_extractStoryKey(issue) { _extractStoryKey(issue) {
// Look for story: label first // Look for story: label first
const storyLabel = issue.labels?.find(l => const storyLabel = issue.labels?.find((l) => (typeof l === 'string' ? l : l.name)?.startsWith('story:'));
(typeof l === 'string' ? l : l.name)?.startsWith('story:')
);
if (storyLabel) { if (storyLabel) {
const labelName = typeof storyLabel === 'string' ? storyLabel : storyLabel.name; const labelName = typeof storyLabel === 'string' ? storyLabel : storyLabel.name;
@ -130,7 +128,7 @@ class SyncEngine {
* @private * @private
*/ */
_extractStatus(issue) { _extractStatus(issue) {
const statusLabel = issue.labels?.find(l => { const statusLabel = issue.labels?.find((l) => {
const name = typeof l === 'string' ? l : l.name; const name = typeof l === 'string' ? l : l.name;
return name?.startsWith('status:'); return name?.startsWith('status:');
}); });
@ -178,7 +176,7 @@ class SyncEngine {
// Search for updated stories (single API call) // Search for updated stories (single API call)
const searchResult = await this._retryWithBackoff( const searchResult = await this._retryWithBackoff(
async () => this.githubClient('search_issues', { query }), async () => this.githubClient('search_issues', { query }),
'Search for updated stories' 'Search for updated stories',
); );
const issues = searchResult.items || []; const issues = searchResult.items || [];
@ -212,7 +210,6 @@ class SyncEngine {
console.log(`✅ Sync complete: ${result.updated.length} updated, ${result.errors.length} errors`); console.log(`✅ Sync complete: ${result.updated.length} updated, ${result.errors.length} errors`);
return result; return result;
} finally { } finally {
this.syncInProgress = false; this.syncInProgress = false;
} }
@ -242,10 +239,11 @@ class SyncEngine {
// Fetch issue if not provided // Fetch issue if not provided
if (!issue) { if (!issue) {
const searchResult = await this._retryWithBackoff( const searchResult = await this._retryWithBackoff(
async () => this.githubClient('search_issues', { async () =>
query: `repo:${this.github.owner}/${this.github.repo} label:story:${storyKey}` this.githubClient('search_issues', {
}), query: `repo:${this.github.owner}/${this.github.repo} label:story:${storyKey}`,
`Fetch story ${storyKey}` }),
`Fetch story ${storyKey}`,
); );
const issues = searchResult.items || []; const issues = searchResult.items || [];
@ -270,7 +268,7 @@ class SyncEngine {
github_issue: issue.number, github_issue: issue.number,
github_updated_at: issue.updated_at, github_updated_at: issue.updated_at,
locked_by: issue.assignee?.login || null, locked_by: issue.assignee?.login || null,
locked_until: issue.assignee ? this._calculateLockExpiry() : null locked_until: issue.assignee ? this._calculateLockExpiry() : null,
}); });
console.log(`${storyKey} synced (Issue #${issue.number})`); console.log(`${storyKey} synced (Issue #${issue.number})`);
@ -302,10 +300,11 @@ class SyncEngine {
// Single API call for all stories in epic // Single API call for all stories in epic
const searchResult = await this._retryWithBackoff( const searchResult = await this._retryWithBackoff(
async () => this.githubClient('search_issues', { async () =>
query: `repo:${this.github.owner}/${this.github.repo} label:epic:${epicNumber} label:type:story` this.githubClient('search_issues', {
}), query: `repo:${this.github.owner}/${this.github.repo} label:epic:${epicNumber} label:type:story`,
`Pre-fetch Epic ${epicNumber}` }),
`Pre-fetch Epic ${epicNumber}`,
); );
const issues = searchResult.items || []; const issues = searchResult.items || [];
@ -357,13 +356,14 @@ class SyncEngine {
// Add comment if provided // Add comment if provided
if (update.comment) { if (update.comment) {
await this._retryWithBackoff( await this._retryWithBackoff(
async () => this.githubClient('add_issue_comment', { async () =>
owner: this.github.owner, this.githubClient('add_issue_comment', {
repo: this.github.repo, owner: this.github.owner,
issue_number: issueNumber, repo: this.github.repo,
body: update.comment issue_number: issueNumber,
}), body: update.comment,
`Add comment to issue #${issueNumber}` }),
`Add comment to issue #${issueNumber}`,
); );
} }
@ -374,14 +374,14 @@ class SyncEngine {
method: 'get', method: 'get',
owner: this.github.owner, owner: this.github.owner,
repo: this.github.repo, repo: this.github.repo,
issue_number: issueNumber issue_number: issueNumber,
}); });
let labels = issue.labels?.map(l => typeof l === 'string' ? l : l.name) || []; let labels = issue.labels?.map((l) => (typeof l === 'string' ? l : l.name)) || [];
// Remove labels // Remove labels
if (update.removeLabels) { if (update.removeLabels) {
labels = labels.filter(l => !update.removeLabels.includes(l)); labels = labels.filter((l) => !update.removeLabels.includes(l));
} }
// Add labels // Add labels
@ -394,14 +394,15 @@ class SyncEngine {
} }
await this._retryWithBackoff( await this._retryWithBackoff(
async () => this.githubClient('issue_write', { async () =>
method: 'update', this.githubClient('issue_write', {
owner: this.github.owner, method: 'update',
repo: this.github.repo, owner: this.github.owner,
issue_number: issueNumber, repo: this.github.repo,
labels issue_number: issueNumber,
}), labels,
`Update labels on issue #${issueNumber}` }),
`Update labels on issue #${issueNumber}`,
); );
} }
@ -412,7 +413,7 @@ class SyncEngine {
method: 'get', method: 'get',
owner: this.github.owner, owner: this.github.owner,
repo: this.github.repo, repo: this.github.repo,
issue_number: issueNumber issue_number: issueNumber,
}); });
console.log(`✅ GitHub issue #${issueNumber} updated and verified`); console.log(`✅ GitHub issue #${issueNumber} updated and verified`);
@ -420,7 +421,7 @@ class SyncEngine {
return { return {
storyKey, storyKey,
issueNumber, issueNumber,
verified: true verified: true,
}; };
} }
@ -458,7 +459,7 @@ class SyncEngine {
if (!storyMeta || !storyMeta.github_issue) { if (!storyMeta || !storyMeta.github_issue) {
// Need to find the issue first // Need to find the issue first
const searchResult = await this.githubClient('search_issues', { const searchResult = await this.githubClient('search_issues', {
query: `repo:${this.github.owner}/${this.github.repo} label:story:${storyKey}` query: `repo:${this.github.owner}/${this.github.repo} label:story:${storyKey}`,
}); });
if (!searchResult.items?.length) { if (!searchResult.items?.length) {
@ -472,28 +473,29 @@ class SyncEngine {
// Assign user and update status label // Assign user and update status label
await this._retryWithBackoff( await this._retryWithBackoff(
async () => this.githubClient('issue_write', { async () =>
method: 'update', this.githubClient('issue_write', {
owner: this.github.owner, method: 'update',
repo: this.github.repo, owner: this.github.owner,
issue_number: issueNumber, repo: this.github.repo,
assignees: [username] issue_number: issueNumber,
}), assignees: [username],
`Assign issue #${issueNumber} to ${username}` }),
`Assign issue #${issueNumber} to ${username}`,
); );
// Update status label to in-progress // Update status label to in-progress
await this.pushToGitHub(storyKey, { await this.pushToGitHub(storyKey, {
addLabels: ['status:in-progress'], addLabels: ['status:in-progress'],
removeLabels: ['status:backlog', 'status:ready-for-dev'], removeLabels: ['status:backlog', 'status:ready-for-dev'],
comment: `🔒 **Story locked by @${username}**\n\nLock expires in 8 hours.` comment: `🔒 **Story locked by @${username}**\n\nLock expires in 8 hours.`,
}); });
// Update cache // Update cache
const lockExpiry = this._calculateLockExpiry(); const lockExpiry = this._calculateLockExpiry();
this.cache.updateLock(storyKey, { this.cache.updateLock(storyKey, {
locked_by: username, locked_by: username,
locked_until: lockExpiry locked_until: lockExpiry,
}); });
// Verify assignment // Verify assignment
@ -503,10 +505,10 @@ class SyncEngine {
method: 'get', method: 'get',
owner: this.github.owner, owner: this.github.owner,
repo: this.github.repo, repo: this.github.repo,
issue_number: issueNumber issue_number: issueNumber,
}); });
if (!verify.assignees?.some(a => a.login === username)) { if (!verify.assignees?.some((a) => a.login === username)) {
throw new Error('Assignment verification failed'); throw new Error('Assignment verification failed');
} }
@ -516,7 +518,7 @@ class SyncEngine {
storyKey, storyKey,
issueNumber, issueNumber,
assignee: username, assignee: username,
lockExpiry lockExpiry,
}; };
} }
@ -539,21 +541,22 @@ class SyncEngine {
// Remove assignees // Remove assignees
await this._retryWithBackoff( await this._retryWithBackoff(
async () => this.githubClient('issue_write', { async () =>
method: 'update', this.githubClient('issue_write', {
owner: this.github.owner, method: 'update',
repo: this.github.repo, owner: this.github.owner,
issue_number: issueNumber, repo: this.github.repo,
assignees: [] issue_number: issueNumber,
}), assignees: [],
`Unassign issue #${issueNumber}` }),
`Unassign issue #${issueNumber}`,
); );
// Update status label // Update status label
await this.pushToGitHub(storyKey, { await this.pushToGitHub(storyKey, {
addLabels: ['status:ready-for-dev'], addLabels: ['status:ready-for-dev'],
removeLabels: ['status:in-progress'], removeLabels: ['status:in-progress'],
comment: `🔓 **Story unlocked**${reason ? `\n\nReason: ${reason}` : ''}` comment: `🔓 **Story unlocked**${reason ? `\n\nReason: ${reason}` : ''}`,
}); });
// Clear cache lock // Clear cache lock
@ -580,13 +583,13 @@ class SyncEngine {
available: false, available: false,
locked_by: cacheLock.locked_by, locked_by: cacheLock.locked_by,
locked_until: cacheLock.locked_until, locked_until: cacheLock.locked_until,
source: 'cache' source: 'cache',
}; };
} }
// Verify with GitHub (source of truth) // Verify with GitHub (source of truth)
const searchResult = await this.githubClient('search_issues', { const searchResult = await this.githubClient('search_issues', {
query: `repo:${this.github.owner}/${this.github.repo} label:story:${storyKey}` query: `repo:${this.github.owner}/${this.github.repo} label:story:${storyKey}`,
}); });
if (!searchResult.items?.length) { if (!searchResult.items?.length) {
@ -599,20 +602,20 @@ class SyncEngine {
// Update cache with GitHub truth // Update cache with GitHub truth
this.cache.updateLock(storyKey, { this.cache.updateLock(storyKey, {
locked_by: issue.assignee.login, locked_by: issue.assignee.login,
locked_until: this._calculateLockExpiry() locked_until: this._calculateLockExpiry(),
}); });
return { return {
available: false, available: false,
locked_by: issue.assignee.login, locked_by: issue.assignee.login,
github_issue: issue.number, github_issue: issue.number,
source: 'github' source: 'github',
}; };
} }
return { return {
available: true, available: true,
github_issue: issue.number github_issue: issue.number,
}; };
} }
@ -640,17 +643,19 @@ class SyncEngine {
const searchResult = await this._retryWithBackoff( const searchResult = await this._retryWithBackoff(
async () => this.githubClient('search_issues', { query }), async () => this.githubClient('search_issues', { query }),
'Search for available stories' 'Search for available stories',
); );
const stories = (searchResult.items || []).map(issue => ({ const stories = (searchResult.items || [])
storyKey: this._extractStoryKey(issue), .map((issue) => ({
title: issue.title, storyKey: this._extractStoryKey(issue),
issueNumber: issue.number, title: issue.title,
status: this._extractStatus(issue), issueNumber: issue.number,
labels: issue.labels?.map(l => typeof l === 'string' ? l : l.name) || [], status: this._extractStatus(issue),
url: issue.html_url labels: issue.labels?.map((l) => (typeof l === 'string' ? l : l.name)) || [],
})).filter(s => s.storyKey); // Filter out any without valid story keys url: issue.html_url,
}))
.filter((s) => s.storyKey); // Filter out any without valid story keys
return stories; return stories;
} }

View File

@ -9,62 +9,62 @@ const FEEDBACK_TYPES = {
clarification: { clarification: {
label: 'feedback-type:clarification', label: 'feedback-type:clarification',
emoji: '📋', emoji: '📋',
description: 'Something is unclear or needs more detail' description: 'Something is unclear or needs more detail',
}, },
concern: { concern: {
label: 'feedback-type:concern', label: 'feedback-type:concern',
emoji: '⚠️', emoji: '⚠️',
description: 'Potential issue, risk, or problem' description: 'Potential issue, risk, or problem',
}, },
suggestion: { suggestion: {
label: 'feedback-type:suggestion', label: 'feedback-type:suggestion',
emoji: '💡', emoji: '💡',
description: 'Improvement idea or alternative approach' description: 'Improvement idea or alternative approach',
}, },
addition: { addition: {
label: 'feedback-type:addition', label: 'feedback-type:addition',
emoji: '', emoji: '',
description: 'Missing requirement or feature' description: 'Missing requirement or feature',
}, },
priority: { priority: {
label: 'feedback-type:priority', label: 'feedback-type:priority',
emoji: '🔢', emoji: '🔢',
description: 'Disagree with prioritization or ordering' description: 'Disagree with prioritization or ordering',
}, },
// Epic-specific types // Epic-specific types
scope: { scope: {
label: 'feedback-type:scope', label: 'feedback-type:scope',
emoji: '📐', emoji: '📐',
description: 'Epic scope is too large or should be split' description: 'Epic scope is too large or should be split',
}, },
dependency: { dependency: {
label: 'feedback-type:dependency', label: 'feedback-type:dependency',
emoji: '🔗', emoji: '🔗',
description: 'Dependency or blocking relationship' description: 'Dependency or blocking relationship',
}, },
technical_risk: { technical_risk: {
label: 'feedback-type:technical-risk', label: 'feedback-type:technical-risk',
emoji: '🔧', emoji: '🔧',
description: 'Technical or architectural concern' description: 'Technical or architectural concern',
}, },
story_split: { story_split: {
label: 'feedback-type:story-split', label: 'feedback-type:story-split',
emoji: '✂️', emoji: '✂️',
description: 'Suggest different story breakdown' description: 'Suggest different story breakdown',
} },
}; };
const FEEDBACK_STATUS = { const FEEDBACK_STATUS = {
new: 'feedback-status:new', new: 'feedback-status:new',
reviewed: 'feedback-status:reviewed', reviewed: 'feedback-status:reviewed',
incorporated: 'feedback-status:incorporated', incorporated: 'feedback-status:incorporated',
deferred: 'feedback-status:deferred' deferred: 'feedback-status:deferred',
}; };
const PRIORITY_LEVELS = { const PRIORITY_LEVELS = {
high: 'priority:high', high: 'priority:high',
medium: 'priority:medium', medium: 'priority:medium',
low: 'priority:low' low: 'priority:low',
}; };
class FeedbackManager { class FeedbackManager {
@ -78,16 +78,16 @@ class FeedbackManager {
*/ */
async createFeedback({ async createFeedback({
reviewIssueNumber, reviewIssueNumber,
documentKey, // prd:user-auth or epic:2 documentKey, // prd:user-auth or epic:2
documentType, // 'prd' or 'epic' documentType, // 'prd' or 'epic'
section, // e.g., 'User Stories', 'FR-3' section, // e.g., 'User Stories', 'FR-3'
feedbackType, // 'clarification', 'concern', etc. feedbackType, // 'clarification', 'concern', etc.
priority, // 'high', 'medium', 'low' priority, // 'high', 'medium', 'low'
title, // Brief title title, // Brief title
content, // Detailed feedback content, // Detailed feedback
suggestedChange, // Optional proposed change suggestedChange, // Optional proposed change
rationale, // Why this matters rationale, // Why this matters
submittedBy // @username submittedBy, // @username
}) { }) {
const typeConfig = FEEDBACK_TYPES[feedbackType]; const typeConfig = FEEDBACK_TYPES[feedbackType];
if (!typeConfig) { if (!typeConfig) {
@ -101,7 +101,7 @@ class FeedbackManager {
`feedback-section:${section.toLowerCase().replace(/\s+/g, '-')}`, `feedback-section:${section.toLowerCase().replace(/\s+/g, '-')}`,
typeConfig.label, typeConfig.label,
FEEDBACK_STATUS.new, FEEDBACK_STATUS.new,
PRIORITY_LEVELS[priority] || PRIORITY_LEVELS.medium PRIORITY_LEVELS[priority] || PRIORITY_LEVELS.medium,
]; ];
const body = this._formatFeedbackBody({ const body = this._formatFeedbackBody({
@ -114,14 +114,14 @@ class FeedbackManager {
content, content,
suggestedChange, suggestedChange,
rationale, rationale,
submittedBy submittedBy,
}); });
// Create the feedback issue // Create the feedback issue
const issue = await this._createIssue({ const issue = await this._createIssue({
title: `${typeConfig.emoji} Feedback: ${title}`, title: `${typeConfig.emoji} Feedback: ${title}`,
body, body,
labels labels,
}); });
// Add comment to review issue linking to this feedback // Add comment to review issue linking to this feedback
@ -133,7 +133,7 @@ class FeedbackManager {
documentKey, documentKey,
section, section,
feedbackType, feedbackType,
status: 'new' status: 'new',
}; };
} }
@ -141,12 +141,12 @@ class FeedbackManager {
* Query all feedback for a document or review round * Query all feedback for a document or review round
*/ */
async getFeedback({ async getFeedback({
documentKey, // Optional: filter by document documentKey, // Optional: filter by document
reviewIssueNumber, // Optional: filter by review round reviewIssueNumber, // Optional: filter by review round
documentType, // 'prd' or 'epic' documentType, // 'prd' or 'epic'
status, // Optional: filter by status status, // Optional: filter by status
section, // Optional: filter by section section, // Optional: filter by section
feedbackType // Optional: filter by type feedbackType, // Optional: filter by type
}) { }) {
let query = `repo:${this.owner}/${this.repo} type:issue is:open`; let query = `repo:${this.owner}/${this.repo} type:issue is:open`;
query += ` label:type:${documentType}-feedback`; query += ` label:type:${documentType}-feedback`;
@ -177,7 +177,7 @@ class FeedbackManager {
const results = await this._searchIssues(query); const results = await this._searchIssues(query);
return results.map(issue => this._parseFeedbackIssue(issue)); return results.map((issue) => this._parseFeedbackIssue(issue));
} }
/** /**
@ -225,15 +225,15 @@ class FeedbackManager {
if (feedbackList.length < 2) continue; if (feedbackList.length < 2) continue;
// Check for opposing views on the same topic // Check for opposing views on the same topic
const concerns = feedbackList.filter(f => f.feedbackType === 'concern'); const concerns = feedbackList.filter((f) => f.feedbackType === 'concern');
const suggestions = feedbackList.filter(f => f.feedbackType === 'suggestion'); const suggestions = feedbackList.filter((f) => f.feedbackType === 'suggestion');
if (concerns.length > 1 || (concerns.length >= 1 && suggestions.length >= 1)) { if (concerns.length > 1 || (concerns.length >= 1 && suggestions.length >= 1)) {
conflicts.push({ conflicts.push({
section, section,
feedbackItems: feedbackList, feedbackItems: feedbackList,
conflictType: 'multiple_opinions', conflictType: 'multiple_opinions',
summary: `${feedbackList.length} stakeholders have input on ${section}` summary: `${feedbackList.length} stakeholders have input on ${section}`,
}); });
} }
} }
@ -252,27 +252,21 @@ class FeedbackManager {
// Get current labels // Get current labels
const issue = await this._getIssue(feedbackIssueNumber); const issue = await this._getIssue(feedbackIssueNumber);
const currentLabels = issue.labels.map(l => l.name); const currentLabels = issue.labels.map((l) => l.name);
// Remove old status labels, add new one // Remove old status labels, add new one
const newLabels = currentLabels const newLabels = currentLabels.filter((l) => !l.startsWith('feedback-status:')).concat([statusLabel]);
.filter(l => !l.startsWith('feedback-status:'))
.concat([statusLabel]);
await this._updateIssue(feedbackIssueNumber, { labels: newLabels }); await this._updateIssue(feedbackIssueNumber, { labels: newLabels });
// Add resolution comment if provided // Add resolution comment if provided
if (resolution) { if (resolution) {
await this._addComment(feedbackIssueNumber, await this._addComment(feedbackIssueNumber, `**Status Updated: ${newStatus}**\n\n${resolution}`);
`**Status Updated: ${newStatus}**\n\n${resolution}`
);
} }
// Close issue if incorporated or deferred // Close issue if incorporated or deferred
if (newStatus === 'incorporated' || newStatus === 'deferred') { if (newStatus === 'incorporated' || newStatus === 'deferred') {
await this._closeIssue(feedbackIssueNumber, await this._closeIssue(feedbackIssueNumber, newStatus === 'incorporated' ? 'completed' : 'not_planned');
newStatus === 'incorporated' ? 'completed' : 'not_planned'
);
} }
return { feedbackId: feedbackIssueNumber, status: newStatus }; return { feedbackId: feedbackIssueNumber, status: newStatus };
@ -290,7 +284,7 @@ class FeedbackManager {
byStatus: {}, byStatus: {},
bySection: {}, bySection: {},
byPriority: {}, byPriority: {},
submitters: new Set() submitters: new Set(),
}; };
for (const fb of allFeedback) { for (const fb of allFeedback) {
@ -318,7 +312,18 @@ class FeedbackManager {
// ============ Private Methods ============ // ============ Private Methods ============
_formatFeedbackBody({ reviewIssueNumber, documentKey, section, feedbackType, typeConfig, priority, content, suggestedChange, rationale, submittedBy }) { _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`; let body = `# ${typeConfig.emoji} Feedback: ${feedbackType.charAt(0).toUpperCase() + feedbackType.slice(1)}\n\n`;
body += `**Review:** #${reviewIssueNumber}\n`; body += `**Review:** #${reviewIssueNumber}\n`;
body += `**Document:** \`${documentKey}\`\n`; body += `**Document:** \`${documentKey}\`\n`;
@ -343,7 +348,7 @@ class FeedbackManager {
} }
_parseFeedbackIssue(issue) { _parseFeedbackIssue(issue) {
const labels = issue.labels.map(l => l.name); const labels = issue.labels.map((l) => l.name);
return { return {
id: issue.number, id: issue.number,
@ -356,18 +361,19 @@ class FeedbackManager {
submittedBy: issue.user?.login, submittedBy: issue.user?.login,
createdAt: issue.created_at, createdAt: issue.created_at,
updatedAt: issue.updated_at, updatedAt: issue.updated_at,
body: issue.body body: issue.body,
}; };
} }
_extractLabel(labels, prefix) { _extractLabel(labels, prefix) {
const label = labels.find(l => l.startsWith(prefix)); const label = labels.find((l) => l.startsWith(prefix));
return label ? label.replace(prefix, '') : null; return label ? label.replace(prefix, '') : null;
} }
async _addLinkComment(reviewIssueNumber, feedbackIssueNumber, title, feedbackType, submittedBy) { async _addLinkComment(reviewIssueNumber, feedbackIssueNumber, title, feedbackType, submittedBy) {
const typeConfig = FEEDBACK_TYPES[feedbackType]; const typeConfig = FEEDBACK_TYPES[feedbackType];
const comment = `${typeConfig.emoji} **New Feedback** from @${submittedBy}\n\n` + const comment =
`${typeConfig.emoji} **New Feedback** from @${submittedBy}\n\n` +
`**${title}** → #${feedbackIssueNumber}\n` + `**${title}** → #${feedbackIssueNumber}\n` +
`Type: ${feedbackType}`; `Type: ${feedbackType}`;
@ -410,5 +416,5 @@ module.exports = {
FeedbackManager, FeedbackManager,
FEEDBACK_TYPES, FEEDBACK_TYPES,
FEEDBACK_STATUS, FEEDBACK_STATUS,
PRIORITY_LEVELS PRIORITY_LEVELS,
}; };

View File

@ -24,5 +24,5 @@ module.exports = {
SignoffManager, SignoffManager,
SIGNOFF_STATUS, SIGNOFF_STATUS,
THRESHOLD_TYPES, THRESHOLD_TYPES,
DEFAULT_CONFIG DEFAULT_CONFIG,
}; };

View File

@ -11,13 +11,13 @@ const SIGNOFF_STATUS = {
pending: 'signoff:pending', pending: 'signoff:pending',
approved: 'signoff:approved', approved: 'signoff:approved',
approved_with_note: 'signoff:approved-with-note', approved_with_note: 'signoff:approved-with-note',
blocked: 'signoff:blocked' blocked: 'signoff:blocked',
}; };
const THRESHOLD_TYPES = { const THRESHOLD_TYPES = {
count: 'count', count: 'count',
percentage: 'percentage', percentage: 'percentage',
required_approvers: 'required_approvers' required_approvers: 'required_approvers',
}; };
const DEFAULT_CONFIG = { const DEFAULT_CONFIG = {
@ -28,7 +28,7 @@ const DEFAULT_CONFIG = {
optional: [], optional: [],
minimum_optional: 0, minimum_optional: 0,
allow_blocks: true, allow_blocks: true,
block_threshold: 1 block_threshold: 1,
}; };
class SignoffManager { class SignoffManager {
@ -42,11 +42,11 @@ class SignoffManager {
*/ */
async requestSignoff({ async requestSignoff({
documentKey, documentKey,
documentType, // 'prd' or 'epic' documentType, // 'prd' or 'epic'
reviewIssueNumber, reviewIssueNumber,
stakeholders, // Array of @usernames stakeholders, // Array of @usernames
deadline, // ISO date string deadline, // ISO date string
config = {} // Sign-off configuration config = {}, // Sign-off configuration
}) { }) {
const signoffConfig = { ...DEFAULT_CONFIG, ...config }; const signoffConfig = { ...DEFAULT_CONFIG, ...config };
@ -54,16 +54,10 @@ class SignoffManager {
this._validateConfig(signoffConfig, stakeholders); this._validateConfig(signoffConfig, stakeholders);
// Update the review issue to signoff status // Update the review issue to signoff status
const labels = [ const labels = [`type:${documentType}-review`, `${documentType}:${documentKey.split(':')[1]}`, 'review-status:signoff'];
`type:${documentType}-review`,
`${documentType}:${documentKey.split(':')[1]}`,
'review-status:signoff'
];
// Build stakeholder checklist // Build stakeholder checklist
const checklist = stakeholders.map(user => const checklist = stakeholders.map((user) => `- [ ] @${user.replace('@', '')} - ⏳ Pending`).join('\n');
`- [ ] @${user.replace('@', '')} - ⏳ Pending`
).join('\n');
const body = this._formatSignoffRequestBody({ const body = this._formatSignoffRequestBody({
documentKey, documentKey,
@ -71,7 +65,7 @@ class SignoffManager {
stakeholders, stakeholders,
deadline, deadline,
config: signoffConfig, config: signoffConfig,
checklist checklist,
}); });
// Add comment to review issue // Add comment to review issue
@ -83,7 +77,7 @@ class SignoffManager {
stakeholders, stakeholders,
deadline, deadline,
config: signoffConfig, config: signoffConfig,
status: 'signoff_requested' status: 'signoff_requested',
}; };
} }
@ -95,9 +89,9 @@ class SignoffManager {
documentKey, documentKey,
documentType, documentType,
user, user,
decision, // 'approved' | 'approved_with_note' | 'blocked' decision, // 'approved' | 'approved_with_note' | 'blocked'
note = null, // Optional note or blocking reason note = null, // Optional note or blocking reason
feedbackIssueNumber = null // If blocked, link to feedback issue feedbackIssueNumber = null, // If blocked, link to feedback issue
}) { }) {
if (!Object.keys(SIGNOFF_STATUS).includes(decision)) { if (!Object.keys(SIGNOFF_STATUS).includes(decision)) {
throw new Error(`Invalid decision: ${decision}. Must be one of: ${Object.keys(SIGNOFF_STATUS).join(', ')}`); throw new Error(`Invalid decision: ${decision}. Must be one of: ${Object.keys(SIGNOFF_STATUS).join(', ')}`);
@ -128,7 +122,7 @@ class SignoffManager {
user, user,
decision, decision,
note, note,
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
}; };
} }
@ -137,7 +131,7 @@ class SignoffManager {
*/ */
async getSignoffs(reviewIssueNumber) { async getSignoffs(reviewIssueNumber) {
const issue = await this._getIssue(reviewIssueNumber); const issue = await this._getIssue(reviewIssueNumber);
const labels = issue.labels.map(l => l.name); const labels = issue.labels.map((l) => l.name);
// Parse signoff labels: signoff-{user}-{status} // Parse signoff labels: signoff-{user}-{status}
const signoffs = []; const signoffs = [];
@ -147,7 +141,7 @@ class SignoffManager {
signoffs.push({ signoffs.push({
user: match[1], user: match[1],
status: match[2].replace(/-/g, '_'), status: match[2].replace(/-/g, '_'),
label: label label: label,
}); });
} }
} }
@ -159,20 +153,16 @@ class SignoffManager {
* Calculate sign-off status based on configuration * Calculate sign-off status based on configuration
*/ */
calculateStatus(signoffs, stakeholders, config = DEFAULT_CONFIG) { calculateStatus(signoffs, stakeholders, config = DEFAULT_CONFIG) {
const approvals = signoffs.filter(s => const approvals = signoffs.filter((s) => s.status === 'approved' || s.status === 'approved_with_note');
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('@', '')));
const blocks = signoffs.filter(s => s.status === 'blocked');
const pending = stakeholders.filter(user =>
!signoffs.some(s => s.user === user.replace('@', ''))
);
// Check for blockers first // Check for blockers first
if (config.allow_blocks && blocks.length >= config.block_threshold) { if (config.allow_blocks && blocks.length >= config.block_threshold) {
return { return {
status: 'blocked', status: 'blocked',
blockers: blocks.map(b => b.user), blockers: blocks.map((b) => b.user),
message: `Blocked by ${blocks.length} stakeholder(s)` message: `Blocked by ${blocks.length} stakeholder(s)`,
}; };
} }
@ -205,15 +195,11 @@ class SignoffManager {
getProgressSummary(signoffs, stakeholders, config = DEFAULT_CONFIG) { getProgressSummary(signoffs, stakeholders, config = DEFAULT_CONFIG) {
const status = this.calculateStatus(signoffs, stakeholders, config); const status = this.calculateStatus(signoffs, stakeholders, config);
const approvalCount = signoffs.filter(s => const approvalCount = signoffs.filter((s) => s.status === 'approved' || s.status === 'approved_with_note').length;
s.status === 'approved' || s.status === 'approved_with_note'
).length;
const blockCount = signoffs.filter(s => s.status === 'blocked').length; const blockCount = signoffs.filter((s) => s.status === 'blocked').length;
const pendingUsers = stakeholders.filter(user => const pendingUsers = stakeholders.filter((user) => !signoffs.some((s) => s.user === user.replace('@', '')));
!signoffs.some(s => s.user === user.replace('@', ''))
);
return { return {
...status, ...status,
@ -222,7 +208,7 @@ class SignoffManager {
blocked_count: blockCount, blocked_count: blockCount,
pending_count: pendingUsers.length, pending_count: pendingUsers.length,
pending_users: pendingUsers, pending_users: pendingUsers,
progress_percent: Math.round((approvalCount / stakeholders.length) * 100) progress_percent: Math.round((approvalCount / stakeholders.length) * 100),
}; };
} }
@ -230,9 +216,10 @@ class SignoffManager {
* Send reminder to pending stakeholders * Send reminder to pending stakeholders
*/ */
async sendReminder(reviewIssueNumber, pendingUsers, deadline) { async sendReminder(reviewIssueNumber, pendingUsers, deadline) {
const mentions = pendingUsers.map(u => `@${u.replace('@', '')}`).join(', '); const mentions = pendingUsers.map((u) => `@${u.replace('@', '')}`).join(', ');
const comment = `### ⏰ Reminder: Sign-off Needed\n\n` + const comment =
`### ⏰ Reminder: Sign-off Needed\n\n` +
`${mentions}\n\n` + `${mentions}\n\n` +
`Your sign-off is still pending for this review.\n` + `Your sign-off is still pending for this review.\n` +
`**Deadline:** ${deadline}\n\n` + `**Deadline:** ${deadline}\n\n` +
@ -264,16 +251,12 @@ class SignoffManager {
_validateConfig(config, stakeholders) { _validateConfig(config, stakeholders) {
if (config.threshold_type === THRESHOLD_TYPES.count) { if (config.threshold_type === THRESHOLD_TYPES.count) {
if (config.minimum_approvals > stakeholders.length) { if (config.minimum_approvals > stakeholders.length) {
throw new Error( throw new Error(`minimum_approvals (${config.minimum_approvals}) cannot exceed stakeholder count (${stakeholders.length})`);
`minimum_approvals (${config.minimum_approvals}) cannot exceed stakeholder count (${stakeholders.length})`
);
} }
} }
if (config.threshold_type === THRESHOLD_TYPES.required_approvers) { if (config.threshold_type === THRESHOLD_TYPES.required_approvers) {
const allRequired = config.required.every(r => const allRequired = config.required.every((r) => stakeholders.some((s) => s.replace('@', '') === r.replace('@', '')));
stakeholders.some(s => s.replace('@', '') === r.replace('@', ''))
);
if (!allRequired) { if (!allRequired) {
throw new Error('All required approvers must be in stakeholder list'); throw new Error('All required approvers must be in stakeholder list');
} }
@ -289,7 +272,7 @@ class SignoffManager {
status: 'pending', status: 'pending',
needed: config.minimum_approvals - approvals.length, needed: config.minimum_approvals - approvals.length,
pending_users: pending, pending_users: pending,
message: `Need ${config.minimum_approvals - approvals.length} more approval(s)` message: `Need ${config.minimum_approvals - approvals.length} more approval(s)`,
}; };
} }
@ -299,7 +282,7 @@ class SignoffManager {
if (percent >= config.approval_percentage) { if (percent >= config.approval_percentage) {
return { return {
status: 'approved', status: 'approved',
message: `${Math.round(percent)}% approved (threshold: ${config.approval_percentage}%)` message: `${Math.round(percent)}% approved (threshold: ${config.approval_percentage}%)`,
}; };
} }
@ -310,44 +293,38 @@ class SignoffManager {
needed_percent: config.approval_percentage, needed_percent: config.approval_percentage,
needed: needed, needed: needed,
pending_users: pending, pending_users: pending,
message: `${Math.round(percent)}% approved, need ${config.approval_percentage}%` message: `${Math.round(percent)}% approved, need ${config.approval_percentage}%`,
}; };
} }
_calculateRequiredApproversStatus(approvals, config, pending) { _calculateRequiredApproversStatus(approvals, config, pending) {
const approvedUsers = approvals.map(a => a.user); const approvedUsers = approvals.map((a) => a.user);
// Check required approvers // Check required approvers
const missingRequired = config.required.filter(r => const missingRequired = config.required.filter((r) => !approvedUsers.includes(r.replace('@', '')));
!approvedUsers.includes(r.replace('@', ''))
);
if (missingRequired.length > 0) { if (missingRequired.length > 0) {
return { return {
status: 'pending', status: 'pending',
missing_required: missingRequired, missing_required: missingRequired,
pending_users: pending, pending_users: pending,
message: `Waiting for required approvers: ${missingRequired.join(', ')}` message: `Waiting for required approvers: ${missingRequired.join(', ')}`,
}; };
} }
// Check optional approvers // Check optional approvers
const optionalApproved = approvals.filter(a => const optionalApproved = approvals.filter((a) => config.optional.some((o) => o.replace('@', '') === a.user)).length;
config.optional.some(o => o.replace('@', '') === a.user)
).length;
if (optionalApproved < config.minimum_optional) { if (optionalApproved < config.minimum_optional) {
const neededOptional = config.minimum_optional - optionalApproved; const neededOptional = config.minimum_optional - optionalApproved;
const pendingOptional = config.optional.filter(o => const pendingOptional = config.optional.filter((o) => !approvedUsers.includes(o.replace('@', '')));
!approvedUsers.includes(o.replace('@', ''))
);
return { return {
status: 'pending', status: 'pending',
optional_needed: neededOptional, optional_needed: neededOptional,
pending_optional: pendingOptional, pending_optional: pendingOptional,
pending_users: pending, pending_users: pending,
message: `Need ${neededOptional} more optional approver(s)` message: `Need ${neededOptional} more optional approver(s)`,
}; };
} }
@ -356,19 +333,27 @@ class SignoffManager {
_getDecisionEmoji(decision) { _getDecisionEmoji(decision) {
switch (decision) { switch (decision) {
case 'approved': return '✅'; case 'approved':
case 'approved_with_note': return '✅📝'; return '✅';
case 'blocked': return '🚫'; case 'approved_with_note':
default: return '⏳'; return '✅📝';
case 'blocked':
return '🚫';
default:
return '⏳';
} }
} }
_getDecisionText(decision) { _getDecisionText(decision) {
switch (decision) { switch (decision) {
case 'approved': return 'Approved'; case 'approved':
case 'approved_with_note': return 'Approved with Note'; return 'Approved';
case 'blocked': return 'Blocked'; case 'approved_with_note':
default: return 'Pending'; return 'Approved with Note';
case 'blocked':
return 'Blocked';
default:
return 'Pending';
} }
} }
@ -419,12 +404,10 @@ class SignoffManager {
// Get current labels // Get current labels
const issue = await this._getIssue(issueNumber); const issue = await this._getIssue(issueNumber);
const currentLabels = issue.labels.map(l => l.name); const currentLabels = issue.labels.map((l) => l.name);
// Remove any existing signoff label for this user // Remove any existing signoff label for this user
const newLabels = currentLabels.filter(l => const newLabels = currentLabels.filter((l) => !l.startsWith(`signoff-${normalizedUser}-`));
!l.startsWith(`signoff-${normalizedUser}-`)
);
// Add new signoff label // Add new signoff label
newLabels.push(label); newLabels.push(label);
@ -453,5 +436,5 @@ module.exports = {
SignoffManager, SignoffManager,
SIGNOFF_STATUS, SIGNOFF_STATUS,
THRESHOLD_TYPES, THRESHOLD_TYPES,
DEFAULT_CONFIG DEFAULT_CONFIG,
}; };

View File

@ -61,7 +61,7 @@ Generate the updated section text that:
2. Maintains consistent tone and format 2. Maintains consistent tone and format
3. Is clear and actionable 3. Is clear and actionable
Return the complete updated section text.` Return the complete updated section text.`,
}, },
epic: { epic: {
@ -103,8 +103,8 @@ Propose an updated story breakdown that:
Format as JSON with: Format as JSON with:
- stories: Array of { key, title, description, tasks_estimate } - stories: Array of { key, title, description, tasks_estimate }
- changes_made: What changed from original - changes_made: What changed from original
- rationale: Why this split works better` - rationale: Why this split works better`,
} },
}; };
class SynthesisEngine { class SynthesisEngine {
@ -121,29 +121,29 @@ class SynthesisEngine {
conflicts: [], conflicts: [],
themes: [], themes: [],
suggestedChanges: [], suggestedChanges: [],
summary: {} summary: {},
}; };
for (const [section, feedbackList] of Object.entries(feedbackBySection)) { for (const [section, feedbackList] of Object.entries(feedbackBySection)) {
const sectionAnalysis = await this._analyzeSection( const sectionAnalysis = await this._analyzeSection(section, feedbackList, originalDocument[section]);
section,
feedbackList,
originalDocument[section]
);
analysis.sections[section] = sectionAnalysis; analysis.sections[section] = sectionAnalysis;
if (sectionAnalysis.conflicts.length > 0) { if (sectionAnalysis.conflicts.length > 0) {
analysis.conflicts.push(...sectionAnalysis.conflicts.map(c => ({ analysis.conflicts.push(
...c, ...sectionAnalysis.conflicts.map((c) => ({
section ...c,
}))); section,
})),
);
} }
analysis.suggestedChanges.push(...sectionAnalysis.suggestedChanges.map(c => ({ analysis.suggestedChanges.push(
...c, ...sectionAnalysis.suggestedChanges.map((c) => ({
section ...c,
}))); section,
})),
);
} }
// Generate overall summary // Generate overall summary
@ -161,7 +161,7 @@ class SynthesisEngine {
byType: this._groupByType(feedbackList), byType: this._groupByType(feedbackList),
themes: [], themes: [],
conflicts: [], conflicts: [],
suggestedChanges: [] suggestedChanges: [],
}; };
// Identify conflicts (multiple feedback on same aspect) // Identify conflicts (multiple feedback on same aspect)
@ -171,9 +171,7 @@ class SynthesisEngine {
result.themes = this._identifyThemes(feedbackList); result.themes = this._identifyThemes(feedbackList);
// Generate suggested changes for non-conflicting feedback // Generate suggested changes for non-conflicting feedback
const nonConflicting = feedbackList.filter( const nonConflicting = feedbackList.filter((f) => !result.conflicts.some((c) => c.feedbackIds.includes(f.id)));
f => !result.conflicts.some(c => c.feedbackIds.includes(f.id))
);
for (const feedback of nonConflicting) { for (const feedback of nonConflicting) {
result.suggestedChanges.push({ result.suggestedChanges.push({
@ -182,7 +180,7 @@ class SynthesisEngine {
priority: feedback.priority, priority: feedback.priority,
description: feedback.title, description: feedback.title,
suggestedChange: feedback.suggestedChange, suggestedChange: feedback.suggestedChange,
submittedBy: feedback.submittedBy submittedBy: feedback.submittedBy,
}); });
} }
@ -210,13 +208,13 @@ class SynthesisEngine {
if (items.length < 2) continue; if (items.length < 2) continue;
// Check if they have different suggestions // Check if they have different suggestions
const uniqueSuggestions = new Set(items.map(i => i.suggestedChange).filter(Boolean)); const uniqueSuggestions = new Set(items.map((i) => i.suggestedChange).filter(Boolean));
if (uniqueSuggestions.size > 1) { if (uniqueSuggestions.size > 1) {
conflicts.push({ conflicts.push({
topic, topic,
feedbackIds: items.map(i => i.id), feedbackIds: items.map((i) => i.id),
stakeholders: items.map(i => ({ user: i.submittedBy, position: i.title })), stakeholders: items.map((i) => ({ user: i.submittedBy, position: i.title })),
description: `Conflicting views on ${topic}` description: `Conflicting views on ${topic}`,
}); });
} }
} }
@ -244,10 +242,10 @@ class SynthesisEngine {
// Return themes mentioned by multiple people // Return themes mentioned by multiple people
return Object.values(themes) return Object.values(themes)
.filter(t => t.count >= 2) .filter((t) => t.count >= 2)
.map(t => ({ .map((t) => ({
...t, ...t,
types: Array.from(t.types) types: Array.from(t.types),
})) }))
.sort((a, b) => b.count - a.count); .sort((a, b) => b.count - a.count);
} }
@ -271,8 +269,8 @@ class SynthesisEngine {
proposed_text: 'string', proposed_text: 'string',
rationale: 'string', rationale: 'string',
trade_offs: 'string[]', trade_offs: 'string[]',
confidence: 'high|medium|low' confidence: 'high|medium|low',
} },
}; };
} }
@ -280,9 +278,9 @@ class SynthesisEngine {
* Generate merge prompt for incorporating feedback * Generate merge prompt for incorporating feedback
*/ */
generateMergePrompt(section, originalText, approvedFeedback) { generateMergePrompt(section, originalText, approvedFeedback) {
const feedbackText = approvedFeedback.map(f => const feedbackText = approvedFeedback
`- ${f.feedbackType}: ${f.title}\n Change: ${f.suggestedChange || 'Address the concern'}` .map((f) => `- ${f.feedbackType}: ${f.title}\n Change: ${f.suggestedChange || 'Address the concern'}`)
).join('\n\n'); .join('\n\n');
return SYNTHESIS_PROMPTS[this.documentType].merge return SYNTHESIS_PROMPTS[this.documentType].merge
.replace('{{section}}', section) .replace('{{section}}', section)
@ -309,8 +307,7 @@ class SynthesisEngine {
* Generate synthesis summary * Generate synthesis summary
*/ */
_generateSummary(analysis) { _generateSummary(analysis) {
const totalFeedback = Object.values(analysis.sections) const totalFeedback = Object.values(analysis.sections).reduce((sum, s) => sum + s.feedbackCount, 0);
.reduce((sum, s) => sum + s.feedbackCount, 0);
const allTypes = {}; const allTypes = {};
for (const section of Object.values(analysis.sections)) { for (const section of Object.values(analysis.sections)) {
@ -326,7 +323,7 @@ class SynthesisEngine {
themeCount: analysis.themes ? analysis.themes.length : 0, themeCount: analysis.themes ? analysis.themes.length : 0,
changeCount: analysis.suggestedChanges.length, changeCount: analysis.suggestedChanges.length,
feedbackByType: allTypes, feedbackByType: allTypes,
needsAttention: analysis.conflicts.length > 0 needsAttention: analysis.conflicts.length > 0,
}; };
} }
@ -349,20 +346,69 @@ class SynthesisEngine {
// Simple keyword extraction - can be enhanced // Simple keyword extraction - can be enhanced
const stopWords = new Set([ const stopWords = new Set([
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'the',
'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'a',
'would', 'could', 'should', 'may', 'might', 'must', 'shall', 'an',
'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'is',
'this', 'that', 'these', 'those', 'it', 'its', 'and', 'or', 'are',
'but', 'not', 'no', 'if', 'then', 'else', 'when', 'where', 'was',
'why', 'how', 'what', 'which', 'who', 'whom', 'whose' '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 return text
.toLowerCase() .toLowerCase()
.replace(/[^\w\s]/g, ' ') .replace(/[^\w\s]/g, ' ')
.split(/\s+/) .split(/\s+/)
.filter(word => word.length > 3 && !stopWords.has(word)) .filter((word) => word.length > 3 && !stopWords.has(word))
.slice(0, 10); // Limit to top 10 keywords .slice(0, 10); // Limit to top 10 keywords
} }

View File

@ -62,7 +62,7 @@ View Document: {{document_url}}
--- ---
PRD Crowdsourcing System PRD Crowdsourcing System
` `,
}, },
signoff_requested: { signoff_requested: {
@ -143,7 +143,7 @@ Review & Sign Off: {{document_url}}
--- ---
PRD Crowdsourcing System PRD Crowdsourcing System
` `,
}, },
document_approved: { document_approved: {
@ -207,7 +207,7 @@ View Approved Document: {{document_url}}
--- ---
PRD Crowdsourcing System PRD Crowdsourcing System
` `,
}, },
document_blocked: { document_blocked: {
@ -273,7 +273,7 @@ View Blocking Issue: {{feedback_url}}
--- ---
PRD Crowdsourcing System PRD Crowdsourcing System
` `,
}, },
reminder: { reminder: {
@ -339,8 +339,8 @@ Take Action: {{document_url}}
--- ---
PRD Crowdsourcing System PRD Crowdsourcing System
` `,
} },
}; };
class EmailNotifier { class EmailNotifier {
@ -385,7 +385,7 @@ class EmailNotifier {
return { return {
success: false, success: false,
channel: 'email', channel: 'email',
error: 'Email notifications not enabled' error: 'Email notifications not enabled',
}; };
} }
@ -394,21 +394,21 @@ class EmailNotifier {
return { return {
success: false, success: false,
channel: 'email', channel: 'email',
error: `Unknown notification event type: ${eventType}` error: `Unknown notification event type: ${eventType}`,
}; };
} }
// Get recipient emails // Get recipient emails
const recipients = options.recipients || []; const recipients = options.recipients || [];
if (data.users) { if (data.users) {
recipients.push(...data.users.map(u => this.userEmails[u]).filter(Boolean)); recipients.push(...data.users.map((u) => this.userEmails[u]).filter(Boolean));
} }
if (recipients.length === 0) { if (recipients.length === 0) {
return { return {
success: false, success: false,
channel: 'email', channel: 'email',
error: 'No recipients specified' error: 'No recipients specified',
}; };
} }
@ -421,19 +421,19 @@ class EmailNotifier {
to: recipients, to: recipients,
subject, subject,
html, html,
text text,
}); });
return { return {
success: true, success: true,
channel: 'email', channel: 'email',
recipientCount: recipients.length recipientCount: recipients.length,
}; };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
channel: 'email', channel: 'email',
error: error.message error: error.message,
}; };
} }
} }
@ -451,7 +451,7 @@ class EmailNotifier {
return { return {
success: false, success: false,
channel: 'email', channel: 'email',
error: 'Email notifications not enabled' error: 'Email notifications not enabled',
}; };
} }
@ -460,19 +460,19 @@ class EmailNotifier {
to: recipients, to: recipients,
subject, subject,
html: options.html ? body : undefined, html: options.html ? body : undefined,
text: options.html ? undefined : body text: options.html ? undefined : body,
}); });
return { return {
success: true, success: true,
channel: 'email', channel: 'email',
recipientCount: recipients.length recipientCount: recipients.length,
}; };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
channel: 'email', channel: 'email',
error: error.message error: error.message,
}; };
} }
} }
@ -506,12 +506,12 @@ class EmailNotifier {
const emailPayload = { const emailPayload = {
from: { from: {
name: this.fromName, name: this.fromName,
address: this.fromAddress address: this.fromAddress,
}, },
to: Array.isArray(to) ? to : [to], to: Array.isArray(to) ? to : [to],
subject, subject,
html, html,
text text,
}; };
switch (this.provider) { switch (this.provider) {
@ -574,5 +574,5 @@ class EmailNotifier {
module.exports = { module.exports = {
EmailNotifier, EmailNotifier,
EMAIL_TEMPLATES EMAIL_TEMPLATES,
}; };

View File

@ -26,7 +26,7 @@ Please review and provide your feedback by {{deadline}}.
{{actions}} {{actions}}
{{/if}} {{/if}}
_Notification from PRD Crowdsourcing System_` _Notification from PRD Crowdsourcing System_`,
}, },
feedback_submitted: { feedback_submitted: {
@ -46,7 +46,7 @@ _Notification from PRD Crowdsourcing System_`
[View Feedback #{{feedback_issue}}]({{feedback_url}}) [View Feedback #{{feedback_issue}}]({{feedback_url}})
_Notification from PRD Crowdsourcing System_` _Notification from PRD Crowdsourcing System_`,
}, },
synthesis_complete: { synthesis_complete: {
@ -67,7 +67,7 @@ _Notification from PRD Crowdsourcing System_`
[View Updated Document]({{document_url}}) [View Updated Document]({{document_url}})
_Notification from PRD Crowdsourcing System_` _Notification from PRD Crowdsourcing System_`,
}, },
signoff_requested: { signoff_requested: {
@ -92,7 +92,7 @@ Please review and provide your sign-off decision by {{deadline}}.
[View Document]({{document_url}}) [View Document]({{document_url}})
_Notification from PRD Crowdsourcing System_` _Notification from PRD Crowdsourcing System_`,
}, },
signoff_received: { signoff_received: {
@ -112,7 +112,7 @@ _Notification from PRD Crowdsourcing System_`
[View Review Issue #{{review_issue}}]({{review_url}}) [View Review Issue #{{review_issue}}]({{review_url}})
_Notification from PRD Crowdsourcing System_` _Notification from PRD Crowdsourcing System_`,
}, },
document_approved: { document_approved: {
@ -130,7 +130,7 @@ All required sign-offs have been received. This document is now approved and rea
[View Approved Document]({{document_url}}) [View Approved Document]({{document_url}})
_Notification from PRD Crowdsourcing System_` _Notification from PRD Crowdsourcing System_`,
}, },
document_blocked: { document_blocked: {
@ -152,7 +152,7 @@ This blocking concern must be resolved before the document can be approved.
[View Blocking Issue #{{feedback_issue}}]({{feedback_url}}) [View Blocking Issue #{{feedback_issue}}]({{feedback_url}})
{{/if}} {{/if}}
_Notification from PRD Crowdsourcing System_` _Notification from PRD Crowdsourcing System_`,
}, },
reminder: { reminder: {
@ -171,7 +171,7 @@ Please complete your {{action_needed}} by {{deadline}}.
[View Document]({{document_url}}) [View Document]({{document_url}})
_Notification from PRD Crowdsourcing System_` _Notification from PRD Crowdsourcing System_`,
}, },
deadline_extended: { deadline_extended: {
@ -190,8 +190,8 @@ _Notification from PRD Crowdsourcing System_`
[View Document]({{document_url}}) [View Document]({{document_url}})
_Notification from PRD Crowdsourcing System_` _Notification from PRD Crowdsourcing System_`,
} },
}; };
class GitHubNotifier { class GitHubNotifier {
@ -229,11 +229,7 @@ class GitHubNotifier {
return await this._postComment(options.issueNumber, message); return await this._postComment(options.issueNumber, message);
} else if (options.createIssue) { } else if (options.createIssue) {
// Create a new issue // Create a new issue
return await this._createIssue( return await this._createIssue(this._renderTemplate(template.subject, data), message, options.labels || []);
this._renderTemplate(template.subject, data),
message,
options.labels || []
);
} else if (data.review_issue) { } else if (data.review_issue) {
// Default to review issue if available // Default to review issue if available
return await this._postComment(data.review_issue, message); return await this._postComment(data.review_issue, message);
@ -244,7 +240,7 @@ class GitHubNotifier {
success: true, success: true,
channel: 'github', channel: 'github',
message, message,
note: 'No target issue specified, message returned for manual handling' note: 'No target issue specified, message returned for manual handling',
}; };
} }
@ -258,7 +254,7 @@ class GitHubNotifier {
async sendReminder(issueNumber, users, data) { async sendReminder(issueNumber, users, data) {
const reminderData = { const reminderData = {
...data, ...data,
mentions: users.map(u => `@${u}`).join(' ') mentions: users.map((u) => `@${u}`).join(' '),
}; };
return await this.send('reminder', reminderData, { issueNumber }); return await this.send('reminder', reminderData, { issueNumber });
@ -272,7 +268,7 @@ class GitHubNotifier {
* @returns {Object} Notification result * @returns {Object} Notification result
*/ */
async notifyStakeholders(users, message, issueNumber) { async notifyStakeholders(users, message, issueNumber) {
const mentions = users.map(u => `@${u}`).join(' '); const mentions = users.map((u) => `@${u}`).join(' ');
const fullMessage = `${mentions}\n\n${message}`; const fullMessage = `${mentions}\n\n${message}`;
return await this._postComment(issueNumber, fullMessage); return await this._postComment(issueNumber, fullMessage);
@ -288,7 +284,7 @@ class GitHubNotifier {
owner: this.owner, owner: this.owner,
repo: this.repo, repo: this.repo,
issue_number: issueNumber, issue_number: issueNumber,
body body,
}); });
return { return {
@ -296,13 +292,13 @@ class GitHubNotifier {
channel: 'github', channel: 'github',
type: 'comment', type: 'comment',
issueNumber, issueNumber,
commentId: result.id commentId: result.id,
}; };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
channel: 'github', channel: 'github',
error: error.message error: error.message,
}; };
} }
} }
@ -318,20 +314,20 @@ class GitHubNotifier {
repo: this.repo, repo: this.repo,
title, title,
body, body,
labels labels,
}); });
return { return {
success: true, success: true,
channel: 'github', channel: 'github',
type: 'issue', type: 'issue',
issueNumber: result.number issueNumber: result.number,
}; };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
channel: 'github', channel: 'github',
error: error.message error: error.message,
}; };
} }
} }
@ -358,18 +354,20 @@ class GitHubNotifier {
result = result.replace(/\{\{#each\s+(\w+)\}\}([\s\S]*?)\{\{\/each\}\}/g, (match, key, content) => { result = result.replace(/\{\{#each\s+(\w+)\}\}([\s\S]*?)\{\{\/each\}\}/g, (match, key, content) => {
const arr = data[key]; const arr = data[key];
if (!Array.isArray(arr)) return ''; if (!Array.isArray(arr)) return '';
return arr.map((item, index) => { return arr
let itemContent = content; .map((item, index) => {
if (typeof item === 'object') { let itemContent = content;
Object.entries(item).forEach(([k, v]) => { if (typeof item === 'object') {
itemContent = itemContent.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), String(v)); Object.entries(item).forEach(([k, v]) => {
}); itemContent = itemContent.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), String(v));
} else { });
itemContent = itemContent.replace(/\{\{this\}\}/g, String(item)); } else {
} itemContent = itemContent.replace(/\{\{this\}\}/g, String(item));
itemContent = itemContent.replace(/\{\{@index\}\}/g, String(index)); }
return itemContent; itemContent = itemContent.replace(/\{\{@index\}\}/g, String(index));
}).join(''); return itemContent;
})
.join('');
}); });
return result; return result;
@ -378,5 +376,5 @@ class GitHubNotifier {
module.exports = { module.exports = {
GitHubNotifier, GitHubNotifier,
NOTIFICATION_TEMPLATES NOTIFICATION_TEMPLATES,
}; };

View File

@ -53,5 +53,5 @@ module.exports = {
// Templates (for customization) // Templates (for customization)
GITHUB_TEMPLATES, GITHUB_TEMPLATES,
SLACK_TEMPLATES, SLACK_TEMPLATES,
EMAIL_TEMPLATES EMAIL_TEMPLATES,
}; };

View File

@ -16,48 +16,48 @@ const NOTIFICATION_EVENTS = {
feedback_round_opened: { feedback_round_opened: {
description: 'PRD/Epic is open for feedback', description: 'PRD/Epic is open for feedback',
defaultChannels: ['github', 'slack', 'email'], defaultChannels: ['github', 'slack', 'email'],
priority: 'normal' priority: 'normal',
}, },
feedback_submitted: { feedback_submitted: {
description: 'New feedback submitted', description: 'New feedback submitted',
defaultChannels: ['github', 'slack'], defaultChannels: ['github', 'slack'],
priority: 'normal' priority: 'normal',
}, },
synthesis_complete: { synthesis_complete: {
description: 'Feedback synthesis completed', description: 'Feedback synthesis completed',
defaultChannels: ['github', 'slack'], defaultChannels: ['github', 'slack'],
priority: 'normal' priority: 'normal',
}, },
signoff_requested: { signoff_requested: {
description: 'Sign-off requested from stakeholders', description: 'Sign-off requested from stakeholders',
defaultChannels: ['github', 'slack', 'email'], defaultChannels: ['github', 'slack', 'email'],
priority: 'high' priority: 'high',
}, },
signoff_received: { signoff_received: {
description: 'Sign-off decision received', description: 'Sign-off decision received',
defaultChannels: ['github', 'slack'], defaultChannels: ['github', 'slack'],
priority: 'normal' priority: 'normal',
}, },
document_approved: { document_approved: {
description: 'Document fully approved', description: 'Document fully approved',
defaultChannels: ['github', 'slack', 'email'], defaultChannels: ['github', 'slack', 'email'],
priority: 'high' priority: 'high',
}, },
document_blocked: { document_blocked: {
description: 'Document blocked by stakeholder', description: 'Document blocked by stakeholder',
defaultChannels: ['github', 'slack', 'email'], defaultChannels: ['github', 'slack', 'email'],
priority: 'urgent' priority: 'urgent',
}, },
reminder: { reminder: {
description: 'Reminder for pending action', description: 'Reminder for pending action',
defaultChannels: ['github', 'slack', 'email'], defaultChannels: ['github', 'slack', 'email'],
priority: 'normal' priority: 'normal',
}, },
deadline_extended: { deadline_extended: {
description: 'Deadline has been extended', description: 'Deadline has been extended',
defaultChannels: ['github'], defaultChannels: ['github'],
priority: 'low' priority: 'low',
} },
}; };
/** /**
@ -67,23 +67,23 @@ const PRIORITY_BEHAVIOR = {
urgent: { urgent: {
retryOnFailure: true, retryOnFailure: true,
maxRetries: 3, maxRetries: 3,
allChannels: true // Send on all available channels allChannels: true, // Send on all available channels
}, },
high: { high: {
retryOnFailure: true, retryOnFailure: true,
maxRetries: 2, maxRetries: 2,
allChannels: false allChannels: false,
}, },
normal: { normal: {
retryOnFailure: false, retryOnFailure: false,
maxRetries: 1, maxRetries: 1,
allChannels: false allChannels: false,
}, },
low: { low: {
retryOnFailure: false, retryOnFailure: false,
maxRetries: 1, maxRetries: 1,
allChannels: false allChannels: false,
} },
}; };
class NotificationService { class NotificationService {
@ -97,7 +97,7 @@ class NotificationService {
constructor(config) { constructor(config) {
// GitHub is always required and enabled // GitHub is always required and enabled
this.channels = { this.channels = {
github: new GitHubNotifier(config.github) github: new GitHubNotifier(config.github),
}; };
// Optional channels // Optional channels
@ -146,7 +146,7 @@ class NotificationService {
let channels = options.channels || eventConfig.defaultChannels; let channels = options.channels || eventConfig.defaultChannels;
// Filter to only available channels // Filter to only available channels
channels = channels.filter(ch => this.isChannelAvailable(ch)); channels = channels.filter((ch) => this.isChannelAvailable(ch));
// For urgent priority, use all available channels // For urgent priority, use all available channels
const priority = options.priority || eventConfig.priority; const priority = options.priority || eventConfig.priority;
@ -165,17 +165,17 @@ class NotificationService {
const results = await Promise.all( const results = await Promise.all(
channels.map(async (channel) => { channels.map(async (channel) => {
return await this._sendToChannel(channel, eventType, data, options, priorityBehavior); return await this._sendToChannel(channel, eventType, data, options, priorityBehavior);
}) }),
); );
// Aggregate results // Aggregate results
const aggregated = { const aggregated = {
success: results.some(r => r.success), success: results.some((r) => r.success),
eventType, eventType,
results: results.reduce((acc, r) => { results: results.reduce((acc, r) => {
acc[r.channel] = r; acc[r.channel] = r;
return acc; return acc;
}, {}) }, {}),
}; };
return aggregated; return aggregated;
@ -193,9 +193,9 @@ class NotificationService {
const data = { const data = {
document_type: documentType, document_type: documentType,
document_key: documentKey, document_key: documentKey,
mentions: users.map(u => `@${u}`).join(' '), mentions: users.map((u) => `@${u}`).join(' '),
users, users,
...reminderData ...reminderData,
}; };
return await this.notify('reminder', data); return await this.notify('reminder', data);
@ -216,10 +216,10 @@ class NotificationService {
version: document.version, version: document.version,
deadline, deadline,
stakeholder_count: stakeholders.length, stakeholder_count: stakeholders.length,
mentions: stakeholders.map(s => `@${s}`).join(' '), mentions: stakeholders.map((s) => `@${s}`).join(' '),
users: stakeholders, users: stakeholders,
document_url: document.url, document_url: document.url,
review_issue: document.reviewIssue review_issue: document.reviewIssue,
}; };
return await this.notify('feedback_round_opened', data); return await this.notify('feedback_round_opened', data);
@ -241,12 +241,12 @@ class NotificationService {
summary: feedback.summary || feedback.title, summary: feedback.summary || feedback.title,
feedback_issue: feedback.issueNumber, feedback_issue: feedback.issueNumber,
feedback_url: feedback.url, feedback_url: feedback.url,
review_issue: document.reviewIssue review_issue: document.reviewIssue,
}; };
// Only notify PO (not all stakeholders) // Only notify PO (not all stakeholders)
return await this.notify('feedback_submitted', data, { return await this.notify('feedback_submitted', data, {
notifyOnly: [document.owner] notifyOnly: [document.owner],
}); });
} }
@ -266,7 +266,7 @@ class NotificationService {
conflicts_resolved: synthesis.conflictsResolved, conflicts_resolved: synthesis.conflictsResolved,
summary: synthesis.summary, summary: synthesis.summary,
document_url: document.url, document_url: document.url,
review_issue: document.reviewIssue review_issue: document.reviewIssue,
}; };
return await this.notify('synthesis_complete', data); return await this.notify('synthesis_complete', data);
@ -288,11 +288,11 @@ class NotificationService {
version: document.version, version: document.version,
deadline, deadline,
approvals_needed: config.minimum_approvals || Math.ceil(stakeholders.length * 0.5), approvals_needed: config.minimum_approvals || Math.ceil(stakeholders.length * 0.5),
mentions: stakeholders.map(s => `@${s}`).join(' '), mentions: stakeholders.map((s) => `@${s}`).join(' '),
users: stakeholders, users: stakeholders,
document_url: document.url, document_url: document.url,
signoff_url: document.signoffUrl, signoff_url: document.signoffUrl,
review_issue: document.reviewIssue review_issue: document.reviewIssue,
}; };
return await this.notify('signoff_requested', data); return await this.notify('signoff_requested', data);
@ -309,7 +309,7 @@ class NotificationService {
const emojis = { const emojis = {
approved: '✅', approved: '✅',
'approved-with-note': '✅📝', 'approved-with-note': '✅📝',
blocked: '🚫' blocked: '🚫',
}; };
const data = { const data = {
@ -322,7 +322,7 @@ class NotificationService {
progress_current: progress.current, progress_current: progress.current,
progress_total: progress.total, progress_total: progress.total,
review_issue: document.reviewIssue, review_issue: document.reviewIssue,
review_url: document.reviewUrl review_url: document.reviewUrl,
}; };
return await this.notify('signoff_received', data); return await this.notify('signoff_received', data);
@ -343,7 +343,7 @@ class NotificationService {
version: document.version, version: document.version,
approval_count: approvalCount, approval_count: approvalCount,
stakeholder_count: stakeholderCount, stakeholder_count: stakeholderCount,
document_url: document.url document_url: document.url,
}; };
return await this.notify('document_approved', data); return await this.notify('document_approved', data);
@ -362,7 +362,7 @@ class NotificationService {
user: block.user, user: block.user,
reason: block.reason, reason: block.reason,
feedback_issue: block.feedbackIssue, feedback_issue: block.feedbackIssue,
feedback_url: block.feedbackUrl feedback_url: block.feedbackUrl,
}; };
return await this.notify('document_blocked', data); return await this.notify('document_blocked', data);
@ -378,7 +378,7 @@ class NotificationService {
return { return {
success: false, success: false,
channel, channel,
error: 'Channel not available' error: 'Channel not available',
}; };
} }
@ -402,7 +402,7 @@ class NotificationService {
// Wait before retry (exponential backoff) // Wait before retry (exponential backoff)
if (attempt < maxRetries) { if (attempt < maxRetries) {
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt - 1))); await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt - 1)));
} }
} }
@ -410,7 +410,7 @@ class NotificationService {
success: false, success: false,
channel, channel,
error: lastError, error: lastError,
attempts: maxRetries attempts: maxRetries,
}; };
} }
} }
@ -418,5 +418,5 @@ class NotificationService {
module.exports = { module.exports = {
NotificationService, NotificationService,
NOTIFICATION_EVENTS, NOTIFICATION_EVENTS,
PRIORITY_BEHAVIOR PRIORITY_BEHAVIOR,
}; };

View File

@ -12,7 +12,7 @@ const SLACK_TEMPLATES = {
blocks: (data) => [ blocks: (data) => [
{ {
type: 'header', type: 'header',
text: { type: 'plain_text', text: '📣 Feedback Round Open', emoji: true } text: { type: 'plain_text', text: '📣 Feedback Round Open', emoji: true },
}, },
{ {
type: 'section', type: 'section',
@ -20,12 +20,12 @@ const SLACK_TEMPLATES = {
{ type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` }, { type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` },
{ type: 'mrkdwn', text: `*Version:*\nv${data.version}` }, { type: 'mrkdwn', text: `*Version:*\nv${data.version}` },
{ type: 'mrkdwn', text: `*Deadline:*\n${data.deadline}` }, { type: 'mrkdwn', text: `*Deadline:*\n${data.deadline}` },
{ type: 'mrkdwn', text: `*Stakeholders:*\n${data.stakeholder_count}` } { type: 'mrkdwn', text: `*Stakeholders:*\n${data.stakeholder_count}` },
] ],
}, },
{ {
type: 'section', type: 'section',
text: { type: 'mrkdwn', text: `Please review and provide feedback by *${data.deadline}*.` } text: { type: 'mrkdwn', text: `Please review and provide feedback by *${data.deadline}*.` },
}, },
{ {
type: 'actions', type: 'actions',
@ -34,11 +34,11 @@ const SLACK_TEMPLATES = {
type: 'button', type: 'button',
text: { type: 'plain_text', text: 'View Document', emoji: true }, text: { type: 'plain_text', text: 'View Document', emoji: true },
url: data.document_url, url: data.document_url,
style: 'primary' style: 'primary',
} },
] ],
} },
] ],
}, },
feedback_submitted: { feedback_submitted: {
@ -47,7 +47,7 @@ const SLACK_TEMPLATES = {
blocks: (data) => [ blocks: (data) => [
{ {
type: 'header', type: 'header',
text: { type: 'plain_text', text: '💬 New Feedback', emoji: true } text: { type: 'plain_text', text: '💬 New Feedback', emoji: true },
}, },
{ {
type: 'section', type: 'section',
@ -55,12 +55,12 @@ const SLACK_TEMPLATES = {
{ type: 'mrkdwn', text: `*From:*\n${data.user}` }, { type: 'mrkdwn', text: `*From:*\n${data.user}` },
{ type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` }, { type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` },
{ type: 'mrkdwn', text: `*Type:*\n${data.feedback_type}` }, { type: 'mrkdwn', text: `*Type:*\n${data.feedback_type}` },
{ type: 'mrkdwn', text: `*Section:*\n${data.section}` } { type: 'mrkdwn', text: `*Section:*\n${data.section}` },
] ],
}, },
{ {
type: 'section', type: 'section',
text: { type: 'mrkdwn', text: `> ${data.summary.substring(0, 200)}${data.summary.length > 200 ? '...' : ''}` } text: { type: 'mrkdwn', text: `> ${data.summary.substring(0, 200)}${data.summary.length > 200 ? '...' : ''}` },
}, },
{ {
type: 'actions', type: 'actions',
@ -68,11 +68,11 @@ const SLACK_TEMPLATES = {
{ {
type: 'button', type: 'button',
text: { type: 'plain_text', text: 'View Feedback', emoji: true }, text: { type: 'plain_text', text: 'View Feedback', emoji: true },
url: data.feedback_url url: data.feedback_url,
} },
] ],
} },
] ],
}, },
synthesis_complete: { synthesis_complete: {
@ -81,7 +81,7 @@ const SLACK_TEMPLATES = {
blocks: (data) => [ blocks: (data) => [
{ {
type: 'header', type: 'header',
text: { type: 'plain_text', text: '🔄 Synthesis Complete', emoji: true } text: { type: 'plain_text', text: '🔄 Synthesis Complete', emoji: true },
}, },
{ {
type: 'section', type: 'section',
@ -89,12 +89,12 @@ const SLACK_TEMPLATES = {
{ type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` }, { 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: `*Version:*\nv${data.old_version} → v${data.new_version}` },
{ type: 'mrkdwn', text: `*Feedback Processed:*\n${data.feedback_count} items` }, { type: 'mrkdwn', text: `*Feedback Processed:*\n${data.feedback_count} items` },
{ type: 'mrkdwn', text: `*Conflicts Resolved:*\n${data.conflicts_resolved || 0}` } { type: 'mrkdwn', text: `*Conflicts Resolved:*\n${data.conflicts_resolved || 0}` },
] ],
}, },
{ {
type: 'section', type: 'section',
text: { type: 'mrkdwn', text: data.summary.substring(0, 500) } text: { type: 'mrkdwn', text: data.summary.substring(0, 500) },
}, },
{ {
type: 'actions', type: 'actions',
@ -103,11 +103,11 @@ const SLACK_TEMPLATES = {
type: 'button', type: 'button',
text: { type: 'plain_text', text: 'View Document', emoji: true }, text: { type: 'plain_text', text: 'View Document', emoji: true },
url: data.document_url, url: data.document_url,
style: 'primary' style: 'primary',
} },
] ],
} },
] ],
}, },
signoff_requested: { signoff_requested: {
@ -116,7 +116,7 @@ const SLACK_TEMPLATES = {
blocks: (data) => [ blocks: (data) => [
{ {
type: 'header', type: 'header',
text: { type: 'plain_text', text: '✍️ Sign-off Requested', emoji: true } text: { type: 'plain_text', text: '✍️ Sign-off Requested', emoji: true },
}, },
{ {
type: 'section', type: 'section',
@ -124,12 +124,12 @@ const SLACK_TEMPLATES = {
{ type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` }, { type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` },
{ type: 'mrkdwn', text: `*Version:*\nv${data.version}` }, { type: 'mrkdwn', text: `*Version:*\nv${data.version}` },
{ type: 'mrkdwn', text: `*Deadline:*\n${data.deadline}` }, { type: 'mrkdwn', text: `*Deadline:*\n${data.deadline}` },
{ type: 'mrkdwn', text: `*Approvals Needed:*\n${data.approvals_needed}` } { type: 'mrkdwn', text: `*Approvals Needed:*\n${data.approvals_needed}` },
] ],
}, },
{ {
type: 'section', type: 'section',
text: { type: 'mrkdwn', text: 'Please review and provide your sign-off decision.' } text: { type: 'mrkdwn', text: 'Please review and provide your sign-off decision.' },
}, },
{ {
type: 'actions', type: 'actions',
@ -138,25 +138,25 @@ const SLACK_TEMPLATES = {
type: 'button', type: 'button',
text: { type: 'plain_text', text: 'View Document', emoji: true }, text: { type: 'plain_text', text: 'View Document', emoji: true },
url: data.document_url, url: data.document_url,
style: 'primary' style: 'primary',
}, },
{ {
type: 'button', type: 'button',
text: { type: 'plain_text', text: 'Sign Off', emoji: true }, text: { type: 'plain_text', text: 'Sign Off', emoji: true },
url: data.signoff_url url: data.signoff_url,
} },
] ],
} },
] ],
}, },
signoff_received: { signoff_received: {
color: (data) => data.decision === 'blocked' ? '#dc3545' : '#28a745', color: (data) => (data.decision === 'blocked' ? '#dc3545' : '#28a745'),
title: (data) => `${data.emoji} Sign-off from ${data.user}`, title: (data) => `${data.emoji} Sign-off from ${data.user}`,
blocks: (data) => [ blocks: (data) => [
{ {
type: 'header', type: 'header',
text: { type: 'plain_text', text: `${data.emoji} Sign-off Received`, emoji: true } text: { type: 'plain_text', text: `${data.emoji} Sign-off Received`, emoji: true },
}, },
{ {
type: 'section', type: 'section',
@ -164,24 +164,28 @@ const SLACK_TEMPLATES = {
{ type: 'mrkdwn', text: `*From:*\n${data.user}` }, { type: 'mrkdwn', text: `*From:*\n${data.user}` },
{ type: 'mrkdwn', text: `*Decision:*\n${data.decision}` }, { type: 'mrkdwn', text: `*Decision:*\n${data.decision}` },
{ type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` }, { type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` },
{ type: 'mrkdwn', text: `*Progress:*\n${data.progress_current}/${data.progress_total}` } { type: 'mrkdwn', text: `*Progress:*\n${data.progress_current}/${data.progress_total}` },
] ],
}, },
...(data.note ? [{ ...(data.note
type: 'section', ? [
text: { type: 'mrkdwn', text: `*Note:* ${data.note}` } {
}] : []), type: 'section',
text: { type: 'mrkdwn', text: `*Note:* ${data.note}` },
},
]
: []),
{ {
type: 'actions', type: 'actions',
elements: [ elements: [
{ {
type: 'button', type: 'button',
text: { type: 'plain_text', text: 'View Progress', emoji: true }, text: { type: 'plain_text', text: 'View Progress', emoji: true },
url: data.review_url url: data.review_url,
} },
] ],
} },
] ],
}, },
document_approved: { document_approved: {
@ -190,7 +194,7 @@ const SLACK_TEMPLATES = {
blocks: (data) => [ blocks: (data) => [
{ {
type: 'header', type: 'header',
text: { type: 'plain_text', text: '✅ Document Approved!', emoji: true } text: { type: 'plain_text', text: '✅ Document Approved!', emoji: true },
}, },
{ {
type: 'section', type: 'section',
@ -198,12 +202,12 @@ const SLACK_TEMPLATES = {
{ type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` }, { type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` },
{ type: 'mrkdwn', text: `*Title:*\n${data.title}` }, { type: 'mrkdwn', text: `*Title:*\n${data.title}` },
{ type: 'mrkdwn', text: `*Version:*\nv${data.version}` }, { type: 'mrkdwn', text: `*Version:*\nv${data.version}` },
{ type: 'mrkdwn', text: `*Approvals:*\n${data.approval_count}/${data.stakeholder_count}` } { type: 'mrkdwn', text: `*Approvals:*\n${data.approval_count}/${data.stakeholder_count}` },
] ],
}, },
{ {
type: 'section', type: 'section',
text: { type: 'mrkdwn', text: '🎉 All required sign-offs received. Ready for implementation!' } text: { type: 'mrkdwn', text: '🎉 All required sign-offs received. Ready for implementation!' },
}, },
{ {
type: 'actions', type: 'actions',
@ -212,11 +216,11 @@ const SLACK_TEMPLATES = {
type: 'button', type: 'button',
text: { type: 'plain_text', text: 'View Document', emoji: true }, text: { type: 'plain_text', text: 'View Document', emoji: true },
url: data.document_url, url: data.document_url,
style: 'primary' style: 'primary',
} },
] ],
} },
] ],
}, },
document_blocked: { document_blocked: {
@ -225,35 +229,39 @@ const SLACK_TEMPLATES = {
blocks: (data) => [ blocks: (data) => [
{ {
type: 'header', type: 'header',
text: { type: 'plain_text', text: '🚫 Document Blocked', emoji: true } text: { type: 'plain_text', text: '🚫 Document Blocked', emoji: true },
}, },
{ {
type: 'section', type: 'section',
fields: [ fields: [
{ type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` }, { type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` },
{ type: 'mrkdwn', text: `*Blocked by:*\n${data.user}` } { type: 'mrkdwn', text: `*Blocked by:*\n${data.user}` },
] ],
}, },
{ {
type: 'section', type: 'section',
text: { type: 'mrkdwn', text: `*Reason:*\n${data.reason}` } text: { type: 'mrkdwn', text: `*Reason:*\n${data.reason}` },
}, },
{ {
type: 'section', type: 'section',
text: { type: 'mrkdwn', text: '⚠️ This blocking concern must be resolved before approval.' } text: { type: 'mrkdwn', text: '⚠️ This blocking concern must be resolved before approval.' },
}, },
...(data.feedback_url ? [{ ...(data.feedback_url
type: 'actions', ? [
elements: [ {
{ type: 'actions',
type: 'button', elements: [
text: { type: 'plain_text', text: 'View Issue', emoji: true }, {
url: data.feedback_url, type: 'button',
style: 'danger' text: { type: 'plain_text', text: 'View Issue', emoji: true },
} url: data.feedback_url,
] style: 'danger',
}] : []) },
] ],
},
]
: []),
],
}, },
reminder: { reminder: {
@ -262,7 +270,7 @@ const SLACK_TEMPLATES = {
blocks: (data) => [ blocks: (data) => [
{ {
type: 'header', type: 'header',
text: { type: 'plain_text', text: '⏰ Reminder: Action Needed', emoji: true } text: { type: 'plain_text', text: '⏰ Reminder: Action Needed', emoji: true },
}, },
{ {
type: 'section', type: 'section',
@ -270,12 +278,12 @@ const SLACK_TEMPLATES = {
{ type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` }, { type: 'mrkdwn', text: `*Document:*\n${data.document_type}:${data.document_key}` },
{ type: 'mrkdwn', text: `*Action:*\n${data.action_needed}` }, { type: 'mrkdwn', text: `*Action:*\n${data.action_needed}` },
{ type: 'mrkdwn', text: `*Deadline:*\n${data.deadline}` }, { type: 'mrkdwn', text: `*Deadline:*\n${data.deadline}` },
{ type: 'mrkdwn', text: `*Time Remaining:*\n${data.time_remaining}` } { type: 'mrkdwn', text: `*Time Remaining:*\n${data.time_remaining}` },
] ],
}, },
{ {
type: 'section', type: 'section',
text: { type: 'mrkdwn', text: `Pending: ${data.pending_users?.join(', ') || 'Unknown'}` } text: { type: 'mrkdwn', text: `Pending: ${data.pending_users?.join(', ') || 'Unknown'}` },
}, },
{ {
type: 'actions', type: 'actions',
@ -284,12 +292,12 @@ const SLACK_TEMPLATES = {
type: 'button', type: 'button',
text: { type: 'plain_text', text: 'View Document', emoji: true }, text: { type: 'plain_text', text: 'View Document', emoji: true },
url: data.document_url, url: data.document_url,
style: 'primary' style: 'primary',
} },
] ],
} },
] ],
} },
}; };
class SlackNotifier { class SlackNotifier {
@ -329,7 +337,7 @@ class SlackNotifier {
return { return {
success: false, success: false,
channel: 'slack', channel: 'slack',
error: 'Slack notifications not enabled' error: 'Slack notifications not enabled',
}; };
} }
@ -338,7 +346,7 @@ class SlackNotifier {
return { return {
success: false, success: false,
channel: 'slack', channel: 'slack',
error: `Unknown notification event type: ${eventType}` error: `Unknown notification event type: ${eventType}`,
}; };
} }
@ -349,13 +357,13 @@ class SlackNotifier {
return { return {
success: true, success: true,
channel: 'slack', channel: 'slack',
eventType eventType,
}; };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
channel: 'slack', channel: 'slack',
error: error.message error: error.message,
}; };
} }
} }
@ -371,7 +379,7 @@ class SlackNotifier {
return { return {
success: false, success: false,
channel: 'slack', channel: 'slack',
error: 'Slack notifications not enabled' error: 'Slack notifications not enabled',
}; };
} }
@ -380,20 +388,20 @@ class SlackNotifier {
channel: options.channel || this.channel, channel: options.channel || this.channel,
username: this.username, username: this.username,
icon_emoji: this.iconEmoji, icon_emoji: this.iconEmoji,
...options ...options,
}; };
try { try {
await this._sendWebhook(payload); await this._sendWebhook(payload);
return { return {
success: true, success: true,
channel: 'slack' channel: 'slack',
}; };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
channel: 'slack', channel: 'slack',
error: error.message error: error.message,
}; };
} }
} }
@ -403,13 +411,9 @@ class SlackNotifier {
* @private * @private
*/ */
_buildPayload(template, data, options) { _buildPayload(template, data, options) {
const color = typeof template.color === 'function' const color = typeof template.color === 'function' ? template.color(data) : template.color;
? template.color(data)
: template.color;
const title = typeof template.title === 'function' const title = typeof template.title === 'function' ? template.title(data) : template.title;
? template.title(data)
: template.title;
const blocks = template.blocks(data); const blocks = template.blocks(data);
@ -422,9 +426,9 @@ class SlackNotifier {
{ {
color, color,
fallback: title, fallback: title,
blocks blocks,
} },
] ],
}; };
} }
@ -438,9 +442,9 @@ class SlackNotifier {
const response = await fetch(this.webhookUrl, { const response = await fetch(this.webhookUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json',
}, },
body: JSON.stringify(payload) body: JSON.stringify(payload),
}); });
if (!response.ok) { if (!response.ok) {
@ -453,5 +457,5 @@ class SlackNotifier {
module.exports = { module.exports = {
SlackNotifier, SlackNotifier,
SLACK_TEMPLATES SLACK_TEMPLATES,
}; };

View File

@ -13,9 +13,9 @@ github:
repo: "{config_source}:github_repo" repo: "{config_source}:github_repo"
# Parameters # Parameters
source_prd: "" # PRD key to create epic from source_prd: "" # PRD key to create epic from
epic_key: "" # Optional: override generated epic key epic_key: "" # Optional: override generated epic key
stakeholders: [] # Override default stakeholders stakeholders: [] # Override default stakeholders
installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/create-epic-draft" installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/create-epic-draft"
instructions: "{installed_path}/instructions.md" instructions: "{installed_path}/instructions.md"

View File

@ -13,7 +13,7 @@ github:
repo: "{config_source}:github_repo" repo: "{config_source}:github_repo"
# PRD creation options # PRD creation options
import_from: "" # 'scratch', 'existing-prd', 'product-brief' import_from: "" # 'scratch', 'existing-prd', 'product-brief'
installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/create-prd-draft" installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/create-prd-draft"
instructions: "{installed_path}/instructions.md" instructions: "{installed_path}/instructions.md"

View File

@ -13,8 +13,8 @@ github:
repo: "{config_source}:github_repo" repo: "{config_source}:github_repo"
# Optional filter # Optional filter
epic_key: "" # Empty for all epics, or specific key for detail view epic_key: "" # Empty for all epics, or specific key for detail view
source_prd: "" # Filter by source PRD source_prd: "" # Filter by source PRD
installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/epic-dashboard" installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/epic-dashboard"
instructions: "{installed_path}/instructions.md" instructions: "{installed_path}/instructions.md"

View File

@ -14,7 +14,7 @@ github:
# Parameters # Parameters
epic_key: "" epic_key: ""
feedback_days: 3 # Default deadline in days feedback_days: 3 # Default deadline in days
installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/open-epic-feedback" installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/open-epic-feedback"
instructions: "{installed_path}/instructions.md" instructions: "{installed_path}/instructions.md"

View File

@ -13,8 +13,8 @@ github:
repo: "{config_source}:github_repo" repo: "{config_source}:github_repo"
# Parameters (can be passed in or prompted) # Parameters (can be passed in or prompted)
document_key: "" # e.g., "user-auth" for PRD document_key: "" # e.g., "user-auth" for PRD
document_type: "prd" # "prd" or "epic" document_type: "prd" # "prd" or "epic"
installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/open-feedback-round" installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/open-feedback-round"
instructions: "{installed_path}/instructions.md" instructions: "{installed_path}/instructions.md"

View File

@ -13,7 +13,7 @@ github:
repo: "{config_source}:github_repo" repo: "{config_source}:github_repo"
# Optional filter # Optional filter
prd_key: "" # Empty for all PRDs, or specific key for detail view prd_key: "" # Empty for all PRDs, or specific key for detail view
installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/prd-dashboard" installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/prd-dashboard"
instructions: "{installed_path}/instructions.md" instructions: "{installed_path}/instructions.md"

View File

@ -13,8 +13,8 @@ github:
repo: "{config_source}:github_repo" repo: "{config_source}:github_repo"
# Parameters (can be passed in or prompted) # Parameters (can be passed in or prompted)
document_key: "" # e.g., "user-auth" for PRD, "2" for Epic document_key: "" # e.g., "user-auth" for PRD, "2" for Epic
document_type: "" # "prd" or "epic" - will auto-detect if empty document_type: "" # "prd" or "epic" - will auto-detect if empty
installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/submit-feedback" installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/submit-feedback"
instructions: "{installed_path}/instructions.md" instructions: "{installed_path}/instructions.md"

View File

@ -14,7 +14,7 @@ github:
# Parameters # Parameters
document_key: "" document_key: ""
document_type: "prd" # "prd" or "epic" document_type: "prd" # "prd" or "epic"
installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/synthesize-feedback" installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/synthesize-feedback"
instructions: "{installed_path}/instructions.md" instructions: "{installed_path}/instructions.md"

View File

@ -13,7 +13,7 @@ github:
# Parameters # Parameters
document_key: "" document_key: ""
document_type: "" # "prd" or "epic" document_type: "" # "prd" or "epic"
installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/view-feedback" installed_path: "{project-root}/_bmad/bmm/workflows/1-requirements/crowdsource/view-feedback"
instructions: "{installed_path}/instructions.md" instructions: "{installed_path}/instructions.md"

View File

@ -20,9 +20,9 @@ github_integration:
dir: "{output_folder}/cache" dir: "{output_folder}/cache"
staleness_minutes: "{config_source}:github_cache_staleness_minutes" staleness_minutes: "{config_source}:github_cache_staleness_minutes"
sync: sync:
create_pr: true # Create PR linking to GitHub Issue create_pr: true # Create PR linking to GitHub Issue
update_issue_status: true # Update issue to in-review update_issue_status: true # Update issue to in-review
add_completion_comment: true # Add implementation summary to issue 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"

View File

@ -17,7 +17,7 @@ epic_key: "" # e.g., "2" for epic 2, empty for all epics
# Display options # Display options
show_details: false # Show individual story details show_details: false # Show individual story details
show_burndown: true # Show epic burndown metrics show_burndown: true # Show epic burndown metrics
show_risks: true # Highlight at-risk stories show_risks: true # Highlight at-risk stories
installed_path: "{project-root}/_bmad/bmm/workflows/po/epic-dashboard" installed_path: "{project-root}/_bmad/bmm/workflows/po/epic-dashboard"
instructions: "{installed_path}/instructions.md" instructions: "{installed_path}/instructions.md"

View File

@ -19,9 +19,7 @@ import path from 'path';
import os from 'os'; import os from 'os';
// Import the CacheManager (CommonJS module) // Import the CacheManager (CommonJS module)
const { CacheManager, DOCUMENT_TYPES, CACHE_META_FILENAME } = await import( const { CacheManager, DOCUMENT_TYPES, CACHE_META_FILENAME } = await import('../../../src/modules/bmm/lib/cache/cache-manager.js');
'../../../src/modules/bmm/lib/cache/cache-manager.js'
);
describe('CacheManager PRD/Epic Extensions', () => { describe('CacheManager PRD/Epic Extensions', () => {
let cacheManager; let cacheManager;
@ -34,7 +32,7 @@ describe('CacheManager PRD/Epic Extensions', () => {
cacheManager = new CacheManager({ cacheManager = new CacheManager({
cacheDir: testCacheDir, cacheDir: testCacheDir,
stalenessThresholdMinutes: 5, stalenessThresholdMinutes: 5,
github: { owner: 'test-org', repo: 'test-repo' } github: { owner: 'test-org', repo: 'test-repo' },
}); });
}); });
@ -77,18 +75,14 @@ describe('CacheManager PRD/Epic Extensions', () => {
// Write v1 metadata directly // Write v1 metadata directly
const v1Meta = { const v1Meta = {
version: '1.0.0', version: '1.0.0',
stories: { 'story-1': { github_issue: 10 } } stories: { 'story-1': { github_issue: 10 } },
}; };
fs.writeFileSync( fs.writeFileSync(path.join(testCacheDir, CACHE_META_FILENAME), JSON.stringify(v1Meta), 'utf8');
path.join(testCacheDir, CACHE_META_FILENAME),
JSON.stringify(v1Meta),
'utf8'
);
// Create new manager to trigger migration // Create new manager to trigger migration
const manager = new CacheManager({ const manager = new CacheManager({
cacheDir: testCacheDir, cacheDir: testCacheDir,
github: {} github: {},
}); });
const meta = manager.loadMeta(); const meta = manager.loadMeta();
@ -105,17 +99,13 @@ describe('CacheManager PRD/Epic Extensions', () => {
version: '2.0.0', version: '2.0.0',
prds: { 'existing-prd': { status: 'approved' } }, prds: { 'existing-prd': { status: 'approved' } },
epics: { 'existing-epic': { status: 'draft' } }, epics: { 'existing-epic': { status: 'draft' } },
stories: {} stories: {},
}; };
fs.writeFileSync( fs.writeFileSync(path.join(testCacheDir, CACHE_META_FILENAME), JSON.stringify(v2Meta), 'utf8');
path.join(testCacheDir, CACHE_META_FILENAME),
JSON.stringify(v2Meta),
'utf8'
);
const manager = new CacheManager({ const manager = new CacheManager({
cacheDir: testCacheDir, cacheDir: testCacheDir,
github: {} github: {},
}); });
const meta = manager.loadMeta(); const meta = manager.loadMeta();
@ -143,7 +133,7 @@ describe('CacheManager PRD/Epic Extensions', () => {
version: 1, version: 1,
status: 'draft', status: 'draft',
stakeholders: ['@alice', '@bob'], stakeholders: ['@alice', '@bob'],
owner: '@sarah' owner: '@sarah',
}; };
const result = cacheManager.writePrd('user-auth', content, prdMeta); const result = cacheManager.writePrd('user-auth', content, prdMeta);
@ -169,7 +159,7 @@ describe('CacheManager PRD/Epic Extensions', () => {
review_issue: 100, review_issue: 100,
version: 2, version: 2,
status: 'feedback', status: 'feedback',
stakeholders: ['@alice'] stakeholders: ['@alice'],
}); });
// Write with partial metadata // Write with partial metadata
@ -193,7 +183,7 @@ describe('CacheManager PRD/Epic Extensions', () => {
const content = '# PRD: User Auth'; const content = '# PRD: User Auth';
cacheManager.writePrd('user-auth', content, { cacheManager.writePrd('user-auth', content, {
version: 1, version: 1,
status: 'draft' status: 'draft',
}); });
const result = cacheManager.readPrd('user-auth'); const result = cacheManager.readPrd('user-auth');
@ -275,8 +265,8 @@ describe('CacheManager PRD/Epic Extensions', () => {
const feedbackPrds = cacheManager.getPrdsByStatus('feedback'); const feedbackPrds = cacheManager.getPrdsByStatus('feedback');
expect(feedbackPrds).toHaveLength(2); expect(feedbackPrds).toHaveLength(2);
expect(feedbackPrds.map(p => p.prdKey)).toContain('user-auth'); expect(feedbackPrds.map((p) => p.prdKey)).toContain('user-auth');
expect(feedbackPrds.map(p => p.prdKey)).toContain('mobile'); expect(feedbackPrds.map((p) => p.prdKey)).toContain('mobile');
}); });
}); });
@ -284,15 +274,15 @@ describe('CacheManager PRD/Epic Extensions', () => {
it('should find PRDs needing feedback from user', () => { it('should find PRDs needing feedback from user', () => {
cacheManager.writePrd('user-auth', '# PRD 1', { cacheManager.writePrd('user-auth', '# PRD 1', {
status: 'feedback', status: 'feedback',
stakeholders: ['@alice', '@bob'] stakeholders: ['@alice', '@bob'],
}); });
cacheManager.writePrd('payments', '# PRD 2', { cacheManager.writePrd('payments', '# PRD 2', {
status: 'signoff', status: 'signoff',
stakeholders: ['@alice', '@charlie'] stakeholders: ['@alice', '@charlie'],
}); });
cacheManager.writePrd('mobile', '# PRD 3', { cacheManager.writePrd('mobile', '# PRD 3', {
status: 'feedback', status: 'feedback',
stakeholders: ['@charlie'] stakeholders: ['@charlie'],
}); });
const tasks = cacheManager.getPrdsNeedingAttention('alice'); const tasks = cacheManager.getPrdsNeedingAttention('alice');
@ -306,7 +296,7 @@ describe('CacheManager PRD/Epic Extensions', () => {
it('should handle @ prefix in username', () => { it('should handle @ prefix in username', () => {
cacheManager.writePrd('user-auth', '# PRD 1', { cacheManager.writePrd('user-auth', '# PRD 1', {
status: 'feedback', status: 'feedback',
stakeholders: ['alice', 'bob'] stakeholders: ['alice', 'bob'],
}); });
const tasks = cacheManager.getPrdsNeedingAttention('@alice'); const tasks = cacheManager.getPrdsNeedingAttention('@alice');
@ -348,7 +338,7 @@ describe('CacheManager PRD/Epic Extensions', () => {
prd_key: 'user-auth', prd_key: 'user-auth',
version: 1, version: 1,
status: 'draft', status: 'draft',
stories: ['2-1-login', '2-2-logout'] stories: ['2-1-login', '2-2-logout'],
}; };
const result = cacheManager.writeEpic('2', content, epicMeta); const result = cacheManager.writeEpic('2', content, epicMeta);
@ -366,7 +356,7 @@ describe('CacheManager PRD/Epic Extensions', () => {
it('should track PRD lineage in metadata', () => { it('should track PRD lineage in metadata', () => {
cacheManager.writeEpic('2', 'Epic content', { cacheManager.writeEpic('2', 'Epic content', {
prd_key: 'user-auth', prd_key: 'user-auth',
status: 'draft' status: 'draft',
}); });
const meta = cacheManager.loadMeta(); const meta = cacheManager.loadMeta();
@ -385,7 +375,7 @@ describe('CacheManager PRD/Epic Extensions', () => {
cacheManager.writeEpic('2', content, { cacheManager.writeEpic('2', content, {
prd_key: 'user-auth', prd_key: 'user-auth',
version: 1, version: 1,
status: 'draft' status: 'draft',
}); });
const result = cacheManager.readEpic('2'); const result = cacheManager.readEpic('2');
@ -437,8 +427,8 @@ describe('CacheManager PRD/Epic Extensions', () => {
const authEpics = cacheManager.getEpicsByPrd('user-auth'); const authEpics = cacheManager.getEpicsByPrd('user-auth');
expect(authEpics).toHaveLength(2); expect(authEpics).toHaveLength(2);
expect(authEpics.map(e => e.epicKey)).toContain('1'); expect(authEpics.map((e) => e.epicKey)).toContain('1');
expect(authEpics.map(e => e.epicKey)).toContain('2'); expect(authEpics.map((e) => e.epicKey)).toContain('2');
}); });
}); });
@ -446,15 +436,15 @@ describe('CacheManager PRD/Epic Extensions', () => {
it('should find Epics needing feedback from user', () => { it('should find Epics needing feedback from user', () => {
cacheManager.writeEpic('1', '# Epic 1', { cacheManager.writeEpic('1', '# Epic 1', {
status: 'feedback', status: 'feedback',
stakeholders: ['@alice', '@bob'] stakeholders: ['@alice', '@bob'],
}); });
cacheManager.writeEpic('2', '# Epic 2', { cacheManager.writeEpic('2', '# Epic 2', {
status: 'draft', status: 'draft',
stakeholders: ['@alice'] stakeholders: ['@alice'],
}); });
cacheManager.writeEpic('3', '# Epic 3', { cacheManager.writeEpic('3', '# Epic 3', {
status: 'feedback', status: 'feedback',
stakeholders: ['@charlie'] stakeholders: ['@charlie'],
}); });
const tasks = cacheManager.getEpicsNeedingAttention('alice'); const tasks = cacheManager.getEpicsNeedingAttention('alice');
@ -485,15 +475,15 @@ describe('CacheManager PRD/Epic Extensions', () => {
it('should return combined PRD and Epic tasks', () => { it('should return combined PRD and Epic tasks', () => {
cacheManager.writePrd('user-auth', '# PRD 1', { cacheManager.writePrd('user-auth', '# PRD 1', {
status: 'feedback', status: 'feedback',
stakeholders: ['@alice'] stakeholders: ['@alice'],
}); });
cacheManager.writePrd('payments', '# PRD 2', { cacheManager.writePrd('payments', '# PRD 2', {
status: 'signoff', status: 'signoff',
stakeholders: ['@alice'] stakeholders: ['@alice'],
}); });
cacheManager.writeEpic('2', '# Epic 2', { cacheManager.writeEpic('2', '# Epic 2', {
status: 'feedback', status: 'feedback',
stakeholders: ['@alice'] stakeholders: ['@alice'],
}); });
const tasks = cacheManager.getMyTasks('alice'); const tasks = cacheManager.getMyTasks('alice');
@ -506,7 +496,7 @@ describe('CacheManager PRD/Epic Extensions', () => {
it('should return empty arrays when user has no tasks', () => { it('should return empty arrays when user has no tasks', () => {
cacheManager.writePrd('user-auth', '# PRD 1', { cacheManager.writePrd('user-auth', '# PRD 1', {
status: 'feedback', status: 'feedback',
stakeholders: ['@bob'] stakeholders: ['@bob'],
}); });
const tasks = cacheManager.getMyTasks('alice'); const tasks = cacheManager.getMyTasks('alice');
@ -534,12 +524,12 @@ describe('CacheManager PRD/Epic Extensions', () => {
expect(stats.prd_count).toBe(3); expect(stats.prd_count).toBe(3);
expect(stats.prds_by_status).toEqual({ expect(stats.prds_by_status).toEqual({
feedback: 2, feedback: 2,
approved: 1 approved: 1,
}); });
expect(stats.epic_count).toBe(2); expect(stats.epic_count).toBe(2);
expect(stats.epics_by_status).toEqual({ expect(stats.epics_by_status).toEqual({
approved: 1, approved: 1,
draft: 1 draft: 1,
}); });
expect(stats.prd_size_kb).toBeGreaterThanOrEqual(0); expect(stats.prd_size_kb).toBeGreaterThanOrEqual(0);
expect(stats.epic_size_kb).toBeGreaterThanOrEqual(0); expect(stats.epic_size_kb).toBeGreaterThanOrEqual(0);
@ -556,7 +546,7 @@ describe('CacheManager PRD/Epic Extensions', () => {
it('should return true for old cache timestamp', () => { it('should return true for old cache timestamp', () => {
const oldMeta = { const oldMeta = {
cache_timestamp: '2020-01-01T00:00:00Z' cache_timestamp: '2020-01-01T00:00:00Z',
}; };
expect(cacheManager._isDocumentStale(oldMeta)).toBe(true); expect(cacheManager._isDocumentStale(oldMeta)).toBe(true);
@ -564,7 +554,7 @@ describe('CacheManager PRD/Epic Extensions', () => {
it('should return false for recent cache timestamp', () => { it('should return false for recent cache timestamp', () => {
const recentMeta = { const recentMeta = {
cache_timestamp: new Date().toISOString() cache_timestamp: new Date().toISOString(),
}; };
expect(cacheManager._isDocumentStale(recentMeta)).toBe(false); expect(cacheManager._isDocumentStale(recentMeta)).toBe(false);
@ -611,7 +601,7 @@ describe('CacheManager PRD/Epic Extensions', () => {
it('should handle empty stakeholder arrays', () => { it('should handle empty stakeholder arrays', () => {
cacheManager.writePrd('user-auth', '# PRD', { cacheManager.writePrd('user-auth', '# PRD', {
status: 'feedback', status: 'feedback',
stakeholders: [] stakeholders: [],
}); });
const tasks = cacheManager.getPrdsNeedingAttention('alice'); const tasks = cacheManager.getPrdsNeedingAttention('alice');

View File

@ -16,7 +16,7 @@ import {
FeedbackManager, FeedbackManager,
FEEDBACK_TYPES, FEEDBACK_TYPES,
FEEDBACK_STATUS, FEEDBACK_STATUS,
PRIORITY_LEVELS PRIORITY_LEVELS,
} from '../../../src/modules/bmm/lib/crowdsource/feedback-manager.js'; } from '../../../src/modules/bmm/lib/crowdsource/feedback-manager.js';
// Create a testable subclass that allows injecting mock implementations // Create a testable subclass that allows injecting mock implementations
@ -74,13 +74,7 @@ describe('FeedbackManager', () => {
describe('FEEDBACK_TYPES', () => { describe('FEEDBACK_TYPES', () => {
it('should define all standard feedback types', () => { it('should define all standard feedback types', () => {
const expectedTypes = [ const expectedTypes = ['clarification', 'concern', 'suggestion', 'addition', 'priority'];
'clarification',
'concern',
'suggestion',
'addition',
'priority'
];
for (const type of expectedTypes) { for (const type of expectedTypes) {
expect(FEEDBACK_TYPES[type]).toBeDefined(); expect(FEEDBACK_TYPES[type]).toBeDefined();
@ -137,7 +131,7 @@ describe('FeedbackManager', () => {
it('should initialize with github config', () => { it('should initialize with github config', () => {
const manager = new FeedbackManager({ const manager = new FeedbackManager({
owner: 'test-org', owner: 'test-org',
repo: 'test-repo' repo: 'test-repo',
}); });
expect(manager.owner).toBe('test-org'); expect(manager.owner).toBe('test-org');
@ -155,13 +149,13 @@ describe('FeedbackManager', () => {
beforeEach(() => { beforeEach(() => {
mockCreateIssue = vi.fn().mockResolvedValue({ mockCreateIssue = vi.fn().mockResolvedValue({
number: 42, number: 42,
html_url: 'https://github.com/test-org/test-repo/issues/42' html_url: 'https://github.com/test-org/test-repo/issues/42',
}); });
mockAddComment = vi.fn().mockResolvedValue({}); mockAddComment = vi.fn().mockResolvedValue({});
manager = new TestableFeedbackManager( manager = new TestableFeedbackManager(
{ owner: 'test-org', repo: 'test-repo' }, { owner: 'test-org', repo: 'test-repo' },
{ createIssue: mockCreateIssue, addComment: mockAddComment } { createIssue: mockCreateIssue, addComment: mockAddComment },
); );
}); });
@ -175,7 +169,7 @@ describe('FeedbackManager', () => {
priority: 'high', priority: 'high',
title: 'Unclear login flow', title: 'Unclear login flow',
content: 'The login flow description is ambiguous', content: 'The login flow description is ambiguous',
submittedBy: 'alice' submittedBy: 'alice',
}); });
expect(mockCreateIssue).toHaveBeenCalledTimes(1); expect(mockCreateIssue).toHaveBeenCalledTimes(1);
@ -204,7 +198,7 @@ describe('FeedbackManager', () => {
priority: 'medium', priority: 'medium',
title: 'Epic too large', title: 'Epic too large',
content: 'Should be split into smaller epics', content: 'Should be split into smaller epics',
submittedBy: 'bob' submittedBy: 'bob',
}); });
const createCall = mockCreateIssue.mock.calls[0][0]; const createCall = mockCreateIssue.mock.calls[0][0];
@ -225,7 +219,7 @@ describe('FeedbackManager', () => {
priority: 'high', priority: 'high',
title: 'Security risk', title: 'Security risk',
content: 'Missing security consideration', content: 'Missing security consideration',
submittedBy: 'security-team' submittedBy: 'security-team',
}); });
expect(mockAddComment).toHaveBeenCalledTimes(1); expect(mockAddComment).toHaveBeenCalledTimes(1);
@ -249,7 +243,7 @@ describe('FeedbackManager', () => {
content: 'Need better error messages', content: 'Need better error messages',
suggestedChange: 'Add user-friendly error codes', suggestedChange: 'Add user-friendly error codes',
rationale: 'Improves debugging for support team', rationale: 'Improves debugging for support team',
submittedBy: 'dev-lead' submittedBy: 'dev-lead',
}); });
const createCall = mockCreateIssue.mock.calls[0][0]; const createCall = mockCreateIssue.mock.calls[0][0];
@ -261,17 +255,19 @@ describe('FeedbackManager', () => {
}); });
it('should throw error for unknown feedback type', async () => { it('should throw error for unknown feedback type', async () => {
await expect(manager.createFeedback({ await expect(
reviewIssueNumber: 100, manager.createFeedback({
documentKey: 'prd:test', reviewIssueNumber: 100,
documentType: 'prd', documentKey: 'prd:test',
section: 'Test', documentType: 'prd',
feedbackType: 'invalid-type', section: 'Test',
priority: 'medium', feedbackType: 'invalid-type',
title: 'Test', priority: 'medium',
content: 'Test', title: 'Test',
submittedBy: 'user' content: 'Test',
})).rejects.toThrow('Unknown feedback type: invalid-type'); submittedBy: 'user',
}),
).rejects.toThrow('Unknown feedback type: invalid-type');
}); });
it('should default to medium priority when invalid priority provided', async () => { it('should default to medium priority when invalid priority provided', async () => {
@ -284,7 +280,7 @@ describe('FeedbackManager', () => {
priority: 'invalid', priority: 'invalid',
title: 'Test', title: 'Test',
content: 'Test', content: 'Test',
submittedBy: 'user' submittedBy: 'user',
}); });
const createCall = mockCreateIssue.mock.calls[0][0]; const createCall = mockCreateIssue.mock.calls[0][0];
@ -301,7 +297,7 @@ describe('FeedbackManager', () => {
priority: 'low', priority: 'low',
title: 'Test', title: 'Test',
content: 'Test', content: 'Test',
submittedBy: 'user' submittedBy: 'user',
}); });
const createCall = mockCreateIssue.mock.calls[0][0]; const createCall = mockCreateIssue.mock.calls[0][0];
@ -327,25 +323,22 @@ describe('FeedbackManager', () => {
{ name: 'feedback-section:user-stories' }, { name: 'feedback-section:user-stories' },
{ name: 'feedback-type:clarification' }, { name: 'feedback-type:clarification' },
{ name: 'feedback-status:new' }, { name: 'feedback-status:new' },
{ name: 'priority:high' } { name: 'priority:high' },
], ],
user: { login: 'alice' }, user: { login: 'alice' },
created_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-02T00:00:00Z', updated_at: '2026-01-02T00:00:00Z',
body: 'Test body' body: 'Test body',
} },
]); ]);
manager = new TestableFeedbackManager( manager = new TestableFeedbackManager({ owner: 'test-org', repo: 'test-repo' }, { searchIssues: mockSearchIssues });
{ owner: 'test-org', repo: 'test-repo' },
{ searchIssues: mockSearchIssues }
);
}); });
it('should query feedback with document key filter', async () => { it('should query feedback with document key filter', async () => {
await manager.getFeedback({ await manager.getFeedback({
documentKey: 'prd:user-auth', documentKey: 'prd:user-auth',
documentType: 'prd' documentType: 'prd',
}); });
expect(mockSearchIssues).toHaveBeenCalledTimes(1); expect(mockSearchIssues).toHaveBeenCalledTimes(1);
@ -361,7 +354,7 @@ describe('FeedbackManager', () => {
it('should query feedback with review issue filter', async () => { it('should query feedback with review issue filter', async () => {
await manager.getFeedback({ await manager.getFeedback({
reviewIssueNumber: 100, reviewIssueNumber: 100,
documentType: 'prd' documentType: 'prd',
}); });
const query = mockSearchIssues.mock.calls[0][0]; const query = mockSearchIssues.mock.calls[0][0];
@ -371,7 +364,7 @@ describe('FeedbackManager', () => {
it('should query feedback with status filter', async () => { it('should query feedback with status filter', async () => {
await manager.getFeedback({ await manager.getFeedback({
documentType: 'prd', documentType: 'prd',
status: 'incorporated' status: 'incorporated',
}); });
const query = mockSearchIssues.mock.calls[0][0]; const query = mockSearchIssues.mock.calls[0][0];
@ -381,7 +374,7 @@ describe('FeedbackManager', () => {
it('should query feedback with section filter', async () => { it('should query feedback with section filter', async () => {
await manager.getFeedback({ await manager.getFeedback({
documentType: 'epic', documentType: 'epic',
section: 'Story Breakdown' section: 'Story Breakdown',
}); });
const query = mockSearchIssues.mock.calls[0][0]; const query = mockSearchIssues.mock.calls[0][0];
@ -391,7 +384,7 @@ describe('FeedbackManager', () => {
it('should query feedback with type filter', async () => { it('should query feedback with type filter', async () => {
await manager.getFeedback({ await manager.getFeedback({
documentType: 'prd', documentType: 'prd',
feedbackType: 'concern' feedbackType: 'concern',
}); });
const query = mockSearchIssues.mock.calls[0][0]; const query = mockSearchIssues.mock.calls[0][0];
@ -401,7 +394,7 @@ describe('FeedbackManager', () => {
it('should parse feedback issues correctly', async () => { it('should parse feedback issues correctly', async () => {
const results = await manager.getFeedback({ const results = await manager.getFeedback({
documentType: 'prd', documentType: 'prd',
documentKey: 'prd:user-auth' documentKey: 'prd:user-auth',
}); });
expect(results).toHaveLength(1); expect(results).toHaveLength(1);
@ -413,14 +406,14 @@ describe('FeedbackManager', () => {
feedbackType: 'clarification', feedbackType: 'clarification',
status: 'new', status: 'new',
priority: 'high', priority: 'high',
submittedBy: 'alice' submittedBy: 'alice',
}); });
}); });
it('should handle document key with colon', async () => { it('should handle document key with colon', async () => {
await manager.getFeedback({ await manager.getFeedback({
documentKey: 'prd:complex-key', documentKey: 'prd:complex-key',
documentType: 'prd' documentType: 'prd',
}); });
const query = mockSearchIssues.mock.calls[0][0]; const query = mockSearchIssues.mock.calls[0][0];
@ -444,11 +437,11 @@ describe('FeedbackManager', () => {
{ name: 'feedback-section:user-stories' }, { name: 'feedback-section:user-stories' },
{ name: 'feedback-type:clarification' }, { name: 'feedback-type:clarification' },
{ name: 'feedback-status:new' }, { name: 'feedback-status:new' },
{ name: 'priority:high' } { name: 'priority:high' },
], ],
user: { login: 'alice' }, user: { login: 'alice' },
created_at: '2026-01-01', created_at: '2026-01-01',
updated_at: '2026-01-01' updated_at: '2026-01-01',
}, },
{ {
number: 2, number: 2,
@ -458,11 +451,11 @@ describe('FeedbackManager', () => {
{ name: 'feedback-section:user-stories' }, { name: 'feedback-section:user-stories' },
{ name: 'feedback-type:suggestion' }, { name: 'feedback-type:suggestion' },
{ name: 'feedback-status:new' }, { name: 'feedback-status:new' },
{ name: 'priority:medium' } { name: 'priority:medium' },
], ],
user: { login: 'bob' }, user: { login: 'bob' },
created_at: '2026-01-01', created_at: '2026-01-01',
updated_at: '2026-01-01' updated_at: '2026-01-01',
}, },
{ {
number: 3, number: 3,
@ -472,18 +465,15 @@ describe('FeedbackManager', () => {
{ name: 'feedback-section:fr-3' }, { name: 'feedback-section:fr-3' },
{ name: 'feedback-type:concern' }, { name: 'feedback-type:concern' },
{ name: 'feedback-status:new' }, { name: 'feedback-status:new' },
{ name: 'priority:high' } { name: 'priority:high' },
], ],
user: { login: 'charlie' }, user: { login: 'charlie' },
created_at: '2026-01-01', created_at: '2026-01-01',
updated_at: '2026-01-01' updated_at: '2026-01-01',
} },
]); ]);
manager = new TestableFeedbackManager( manager = new TestableFeedbackManager({ owner: 'test-org', repo: 'test-repo' }, { searchIssues: mockSearchIssues });
{ owner: 'test-org', repo: 'test-repo' },
{ searchIssues: mockSearchIssues }
);
}); });
it('should group feedback by section', async () => { it('should group feedback by section', async () => {
@ -518,9 +508,9 @@ describe('FeedbackManager', () => {
{ name: 'feedback-section:test' }, { name: 'feedback-section:test' },
{ name: 'feedback-type:clarification' }, { name: 'feedback-type:clarification' },
{ name: 'feedback-status:new' }, { name: 'feedback-status:new' },
{ name: 'priority:high' } { name: 'priority:high' },
], ],
user: { login: 'alice' } user: { login: 'alice' },
}, },
{ {
number: 2, number: 2,
@ -530,9 +520,9 @@ describe('FeedbackManager', () => {
{ name: 'feedback-section:test2' }, { name: 'feedback-section:test2' },
{ name: 'feedback-type:clarification' }, { name: 'feedback-type:clarification' },
{ name: 'feedback-status:new' }, { name: 'feedback-status:new' },
{ name: 'priority:medium' } { name: 'priority:medium' },
], ],
user: { login: 'bob' } user: { login: 'bob' },
}, },
{ {
number: 3, number: 3,
@ -542,16 +532,13 @@ describe('FeedbackManager', () => {
{ name: 'feedback-section:test' }, { name: 'feedback-section:test' },
{ name: 'feedback-type:concern' }, { name: 'feedback-type:concern' },
{ name: 'feedback-status:new' }, { name: 'feedback-status:new' },
{ name: 'priority:high' } { name: 'priority:high' },
], ],
user: { login: 'charlie' } user: { login: 'charlie' },
} },
]); ]);
manager = new TestableFeedbackManager( manager = new TestableFeedbackManager({ owner: 'test-org', repo: 'test-repo' }, { searchIssues: mockSearchIssues });
{ owner: 'test-org', repo: 'test-repo' },
{ searchIssues: mockSearchIssues }
);
}); });
it('should group feedback by type', async () => { it('should group feedback by type', async () => {
@ -579,9 +566,9 @@ describe('FeedbackManager', () => {
{ name: 'feedback-section:fr-5' }, { name: 'feedback-section:fr-5' },
{ name: 'feedback-type:concern' }, { name: 'feedback-type:concern' },
{ name: 'feedback-status:new' }, { name: 'feedback-status:new' },
{ name: 'priority:high' } { name: 'priority:high' },
], ],
user: { login: 'security' } user: { login: 'security' },
}, },
{ {
number: 2, number: 2,
@ -591,16 +578,13 @@ describe('FeedbackManager', () => {
{ name: 'feedback-section:fr-5' }, { name: 'feedback-section:fr-5' },
{ name: 'feedback-type:concern' }, { name: 'feedback-type:concern' },
{ name: 'feedback-status:new' }, { name: 'feedback-status:new' },
{ name: 'priority:medium' } { name: 'priority:medium' },
], ],
user: { login: 'ux-team' } user: { login: 'ux-team' },
} },
]); ]);
manager = new TestableFeedbackManager( manager = new TestableFeedbackManager({ owner: 'test-org', repo: 'test-repo' }, { searchIssues: mockSearchIssues });
{ owner: 'test-org', repo: 'test-repo' },
{ searchIssues: mockSearchIssues }
);
const conflicts = await manager.detectConflicts('prd:user-auth', 'prd'); const conflicts = await manager.detectConflicts('prd:user-auth', 'prd');
@ -620,9 +604,9 @@ describe('FeedbackManager', () => {
{ name: 'feedback-section:security' }, { name: 'feedback-section:security' },
{ name: 'feedback-type:concern' }, { name: 'feedback-type:concern' },
{ name: 'feedback-status:new' }, { name: 'feedback-status:new' },
{ name: 'priority:high' } { name: 'priority:high' },
], ],
user: { login: 'security' } user: { login: 'security' },
}, },
{ {
number: 2, number: 2,
@ -632,16 +616,13 @@ describe('FeedbackManager', () => {
{ name: 'feedback-section:security' }, { name: 'feedback-section:security' },
{ name: 'feedback-type:suggestion' }, { name: 'feedback-type:suggestion' },
{ name: 'feedback-status:new' }, { name: 'feedback-status:new' },
{ name: 'priority:medium' } { name: 'priority:medium' },
], ],
user: { login: 'dev' } user: { login: 'dev' },
} },
]); ]);
manager = new TestableFeedbackManager( manager = new TestableFeedbackManager({ owner: 'test-org', repo: 'test-repo' }, { searchIssues: mockSearchIssues });
{ owner: 'test-org', repo: 'test-repo' },
{ searchIssues: mockSearchIssues }
);
const conflicts = await manager.detectConflicts('prd:test', 'prd'); const conflicts = await manager.detectConflicts('prd:test', 'prd');
@ -659,16 +640,13 @@ describe('FeedbackManager', () => {
{ name: 'feedback-section:fr-1' }, { name: 'feedback-section:fr-1' },
{ name: 'feedback-type:concern' }, { name: 'feedback-type:concern' },
{ name: 'feedback-status:new' }, { name: 'feedback-status:new' },
{ name: 'priority:high' } { name: 'priority:high' },
], ],
user: { login: 'user1' } user: { login: 'user1' },
} },
]); ]);
manager = new TestableFeedbackManager( manager = new TestableFeedbackManager({ owner: 'test-org', repo: 'test-repo' }, { searchIssues: mockSearchIssues });
{ owner: 'test-org', repo: 'test-repo' },
{ searchIssues: mockSearchIssues }
);
const conflicts = await manager.detectConflicts('prd:test', 'prd'); const conflicts = await manager.detectConflicts('prd:test', 'prd');
@ -685,9 +663,9 @@ describe('FeedbackManager', () => {
{ name: 'feedback-section:fr-1' }, { name: 'feedback-section:fr-1' },
{ name: 'feedback-type:clarification' }, { name: 'feedback-type:clarification' },
{ name: 'feedback-status:new' }, { name: 'feedback-status:new' },
{ name: 'priority:medium' } { name: 'priority:medium' },
], ],
user: { login: 'user1' } user: { login: 'user1' },
}, },
{ {
number: 2, number: 2,
@ -697,16 +675,13 @@ describe('FeedbackManager', () => {
{ name: 'feedback-section:fr-1' }, { name: 'feedback-section:fr-1' },
{ name: 'feedback-type:clarification' }, { name: 'feedback-type:clarification' },
{ name: 'feedback-status:new' }, { name: 'feedback-status:new' },
{ name: 'priority:low' } { name: 'priority:low' },
], ],
user: { login: 'user2' } user: { login: 'user2' },
} },
]); ]);
manager = new TestableFeedbackManager( manager = new TestableFeedbackManager({ owner: 'test-org', repo: 'test-repo' }, { searchIssues: mockSearchIssues });
{ owner: 'test-org', repo: 'test-repo' },
{ searchIssues: mockSearchIssues }
);
const conflicts = await manager.detectConflicts('prd:test', 'prd'); const conflicts = await manager.detectConflicts('prd:test', 'prd');
@ -726,11 +701,7 @@ describe('FeedbackManager', () => {
beforeEach(() => { beforeEach(() => {
mockGetIssue = vi.fn().mockResolvedValue({ mockGetIssue = vi.fn().mockResolvedValue({
number: 42, number: 42,
labels: [ labels: [{ name: 'type:prd-feedback' }, { name: 'feedback-status:new' }, { name: 'priority:high' }],
{ name: 'type:prd-feedback' },
{ name: 'feedback-status:new' },
{ name: 'priority:high' }
]
}); });
mockUpdateIssue = vi.fn().mockResolvedValue({}); mockUpdateIssue = vi.fn().mockResolvedValue({});
mockAddComment = vi.fn().mockResolvedValue({}); mockAddComment = vi.fn().mockResolvedValue({});
@ -742,8 +713,8 @@ describe('FeedbackManager', () => {
getIssue: mockGetIssue, getIssue: mockGetIssue,
updateIssue: mockUpdateIssue, updateIssue: mockUpdateIssue,
addComment: mockAddComment, addComment: mockAddComment,
closeIssue: mockCloseIssue closeIssue: mockCloseIssue,
} },
); );
}); });
@ -792,9 +763,7 @@ describe('FeedbackManager', () => {
}); });
it('should throw error for unknown status', async () => { it('should throw error for unknown status', async () => {
await expect( await expect(manager.updateFeedbackStatus(42, 'invalid-status')).rejects.toThrow('Unknown status: invalid-status');
manager.updateFeedbackStatus(42, 'invalid-status')
).rejects.toThrow('Unknown status: invalid-status');
}); });
it('should return updated status info', async () => { it('should return updated status info', async () => {
@ -802,7 +771,7 @@ describe('FeedbackManager', () => {
expect(result).toEqual({ expect(result).toEqual({
feedbackId: 42, feedbackId: 42,
status: 'reviewed' status: 'reviewed',
}); });
}); });
}); });
@ -823,9 +792,9 @@ describe('FeedbackManager', () => {
{ name: 'feedback-section:user-stories' }, { name: 'feedback-section:user-stories' },
{ name: 'feedback-type:clarification' }, { name: 'feedback-type:clarification' },
{ name: 'feedback-status:new' }, { name: 'feedback-status:new' },
{ name: 'priority:high' } { name: 'priority:high' },
], ],
user: { login: 'alice' } user: { login: 'alice' },
}, },
{ {
number: 2, number: 2,
@ -835,9 +804,9 @@ describe('FeedbackManager', () => {
{ name: 'feedback-section:user-stories' }, { name: 'feedback-section:user-stories' },
{ name: 'feedback-type:concern' }, { name: 'feedback-type:concern' },
{ name: 'feedback-status:reviewed' }, { name: 'feedback-status:reviewed' },
{ name: 'priority:high' } { name: 'priority:high' },
], ],
user: { login: 'bob' } user: { login: 'bob' },
}, },
{ {
number: 3, number: 3,
@ -847,16 +816,13 @@ describe('FeedbackManager', () => {
{ name: 'feedback-section:fr-3' }, { name: 'feedback-section:fr-3' },
{ name: 'feedback-type:suggestion' }, { name: 'feedback-type:suggestion' },
{ name: 'feedback-status:new' }, { name: 'feedback-status:new' },
{ name: 'priority:medium' } { name: 'priority:medium' },
], ],
user: { login: 'alice' } user: { login: 'alice' },
} },
]); ]);
manager = new TestableFeedbackManager( manager = new TestableFeedbackManager({ owner: 'test-org', repo: 'test-repo' }, { searchIssues: mockSearchIssues });
{ owner: 'test-org', repo: 'test-repo' },
{ searchIssues: mockSearchIssues }
);
}); });
it('should calculate total feedback count', async () => { it('should calculate total feedback count', async () => {
@ -871,7 +837,7 @@ describe('FeedbackManager', () => {
expect(stats.byType).toEqual({ expect(stats.byType).toEqual({
clarification: 1, clarification: 1,
concern: 1, concern: 1,
suggestion: 1 suggestion: 1,
}); });
}); });
@ -880,7 +846,7 @@ describe('FeedbackManager', () => {
expect(stats.byStatus).toEqual({ expect(stats.byStatus).toEqual({
new: 2, new: 2,
reviewed: 1 reviewed: 1,
}); });
}); });
@ -889,7 +855,7 @@ describe('FeedbackManager', () => {
expect(stats.bySection).toEqual({ expect(stats.bySection).toEqual({
'user-stories': 2, 'user-stories': 2,
'fr-3': 1 'fr-3': 1,
}); });
}); });
@ -898,7 +864,7 @@ describe('FeedbackManager', () => {
expect(stats.byPriority).toEqual({ expect(stats.byPriority).toEqual({
high: 2, high: 2,
medium: 1 medium: 1,
}); });
}); });
@ -929,7 +895,7 @@ describe('FeedbackManager', () => {
typeConfig: FEEDBACK_TYPES.clarification, typeConfig: FEEDBACK_TYPES.clarification,
priority: 'high', priority: 'high',
content: 'This is unclear', content: 'This is unclear',
submittedBy: 'alice' submittedBy: 'alice',
}); });
expect(body).toContain('# 📋 Feedback: Clarification'); expect(body).toContain('# 📋 Feedback: Clarification');
@ -952,7 +918,7 @@ describe('FeedbackManager', () => {
priority: 'medium', priority: 'medium',
content: 'Could be improved', content: 'Could be improved',
suggestedChange: 'Use async/await pattern', suggestedChange: 'Use async/await pattern',
submittedBy: 'bob' submittedBy: 'bob',
}); });
expect(body).toContain('## Suggested Change'); expect(body).toContain('## Suggested Change');
@ -969,7 +935,7 @@ describe('FeedbackManager', () => {
priority: 'high', priority: 'high',
content: 'Security risk', content: 'Security risk',
rationale: 'OWASP Top 10 vulnerability', rationale: 'OWASP Top 10 vulnerability',
submittedBy: 'security' submittedBy: 'security',
}); });
expect(body).toContain('## Context/Rationale'); expect(body).toContain('## Context/Rationale');
@ -993,12 +959,12 @@ describe('FeedbackManager', () => {
{ name: 'feedback-section:user-stories' }, { name: 'feedback-section:user-stories' },
{ name: 'feedback-type:clarification' }, { name: 'feedback-type:clarification' },
{ name: 'feedback-status:new' }, { name: 'feedback-status:new' },
{ name: 'priority:high' } { name: 'priority:high' },
], ],
user: { login: 'alice' }, user: { login: 'alice' },
created_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-02T00:00:00Z', updated_at: '2026-01-02T00:00:00Z',
body: 'Test body content' body: 'Test body content',
}; };
const parsed = manager._parseFeedbackIssue(issue); const parsed = manager._parseFeedbackIssue(issue);
@ -1014,7 +980,7 @@ describe('FeedbackManager', () => {
submittedBy: 'alice', submittedBy: 'alice',
createdAt: '2026-01-01T00:00:00Z', createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z', updatedAt: '2026-01-02T00:00:00Z',
body: 'Test body content' body: 'Test body content',
}); });
}); });
@ -1024,7 +990,7 @@ describe('FeedbackManager', () => {
html_url: 'url', html_url: 'url',
title: '⚠️ Feedback: Important concern', title: '⚠️ Feedback: Important concern',
labels: [], labels: [],
user: null user: null,
}; };
const parsed = manager._parseFeedbackIssue(issue); const parsed = manager._parseFeedbackIssue(issue);
@ -1037,7 +1003,7 @@ describe('FeedbackManager', () => {
html_url: 'url', html_url: 'url',
title: 'Feedback: Missing labels', title: 'Feedback: Missing labels',
labels: [], labels: [],
user: { login: 'user' } user: { login: 'user' },
}; };
const parsed = manager._parseFeedbackIssue(issue); const parsed = manager._parseFeedbackIssue(issue);
@ -1076,17 +1042,11 @@ describe('FeedbackManager', () => {
it('should throw when GitHub methods not implemented', async () => { it('should throw when GitHub methods not implemented', async () => {
const manager = new FeedbackManager({ owner: 'test', repo: 'test' }); const manager = new FeedbackManager({ owner: 'test', repo: 'test' });
await expect(manager._createIssue({})).rejects.toThrow( await expect(manager._createIssue({})).rejects.toThrow('_createIssue must be implemented by caller via GitHub MCP');
'_createIssue must be implemented by caller via GitHub MCP'
);
await expect(manager._getIssue(1)).rejects.toThrow( await expect(manager._getIssue(1)).rejects.toThrow('_getIssue must be implemented by caller via GitHub MCP');
'_getIssue must be implemented by caller via GitHub MCP'
);
await expect(manager._searchIssues('')).rejects.toThrow( await expect(manager._searchIssues('')).rejects.toThrow('_searchIssues must be implemented by caller via GitHub MCP');
'_searchIssues must be implemented by caller via GitHub MCP'
);
}); });
}); });
}); });

View File

@ -16,7 +16,7 @@ import {
SignoffManager, SignoffManager,
SIGNOFF_STATUS, SIGNOFF_STATUS,
THRESHOLD_TYPES, THRESHOLD_TYPES,
DEFAULT_CONFIG DEFAULT_CONFIG,
} from '../../../src/modules/bmm/lib/crowdsource/signoff-manager.js'; } from '../../../src/modules/bmm/lib/crowdsource/signoff-manager.js';
// Create a testable subclass that allows injecting mock implementations // Create a testable subclass that allows injecting mock implementations
@ -87,7 +87,7 @@ describe('SignoffManager', () => {
it('should initialize with github config', () => { it('should initialize with github config', () => {
const manager = new SignoffManager({ const manager = new SignoffManager({
owner: 'test-org', owner: 'test-org',
repo: 'test-repo' repo: 'test-repo',
}); });
expect(manager.owner).toBe('test-org'); expect(manager.owner).toBe('test-org');
@ -104,10 +104,7 @@ describe('SignoffManager', () => {
beforeEach(() => { beforeEach(() => {
mockAddComment = vi.fn().mockResolvedValue({}); mockAddComment = vi.fn().mockResolvedValue({});
manager = new TestableSignoffManager( manager = new TestableSignoffManager({ owner: 'test-org', repo: 'test-repo' }, { addComment: mockAddComment });
{ owner: 'test-org', repo: 'test-repo' },
{ addComment: mockAddComment }
);
}); });
it('should create sign-off request with stakeholder checklist', async () => { it('should create sign-off request with stakeholder checklist', async () => {
@ -116,7 +113,7 @@ describe('SignoffManager', () => {
documentType: 'prd', documentType: 'prd',
reviewIssueNumber: 100, reviewIssueNumber: 100,
stakeholders: ['alice', 'bob', 'charlie'], stakeholders: ['alice', 'bob', 'charlie'],
deadline: '2026-01-15' deadline: '2026-01-15',
}); });
expect(mockAddComment).toHaveBeenCalledTimes(1); expect(mockAddComment).toHaveBeenCalledTimes(1);
@ -145,8 +142,8 @@ describe('SignoffManager', () => {
deadline: '2026-01-15', deadline: '2026-01-15',
config: { config: {
minimum_approvals: 5, minimum_approvals: 5,
block_threshold: 2 block_threshold: 2,
} },
}); });
expect(result.config.minimum_approvals).toBe(5); expect(result.config.minimum_approvals).toBe(5);
@ -163,7 +160,7 @@ describe('SignoffManager', () => {
reviewIssueNumber: 100, reviewIssueNumber: 100,
stakeholders: ['alice', 'bob', 'charlie'], stakeholders: ['alice', 'bob', 'charlie'],
deadline: '2026-01-15', deadline: '2026-01-15',
config: { threshold_type: 'count', minimum_approvals: 2 } config: { threshold_type: 'count', minimum_approvals: 2 },
}); });
const comment = mockAddComment.mock.calls[0][1]; const comment = mockAddComment.mock.calls[0][1];
@ -177,7 +174,7 @@ describe('SignoffManager', () => {
reviewIssueNumber: 100, reviewIssueNumber: 100,
stakeholders: ['alice', 'bob', 'charlie'], stakeholders: ['alice', 'bob', 'charlie'],
deadline: '2026-01-15', deadline: '2026-01-15',
config: { threshold_type: 'percentage', approval_percentage: 75 } config: { threshold_type: 'percentage', approval_percentage: 75 },
}); });
const comment = mockAddComment.mock.calls[0][1]; const comment = mockAddComment.mock.calls[0][1];
@ -195,8 +192,8 @@ describe('SignoffManager', () => {
threshold_type: 'required_approvers', threshold_type: 'required_approvers',
required: ['alice', 'bob'], required: ['alice', 'bob'],
optional: ['charlie', 'dave'], optional: ['charlie', 'dave'],
minimum_optional: 1 minimum_optional: 1,
} },
}); });
const comment = mockAddComment.mock.calls[0][1]; const comment = mockAddComment.mock.calls[0][1];
@ -210,7 +207,7 @@ describe('SignoffManager', () => {
documentType: 'prd', documentType: 'prd',
reviewIssueNumber: 100, reviewIssueNumber: 100,
stakeholders: ['alice', 'bob'], stakeholders: ['alice', 'bob'],
deadline: '2026-01-15' deadline: '2026-01-15',
}); });
const comment = mockAddComment.mock.calls[0][1]; const comment = mockAddComment.mock.calls[0][1];
@ -220,28 +217,32 @@ describe('SignoffManager', () => {
}); });
it('should validate count threshold against stakeholder list', async () => { it('should validate count threshold against stakeholder list', async () => {
await expect(manager.requestSignoff({ await expect(
documentKey: 'prd:test', manager.requestSignoff({
documentType: 'prd', documentKey: 'prd:test',
reviewIssueNumber: 100, documentType: 'prd',
stakeholders: ['alice', 'bob'], reviewIssueNumber: 100,
deadline: '2026-01-15', stakeholders: ['alice', 'bob'],
config: { threshold_type: 'count', minimum_approvals: 5 } deadline: '2026-01-15',
})).rejects.toThrow('minimum_approvals (5) cannot exceed stakeholder count (2)'); config: { threshold_type: 'count', minimum_approvals: 5 },
}),
).rejects.toThrow('minimum_approvals (5) cannot exceed stakeholder count (2)');
}); });
it('should validate required approvers are in stakeholder list', async () => { it('should validate required approvers are in stakeholder list', async () => {
await expect(manager.requestSignoff({ await expect(
documentKey: 'prd:test', manager.requestSignoff({
documentType: 'prd', documentKey: 'prd:test',
reviewIssueNumber: 100, documentType: 'prd',
stakeholders: ['alice', 'bob'], reviewIssueNumber: 100,
deadline: '2026-01-15', stakeholders: ['alice', 'bob'],
config: { deadline: '2026-01-15',
threshold_type: 'required_approvers', config: {
required: ['alice', 'charlie'] // charlie not in stakeholders threshold_type: 'required_approvers',
} required: ['alice', 'charlie'], // charlie not in stakeholders
})).rejects.toThrow('All required approvers must be in stakeholder list'); },
}),
).rejects.toThrow('All required approvers must be in stakeholder list');
}); });
it('should handle @ prefix in stakeholder names', async () => { it('should handle @ prefix in stakeholder names', async () => {
@ -250,7 +251,7 @@ describe('SignoffManager', () => {
documentType: 'prd', documentType: 'prd',
reviewIssueNumber: 100, reviewIssueNumber: 100,
stakeholders: ['@alice', '@bob'], stakeholders: ['@alice', '@bob'],
deadline: '2026-01-15' deadline: '2026-01-15',
}); });
const comment = mockAddComment.mock.calls[0][1]; const comment = mockAddComment.mock.calls[0][1];
@ -271,7 +272,7 @@ describe('SignoffManager', () => {
beforeEach(() => { beforeEach(() => {
mockAddComment = vi.fn().mockResolvedValue({}); mockAddComment = vi.fn().mockResolvedValue({});
mockGetIssue = vi.fn().mockResolvedValue({ mockGetIssue = vi.fn().mockResolvedValue({
labels: [{ name: 'type:prd-review' }, { name: 'review-status:signoff' }] labels: [{ name: 'type:prd-review' }, { name: 'review-status:signoff' }],
}); });
mockUpdateIssue = vi.fn().mockResolvedValue({}); mockUpdateIssue = vi.fn().mockResolvedValue({});
@ -280,8 +281,8 @@ describe('SignoffManager', () => {
{ {
addComment: mockAddComment, addComment: mockAddComment,
getIssue: mockGetIssue, getIssue: mockGetIssue,
updateIssue: mockUpdateIssue updateIssue: mockUpdateIssue,
} },
); );
}); });
@ -291,7 +292,7 @@ describe('SignoffManager', () => {
documentKey: 'prd:user-auth', documentKey: 'prd:user-auth',
documentType: 'prd', documentType: 'prd',
user: 'alice', user: 'alice',
decision: 'approved' decision: 'approved',
}); });
expect(mockAddComment).toHaveBeenCalledTimes(1); expect(mockAddComment).toHaveBeenCalledTimes(1);
@ -313,7 +314,7 @@ describe('SignoffManager', () => {
documentType: 'prd', documentType: 'prd',
user: 'bob', user: 'bob',
decision: 'approved_with_note', decision: 'approved_with_note',
note: 'Please update docs before implementation' note: 'Please update docs before implementation',
}); });
const comment = mockAddComment.mock.calls[0][1]; const comment = mockAddComment.mock.calls[0][1];
@ -331,7 +332,7 @@ describe('SignoffManager', () => {
user: 'security', user: 'security',
decision: 'blocked', decision: 'blocked',
note: 'Security review required', note: 'Security review required',
feedbackIssueNumber: 42 feedbackIssueNumber: 42,
}); });
const comment = mockAddComment.mock.calls[0][1]; const comment = mockAddComment.mock.calls[0][1];
@ -348,7 +349,7 @@ describe('SignoffManager', () => {
documentKey: 'prd:test', documentKey: 'prd:test',
documentType: 'prd', documentType: 'prd',
user: 'alice', user: 'alice',
decision: 'approved' decision: 'approved',
}); });
expect(mockUpdateIssue).toHaveBeenCalledTimes(1); expect(mockUpdateIssue).toHaveBeenCalledTimes(1);
@ -362,8 +363,8 @@ describe('SignoffManager', () => {
mockGetIssue.mockResolvedValue({ mockGetIssue.mockResolvedValue({
labels: [ labels: [
{ name: 'type:prd-review' }, { name: 'type:prd-review' },
{ name: 'signoff-alice-pending' } // Previous status { name: 'signoff-alice-pending' }, // Previous status
] ],
}); });
await manager.submitSignoff({ await manager.submitSignoff({
@ -371,7 +372,7 @@ describe('SignoffManager', () => {
documentKey: 'prd:test', documentKey: 'prd:test',
documentType: 'prd', documentType: 'prd',
user: 'alice', user: 'alice',
decision: 'approved' decision: 'approved',
}); });
const updateCall = mockUpdateIssue.mock.calls[0]; const updateCall = mockUpdateIssue.mock.calls[0];
@ -386,7 +387,7 @@ describe('SignoffManager', () => {
documentKey: 'prd:test', documentKey: 'prd:test',
documentType: 'prd', documentType: 'prd',
user: '@alice', user: '@alice',
decision: 'approved' decision: 'approved',
}); });
const updateCall = mockUpdateIssue.mock.calls[0]; const updateCall = mockUpdateIssue.mock.calls[0];
@ -394,13 +395,15 @@ describe('SignoffManager', () => {
}); });
it('should throw error for invalid decision', async () => { it('should throw error for invalid decision', async () => {
await expect(manager.submitSignoff({ await expect(
reviewIssueNumber: 100, manager.submitSignoff({
documentKey: 'prd:test', reviewIssueNumber: 100,
documentType: 'prd', documentKey: 'prd:test',
user: 'alice', documentType: 'prd',
decision: 'invalid' user: 'alice',
})).rejects.toThrow('Invalid decision: invalid'); decision: 'invalid',
}),
).rejects.toThrow('Invalid decision: invalid');
}); });
}); });
@ -413,10 +416,7 @@ describe('SignoffManager', () => {
beforeEach(() => { beforeEach(() => {
mockGetIssue = vi.fn(); mockGetIssue = vi.fn();
manager = new TestableSignoffManager( manager = new TestableSignoffManager({ owner: 'test-org', repo: 'test-repo' }, { getIssue: mockGetIssue });
{ owner: 'test-org', repo: 'test-repo' },
{ getIssue: mockGetIssue }
);
}); });
it('should parse signoff labels from issue', async () => { it('should parse signoff labels from issue', async () => {
@ -426,8 +426,8 @@ describe('SignoffManager', () => {
{ name: 'signoff-alice-approved' }, { name: 'signoff-alice-approved' },
{ name: 'signoff-bob-approved-with-note' }, { name: 'signoff-bob-approved-with-note' },
{ name: 'signoff-charlie-blocked' }, { name: 'signoff-charlie-blocked' },
{ name: 'signoff-dave-pending' } { name: 'signoff-dave-pending' },
] ],
}); });
const signoffs = await manager.getSignoffs(100); const signoffs = await manager.getSignoffs(100);
@ -436,31 +436,28 @@ describe('SignoffManager', () => {
expect(signoffs).toContainEqual({ expect(signoffs).toContainEqual({
user: 'alice', user: 'alice',
status: 'approved', status: 'approved',
label: 'signoff-alice-approved' label: 'signoff-alice-approved',
}); });
expect(signoffs).toContainEqual({ expect(signoffs).toContainEqual({
user: 'bob', user: 'bob',
status: 'approved_with_note', status: 'approved_with_note',
label: 'signoff-bob-approved-with-note' label: 'signoff-bob-approved-with-note',
}); });
expect(signoffs).toContainEqual({ expect(signoffs).toContainEqual({
user: 'charlie', user: 'charlie',
status: 'blocked', status: 'blocked',
label: 'signoff-charlie-blocked' label: 'signoff-charlie-blocked',
}); });
expect(signoffs).toContainEqual({ expect(signoffs).toContainEqual({
user: 'dave', user: 'dave',
status: 'pending', status: 'pending',
label: 'signoff-dave-pending' label: 'signoff-dave-pending',
}); });
}); });
it('should return empty array when no signoff labels', async () => { it('should return empty array when no signoff labels', async () => {
mockGetIssue.mockResolvedValue({ mockGetIssue.mockResolvedValue({
labels: [ labels: [{ name: 'type:prd-review' }, { name: 'review-status:signoff' }],
{ name: 'type:prd-review' },
{ name: 'review-status:signoff' }
]
}); });
const signoffs = await manager.getSignoffs(100); const signoffs = await manager.getSignoffs(100);
@ -470,11 +467,7 @@ describe('SignoffManager', () => {
it('should ignore non-signoff labels', async () => { it('should ignore non-signoff labels', async () => {
mockGetIssue.mockResolvedValue({ mockGetIssue.mockResolvedValue({
labels: [ labels: [{ name: 'signoff-alice-approved' }, { name: 'priority:high' }, { name: 'type:prd-feedback' }],
{ name: 'signoff-alice-approved' },
{ name: 'priority:high' },
{ name: 'type:prd-feedback' }
]
}); });
const signoffs = await manager.getSignoffs(100); const signoffs = await manager.getSignoffs(100);
@ -496,7 +489,7 @@ describe('SignoffManager', () => {
it('should return approved when minimum approvals reached', () => { it('should return approved when minimum approvals reached', () => {
const signoffs = [ const signoffs = [
{ user: 'alice', status: 'approved' }, { user: 'alice', status: 'approved' },
{ user: 'bob', status: 'approved' } { user: 'bob', status: 'approved' },
]; ];
const stakeholders = ['alice', 'bob', 'charlie']; const stakeholders = ['alice', 'bob', 'charlie'];
const config = { ...DEFAULT_CONFIG, minimum_approvals: 2 }; const config = { ...DEFAULT_CONFIG, minimum_approvals: 2 };
@ -508,9 +501,7 @@ describe('SignoffManager', () => {
}); });
it('should return pending when more approvals needed', () => { it('should return pending when more approvals needed', () => {
const signoffs = [ const signoffs = [{ user: 'alice', status: 'approved' }];
{ user: 'alice', status: 'approved' }
];
const stakeholders = ['alice', 'bob', 'charlie']; const stakeholders = ['alice', 'bob', 'charlie'];
const config = { ...DEFAULT_CONFIG, minimum_approvals: 2 }; const config = { ...DEFAULT_CONFIG, minimum_approvals: 2 };
@ -524,7 +515,7 @@ describe('SignoffManager', () => {
it('should count approved_with_note as approval', () => { it('should count approved_with_note as approval', () => {
const signoffs = [ const signoffs = [
{ user: 'alice', status: 'approved' }, { user: 'alice', status: 'approved' },
{ user: 'bob', status: 'approved_with_note' } { user: 'bob', status: 'approved_with_note' },
]; ];
const stakeholders = ['alice', 'bob', 'charlie']; const stakeholders = ['alice', 'bob', 'charlie'];
const config = { ...DEFAULT_CONFIG, minimum_approvals: 2 }; const config = { ...DEFAULT_CONFIG, minimum_approvals: 2 };
@ -537,7 +528,7 @@ describe('SignoffManager', () => {
it('should return blocked when block threshold reached', () => { it('should return blocked when block threshold reached', () => {
const signoffs = [ const signoffs = [
{ user: 'alice', status: 'approved' }, { user: 'alice', status: 'approved' },
{ user: 'bob', status: 'blocked' } { user: 'bob', status: 'blocked' },
]; ];
const stakeholders = ['alice', 'bob', 'charlie']; const stakeholders = ['alice', 'bob', 'charlie'];
const config = { ...DEFAULT_CONFIG, minimum_approvals: 2, block_threshold: 1 }; const config = { ...DEFAULT_CONFIG, minimum_approvals: 2, block_threshold: 1 };
@ -552,7 +543,7 @@ describe('SignoffManager', () => {
const signoffs = [ const signoffs = [
{ user: 'alice', status: 'approved' }, { user: 'alice', status: 'approved' },
{ user: 'bob', status: 'approved' }, { user: 'bob', status: 'approved' },
{ user: 'charlie', status: 'blocked' } { user: 'charlie', status: 'blocked' },
]; ];
const stakeholders = ['alice', 'bob', 'charlie']; const stakeholders = ['alice', 'bob', 'charlie'];
const config = { ...DEFAULT_CONFIG, minimum_approvals: 2, allow_blocks: false }; const config = { ...DEFAULT_CONFIG, minimum_approvals: 2, allow_blocks: false };
@ -566,7 +557,7 @@ describe('SignoffManager', () => {
const signoffs = [ const signoffs = [
{ user: 'alice', status: 'approved' }, { user: 'alice', status: 'approved' },
{ user: 'bob', status: 'approved' }, { user: 'bob', status: 'approved' },
{ user: 'charlie', status: 'blocked' } { user: 'charlie', status: 'blocked' },
]; ];
const stakeholders = ['alice', 'bob', 'charlie', 'dave']; const stakeholders = ['alice', 'bob', 'charlie', 'dave'];
const config = { ...DEFAULT_CONFIG, minimum_approvals: 2, block_threshold: 2 }; const config = { ...DEFAULT_CONFIG, minimum_approvals: 2, block_threshold: 2 };
@ -589,13 +580,13 @@ describe('SignoffManager', () => {
it('should return approved when percentage threshold met', () => { it('should return approved when percentage threshold met', () => {
const signoffs = [ const signoffs = [
{ user: 'alice', status: 'approved' }, { user: 'alice', status: 'approved' },
{ user: 'bob', status: 'approved' } { user: 'bob', status: 'approved' },
]; ];
const stakeholders = ['alice', 'bob', 'charlie']; // 2/3 = 66.67% const stakeholders = ['alice', 'bob', 'charlie']; // 2/3 = 66.67%
const config = { const config = {
...DEFAULT_CONFIG, ...DEFAULT_CONFIG,
threshold_type: 'percentage', threshold_type: 'percentage',
approval_percentage: 66 approval_percentage: 66,
}; };
const status = manager.calculateStatus(signoffs, stakeholders, config); const status = manager.calculateStatus(signoffs, stakeholders, config);
@ -606,14 +597,12 @@ describe('SignoffManager', () => {
}); });
it('should return pending when percentage not met', () => { it('should return pending when percentage not met', () => {
const signoffs = [ const signoffs = [{ user: 'alice', status: 'approved' }];
{ user: 'alice', status: 'approved' }
];
const stakeholders = ['alice', 'bob', 'charlie', 'dave']; // 1/4 = 25% const stakeholders = ['alice', 'bob', 'charlie', 'dave']; // 1/4 = 25%
const config = { const config = {
...DEFAULT_CONFIG, ...DEFAULT_CONFIG,
threshold_type: 'percentage', threshold_type: 'percentage',
approval_percentage: 50 approval_percentage: 50,
}; };
const status = manager.calculateStatus(signoffs, stakeholders, config); const status = manager.calculateStatus(signoffs, stakeholders, config);
@ -627,13 +616,13 @@ describe('SignoffManager', () => {
it('should calculate correctly for 100% threshold', () => { it('should calculate correctly for 100% threshold', () => {
const signoffs = [ const signoffs = [
{ user: 'alice', status: 'approved' }, { user: 'alice', status: 'approved' },
{ user: 'bob', status: 'approved' } { user: 'bob', status: 'approved' },
]; ];
const stakeholders = ['alice', 'bob', 'charlie']; const stakeholders = ['alice', 'bob', 'charlie'];
const config = { const config = {
...DEFAULT_CONFIG, ...DEFAULT_CONFIG,
threshold_type: 'percentage', threshold_type: 'percentage',
approval_percentage: 100 approval_percentage: 100,
}; };
const status = manager.calculateStatus(signoffs, stakeholders, config); const status = manager.calculateStatus(signoffs, stakeholders, config);
@ -656,7 +645,7 @@ describe('SignoffManager', () => {
const signoffs = [ const signoffs = [
{ user: 'alice', status: 'approved' }, { user: 'alice', status: 'approved' },
{ user: 'bob', status: 'approved' }, { user: 'bob', status: 'approved' },
{ user: 'charlie', status: 'approved' } { user: 'charlie', status: 'approved' },
]; ];
const stakeholders = ['alice', 'bob', 'charlie', 'dave']; const stakeholders = ['alice', 'bob', 'charlie', 'dave'];
const config = { const config = {
@ -664,7 +653,7 @@ describe('SignoffManager', () => {
threshold_type: 'required_approvers', threshold_type: 'required_approvers',
required: ['alice', 'bob'], required: ['alice', 'bob'],
optional: ['charlie', 'dave'], optional: ['charlie', 'dave'],
minimum_optional: 1 minimum_optional: 1,
}; };
const status = manager.calculateStatus(signoffs, stakeholders, config); const status = manager.calculateStatus(signoffs, stakeholders, config);
@ -676,7 +665,7 @@ describe('SignoffManager', () => {
it('should return pending when required approver missing', () => { it('should return pending when required approver missing', () => {
const signoffs = [ const signoffs = [
{ user: 'alice', status: 'approved' }, { user: 'alice', status: 'approved' },
{ user: 'charlie', status: 'approved' } { user: 'charlie', status: 'approved' },
]; ];
const stakeholders = ['alice', 'bob', 'charlie', 'dave']; const stakeholders = ['alice', 'bob', 'charlie', 'dave'];
const config = { const config = {
@ -684,7 +673,7 @@ describe('SignoffManager', () => {
threshold_type: 'required_approvers', threshold_type: 'required_approvers',
required: ['alice', 'bob'], required: ['alice', 'bob'],
optional: ['charlie', 'dave'], optional: ['charlie', 'dave'],
minimum_optional: 1 minimum_optional: 1,
}; };
const status = manager.calculateStatus(signoffs, stakeholders, config); const status = manager.calculateStatus(signoffs, stakeholders, config);
@ -697,7 +686,7 @@ describe('SignoffManager', () => {
it('should return pending when optional threshold not met', () => { it('should return pending when optional threshold not met', () => {
const signoffs = [ const signoffs = [
{ user: 'alice', status: 'approved' }, { user: 'alice', status: 'approved' },
{ user: 'bob', status: 'approved' } { user: 'bob', status: 'approved' },
// No optional approvers // No optional approvers
]; ];
const stakeholders = ['alice', 'bob', 'charlie', 'dave']; const stakeholders = ['alice', 'bob', 'charlie', 'dave'];
@ -706,7 +695,7 @@ describe('SignoffManager', () => {
threshold_type: 'required_approvers', threshold_type: 'required_approvers',
required: ['alice', 'bob'], required: ['alice', 'bob'],
optional: ['charlie', 'dave'], optional: ['charlie', 'dave'],
minimum_optional: 1 minimum_optional: 1,
}; };
const status = manager.calculateStatus(signoffs, stakeholders, config); const status = manager.calculateStatus(signoffs, stakeholders, config);
@ -719,7 +708,7 @@ describe('SignoffManager', () => {
it('should handle @ prefix in required list', () => { it('should handle @ prefix in required list', () => {
const signoffs = [ const signoffs = [
{ user: 'alice', status: 'approved' }, { user: 'alice', status: 'approved' },
{ user: 'bob', status: 'approved' } { user: 'bob', status: 'approved' },
]; ];
const stakeholders = ['@alice', '@bob']; const stakeholders = ['@alice', '@bob'];
const config = { const config = {
@ -727,7 +716,7 @@ describe('SignoffManager', () => {
threshold_type: 'required_approvers', threshold_type: 'required_approvers',
required: ['@alice', '@bob'], required: ['@alice', '@bob'],
optional: [], optional: [],
minimum_optional: 0 minimum_optional: 0,
}; };
const status = manager.calculateStatus(signoffs, stakeholders, config); const status = manager.calculateStatus(signoffs, stakeholders, config);
@ -748,25 +737,23 @@ describe('SignoffManager', () => {
it('should return true when approved', () => { it('should return true when approved', () => {
const signoffs = [ const signoffs = [
{ user: 'alice', status: 'approved' }, { user: 'alice', status: 'approved' },
{ user: 'bob', status: 'approved' } { user: 'bob', status: 'approved' },
]; ];
const approved = manager.isApproved(signoffs, ['alice', 'bob', 'charlie'], { const approved = manager.isApproved(signoffs, ['alice', 'bob', 'charlie'], {
...DEFAULT_CONFIG, ...DEFAULT_CONFIG,
minimum_approvals: 2 minimum_approvals: 2,
}); });
expect(approved).toBe(true); expect(approved).toBe(true);
}); });
it('should return false when pending', () => { it('should return false when pending', () => {
const signoffs = [ const signoffs = [{ user: 'alice', status: 'approved' }];
{ user: 'alice', status: 'approved' }
];
const approved = manager.isApproved(signoffs, ['alice', 'bob', 'charlie'], { const approved = manager.isApproved(signoffs, ['alice', 'bob', 'charlie'], {
...DEFAULT_CONFIG, ...DEFAULT_CONFIG,
minimum_approvals: 2 minimum_approvals: 2,
}); });
expect(approved).toBe(false); expect(approved).toBe(false);
@ -775,12 +762,12 @@ describe('SignoffManager', () => {
it('should return false when blocked', () => { it('should return false when blocked', () => {
const signoffs = [ const signoffs = [
{ user: 'alice', status: 'approved' }, { user: 'alice', status: 'approved' },
{ user: 'bob', status: 'blocked' } { user: 'bob', status: 'blocked' },
]; ];
const approved = manager.isApproved(signoffs, ['alice', 'bob'], { const approved = manager.isApproved(signoffs, ['alice', 'bob'], {
...DEFAULT_CONFIG, ...DEFAULT_CONFIG,
minimum_approvals: 1 minimum_approvals: 1,
}); });
expect(approved).toBe(false); expect(approved).toBe(false);
@ -800,7 +787,7 @@ describe('SignoffManager', () => {
const signoffs = [ const signoffs = [
{ user: 'alice', status: 'approved' }, { user: 'alice', status: 'approved' },
{ user: 'bob', status: 'approved_with_note' }, { user: 'bob', status: 'approved_with_note' },
{ user: 'charlie', status: 'blocked' } { user: 'charlie', status: 'blocked' },
]; ];
const stakeholders = ['alice', 'bob', 'charlie', 'dave', 'eve']; const stakeholders = ['alice', 'bob', 'charlie', 'dave', 'eve'];
@ -818,13 +805,13 @@ describe('SignoffManager', () => {
it('should include status info from calculateStatus', () => { it('should include status info from calculateStatus', () => {
const signoffs = [ const signoffs = [
{ user: 'alice', status: 'approved' }, { user: 'alice', status: 'approved' },
{ user: 'bob', status: 'approved' } { user: 'bob', status: 'approved' },
]; ];
const stakeholders = ['alice', 'bob', 'charlie']; const stakeholders = ['alice', 'bob', 'charlie'];
const summary = manager.getProgressSummary(signoffs, stakeholders, { const summary = manager.getProgressSummary(signoffs, stakeholders, {
...DEFAULT_CONFIG, ...DEFAULT_CONFIG,
minimum_approvals: 2 minimum_approvals: 2,
}); });
expect(summary.status).toBe('approved'); expect(summary.status).toBe('approved');
@ -832,9 +819,7 @@ describe('SignoffManager', () => {
}); });
it('should handle @ prefix in stakeholder names', () => { it('should handle @ prefix in stakeholder names', () => {
const signoffs = [ const signoffs = [{ user: 'alice', status: 'approved' }];
{ user: 'alice', status: 'approved' }
];
const stakeholders = ['@alice', '@bob']; const stakeholders = ['@alice', '@bob'];
const summary = manager.getProgressSummary(signoffs, stakeholders, DEFAULT_CONFIG); const summary = manager.getProgressSummary(signoffs, stakeholders, DEFAULT_CONFIG);
@ -853,18 +838,11 @@ describe('SignoffManager', () => {
beforeEach(() => { beforeEach(() => {
mockAddComment = vi.fn().mockResolvedValue({}); mockAddComment = vi.fn().mockResolvedValue({});
manager = new TestableSignoffManager( manager = new TestableSignoffManager({ owner: 'test-org', repo: 'test-repo' }, { addComment: mockAddComment });
{ owner: 'test-org', repo: 'test-repo' },
{ addComment: mockAddComment }
);
}); });
it('should send reminder to pending users', async () => { it('should send reminder to pending users', async () => {
const result = await manager.sendReminder( const result = await manager.sendReminder(100, ['alice', 'bob'], '2026-01-15');
100,
['alice', 'bob'],
'2026-01-15'
);
expect(mockAddComment).toHaveBeenCalledTimes(1); expect(mockAddComment).toHaveBeenCalledTimes(1);
const comment = mockAddComment.mock.calls[0][1]; const comment = mockAddComment.mock.calls[0][1];
@ -896,10 +874,7 @@ describe('SignoffManager', () => {
beforeEach(() => { beforeEach(() => {
mockAddComment = vi.fn().mockResolvedValue({}); mockAddComment = vi.fn().mockResolvedValue({});
manager = new TestableSignoffManager( manager = new TestableSignoffManager({ owner: 'test-org', repo: 'test-repo' }, { addComment: mockAddComment });
{ owner: 'test-org', repo: 'test-repo' },
{ addComment: mockAddComment }
);
}); });
it('should post deadline extension comment', async () => { it('should post deadline extension comment', async () => {
@ -978,7 +953,7 @@ describe('SignoffManager', () => {
const config = { const config = {
threshold_type: 'required_approvers', threshold_type: 'required_approvers',
required: ['alice', 'bob'], required: ['alice', 'bob'],
minimum_optional: 2 minimum_optional: 2,
}; };
expect(manager._formatThreshold(config)).toBe('Required: alice, bob + 2 optional'); expect(manager._formatThreshold(config)).toBe('Required: alice, bob + 2 optional');
}); });
@ -995,13 +970,9 @@ describe('SignoffManager', () => {
it('should throw when GitHub methods not implemented', async () => { it('should throw when GitHub methods not implemented', async () => {
const manager = new SignoffManager({ owner: 'test', repo: 'test' }); const manager = new SignoffManager({ owner: 'test', repo: 'test' });
await expect(manager._getIssue(1)).rejects.toThrow( await expect(manager._getIssue(1)).rejects.toThrow('_getIssue must be implemented by caller via GitHub MCP');
'_getIssue must be implemented by caller via GitHub MCP'
);
await expect(manager._addComment(1, 'test')).rejects.toThrow( await expect(manager._addComment(1, 'test')).rejects.toThrow('_addComment must be implemented by caller via GitHub MCP');
'_addComment must be implemented by caller via GitHub MCP'
);
}); });
it('should throw for unknown threshold type in calculateStatus', () => { it('should throw for unknown threshold type in calculateStatus', () => {

View File

@ -11,10 +11,7 @@
*/ */
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { import { SynthesisEngine, SYNTHESIS_PROMPTS } from '../../../src/modules/bmm/lib/crowdsource/synthesis-engine.js';
SynthesisEngine,
SYNTHESIS_PROMPTS
} from '../../../src/modules/bmm/lib/crowdsource/synthesis-engine.js';
describe('SynthesisEngine', () => { describe('SynthesisEngine', () => {
// ============ SYNTHESIS_PROMPTS Tests ============ // ============ SYNTHESIS_PROMPTS Tests ============
@ -115,8 +112,8 @@ describe('SynthesisEngine', () => {
feedbackType: 'suggestion', feedbackType: 'suggestion',
priority: 'high', priority: 'high',
submittedBy: 'alice', submittedBy: 'alice',
body: 'Need login flow description' body: 'Need login flow description',
} },
], ],
'fr-3': [ 'fr-3': [
{ {
@ -125,14 +122,14 @@ describe('SynthesisEngine', () => {
feedbackType: 'concern', feedbackType: 'concern',
priority: 'high', priority: 'high',
submittedBy: 'bob', submittedBy: 'bob',
body: 'Session timeout too long' body: 'Session timeout too long',
} },
] ],
}; };
const originalDocument = { const originalDocument = {
'user-stories': 'Current user story text', 'user-stories': 'Current user story text',
'fr-3': 'FR-3 original text' 'fr-3': 'FR-3 original text',
}; };
const analysis = await engine.analyzeFeedback(feedbackBySection, originalDocument); const analysis = await engine.analyzeFeedback(feedbackBySection, originalDocument);
@ -145,7 +142,7 @@ describe('SynthesisEngine', () => {
it('should collect conflicts from all sections', async () => { it('should collect conflicts from all sections', async () => {
const feedbackBySection = { const feedbackBySection = {
'security': [ security: [
{ {
id: 1, id: 1,
title: 'Short timeout', title: 'Short timeout',
@ -153,7 +150,7 @@ describe('SynthesisEngine', () => {
priority: 'high', priority: 'high',
submittedBy: 'security', submittedBy: 'security',
body: 'timeout should be 15 min', body: 'timeout should be 15 min',
suggestedChange: '15 minute timeout' suggestedChange: '15 minute timeout',
}, },
{ {
id: 2, id: 2,
@ -162,9 +159,9 @@ describe('SynthesisEngine', () => {
priority: 'medium', priority: 'medium',
submittedBy: 'ux', submittedBy: 'ux',
body: 'timeout should be 30 min', body: 'timeout should be 30 min',
suggestedChange: '30 minute timeout' suggestedChange: '30 minute timeout',
} },
] ],
}; };
const analysis = await engine.analyzeFeedback(feedbackBySection, {}); const analysis = await engine.analyzeFeedback(feedbackBySection, {});
@ -175,13 +172,11 @@ describe('SynthesisEngine', () => {
it('should generate summary statistics', async () => { it('should generate summary statistics', async () => {
const feedbackBySection = { const feedbackBySection = {
'section1': [ section1: [
{ id: 1, title: 'FB1', feedbackType: 'clarification', submittedBy: 'user1' }, { id: 1, title: 'FB1', feedbackType: 'clarification', submittedBy: 'user1' },
{ id: 2, title: 'FB2', feedbackType: 'concern', submittedBy: 'user2' } { id: 2, title: 'FB2', feedbackType: 'concern', submittedBy: 'user2' },
], ],
'section2': [ section2: [{ id: 3, title: 'FB3', feedbackType: 'suggestion', submittedBy: 'user3' }],
{ id: 3, title: 'FB3', feedbackType: 'suggestion', submittedBy: 'user3' }
]
}; };
const analysis = await engine.analyzeFeedback(feedbackBySection, {}); const analysis = await engine.analyzeFeedback(feedbackBySection, {});
@ -205,7 +200,7 @@ describe('SynthesisEngine', () => {
const feedbackList = [ const feedbackList = [
{ id: 1, feedbackType: 'clarification', title: 'Q1' }, { id: 1, feedbackType: 'clarification', title: 'Q1' },
{ id: 2, feedbackType: 'clarification', title: 'Q2' }, { id: 2, feedbackType: 'clarification', title: 'Q2' },
{ id: 3, feedbackType: 'concern', title: 'C1' } { id: 3, feedbackType: 'concern', title: 'C1' },
]; ];
const result = await engine._analyzeSection('test-section', feedbackList, ''); const result = await engine._analyzeSection('test-section', feedbackList, '');
@ -223,8 +218,8 @@ describe('SynthesisEngine', () => {
feedbackType: 'suggestion', feedbackType: 'suggestion',
priority: 'high', priority: 'high',
suggestedChange: 'Add input validation', suggestedChange: 'Add input validation',
submittedBy: 'alice' submittedBy: 'alice',
} },
]; ];
const result = await engine._analyzeSection('test-section', feedbackList, ''); const result = await engine._analyzeSection('test-section', feedbackList, '');
@ -251,20 +246,20 @@ describe('SynthesisEngine', () => {
id: 1, id: 1,
title: 'timeout should be shorter', title: 'timeout should be shorter',
body: 'Session timeout configuration', body: 'Session timeout configuration',
suggestedChange: 'Set to 15 minutes' suggestedChange: 'Set to 15 minutes',
}, },
{ {
id: 2, id: 2,
title: 'timeout should be longer', title: 'timeout should be longer',
body: 'Session timeout configuration', body: 'Session timeout configuration',
suggestedChange: 'Set to 30 minutes' suggestedChange: 'Set to 30 minutes',
} },
]; ];
const conflicts = engine._identifyConflicts(feedbackList); const conflicts = engine._identifyConflicts(feedbackList);
expect(conflicts.length).toBeGreaterThan(0); expect(conflicts.length).toBeGreaterThan(0);
const timeoutConflict = conflicts.find(c => c.topic === 'timeout'); const timeoutConflict = conflicts.find((c) => c.topic === 'timeout');
expect(timeoutConflict).toBeDefined(); expect(timeoutConflict).toBeDefined();
expect(timeoutConflict.feedbackIds).toContain(1); expect(timeoutConflict.feedbackIds).toContain(1);
expect(timeoutConflict.feedbackIds).toContain(2); expect(timeoutConflict.feedbackIds).toContain(2);
@ -276,22 +271,21 @@ describe('SynthesisEngine', () => {
id: 1, id: 1,
title: 'auth improvement', title: 'auth improvement',
body: 'Authentication flow', body: 'Authentication flow',
suggestedChange: 'Add OAuth' suggestedChange: 'Add OAuth',
}, },
{ {
id: 2, id: 2,
title: 'auth needed', title: 'auth needed',
body: 'Authentication required', body: 'Authentication required',
suggestedChange: 'Add OAuth' suggestedChange: 'Add OAuth',
} },
]; ];
const conflicts = engine._identifyConflicts(feedbackList); const conflicts = engine._identifyConflicts(feedbackList);
// Same suggestion = no conflict // Same suggestion = no conflict
const authConflict = conflicts.find(c => const authConflict = conflicts.find(
c.feedbackIds.includes(1) && c.feedbackIds.includes(2) && (c) => c.feedbackIds.includes(1) && c.feedbackIds.includes(2) && c.description.includes('Conflicting'),
c.description.includes('Conflicting')
); );
expect(authConflict).toBeUndefined(); expect(authConflict).toBeUndefined();
}); });
@ -302,8 +296,8 @@ describe('SynthesisEngine', () => {
id: 1, id: 1,
title: 'unique topic here', title: 'unique topic here',
body: 'Only one feedback on this', body: 'Only one feedback on this',
suggestedChange: 'Some change' suggestedChange: 'Some change',
} },
]; ];
const conflicts = engine._identifyConflicts(feedbackList); const conflicts = engine._identifyConflicts(feedbackList);
@ -315,15 +309,15 @@ describe('SynthesisEngine', () => {
{ {
id: 1, id: 1,
title: 'question about feature', title: 'question about feature',
body: 'What does this do?' body: 'What does this do?',
// No suggestedChange // No suggestedChange
}, },
{ {
id: 2, id: 2,
title: 'another question feature', title: 'another question feature',
body: 'How does this work?' body: 'How does this work?',
// No suggestedChange // No suggestedChange
} },
]; ];
// Should not throw, and no conflicts detected (no different suggestions) // Should not throw, and no conflicts detected (no different suggestions)
@ -345,12 +339,12 @@ describe('SynthesisEngine', () => {
const feedbackList = [ const feedbackList = [
{ id: 1, title: 'authentication needs work', feedbackType: 'concern' }, { id: 1, title: 'authentication needs work', feedbackType: 'concern' },
{ id: 2, title: 'authentication is unclear', feedbackType: 'clarification' }, { id: 2, title: 'authentication is unclear', feedbackType: 'clarification' },
{ id: 3, title: 'completely different topic', feedbackType: 'suggestion' } { id: 3, title: 'completely different topic', feedbackType: 'suggestion' },
]; ];
const themes = engine._identifyThemes(feedbackList); const themes = engine._identifyThemes(feedbackList);
const authTheme = themes.find(t => t.keyword === 'authentication'); const authTheme = themes.find((t) => t.keyword === 'authentication');
expect(authTheme).toBeDefined(); expect(authTheme).toBeDefined();
expect(authTheme.count).toBe(2); expect(authTheme.count).toBe(2);
expect(authTheme.feedbackIds).toContain(1); expect(authTheme.feedbackIds).toContain(1);
@ -360,12 +354,12 @@ describe('SynthesisEngine', () => {
it('should track feedback types for each theme', () => { it('should track feedback types for each theme', () => {
const feedbackList = [ const feedbackList = [
{ id: 1, title: 'security concern here', feedbackType: 'concern' }, { id: 1, title: 'security concern here', feedbackType: 'concern' },
{ id: 2, title: 'security suggestion', feedbackType: 'suggestion' } { id: 2, title: 'security suggestion', feedbackType: 'suggestion' },
]; ];
const themes = engine._identifyThemes(feedbackList); const themes = engine._identifyThemes(feedbackList);
const securityTheme = themes.find(t => t.keyword === 'security'); const securityTheme = themes.find((t) => t.keyword === 'security');
expect(securityTheme).toBeDefined(); expect(securityTheme).toBeDefined();
expect(securityTheme.types).toContain('concern'); expect(securityTheme.types).toContain('concern');
expect(securityTheme.types).toContain('suggestion'); expect(securityTheme.types).toContain('suggestion');
@ -376,7 +370,7 @@ describe('SynthesisEngine', () => {
{ id: 1, title: 'rare topic', feedbackType: 'concern' }, { id: 1, title: 'rare topic', feedbackType: 'concern' },
{ id: 2, title: 'common topic', feedbackType: 'concern' }, { id: 2, title: 'common topic', feedbackType: 'concern' },
{ id: 3, title: 'common topic again', feedbackType: 'suggestion' }, { id: 3, title: 'common topic again', feedbackType: 'suggestion' },
{ id: 4, title: 'common topic still', feedbackType: 'clarification' } { id: 4, title: 'common topic still', feedbackType: 'clarification' },
]; ];
const themes = engine._identifyThemes(feedbackList); const themes = engine._identifyThemes(feedbackList);
@ -393,7 +387,7 @@ describe('SynthesisEngine', () => {
const feedbackList = [ const feedbackList = [
{ id: 1, title: 'unique topic alpha', feedbackType: 'concern' }, { id: 1, title: 'unique topic alpha', feedbackType: 'concern' },
{ id: 2, title: 'unique topic beta', feedbackType: 'suggestion' }, { id: 2, title: 'unique topic beta', feedbackType: 'suggestion' },
{ id: 3, title: 'unique topic gamma', feedbackType: 'clarification' } { id: 3, title: 'unique topic gamma', feedbackType: 'clarification' },
]; ];
const themes = engine._identifyThemes(feedbackList); const themes = engine._identifyThemes(feedbackList);
@ -453,7 +447,7 @@ describe('SynthesisEngine', () => {
const keywords = engine._extractKeywords('User-authentication, session.timeout!'); const keywords = engine._extractKeywords('User-authentication, session.timeout!');
// Should normalize punctuation // Should normalize punctuation
const hasAuth = keywords.some(k => k.includes('auth')); const hasAuth = keywords.some((k) => k.includes('auth'));
expect(hasAuth).toBe(true); expect(hasAuth).toBe(true);
}); });
@ -464,7 +458,8 @@ describe('SynthesisEngine', () => {
}); });
it('should limit to 10 keywords', () => { it('should limit to 10 keywords', () => {
const longText = 'authentication authorization validation configuration implementation documentation optimization visualization serialization deserialization normalization denormalization extra words here'; const longText =
'authentication authorization validation configuration implementation documentation optimization visualization serialization deserialization normalization denormalization extra words here';
const keywords = engine._extractKeywords(longText); const keywords = engine._extractKeywords(longText);
@ -480,17 +475,13 @@ describe('SynthesisEngine', () => {
const conflict = { const conflict = {
section: 'FR-5', section: 'FR-5',
description: 'Conflicting views on session timeout' description: 'Conflicting views on session timeout',
}; };
const result = engine.generateConflictResolution( const result = engine.generateConflictResolution(conflict, 'Session timeout is 30 minutes.', [
conflict, { user: 'security', position: '15 minutes for security' },
'Session timeout is 30 minutes.', { user: 'ux', position: '30 minutes for usability' },
[ ]);
{ user: 'security', position: '15 minutes for security' },
{ user: 'ux', position: '30 minutes for usability' }
]
);
expect(result.prompt).toContain('FR-5'); expect(result.prompt).toContain('FR-5');
expect(result.prompt).toContain('Session timeout is 30 minutes'); expect(result.prompt).toContain('Session timeout is 30 minutes');
@ -507,16 +498,12 @@ describe('SynthesisEngine', () => {
const conflict = { const conflict = {
section: 'Story Breakdown', section: 'Story Breakdown',
description: 'Disagreement on story granularity' description: 'Disagreement on story granularity',
}; };
// Epic prompts only have grouping and storySplit, not resolution // Epic prompts only have grouping and storySplit, not resolution
expect(() => { expect(() => {
engine.generateConflictResolution( engine.generateConflictResolution(conflict, 'Epic contains 5 stories', []);
conflict,
'Epic contains 5 stories',
[]
);
}).toThrow(); }).toThrow();
}); });
@ -525,7 +512,7 @@ describe('SynthesisEngine', () => {
const conflict = { const conflict = {
section: 'New Section', section: 'New Section',
description: 'Need new content' description: 'Need new content',
}; };
const result = engine.generateConflictResolution(conflict, null, []); const result = engine.generateConflictResolution(conflict, null, []);
@ -548,20 +535,16 @@ describe('SynthesisEngine', () => {
{ {
feedbackType: 'suggestion', feedbackType: 'suggestion',
title: 'Add error handling', title: 'Add error handling',
suggestedChange: 'Include try-catch blocks' suggestedChange: 'Include try-catch blocks',
}, },
{ {
feedbackType: 'addition', feedbackType: 'addition',
title: 'Missing validation', title: 'Missing validation',
suggestedChange: 'Add input validation' suggestedChange: 'Add input validation',
} },
]; ];
const prompt = engine.generateMergePrompt( const prompt = engine.generateMergePrompt('FR-3', 'Original function implementation', approvedFeedback);
'FR-3',
'Original function implementation',
approvedFeedback
);
expect(prompt).toContain('FR-3'); expect(prompt).toContain('FR-3');
expect(prompt).toContain('Original function implementation'); expect(prompt).toContain('Original function implementation');
@ -575,9 +558,9 @@ describe('SynthesisEngine', () => {
const approvedFeedback = [ const approvedFeedback = [
{ {
feedbackType: 'concern', feedbackType: 'concern',
title: 'Security risk' title: 'Security risk',
// No suggestedChange // No suggestedChange
} },
]; ];
const prompt = engine.generateMergePrompt('Security', 'Current text', approvedFeedback); const prompt = engine.generateMergePrompt('Security', 'Current text', approvedFeedback);
@ -598,11 +581,9 @@ describe('SynthesisEngine', () => {
'Authentication epic for user login and session management', 'Authentication epic for user login and session management',
[ [
{ key: '2-1', title: 'Login Form' }, { key: '2-1', title: 'Login Form' },
{ key: '2-2', title: 'Session Management' } { key: '2-2', title: 'Session Management' },
], ],
[ [{ id: 1, title: 'Story 2-2 too large', suggestedChange: 'Split into 3 stories' }],
{ id: 1, title: 'Story 2-2 too large', suggestedChange: 'Split into 3 stories' }
]
); );
expect(prompt).toContain('epic:2'); expect(prompt).toContain('epic:2');
@ -633,11 +614,11 @@ describe('SynthesisEngine', () => {
it('should calculate total feedback count', () => { it('should calculate total feedback count', () => {
const analysis = { const analysis = {
sections: { sections: {
'section1': { feedbackCount: 3, byType: { concern: 2, suggestion: 1 } }, section1: { feedbackCount: 3, byType: { concern: 2, suggestion: 1 } },
'section2': { feedbackCount: 2, byType: { clarification: 2 } } section2: { feedbackCount: 2, byType: { clarification: 2 } },
}, },
conflicts: [], conflicts: [],
suggestedChanges: [] suggestedChanges: [],
}; };
const summary = engine._generateSummary(analysis); const summary = engine._generateSummary(analysis);
@ -648,12 +629,12 @@ describe('SynthesisEngine', () => {
it('should count sections with feedback', () => { it('should count sections with feedback', () => {
const analysis = { const analysis = {
sections: { sections: {
'section1': { feedbackCount: 1, byType: {} }, section1: { feedbackCount: 1, byType: {} },
'section2': { feedbackCount: 2, byType: {} }, section2: { feedbackCount: 2, byType: {} },
'section3': { feedbackCount: 1, byType: {} } section3: { feedbackCount: 1, byType: {} },
}, },
conflicts: [], conflicts: [],
suggestedChanges: [] suggestedChanges: [],
}; };
const summary = engine._generateSummary(analysis); const summary = engine._generateSummary(analysis);
@ -664,11 +645,11 @@ describe('SynthesisEngine', () => {
it('should aggregate feedback by type across sections', () => { it('should aggregate feedback by type across sections', () => {
const analysis = { const analysis = {
sections: { sections: {
'section1': { feedbackCount: 2, byType: { concern: 1, suggestion: 1 } }, section1: { feedbackCount: 2, byType: { concern: 1, suggestion: 1 } },
'section2': { feedbackCount: 2, byType: { concern: 1, clarification: 1 } } section2: { feedbackCount: 2, byType: { concern: 1, clarification: 1 } },
}, },
conflicts: [], conflicts: [],
suggestedChanges: [] suggestedChanges: [],
}; };
const summary = engine._generateSummary(analysis); const summary = engine._generateSummary(analysis);
@ -682,13 +663,13 @@ describe('SynthesisEngine', () => {
const analysisWithConflicts = { const analysisWithConflicts = {
sections: {}, sections: {},
conflicts: [{ section: 'test', description: 'conflict' }], conflicts: [{ section: 'test', description: 'conflict' }],
suggestedChanges: [] suggestedChanges: [],
}; };
const analysisWithoutConflicts = { const analysisWithoutConflicts = {
sections: {}, sections: {},
conflicts: [], conflicts: [],
suggestedChanges: [] suggestedChanges: [],
}; };
expect(engine._generateSummary(analysisWithConflicts).needsAttention).toBe(true); expect(engine._generateSummary(analysisWithConflicts).needsAttention).toBe(true);
@ -699,7 +680,7 @@ describe('SynthesisEngine', () => {
const analysis = { const analysis = {
sections: {}, sections: {},
conflicts: [{ id: 1 }, { id: 2 }], conflicts: [{ id: 1 }, { id: 2 }],
suggestedChanges: [{ id: 1 }, { id: 2 }, { id: 3 }] suggestedChanges: [{ id: 1 }, { id: 2 }, { id: 3 }],
}; };
const summary = engine._generateSummary(analysis); const summary = engine._generateSummary(analysis);
@ -723,7 +704,7 @@ describe('SynthesisEngine', () => {
{ feedbackType: 'concern' }, { feedbackType: 'concern' },
{ feedbackType: 'concern' }, { feedbackType: 'concern' },
{ feedbackType: 'suggestion' }, { feedbackType: 'suggestion' },
{ feedbackType: 'clarification' } { feedbackType: 'clarification' },
]; ];
const byType = engine._groupByType(feedbackList); const byType = engine._groupByType(feedbackList);
@ -755,11 +736,11 @@ describe('SynthesisEngine', () => {
sectionsWithFeedback: 2, sectionsWithFeedback: 2,
conflictCount: 1, conflictCount: 1,
changeCount: 3, changeCount: 3,
needsAttention: true needsAttention: true,
}, },
sections: { sections: {
'user-stories': { feedbackCount: 3, byType: { concern: 2, suggestion: 1 } }, 'user-stories': { feedbackCount: 3, byType: { concern: 2, suggestion: 1 } },
'fr-3': { feedbackCount: 2, byType: { clarification: 2 } } 'fr-3': { feedbackCount: 2, byType: { clarification: 2 } },
}, },
conflicts: [ conflicts: [
{ {
@ -767,10 +748,10 @@ describe('SynthesisEngine', () => {
description: 'Timeout conflict', description: 'Timeout conflict',
stakeholders: [ stakeholders: [
{ user: 'security', position: '15 min' }, { user: 'security', position: '15 min' },
{ user: 'ux', position: '30 min' } { user: 'ux', position: '30 min' },
] ],
} },
] ],
}; };
const output = engine.formatForDisplay(analysis); const output = engine.formatForDisplay(analysis);
@ -794,12 +775,12 @@ describe('SynthesisEngine', () => {
sectionsWithFeedback: 1, sectionsWithFeedback: 1,
conflictCount: 0, conflictCount: 0,
changeCount: 1, changeCount: 1,
needsAttention: false needsAttention: false,
}, },
sections: { sections: {
'test': { feedbackCount: 1, byType: { suggestion: 1 } } test: { feedbackCount: 1, byType: { suggestion: 1 } },
}, },
conflicts: [] conflicts: [],
}; };
const output = engine.formatForDisplay(analysis); const output = engine.formatForDisplay(analysis);

View File

@ -11,23 +11,14 @@
*/ */
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { import { EmailNotifier, EMAIL_TEMPLATES } from '../../../src/modules/bmm/lib/notifications/email-notifier.js';
EmailNotifier,
EMAIL_TEMPLATES
} from '../../../src/modules/bmm/lib/notifications/email-notifier.js';
describe('EmailNotifier', () => { describe('EmailNotifier', () => {
// ============ EMAIL_TEMPLATES Tests ============ // ============ EMAIL_TEMPLATES Tests ============
describe('EMAIL_TEMPLATES', () => { describe('EMAIL_TEMPLATES', () => {
it('should define all required event types', () => { it('should define all required event types', () => {
const expectedTypes = [ const expectedTypes = ['feedback_round_opened', 'signoff_requested', 'document_approved', 'document_blocked', 'reminder'];
'feedback_round_opened',
'signoff_requested',
'document_approved',
'document_blocked',
'reminder'
];
for (const type of expectedTypes) { for (const type of expectedTypes) {
expect(EMAIL_TEMPLATES[type]).toBeDefined(); expect(EMAIL_TEMPLATES[type]).toBeDefined();
@ -93,10 +84,10 @@ describe('EmailNotifier', () => {
provider: 'smtp', provider: 'smtp',
smtp: { smtp: {
host: 'smtp.example.com', host: 'smtp.example.com',
port: 587 port: 587,
}, },
fromAddress: 'noreply@example.com', fromAddress: 'noreply@example.com',
fromName: 'PRD System' fromName: 'PRD System',
}); });
expect(notifier.provider).toBe('smtp'); expect(notifier.provider).toBe('smtp');
@ -110,7 +101,7 @@ describe('EmailNotifier', () => {
const notifier = new EmailNotifier({ const notifier = new EmailNotifier({
provider: 'sendgrid', provider: 'sendgrid',
apiKey: 'SG.xxx', apiKey: 'SG.xxx',
fromAddress: 'noreply@example.com' fromAddress: 'noreply@example.com',
}); });
expect(notifier.provider).toBe('sendgrid'); expect(notifier.provider).toBe('sendgrid');
@ -120,7 +111,7 @@ describe('EmailNotifier', () => {
it('should use default values', () => { it('should use default values', () => {
const notifier = new EmailNotifier({ const notifier = new EmailNotifier({
smtp: { host: 'localhost' } smtp: { host: 'localhost' },
}); });
expect(notifier.provider).toBe('smtp'); expect(notifier.provider).toBe('smtp');
@ -138,9 +129,9 @@ describe('EmailNotifier', () => {
const notifier = new EmailNotifier({ const notifier = new EmailNotifier({
smtp: { host: 'localhost' }, smtp: { host: 'localhost' },
userEmails: { userEmails: {
'alice': 'alice@example.com', alice: 'alice@example.com',
'bob': 'bob@example.com' bob: 'bob@example.com',
} },
}); });
expect(notifier.userEmails['alice']).toBe('alice@example.com'); expect(notifier.userEmails['alice']).toBe('alice@example.com');
@ -153,7 +144,7 @@ describe('EmailNotifier', () => {
describe('isEnabled', () => { describe('isEnabled', () => {
it('should return true when SMTP configured', () => { it('should return true when SMTP configured', () => {
const notifier = new EmailNotifier({ const notifier = new EmailNotifier({
smtp: { host: 'localhost' } smtp: { host: 'localhost' },
}); });
expect(notifier.isEnabled()).toBe(true); expect(notifier.isEnabled()).toBe(true);
@ -161,7 +152,7 @@ describe('EmailNotifier', () => {
it('should return true when API key configured', () => { it('should return true when API key configured', () => {
const notifier = new EmailNotifier({ const notifier = new EmailNotifier({
apiKey: 'xxx' apiKey: 'xxx',
}); });
expect(notifier.isEnabled()).toBe(true); expect(notifier.isEnabled()).toBe(true);
@ -186,9 +177,9 @@ describe('EmailNotifier', () => {
smtp: { host: 'localhost', port: 587 }, smtp: { host: 'localhost', port: 587 },
fromAddress: 'noreply@example.com', fromAddress: 'noreply@example.com',
userEmails: { userEmails: {
'alice': 'alice@example.com', alice: 'alice@example.com',
'bob': 'bob@example.com' bob: 'bob@example.com',
} },
}); });
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
@ -204,9 +195,13 @@ describe('EmailNotifier', () => {
}); });
it('should return error for unknown event type', async () => { it('should return error for unknown event type', async () => {
const result = await notifier.send('unknown_event', {}, { const result = await notifier.send(
recipients: ['test@example.com'] 'unknown_event',
}); {},
{
recipients: ['test@example.com'],
},
);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain('Unknown notification event type'); expect(result.error).toContain('Unknown notification event type');
@ -215,7 +210,7 @@ describe('EmailNotifier', () => {
it('should return error when no recipients', async () => { it('should return error when no recipients', async () => {
const result = await notifier.send('feedback_round_opened', { const result = await notifier.send('feedback_round_opened', {
document_type: 'prd', document_type: 'prd',
document_key: 'test' document_key: 'test',
}); });
expect(result.success).toBe(false); expect(result.success).toBe(false);
@ -223,15 +218,19 @@ describe('EmailNotifier', () => {
}); });
it('should send email with direct recipients', async () => { it('should send email with direct recipients', async () => {
const result = await notifier.send('feedback_round_opened', { const result = await notifier.send(
document_type: 'prd', 'feedback_round_opened',
document_key: 'user-auth', {
version: 1, document_type: 'prd',
deadline: '2026-01-15', document_key: 'user-auth',
document_url: 'https://example.com/doc' version: 1,
}, { deadline: '2026-01-15',
recipients: ['direct@example.com'] document_url: 'https://example.com/doc',
}); },
{
recipients: ['direct@example.com'],
},
);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.channel).toBe('email'); expect(result.channel).toBe('email');
@ -241,7 +240,7 @@ describe('EmailNotifier', () => {
expect.stringContaining('[EMAIL]'), expect.stringContaining('[EMAIL]'),
expect.stringContaining('Feedback Requested'), expect.stringContaining('Feedback Requested'),
expect.any(String), expect.any(String),
expect.stringContaining('direct@example.com') expect.stringContaining('direct@example.com'),
); );
}); });
@ -252,7 +251,7 @@ describe('EmailNotifier', () => {
version: 1, version: 1,
deadline: '2026-01-15', deadline: '2026-01-15',
document_url: 'https://example.com/doc', document_url: 'https://example.com/doc',
users: ['alice', 'bob'] users: ['alice', 'bob'],
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -262,7 +261,7 @@ describe('EmailNotifier', () => {
expect.anything(), expect.anything(),
expect.anything(), expect.anything(),
expect.anything(), expect.anything(),
expect.stringContaining('alice@example.com') expect.stringContaining('alice@example.com'),
); );
}); });
@ -275,7 +274,7 @@ describe('EmailNotifier', () => {
approval_count: 3, approval_count: 3,
stakeholder_count: 3, stakeholder_count: 3,
document_url: 'https://example.com/doc', document_url: 'https://example.com/doc',
users: ['alice', 'unknown-user'] // unknown-user not in mapping users: ['alice', 'unknown-user'], // unknown-user not in mapping
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -283,21 +282,25 @@ describe('EmailNotifier', () => {
}); });
it('should render template with data', async () => { it('should render template with data', async () => {
await notifier.send('document_blocked', { await notifier.send(
document_type: 'prd', 'document_blocked',
document_key: 'payments', {
user: 'legal', document_type: 'prd',
reason: 'Compliance review needed', document_key: 'payments',
feedback_url: 'https://example.com/feedback/1' user: 'legal',
}, { reason: 'Compliance review needed',
recipients: ['test@example.com'] feedback_url: 'https://example.com/feedback/1',
}); },
{
recipients: ['test@example.com'],
},
);
expect(consoleSpy).toHaveBeenCalledWith( expect(consoleSpy).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
expect.stringContaining('[prd:payments]'), expect.stringContaining('[prd:payments]'),
expect.anything(), expect.anything(),
expect.anything() expect.anything(),
); );
}); });
}); });
@ -311,7 +314,7 @@ describe('EmailNotifier', () => {
beforeEach(() => { beforeEach(() => {
notifier = new EmailNotifier({ notifier = new EmailNotifier({
provider: 'smtp', provider: 'smtp',
smtp: { host: 'localhost' } smtp: { host: 'localhost' },
}); });
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
@ -327,11 +330,7 @@ describe('EmailNotifier', () => {
}); });
it('should send custom email', async () => { it('should send custom email', async () => {
const result = await notifier.sendCustom( const result = await notifier.sendCustom(['user1@example.com', 'user2@example.com'], 'Custom Subject', 'Custom body content');
['user1@example.com', 'user2@example.com'],
'Custom Subject',
'Custom body content'
);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.recipientCount).toBe(2); expect(result.recipientCount).toBe(2);
@ -340,17 +339,12 @@ describe('EmailNotifier', () => {
expect.anything(), expect.anything(),
expect.stringContaining('Custom Subject'), expect.stringContaining('Custom Subject'),
expect.anything(), expect.anything(),
expect.stringContaining('user1@example.com, user2@example.com') expect.stringContaining('user1@example.com, user2@example.com'),
); );
}); });
it('should handle HTML option', async () => { it('should handle HTML option', async () => {
const result = await notifier.sendCustom( const result = await notifier.sendCustom(['test@example.com'], 'HTML Email', '<h1>Hello</h1>', { html: true });
['test@example.com'],
'HTML Email',
'<h1>Hello</h1>',
{ html: true }
);
expect(result.success).toBe(true); expect(result.success).toBe(true);
}); });
@ -365,8 +359,8 @@ describe('EmailNotifier', () => {
notifier = new EmailNotifier({ notifier = new EmailNotifier({
smtp: { host: 'localhost' }, smtp: { host: 'localhost' },
userEmails: { userEmails: {
'existing': 'existing@example.com' existing: 'existing@example.com',
} },
}); });
}); });
@ -404,7 +398,7 @@ describe('EmailNotifier', () => {
const template = 'Hello {{name}}, your order is {{status}}'; const template = 'Hello {{name}}, your order is {{status}}';
const result = notifier._renderTemplate(template, { const result = notifier._renderTemplate(template, {
name: 'Alice', name: 'Alice',
status: 'complete' status: 'complete',
}); });
expect(result).toBe('Hello Alice, your order is complete'); expect(result).toBe('Hello Alice, your order is complete');
@ -413,7 +407,7 @@ describe('EmailNotifier', () => {
it('should keep placeholder when variable not found', () => { it('should keep placeholder when variable not found', () => {
const template = 'Document: {{document_key}}, Version: {{version}}'; const template = 'Document: {{document_key}}, Version: {{version}}';
const result = notifier._renderTemplate(template, { const result = notifier._renderTemplate(template, {
document_key: 'test' document_key: 'test',
}); });
expect(result).toBe('Document: test, Version: {{version}}'); expect(result).toBe('Document: test, Version: {{version}}');
@ -423,7 +417,7 @@ describe('EmailNotifier', () => {
const template = '<div class="title">{{title}}</div><p>{{content}}</p>'; const template = '<div class="title">{{title}}</div><p>{{content}}</p>';
const result = notifier._renderTemplate(template, { const result = notifier._renderTemplate(template, {
title: 'Welcome', title: 'Welcome',
content: 'This is the body' content: 'This is the body',
}); });
expect(result).toBe('<div class="title">Welcome</div><p>This is the body</p>'); expect(result).toBe('<div class="title">Welcome</div><p>This is the body</p>');
@ -442,55 +436,40 @@ describe('EmailNotifier', () => {
it('should use SMTP provider', async () => { it('should use SMTP provider', async () => {
const notifier = new EmailNotifier({ const notifier = new EmailNotifier({
provider: 'smtp', provider: 'smtp',
smtp: { host: 'smtp.example.com' } smtp: { host: 'smtp.example.com' },
}); });
await notifier.sendCustom(['test@example.com'], 'Test', 'Body'); await notifier.sendCustom(['test@example.com'], 'Test', 'Body');
expect(consoleSpy).toHaveBeenCalledWith( expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('SMTP'), expect.anything(), expect.anything(), expect.anything());
expect.stringContaining('SMTP'),
expect.anything(),
expect.anything(),
expect.anything()
);
}); });
it('should use SendGrid provider', async () => { it('should use SendGrid provider', async () => {
const notifier = new EmailNotifier({ const notifier = new EmailNotifier({
provider: 'sendgrid', provider: 'sendgrid',
apiKey: 'SG.xxx' apiKey: 'SG.xxx',
}); });
await notifier.sendCustom(['test@example.com'], 'Test', 'Body'); await notifier.sendCustom(['test@example.com'], 'Test', 'Body');
expect(consoleSpy).toHaveBeenCalledWith( expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('SendGrid'), expect.anything(), expect.anything(), expect.anything());
expect.stringContaining('SendGrid'),
expect.anything(),
expect.anything(),
expect.anything()
);
}); });
it('should use SES provider', async () => { it('should use SES provider', async () => {
const notifier = new EmailNotifier({ const notifier = new EmailNotifier({
provider: 'ses', provider: 'ses',
apiKey: 'aws-key' apiKey: 'aws-key',
}); });
await notifier.sendCustom(['test@example.com'], 'Test', 'Body'); await notifier.sendCustom(['test@example.com'], 'Test', 'Body');
expect(consoleSpy).toHaveBeenCalledWith( expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('SES'), expect.anything(), expect.anything(), expect.anything());
expect.stringContaining('SES'),
expect.anything(),
expect.anything(),
expect.anything()
);
}); });
it('should throw for unknown provider', async () => { it('should throw for unknown provider', async () => {
const notifier = new EmailNotifier({ const notifier = new EmailNotifier({
provider: 'unknown-provider', provider: 'unknown-provider',
apiKey: 'xxx' apiKey: 'xxx',
}); });
const result = await notifier.sendCustom(['test@example.com'], 'Test', 'Body'); const result = await notifier.sendCustom(['test@example.com'], 'Test', 'Body');
@ -513,10 +492,10 @@ describe('EmailNotifier', () => {
fromAddress: 'prd-bot@company.com', fromAddress: 'prd-bot@company.com',
fromName: 'PRD System', fromName: 'PRD System',
userEmails: { userEmails: {
'po': 'po@company.com', po: 'po@company.com',
'tech-lead': 'tech@company.com', 'tech-lead': 'tech@company.com',
'security': 'security@company.com' security: 'security@company.com',
} },
}); });
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
@ -530,7 +509,7 @@ describe('EmailNotifier', () => {
deadline: '2026-01-15', deadline: '2026-01-15',
document_url: 'https://example.com/doc', document_url: 'https://example.com/doc',
unsubscribe_url: 'https://example.com/unsubscribe', unsubscribe_url: 'https://example.com/unsubscribe',
users: ['po', 'tech-lead', 'security'] users: ['po', 'tech-lead', 'security'],
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -538,16 +517,20 @@ describe('EmailNotifier', () => {
}); });
it('should send document_blocked with blocking details', async () => { it('should send document_blocked with blocking details', async () => {
const result = await notifier.send('document_blocked', { const result = await notifier.send(
document_type: 'prd', 'document_blocked',
document_key: 'payments-v2', {
user: 'security', document_type: 'prd',
reason: 'PCI DSS compliance verification required before approval', document_key: 'payments-v2',
feedback_url: 'https://example.com/issues/42', user: 'security',
unsubscribe_url: 'https://example.com/unsubscribe' reason: 'PCI DSS compliance verification required before approval',
}, { feedback_url: 'https://example.com/issues/42',
recipients: ['po@company.com'] unsubscribe_url: 'https://example.com/unsubscribe',
}); },
{
recipients: ['po@company.com'],
},
);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -556,7 +539,7 @@ describe('EmailNotifier', () => {
expect.anything(), expect.anything(),
expect.stringContaining('[prd:payments-v2]'), expect.stringContaining('[prd:payments-v2]'),
expect.anything(), expect.anything(),
expect.anything() expect.anything(),
); );
}); });
@ -569,7 +552,7 @@ describe('EmailNotifier', () => {
time_remaining: '24 hours', time_remaining: '24 hours',
document_url: 'https://example.com/doc', document_url: 'https://example.com/doc',
unsubscribe_url: 'https://example.com/unsubscribe', unsubscribe_url: 'https://example.com/unsubscribe',
users: ['tech-lead'] users: ['tech-lead'],
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);

View File

@ -11,10 +11,7 @@
*/ */
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { import { GitHubNotifier, NOTIFICATION_TEMPLATES } from '../../../src/modules/bmm/lib/notifications/github-notifier.js';
GitHubNotifier,
NOTIFICATION_TEMPLATES
} from '../../../src/modules/bmm/lib/notifications/github-notifier.js';
describe('GitHubNotifier', () => { describe('GitHubNotifier', () => {
// ============ NOTIFICATION_TEMPLATES Tests ============ // ============ NOTIFICATION_TEMPLATES Tests ============
@ -30,7 +27,7 @@ describe('GitHubNotifier', () => {
'document_approved', 'document_approved',
'document_blocked', 'document_blocked',
'reminder', 'reminder',
'deadline_extended' 'deadline_extended',
]; ];
for (const type of expectedTypes) { for (const type of expectedTypes) {
@ -65,7 +62,7 @@ describe('GitHubNotifier', () => {
const notifier = new GitHubNotifier({ const notifier = new GitHubNotifier({
owner: 'test-org', owner: 'test-org',
repo: 'test-repo', repo: 'test-repo',
github: mockGithub github: mockGithub,
}); });
expect(notifier.owner).toBe('test-org'); expect(notifier.owner).toBe('test-org');
@ -83,38 +80,40 @@ describe('GitHubNotifier', () => {
beforeEach(() => { beforeEach(() => {
mockGithub = { mockGithub = {
addIssueComment: vi.fn().mockResolvedValue({ id: 123 }), addIssueComment: vi.fn().mockResolvedValue({ id: 123 }),
createIssue: vi.fn().mockResolvedValue({ number: 456 }) createIssue: vi.fn().mockResolvedValue({ number: 456 }),
}; };
notifier = new GitHubNotifier({ notifier = new GitHubNotifier({
owner: 'test-org', owner: 'test-org',
repo: 'test-repo', repo: 'test-repo',
github: mockGithub github: mockGithub,
}); });
}); });
it('should throw for unknown event type', async () => { it('should throw for unknown event type', async () => {
await expect( await expect(notifier.send('unknown_event', {})).rejects.toThrow('Unknown notification event type: unknown_event');
notifier.send('unknown_event', {})
).rejects.toThrow('Unknown notification event type: unknown_event');
}); });
it('should post comment when issueNumber provided', async () => { it('should post comment when issueNumber provided', async () => {
const result = await notifier.send('feedback_round_opened', { const result = await notifier.send(
mentions: '@alice @bob', 'feedback_round_opened',
document_type: 'prd', {
document_key: 'user-auth', mentions: '@alice @bob',
version: 1, document_type: 'prd',
deadline: '2026-01-15', document_key: 'user-auth',
document_url: 'https://example.com/doc' version: 1,
}, { issueNumber: 100 }); deadline: '2026-01-15',
document_url: 'https://example.com/doc',
},
{ issueNumber: 100 },
);
expect(mockGithub.addIssueComment).toHaveBeenCalledTimes(1); expect(mockGithub.addIssueComment).toHaveBeenCalledTimes(1);
expect(mockGithub.addIssueComment).toHaveBeenCalledWith({ expect(mockGithub.addIssueComment).toHaveBeenCalledWith({
owner: 'test-org', owner: 'test-org',
repo: 'test-repo', repo: 'test-repo',
issue_number: 100, issue_number: 100,
body: expect.stringContaining('Feedback Round Open') body: expect.stringContaining('Feedback Round Open'),
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -124,15 +123,19 @@ describe('GitHubNotifier', () => {
}); });
it('should create issue when createIssue option provided', async () => { it('should create issue when createIssue option provided', async () => {
const result = await notifier.send('document_approved', { const result = await notifier.send(
document_type: 'prd', 'document_approved',
document_key: 'user-auth', {
title: 'User Authentication', document_type: 'prd',
version: 2, document_key: 'user-auth',
approval_count: 5, title: 'User Authentication',
stakeholder_count: 5, version: 2,
document_url: 'https://example.com/doc' approval_count: 5,
}, { createIssue: true, labels: ['notification', 'approved'] }); stakeholder_count: 5,
document_url: 'https://example.com/doc',
},
{ createIssue: true, labels: ['notification', 'approved'] },
);
expect(mockGithub.createIssue).toHaveBeenCalledTimes(1); expect(mockGithub.createIssue).toHaveBeenCalledTimes(1);
expect(mockGithub.createIssue).toHaveBeenCalledWith({ expect(mockGithub.createIssue).toHaveBeenCalledWith({
@ -140,7 +143,7 @@ describe('GitHubNotifier', () => {
repo: 'test-repo', repo: 'test-repo',
title: expect.stringContaining('Document Approved'), title: expect.stringContaining('Document Approved'),
body: expect.stringContaining('User Authentication'), body: expect.stringContaining('User Authentication'),
labels: ['notification', 'approved'] labels: ['notification', 'approved'],
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -158,7 +161,7 @@ describe('GitHubNotifier', () => {
summary: 'Security issue found', summary: 'Security issue found',
feedback_issue: 42, feedback_issue: 42,
feedback_url: 'https://example.com/feedback/42', feedback_url: 'https://example.com/feedback/42',
review_issue: 100 review_issue: 100,
}); });
expect(mockGithub.addIssueComment).toHaveBeenCalledTimes(1); expect(mockGithub.addIssueComment).toHaveBeenCalledTimes(1);
@ -171,7 +174,7 @@ describe('GitHubNotifier', () => {
document_key: 'test', document_key: 'test',
old_deadline: '2026-01-10', old_deadline: '2026-01-10',
new_deadline: '2026-01-20', new_deadline: '2026-01-20',
document_url: 'https://example.com/doc' document_url: 'https://example.com/doc',
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -182,15 +185,19 @@ describe('GitHubNotifier', () => {
it('should handle GitHub API error', async () => { it('should handle GitHub API error', async () => {
mockGithub.addIssueComment.mockRejectedValue(new Error('API rate limit')); mockGithub.addIssueComment.mockRejectedValue(new Error('API rate limit'));
const result = await notifier.send('reminder', { const result = await notifier.send(
mentions: '@alice', 'reminder',
document_type: 'prd', {
document_key: 'test', mentions: '@alice',
action_needed: 'feedback', document_type: 'prd',
deadline: '2026-01-15', document_key: 'test',
time_remaining: '2 days', action_needed: 'feedback',
document_url: 'https://example.com/doc' deadline: '2026-01-15',
}, { issueNumber: 100 }); time_remaining: '2 days',
document_url: 'https://example.com/doc',
},
{ issueNumber: 100 },
);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toBe('API rate limit'); expect(result.error).toBe('API rate limit');
@ -205,13 +212,13 @@ describe('GitHubNotifier', () => {
beforeEach(() => { beforeEach(() => {
mockGithub = { mockGithub = {
addIssueComment: vi.fn().mockResolvedValue({ id: 123 }) addIssueComment: vi.fn().mockResolvedValue({ id: 123 }),
}; };
notifier = new GitHubNotifier({ notifier = new GitHubNotifier({
owner: 'test-org', owner: 'test-org',
repo: 'test-repo', repo: 'test-repo',
github: mockGithub github: mockGithub,
}); });
}); });
@ -222,7 +229,7 @@ describe('GitHubNotifier', () => {
action_needed: 'sign-off', action_needed: 'sign-off',
deadline: '2026-01-15', deadline: '2026-01-15',
time_remaining: '24 hours', time_remaining: '24 hours',
document_url: 'https://example.com/doc' document_url: 'https://example.com/doc',
}); });
expect(mockGithub.addIssueComment).toHaveBeenCalledTimes(1); expect(mockGithub.addIssueComment).toHaveBeenCalledTimes(1);
@ -242,22 +249,18 @@ describe('GitHubNotifier', () => {
beforeEach(() => { beforeEach(() => {
mockGithub = { mockGithub = {
addIssueComment: vi.fn().mockResolvedValue({ id: 123 }) addIssueComment: vi.fn().mockResolvedValue({ id: 123 }),
}; };
notifier = new GitHubNotifier({ notifier = new GitHubNotifier({
owner: 'test-org', owner: 'test-org',
repo: 'test-repo', repo: 'test-repo',
github: mockGithub github: mockGithub,
}); });
}); });
it('should format mentions and post message', async () => { it('should format mentions and post message', async () => {
await notifier.notifyStakeholders( await notifier.notifyStakeholders(['alice', 'bob', 'charlie'], 'Please review the updated document', 100);
['alice', 'bob', 'charlie'],
'Please review the updated document',
100
);
expect(mockGithub.addIssueComment).toHaveBeenCalledTimes(1); expect(mockGithub.addIssueComment).toHaveBeenCalledTimes(1);
const body = mockGithub.addIssueComment.mock.calls[0][0].body; const body = mockGithub.addIssueComment.mock.calls[0][0].body;
@ -276,7 +279,7 @@ describe('GitHubNotifier', () => {
notifier = new GitHubNotifier({ notifier = new GitHubNotifier({
owner: 'test', owner: 'test',
repo: 'test', repo: 'test',
github: {} github: {},
}); });
}); });
@ -284,7 +287,7 @@ describe('GitHubNotifier', () => {
const template = 'Hello {{name}}, welcome to {{place}}!'; const template = 'Hello {{name}}, welcome to {{place}}!';
const result = notifier._renderTemplate(template, { const result = notifier._renderTemplate(template, {
name: 'Alice', name: 'Alice',
place: 'Wonderland' place: 'Wonderland',
}); });
expect(result).toBe('Hello Alice, welcome to Wonderland!'); expect(result).toBe('Hello Alice, welcome to Wonderland!');
@ -323,8 +326,8 @@ describe('GitHubNotifier', () => {
const result = notifier._renderTemplate(template, { const result = notifier._renderTemplate(template, {
items: [ items: [
{ name: 'a', value: 1 }, { name: 'a', value: 1 },
{ name: 'b', value: 2 } { name: 'b', value: 2 },
] ],
}); });
expect(result).toBe('Items: a=1; b=2;'); expect(result).toBe('Items: a=1; b=2;');
@ -333,7 +336,7 @@ describe('GitHubNotifier', () => {
it('should handle each blocks with primitives', () => { it('should handle each blocks with primitives', () => {
const template = 'List:{{#each items}} {{this}}{{/each}}'; const template = 'List:{{#each items}} {{this}}{{/each}}';
const result = notifier._renderTemplate(template, { const result = notifier._renderTemplate(template, {
items: ['apple', 'banana', 'cherry'] items: ['apple', 'banana', 'cherry'],
}); });
expect(result).toBe('List: apple banana cherry'); expect(result).toBe('List: apple banana cherry');
@ -342,7 +345,7 @@ describe('GitHubNotifier', () => {
it('should handle each with @index', () => { it('should handle each with @index', () => {
const template = '{{#each items}}{{@index}}.{{this}} {{/each}}'; const template = '{{#each items}}{{@index}}.{{this}} {{/each}}';
const result = notifier._renderTemplate(template, { const result = notifier._renderTemplate(template, {
items: ['a', 'b', 'c'] items: ['a', 'b', 'c'],
}); });
expect(result).toBe('0.a 1.b 2.c '); expect(result).toBe('0.a 1.b 2.c ');
@ -351,7 +354,7 @@ describe('GitHubNotifier', () => {
it('should handle each with non-array', () => { it('should handle each with non-array', () => {
const template = 'Items:{{#each items}} item{{/each}}'; const template = 'Items:{{#each items}} item{{/each}}';
const result = notifier._renderTemplate(template, { const result = notifier._renderTemplate(template, {
items: 'not an array' items: 'not an array',
}); });
expect(result).toBe('Items:'); expect(result).toBe('Items:');
@ -381,8 +384,8 @@ Items:
note: 'Great work!', note: 'Great work!',
items: [ items: [
{ name: 'Item 1', value: 'Value 1' }, { name: 'Item 1', value: 'Value 1' },
{ name: 'Item 2', value: 'Value 2' } { name: 'Item 2', value: 'Value 2' },
] ],
}); });
expect(result).toContain('## Test'); expect(result).toContain('## Test');
@ -403,25 +406,29 @@ Items:
beforeEach(() => { beforeEach(() => {
mockGithub = { mockGithub = {
addIssueComment: vi.fn().mockResolvedValue({ id: 123 }), addIssueComment: vi.fn().mockResolvedValue({ id: 123 }),
createIssue: vi.fn().mockResolvedValue({ number: 456 }) createIssue: vi.fn().mockResolvedValue({ number: 456 }),
}; };
notifier = new GitHubNotifier({ notifier = new GitHubNotifier({
owner: 'test-org', owner: 'test-org',
repo: 'test-repo', repo: 'test-repo',
github: mockGithub github: mockGithub,
}); });
}); });
it('should send feedback_round_opened notification', async () => { it('should send feedback_round_opened notification', async () => {
await notifier.send('feedback_round_opened', { await notifier.send(
mentions: '@alice @bob @charlie', 'feedback_round_opened',
document_type: 'prd', {
document_key: 'user-auth', mentions: '@alice @bob @charlie',
version: 1, document_type: 'prd',
deadline: '2026-01-15', document_key: 'user-auth',
document_url: 'https://github.com/org/repo/docs/prd/user-auth.md' version: 1,
}, { issueNumber: 100 }); 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; const body = mockGithub.addIssueComment.mock.calls[0][0].body;
@ -433,18 +440,22 @@ Items:
}); });
it('should send signoff_received notification with note', async () => { it('should send signoff_received notification with note', async () => {
await notifier.send('signoff_received', { await notifier.send(
emoji: '✅📝', 'signoff_received',
user: 'security-lead', {
decision: 'Approved with Note', emoji: '✅📝',
document_type: 'prd', user: 'security-lead',
document_key: 'payments', decision: 'Approved with Note',
progress_current: 3, document_type: 'prd',
progress_total: 5, document_key: 'payments',
note: 'Please update PCI compliance section before implementation', progress_current: 3,
review_issue: 200, progress_total: 5,
review_url: 'https://github.com/org/repo/issues/200' note: 'Please update PCI compliance section before implementation',
}, { issueNumber: 200 }); review_issue: 200,
review_url: 'https://github.com/org/repo/issues/200',
},
{ issueNumber: 200 },
);
const body = mockGithub.addIssueComment.mock.calls[0][0].body; const body = mockGithub.addIssueComment.mock.calls[0][0].body;
@ -456,14 +467,18 @@ Items:
}); });
it('should send document_blocked notification', async () => { it('should send document_blocked notification', async () => {
await notifier.send('document_blocked', { await notifier.send(
document_type: 'prd', 'document_blocked',
document_key: 'data-migration', {
user: 'legal', document_type: 'prd',
reason: 'GDPR compliance review required before proceeding', document_key: 'data-migration',
feedback_issue: 42, user: 'legal',
feedback_url: 'https://github.com/org/repo/issues/42' reason: 'GDPR compliance review required before proceeding',
}, { issueNumber: 100 }); feedback_issue: 42,
feedback_url: 'https://github.com/org/repo/issues/42',
},
{ issueNumber: 100 },
);
const body = mockGithub.addIssueComment.mock.calls[0][0].body; const body = mockGithub.addIssueComment.mock.calls[0][0].body;

View File

@ -13,26 +13,26 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { import {
NotificationService, NotificationService,
NOTIFICATION_EVENTS, NOTIFICATION_EVENTS,
PRIORITY_BEHAVIOR PRIORITY_BEHAVIOR,
} from '../../../src/modules/bmm/lib/notifications/notification-service.js'; } from '../../../src/modules/bmm/lib/notifications/notification-service.js';
// Mock the notifier modules // Mock the notifier modules
vi.mock('../../../src/modules/bmm/lib/notifications/github-notifier.js', () => ({ vi.mock('../../../src/modules/bmm/lib/notifications/github-notifier.js', () => ({
GitHubNotifier: vi.fn().mockImplementation(() => ({ GitHubNotifier: vi.fn().mockImplementation(() => ({
send: vi.fn().mockResolvedValue({ success: true, channel: 'github' }) send: vi.fn().mockResolvedValue({ success: true, channel: 'github' }),
})) })),
})); }));
vi.mock('../../../src/modules/bmm/lib/notifications/slack-notifier.js', () => ({ vi.mock('../../../src/modules/bmm/lib/notifications/slack-notifier.js', () => ({
SlackNotifier: vi.fn().mockImplementation(() => ({ SlackNotifier: vi.fn().mockImplementation(() => ({
send: vi.fn().mockResolvedValue({ success: true, channel: 'slack' }) send: vi.fn().mockResolvedValue({ success: true, channel: 'slack' }),
})) })),
})); }));
vi.mock('../../../src/modules/bmm/lib/notifications/email-notifier.js', () => ({ vi.mock('../../../src/modules/bmm/lib/notifications/email-notifier.js', () => ({
EmailNotifier: vi.fn().mockImplementation(() => ({ EmailNotifier: vi.fn().mockImplementation(() => ({
send: vi.fn().mockResolvedValue({ success: true, channel: 'email' }) send: vi.fn().mockResolvedValue({ success: true, channel: 'email' }),
})) })),
})); }));
describe('NotificationService', () => { describe('NotificationService', () => {
@ -49,7 +49,7 @@ describe('NotificationService', () => {
'document_approved', 'document_approved',
'document_blocked', 'document_blocked',
'reminder', 'reminder',
'deadline_extended' 'deadline_extended',
]; ];
for (const event of expectedEvents) { for (const event of expectedEvents) {
@ -111,7 +111,7 @@ describe('NotificationService', () => {
describe('constructor', () => { describe('constructor', () => {
it('should always initialize GitHub channel', () => { it('should always initialize GitHub channel', () => {
const service = new NotificationService({ const service = new NotificationService({
github: { owner: 'test', repo: 'test' } github: { owner: 'test', repo: 'test' },
}); });
expect(service.channels.github).toBeDefined(); expect(service.channels.github).toBeDefined();
@ -123,8 +123,8 @@ describe('NotificationService', () => {
github: { owner: 'test', repo: 'test' }, github: { owner: 'test', repo: 'test' },
slack: { slack: {
enabled: true, enabled: true,
webhookUrl: 'https://hooks.slack.com/xxx' webhookUrl: 'https://hooks.slack.com/xxx',
} },
}); });
expect(service.channels.slack).toBeDefined(); expect(service.channels.slack).toBeDefined();
@ -134,7 +134,7 @@ describe('NotificationService', () => {
it('should not initialize Slack without webhook', () => { it('should not initialize Slack without webhook', () => {
const service = new NotificationService({ const service = new NotificationService({
github: { owner: 'test', repo: 'test' }, github: { owner: 'test', repo: 'test' },
slack: { enabled: true } // No webhookUrl slack: { enabled: true }, // No webhookUrl
}); });
expect(service.channels.slack).toBeUndefined(); expect(service.channels.slack).toBeUndefined();
@ -146,8 +146,8 @@ describe('NotificationService', () => {
github: { owner: 'test', repo: 'test' }, github: { owner: 'test', repo: 'test' },
email: { email: {
enabled: true, enabled: true,
smtp: { host: 'localhost' } smtp: { host: 'localhost' },
} },
}); });
expect(service.channels.email).toBeDefined(); expect(service.channels.email).toBeDefined();
@ -159,8 +159,8 @@ describe('NotificationService', () => {
github: { owner: 'test', repo: 'test' }, github: { owner: 'test', repo: 'test' },
email: { email: {
enabled: true, enabled: true,
apiKey: 'SG.xxx' apiKey: 'SG.xxx',
} },
}); });
expect(service.channels.email).toBeDefined(); expect(service.channels.email).toBeDefined();
@ -169,7 +169,7 @@ describe('NotificationService', () => {
it('should not initialize Email without config', () => { it('should not initialize Email without config', () => {
const service = new NotificationService({ const service = new NotificationService({
github: { owner: 'test', repo: 'test' }, github: { owner: 'test', repo: 'test' },
email: { enabled: true } // No smtp or apiKey email: { enabled: true }, // No smtp or apiKey
}); });
expect(service.channels.email).toBeUndefined(); expect(service.channels.email).toBeUndefined();
@ -181,7 +181,7 @@ describe('NotificationService', () => {
describe('getAvailableChannels', () => { describe('getAvailableChannels', () => {
it('should return only GitHub when minimal config', () => { it('should return only GitHub when minimal config', () => {
const service = new NotificationService({ const service = new NotificationService({
github: { owner: 'test', repo: 'test' } github: { owner: 'test', repo: 'test' },
}); });
expect(service.getAvailableChannels()).toEqual(['github']); expect(service.getAvailableChannels()).toEqual(['github']);
@ -191,7 +191,7 @@ describe('NotificationService', () => {
const service = new NotificationService({ const service = new NotificationService({
github: { owner: 'test', repo: 'test' }, github: { owner: 'test', repo: 'test' },
slack: { enabled: true, webhookUrl: 'https://xxx' }, slack: { enabled: true, webhookUrl: 'https://xxx' },
email: { enabled: true, smtp: { host: 'localhost' } } email: { enabled: true, smtp: { host: 'localhost' } },
}); });
const channels = service.getAvailableChannels(); const channels = service.getAvailableChannels();
@ -217,7 +217,7 @@ describe('NotificationService', () => {
service = new NotificationService({ service = new NotificationService({
github: { owner: 'test', repo: 'test' }, github: { owner: 'test', repo: 'test' },
slack: { enabled: true, webhookUrl: 'https://xxx' }, slack: { enabled: true, webhookUrl: 'https://xxx' },
email: { enabled: true, smtp: { host: 'localhost' } } email: { enabled: true, smtp: { host: 'localhost' } },
}); });
service.channels.github.send = mockGithubSend; service.channels.github.send = mockGithubSend;
@ -226,15 +226,13 @@ describe('NotificationService', () => {
}); });
it('should throw for unknown event type', async () => { it('should throw for unknown event type', async () => {
await expect( await expect(service.notify('unknown_event', {})).rejects.toThrow('Unknown notification event type: unknown_event');
service.notify('unknown_event', {})
).rejects.toThrow('Unknown notification event type: unknown_event');
}); });
it('should send to default channels for event', async () => { it('should send to default channels for event', async () => {
await service.notify('feedback_round_opened', { await service.notify('feedback_round_opened', {
document_type: 'prd', document_type: 'prd',
document_key: 'test' document_key: 'test',
}); });
expect(mockGithubSend).toHaveBeenCalled(); expect(mockGithubSend).toHaveBeenCalled();
@ -245,7 +243,7 @@ describe('NotificationService', () => {
it('should filter to available channels only', async () => { it('should filter to available channels only', async () => {
// Service with only GitHub // Service with only GitHub
const minimalService = new NotificationService({ const minimalService = new NotificationService({
github: { owner: 'test', repo: 'test' } github: { owner: 'test', repo: 'test' },
}); });
minimalService.channels.github.send = mockGithubSend; minimalService.channels.github.send = mockGithubSend;
@ -257,10 +255,14 @@ describe('NotificationService', () => {
}); });
it('should always include GitHub as baseline', async () => { it('should always include GitHub as baseline', async () => {
await service.notify('feedback_submitted', { await service.notify(
document_type: 'prd', 'feedback_submitted',
document_key: 'test' {
}, { channels: ['slack'] }); // Explicitly only slack document_type: 'prd',
document_key: 'test',
},
{ channels: ['slack'] },
); // Explicitly only slack
// GitHub should still be included // GitHub should still be included
expect(mockGithubSend).toHaveBeenCalled(); expect(mockGithubSend).toHaveBeenCalled();
@ -272,7 +274,7 @@ describe('NotificationService', () => {
document_type: 'prd', document_type: 'prd',
document_key: 'test', document_key: 'test',
user: 'security', user: 'security',
reason: 'Blocked' reason: 'Blocked',
}); });
// document_blocked is urgent, should use all available channels // document_blocked is urgent, should use all available channels
@ -282,10 +284,14 @@ describe('NotificationService', () => {
}); });
it('should respect custom channels option', async () => { it('should respect custom channels option', async () => {
await service.notify('deadline_extended', { await service.notify(
document_type: 'prd', 'deadline_extended',
document_key: 'test' {
}, { channels: ['github', 'slack'] }); document_type: 'prd',
document_key: 'test',
},
{ channels: ['github', 'slack'] },
);
expect(mockGithubSend).toHaveBeenCalled(); expect(mockGithubSend).toHaveBeenCalled();
expect(mockSlackSend).toHaveBeenCalled(); expect(mockSlackSend).toHaveBeenCalled();
@ -295,7 +301,7 @@ describe('NotificationService', () => {
it('should aggregate results from all channels', async () => { it('should aggregate results from all channels', async () => {
const result = await service.notify('signoff_requested', { const result = await service.notify('signoff_requested', {
document_type: 'prd', document_type: 'prd',
document_key: 'test' document_key: 'test',
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -334,7 +340,7 @@ describe('NotificationService', () => {
beforeEach(() => { beforeEach(() => {
service = new NotificationService({ service = new NotificationService({
github: { owner: 'test', repo: 'test' } github: { owner: 'test', repo: 'test' },
}); });
notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true }); notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true });
@ -343,15 +349,18 @@ describe('NotificationService', () => {
it('should format users as mentions', async () => { it('should format users as mentions', async () => {
await service.sendReminder('prd', 'user-auth', ['alice', 'bob'], { await service.sendReminder('prd', 'user-auth', ['alice', 'bob'], {
action_needed: 'feedback', action_needed: 'feedback',
deadline: '2026-01-15' deadline: '2026-01-15',
}); });
expect(notifySpy).toHaveBeenCalledWith('reminder', expect.objectContaining({ expect(notifySpy).toHaveBeenCalledWith(
mentions: '@alice @bob', 'reminder',
users: ['alice', 'bob'], expect.objectContaining({
document_type: 'prd', mentions: '@alice @bob',
document_key: 'user-auth' users: ['alice', 'bob'],
})); document_type: 'prd',
document_key: 'user-auth',
}),
);
}); });
}); });
@ -363,7 +372,7 @@ describe('NotificationService', () => {
beforeEach(() => { beforeEach(() => {
service = new NotificationService({ service = new NotificationService({
github: { owner: 'test', repo: 'test' } github: { owner: 'test', repo: 'test' },
}); });
notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true }); notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true });
@ -377,24 +386,27 @@ describe('NotificationService', () => {
title: 'User Authentication', title: 'User Authentication',
version: 1, version: 1,
url: 'https://example.com/doc', url: 'https://example.com/doc',
reviewIssue: 100 reviewIssue: 100,
}, },
['alice', 'bob', 'charlie'], ['alice', 'bob', 'charlie'],
'2026-01-15' '2026-01-15',
); );
expect(notifySpy).toHaveBeenCalledWith('feedback_round_opened', expect.objectContaining({ expect(notifySpy).toHaveBeenCalledWith(
document_type: 'prd', 'feedback_round_opened',
document_key: 'user-auth', expect.objectContaining({
title: 'User Authentication', document_type: 'prd',
version: 1, document_key: 'user-auth',
deadline: '2026-01-15', title: 'User Authentication',
stakeholder_count: 3, version: 1,
mentions: '@alice @bob @charlie', deadline: '2026-01-15',
users: ['alice', 'bob', 'charlie'], stakeholder_count: 3,
document_url: 'https://example.com/doc', mentions: '@alice @bob @charlie',
review_issue: 100 users: ['alice', 'bob', 'charlie'],
})); document_url: 'https://example.com/doc',
review_issue: 100,
}),
);
}); });
}); });
@ -406,7 +418,7 @@ describe('NotificationService', () => {
beforeEach(() => { beforeEach(() => {
service = new NotificationService({ service = new NotificationService({
github: { owner: 'test', repo: 'test' } github: { owner: 'test', repo: 'test' },
}); });
notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true }); notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true });
@ -420,14 +432,14 @@ describe('NotificationService', () => {
section: 'FR-3', section: 'FR-3',
summary: 'Security vulnerability identified', summary: 'Security vulnerability identified',
issueNumber: 42, issueNumber: 42,
url: 'https://example.com/issues/42' url: 'https://example.com/issues/42',
}, },
{ {
type: 'prd', type: 'prd',
key: 'payments', key: 'payments',
owner: 'product-owner', owner: 'product-owner',
reviewIssue: 100 reviewIssue: 100,
} },
); );
expect(notifySpy).toHaveBeenCalledWith( expect(notifySpy).toHaveBeenCalledWith(
@ -438,11 +450,11 @@ describe('NotificationService', () => {
user: 'security', user: 'security',
feedback_type: 'concern', feedback_type: 'concern',
section: 'FR-3', section: 'FR-3',
feedback_issue: 42 feedback_issue: 42,
}), }),
expect.objectContaining({ expect.objectContaining({
notifyOnly: ['product-owner'] notifyOnly: ['product-owner'],
}) }),
); );
}); });
}); });
@ -455,7 +467,7 @@ describe('NotificationService', () => {
beforeEach(() => { beforeEach(() => {
service = new NotificationService({ service = new NotificationService({
github: { owner: 'test', repo: 'test' } github: { owner: 'test', repo: 'test' },
}); });
notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true }); notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true });
@ -467,26 +479,29 @@ describe('NotificationService', () => {
type: 'prd', type: 'prd',
key: 'user-auth', key: 'user-auth',
url: 'https://example.com/doc', url: 'https://example.com/doc',
reviewIssue: 100 reviewIssue: 100,
}, },
{ {
oldVersion: 1, oldVersion: 1,
newVersion: 2, newVersion: 2,
feedbackCount: 12, feedbackCount: 12,
conflictsResolved: 3, conflictsResolved: 3,
summary: 'Incorporated security feedback and clarified auth flow' summary: 'Incorporated security feedback and clarified auth flow',
} },
); );
expect(notifySpy).toHaveBeenCalledWith('synthesis_complete', expect.objectContaining({ expect(notifySpy).toHaveBeenCalledWith(
document_type: 'prd', 'synthesis_complete',
document_key: 'user-auth', expect.objectContaining({
old_version: 1, document_type: 'prd',
new_version: 2, document_key: 'user-auth',
feedback_count: 12, old_version: 1,
conflicts_resolved: 3, new_version: 2,
summary: expect.stringContaining('security feedback') feedback_count: 12,
})); conflicts_resolved: 3,
summary: expect.stringContaining('security feedback'),
}),
);
}); });
}); });
@ -498,7 +513,7 @@ describe('NotificationService', () => {
beforeEach(() => { beforeEach(() => {
service = new NotificationService({ service = new NotificationService({
github: { owner: 'test', repo: 'test' } github: { owner: 'test', repo: 'test' },
}); });
notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true }); notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true });
@ -513,23 +528,26 @@ describe('NotificationService', () => {
version: 2, version: 2,
url: 'https://example.com/doc', url: 'https://example.com/doc',
signoffUrl: 'https://example.com/signoff', signoffUrl: 'https://example.com/signoff',
reviewIssue: 200 reviewIssue: 200,
}, },
['alice', 'bob', 'charlie'], ['alice', 'bob', 'charlie'],
'2026-01-20', '2026-01-20',
{ minimum_approvals: 2 } { minimum_approvals: 2 },
); );
expect(notifySpy).toHaveBeenCalledWith('signoff_requested', expect.objectContaining({ expect(notifySpy).toHaveBeenCalledWith(
document_type: 'prd', 'signoff_requested',
document_key: 'payments', expect.objectContaining({
title: 'Payments V2', document_type: 'prd',
version: 2, document_key: 'payments',
deadline: '2026-01-20', title: 'Payments V2',
approvals_needed: 2, version: 2,
mentions: '@alice @bob @charlie', deadline: '2026-01-20',
users: ['alice', 'bob', 'charlie'] approvals_needed: 2,
})); mentions: '@alice @bob @charlie',
users: ['alice', 'bob', 'charlie'],
}),
);
}); });
it('should calculate approvals_needed from stakeholder count when not specified', async () => { it('should calculate approvals_needed from stakeholder count when not specified', async () => {
@ -538,16 +556,19 @@ describe('NotificationService', () => {
type: 'prd', type: 'prd',
key: 'test', key: 'test',
title: 'Test', title: 'Test',
version: 1 version: 1,
}, },
['a', 'b', 'c', 'd', 'e'], ['a', 'b', 'c', 'd', 'e'],
'2026-01-20', '2026-01-20',
{} // No minimum_approvals {}, // No minimum_approvals
); );
expect(notifySpy).toHaveBeenCalledWith('signoff_requested', expect.objectContaining({ expect(notifySpy).toHaveBeenCalledWith(
approvals_needed: 3 // ceil(5 * 0.5) = 3 'signoff_requested',
})); expect.objectContaining({
approvals_needed: 3, // ceil(5 * 0.5) = 3
}),
);
}); });
}); });
@ -559,7 +580,7 @@ describe('NotificationService', () => {
beforeEach(() => { beforeEach(() => {
service = new NotificationService({ service = new NotificationService({
github: { owner: 'test', repo: 'test' } github: { owner: 'test', repo: 'test' },
}); });
notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true }); notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true });
@ -570,26 +591,29 @@ describe('NotificationService', () => {
{ {
user: 'alice', user: 'alice',
decision: 'approved', decision: 'approved',
note: null note: null,
}, },
{ {
type: 'prd', type: 'prd',
key: 'test', key: 'test',
reviewIssue: 100, reviewIssue: 100,
reviewUrl: 'https://example.com/issues/100' reviewUrl: 'https://example.com/issues/100',
}, },
{ current: 2, total: 3 } { current: 2, total: 3 },
); );
expect(notifySpy).toHaveBeenCalledWith('signoff_received', expect.objectContaining({ expect(notifySpy).toHaveBeenCalledWith(
document_type: 'prd', 'signoff_received',
document_key: 'test', expect.objectContaining({
user: 'alice', document_type: 'prd',
decision: 'approved', document_key: 'test',
emoji: '✅', user: 'alice',
progress_current: 2, decision: 'approved',
progress_total: 3 emoji: '✅',
})); progress_current: 2,
progress_total: 3,
}),
);
}); });
it('should format blocked signoff with correct emoji', async () => { it('should format blocked signoff with correct emoji', async () => {
@ -597,21 +621,24 @@ describe('NotificationService', () => {
{ {
user: 'security', user: 'security',
decision: 'blocked', decision: 'blocked',
note: 'Security concern' note: 'Security concern',
}, },
{ {
type: 'prd', type: 'prd',
key: 'test', key: 'test',
reviewIssue: 100 reviewIssue: 100,
}, },
{ current: 1, total: 3 } { current: 1, total: 3 },
); );
expect(notifySpy).toHaveBeenCalledWith('signoff_received', expect.objectContaining({ expect(notifySpy).toHaveBeenCalledWith(
decision: 'blocked', 'signoff_received',
emoji: '🚫', expect.objectContaining({
note: 'Security concern' decision: 'blocked',
})); emoji: '🚫',
note: 'Security concern',
}),
);
}); });
it('should format approved-with-note signoff correctly', async () => { it('should format approved-with-note signoff correctly', async () => {
@ -619,20 +646,23 @@ describe('NotificationService', () => {
{ {
user: 'bob', user: 'bob',
decision: 'approved-with-note', decision: 'approved-with-note',
note: 'Minor concern' note: 'Minor concern',
}, },
{ {
type: 'prd', type: 'prd',
key: 'test', key: 'test',
reviewIssue: 100 reviewIssue: 100,
}, },
{ current: 2, total: 3 } { current: 2, total: 3 },
); );
expect(notifySpy).toHaveBeenCalledWith('signoff_received', expect.objectContaining({ expect(notifySpy).toHaveBeenCalledWith(
emoji: '✅📝', 'signoff_received',
note: 'Minor concern' expect.objectContaining({
})); emoji: '✅📝',
note: 'Minor concern',
}),
);
}); });
}); });
@ -644,7 +674,7 @@ describe('NotificationService', () => {
beforeEach(() => { beforeEach(() => {
service = new NotificationService({ service = new NotificationService({
github: { owner: 'test', repo: 'test' } github: { owner: 'test', repo: 'test' },
}); });
notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true }); notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true });
@ -657,21 +687,24 @@ describe('NotificationService', () => {
key: 'user-auth', key: 'user-auth',
title: 'User Authentication', title: 'User Authentication',
version: 2, version: 2,
url: 'https://example.com/doc' url: 'https://example.com/doc',
}, },
3, 3,
3 3,
); );
expect(notifySpy).toHaveBeenCalledWith('document_approved', expect.objectContaining({ expect(notifySpy).toHaveBeenCalledWith(
document_type: 'prd', 'document_approved',
document_key: 'user-auth', expect.objectContaining({
title: 'User Authentication', document_type: 'prd',
version: 2, document_key: 'user-auth',
approval_count: 3, title: 'User Authentication',
stakeholder_count: 3, version: 2,
document_url: 'https://example.com/doc' approval_count: 3,
})); stakeholder_count: 3,
document_url: 'https://example.com/doc',
}),
);
}); });
}); });
@ -683,7 +716,7 @@ describe('NotificationService', () => {
beforeEach(() => { beforeEach(() => {
service = new NotificationService({ service = new NotificationService({
github: { owner: 'test', repo: 'test' } github: { owner: 'test', repo: 'test' },
}); });
notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true }); notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true });
@ -693,24 +726,27 @@ describe('NotificationService', () => {
await service.notifyDocumentBlocked( await service.notifyDocumentBlocked(
{ {
type: 'prd', type: 'prd',
key: 'payments' key: 'payments',
}, },
{ {
user: 'legal', user: 'legal',
reason: 'GDPR compliance review required', reason: 'GDPR compliance review required',
feedbackIssue: 42, feedbackIssue: 42,
feedbackUrl: 'https://example.com/issues/42' feedbackUrl: 'https://example.com/issues/42',
} },
); );
expect(notifySpy).toHaveBeenCalledWith('document_blocked', expect.objectContaining({ expect(notifySpy).toHaveBeenCalledWith(
document_type: 'prd', 'document_blocked',
document_key: 'payments', expect.objectContaining({
user: 'legal', document_type: 'prd',
reason: 'GDPR compliance review required', document_key: 'payments',
feedback_issue: 42, user: 'legal',
feedback_url: 'https://example.com/issues/42' reason: 'GDPR compliance review required',
})); feedback_issue: 42,
feedback_url: 'https://example.com/issues/42',
}),
);
}); });
}); });
@ -724,7 +760,7 @@ describe('NotificationService', () => {
mockGithubSend = vi.fn(); mockGithubSend = vi.fn();
service = new NotificationService({ service = new NotificationService({
github: { owner: 'test', repo: 'test' } github: { owner: 'test', repo: 'test' },
}); });
service.channels.github.send = mockGithubSend; service.channels.github.send = mockGithubSend;
@ -744,7 +780,7 @@ describe('NotificationService', () => {
document_type: 'prd', document_type: 'prd',
document_key: 'test', document_key: 'test',
user: 'blocker', user: 'blocker',
reason: 'Issue' reason: 'Issue',
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -756,7 +792,7 @@ describe('NotificationService', () => {
const result = await service.notify('deadline_extended', { const result = await service.notify('deadline_extended', {
document_type: 'prd', document_type: 'prd',
document_key: 'test' document_key: 'test',
}); });
expect(result.results.github.success).toBe(false); expect(result.results.github.success).toBe(false);

View File

@ -11,10 +11,7 @@
*/ */
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { import { SlackNotifier, SLACK_TEMPLATES } from '../../../src/modules/bmm/lib/notifications/slack-notifier.js';
SlackNotifier,
SLACK_TEMPLATES
} from '../../../src/modules/bmm/lib/notifications/slack-notifier.js';
// Mock global fetch // Mock global fetch
global.fetch = vi.fn(); global.fetch = vi.fn();
@ -37,7 +34,7 @@ describe('SlackNotifier', () => {
'signoff_received', 'signoff_received',
'document_approved', 'document_approved',
'document_blocked', 'document_blocked',
'reminder' 'reminder',
]; ];
for (const type of expectedTypes) { for (const type of expectedTypes) {
@ -54,7 +51,7 @@ describe('SlackNotifier', () => {
version: 1, version: 1,
deadline: '2026-01-15', deadline: '2026-01-15',
stakeholder_count: 5, stakeholder_count: 5,
document_url: 'https://example.com/doc' document_url: 'https://example.com/doc',
}; };
const blocks = SLACK_TEMPLATES.feedback_round_opened.blocks(data); const blocks = SLACK_TEMPLATES.feedback_round_opened.blocks(data);
@ -63,16 +60,16 @@ describe('SlackNotifier', () => {
expect(blocks.length).toBeGreaterThan(0); expect(blocks.length).toBeGreaterThan(0);
// Check header block // Check header block
const header = blocks.find(b => b.type === 'header'); const header = blocks.find((b) => b.type === 'header');
expect(header).toBeDefined(); expect(header).toBeDefined();
expect(header.text.text).toContain('Feedback'); expect(header.text.text).toContain('Feedback');
// Check section with fields // Check section with fields
const section = blocks.find(b => b.type === 'section' && b.fields); const section = blocks.find((b) => b.type === 'section' && b.fields);
expect(section).toBeDefined(); expect(section).toBeDefined();
// Check actions block // Check actions block
const actions = blocks.find(b => b.type === 'actions'); const actions = blocks.find((b) => b.type === 'actions');
expect(actions).toBeDefined(); expect(actions).toBeDefined();
expect(actions.elements[0].url).toBe('https://example.com/doc'); expect(actions.elements[0].url).toBe('https://example.com/doc');
}); });
@ -98,7 +95,7 @@ describe('SlackNotifier', () => {
const title = SLACK_TEMPLATES.signoff_received.title({ const title = SLACK_TEMPLATES.signoff_received.title({
emoji: '✅', emoji: '✅',
user: 'alice' user: 'alice',
}); });
expect(title).toContain('✅'); expect(title).toContain('✅');
@ -115,7 +112,7 @@ describe('SlackNotifier', () => {
progress_current: 2, progress_current: 2,
progress_total: 3, progress_total: 3,
note: 'Minor concern noted', note: 'Minor concern noted',
review_url: 'https://example.com' review_url: 'https://example.com',
}; };
const dataWithoutNote = { ...dataWithNote, note: null }; const dataWithoutNote = { ...dataWithNote, note: null };
@ -136,13 +133,11 @@ describe('SlackNotifier', () => {
feedback_type: 'concern', feedback_type: 'concern',
section: 'FR-1', section: 'FR-1',
summary: longSummary, summary: longSummary,
feedback_url: 'https://example.com' feedback_url: 'https://example.com',
}; };
const blocks = SLACK_TEMPLATES.feedback_submitted.blocks(data); const blocks = SLACK_TEMPLATES.feedback_submitted.blocks(data);
const summaryBlock = blocks.find(b => const summaryBlock = blocks.find((b) => b.type === 'section' && b.text?.text?.startsWith('>'));
b.type === 'section' && b.text?.text?.startsWith('>')
);
expect(summaryBlock.text.text.length).toBeLessThan(250); expect(summaryBlock.text.text.length).toBeLessThan(250);
expect(summaryBlock.text.text).toContain('...'); expect(summaryBlock.text.text).toContain('...');
@ -155,7 +150,7 @@ describe('SlackNotifier', () => {
it('should initialize with webhook URL', () => { it('should initialize with webhook URL', () => {
const notifier = new SlackNotifier({ const notifier = new SlackNotifier({
webhookUrl: 'https://hooks.slack.com/services/xxx', webhookUrl: 'https://hooks.slack.com/services/xxx',
channel: '#prd-updates' channel: '#prd-updates',
}); });
expect(notifier.webhookUrl).toBe('https://hooks.slack.com/services/xxx'); expect(notifier.webhookUrl).toBe('https://hooks.slack.com/services/xxx');
@ -165,7 +160,7 @@ describe('SlackNotifier', () => {
it('should use default values', () => { it('should use default values', () => {
const notifier = new SlackNotifier({ const notifier = new SlackNotifier({
webhookUrl: 'https://hooks.slack.com/services/xxx' webhookUrl: 'https://hooks.slack.com/services/xxx',
}); });
expect(notifier.username).toBe('PRD Crowdsource Bot'); expect(notifier.username).toBe('PRD Crowdsource Bot');
@ -184,7 +179,7 @@ describe('SlackNotifier', () => {
describe('isEnabled', () => { describe('isEnabled', () => {
it('should return true when webhook configured', () => { it('should return true when webhook configured', () => {
const notifier = new SlackNotifier({ const notifier = new SlackNotifier({
webhookUrl: 'https://hooks.slack.com/services/xxx' webhookUrl: 'https://hooks.slack.com/services/xxx',
}); });
expect(notifier.isEnabled()).toBe(true); expect(notifier.isEnabled()).toBe(true);
@ -205,7 +200,7 @@ describe('SlackNotifier', () => {
beforeEach(() => { beforeEach(() => {
notifier = new SlackNotifier({ notifier = new SlackNotifier({
webhookUrl: 'https://hooks.slack.com/services/xxx', webhookUrl: 'https://hooks.slack.com/services/xxx',
channel: '#prd-updates' channel: '#prd-updates',
}); });
}); });
@ -232,7 +227,7 @@ describe('SlackNotifier', () => {
version: 1, version: 1,
deadline: '2026-01-15', deadline: '2026-01-15',
stakeholder_count: 5, stakeholder_count: 5,
document_url: 'https://example.com/doc' document_url: 'https://example.com/doc',
}; };
const result = await notifier.send('feedback_round_opened', data); const result = await notifier.send('feedback_round_opened', data);
@ -242,8 +237,8 @@ describe('SlackNotifier', () => {
'https://hooks.slack.com/services/xxx', 'https://hooks.slack.com/services/xxx',
expect.objectContaining({ expect.objectContaining({
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' },
}) }),
); );
const payload = JSON.parse(global.fetch.mock.calls[0][1].body); const payload = JSON.parse(global.fetch.mock.calls[0][1].body);
@ -262,7 +257,7 @@ describe('SlackNotifier', () => {
global.fetch.mockResolvedValue({ global.fetch.mockResolvedValue({
ok: false, ok: false,
status: 500, status: 500,
statusText: 'Internal Server Error' statusText: 'Internal Server Error',
}); });
const result = await notifier.send('document_approved', { const result = await notifier.send('document_approved', {
@ -272,7 +267,7 @@ describe('SlackNotifier', () => {
version: 1, version: 1,
approval_count: 3, approval_count: 3,
stakeholder_count: 3, stakeholder_count: 3,
document_url: 'https://example.com' document_url: 'https://example.com',
}); });
expect(result.success).toBe(false); expect(result.success).toBe(false);
@ -280,14 +275,18 @@ describe('SlackNotifier', () => {
}); });
it('should use custom channel from options', async () => { it('should use custom channel from options', async () => {
await notifier.send('reminder', { await notifier.send(
document_type: 'prd', 'reminder',
document_key: 'test', {
action_needed: 'feedback', document_type: 'prd',
deadline: '2026-01-15', document_key: 'test',
time_remaining: '2 days', action_needed: 'feedback',
document_url: 'https://example.com' deadline: '2026-01-15',
}, { channel: '#urgent-prd' }); time_remaining: '2 days',
document_url: 'https://example.com',
},
{ channel: '#urgent-prd' },
);
const payload = JSON.parse(global.fetch.mock.calls[0][1].body); const payload = JSON.parse(global.fetch.mock.calls[0][1].body);
expect(payload.channel).toBe('#urgent-prd'); expect(payload.channel).toBe('#urgent-prd');
@ -302,7 +301,7 @@ describe('SlackNotifier', () => {
beforeEach(() => { beforeEach(() => {
notifier = new SlackNotifier({ notifier = new SlackNotifier({
webhookUrl: 'https://hooks.slack.com/services/xxx', webhookUrl: 'https://hooks.slack.com/services/xxx',
channel: '#general' channel: '#general',
}); });
}); });
@ -354,7 +353,7 @@ describe('SlackNotifier', () => {
webhookUrl: 'https://hooks.slack.com/services/xxx', webhookUrl: 'https://hooks.slack.com/services/xxx',
channel: '#default', channel: '#default',
username: 'TestBot', username: 'TestBot',
iconEmoji: ':robot:' iconEmoji: ':robot:',
}); });
}); });
@ -366,7 +365,7 @@ describe('SlackNotifier', () => {
version: 1, version: 1,
deadline: '2026-01-15', deadline: '2026-01-15',
stakeholder_count: 3, stakeholder_count: 3,
document_url: 'https://example.com' document_url: 'https://example.com',
}; };
const payload = notifier._buildPayload(template, data, {}); const payload = notifier._buildPayload(template, data, {});
@ -388,7 +387,7 @@ describe('SlackNotifier', () => {
document_key: 'test', document_key: 'test',
progress_current: 2, progress_current: 2,
progress_total: 5, progress_total: 5,
review_url: 'https://example.com' review_url: 'https://example.com',
}; };
const payload = notifier._buildPayload(template, data, {}); const payload = notifier._buildPayload(template, data, {});
@ -406,7 +405,7 @@ describe('SlackNotifier', () => {
document_key: 'test', document_key: 'test',
progress_current: 3, progress_current: 3,
progress_total: 3, progress_total: 3,
review_url: 'https://example.com' review_url: 'https://example.com',
}; };
const payload = notifier._buildPayload(template, data, {}); const payload = notifier._buildPayload(template, data, {});
@ -424,7 +423,7 @@ describe('SlackNotifier', () => {
beforeEach(() => { beforeEach(() => {
notifier = new SlackNotifier({ notifier = new SlackNotifier({
webhookUrl: 'https://hooks.slack.com/services/xxx', webhookUrl: 'https://hooks.slack.com/services/xxx',
channel: '#prd-notifications' channel: '#prd-notifications',
}); });
}); });
@ -434,7 +433,7 @@ describe('SlackNotifier', () => {
document_key: 'payments-v2', document_key: 'payments-v2',
user: 'legal-team', user: 'legal-team',
reason: 'Compliance review required', reason: 'Compliance review required',
feedback_url: 'https://example.com/feedback/123' feedback_url: 'https://example.com/feedback/123',
}); });
const payload = JSON.parse(global.fetch.mock.calls[0][1].body); const payload = JSON.parse(global.fetch.mock.calls[0][1].body);
@ -443,9 +442,7 @@ describe('SlackNotifier', () => {
expect(payload.attachments[0].blocks).toBeInstanceOf(Array); expect(payload.attachments[0].blocks).toBeInstanceOf(Array);
// Find blocking reason in blocks // Find blocking reason in blocks
const reasonBlock = payload.attachments[0].blocks.find( const reasonBlock = payload.attachments[0].blocks.find((b) => b.type === 'section' && b.text?.text?.includes('Compliance'));
b => b.type === 'section' && b.text?.text?.includes('Compliance')
);
expect(reasonBlock).toBeDefined(); expect(reasonBlock).toBeDefined();
}); });
@ -458,7 +455,7 @@ describe('SlackNotifier', () => {
feedback_count: 12, feedback_count: 12,
conflicts_resolved: 3, conflicts_resolved: 3,
summary: 'Incorporated 12 feedback items including session timeout resolution', summary: 'Incorporated 12 feedback items including session timeout resolution',
document_url: 'https://example.com/doc' document_url: 'https://example.com/doc',
}); });
const payload = JSON.parse(global.fetch.mock.calls[0][1].body); const payload = JSON.parse(global.fetch.mock.calls[0][1].body);