Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions repokeeper.config.example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,11 @@ export default {
// Set to your deployment URL to add a "Try it live" link in the footer
// playgroundUrl: 'https://your-server.example.com/playground',
},
// Optional: send notifications to Slack/Discord when issues are triaged or PRs are reviewed
notifications: {
enabled: false,
// slackWebhookUrl: 'https://hooks.slack.com/services/...',
// discordWebhookUrl: 'https://discord.com/api/webhooks/...',
},
port: 3001,
};
8 changes: 8 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ export interface RepoKeeperConfig {
enabled: boolean;
playgroundUrl?: string;
};
notifications: {
enabled: boolean;
slackWebhookUrl?: string;
discordWebhookUrl?: string;
};
port: number;
}

Expand Down Expand Up @@ -84,6 +89,9 @@ const defaults: RepoKeeperConfig = {
enabled: true,
playgroundUrl: undefined,
},
notifications: {
enabled: false,
},
port: 3001,
};

Expand Down
129 changes: 129 additions & 0 deletions src/notifications/notifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import type { RepoKeeperConfig } from '../config.js';
import { log } from '../logger.js';

export interface NotificationEvent {
type: 'issue_triaged' | 'pr_reviewed' | 'pr_summarised';
repo: string;
number: number;
title: string;
url: string;
classification?: string;
summary: string;
}

export async function sendNotification(
event: NotificationEvent,
config: RepoKeeperConfig['notifications'],
): Promise<void> {
if (!config.enabled) return;

const promises: Promise<void>[] = [];

if (config.slackWebhookUrl) {
promises.push(sendSlack(event, config.slackWebhookUrl));
}
if (config.discordWebhookUrl) {
promises.push(sendDiscord(event, config.discordWebhookUrl));
}

await Promise.allSettled(promises);
}

function eventLabel(type: NotificationEvent['type']): string {
switch (type) {
case 'issue_triaged': return 'Issue Triaged';
case 'pr_reviewed': return 'PR Reviewed';
case 'pr_summarised': return 'PR Summarised';
}
}

function eventEmoji(type: NotificationEvent['type']): string {
switch (type) {
case 'issue_triaged': return '🏷️';
case 'pr_reviewed': return '🔍';
case 'pr_summarised': return '📝';
}
}

async function sendSlack(event: NotificationEvent, webhookUrl: string): Promise<void> {
const fields = [
{ type: 'mrkdwn', text: `*Repo:*\n${event.repo}` },
{ type: 'mrkdwn', text: `*Number:*\n#${event.number}` },
];

if (event.classification) {
fields.push({ type: 'mrkdwn', text: `*Classification:*\n${event.classification}` });
}

const payload = {
blocks: [
{
type: 'header',
text: { type: 'plain_text', text: `${eventEmoji(event.type)} ${eventLabel(event.type)}` },
},
{
type: 'section',
text: { type: 'mrkdwn', text: `*<${event.url}|${event.title}>*\n${event.summary}` },
},
{
type: 'section',
fields,
},
],
};

try {
const res = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});

if (!res.ok) {
log('warn', `Slack notification failed: ${res.status} ${res.statusText}`);
}
} catch (err) {
log('warn', 'Slack notification failed', {
error: err instanceof Error ? err.message : String(err),
});
}
}

async function sendDiscord(event: NotificationEvent, webhookUrl: string): Promise<void> {
const fields = [
{ name: 'Repo', value: event.repo, inline: true },
{ name: 'Number', value: `#${event.number}`, inline: true },
];

if (event.classification) {
fields.push({ name: 'Classification', value: event.classification, inline: true });
}

const payload = {
embeds: [
{
title: `${eventEmoji(event.type)} ${eventLabel(event.type)}: ${event.title}`,
url: event.url,
description: event.summary,
fields,
color: event.type === 'issue_triaged' ? 0x2ea44f : 0x0969da,
},
],
};

try {
const res = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});

if (!res.ok) {
log('warn', `Discord notification failed: ${res.status} ${res.statusText}`);
}
} catch (err) {
log('warn', 'Discord notification failed', {
error: err instanceof Error ? err.message : String(err),
});
}
}
11 changes: 9 additions & 2 deletions src/triage/responder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,17 @@ Write a brief, friendly comment asking for more information. Rules:

Write ONLY the comment text. Nothing else.`;

export interface TriageResult {
classification: string;
summary: string;
}

export async function handleIssueOpened(
payload: IssuePayload,
ai: AIProvider,
github: GitHubClient,
config: RepoKeeperConfig,
): Promise<void> {
): Promise<TriageResult | null> {
const { number, title, body } = payload.issue;
const bodyText = body ?? '';

Expand All @@ -80,7 +85,7 @@ export async function handleIssueOpened(
`additional details explaining how it differs.\n\nThank you for contributing!`,
);
log('info', `Issue #${number} flagged as possible duplicate of #${dup.number}`);
return;
return { classification: 'possible-duplicate', summary: `Possible duplicate of #${dup.number} ("${dup.title}")` };
}

