diff --git a/applications/chatops/slack-bot/src/shared/build-lock.ts b/applications/chatops/slack-bot/src/shared/build-lock.ts new file mode 100644 index 0000000..198ff3a --- /dev/null +++ b/applications/chatops/slack-bot/src/shared/build-lock.ts @@ -0,0 +1,233 @@ +// Distributed Lock Manager for Build/Deploy Operations +// Prevents duplicate builds when multiple users trigger the same command + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { + DynamoDBDocumentClient, + PutCommand, + GetCommand, + UpdateCommand, +} from '@aws-sdk/lib-dynamodb'; +import { logger } from './logger'; +import { getConfig } from './config'; + +const client = new DynamoDBClient({ region: 'ca-central-1' }); +const docClient = DynamoDBDocumentClient.from(client); + +interface BuildLock { + lockKey: string; + lockedBy: string; + lockedByName: string; + lockedAt: string; + status: 'IN_PROGRESS' | 'COMPLETED' | 'FAILED'; + ttl: number; + component: string; + environment: string; + correlationId?: string; +} + +interface LockAcquisitionResult { + acquired: boolean; + lockedBy?: string; + lockedByName?: string; + lockedAt?: string; + existingLock?: BuildLock; +} + +export class BuildLockManager { + private tableName: string; + + constructor() { + const config = getConfig(); + this.tableName = `${config.orgPrefix}-${config.environment}-chatbot-build-locks`; + } + + /** + * Generate lock key from command parameters + */ + private generateLockKey( + command: 'build' | 'deploy', + component: string, + environment: string + ): string { + return `${command}-${component}-${environment}`; + } + + /** + * Attempt to acquire a lock for a build/deploy operation + * Returns true if lock acquired, false if already locked + */ + async acquireLock(params: { + command: 'build' | 'deploy'; + component: string; + environment: string; + userId: string; + userName: string; + correlationId?: string; + }): Promise { + const lockKey = this.generateLockKey( + params.command, + params.component, + params.environment + ); + + // TTL: 10 minutes for builds, 30 minutes for deploys + const ttlMinutes = params.command === 'build' ? 10 : 30; + const ttl = Math.floor(Date.now() / 1000) + ttlMinutes * 60; + + const lock: BuildLock = { + lockKey, + lockedBy: params.userId, + lockedByName: params.userName, + lockedAt: new Date().toISOString(), + status: 'IN_PROGRESS', + ttl, + component: params.component, + environment: params.environment, + correlationId: params.correlationId, + }; + + try { + // Attempt to create lock with conditional write + // Succeeds only if: + // 1. Lock doesn't exist, OR + // 2. Lock status is COMPLETED or FAILED, OR + // 3. Lock TTL has expired + await docClient.send( + new PutCommand({ + TableName: this.tableName, + Item: lock, + ConditionExpression: + 'attribute_not_exists(lockKey) OR #status IN (:completed, :failed) OR #ttl < :now', + ExpressionAttributeNames: { + '#status': 'status', + '#ttl': 'ttl', + }, + ExpressionAttributeValues: { + ':completed': 'COMPLETED', + ':failed': 'FAILED', + ':now': Math.floor(Date.now() / 1000), + }, + }) + ); + + logger.info('Lock acquired successfully', { + lockKey, + userId: params.userId, + userName: params.userName, + ttl: ttlMinutes, + }); + + return { acquired: true }; + } catch (error: any) { + if (error.name === 'ConditionalCheckFailedException') { + // Lock already exists and is active + const existingLock = await this.getLock(lockKey); + + logger.info('Lock acquisition failed - already locked', { + lockKey, + requestedBy: params.userId, + lockedBy: existingLock?.lockedBy, + lockedByName: existingLock?.lockedByName, + }); + + return { + acquired: false, + lockedBy: existingLock?.lockedBy, + lockedByName: existingLock?.lockedByName, + lockedAt: existingLock?.lockedAt, + existingLock, + }; + } + + // Unexpected error + logger.error('Unexpected error acquiring lock', error); + throw error; + } + } + + /** + * Get current lock status + */ + async getLock(lockKey: string): Promise { + try { + const result = await docClient.send( + new GetCommand({ + TableName: this.tableName, + Key: { lockKey }, + }) + ); + + if (!result.Item) { + return null; + } + + return result.Item as BuildLock; + } catch (error) { + logger.error('Error getting lock', error as Error, { lockKey }); + return null; + } + } + + /** + * Release lock by updating status + */ + async releaseLock( + command: 'build' | 'deploy', + component: string, + environment: string, + status: 'COMPLETED' | 'FAILED' + ): Promise { + const lockKey = this.generateLockKey(command, component, environment); + + try { + await docClient.send( + new UpdateCommand({ + TableName: this.tableName, + Key: { lockKey }, + UpdateExpression: 'SET #status = :status, completedAt = :now', + ExpressionAttributeNames: { + '#status': 'status', + }, + ExpressionAttributeValues: { + ':status': status, + ':now': new Date().toISOString(), + }, + }) + ); + + logger.info('Lock released', { lockKey, status }); + } catch (error) { + logger.error('Error releasing lock', error as Error, { lockKey, status }); + // Don't throw - lock will expire via TTL + } + } + + /** + * Check if a build/deploy is currently in progress + */ + async isLocked( + command: 'build' | 'deploy', + component: string, + environment: string + ): Promise { + const lockKey = this.generateLockKey(command, component, environment); + const lock = await this.getLock(lockKey); + + if (!lock) { + return false; + } + + // Check if lock is expired + const now = Math.floor(Date.now() / 1000); + if (lock.ttl < now) { + return false; + } + + // Check if lock is in progress + return lock.status === 'IN_PROGRESS'; + } +} + +// Singleton instance +export const buildLockManager = new BuildLockManager(); diff --git a/applications/chatops/slack-bot/src/shared/command-config.ts b/applications/chatops/slack-bot/src/shared/command-config.ts new file mode 100644 index 0000000..b6835e1 --- /dev/null +++ b/applications/chatops/slack-bot/src/shared/command-config.ts @@ -0,0 +1,88 @@ +// Command Configuration +// Defines behavior and requirements for each chatbot command + +export interface CommandConfig { + command: string; + description: string; + requiresLock: boolean; + lockScope?: 'component-environment' | 'global'; + lockTTL?: number; // TTL in minutes + enableCache?: boolean; + cacheTTL?: number; // TTL in seconds + cacheStrategy?: 'request-dedup' | 'response-cache' | 'data-cache'; +} + +export const COMMAND_CONFIG: Record = { + '/echo': { + command: '/echo', + description: 'Echo command for testing', + requiresLock: false, + enableCache: false, + }, + + '/status': { + command: '/status', + description: 'Check build/deploy status', + requiresLock: false, + enableCache: true, + cacheTTL: 30, // 30 seconds + cacheStrategy: 'response-cache', + }, + + '/build': { + command: '/build', + description: 'Trigger GitHub Actions build', + requiresLock: true, + lockScope: 'component-environment', + lockTTL: 10, // 10 minutes + enableCache: false, + }, + + '/deploy': { + command: '/deploy', + description: 'Deploy to environment', + requiresLock: true, + lockScope: 'component-environment', + lockTTL: 30, // 30 minutes (deploys take longer) + enableCache: false, + }, +} as const; + +/** + * Get configuration for a command + */ +export function getCommandConfig(command: string): CommandConfig | null { + return COMMAND_CONFIG[command] || null; +} + +/** + * Check if command requires distributed lock + */ +export function requiresLock(command: string): boolean { + const config = getCommandConfig(command); + return config?.requiresLock ?? false; +} + +/** + * Check if command supports caching + */ +export function supportsCache(command: string): boolean { + const config = getCommandConfig(command); + return config?.enableCache ?? false; +} + +/** + * Get cache TTL for command + */ +export function getCacheTTL(command: string): number { + const config = getCommandConfig(command); + return config?.cacheTTL ?? 0; +} + +/** + * Get lock TTL for command + */ +export function getLockTTL(command: string): number { + const config = getCommandConfig(command); + return config?.lockTTL ?? 10; +} diff --git a/applications/chatops/slack-bot/src/shared/response-cache.ts b/applications/chatops/slack-bot/src/shared/response-cache.ts new file mode 100644 index 0000000..c914382 --- /dev/null +++ b/applications/chatops/slack-bot/src/shared/response-cache.ts @@ -0,0 +1,256 @@ +// Response Cache Manager for Read-only Operations +// Caches API responses to reduce external API calls and improve performance + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { + DynamoDBDocumentClient, + GetCommand, + PutCommand, + UpdateCommand, +} from '@aws-sdk/lib-dynamodb'; +import { logger } from './logger'; +import { getConfig } from './config'; + +const client = new DynamoDBClient({ region: 'ca-central-1' }); +const docClient = DynamoDBDocumentClient.from(client); + +interface CacheEntry { + cacheKey: string; + response: T; + createdAt: string; + ttl: number; + hitCount?: number; + lastAccessedAt?: string; + cacheStrategy?: 'request-dedup' | 'response-cache' | 'data-cache'; +} + +interface CacheOptions { + ttlSeconds: number; + strategy?: 'request-dedup' | 'response-cache' | 'data-cache'; + updateHitCount?: boolean; +} + +export class ResponseCacheManager { + private tableName: string; + + constructor() { + const config = getConfig(); + this.tableName = `${config.orgPrefix}-${config.environment}-chatbot-response-cache`; + } + + /** + * Get cached response + * Returns null if cache miss or expired + */ + async get(cacheKey: string, options?: { updateHitCount?: boolean }): Promise { + try { + const result = await docClient.send( + new GetCommand({ + TableName: this.tableName, + Key: { cacheKey }, + }) + ); + + if (!result.Item) { + logger.debug('Cache miss', { cacheKey }); + return null; + } + + const entry = result.Item as CacheEntry; + + // Check if cache is expired (DynamoDB TTL is async, so explicit check) + const now = Math.floor(Date.now() / 1000); + if (entry.ttl < now) { + logger.debug('Cache expired', { cacheKey, ttl: entry.ttl, now }); + return null; + } + + // Calculate age + const ageMs = Date.now() - new Date(entry.createdAt).getTime(); + const ageSec = Math.floor(ageMs / 1000); + + logger.info('Cache hit', { + cacheKey, + age: ageSec, + strategy: entry.cacheStrategy, + hitCount: entry.hitCount, + }); + + // Update hit count asynchronously (fire-and-forget) + if (options?.updateHitCount !== false) { + this.incrementHitCount(cacheKey).catch((error) => { + logger.warn('Failed to update hit count', { cacheKey, error }); + }); + } + + return entry.response; + } catch (error) { + logger.error('Cache get error', error as Error, { cacheKey }); + // On error, treat as cache miss (fail open) + return null; + } + } + + /** + * Store response in cache + */ + async set( + cacheKey: string, + response: T, + options: CacheOptions + ): Promise { + const now = new Date(); + const ttl = Math.floor(now.getTime() / 1000) + options.ttlSeconds; + + const entry: CacheEntry = { + cacheKey, + response, + createdAt: now.toISOString(), + ttl, + hitCount: 0, + lastAccessedAt: now.toISOString(), + cacheStrategy: options.strategy || 'response-cache', + }; + + try { + await docClient.send( + new PutCommand({ + TableName: this.tableName, + Item: entry, + }) + ); + + logger.info('Cache set', { + cacheKey, + ttl: options.ttlSeconds, + strategy: options.strategy, + }); + } catch (error) { + logger.error('Cache set error', error as Error, { cacheKey }); + // Don't throw - cache write failure shouldn't break the request + } + } + + /** + * Get or compute cached value + * If cache miss, execute computeFn and cache the result + */ + async getOrCompute( + cacheKey: string, + computeFn: () => Promise, + options: CacheOptions + ): Promise<{ value: T; fromCache: boolean }> { + // Try cache first + const cached = await this.get(cacheKey, { updateHitCount: true }); + if (cached !== null) { + return { value: cached, fromCache: true }; + } + + // Cache miss - compute value + logger.info('Cache miss - computing value', { cacheKey }); + + const value = await computeFn(); + + // Store in cache (fire-and-forget) + this.set(cacheKey, value, options).catch((error) => { + logger.warn('Failed to cache computed value', { cacheKey, error }); + }); + + return { value, fromCache: false }; + } + + /** + * Invalidate (delete) cache entry + */ + async invalidate(cacheKey: string): Promise { + try { + // Set TTL to immediate expiration + const now = Math.floor(Date.now() / 1000); + + await docClient.send( + new UpdateCommand({ + TableName: this.tableName, + Key: { cacheKey }, + UpdateExpression: 'SET #ttl = :ttl', + ExpressionAttributeNames: { + '#ttl': 'ttl', + }, + ExpressionAttributeValues: { + ':ttl': now - 1, // Already expired + }, + }) + ); + + logger.info('Cache invalidated', { cacheKey }); + } catch (error) { + logger.error('Cache invalidation error', error as Error, { cacheKey }); + } + } + + /** + * Invalidate multiple cache entries by pattern + * WARNING: This requires scanning the table - use sparingly + */ + async invalidatePattern(pattern: RegExp): Promise { + logger.warn('Pattern-based cache invalidation not implemented', { pattern: pattern.toString() }); + // TODO: Implement with DynamoDB Scan if needed + // For now, rely on TTL for cache expiration + return 0; + } + + /** + * Increment hit count for analytics + * Async operation - failures are logged but not thrown + */ + private async incrementHitCount(cacheKey: string): Promise { + try { + await docClient.send( + new UpdateCommand({ + TableName: this.tableName, + Key: { cacheKey }, + UpdateExpression: + 'SET hitCount = if_not_exists(hitCount, :zero) + :one, lastAccessedAt = :now', + ExpressionAttributeValues: { + ':zero': 0, + ':one': 1, + ':now': new Date().toISOString(), + }, + }) + ); + } catch (error) { + // Log but don't throw - hit count is non-critical + logger.debug('Failed to increment hit count', { cacheKey, error }); + } + } + + /** + * Check if cache entry exists and is valid + */ + async exists(cacheKey: string): Promise { + const entry = await this.get(cacheKey, { updateHitCount: false }); + return entry !== null; + } + + /** + * Generate cache key from components + */ + static generateKey(...parts: string[]): string { + return parts.filter(Boolean).join('-'); + } +} + +// Singleton instance +export const responseCacheManager = new ResponseCacheManager(); + +// Helper function for common use case +export async function withCache( + cacheKey: string, + computeFn: () => Promise, + ttlSeconds: number, + strategy?: 'request-dedup' | 'response-cache' | 'data-cache' +): Promise<{ value: T; fromCache: boolean }> { + return responseCacheManager.getOrCompute(cacheKey, computeFn, { + ttlSeconds, + strategy, + }); +} diff --git a/applications/chatops/slack-bot/src/shared/secrets.ts b/applications/chatops/slack-bot/src/shared/secrets.ts index 4877c10..b8aa60e 100644 --- a/applications/chatops/slack-bot/src/shared/secrets.ts +++ b/applications/chatops/slack-bot/src/shared/secrets.ts @@ -74,7 +74,7 @@ export async function getSlackSigningSecret(): Promise { export async function getGitHubToken(): Promise { const cacheKey = 'github-pat'; - + // Check cache first const cached = secretCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { @@ -82,6 +82,15 @@ export async function getGitHubToken(): Promise { return cached.value; } + // For local development, use environment variable + if (config.get().isLocal) { + const value = process.env.GITHUB_PAT_CLOUD_APPS; + if (value) { + logger.debug('GitHub PAT retrieved from environment'); + return value; + } + } + // GitHub PAT is stored in common environment, not environment-specific // Use direct parameter path instead of getSecret() which adds environment prefix const parameterPath = '/laco/cmn/github/pat/cloud-apps'; diff --git a/applications/chatops/slack-bot/src/workers/build/index.ts b/applications/chatops/slack-bot/src/workers/build/index.ts index 4f5fa95..2779209 100644 --- a/applications/chatops/slack-bot/src/workers/build/index.ts +++ b/applications/chatops/slack-bot/src/workers/build/index.ts @@ -6,6 +6,7 @@ import { logger } from '../../shared/logger'; import { sendSlackResponse } from '../../shared/slack-client'; import { WorkerMessage } from '../../shared/types'; import { getGitHubToken } from '../../shared/secrets'; +import { buildLockManager } from '../../shared/build-lock'; interface BuildCommand { component: string; // router, echo, deploy, status, all @@ -136,43 +137,116 @@ export async function handler(event: SQSEvent): Promise { environment }); - // Send immediate acknowledgment - await sendSlackResponse(message.response_url, { - response_type: 'in_channel', - text: `🔨 Building ${component}...`, - blocks: [ - { - type: 'section', - text: { - type: 'mrkdwn', - text: `🔨 *Building ${component}*\n\nEnvironment: \`${environment}\`\nRequested by: <@${message.user_id}>\n\nTriggering GitHub Actions workflow...\nThis will take ~2 minutes` - } - }, - { - type: 'context', - elements: [ - { + // Attempt to acquire distributed lock + const lockResult = await buildLockManager.acquireLock({ + command: 'build', + component, + environment, + userId: message.user_id, + userName: message.user_name, + correlationId, + }); + + if (!lockResult.acquired) { + // Lock already held by another user - notify and skip + const lockedSince = lockResult.lockedAt + ? new Date(lockResult.lockedAt) + : null; + const timeSince = lockedSince + ? Math.floor((Date.now() - lockedSince.getTime()) / 1000) + : null; + + await sendSlackResponse(message.response_url, { + response_type: 'ephemeral', + text: `⚠️ Build already in progress`, + blocks: [ + { + type: 'section', + text: { type: 'mrkdwn', - text: '⏳ Build in progress...' + text: `⚠️ *Build Already In Progress*\n\nA build for \`${component}\` (${environment}) is already running.\n\n` + + `Started by: ${lockResult.lockedByName || 'Unknown'}\n` + + (timeSince ? `Started: ${timeSince}s ago\n` : '') + + `\nPlease wait for the current build to complete.` } - ] - } - ] - }); + } + ] + }); + + messageLogger.info('Build skipped - lock held by another user', { + component, + environment, + requestedBy: message.user_id, + lockedBy: lockResult.lockedBy, + lockedByName: lockResult.lockedByName, + }); + + // Don't add to failures - this is expected behavior + continue; + } - // Trigger GitHub Actions workflow - await triggerGitHubWorkflow({ + // Lock acquired - proceed with build + messageLogger.info('Lock acquired - proceeding with build', { component, environment, - response_url: message.response_url, - user: message.user_name }); - messageLogger.info('Build command processed successfully', { - duration: Date.now() - startTime, - component, - environment - }); + try { + // Send immediate acknowledgment + await sendSlackResponse(message.response_url, { + response_type: 'in_channel', + text: `🔨 Building ${component}...`, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `🔨 *Building ${component}*\n\nEnvironment: \`${environment}\`\nRequested by: <@${message.user_id}>\n\nTriggering GitHub Actions workflow...\nThis will take ~2 minutes` + } + }, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: '⏳ Build in progress...' + } + ] + } + ] + }); + + // Trigger GitHub Actions workflow + await triggerGitHubWorkflow({ + component, + environment, + response_url: message.response_url, + user: message.user_name + }); + + // Release lock on success + await buildLockManager.releaseLock( + 'build', + component, + environment, + 'COMPLETED' + ); + + messageLogger.info('Build command processed successfully', { + duration: Date.now() - startTime, + component, + environment + }); + } catch (buildError) { + // Release lock on failure + await buildLockManager.releaseLock( + 'build', + component, + environment, + 'FAILED' + ); + throw buildError; // Re-throw to be caught by outer catch + } } catch (error) { const duration = Date.now() - startTime; diff --git a/applications/chatops/slack-bot/src/workers/status/index.ts b/applications/chatops/slack-bot/src/workers/status/index.ts index 896e9a0..ec4ef0c 100644 --- a/applications/chatops/slack-bot/src/workers/status/index.ts +++ b/applications/chatops/slack-bot/src/workers/status/index.ts @@ -1,29 +1,78 @@ -// Status Worker Lambda - Reports system status +// Status Worker Lambda - Reports build/deploy status with caching import { SQSEvent, SQSBatchResponse } from 'aws-lambda'; +import axios from 'axios'; import { logger } from '../../shared/logger'; import { sendSlackResponse } from '../../shared/slack-client'; import { WorkerMessage } from '../../shared/types'; +import { ResponseCacheManager } from '../../shared/response-cache'; +import { getCommandConfig } from '../../shared/command-config'; +import { getGitHubToken } from '../../shared/secrets'; -interface ServiceStatus { +interface WorkflowRun { + id: number; name: string; - status: 'healthy' | 'degraded' | 'down'; - latency?: number; - message?: string; + status: string; + conclusion: string | null; + created_at: string; + updated_at: string; + html_url: string; } -async function checkServiceStatus(serviceName: string): Promise { - // Simulate health check - await new Promise(resolve => setTimeout(resolve, 100 + Math.random() * 500)); - - const isHealthy = Math.random() > 0.1; // 90% success rate +interface BuildStatus { + workflows: Array<{ + name: string; + status: string; + conclusion: string | null; + created: string; + url: string; + }>; + timestamp: string; +} - return { - name: serviceName, - status: isHealthy ? 'healthy' : 'degraded', - latency: Math.round(50 + Math.random() * 200), - message: isHealthy ? 'All systems operational' : 'Experiencing delays' - }; +const cacheManager = new ResponseCacheManager(); + +/** + * Fetch GitHub Actions workflow runs + */ +async function fetchGitHubWorkflowStatus(): Promise { + const githubToken = await getGitHubToken(); + const owner = 'llamandcoco'; + const repo = 'cloud-apps'; + + logger.info('Fetching GitHub workflow status', { owner, repo }); + + try { + const response = await axios.get<{ workflow_runs: WorkflowRun[] }>( + `https://api.github.com/repos/${owner}/${repo}/actions/runs`, + { + params: { + per_page: 5, + status: 'queued,in_progress,completed', + }, + headers: { + 'Authorization': `token ${githubToken}`, + 'Accept': 'application/vnd.github.v3+json', + }, + } + ); + + const workflows = response.data.workflow_runs.map(run => ({ + name: run.name, + status: run.status, + conclusion: run.conclusion, + created: run.created_at, + url: run.html_url, + })); + + return { + workflows, + timestamp: new Date().toISOString(), + }; + } catch (error) { + logger.error('Failed to fetch GitHub workflow status', error as Error); + throw new Error(`GitHub API error: ${(error as any).response?.data?.message || (error as Error).message}`); + } } export async function handler(event: SQSEvent): Promise { @@ -34,6 +83,8 @@ export async function handler(event: SQSEvent): Promise { const batchItemFailures: { itemIdentifier: string }[] = []; for (const record of event.Records) { + const startTime = Date.now(); + try { const message: WorkerMessage = JSON.parse(record.body); @@ -41,50 +92,62 @@ export async function handler(event: SQSEvent): Promise { user: message.user_name }); - // Send initial response - await sendSlackResponse(message.response_url, { - response_type: 'ephemeral', - text: 'Checking system status...' - }); - - // Check various services - const services = [ - 'API Gateway', - 'Lambda Functions', - 'EventBridge', - 'SQS Queues', - 'Parameter Store' - ]; - - const statuses = await Promise.all( - services.map(service => checkServiceStatus(service)) + // Get command configuration for cache TTL + const commandConfig = getCommandConfig('/status'); + const cacheTTL = commandConfig?.cacheTTL || 30; + + // Generate cache key + const cacheKey = ResponseCacheManager.generateKey('status', 'workflows'); + + // Fetch status with caching + const { value: buildStatus, fromCache } = await cacheManager.getOrCompute( + cacheKey, + () => fetchGitHubWorkflowStatus(), + { + ttlSeconds: cacheTTL, + strategy: 'response-cache', + } ); - // Generate status report - const allHealthy = statuses.every(s => s.status === 'healthy'); - const overallStatus = allHealthy ? '✅ All Systems Operational' : '⚠️ Some Services Degraded'; - - const statusBlocks = statuses.map(s => { - const icon = s.status === 'healthy' ? '✅' : '⚠️'; - return `${icon} *${s.name}*: ${s.status} (${s.latency}ms)`; - }).join('\n'); + // Calculate cache age + const cacheAge = fromCache + ? Math.floor((Date.now() - new Date(buildStatus.timestamp).getTime()) / 1000) + : 0; + + // Format workflow status + const workflowBlocks = buildStatus.workflows.length > 0 + ? buildStatus.workflows.map(w => { + const statusIcon = w.status === 'completed' + ? (w.conclusion === 'success' ? '✅' : '❌') + : '🔄'; + return `${statusIcon} <${w.url}|${w.name}>: ${w.status}${w.conclusion ? ` (${w.conclusion})` : ''}`; + }).join('\n') + : '_No recent workflows found_'; + + // Determine overall status + const hasRunning = buildStatus.workflows.some(w => w.status === 'in_progress' || w.status === 'queued'); + const hasFailed = buildStatus.workflows.some(w => w.conclusion === 'failure'); + const overallStatus = hasFailed + ? '⚠️ Some builds failed' + : hasRunning + ? '🔄 Builds in progress' + : '✅ All builds healthy'; await sendSlackResponse(message.response_url, { response_type: 'in_channel', - text: overallStatus, blocks: [ { type: 'header', text: { type: 'plain_text', - text: '📊 System Status Report' + text: '📊 Build Status Report' } }, { type: 'section', text: { type: 'mrkdwn', - text: statusBlocks + text: `*Recent Workflows:*\n${workflowBlocks}` } }, { @@ -99,19 +162,40 @@ export async function handler(event: SQSEvent): Promise { elements: [ { type: 'mrkdwn', - text: `Requested by <@${message.user_id}> | ${new Date().toISOString()}` + text: fromCache + ? `⚡ Cached ${cacheAge}s ago | Requested by <@${message.user_id}>` + : `🔄 Live data | Requested by <@${message.user_id}>` } ] } ] }); - logger.info('Status report sent'); + logger.info('Status report sent', { + duration: Date.now() - startTime, + fromCache, + cacheAge: fromCache ? cacheAge : null, + }); + } catch (error) { + const duration = Date.now() - startTime; + logger.error('Failed to process status command', error as Error, { - messageId: record.messageId + messageId: record.messageId, + duration }); + // Try to notify user of failure + try { + const message: WorkerMessage = JSON.parse(record.body); + await sendSlackResponse(message.response_url, { + response_type: 'ephemeral', + text: `❌ Failed to fetch status: ${(error as Error).message}` + }); + } catch (notifyError) { + logger.error('Failed to send error notification', notifyError as Error); + } + batchItemFailures.push({ itemIdentifier: record.messageId }); } }