Changes before error encountered
Co-authored-by: blackboxprogramming <118287761+blackboxprogramming@users.noreply.github.com>
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,3 +1,7 @@
|
|||||||
node_modules
|
node_modules
|
||||||
coverage
|
coverage
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Generated JS files (source is TypeScript)
|
||||||
|
*.js
|
||||||
|
!vitest.config.js
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export async function GET() {
|
|
||||||
return Response.json({ status: "ok" });
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { getBuildInfo } from "../../../src/utils/buildInfo";
|
|
||||||
export async function GET() {
|
|
||||||
const info = getBuildInfo();
|
|
||||||
return Response.json({ version: info.version, commit: info.commit });
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
||||||
import { StatusPill } from "./StatusPill";
|
|
||||||
export function EnvCard({ env }) {
|
|
||||||
return (_jsxs("div", { className: "env-card", children: [_jsx("div", { className: "env-region", style: { textTransform: "uppercase", fontSize: "0.85rem" }, children: env.region }), _jsx("h2", { children: env.name }), _jsxs("div", { children: ["Env ID: ", env.id] }), _jsx(StatusPill, { status: env.status })] }));
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { jsx as _jsx } from "react/jsx-runtime";
|
|
||||||
const statusConfig = {
|
|
||||||
healthy: { label: "Healthy", className: "status-pill status-pill--healthy" },
|
|
||||||
degraded: { label: "Degraded", className: "status-pill status-pill--degraded" },
|
|
||||||
down: { label: "Down", className: "status-pill status-pill--down" }
|
|
||||||
};
|
|
||||||
export function StatusPill({ status }) {
|
|
||||||
const config = statusConfig[status];
|
|
||||||
return _jsx("span", { className: config.className, children: config.label });
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
const mockEnvironments = [
|
|
||||||
{ id: "env_1", name: "Development", region: "us-east-1", status: "healthy" },
|
|
||||||
{ id: "env_2", name: "Staging", region: "eu-west-1", status: "degraded" }
|
|
||||||
];
|
|
||||||
export async function getEnvironments() {
|
|
||||||
return mockEnvironments;
|
|
||||||
}
|
|
||||||
export async function getEnvById(id) {
|
|
||||||
return mockEnvironments.find((env) => env.id === id);
|
|
||||||
}
|
|
||||||
export async function getHealth() {
|
|
||||||
return { status: "ok", uptime: process.uptime() };
|
|
||||||
}
|
|
||||||
export async function getVersion() {
|
|
||||||
const version = process.env.APP_VERSION || "1.0.0";
|
|
||||||
const commit = process.env.APP_COMMIT || "unknown";
|
|
||||||
return { version, commit };
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import { createMetaRouter } from "./routes/meta";
|
|
||||||
export function createApp() {
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use("/internal", createMetaRouter());
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import { createMetaRouter } from "./routes/meta";
|
import { createMetaRouter } from "./routes/meta";
|
||||||
|
import { createDigestRouter } from "./routes/digest";
|
||||||
|
|
||||||
export function createApp() {
|
export function createApp() {
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use("/internal", createMetaRouter());
|
app.use("/internal", createMetaRouter());
|
||||||
|
app.use("/api/digest", createDigestRouter());
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
33
src/digest/codex-digest-agent.prompt.txt
Normal file
33
src/digest/codex-digest-agent.prompt.txt
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
You are Lucidia, an AI agent orchestrator for BlackRoad OS. Your task is to generate concise, engaging voice-ready digests for pull requests.
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
Given the following PR metadata, generate a natural-sounding summary that:
|
||||||
|
1. Briefly explains what the PR does (1-2 sentences)
|
||||||
|
2. Highlights key changes or features
|
||||||
|
3. Mentions the author
|
||||||
|
4. Is suitable for text-to-speech playback (conversational tone)
|
||||||
|
5. Is under 200 words for optimal voice synthesis
|
||||||
|
|
||||||
|
## Format
|
||||||
|
|
||||||
|
Keep the tone professional but friendly. Avoid technical jargon where possible. Use natural sentence structure that sounds good when spoken aloud.
|
||||||
|
|
||||||
|
## Example Output
|
||||||
|
|
||||||
|
"A new pull request has been opened by developer-name in the blackroad-os repository. This PR introduces a voice digest module that automatically generates audio summaries of code changes. Key features include integration with ElevenLabs for text-to-speech, Discord webhook notifications, and support for multiple voice providers. The changes touch 5 files and add comprehensive test coverage."
|
||||||
|
|
||||||
|
## PR Metadata
|
||||||
|
|
||||||
|
Title: {{title}}
|
||||||
|
Author: {{author}}
|
||||||
|
Repository: {{owner}}/{{repo}}
|
||||||
|
PR Number: #{{number}}
|
||||||
|
Created: {{createdAt}}
|
||||||
|
Description: {{body}}
|
||||||
|
Files Changed: {{filesChanged}}
|
||||||
|
Labels: {{labels}}
|
||||||
|
|
||||||
|
## Your Digest
|
||||||
|
|
||||||
|
Generate a voice-friendly summary based on the above metadata:
|
||||||
195
src/digest/digest-generator.ts
Normal file
195
src/digest/digest-generator.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* Digest Generator
|
||||||
|
*
|
||||||
|
* Generates text digests from PR metadata using a template-based approach.
|
||||||
|
* Uses the Codex digest agent prompt for consistent formatting.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PRMetadata, DigestContent } from "./types";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the Codex digest agent prompt template
|
||||||
|
*
|
||||||
|
* @returns The prompt template string
|
||||||
|
*/
|
||||||
|
export function loadPromptTemplate(): string {
|
||||||
|
const promptPath = path.join(__dirname, "codex-digest-agent.prompt.txt");
|
||||||
|
|
||||||
|
if (fs.existsSync(promptPath)) {
|
||||||
|
return fs.readFileSync(promptPath, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback template if file not found
|
||||||
|
return getDefaultPromptTemplate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the default prompt template
|
||||||
|
*/
|
||||||
|
function getDefaultPromptTemplate(): string {
|
||||||
|
return `Generate a voice-friendly summary for PR #{{number}} titled "{{title}}" by {{author}} in {{owner}}/{{repo}}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces template placeholders with actual values
|
||||||
|
*
|
||||||
|
* @param template - Template string with {{placeholders}}
|
||||||
|
* @param metadata - PR metadata values
|
||||||
|
* @returns Filled template string
|
||||||
|
*/
|
||||||
|
export function fillTemplate(template: string, metadata: PRMetadata): string {
|
||||||
|
return template
|
||||||
|
.replace(/\{\{title\}\}/g, metadata.title)
|
||||||
|
.replace(/\{\{author\}\}/g, metadata.author)
|
||||||
|
.replace(/\{\{owner\}\}/g, metadata.owner)
|
||||||
|
.replace(/\{\{repo\}\}/g, metadata.repo)
|
||||||
|
.replace(/\{\{number\}\}/g, String(metadata.number))
|
||||||
|
.replace(/\{\{url\}\}/g, metadata.url)
|
||||||
|
.replace(/\{\{createdAt\}\}/g, metadata.createdAt)
|
||||||
|
.replace(/\{\{body\}\}/g, metadata.body || "No description provided")
|
||||||
|
.replace(
|
||||||
|
/\{\{filesChanged\}\}/g,
|
||||||
|
metadata.filesChanged?.join(", ") || "Not specified"
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
/\{\{labels\}\}/g,
|
||||||
|
metadata.labels?.join(", ") || "None"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a digest from PR metadata
|
||||||
|
*
|
||||||
|
* This creates a voice-friendly summary suitable for text-to-speech.
|
||||||
|
*
|
||||||
|
* @param metadata - PR metadata
|
||||||
|
* @returns Generated digest content
|
||||||
|
*/
|
||||||
|
export function generateDigest(metadata: PRMetadata): DigestContent {
|
||||||
|
const text = createDigestText(metadata);
|
||||||
|
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
metadata,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the digest text from PR metadata
|
||||||
|
*
|
||||||
|
* @param metadata - PR metadata
|
||||||
|
* @returns Voice-friendly digest text
|
||||||
|
*/
|
||||||
|
export function createDigestText(metadata: PRMetadata): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
// Opening
|
||||||
|
parts.push(
|
||||||
|
`A new pull request has been opened by ${metadata.author} in the ${metadata.owner}/${metadata.repo} repository.`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Title and description
|
||||||
|
parts.push(`PR number ${metadata.number} is titled "${metadata.title}".`);
|
||||||
|
|
||||||
|
// Body summary (if available)
|
||||||
|
if (metadata.body && metadata.body.trim()) {
|
||||||
|
const summary = summarizeBody(metadata.body);
|
||||||
|
if (summary) {
|
||||||
|
parts.push(summary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files changed
|
||||||
|
if (metadata.filesChanged && metadata.filesChanged.length > 0) {
|
||||||
|
const fileCount = metadata.filesChanged.length;
|
||||||
|
if (fileCount === 1) {
|
||||||
|
parts.push(`This change affects 1 file.`);
|
||||||
|
} else {
|
||||||
|
parts.push(`This change affects ${fileCount} files.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Labels
|
||||||
|
if (metadata.labels && metadata.labels.length > 0) {
|
||||||
|
const labelText = metadata.labels.join(", ");
|
||||||
|
parts.push(`Labels: ${labelText}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closing
|
||||||
|
parts.push(`You can view the full details on GitHub.`);
|
||||||
|
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summarizes a PR body to a voice-friendly length
|
||||||
|
*
|
||||||
|
* @param body - Full PR body text
|
||||||
|
* @returns Summarized text suitable for voice
|
||||||
|
*/
|
||||||
|
export function summarizeBody(body: string): string {
|
||||||
|
// Remove markdown formatting
|
||||||
|
let cleaned = body
|
||||||
|
.replace(/```[\s\S]*?```/g, "") // Remove code blocks
|
||||||
|
.replace(/`[^`]+`/g, "") // Remove inline code
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Convert links to text
|
||||||
|
.replace(/#{1,6}\s+/g, "") // Remove headers
|
||||||
|
.replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold
|
||||||
|
.replace(/\*([^*]+)\*/g, "$1") // Remove italic
|
||||||
|
.replace(/- \[[ x]\]/g, "") // Remove checkboxes
|
||||||
|
.replace(/^\s*[-*+]\s+/gm, "") // Remove list markers
|
||||||
|
.replace(/\n+/g, " ") // Collapse newlines
|
||||||
|
.replace(/\s+/g, " ") // Collapse whitespace
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Truncate to reasonable length for voice
|
||||||
|
if (cleaned.length > 200) {
|
||||||
|
// Find a good breaking point
|
||||||
|
const truncated = cleaned.substring(0, 200);
|
||||||
|
const lastSentence = truncated.lastIndexOf(".");
|
||||||
|
const lastSpace = truncated.lastIndexOf(" ");
|
||||||
|
|
||||||
|
if (lastSentence > 100) {
|
||||||
|
cleaned = truncated.substring(0, lastSentence + 1);
|
||||||
|
} else if (lastSpace > 100) {
|
||||||
|
cleaned = truncated.substring(0, lastSpace) + "...";
|
||||||
|
} else {
|
||||||
|
cleaned = truncated + "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates PR metadata for digest generation
|
||||||
|
*
|
||||||
|
* @param metadata - Metadata to validate
|
||||||
|
* @returns true if valid, throws error otherwise
|
||||||
|
*/
|
||||||
|
export function validateMetadata(metadata: PRMetadata): boolean {
|
||||||
|
if (!metadata.number || metadata.number <= 0) {
|
||||||
|
throw new Error("Valid PR number is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!metadata.title || !metadata.title.trim()) {
|
||||||
|
throw new Error("PR title is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!metadata.author || !metadata.author.trim()) {
|
||||||
|
throw new Error("PR author is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!metadata.owner || !metadata.owner.trim()) {
|
||||||
|
throw new Error("Repository owner is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!metadata.repo || !metadata.repo.trim()) {
|
||||||
|
throw new Error("Repository name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
200
src/digest/digest-voice-runner.ts
Normal file
200
src/digest/digest-voice-runner.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* Digest Voice Runner
|
||||||
|
*
|
||||||
|
* Main orchestrator for the Voice + Video Digest module.
|
||||||
|
* Handles the full pipeline: PR metadata → Digest → Voice → Discord
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PRMetadata,
|
||||||
|
DigestContent,
|
||||||
|
AudioResult,
|
||||||
|
DigestResult,
|
||||||
|
DigestRunnerConfig,
|
||||||
|
VoiceConfig,
|
||||||
|
DiscordWebhookConfig,
|
||||||
|
} from "./types";
|
||||||
|
import { generateDigest, validateMetadata } from "./digest-generator";
|
||||||
|
import {
|
||||||
|
generateSpeech,
|
||||||
|
createAudioResult,
|
||||||
|
validateConfig as validateVoiceConfig,
|
||||||
|
DEFAULT_VOICE_CONFIG,
|
||||||
|
} from "./elevenlabs";
|
||||||
|
import {
|
||||||
|
postToWebhook,
|
||||||
|
createDigestPayload,
|
||||||
|
validateConfig as validateDiscordConfig,
|
||||||
|
DEFAULT_DISCORD_CONFIG,
|
||||||
|
} from "./discord-webhook";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main digest runner that orchestrates the full pipeline
|
||||||
|
*/
|
||||||
|
export class DigestVoiceRunner {
|
||||||
|
private config: DigestRunnerConfig;
|
||||||
|
|
||||||
|
constructor(config: Partial<DigestRunnerConfig> = {}) {
|
||||||
|
this.config = {
|
||||||
|
enableVoice: config.enableVoice ?? false,
|
||||||
|
enableDiscord: config.enableDiscord ?? false,
|
||||||
|
voice: config.voice,
|
||||||
|
discord: config.discord,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the full digest pipeline for a PR
|
||||||
|
*
|
||||||
|
* @param metadata - PR metadata to process
|
||||||
|
* @param audioUrlGenerator - Optional function to generate audio URL from buffer
|
||||||
|
* @returns DigestResult with all outputs
|
||||||
|
*/
|
||||||
|
async run(
|
||||||
|
metadata: PRMetadata,
|
||||||
|
audioUrlGenerator?: (buffer: Buffer) => Promise<string>
|
||||||
|
): Promise<DigestResult> {
|
||||||
|
// Validate metadata
|
||||||
|
validateMetadata(metadata);
|
||||||
|
|
||||||
|
// Generate text digest
|
||||||
|
const digest = generateDigest(metadata);
|
||||||
|
|
||||||
|
let audio: AudioResult | undefined;
|
||||||
|
let postedToDiscord = false;
|
||||||
|
let discordMessageId: string | undefined;
|
||||||
|
|
||||||
|
// Generate voice if enabled
|
||||||
|
if (this.config.enableVoice && this.config.voice) {
|
||||||
|
validateVoiceConfig(this.config.voice);
|
||||||
|
const audioBuffer = await generateSpeech(digest.text, this.config.voice);
|
||||||
|
|
||||||
|
// Generate audio URL (requires external storage handling)
|
||||||
|
const audioUrl = audioUrlGenerator
|
||||||
|
? await audioUrlGenerator(audioBuffer)
|
||||||
|
: `data:audio/mpeg;base64,${audioBuffer.toString("base64")}`;
|
||||||
|
|
||||||
|
audio = createAudioResult(audioBuffer, audioUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post to Discord if enabled
|
||||||
|
if (this.config.enableDiscord && this.config.discord) {
|
||||||
|
validateDiscordConfig(this.config.discord);
|
||||||
|
const payload = createDigestPayload(digest, audio);
|
||||||
|
const result = await postToWebhook(this.config.discord, payload);
|
||||||
|
postedToDiscord = result.success;
|
||||||
|
discordMessageId = result.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
digest,
|
||||||
|
audio,
|
||||||
|
postedToDiscord,
|
||||||
|
discordMessageId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates only the text digest (no voice/posting)
|
||||||
|
*
|
||||||
|
* @param metadata - PR metadata
|
||||||
|
* @returns Generated digest content
|
||||||
|
*/
|
||||||
|
generateDigestOnly(metadata: PRMetadata): DigestContent {
|
||||||
|
validateMetadata(metadata);
|
||||||
|
return generateDigest(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates runner configuration
|
||||||
|
*
|
||||||
|
* @param config - New configuration values
|
||||||
|
*/
|
||||||
|
updateConfig(config: Partial<DigestRunnerConfig>): void {
|
||||||
|
this.config = {
|
||||||
|
...this.config,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets current configuration (without sensitive data)
|
||||||
|
*/
|
||||||
|
getConfig(): Omit<DigestRunnerConfig, "voice" | "discord"> & {
|
||||||
|
voice?: Omit<VoiceConfig, "apiKey">;
|
||||||
|
discord?: Omit<DiscordWebhookConfig, "webhookUrl">;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
enableVoice: this.config.enableVoice,
|
||||||
|
enableDiscord: this.config.enableDiscord,
|
||||||
|
voice: this.config.voice
|
||||||
|
? {
|
||||||
|
provider: this.config.voice.provider,
|
||||||
|
voiceId: this.config.voice.voiceId,
|
||||||
|
modelId: this.config.voice.modelId,
|
||||||
|
stability: this.config.voice.stability,
|
||||||
|
similarityBoost: this.config.voice.similarityBoost,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
discord: this.config.discord
|
||||||
|
? {
|
||||||
|
username: this.config.discord.username,
|
||||||
|
avatarUrl: this.config.discord.avatarUrl,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a DigestVoiceRunner from environment variables
|
||||||
|
*
|
||||||
|
* Environment variables:
|
||||||
|
* - ELEVENLABS_API_KEY: ElevenLabs API key
|
||||||
|
* - ELEVENLABS_VOICE_ID: Voice ID to use
|
||||||
|
* - ELEVENLABS_MODEL_ID: Model ID (optional)
|
||||||
|
* - DISCORD_WEBHOOK_URL: Discord webhook URL
|
||||||
|
* - DISCORD_BOT_USERNAME: Bot display name (optional)
|
||||||
|
* - DISCORD_BOT_AVATAR: Bot avatar URL (optional)
|
||||||
|
* - ENABLE_VOICE: Enable voice generation (true/false)
|
||||||
|
* - ENABLE_DISCORD: Enable Discord posting (true/false)
|
||||||
|
*
|
||||||
|
* @returns Configured DigestVoiceRunner
|
||||||
|
*/
|
||||||
|
export function createRunnerFromEnv(): DigestVoiceRunner {
|
||||||
|
const enableVoice = process.env.ENABLE_VOICE === "true";
|
||||||
|
const enableDiscord = process.env.ENABLE_DISCORD === "true";
|
||||||
|
|
||||||
|
const voice: VoiceConfig | undefined = enableVoice
|
||||||
|
? {
|
||||||
|
provider: "elevenlabs",
|
||||||
|
apiKey: process.env.ELEVENLABS_API_KEY || "",
|
||||||
|
voiceId:
|
||||||
|
process.env.ELEVENLABS_VOICE_ID ||
|
||||||
|
(DEFAULT_VOICE_CONFIG.voiceId as string),
|
||||||
|
modelId:
|
||||||
|
process.env.ELEVENLABS_MODEL_ID || DEFAULT_VOICE_CONFIG.modelId,
|
||||||
|
stability: DEFAULT_VOICE_CONFIG.stability,
|
||||||
|
similarityBoost: DEFAULT_VOICE_CONFIG.similarityBoost,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const discord: DiscordWebhookConfig | undefined = enableDiscord
|
||||||
|
? {
|
||||||
|
webhookUrl: process.env.DISCORD_WEBHOOK_URL || "",
|
||||||
|
username:
|
||||||
|
process.env.DISCORD_BOT_USERNAME || DEFAULT_DISCORD_CONFIG.username,
|
||||||
|
avatarUrl: process.env.DISCORD_BOT_AVATAR,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return new DigestVoiceRunner({
|
||||||
|
enableVoice,
|
||||||
|
enableDiscord,
|
||||||
|
voice,
|
||||||
|
discord,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export default runner creation
|
||||||
|
export { DEFAULT_VOICE_CONFIG, DEFAULT_DISCORD_CONFIG };
|
||||||
182
src/digest/discord-webhook.ts
Normal file
182
src/digest/discord-webhook.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* Discord Webhook Integration
|
||||||
|
*
|
||||||
|
* Posts digest notifications to Discord channels via webhooks.
|
||||||
|
* Documentation: https://discord.com/developers/docs/resources/webhook
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
DiscordWebhookConfig,
|
||||||
|
DiscordWebhookPayload,
|
||||||
|
DiscordEmbed,
|
||||||
|
DigestContent,
|
||||||
|
AudioResult,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/** Discord embed color codes */
|
||||||
|
export const DISCORD_COLORS = {
|
||||||
|
GREEN: 0x2ecc71,
|
||||||
|
BLUE: 0x3498db,
|
||||||
|
PURPLE: 0x9b59b6,
|
||||||
|
ORANGE: 0xe67e22,
|
||||||
|
RED: 0xe74c3c,
|
||||||
|
GOLD: 0xf1c40f,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts a message to Discord via webhook
|
||||||
|
*
|
||||||
|
* @param config - Discord webhook configuration
|
||||||
|
* @param payload - Message payload to send
|
||||||
|
* @returns Response from Discord API
|
||||||
|
*/
|
||||||
|
export async function postToWebhook(
|
||||||
|
config: DiscordWebhookConfig,
|
||||||
|
payload: DiscordWebhookPayload
|
||||||
|
): Promise<{ id?: string; success: boolean }> {
|
||||||
|
if (!config.webhookUrl) {
|
||||||
|
throw new Error("Discord webhook URL is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add config username/avatar if not in payload
|
||||||
|
const finalPayload: DiscordWebhookPayload = {
|
||||||
|
...payload,
|
||||||
|
username: payload.username || config.username || "Lucidia",
|
||||||
|
avatar_url: payload.avatar_url || config.avatarUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${config.webhookUrl}?wait=true`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(finalPayload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(
|
||||||
|
`Discord webhook error (${response.status}): ${errorText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { id?: string };
|
||||||
|
return { id: data.id, success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Discord embed for a PR digest
|
||||||
|
*
|
||||||
|
* @param digest - The generated digest content
|
||||||
|
* @param audio - Optional audio result
|
||||||
|
* @returns Discord embed object
|
||||||
|
*/
|
||||||
|
export function createDigestEmbed(
|
||||||
|
digest: DigestContent,
|
||||||
|
audio?: AudioResult
|
||||||
|
): DiscordEmbed {
|
||||||
|
const { metadata } = digest;
|
||||||
|
|
||||||
|
const fields: DiscordEmbed["fields"] = [
|
||||||
|
{
|
||||||
|
name: "Author",
|
||||||
|
value: `[@${metadata.author}](https://github.com/${metadata.author})`,
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Repository",
|
||||||
|
value: `[${metadata.owner}/${metadata.repo}](https://github.com/${metadata.owner}/${metadata.repo})`,
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PR Number",
|
||||||
|
value: `[#${metadata.number}](${metadata.url})`,
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (metadata.labels && metadata.labels.length > 0) {
|
||||||
|
fields.push({
|
||||||
|
name: "Labels",
|
||||||
|
value: metadata.labels.map((l) => `\`${l}\``).join(" "),
|
||||||
|
inline: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audio) {
|
||||||
|
fields.push({
|
||||||
|
name: "🔊 Audio Digest",
|
||||||
|
value: `[Listen to Summary (${audio.format.toUpperCase()})](${audio.audioUrl})`,
|
||||||
|
inline: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `📋 ${metadata.title}`,
|
||||||
|
description: digest.text,
|
||||||
|
url: metadata.url,
|
||||||
|
color: DISCORD_COLORS.PURPLE,
|
||||||
|
fields,
|
||||||
|
footer: {
|
||||||
|
text: "Lucidia Voice Digest",
|
||||||
|
},
|
||||||
|
timestamp: digest.generatedAt,
|
||||||
|
thumbnail: {
|
||||||
|
url: `https://github.com/${metadata.author}.png`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a full Discord webhook payload for a digest
|
||||||
|
*
|
||||||
|
* @param digest - The generated digest content
|
||||||
|
* @param audio - Optional audio result
|
||||||
|
* @returns Complete Discord webhook payload
|
||||||
|
*/
|
||||||
|
export function createDigestPayload(
|
||||||
|
digest: DigestContent,
|
||||||
|
audio?: AudioResult
|
||||||
|
): DiscordWebhookPayload {
|
||||||
|
const embed = createDigestEmbed(digest, audio);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: audio
|
||||||
|
? "🎙️ **New Voice Digest Available**"
|
||||||
|
: "📋 **New PR Digest**",
|
||||||
|
embeds: [embed],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates Discord webhook configuration
|
||||||
|
*
|
||||||
|
* @param config - Configuration to validate
|
||||||
|
* @returns true if valid, throws error otherwise
|
||||||
|
*/
|
||||||
|
export function validateConfig(config: DiscordWebhookConfig): boolean {
|
||||||
|
if (!config.webhookUrl) {
|
||||||
|
throw new Error(
|
||||||
|
"Discord webhook URL is required (DISCORD_WEBHOOK_URL)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic URL validation
|
||||||
|
try {
|
||||||
|
const url = new URL(config.webhookUrl);
|
||||||
|
if (!url.hostname.includes("discord.com") && !url.hostname.includes("discordapp.com")) {
|
||||||
|
throw new Error("Invalid Discord webhook URL format");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw new Error("Invalid Discord webhook URL format");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default Discord webhook configuration
|
||||||
|
*/
|
||||||
|
export const DEFAULT_DISCORD_CONFIG: Partial<DiscordWebhookConfig> = {
|
||||||
|
username: "Lucidia",
|
||||||
|
};
|
||||||
164
src/digest/elevenlabs.ts
Normal file
164
src/digest/elevenlabs.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* ElevenLabs Text-to-Speech Client
|
||||||
|
*
|
||||||
|
* Integrates with ElevenLabs API for high-quality voice synthesis.
|
||||||
|
* Documentation: https://docs.elevenlabs.io/api-reference
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { VoiceConfig, AudioResult } from "./types";
|
||||||
|
|
||||||
|
const ELEVENLABS_API_BASE = "https://api.elevenlabs.io/v1";
|
||||||
|
|
||||||
|
export interface ElevenLabsVoiceSettings {
|
||||||
|
stability: number;
|
||||||
|
similarity_boost: number;
|
||||||
|
style?: number;
|
||||||
|
use_speaker_boost?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElevenLabsRequest {
|
||||||
|
text: string;
|
||||||
|
model_id: string;
|
||||||
|
voice_settings: ElevenLabsVoiceSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElevenLabsVoice {
|
||||||
|
voice_id: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
labels?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate speech from text using ElevenLabs API
|
||||||
|
*
|
||||||
|
* @param text - The text to convert to speech
|
||||||
|
* @param config - Voice configuration with API key and voice settings
|
||||||
|
* @returns Buffer containing the audio data in MP3 format
|
||||||
|
*/
|
||||||
|
export async function generateSpeech(
|
||||||
|
text: string,
|
||||||
|
config: VoiceConfig
|
||||||
|
): Promise<Buffer> {
|
||||||
|
if (config.provider !== "elevenlabs") {
|
||||||
|
throw new Error(`Unsupported provider: ${config.provider}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.apiKey) {
|
||||||
|
throw new Error("ElevenLabs API key is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.voiceId) {
|
||||||
|
throw new Error("Voice ID is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = `${ELEVENLABS_API_BASE}/text-to-speech/${config.voiceId}`;
|
||||||
|
|
||||||
|
const requestBody: ElevenLabsRequest = {
|
||||||
|
text,
|
||||||
|
model_id: config.modelId || "eleven_multilingual_v2",
|
||||||
|
voice_settings: {
|
||||||
|
stability: config.stability ?? 0.5,
|
||||||
|
similarity_boost: config.similarityBoost ?? 0.75,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"xi-api-key": config.apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(
|
||||||
|
`ElevenLabs API error (${response.status}): ${errorText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
return Buffer.from(arrayBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List available voices from ElevenLabs
|
||||||
|
*
|
||||||
|
* @param apiKey - ElevenLabs API key
|
||||||
|
* @returns Array of available voices
|
||||||
|
*/
|
||||||
|
export async function listVoices(apiKey: string): Promise<ElevenLabsVoice[]> {
|
||||||
|
const response = await fetch(`${ELEVENLABS_API_BASE}/voices`, {
|
||||||
|
headers: {
|
||||||
|
"xi-api-key": apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(
|
||||||
|
`ElevenLabs API error (${response.status}): ${errorText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { voices: ElevenLabsVoice[] };
|
||||||
|
return data.voices;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an AudioResult from generated speech buffer
|
||||||
|
*
|
||||||
|
* @param audioBuffer - The audio buffer from generateSpeech
|
||||||
|
* @param audioUrl - URL where the audio will be accessible
|
||||||
|
* @returns AudioResult object
|
||||||
|
*/
|
||||||
|
export function createAudioResult(
|
||||||
|
audioBuffer: Buffer,
|
||||||
|
audioUrl: string
|
||||||
|
): AudioResult {
|
||||||
|
return {
|
||||||
|
audioUrl,
|
||||||
|
format: "mp3",
|
||||||
|
sizeBytes: audioBuffer.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates ElevenLabs configuration
|
||||||
|
*
|
||||||
|
* @param config - Voice configuration to validate
|
||||||
|
* @returns true if valid, throws error otherwise
|
||||||
|
*/
|
||||||
|
export function validateConfig(config: VoiceConfig): boolean {
|
||||||
|
if (!config.apiKey) {
|
||||||
|
throw new Error("ElevenLabs API key is required (ELEVENLABS_API_KEY)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.voiceId) {
|
||||||
|
throw new Error("Voice ID is required (ELEVENLABS_VOICE_ID)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.stability !== undefined && (config.stability < 0 || config.stability > 1)) {
|
||||||
|
throw new Error("Stability must be between 0 and 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.similarityBoost !== undefined && (config.similarityBoost < 0 || config.similarityBoost > 1)) {
|
||||||
|
throw new Error("Similarity boost must be between 0 and 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default voice configuration for ElevenLabs
|
||||||
|
* Uses the Rachel voice which is good for narration
|
||||||
|
*/
|
||||||
|
export const DEFAULT_VOICE_CONFIG: Partial<VoiceConfig> = {
|
||||||
|
provider: "elevenlabs",
|
||||||
|
voiceId: "21m00Tcm4TlvDq8ikWAM", // Rachel - good for narration
|
||||||
|
modelId: "eleven_multilingual_v2",
|
||||||
|
stability: 0.5,
|
||||||
|
similarityBoost: 0.75,
|
||||||
|
};
|
||||||
78
src/digest/index.ts
Normal file
78
src/digest/index.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Voice + Video Digest Module
|
||||||
|
*
|
||||||
|
* Provides functionality for:
|
||||||
|
* - Generating PR digests using Codex logic
|
||||||
|
* - Converting text to speech via ElevenLabs
|
||||||
|
* - Posting notifications to Discord
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { DigestVoiceRunner, createRunnerFromEnv } from './digest';
|
||||||
|
*
|
||||||
|
* // Create runner from environment variables
|
||||||
|
* const runner = createRunnerFromEnv();
|
||||||
|
*
|
||||||
|
* // Generate digest for a PR
|
||||||
|
* const result = await runner.run({
|
||||||
|
* number: 42,
|
||||||
|
* title: 'Add voice digest feature',
|
||||||
|
* author: 'developer',
|
||||||
|
* owner: 'org',
|
||||||
|
* repo: 'repo',
|
||||||
|
* url: 'https://github.com/org/repo/pull/42',
|
||||||
|
* createdAt: new Date().toISOString(),
|
||||||
|
* body: 'This PR adds voice digest functionality.'
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* console.log(result.digest.text);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type {
|
||||||
|
PRMetadata,
|
||||||
|
DigestContent,
|
||||||
|
VoiceConfig,
|
||||||
|
AudioResult,
|
||||||
|
DiscordWebhookConfig,
|
||||||
|
DiscordWebhookPayload,
|
||||||
|
DiscordEmbed,
|
||||||
|
DigestResult,
|
||||||
|
DigestRunnerConfig,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
// Main runner
|
||||||
|
export {
|
||||||
|
DigestVoiceRunner,
|
||||||
|
createRunnerFromEnv,
|
||||||
|
DEFAULT_VOICE_CONFIG,
|
||||||
|
DEFAULT_DISCORD_CONFIG,
|
||||||
|
} from "./digest-voice-runner";
|
||||||
|
|
||||||
|
// Digest generation
|
||||||
|
export {
|
||||||
|
generateDigest,
|
||||||
|
createDigestText,
|
||||||
|
summarizeBody,
|
||||||
|
validateMetadata,
|
||||||
|
loadPromptTemplate,
|
||||||
|
fillTemplate,
|
||||||
|
} from "./digest-generator";
|
||||||
|
|
||||||
|
// ElevenLabs TTS
|
||||||
|
export {
|
||||||
|
generateSpeech,
|
||||||
|
listVoices,
|
||||||
|
createAudioResult,
|
||||||
|
validateConfig as validateVoiceConfig,
|
||||||
|
} from "./elevenlabs";
|
||||||
|
|
||||||
|
// Discord webhook
|
||||||
|
export {
|
||||||
|
postToWebhook,
|
||||||
|
createDigestEmbed,
|
||||||
|
createDigestPayload,
|
||||||
|
validateConfig as validateDiscordConfig,
|
||||||
|
DISCORD_COLORS,
|
||||||
|
} from "./discord-webhook";
|
||||||
137
src/digest/types.ts
Normal file
137
src/digest/types.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Types for the Voice + Video Digest Module
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PRMetadata {
|
||||||
|
/** Pull request number */
|
||||||
|
number: number;
|
||||||
|
/** Pull request title */
|
||||||
|
title: string;
|
||||||
|
/** Pull request body/description */
|
||||||
|
body: string | null;
|
||||||
|
/** Author login */
|
||||||
|
author: string;
|
||||||
|
/** Repository owner */
|
||||||
|
owner: string;
|
||||||
|
/** Repository name */
|
||||||
|
repo: string;
|
||||||
|
/** PR URL */
|
||||||
|
url: string;
|
||||||
|
/** Files changed in the PR */
|
||||||
|
filesChanged?: string[];
|
||||||
|
/** Labels applied to the PR */
|
||||||
|
labels?: string[];
|
||||||
|
/** Timestamp when PR was created */
|
||||||
|
createdAt: string;
|
||||||
|
/** Timestamp when PR was merged (if applicable) */
|
||||||
|
mergedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigestContent {
|
||||||
|
/** Plain text summary */
|
||||||
|
text: string;
|
||||||
|
/** Optional SSML-formatted text for better TTS */
|
||||||
|
ssml?: string;
|
||||||
|
/** PR metadata used to generate this digest */
|
||||||
|
metadata: PRMetadata;
|
||||||
|
/** When this digest was generated */
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceConfig {
|
||||||
|
/** Voice provider (elevenlabs, playht, aws-polly, google-tts) */
|
||||||
|
provider: "elevenlabs" | "playht" | "aws-polly" | "google-tts";
|
||||||
|
/** Voice ID or name */
|
||||||
|
voiceId: string;
|
||||||
|
/** API key for the provider */
|
||||||
|
apiKey: string;
|
||||||
|
/** Model ID (for providers that support multiple models) */
|
||||||
|
modelId?: string;
|
||||||
|
/** Voice stability (0-1 for ElevenLabs) */
|
||||||
|
stability?: number;
|
||||||
|
/** Voice similarity boost (0-1 for ElevenLabs) */
|
||||||
|
similarityBoost?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioResult {
|
||||||
|
/** URL to the generated audio file */
|
||||||
|
audioUrl: string;
|
||||||
|
/** Duration in seconds */
|
||||||
|
durationSeconds?: number;
|
||||||
|
/** Audio format */
|
||||||
|
format: "mp3" | "m4a" | "wav";
|
||||||
|
/** File size in bytes */
|
||||||
|
sizeBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordWebhookConfig {
|
||||||
|
/** Discord webhook URL */
|
||||||
|
webhookUrl: string;
|
||||||
|
/** Bot username to display */
|
||||||
|
username?: string;
|
||||||
|
/** Bot avatar URL */
|
||||||
|
avatarUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordEmbed {
|
||||||
|
/** Embed title */
|
||||||
|
title?: string;
|
||||||
|
/** Embed description */
|
||||||
|
description?: string;
|
||||||
|
/** Embed URL */
|
||||||
|
url?: string;
|
||||||
|
/** Embed color (decimal) */
|
||||||
|
color?: number;
|
||||||
|
/** Embed fields */
|
||||||
|
fields?: Array<{
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
inline?: boolean;
|
||||||
|
}>;
|
||||||
|
/** Embed footer */
|
||||||
|
footer?: {
|
||||||
|
text: string;
|
||||||
|
icon_url?: string;
|
||||||
|
};
|
||||||
|
/** Embed timestamp (ISO 8601) */
|
||||||
|
timestamp?: string;
|
||||||
|
/** Embed thumbnail */
|
||||||
|
thumbnail?: {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordWebhookPayload {
|
||||||
|
/** Message content */
|
||||||
|
content?: string;
|
||||||
|
/** Bot username */
|
||||||
|
username?: string;
|
||||||
|
/** Bot avatar URL */
|
||||||
|
avatar_url?: string;
|
||||||
|
/** Embeds array */
|
||||||
|
embeds?: DiscordEmbed[];
|
||||||
|
/** Thread name (for forum channels) */
|
||||||
|
thread_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigestResult {
|
||||||
|
/** Generated digest content */
|
||||||
|
digest: DigestContent;
|
||||||
|
/** Generated audio (if voice enabled) */
|
||||||
|
audio?: AudioResult;
|
||||||
|
/** Whether successfully posted to Discord */
|
||||||
|
postedToDiscord?: boolean;
|
||||||
|
/** Discord message ID if posted */
|
||||||
|
discordMessageId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigestRunnerConfig {
|
||||||
|
/** Voice configuration */
|
||||||
|
voice?: VoiceConfig;
|
||||||
|
/** Discord webhook configuration */
|
||||||
|
discord?: DiscordWebhookConfig;
|
||||||
|
/** Whether to generate audio */
|
||||||
|
enableVoice: boolean;
|
||||||
|
/** Whether to post to Discord */
|
||||||
|
enableDiscord: boolean;
|
||||||
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import cron from "node-cron";
|
|
||||||
import { Queue } from "bullmq";
|
|
||||||
export function buildHeartbeatQueue(connection = { host: "localhost", port: 6379 }) {
|
|
||||||
return new Queue("heartbeat", { connection });
|
|
||||||
}
|
|
||||||
let defaultQueue = null;
|
|
||||||
function getDefaultQueue() {
|
|
||||||
if (!defaultQueue) {
|
|
||||||
defaultQueue = buildHeartbeatQueue();
|
|
||||||
}
|
|
||||||
return defaultQueue;
|
|
||||||
}
|
|
||||||
export async function enqueueHeartbeat(queue = getDefaultQueue()) {
|
|
||||||
const payload = { ts: Date.now() };
|
|
||||||
await queue.add("heartbeat", payload);
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
export function startHeartbeatScheduler(queue = getDefaultQueue()) {
|
|
||||||
const task = cron.schedule("*/5 * * * *", () => {
|
|
||||||
enqueueHeartbeat(queue);
|
|
||||||
});
|
|
||||||
return task;
|
|
||||||
}
|
|
||||||
23
src/index.js
23
src/index.js
@@ -1,23 +0,0 @@
|
|||||||
import Fastify from "fastify";
|
|
||||||
import { getBuildInfo } from "./utils/buildInfo";
|
|
||||||
export async function createServer() {
|
|
||||||
const server = Fastify({ logger: true });
|
|
||||||
server.get("/health", async () => ({ status: "ok" }));
|
|
||||||
server.get("/version", async () => {
|
|
||||||
const info = getBuildInfo();
|
|
||||||
return { version: info.version, commit: info.commit };
|
|
||||||
});
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
if (require.main === module) {
|
|
||||||
const port = Number(process.env.PORT || 3000);
|
|
||||||
createServer()
|
|
||||||
.then((server) => server.listen({ port, host: "0.0.0.0" }))
|
|
||||||
.then((address) => {
|
|
||||||
console.log(`Server listening at ${address}`);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
55
src/index.ts
55
src/index.ts
@@ -1,5 +1,21 @@
|
|||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
import { getBuildInfo } from "./utils/buildInfo";
|
import { getBuildInfo } from "./utils/buildInfo";
|
||||||
|
import {
|
||||||
|
DigestVoiceRunner,
|
||||||
|
createRunnerFromEnv,
|
||||||
|
generateDigest,
|
||||||
|
validateMetadata,
|
||||||
|
} from "./digest";
|
||||||
|
import type { PRMetadata, DigestRunnerConfig } from "./digest";
|
||||||
|
|
||||||
|
let runner: DigestVoiceRunner | null = null;
|
||||||
|
|
||||||
|
function getRunner(): DigestVoiceRunner {
|
||||||
|
if (!runner) {
|
||||||
|
runner = createRunnerFromEnv();
|
||||||
|
}
|
||||||
|
return runner;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createServer() {
|
export async function createServer() {
|
||||||
const server = Fastify({ logger: true });
|
const server = Fastify({ logger: true });
|
||||||
@@ -11,6 +27,45 @@ export async function createServer() {
|
|||||||
return { version: info.version, commit: info.commit };
|
return { version: info.version, commit: info.commit };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Digest routes
|
||||||
|
server.post<{ Body: PRMetadata }>("/api/digest/generate", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const metadata = request.body;
|
||||||
|
validateMetadata(metadata);
|
||||||
|
const digest = generateDigest(metadata);
|
||||||
|
return { digest };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
reply.status(400);
|
||||||
|
return { error: message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.post<{ Body: PRMetadata }>("/api/digest/run", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const metadata = request.body;
|
||||||
|
const digestRunner = getRunner();
|
||||||
|
const result = await digestRunner.run(metadata);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
reply.status(500);
|
||||||
|
return { error: message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.get("/api/digest/config", async () => {
|
||||||
|
const digestRunner = getRunner();
|
||||||
|
return digestRunner.getConfig();
|
||||||
|
});
|
||||||
|
|
||||||
|
server.post<{ Body: Partial<DigestRunnerConfig> }>("/api/digest/config", async (request) => {
|
||||||
|
const updates = request.body;
|
||||||
|
const digestRunner = getRunner();
|
||||||
|
digestRunner.updateConfig(updates);
|
||||||
|
return { success: true, config: digestRunner.getConfig() };
|
||||||
|
});
|
||||||
|
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { Worker } from "bullmq";
|
|
||||||
export function registerSampleJobProcessor(connection = { host: "localhost", port: 6379 }) {
|
|
||||||
const worker = new Worker("sample", async (job) => {
|
|
||||||
console.log(`Processing job ${job.id}`);
|
|
||||||
return job.data;
|
|
||||||
}, { connection });
|
|
||||||
worker.on("failed", (job, err) => {
|
|
||||||
console.error(`Job ${job?.id} failed`, err);
|
|
||||||
});
|
|
||||||
return worker;
|
|
||||||
}
|
|
||||||
113
src/routes/digest.ts
Normal file
113
src/routes/digest.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import {
|
||||||
|
DigestVoiceRunner,
|
||||||
|
createRunnerFromEnv,
|
||||||
|
generateDigest,
|
||||||
|
validateMetadata,
|
||||||
|
} from "../digest";
|
||||||
|
import type { PRMetadata, DigestRunnerConfig } from "../digest";
|
||||||
|
|
||||||
|
let runner: DigestVoiceRunner | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets or creates the digest runner instance
|
||||||
|
*/
|
||||||
|
function getRunner(): DigestVoiceRunner {
|
||||||
|
if (!runner) {
|
||||||
|
runner = createRunnerFromEnv();
|
||||||
|
}
|
||||||
|
return runner;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the digest router with all digest-related endpoints
|
||||||
|
*/
|
||||||
|
export function createDigestRouter() {
|
||||||
|
const digestRouter = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /digest/generate
|
||||||
|
*
|
||||||
|
* Generates a text digest from PR metadata
|
||||||
|
*
|
||||||
|
* Request body: PRMetadata
|
||||||
|
* Response: { digest: DigestContent }
|
||||||
|
*/
|
||||||
|
digestRouter.post("/generate", (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const metadata = req.body as PRMetadata;
|
||||||
|
validateMetadata(metadata);
|
||||||
|
|
||||||
|
const digest = generateDigest(metadata);
|
||||||
|
res.json({ digest });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
res.status(400).json({ error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /digest/run
|
||||||
|
*
|
||||||
|
* Runs the full digest pipeline (text + optional voice + optional Discord)
|
||||||
|
*
|
||||||
|
* Request body: PRMetadata
|
||||||
|
* Response: DigestResult
|
||||||
|
*/
|
||||||
|
digestRouter.post("/run", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const metadata = req.body as PRMetadata;
|
||||||
|
const digestRunner = getRunner();
|
||||||
|
|
||||||
|
const result = await digestRunner.run(metadata);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
res.status(500).json({ error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /digest/config
|
||||||
|
*
|
||||||
|
* Returns the current runner configuration (without sensitive data)
|
||||||
|
*
|
||||||
|
* Response: Partial<DigestRunnerConfig>
|
||||||
|
*/
|
||||||
|
digestRouter.get("/config", (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const digestRunner = getRunner();
|
||||||
|
const config = digestRunner.getConfig();
|
||||||
|
res.json(config);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
res.status(500).json({ error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /digest/config
|
||||||
|
*
|
||||||
|
* Updates the runner configuration
|
||||||
|
*
|
||||||
|
* Request body: Partial<DigestRunnerConfig>
|
||||||
|
* Response: { success: true, config: ... }
|
||||||
|
*/
|
||||||
|
digestRouter.post("/config", (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const updates = req.body as Partial<DigestRunnerConfig>;
|
||||||
|
const digestRunner = getRunner();
|
||||||
|
|
||||||
|
digestRunner.updateConfig(updates);
|
||||||
|
const config = digestRunner.getConfig();
|
||||||
|
|
||||||
|
res.json({ success: true, config });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
res.status(400).json({ error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return digestRouter;
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { Router } from "express";
|
|
||||||
import { getBuildInfo } from "../utils/buildInfo";
|
|
||||||
export function createMetaRouter() {
|
|
||||||
const router = Router();
|
|
||||||
router.get("/health", (_req, res) => {
|
|
||||||
res.json({ status: "ok" });
|
|
||||||
});
|
|
||||||
router.get("/version", (_req, res) => {
|
|
||||||
const info = getBuildInfo();
|
|
||||||
res.json({ version: info.version, commit: info.commit });
|
|
||||||
});
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export {};
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import * as childProcess from "child_process";
|
|
||||||
export function readGitCommit() {
|
|
||||||
try {
|
|
||||||
return childProcess.execSync("git rev-parse HEAD", { stdio: "pipe" }).toString().trim();
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export function getBuildInfo(gitReader = readGitCommit) {
|
|
||||||
const version = process.env.APP_VERSION || "1.0.0";
|
|
||||||
const commit = process.env.APP_COMMIT || gitReader() || "unknown";
|
|
||||||
const buildTime = new Date().toISOString();
|
|
||||||
return { version, commit, buildTime };
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import request from "supertest";
|
|
||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
||||||
import { createApp } from "../src/app";
|
|
||||||
import { createServer } from "../src/index";
|
|
||||||
vi.mock("../src/utils/buildInfo", () => ({
|
|
||||||
getBuildInfo: () => ({ version: "test-version", commit: "test-commit", buildTime: "now" })
|
|
||||||
}));
|
|
||||||
describe("Express internal routes", () => {
|
|
||||||
const app = createApp();
|
|
||||||
it("returns health", async () => {
|
|
||||||
const response = await request(app).get("/internal/health");
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body).toEqual({ status: "ok" });
|
|
||||||
});
|
|
||||||
it("returns version", async () => {
|
|
||||||
const response = await request(app).get("/internal/version");
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body).toEqual({ version: "test-version", commit: "test-commit" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
describe("Fastify public routes", () => {
|
|
||||||
let server;
|
|
||||||
beforeEach(async () => {
|
|
||||||
server = await createServer();
|
|
||||||
});
|
|
||||||
it("returns health", async () => {
|
|
||||||
const response = await server.inject({ method: "GET", url: "/health" });
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
expect(response.json()).toEqual({ status: "ok" });
|
|
||||||
});
|
|
||||||
it("returns version", async () => {
|
|
||||||
const response = await server.inject({ method: "GET", url: "/version" });
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
expect(response.json()).toEqual({ version: "test-version", commit: "test-commit" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
|
||||||
import { getBuildInfo } from "../src/utils/buildInfo";
|
|
||||||
const originalEnv = { ...process.env };
|
|
||||||
afterEach(() => {
|
|
||||||
process.env = { ...originalEnv };
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
describe("getBuildInfo", () => {
|
|
||||||
it("uses env vars when provided", () => {
|
|
||||||
process.env.APP_VERSION = "3.0.0";
|
|
||||||
process.env.APP_COMMIT = "xyz";
|
|
||||||
const info = getBuildInfo();
|
|
||||||
expect(info.version).toBe("3.0.0");
|
|
||||||
expect(info.commit).toBe("xyz");
|
|
||||||
expect(new Date(info.buildTime).toString()).not.toBe("Invalid Date");
|
|
||||||
});
|
|
||||||
it("falls back to git when env missing", () => {
|
|
||||||
const gitReader = vi.fn().mockReturnValue("abcdef");
|
|
||||||
delete process.env.APP_COMMIT;
|
|
||||||
const info = getBuildInfo(gitReader);
|
|
||||||
expect(info.commit).toBe("abcdef");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
23
tests/debug.test.ts
Normal file
23
tests/debug.test.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
describe("Debug imports", () => {
|
||||||
|
it("should import createApp", async () => {
|
||||||
|
console.log("Importing createApp...");
|
||||||
|
const { createApp } = await import("../src/app");
|
||||||
|
console.log("createApp imported:", !!createApp);
|
||||||
|
|
||||||
|
// Check if app.ts actually imports the digest router
|
||||||
|
const app = createApp();
|
||||||
|
console.log("App created:", !!app);
|
||||||
|
|
||||||
|
// Print all layers
|
||||||
|
const router = (app as any)._router;
|
||||||
|
console.log("Router layers count:", router?.stack?.length);
|
||||||
|
|
||||||
|
router?.stack?.forEach((layer: any, i: number) => {
|
||||||
|
console.log(`Layer ${i}:`, layer.name, layer.regexp?.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
184
tests/digestGenerator.test.ts
Normal file
184
tests/digestGenerator.test.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
generateDigest,
|
||||||
|
createDigestText,
|
||||||
|
summarizeBody,
|
||||||
|
validateMetadata,
|
||||||
|
fillTemplate,
|
||||||
|
} from "../src/digest/digest-generator";
|
||||||
|
import type { PRMetadata } from "../src/digest/types";
|
||||||
|
|
||||||
|
const sampleMetadata: PRMetadata = {
|
||||||
|
number: 42,
|
||||||
|
title: "Add voice digest feature",
|
||||||
|
body: "This PR introduces a new voice digest module that generates audio summaries.",
|
||||||
|
author: "test-user",
|
||||||
|
owner: "test-org",
|
||||||
|
repo: "test-repo",
|
||||||
|
url: "https://github.com/test-org/test-repo/pull/42",
|
||||||
|
createdAt: "2024-01-15T10:00:00Z",
|
||||||
|
filesChanged: ["src/digest/index.ts", "src/digest/types.ts"],
|
||||||
|
labels: ["feature", "voice"],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("generateDigest", () => {
|
||||||
|
it("generates a digest with correct structure", () => {
|
||||||
|
const digest = generateDigest(sampleMetadata);
|
||||||
|
|
||||||
|
expect(digest.text).toBeDefined();
|
||||||
|
expect(digest.text.length).toBeGreaterThan(0);
|
||||||
|
expect(digest.metadata).toEqual(sampleMetadata);
|
||||||
|
expect(digest.generatedAt).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes author and repo in the digest text", () => {
|
||||||
|
const digest = generateDigest(sampleMetadata);
|
||||||
|
|
||||||
|
expect(digest.text).toContain("test-user");
|
||||||
|
expect(digest.text).toContain("test-org/test-repo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes PR number and title", () => {
|
||||||
|
const digest = generateDigest(sampleMetadata);
|
||||||
|
|
||||||
|
expect(digest.text).toContain("42");
|
||||||
|
expect(digest.text).toContain("Add voice digest feature");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createDigestText", () => {
|
||||||
|
it("creates text mentioning the author", () => {
|
||||||
|
const text = createDigestText(sampleMetadata);
|
||||||
|
expect(text).toContain("test-user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates text mentioning files changed count", () => {
|
||||||
|
const text = createDigestText(sampleMetadata);
|
||||||
|
expect(text).toContain("2 files");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles single file change", () => {
|
||||||
|
const metadata = { ...sampleMetadata, filesChanged: ["single-file.ts"] };
|
||||||
|
const text = createDigestText(metadata);
|
||||||
|
expect(text).toContain("1 file");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles no files changed", () => {
|
||||||
|
const metadata = { ...sampleMetadata, filesChanged: undefined };
|
||||||
|
const text = createDigestText(metadata);
|
||||||
|
expect(text).not.toContain("files");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes labels when present", () => {
|
||||||
|
const text = createDigestText(sampleMetadata);
|
||||||
|
expect(text).toContain("feature");
|
||||||
|
expect(text).toContain("voice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles no labels", () => {
|
||||||
|
const metadata = { ...sampleMetadata, labels: undefined };
|
||||||
|
const text = createDigestText(metadata);
|
||||||
|
expect(text).not.toContain("Labels:");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("summarizeBody", () => {
|
||||||
|
it("returns empty string for empty body", () => {
|
||||||
|
expect(summarizeBody("")).toBe("");
|
||||||
|
expect(summarizeBody(" ")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes code blocks", () => {
|
||||||
|
const body = "Some text ```code block``` more text";
|
||||||
|
const result = summarizeBody(body);
|
||||||
|
expect(result).not.toContain("```");
|
||||||
|
expect(result).toContain("Some text");
|
||||||
|
expect(result).toContain("more text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts markdown links to text", () => {
|
||||||
|
const body = "Check out [this link](https://example.com)";
|
||||||
|
const result = summarizeBody(body);
|
||||||
|
expect(result).toContain("this link");
|
||||||
|
expect(result).not.toContain("https://example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes bold and italic formatting", () => {
|
||||||
|
const body = "This is **bold** and *italic* text";
|
||||||
|
const result = summarizeBody(body);
|
||||||
|
expect(result).toContain("bold");
|
||||||
|
expect(result).toContain("italic");
|
||||||
|
expect(result).not.toContain("**");
|
||||||
|
expect(result).not.toContain("*");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates long text", () => {
|
||||||
|
const longBody = "A".repeat(500);
|
||||||
|
const result = summarizeBody(longBody);
|
||||||
|
expect(result.length).toBeLessThanOrEqual(210); // 200 + "..."
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateMetadata", () => {
|
||||||
|
it("passes for valid metadata", () => {
|
||||||
|
expect(() => validateMetadata(sampleMetadata)).not.toThrow();
|
||||||
|
expect(validateMetadata(sampleMetadata)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for missing PR number", () => {
|
||||||
|
const invalid = { ...sampleMetadata, number: 0 };
|
||||||
|
expect(() => validateMetadata(invalid)).toThrow("PR number");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for missing title", () => {
|
||||||
|
const invalid = { ...sampleMetadata, title: "" };
|
||||||
|
expect(() => validateMetadata(invalid)).toThrow("title");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for missing author", () => {
|
||||||
|
const invalid = { ...sampleMetadata, author: "" };
|
||||||
|
expect(() => validateMetadata(invalid)).toThrow("author");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for missing owner", () => {
|
||||||
|
const invalid = { ...sampleMetadata, owner: "" };
|
||||||
|
expect(() => validateMetadata(invalid)).toThrow("owner");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for missing repo", () => {
|
||||||
|
const invalid = { ...sampleMetadata, repo: "" };
|
||||||
|
expect(() => validateMetadata(invalid)).toThrow("Repository name");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fillTemplate", () => {
|
||||||
|
it("replaces placeholders with values", () => {
|
||||||
|
const template = "PR #{{number}} by {{author}}";
|
||||||
|
const result = fillTemplate(template, sampleMetadata);
|
||||||
|
expect(result).toBe("PR #42 by test-user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles all placeholder types", () => {
|
||||||
|
const template =
|
||||||
|
"{{title}} in {{owner}}/{{repo}} at {{url}} created {{createdAt}}";
|
||||||
|
const result = fillTemplate(template, sampleMetadata);
|
||||||
|
|
||||||
|
expect(result).toContain("Add voice digest feature");
|
||||||
|
expect(result).toContain("test-org/test-repo");
|
||||||
|
expect(result).toContain("https://github.com/test-org/test-repo/pull/42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles missing body", () => {
|
||||||
|
const template = "Body: {{body}}";
|
||||||
|
const metadata = { ...sampleMetadata, body: null };
|
||||||
|
const result = fillTemplate(template, metadata);
|
||||||
|
expect(result).toBe("Body: No description provided");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles files and labels", () => {
|
||||||
|
const template = "Files: {{filesChanged}}, Labels: {{labels}}";
|
||||||
|
const result = fillTemplate(template, sampleMetadata);
|
||||||
|
expect(result).toContain("src/digest/index.ts");
|
||||||
|
expect(result).toContain("feature, voice");
|
||||||
|
});
|
||||||
|
});
|
||||||
81
tests/digestRoutes.test.ts
Normal file
81
tests/digestRoutes.test.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import request from "supertest";
|
||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
import { createApp } from "../src/app";
|
||||||
|
|
||||||
|
const sampleMetadata = {
|
||||||
|
number: 42,
|
||||||
|
title: "Add voice digest feature",
|
||||||
|
body: "This PR adds voice digest.",
|
||||||
|
author: "test-user",
|
||||||
|
owner: "test-org",
|
||||||
|
repo: "test-repo",
|
||||||
|
url: "https://github.com/test-org/test-repo/pull/42",
|
||||||
|
createdAt: "2024-01-15T10:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Express Digest API Routes", () => {
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
describe("POST /api/digest/generate", () => {
|
||||||
|
it("generates digest for valid metadata", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post("/api/digest/generate")
|
||||||
|
.send(sampleMetadata);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.digest).toBeDefined();
|
||||||
|
expect(response.body.digest.text).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for invalid metadata (missing number)", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post("/api/digest/generate")
|
||||||
|
.send({ ...sampleMetadata, number: 0 });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for missing title", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post("/api/digest/generate")
|
||||||
|
.send({ ...sampleMetadata, title: "" });
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/digest/run", () => {
|
||||||
|
it("runs digest pipeline for valid metadata", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post("/api/digest/run")
|
||||||
|
.send(sampleMetadata);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.digest).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/digest/config", () => {
|
||||||
|
it("returns current configuration", async () => {
|
||||||
|
const response = await request(app).get("/api/digest/config");
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(typeof response.body.enableVoice).toBe("boolean");
|
||||||
|
expect(typeof response.body.enableDiscord).toBe("boolean");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/digest/config", () => {
|
||||||
|
it("updates configuration", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post("/api/digest/config")
|
||||||
|
.send({ enableVoice: true });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.success).toBe(true);
|
||||||
|
expect(response.body.config).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
183
tests/digestVoiceRunner.test.ts
Normal file
183
tests/digestVoiceRunner.test.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
DigestVoiceRunner,
|
||||||
|
createRunnerFromEnv,
|
||||||
|
} from "../src/digest/digest-voice-runner";
|
||||||
|
import type { PRMetadata } from "../src/digest/types";
|
||||||
|
|
||||||
|
const sampleMetadata: PRMetadata = {
|
||||||
|
number: 42,
|
||||||
|
title: "Add voice digest feature",
|
||||||
|
body: "This PR introduces a new voice digest module.",
|
||||||
|
author: "test-user",
|
||||||
|
owner: "test-org",
|
||||||
|
repo: "test-repo",
|
||||||
|
url: "https://github.com/test-org/test-repo/pull/42",
|
||||||
|
createdAt: "2024-01-15T10:00:00Z",
|
||||||
|
filesChanged: ["src/digest/index.ts"],
|
||||||
|
labels: ["feature"],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("DigestVoiceRunner", () => {
|
||||||
|
describe("constructor", () => {
|
||||||
|
it("creates runner with default config", () => {
|
||||||
|
const runner = new DigestVoiceRunner();
|
||||||
|
const config = runner.getConfig();
|
||||||
|
|
||||||
|
expect(config.enableVoice).toBe(false);
|
||||||
|
expect(config.enableDiscord).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates runner with custom config", () => {
|
||||||
|
const runner = new DigestVoiceRunner({
|
||||||
|
enableVoice: true,
|
||||||
|
enableDiscord: true,
|
||||||
|
});
|
||||||
|
const config = runner.getConfig();
|
||||||
|
|
||||||
|
expect(config.enableVoice).toBe(true);
|
||||||
|
expect(config.enableDiscord).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateDigestOnly", () => {
|
||||||
|
it("generates digest without voice or discord", () => {
|
||||||
|
const runner = new DigestVoiceRunner();
|
||||||
|
const digest = runner.generateDigestOnly(sampleMetadata);
|
||||||
|
|
||||||
|
expect(digest.text).toBeDefined();
|
||||||
|
expect(digest.text).toContain("test-user");
|
||||||
|
expect(digest.metadata).toEqual(sampleMetadata);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for invalid metadata", () => {
|
||||||
|
const runner = new DigestVoiceRunner();
|
||||||
|
const invalidMetadata = { ...sampleMetadata, number: 0 };
|
||||||
|
|
||||||
|
expect(() => runner.generateDigestOnly(invalidMetadata)).toThrow(
|
||||||
|
"PR number"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("run", () => {
|
||||||
|
it("returns digest without voice when voice disabled", async () => {
|
||||||
|
const runner = new DigestVoiceRunner({ enableVoice: false });
|
||||||
|
const result = await runner.run(sampleMetadata);
|
||||||
|
|
||||||
|
expect(result.digest).toBeDefined();
|
||||||
|
expect(result.audio).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns digest without discord when discord disabled", async () => {
|
||||||
|
const runner = new DigestVoiceRunner({ enableDiscord: false });
|
||||||
|
const result = await runner.run(sampleMetadata);
|
||||||
|
|
||||||
|
expect(result.digest).toBeDefined();
|
||||||
|
expect(result.postedToDiscord).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates metadata before processing", async () => {
|
||||||
|
const runner = new DigestVoiceRunner();
|
||||||
|
const invalidMetadata = { ...sampleMetadata, title: "" };
|
||||||
|
|
||||||
|
await expect(runner.run(invalidMetadata)).rejects.toThrow("title");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateConfig", () => {
|
||||||
|
it("updates configuration values", () => {
|
||||||
|
const runner = new DigestVoiceRunner();
|
||||||
|
|
||||||
|
runner.updateConfig({ enableVoice: true });
|
||||||
|
expect(runner.getConfig().enableVoice).toBe(true);
|
||||||
|
|
||||||
|
runner.updateConfig({ enableDiscord: true });
|
||||||
|
expect(runner.getConfig().enableDiscord).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getConfig", () => {
|
||||||
|
it("returns config without sensitive data", () => {
|
||||||
|
const runner = new DigestVoiceRunner({
|
||||||
|
enableVoice: true,
|
||||||
|
voice: {
|
||||||
|
provider: "elevenlabs",
|
||||||
|
apiKey: "secret-key",
|
||||||
|
voiceId: "voice-123",
|
||||||
|
},
|
||||||
|
enableDiscord: true,
|
||||||
|
discord: {
|
||||||
|
webhookUrl: "https://discord.com/api/webhooks/secret",
|
||||||
|
username: "Bot",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = runner.getConfig();
|
||||||
|
|
||||||
|
// Voice config should not include apiKey
|
||||||
|
expect(config.voice).toBeDefined();
|
||||||
|
expect((config.voice as any).apiKey).toBeUndefined();
|
||||||
|
expect(config.voice?.voiceId).toBe("voice-123");
|
||||||
|
|
||||||
|
// Discord config should not include webhookUrl
|
||||||
|
expect(config.discord).toBeDefined();
|
||||||
|
expect((config.discord as any).webhookUrl).toBeUndefined();
|
||||||
|
expect(config.discord?.username).toBe("Bot");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createRunnerFromEnv", () => {
|
||||||
|
const originalEnv = process.env;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates runner with voice disabled by default", () => {
|
||||||
|
delete process.env.ENABLE_VOICE;
|
||||||
|
const runner = createRunnerFromEnv();
|
||||||
|
expect(runner.getConfig().enableVoice).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates runner with discord disabled by default", () => {
|
||||||
|
delete process.env.ENABLE_DISCORD;
|
||||||
|
const runner = createRunnerFromEnv();
|
||||||
|
expect(runner.getConfig().enableDiscord).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enables voice when ENABLE_VOICE=true", () => {
|
||||||
|
process.env.ENABLE_VOICE = "true";
|
||||||
|
process.env.ELEVENLABS_API_KEY = "test-key";
|
||||||
|
const runner = createRunnerFromEnv();
|
||||||
|
expect(runner.getConfig().enableVoice).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enables discord when ENABLE_DISCORD=true", () => {
|
||||||
|
process.env.ENABLE_DISCORD = "true";
|
||||||
|
process.env.DISCORD_WEBHOOK_URL = "https://discord.com/api/webhooks/123";
|
||||||
|
const runner = createRunnerFromEnv();
|
||||||
|
expect(runner.getConfig().enableDiscord).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses custom voice ID from env", () => {
|
||||||
|
process.env.ENABLE_VOICE = "true";
|
||||||
|
process.env.ELEVENLABS_API_KEY = "test-key";
|
||||||
|
process.env.ELEVENLABS_VOICE_ID = "custom-voice";
|
||||||
|
const runner = createRunnerFromEnv();
|
||||||
|
expect(runner.getConfig().voice?.voiceId).toBe("custom-voice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses custom bot username from env", () => {
|
||||||
|
process.env.ENABLE_DISCORD = "true";
|
||||||
|
process.env.DISCORD_WEBHOOK_URL = "https://discord.com/api/webhooks/123";
|
||||||
|
process.env.DISCORD_BOT_USERNAME = "CustomBot";
|
||||||
|
const runner = createRunnerFromEnv();
|
||||||
|
expect(runner.getConfig().discord?.username).toBe("CustomBot");
|
||||||
|
});
|
||||||
|
});
|
||||||
291
tests/discordWebhook.test.ts
Normal file
291
tests/discordWebhook.test.ts
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
validateConfig,
|
||||||
|
createDigestEmbed,
|
||||||
|
createDigestPayload,
|
||||||
|
DISCORD_COLORS,
|
||||||
|
} from "../src/digest/discord-webhook";
|
||||||
|
import type {
|
||||||
|
DiscordWebhookConfig,
|
||||||
|
DigestContent,
|
||||||
|
AudioResult,
|
||||||
|
} from "../src/digest/types";
|
||||||
|
|
||||||
|
const sampleDigest: DigestContent = {
|
||||||
|
text: "A new PR has been opened by test-user",
|
||||||
|
metadata: {
|
||||||
|
number: 42,
|
||||||
|
title: "Test PR Title",
|
||||||
|
body: "Test body",
|
||||||
|
author: "test-user",
|
||||||
|
owner: "test-org",
|
||||||
|
repo: "test-repo",
|
||||||
|
url: "https://github.com/test-org/test-repo/pull/42",
|
||||||
|
createdAt: "2024-01-15T10:00:00Z",
|
||||||
|
labels: ["feature", "voice"],
|
||||||
|
},
|
||||||
|
generatedAt: "2024-01-15T10:05:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sampleAudio: AudioResult = {
|
||||||
|
audioUrl: "https://example.com/audio.mp3",
|
||||||
|
format: "mp3",
|
||||||
|
sizeBytes: 1024,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("validateConfig", () => {
|
||||||
|
it("passes for valid webhook URL", () => {
|
||||||
|
const config: DiscordWebhookConfig = {
|
||||||
|
webhookUrl: "https://discord.com/api/webhooks/123/abc",
|
||||||
|
};
|
||||||
|
expect(() => validateConfig(config)).not.toThrow();
|
||||||
|
expect(validateConfig(config)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts discordapp.com URLs", () => {
|
||||||
|
const config: DiscordWebhookConfig = {
|
||||||
|
webhookUrl: "https://discordapp.com/api/webhooks/123/abc",
|
||||||
|
};
|
||||||
|
expect(() => validateConfig(config)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for missing webhook URL", () => {
|
||||||
|
const config: DiscordWebhookConfig = {
|
||||||
|
webhookUrl: "",
|
||||||
|
};
|
||||||
|
expect(() => validateConfig(config)).toThrow("webhook URL is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for invalid URL format", () => {
|
||||||
|
const config: DiscordWebhookConfig = {
|
||||||
|
webhookUrl: "not-a-url",
|
||||||
|
};
|
||||||
|
expect(() => validateConfig(config)).toThrow("Invalid Discord webhook URL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for non-Discord URL", () => {
|
||||||
|
const config: DiscordWebhookConfig = {
|
||||||
|
webhookUrl: "https://example.com/webhook",
|
||||||
|
};
|
||||||
|
expect(() => validateConfig(config)).toThrow("Invalid Discord webhook URL");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DISCORD_COLORS", () => {
|
||||||
|
it("has expected color values", () => {
|
||||||
|
expect(DISCORD_COLORS.GREEN).toBe(0x2ecc71);
|
||||||
|
expect(DISCORD_COLORS.BLUE).toBe(0x3498db);
|
||||||
|
expect(DISCORD_COLORS.PURPLE).toBe(0x9b59b6);
|
||||||
|
expect(DISCORD_COLORS.ORANGE).toBe(0xe67e22);
|
||||||
|
expect(DISCORD_COLORS.RED).toBe(0xe74c3c);
|
||||||
|
expect(DISCORD_COLORS.GOLD).toBe(0xf1c40f);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createDigestEmbed", () => {
|
||||||
|
it("creates embed with correct title", () => {
|
||||||
|
const embed = createDigestEmbed(sampleDigest);
|
||||||
|
expect(embed.title).toBe("📋 Test PR Title");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates embed with description from digest text", () => {
|
||||||
|
const embed = createDigestEmbed(sampleDigest);
|
||||||
|
expect(embed.description).toBe(sampleDigest.text);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates embed with correct URL", () => {
|
||||||
|
const embed = createDigestEmbed(sampleDigest);
|
||||||
|
expect(embed.url).toBe("https://github.com/test-org/test-repo/pull/42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates embed with purple color", () => {
|
||||||
|
const embed = createDigestEmbed(sampleDigest);
|
||||||
|
expect(embed.color).toBe(DISCORD_COLORS.PURPLE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes author field", () => {
|
||||||
|
const embed = createDigestEmbed(sampleDigest);
|
||||||
|
const authorField = embed.fields?.find((f) => f.name === "Author");
|
||||||
|
expect(authorField).toBeDefined();
|
||||||
|
expect(authorField?.value).toContain("test-user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes repository field", () => {
|
||||||
|
const embed = createDigestEmbed(sampleDigest);
|
||||||
|
const repoField = embed.fields?.find((f) => f.name === "Repository");
|
||||||
|
expect(repoField).toBeDefined();
|
||||||
|
expect(repoField?.value).toContain("test-org/test-repo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes PR number field", () => {
|
||||||
|
const embed = createDigestEmbed(sampleDigest);
|
||||||
|
const prField = embed.fields?.find((f) => f.name === "PR Number");
|
||||||
|
expect(prField).toBeDefined();
|
||||||
|
expect(prField?.value).toContain("#42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes labels when present", () => {
|
||||||
|
const embed = createDigestEmbed(sampleDigest);
|
||||||
|
const labelsField = embed.fields?.find((f) => f.name === "Labels");
|
||||||
|
expect(labelsField).toBeDefined();
|
||||||
|
expect(labelsField?.value).toContain("feature");
|
||||||
|
expect(labelsField?.value).toContain("voice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes labels field when none present", () => {
|
||||||
|
const digestNoLabels: DigestContent = {
|
||||||
|
...sampleDigest,
|
||||||
|
metadata: { ...sampleDigest.metadata, labels: undefined },
|
||||||
|
};
|
||||||
|
const embed = createDigestEmbed(digestNoLabels);
|
||||||
|
const labelsField = embed.fields?.find((f) => f.name === "Labels");
|
||||||
|
expect(labelsField).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes audio link when audio provided", () => {
|
||||||
|
const embed = createDigestEmbed(sampleDigest, sampleAudio);
|
||||||
|
const audioField = embed.fields?.find((f) =>
|
||||||
|
f.name.includes("Audio Digest")
|
||||||
|
);
|
||||||
|
expect(audioField).toBeDefined();
|
||||||
|
expect(audioField?.value).toContain(sampleAudio.audioUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes footer text", () => {
|
||||||
|
const embed = createDigestEmbed(sampleDigest);
|
||||||
|
expect(embed.footer?.text).toBe("Lucidia Voice Digest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes timestamp", () => {
|
||||||
|
const embed = createDigestEmbed(sampleDigest);
|
||||||
|
expect(embed.timestamp).toBe(sampleDigest.generatedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes author avatar thumbnail", () => {
|
||||||
|
const embed = createDigestEmbed(sampleDigest);
|
||||||
|
expect(embed.thumbnail?.url).toBe("https://github.com/test-user.png");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createDigestPayload", () => {
|
||||||
|
it("creates payload with embeds", () => {
|
||||||
|
const payload = createDigestPayload(sampleDigest);
|
||||||
|
expect(payload.embeds).toBeDefined();
|
||||||
|
expect(payload.embeds?.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes PR digest message without audio", () => {
|
||||||
|
const payload = createDigestPayload(sampleDigest);
|
||||||
|
expect(payload.content).toContain("PR Digest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes voice digest message with audio", () => {
|
||||||
|
const payload = createDigestPayload(sampleDigest, sampleAudio);
|
||||||
|
expect(payload.content).toContain("Voice Digest");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("postToWebhook", () => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for missing webhook URL", async () => {
|
||||||
|
const { postToWebhook } = await import("../src/digest/discord-webhook");
|
||||||
|
const config: DiscordWebhookConfig = { webhookUrl: "" };
|
||||||
|
const payload = createDigestPayload(sampleDigest);
|
||||||
|
|
||||||
|
await expect(postToWebhook(config, payload)).rejects.toThrow(
|
||||||
|
"webhook URL is required"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls fetch with correct URL and method", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ id: "12345" }),
|
||||||
|
};
|
||||||
|
(global.fetch as any).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const { postToWebhook } = await import("../src/digest/discord-webhook");
|
||||||
|
const config: DiscordWebhookConfig = {
|
||||||
|
webhookUrl: "https://discord.com/api/webhooks/123/abc",
|
||||||
|
};
|
||||||
|
const payload = createDigestPayload(sampleDigest);
|
||||||
|
|
||||||
|
await postToWebhook(config, payload);
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
"https://discord.com/api/webhooks/123/abc?wait=true",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns success and message ID", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ id: "msg-12345" }),
|
||||||
|
};
|
||||||
|
(global.fetch as any).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const { postToWebhook } = await import("../src/digest/discord-webhook");
|
||||||
|
const config: DiscordWebhookConfig = {
|
||||||
|
webhookUrl: "https://discord.com/api/webhooks/123/abc",
|
||||||
|
};
|
||||||
|
const payload = createDigestPayload(sampleDigest);
|
||||||
|
|
||||||
|
const result = await postToWebhook(config, payload);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.id).toBe("msg-12345");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses config username when not in payload", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ id: "12345" }),
|
||||||
|
};
|
||||||
|
(global.fetch as any).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const { postToWebhook } = await import("../src/digest/discord-webhook");
|
||||||
|
const config: DiscordWebhookConfig = {
|
||||||
|
webhookUrl: "https://discord.com/api/webhooks/123/abc",
|
||||||
|
username: "Custom Bot Name",
|
||||||
|
};
|
||||||
|
const payload = { content: "test" };
|
||||||
|
|
||||||
|
await postToWebhook(config, payload);
|
||||||
|
|
||||||
|
const [, options] = (global.fetch as any).mock.calls[0];
|
||||||
|
const body = JSON.parse(options.body);
|
||||||
|
expect(body.username).toBe("Custom Bot Name");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on API error", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
text: () => Promise.resolve("Bad Request"),
|
||||||
|
};
|
||||||
|
(global.fetch as any).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const { postToWebhook } = await import("../src/digest/discord-webhook");
|
||||||
|
const config: DiscordWebhookConfig = {
|
||||||
|
webhookUrl: "https://discord.com/api/webhooks/123/abc",
|
||||||
|
};
|
||||||
|
const payload = createDigestPayload(sampleDigest);
|
||||||
|
|
||||||
|
await expect(postToWebhook(config, payload)).rejects.toThrow(
|
||||||
|
"Discord webhook error (400)"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
188
tests/elevenlabs.test.ts
Normal file
188
tests/elevenlabs.test.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
validateConfig,
|
||||||
|
DEFAULT_VOICE_CONFIG,
|
||||||
|
} from "../src/digest/elevenlabs";
|
||||||
|
import type { VoiceConfig } from "../src/digest/types";
|
||||||
|
|
||||||
|
describe("ElevenLabs validateConfig", () => {
|
||||||
|
const validConfig: VoiceConfig = {
|
||||||
|
provider: "elevenlabs",
|
||||||
|
apiKey: "test-api-key",
|
||||||
|
voiceId: "test-voice-id",
|
||||||
|
modelId: "eleven_multilingual_v2",
|
||||||
|
stability: 0.5,
|
||||||
|
similarityBoost: 0.75,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("passes for valid configuration", () => {
|
||||||
|
expect(() => validateConfig(validConfig)).not.toThrow();
|
||||||
|
expect(validateConfig(validConfig)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for missing API key", () => {
|
||||||
|
const config = { ...validConfig, apiKey: "" };
|
||||||
|
expect(() => validateConfig(config)).toThrow("API key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for missing voice ID", () => {
|
||||||
|
const config = { ...validConfig, voiceId: "" };
|
||||||
|
expect(() => validateConfig(config)).toThrow("Voice ID");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for invalid stability value (too low)", () => {
|
||||||
|
const config = { ...validConfig, stability: -0.1 };
|
||||||
|
expect(() => validateConfig(config)).toThrow("Stability");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for invalid stability value (too high)", () => {
|
||||||
|
const config = { ...validConfig, stability: 1.5 };
|
||||||
|
expect(() => validateConfig(config)).toThrow("Stability");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for invalid similarity boost (too low)", () => {
|
||||||
|
const config = { ...validConfig, similarityBoost: -0.1 };
|
||||||
|
expect(() => validateConfig(config)).toThrow("Similarity boost");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for invalid similarity boost (too high)", () => {
|
||||||
|
const config = { ...validConfig, similarityBoost: 1.5 };
|
||||||
|
expect(() => validateConfig(config)).toThrow("Similarity boost");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows undefined stability and similarity", () => {
|
||||||
|
const config: VoiceConfig = {
|
||||||
|
provider: "elevenlabs",
|
||||||
|
apiKey: "test-api-key",
|
||||||
|
voiceId: "test-voice-id",
|
||||||
|
};
|
||||||
|
expect(() => validateConfig(config)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DEFAULT_VOICE_CONFIG", () => {
|
||||||
|
it("has expected default values", () => {
|
||||||
|
expect(DEFAULT_VOICE_CONFIG.provider).toBe("elevenlabs");
|
||||||
|
expect(DEFAULT_VOICE_CONFIG.voiceId).toBeDefined();
|
||||||
|
expect(DEFAULT_VOICE_CONFIG.modelId).toBe("eleven_multilingual_v2");
|
||||||
|
expect(DEFAULT_VOICE_CONFIG.stability).toBe(0.5);
|
||||||
|
expect(DEFAULT_VOICE_CONFIG.similarityBoost).toBe(0.75);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock fetch for generateSpeech tests
|
||||||
|
describe("generateSpeech", () => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for unsupported provider", async () => {
|
||||||
|
const { generateSpeech } = await import("../src/digest/elevenlabs");
|
||||||
|
const config: VoiceConfig = {
|
||||||
|
provider: "playht" as any,
|
||||||
|
apiKey: "test-key",
|
||||||
|
voiceId: "test-voice",
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(generateSpeech("Hello", config)).rejects.toThrow(
|
||||||
|
"Unsupported provider"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for missing API key", async () => {
|
||||||
|
const { generateSpeech } = await import("../src/digest/elevenlabs");
|
||||||
|
const config: VoiceConfig = {
|
||||||
|
provider: "elevenlabs",
|
||||||
|
apiKey: "",
|
||||||
|
voiceId: "test-voice",
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(generateSpeech("Hello", config)).rejects.toThrow(
|
||||||
|
"API key is required"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws for missing voice ID", async () => {
|
||||||
|
const { generateSpeech } = await import("../src/digest/elevenlabs");
|
||||||
|
const config: VoiceConfig = {
|
||||||
|
provider: "elevenlabs",
|
||||||
|
apiKey: "test-key",
|
||||||
|
voiceId: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(generateSpeech("Hello", config)).rejects.toThrow(
|
||||||
|
"Voice ID is required"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls fetch with correct parameters", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: true,
|
||||||
|
arrayBuffer: () => Promise.resolve(new ArrayBuffer(100)),
|
||||||
|
};
|
||||||
|
(global.fetch as any).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const { generateSpeech } = await import("../src/digest/elevenlabs");
|
||||||
|
const config: VoiceConfig = {
|
||||||
|
provider: "elevenlabs",
|
||||||
|
apiKey: "test-api-key",
|
||||||
|
voiceId: "test-voice-id",
|
||||||
|
modelId: "eleven_multilingual_v2",
|
||||||
|
stability: 0.5,
|
||||||
|
similarityBoost: 0.75,
|
||||||
|
};
|
||||||
|
|
||||||
|
await generateSpeech("Hello world", config);
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
"https://api.elevenlabs.io/v1/text-to-speech/test-voice-id",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"xi-api-key": "test-api-key",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on API error", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
text: () => Promise.resolve("Unauthorized"),
|
||||||
|
};
|
||||||
|
(global.fetch as any).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const { generateSpeech } = await import("../src/digest/elevenlabs");
|
||||||
|
const config: VoiceConfig = {
|
||||||
|
provider: "elevenlabs",
|
||||||
|
apiKey: "invalid-key",
|
||||||
|
voiceId: "test-voice-id",
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(generateSpeech("Hello", config)).rejects.toThrow(
|
||||||
|
"ElevenLabs API error (401)"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createAudioResult", () => {
|
||||||
|
it("creates correct audio result object", async () => {
|
||||||
|
const { createAudioResult } = await import("../src/digest/elevenlabs");
|
||||||
|
const buffer = Buffer.from("test audio data");
|
||||||
|
const url = "https://example.com/audio.mp3";
|
||||||
|
|
||||||
|
const result = createAudioResult(buffer, url);
|
||||||
|
|
||||||
|
expect(result.audioUrl).toBe(url);
|
||||||
|
expect(result.format).toBe("mp3");
|
||||||
|
expect(result.sizeBytes).toBe(buffer.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { jsx as _jsx } from "react/jsx-runtime";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import { EnvCard } from "../components/EnvCard";
|
|
||||||
describe("EnvCard", () => {
|
|
||||||
const env = {
|
|
||||||
id: "env_123",
|
|
||||||
name: "Production",
|
|
||||||
region: "us-west-2",
|
|
||||||
status: "healthy"
|
|
||||||
};
|
|
||||||
it("renders name, region, and id", () => {
|
|
||||||
render(_jsx(EnvCard, { env: env }));
|
|
||||||
expect(screen.getByText(env.region)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(env.name)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(`Env ID: ${env.id}`)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { getEnvironments, getEnvById, getHealth, getVersion } from "../lib/fetcher";
|
|
||||||
const originalEnv = { ...process.env };
|
|
||||||
afterEach(() => {
|
|
||||||
process.env = { ...originalEnv };
|
|
||||||
});
|
|
||||||
describe("fetcher", () => {
|
|
||||||
it("returns mock environments", async () => {
|
|
||||||
const envs = await getEnvironments();
|
|
||||||
expect(envs).toHaveLength(2);
|
|
||||||
expect(envs[0]).toEqual(expect.objectContaining({ id: "env_1", name: "Development", region: "us-east-1" }));
|
|
||||||
});
|
|
||||||
it("returns environment by id", async () => {
|
|
||||||
const env = await getEnvById("env_2");
|
|
||||||
expect(env?.name).toBe("Staging");
|
|
||||||
expect(await getEnvById("missing"))?.toBeUndefined();
|
|
||||||
});
|
|
||||||
it("returns health with uptime", async () => {
|
|
||||||
vi.spyOn(process, "uptime").mockReturnValue(42);
|
|
||||||
const health = await getHealth();
|
|
||||||
expect(health).toEqual({ status: "ok", uptime: 42 });
|
|
||||||
});
|
|
||||||
it("returns version info", async () => {
|
|
||||||
process.env.APP_VERSION = "2.0.0";
|
|
||||||
process.env.APP_COMMIT = "abc123";
|
|
||||||
const info = await getVersion();
|
|
||||||
expect(info).toEqual({ version: "2.0.0", commit: "abc123" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { vi, describe, it, expect, beforeEach } from "vitest";
|
|
||||||
import cron from "node-cron";
|
|
||||||
import { startHeartbeatScheduler } from "../src/heartbeat";
|
|
||||||
vi.mock("node-cron", () => {
|
|
||||||
return {
|
|
||||||
default: {
|
|
||||||
schedule: vi.fn((expression, callback) => ({ fireOnTick: callback, expression }))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
describe("startHeartbeatScheduler", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
it("schedules heartbeat every five minutes and enqueues payload", async () => {
|
|
||||||
const add = vi.fn();
|
|
||||||
const task = startHeartbeatScheduler({ add });
|
|
||||||
expect(cron.schedule).toHaveBeenCalledWith("*/5 * * * *", expect.any(Function));
|
|
||||||
// fire the cron callback
|
|
||||||
task.fireOnTick();
|
|
||||||
expect(add).toHaveBeenCalledWith("heartbeat", expect.objectContaining({ ts: expect.any(Number) }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { GET as health } from "../app/api/health/route";
|
|
||||||
import { GET as version } from "../app/api/version/route";
|
|
||||||
vi.mock("../src/utils/buildInfo", () => ({
|
|
||||||
getBuildInfo: () => ({ version: "api-version", commit: "api-commit", buildTime: "now" })
|
|
||||||
}));
|
|
||||||
describe("Next API routes", () => {
|
|
||||||
it("returns health response", async () => {
|
|
||||||
const res = await health();
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(await res.json()).toEqual({ status: "ok" });
|
|
||||||
});
|
|
||||||
it("returns version response", async () => {
|
|
||||||
const res = await version();
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(await res.json()).toEqual({ version: "api-version", commit: "api-commit" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { registerSampleJobProcessor } from "../src/jobs/sample.job";
|
|
||||||
vi.mock("bullmq", () => {
|
|
||||||
class MockWorker {
|
|
||||||
constructor(_name, processor, _opts) {
|
|
||||||
this.handlers = {};
|
|
||||||
this.processor = processor;
|
|
||||||
}
|
|
||||||
on(event, handler) {
|
|
||||||
this.handlers[event] = this.handlers[event] || [];
|
|
||||||
this.handlers[event].push(handler);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { Worker: MockWorker };
|
|
||||||
});
|
|
||||||
describe("registerSampleJobProcessor", () => {
|
|
||||||
it("registers worker and handlers", () => {
|
|
||||||
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => { });
|
|
||||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
||||||
const worker = registerSampleJobProcessor({ host: "localhost", port: 6379 });
|
|
||||||
expect(worker.processor).toBeInstanceOf(Function);
|
|
||||||
expect(worker.handlers.failed).toHaveLength(1);
|
|
||||||
// simulate processing and failure
|
|
||||||
worker.processor({ id: 1, data: { hello: "world" } });
|
|
||||||
worker.handlers.failed[0]({ id: 1 }, new Error("boom"));
|
|
||||||
expect(consoleLog).toHaveBeenCalledWith("Processing job 1");
|
|
||||||
expect(consoleError).toHaveBeenCalled();
|
|
||||||
consoleLog.mockRestore();
|
|
||||||
consoleError.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { jsx as _jsx } from "react/jsx-runtime";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import { StatusPill } from "../components/StatusPill";
|
|
||||||
describe("StatusPill", () => {
|
|
||||||
const cases = [
|
|
||||||
["healthy", "Healthy", "status-pill--healthy"],
|
|
||||||
["degraded", "Degraded", "status-pill--degraded"],
|
|
||||||
["down", "Down", "status-pill--down"]
|
|
||||||
];
|
|
||||||
it.each(cases)("renders %s status", (status, label, className) => {
|
|
||||||
render(_jsx(StatusPill, { status: status }));
|
|
||||||
const pill = screen.getByText(label);
|
|
||||||
expect(pill).toBeInTheDocument();
|
|
||||||
expect(pill.className).toContain(className);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user