// Classify the issue
Expand All @@ -95,6 +100,8 @@ export async function handleIssueOpened(
await github.addComment(number, withAttribution(comment, config.attribution));

log('info', `Issue #${number} classified as "${category}", labelled [${labels.join(', ')}]`);

return { classification: category, summary: comment };
}

async function generateComment(
Expand Down
33 changes: 32 additions & 1 deletion src/webhook/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { handlePullRequest, handlePullRequestMerged } from '../pr/summariser.js'
import { handleCodeReview, handleCodeReviewMerged } from '../review/reviewer.js';
import type { AIProvider } from '../ai/provider.js';
import { GitHubClient } from '../github/client.js';
import { sendNotification } from '../notifications/notifier.js';

export function createWebhookHandler(ai: AIProvider, _defaultGithub: GitHubClient) {
return async (req: Request, res: Response): Promise<void> => {
Expand Down Expand Up @@ -60,11 +61,25 @@ export function createWebhookHandler(ai: AIProvider, _defaultGithub: GitHubClien
log('info', `Received webhook: ${eventKey} for ${repoOwner}/${repoName}`);
recordEvent(eventKey);

const repoFullName = `${repoOwner}/${repoName}`;
const htmlUrlBase = req.body?.repository?.html_url ?? `https://github.com/${repoFullName}`;

try {
switch (eventKey) {
case 'issues.opened':
if (config.triage.enabled) {
await handleIssueOpened(req.body, ai, github, config);
const triageResult = await handleIssueOpened(req.body, ai, github, config);
if (triageResult) {
await sendNotification({
type: 'issue_triaged',
repo: repoFullName,
number: req.body.issue.number,
title: req.body.issue.title,
url: `${htmlUrlBase}/issues/${req.body.issue.number}`,
classification: triageResult.classification,
summary: triageResult.summary,
}, config.notifications);
}
}
break;

Expand All @@ -76,9 +91,25 @@ export function createWebhookHandler(ai: AIProvider, _defaultGithub: GitHubClien
case 'pull_request.synchronize':
if (config.prSummariser.enabled) {
await handlePullRequest(req.body, ai, github, config);
await sendNotification({
type: 'pr_summarised',
repo: repoFullName,
number: req.body.pull_request.number,
title: req.body.pull_request.title,
url: `${htmlUrlBase}/pull/${req.body.pull_request.number}`,
summary: `PR #${req.body.pull_request.number} has been summarised.`,
}, config.notifications);
}
if (config.codeReview.enabled) {
await handleCodeReview(req.body, ai, github, config);
await sendNotification({
type: 'pr_reviewed',
repo: repoFullName,
number: req.body.pull_request.number,
title: req.body.pull_request.title,
url: `${htmlUrlBase}/pull/${req.body.pull_request.number}`,
summary: `PR #${req.body.pull_request.number} has been reviewed.`,
}, config.notifications);
}
break;

Expand Down
2 changes: 2 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const baseConfig: RepoKeeperConfig = {
triage: { enabled: true, duplicateThreshold: 0.85, minimumBodyLength: 100 },
prSummariser: { enabled: true, minDiffLines: 50, generateReleaseNotes: true },
codeReview: { enabled: true, focus: ['security', 'performance', 'test-coverage', 'breaking-changes'], maxContextFiles: 5, minDiffLines: 10 },
attribution: { enabled: true },
notifications: { enabled: false },
port: 3001,
};

Expand Down
4 changes: 4 additions & 0 deletions tests/multi-repo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ const multiRepoConfig: RepoKeeperConfig = {
triage: { enabled: true, duplicateThreshold: 0.85, minimumBodyLength: 100 },
prSummariser: { enabled: true, minDiffLines: 50, generateReleaseNotes: true },
codeReview: { enabled: true, focus: ['security', 'performance'], maxContextFiles: 5, minDiffLines: 10 },
attribution: { enabled: true },
notifications: { enabled: false },
port: 3001,
repos: [
{ owner: 'org1', repo: 'repo1', triage: { enabled: true, duplicateThreshold: 0.5 } },
Expand Down Expand Up @@ -119,6 +121,8 @@ describe('Single-repo backward compatibility', () => {
triage: { enabled: true, duplicateThreshold: 0.85, minimumBodyLength: 100 },
prSummariser: { enabled: true, minDiffLines: 50, generateReleaseNotes: true },
codeReview: { enabled: true, focus: ['security'], maxContextFiles: 5, minDiffLines: 10 },
attribution: { enabled: true },
notifications: { enabled: false },
port: 3001,
};
setMockConfig(singleRepoConfig);
Expand Down
Loading