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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,7 +26,7 @@ Please review and provide your feedback by {{deadline}}.
{{actions}}
{{/if}}
_Notification from PRD Crowdsourcing System_`
_Notification from PRD Crowdsourcing System_`,
},
feedback_submitted: {
@ -46,7 +46,7 @@ _Notification from PRD Crowdsourcing System_`
[View Feedback #{{feedback_issue}}]({{feedback_url}})
_Notification from PRD Crowdsourcing System_`
_Notification from PRD Crowdsourcing System_`,
},
synthesis_complete: {
@ -67,7 +67,7 @@ _Notification from PRD Crowdsourcing System_`
[View Updated Document]({{document_url}})
_Notification from PRD Crowdsourcing System_`
_Notification from PRD Crowdsourcing System_`,
},
signoff_requested: {
@ -92,7 +92,7 @@ Please review and provide your sign-off decision by {{deadline}}.
[View Document]({{document_url}})
_Notification from PRD Crowdsourcing System_`
_Notification from PRD Crowdsourcing System_`,
},
signoff_received: {
@ -112,7 +112,7 @@ _Notification from PRD Crowdsourcing System_`
[View Review Issue #{{review_issue}}]({{review_url}})
_Notification from PRD Crowdsourcing System_`
_Notification from PRD Crowdsourcing System_`,
},
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}})
_Notification from PRD Crowdsourcing System_`
_Notification from PRD Crowdsourcing System_`,
},
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}})
{{/if}}
_Notification from PRD Crowdsourcing System_`
_Notification from PRD Crowdsourcing System_`,
},
reminder: {
@ -171,7 +171,7 @@ Please complete your {{action_needed}} by {{deadline}}.
[View Document]({{document_url}})
_Notification from PRD Crowdsourcing System_`
_Notification from PRD Crowdsourcing System_`,
},
deadline_extended: {
@ -190,8 +190,8 @@ _Notification from PRD Crowdsourcing System_`
[View Document]({{document_url}})
_Notification from PRD Crowdsourcing System_`
}
_Notification from PRD Crowdsourcing System_`,
},
};
class GitHubNotifier {
@ -229,11 +229,7 @@ class GitHubNotifier {
return await this._postComment(options.issueNumber, message);
} else if (options.createIssue) {
// Create a new issue
return await this._createIssue(
this._renderTemplate(template.subject, data),
message,
options.labels || []
);
return await this._createIssue(this._renderTemplate(template.subject, data), message, options.labels || []);
} else if (data.review_issue) {
// Default to review issue if available
return await this._postComment(data.review_issue, message);
@ -244,7 +240,7 @@ class GitHubNotifier {
success: true,
channel: 'github',
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) {
const reminderData = {
...data,
mentions: users.map(u => `@${u}`).join(' ')
mentions: users.map((u) => `@${u}`).join(' '),
};
return await this.send('reminder', reminderData, { issueNumber });
@ -272,7 +268,7 @@ class GitHubNotifier {
* @returns {Object} Notification result
*/
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}`;
return await this._postComment(issueNumber, fullMessage);
@ -288,7 +284,7 @@ class GitHubNotifier {
owner: this.owner,
repo: this.repo,
issue_number: issueNumber,
body
body,
});
return {
@ -296,13 +292,13 @@ class GitHubNotifier {
channel: 'github',
type: 'comment',
issueNumber,
commentId: result.id
commentId: result.id,
};
} catch (error) {
return {
success: false,
channel: 'github',
error: error.message
error: error.message,
};
}
}
@ -318,20 +314,20 @@ class GitHubNotifier {
repo: this.repo,
title,
body,
labels
labels,
});
return {
success: true,
channel: 'github',
type: 'issue',
issueNumber: result.number
issueNumber: result.number,
};
} catch (error) {
return {
success: false,
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) => {
const arr = data[key];
if (!Array.isArray(arr)) return '';
return arr.map((item, index) => {
let itemContent = content;
if (typeof item === 'object') {
Object.entries(item).forEach(([k, v]) => {
itemContent = itemContent.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), String(v));
});
} else {
itemContent = itemContent.replace(/\{\{this\}\}/g, String(item));
}
itemContent = itemContent.replace(/\{\{@index\}\}/g, String(index));
return itemContent;
}).join('');
return arr
.map((item, index) => {
let itemContent = content;
if (typeof item === 'object') {
Object.entries(item).forEach(([k, v]) => {
itemContent = itemContent.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), String(v));
});
} else {
itemContent = itemContent.replace(/\{\{this\}\}/g, String(item));
}
itemContent = itemContent.replace(/\{\{@index\}\}/g, String(index));
return itemContent;
})
.join('');
});
return result;
@ -378,5 +376,5 @@ class GitHubNotifier {
module.exports = {
GitHubNotifier,
NOTIFICATION_TEMPLATES
NOTIFICATION_TEMPLATES,
};

View File

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

View File

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

View File

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

View File

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

View File

@ -13,7 +13,7 @@ github:
repo: "{config_source}:github_repo"
# 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"
instructions: "{installed_path}/instructions.md"

View File

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

View File

@ -14,7 +14,7 @@ github:
# Parameters
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"
instructions: "{installed_path}/instructions.md"

View File

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

View File

@ -13,7 +13,7 @@ github:
repo: "{config_source}:github_repo"
# 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"
instructions: "{installed_path}/instructions.md"

View File

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

View File

@ -14,7 +14,7 @@ github:
# Parameters
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"
instructions: "{installed_path}/instructions.md"

View File

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

View File

@ -20,9 +20,9 @@ github_integration:
dir: "{output_folder}/cache"
staleness_minutes: "{config_source}:github_cache_staleness_minutes"
sync:
create_pr: true # Create PR linking to GitHub Issue
update_issue_status: true # Update issue to in-review
add_completion_comment: true # Add implementation summary to issue
create_pr: true # Create PR linking to GitHub Issue
update_issue_status: true # Update issue to in-review
add_completion_comment: true # Add implementation summary to issue
# Workflow paths
installed_path: "{project-root}/_bmad/bmm/workflows/4-implementation/super-dev-pipeline"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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