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