From 044e7eb2e06eba34e92236866a1b8a8457333203 Mon Sep 17 00:00:00 2001 From: Jonah Schulte Date: Thu, 8 Jan 2026 22:10:56 -0500 Subject: [PATCH] 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. --- src/modules/bmm/lib/cache/cache-manager.js | 46 ++- src/modules/bmm/lib/cache/index.js | 2 +- src/modules/bmm/lib/cache/sync-engine.js | 151 ++++---- .../bmm/lib/crowdsource/feedback-manager.js | 112 +++--- src/modules/bmm/lib/crowdsource/index.js | 2 +- .../bmm/lib/crowdsource/signoff-manager.js | 135 +++---- .../bmm/lib/crowdsource/synthesis-engine.js | 136 ++++--- .../bmm/lib/notifications/email-notifier.js | 40 +-- .../bmm/lib/notifications/github-notifier.js | 72 ++-- src/modules/bmm/lib/notifications/index.js | 2 +- .../lib/notifications/notification-service.js | 74 ++-- .../bmm/lib/notifications/slack-notifier.js | 216 +++++------ .../create-epic-draft/workflow.yaml | 6 +- .../create-prd-draft/workflow.yaml | 2 +- .../crowdsource/epic-dashboard/workflow.yaml | 4 +- .../open-epic-feedback/workflow.yaml | 2 +- .../open-feedback-round/workflow.yaml | 4 +- .../crowdsource/prd-dashboard/workflow.yaml | 2 +- .../crowdsource/submit-feedback/workflow.yaml | 4 +- .../synthesize-feedback/workflow.yaml | 2 +- .../crowdsource/view-feedback/workflow.yaml | 2 +- .../super-dev-pipeline/workflow.yaml | 6 +- .../workflows/po/epic-dashboard/workflow.yaml | 2 +- .../unit/cache/cache-manager-prd-epic.test.js | 78 ++-- .../unit/crowdsource/feedback-manager.test.js | 244 ++++++------- test/unit/crowdsource/signoff-manager.test.js | 229 ++++++------ .../unit/crowdsource/synthesis-engine.test.js | 179 +++++----- .../unit/notifications/email-notifier.test.js | 195 +++++----- .../notifications/github-notifier.test.js | 195 +++++----- .../notification-service.test.js | 338 ++++++++++-------- .../unit/notifications/slack-notifier.test.js | 83 +++-- 31 files changed, 1268 insertions(+), 1297 deletions(-) diff --git a/src/modules/bmm/lib/cache/cache-manager.js b/src/modules/bmm/lib/cache/cache-manager.js index bdc330a1..5643ddbe 100644 --- a/src/modules/bmm/lib/cache/cache-manager.js +++ b/src/modules/bmm/lib/cache/cache-manager.js @@ -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), }; } } diff --git a/src/modules/bmm/lib/cache/index.js b/src/modules/bmm/lib/cache/index.js index 2b44528f..e8534732 100644 --- a/src/modules/bmm/lib/cache/index.js +++ b/src/modules/bmm/lib/cache/index.js @@ -40,5 +40,5 @@ module.exports = { SyncEngine, CACHE_META_FILENAME, RETRY_BACKOFF_MS, - MAX_RETRIES + MAX_RETRIES, }; diff --git a/src/modules/bmm/lib/cache/sync-engine.js b/src/modules/bmm/lib/cache/sync-engine.js index 4ad38c97..a605604d 100644 --- a/src/modules/bmm/lib/cache/sync-engine.js +++ b/src/modules/bmm/lib/cache/sync-engine.js @@ -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; } diff --git a/src/modules/bmm/lib/crowdsource/feedback-manager.js b/src/modules/bmm/lib/crowdsource/feedback-manager.js index 8771c728..59b87abd 100644 --- a/src/modules/bmm/lib/crowdsource/feedback-manager.js +++ b/src/modules/bmm/lib/crowdsource/feedback-manager.js @@ -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, }; diff --git a/src/modules/bmm/lib/crowdsource/index.js b/src/modules/bmm/lib/crowdsource/index.js index 64007656..cf0843b5 100644 --- a/src/modules/bmm/lib/crowdsource/index.js +++ b/src/modules/bmm/lib/crowdsource/index.js @@ -24,5 +24,5 @@ module.exports = { SignoffManager, SIGNOFF_STATUS, THRESHOLD_TYPES, - DEFAULT_CONFIG + DEFAULT_CONFIG, }; diff --git a/src/modules/bmm/lib/crowdsource/signoff-manager.js b/src/modules/bmm/lib/crowdsource/signoff-manager.js index 6b5ab154..8b4fd35c 100644 --- a/src/modules/bmm/lib/crowdsource/signoff-manager.js +++ b/src/modules/bmm/lib/crowdsource/signoff-manager.js @@ -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, }; diff --git a/src/modules/bmm/lib/crowdsource/synthesis-engine.js b/src/modules/bmm/lib/crowdsource/synthesis-engine.js index 0b209b10..55a1aa56 100644 --- a/src/modules/bmm/lib/crowdsource/synthesis-engine.js +++ b/src/modules/bmm/lib/crowdsource/synthesis-engine.js @@ -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 } diff --git a/src/modules/bmm/lib/notifications/email-notifier.js b/src/modules/bmm/lib/notifications/email-notifier.js index 8c214db4..7e1730ce 100644 --- a/src/modules/bmm/lib/notifications/email-notifier.js +++ b/src/modules/bmm/lib/notifications/email-notifier.js @@ -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, }; diff --git a/src/modules/bmm/lib/notifications/github-notifier.js b/src/modules/bmm/lib/notifications/github-notifier.js index 881fc42d..2f1b7974 100644 --- a/src/modules/bmm/lib/notifications/github-notifier.js +++ b/src/modules/bmm/lib/notifications/github-notifier.js @@ -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, }; diff --git a/src/modules/bmm/lib/notifications/index.js b/src/modules/bmm/lib/notifications/index.js index b438f38c..4e030bfa 100644 --- a/src/modules/bmm/lib/notifications/index.js +++ b/src/modules/bmm/lib/notifications/index.js @@ -53,5 +53,5 @@ module.exports = { // Templates (for customization) GITHUB_TEMPLATES, SLACK_TEMPLATES, - EMAIL_TEMPLATES + EMAIL_TEMPLATES, }; diff --git a/src/modules/bmm/lib/notifications/notification-service.js b/src/modules/bmm/lib/notifications/notification-service.js index f8074841..db47af59 100644 --- a/src/modules/bmm/lib/notifications/notification-service.js +++ b/src/modules/bmm/lib/notifications/notification-service.js @@ -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, }; diff --git a/src/modules/bmm/lib/notifications/slack-notifier.js b/src/modules/bmm/lib/notifications/slack-notifier.js index efe0e694..e4759302 100644 --- a/src/modules/bmm/lib/notifications/slack-notifier.js +++ b/src/modules/bmm/lib/notifications/slack-notifier.js @@ -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, }; diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/create-epic-draft/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/create-epic-draft/workflow.yaml index 3e79fde2..b001b989 100644 --- a/src/modules/bmm/workflows/1-requirements/crowdsource/create-epic-draft/workflow.yaml +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/create-epic-draft/workflow.yaml @@ -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" diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/create-prd-draft/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/create-prd-draft/workflow.yaml index 31909e70..9ec8520c 100644 --- a/src/modules/bmm/workflows/1-requirements/crowdsource/create-prd-draft/workflow.yaml +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/create-prd-draft/workflow.yaml @@ -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" diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/epic-dashboard/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/epic-dashboard/workflow.yaml index 17205d87..88d87308 100644 --- a/src/modules/bmm/workflows/1-requirements/crowdsource/epic-dashboard/workflow.yaml +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/epic-dashboard/workflow.yaml @@ -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" diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/open-epic-feedback/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/open-epic-feedback/workflow.yaml index ca36f1e0..ca02f202 100644 --- a/src/modules/bmm/workflows/1-requirements/crowdsource/open-epic-feedback/workflow.yaml +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/open-epic-feedback/workflow.yaml @@ -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" diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/open-feedback-round/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/open-feedback-round/workflow.yaml index dff95982..6729ef40 100644 --- a/src/modules/bmm/workflows/1-requirements/crowdsource/open-feedback-round/workflow.yaml +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/open-feedback-round/workflow.yaml @@ -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" diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/prd-dashboard/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/prd-dashboard/workflow.yaml index 8173ac68..2d3d81be 100644 --- a/src/modules/bmm/workflows/1-requirements/crowdsource/prd-dashboard/workflow.yaml +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/prd-dashboard/workflow.yaml @@ -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" diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/submit-feedback/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/submit-feedback/workflow.yaml index 2c2644d5..647aa1de 100644 --- a/src/modules/bmm/workflows/1-requirements/crowdsource/submit-feedback/workflow.yaml +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/submit-feedback/workflow.yaml @@ -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" diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-feedback/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-feedback/workflow.yaml index a98e48d3..966b74ec 100644 --- a/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-feedback/workflow.yaml +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/synthesize-feedback/workflow.yaml @@ -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" diff --git a/src/modules/bmm/workflows/1-requirements/crowdsource/view-feedback/workflow.yaml b/src/modules/bmm/workflows/1-requirements/crowdsource/view-feedback/workflow.yaml index 13786d31..dfecd6f6 100644 --- a/src/modules/bmm/workflows/1-requirements/crowdsource/view-feedback/workflow.yaml +++ b/src/modules/bmm/workflows/1-requirements/crowdsource/view-feedback/workflow.yaml @@ -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" diff --git a/src/modules/bmm/workflows/4-implementation/super-dev-pipeline/workflow.yaml b/src/modules/bmm/workflows/4-implementation/super-dev-pipeline/workflow.yaml index 45ff3274..cbe5428c 100644 --- a/src/modules/bmm/workflows/4-implementation/super-dev-pipeline/workflow.yaml +++ b/src/modules/bmm/workflows/4-implementation/super-dev-pipeline/workflow.yaml @@ -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" diff --git a/src/modules/bmm/workflows/po/epic-dashboard/workflow.yaml b/src/modules/bmm/workflows/po/epic-dashboard/workflow.yaml index 184d75a9..747f180f 100644 --- a/src/modules/bmm/workflows/po/epic-dashboard/workflow.yaml +++ b/src/modules/bmm/workflows/po/epic-dashboard/workflow.yaml @@ -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" diff --git a/test/unit/cache/cache-manager-prd-epic.test.js b/test/unit/cache/cache-manager-prd-epic.test.js index ec015ea7..8939f3cf 100644 --- a/test/unit/cache/cache-manager-prd-epic.test.js +++ b/test/unit/cache/cache-manager-prd-epic.test.js @@ -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'); diff --git a/test/unit/crowdsource/feedback-manager.test.js b/test/unit/crowdsource/feedback-manager.test.js index 5f11ff1d..e89ec95d 100644 --- a/test/unit/crowdsource/feedback-manager.test.js +++ b/test/unit/crowdsource/feedback-manager.test.js @@ -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'); }); }); }); diff --git a/test/unit/crowdsource/signoff-manager.test.js b/test/unit/crowdsource/signoff-manager.test.js index 5d600ab2..2489cd53 100644 --- a/test/unit/crowdsource/signoff-manager.test.js +++ b/test/unit/crowdsource/signoff-manager.test.js @@ -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', () => { diff --git a/test/unit/crowdsource/synthesis-engine.test.js b/test/unit/crowdsource/synthesis-engine.test.js index 75249769..eb338ab4 100644 --- a/test/unit/crowdsource/synthesis-engine.test.js +++ b/test/unit/crowdsource/synthesis-engine.test.js @@ -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); diff --git a/test/unit/notifications/email-notifier.test.js b/test/unit/notifications/email-notifier.test.js index eba326bd..8279c12b 100644 --- a/test/unit/notifications/email-notifier.test.js +++ b/test/unit/notifications/email-notifier.test.js @@ -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', - '

Hello

', - { html: true } - ); + const result = await notifier.sendCustom(['test@example.com'], 'HTML Email', '

Hello

', { 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 = '
{{title}}

{{content}}

'; const result = notifier._renderTemplate(template, { title: 'Welcome', - content: 'This is the body' + content: 'This is the body', }); expect(result).toBe('
Welcome

This is the body

'); @@ -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); diff --git a/test/unit/notifications/github-notifier.test.js b/test/unit/notifications/github-notifier.test.js index 2e7259f7..3b5a39db 100644 --- a/test/unit/notifications/github-notifier.test.js +++ b/test/unit/notifications/github-notifier.test.js @@ -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; diff --git a/test/unit/notifications/notification-service.test.js b/test/unit/notifications/notification-service.test.js index 0e532799..d16d956f 100644 --- a/test/unit/notifications/notification-service.test.js +++ b/test/unit/notifications/notification-service.test.js @@ -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); diff --git a/test/unit/notifications/slack-notifier.test.js b/test/unit/notifications/slack-notifier.test.js index 248ed0d8..9e4ee2ec 100644 --- a/test/unit/notifications/slack-notifier.test.js +++ b/test/unit/notifications/slack-notifier.test.js @@ -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);