Changes before error encountered

Co-authored-by: blackboxprogramming <118287761+blackboxprogramming@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-11-24 23:28:30 +00:00
parent 58db2f1e71
commit 591e37cf08
37 changed files with 2113 additions and 328 deletions

4
.gitignore vendored
View File

@@ -1,3 +1,7 @@
node_modules node_modules
coverage coverage
dist
# Generated JS files (source is TypeScript)
*.js
!vitest.config.js

View File

@@ -1,3 +0,0 @@
export async function GET() {
return Response.json({ status: "ok" });
}

View File

@@ -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 });
}

View File

@@ -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 })] }));
}

View File

@@ -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 });
}

View File

@@ -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 };
}

View File

@@ -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;
}

View File

@@ -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;
} }

View 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:

View 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;
}

View 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 };

View 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
View 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
View 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
View 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;
}

View File

@@ -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;
}

View File

@@ -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);
});
}

View File

@@ -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;
} }

View File

@@ -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
View 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;
}

View File

@@ -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;
}

View File

@@ -1 +0,0 @@
export {};

View File

@@ -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 };
}

View File

@@ -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" });
});
});

View File

@@ -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
View 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);
});
});

View 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");
});
});

View 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();
});
});
});

View 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");
});
});

View 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
View 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);
});
});

View File

@@ -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();
});
});

View File

@@ -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" });
});
});

View File

@@ -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) }));
});
});

View File

@@ -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" });
});
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});