├── helm ├── templates │ ├── configmap.yaml │ ├── secrets.yaml │ ├── service.yaml │ ├── _helpers.tpl │ └── deployment.yaml ├── Chart.yaml └── values.yaml ├── package.json ├── Dockerfile ├── src ├── utils │ ├── logger.js │ └── axios-instance.js ├── server.js ├── app.js ├── services │ ├── config.service.js │ ├── webhook.service.js │ ├── repository.service.js │ └── event.service.js ├── routes │ ├── health.routes.js │ ├── webhook.routes.js │ └── event.routes.js ├── config │ └── index.js └── controllers │ ├── event.controller.js │ └── webhook.controller.js └── README.md /helm/templates/configmap.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: {{ include "unizo-repo-subscriber.fullname" . }}-config 5 | labels: 6 | {{- include "unizo-repo-subscriber.labels" . | nindent 4 }} 7 | data: 8 | {{- range .Values.env }} 9 | {{ .name }}: {{ .value | quote }} 10 | {{- end }} -------------------------------------------------------------------------------- /helm/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: unizo-repo-subscriber 3 | description: A Helm chart for the Unizo Repository Webhook Subscriber service 4 | type: application 5 | version: 0.1.0 6 | appVersion: "1.0.0" 7 | maintainers: 8 | - name: DevOps Team 9 | email: devops@unizo.com 10 | keywords: 11 | - webhook 12 | - github 13 | - repository -------------------------------------------------------------------------------- /helm/templates/secrets.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: {{ include "unizo-repo-subscriber.fullname" . }}-secrets 5 | labels: 6 | {{- include "unizo-repo-subscriber.labels" . | nindent 4 }} 7 | type: Opaque 8 | data: 9 | {{- range .Values.secrets }} 10 | {{ .key }}: {{ .value | b64enc | quote }} 11 | {{- end }} -------------------------------------------------------------------------------- /helm/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "unizo-repo-subscriber.fullname" . }} 5 | labels: 6 | {{- include "unizo-repo-subscriber.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: {{ .Values.service.targetPort }} 12 | protocol: TCP 13 | name: http 14 | selector: 15 | {{- include "unizo-repo-subscriber.selectorLabels" . | nindent 4 }} -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "unizo-repo-subscriber", 3 | "version": "1.0.0", 4 | "description": "Service to register webhooks for repositories tied to an organization", 5 | "main": "src/server.js", 6 | "scripts": { 7 | "start": "node src/server.js", 8 | "dev": "nodemon src/server.js", 9 | "test": "jest", 10 | "lint": "eslint src/**/*.js" 11 | }, 12 | "engines": { 13 | "node": ">=18.0.0" 14 | }, 15 | "dependencies": { 16 | "axios": "^1.6.2", 17 | "axios-retry": "^3.6.0", 18 | "dotenv": "^16.3.1", 19 | "express": "^4.18.2", 20 | "express-validator": "^7.0.1", 21 | "helmet": "^7.1.0", 22 | "pino": "^8.16.2", 23 | "pino-http": "^8.6.0", 24 | "pino-pretty": "^10.2.3" 25 | }, 26 | "devDependencies": { 27 | "eslint": "^8.54.0", 28 | "jest": "^29.7.0", 29 | "nodemon": "^3.0.1" 30 | } 31 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM node:18-alpine AS builder 3 | 4 | WORKDIR /app 5 | 6 | # Copy package files 7 | COPY package*.json ./ 8 | 9 | # Install dependencies 10 | RUN npm ci 11 | 12 | # Copy source code 13 | COPY . . 14 | 15 | # Production stage 16 | FROM node:18-alpine 17 | 18 | WORKDIR /app 19 | 20 | # Install production dependencies only 21 | COPY package*.json ./ 22 | RUN npm ci --only=production 23 | 24 | # Copy built application from builder stage 25 | COPY --from=builder /app/src ./src 26 | 27 | # Set environment variables 28 | ENV NODE_ENV=production 29 | 30 | # Create non-root user 31 | RUN addgroup -S appgroup && adduser -S appuser -G appgroup 32 | USER appuser 33 | 34 | # Expose port 35 | EXPOSE 3000 36 | 37 | # Health check 38 | HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ 39 | CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 40 | 41 | # Start application 42 | CMD ["node", "src/server.js"] -------------------------------------------------------------------------------- /src/utils/logger.js: -------------------------------------------------------------------------------- 1 | const winston = require('winston'); 2 | 3 | const logger = winston.createLogger({ 4 | level: process.env.LOG_LEVEL || 'info', 5 | format: winston.format.combine( 6 | winston.format.timestamp(), 7 | winston.format.errors({ stack: true }), 8 | winston.format.json() 9 | ), 10 | defaultMeta: { service: 'unizo-repo-subscriber' }, 11 | transports: [ 12 | new winston.transports.Console({ 13 | format: winston.format.combine( 14 | winston.format.colorize(), 15 | winston.format.printf(({ timestamp, level, message, ...meta }) => { 16 | return `${timestamp} [${level}]: ${message} ${ 17 | Object.keys(meta).length ? JSON.stringify(meta, null, 2) : '' 18 | }`; 19 | }) 20 | ), 21 | }), 22 | ], 23 | }); 24 | 25 | // Add error event handlers to prevent crashes 26 | logger.on('error', (error) => { 27 | console.error('Logger error:', error); 28 | }); 29 | 30 | module.exports = logger; -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | const app = require('./app'); 2 | const config = require('./config'); 3 | const logger = require('./utils/logger'); 4 | 5 | const server = app.listen(config.port, () => { 6 | logger.info(`Server started on port ${config.port}`); 7 | logger.info(`Environment: ${config.env}`); 8 | }); 9 | 10 | // Handle graceful shutdown 11 | process.on('SIGTERM', () => { 12 | logger.info('SIGTERM received. Shutting down gracefully'); 13 | server.close(() => { 14 | logger.info('Process terminated'); 15 | }); 16 | }); 17 | 18 | process.on('SIGINT', () => { 19 | logger.info('SIGINT received. Shutting down gracefully'); 20 | server.close(() => { 21 | logger.info('Process terminated'); 22 | }); 23 | }); 24 | 25 | process.on('uncaughtException', (err) => { 26 | logger.error({ err }, 'Uncaught exception'); 27 | process.exit(1); 28 | }); 29 | 30 | process.on('unhandledRejection', (reason, promise) => { 31 | logger.error({ reason }, 'Unhandled rejection'); 32 | process.exit(1); 33 | }); -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const cors = require('cors'); 3 | const helmet = require('helmet'); 4 | const rateLimit = require('express-rate-limit'); 5 | const logger = require('./utils/logger'); 6 | const config = require('./config'); 7 | 8 | // Import routes 9 | const healthRoutes = require('./routes/health.routes'); 10 | const eventRoutes = require('./routes/event.routes'); 11 | 12 | // Create Express app 13 | const app = express(); 14 | 15 | // Security middleware 16 | app.use(helmet()); 17 | app.use(cors()); 18 | 19 | // Rate limiting 20 | const limiter = rateLimit({ 21 | windowMs: config.RATE_LIMIT_WINDOW_MS, 22 | max: config.RATE_LIMIT_MAX_REQUESTS, 23 | message: { 24 | error: 'Too many requests, please try again later.' 25 | } 26 | }); 27 | app.use(limiter); 28 | 29 | // Body parsing middleware 30 | app.use(express.json()); 31 | app.use(express.urlencoded({ extended: true })); 32 | 33 | // Request logging middleware 34 | app.use((req, res, next) => { 35 | logger.info(`${req.method} ${req.url}`, { 36 | ip: req.ip, 37 | userAgent: req.get('user-agent') 38 | }); 39 | next(); 40 | }); 41 | 42 | // Routes 43 | app.use('/health', healthRoutes); 44 | app.use('/api/v1', eventRoutes); 45 | 46 | // 404 handler 47 | app.use((req, res) => { 48 | logger.warn(`Route not found: ${req.method} ${req.url}`); 49 | res.status(404).json({ 50 | error: 'Not found', 51 | message: `Route ${req.method} ${req.url} not found` 52 | }); 53 | }); 54 | 55 | // Error handling middleware 56 | app.use((err, req, res, next) => { 57 | logger.error('Application error:', err); 58 | res.status(err.status || 500).json({ 59 | error: 'Internal server error', 60 | message: err.message 61 | }); 62 | }); 63 | 64 | module.exports = app; -------------------------------------------------------------------------------- /src/utils/axios-instance.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const logger = require('./logger'); 3 | 4 | const axiosInstance = axios.create({ 5 | timeout: 10000, // 10 seconds 6 | headers: { 7 | 'Content-Type': 'application/json', 8 | }, 9 | }); 10 | 11 | // Request interceptor 12 | axiosInstance.interceptors.request.use( 13 | (config) => { 14 | // Add request logging 15 | logger.debug('Making request:', { 16 | method: config.method, 17 | url: config.url, 18 | headers: config.headers, 19 | }); 20 | return config; 21 | }, 22 | (error) => { 23 | logger.error('Request error:', error); 24 | return Promise.reject(error); 25 | } 26 | ); 27 | 28 | // Response interceptor 29 | axiosInstance.interceptors.response.use( 30 | (response) => { 31 | logger.debug('Response received:', { 32 | status: response.status, 33 | statusText: response.statusText, 34 | }); 35 | return response; 36 | }, 37 | async (error) => { 38 | if (error.response) { 39 | logger.error('Response error:', { 40 | status: error.response.status, 41 | data: error.response.data, 42 | }); 43 | } else if (error.request) { 44 | logger.error('Request failed:', error.message); 45 | } else { 46 | logger.error('Axios error:', error.message); 47 | } 48 | 49 | // Implement retry logic for specific status codes 50 | if (error.config && error.response && [429, 500, 502, 503, 504].includes(error.response.status)) { 51 | const retryConfig = error.config; 52 | retryConfig.retryCount = (retryConfig.retryCount || 0) + 1; 53 | 54 | if (retryConfig.retryCount <= 3) { 55 | const delayMs = Math.pow(2, retryConfig.retryCount) * 1000; 56 | logger.info(`Retrying request (attempt ${retryConfig.retryCount}) after ${delayMs}ms`); 57 | await new Promise(resolve => setTimeout(resolve, delayMs)); 58 | return axiosInstance(retryConfig); 59 | } 60 | } 61 | 62 | return Promise.reject(error); 63 | } 64 | ); 65 | 66 | module.exports = axiosInstance; -------------------------------------------------------------------------------- /helm/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "unizo-repo-subscriber.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "unizo-repo-subscriber.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "unizo-repo-subscriber.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "unizo-repo-subscriber.labels" -}} 37 | helm.sh/chart: {{ include "unizo-repo-subscriber.chart" . }} 38 | {{ include "unizo-repo-subscriber.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "unizo-repo-subscriber.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "unizo-repo-subscriber.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "unizo-repo-subscriber.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "unizo-repo-subscriber.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} -------------------------------------------------------------------------------- /src/services/config.service.js: -------------------------------------------------------------------------------- 1 | const axiosInstance = require('../utils/axios-instance'); 2 | const config = require('../config'); 3 | const logger = require('../utils/logger'); 4 | 5 | /** 6 | * Fetches the SCM_WATCH_HOOK configuration for an organization 7 | * @param {string} organizationId - The organization ID 8 | * @returns {Promise} The webhook configuration or null if not found 9 | */ 10 | const getOrganizationWebhookConfig = async (organizationId) => { 11 | try { 12 | const url = `${config.unizoApiUrl}/organizations/${organizationId}/configurations`; 13 | 14 | logger.debug({ organizationId }, 'Fetching organization configurations'); 15 | 16 | const response = await axiosInstance.get(url, { 17 | headers: { 18 | 'Accept': 'application/json', 19 | 'apikey': config.unizoApiKey, 20 | // Optional headers if provided in environment 21 | ...(process.env.AUTH_USER_ID && { 'authuserid': process.env.AUTH_USER_ID }), 22 | ...(process.env.CORRELATION_ID && { 'correlationid': process.env.CORRELATION_ID }), 23 | ...(process.env.SOURCE_CHANNEL && { 'sourcechannel': process.env.SOURCE_CHANNEL }), 24 | } 25 | }); 26 | 27 | // Extract the SCM_WATCH_HOOK configuration 28 | const configurations = response.data || []; 29 | const webhookConfig = configurations.find(config => config.type === 'SCM_WATCH_HOOK'); 30 | 31 | if (webhookConfig) { 32 | logger.info({ 33 | organizationId, 34 | webhookConfigFound: true, 35 | webhookUrl: webhookConfig.data?.url 36 | }, 'Found SCM_WATCH_HOOK configuration'); 37 | 38 | return { 39 | url: webhookConfig.data?.url, 40 | ...webhookConfig.data 41 | }; 42 | } 43 | 44 | logger.warn({ organizationId }, 'No SCM_WATCH_HOOK configuration found'); 45 | return null; 46 | } catch (error) { 47 | logger.error({ 48 | organizationId, 49 | error: error.message 50 | }, 'Failed to fetch organization webhook configuration'); 51 | 52 | throw new Error(`Failed to fetch organization webhook configuration: ${error.message}`); 53 | } 54 | }; 55 | 56 | module.exports = { 57 | getOrganizationWebhookConfig 58 | }; -------------------------------------------------------------------------------- /helm/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for unizo-repo-subscriber 2 | replicaCount: 2 3 | 4 | image: 5 | repository: unizo/repo-subscriber 6 | tag: latest 7 | pullPolicy: Always 8 | 9 | nameOverride: "" 10 | fullnameOverride: "" 11 | 12 | serviceAccount: 13 | create: true 14 | annotations: {} 15 | name: "" 16 | 17 | service: 18 | type: ClusterIP 19 | port: 80 20 | targetPort: 3000 21 | 22 | deployment: 23 | annotations: {} 24 | 25 | podAnnotations: {} 26 | 27 | podSecurityContext: 28 | fsGroup: 1000 29 | 30 | securityContext: 31 | capabilities: 32 | drop: 33 | - ALL 34 | readOnlyRootFilesystem: true 35 | runAsNonRoot: true 36 | runAsUser: 1000 37 | 38 | resources: 39 | limits: 40 | cpu: 500m 41 | memory: 512Mi 42 | requests: 43 | cpu: 250m 44 | memory: 256Mi 45 | 46 | autoscaling: 47 | enabled: true 48 | minReplicas: 2 49 | maxReplicas: 5 50 | targetCPUUtilizationPercentage: 80 51 | targetMemoryUtilizationPercentage: 80 52 | 53 | nodeSelector: {} 54 | 55 | tolerations: [] 56 | 57 | affinity: {} 58 | 59 | ingress: 60 | enabled: true 61 | className: "nginx" 62 | annotations: 63 | kubernetes.io/ingress.class: nginx 64 | cert-manager.io/cluster-issuer: letsencrypt-prod 65 | hosts: 66 | - host: repo-subscriber.unizo.com 67 | paths: 68 | - path: / 69 | pathType: Prefix 70 | tls: 71 | - secretName: repo-subscriber-tls 72 | hosts: 73 | - repo-subscriber.unizo.com 74 | 75 | env: 76 | - name: NODE_ENV 77 | value: "production" 78 | - name: PORT 79 | value: "3000" 80 | - name: GITHUB_API_URL 81 | value: "https://api.github.com" 82 | - name: TARGET_ORGANIZATION 83 | value: "unizo" 84 | - name: APP_URL 85 | value: "https://repo-subscriber.unizo.com" 86 | 87 | # Secrets to be created 88 | secrets: 89 | - name: GITHUB_API_TOKEN 90 | key: github-api-token 91 | value: "" 92 | - name: WEBHOOK_SECRET 93 | key: webhook-secret 94 | value: "" 95 | 96 | livenessProbe: 97 | httpGet: 98 | path: /health 99 | port: http 100 | initialDelaySeconds: 30 101 | periodSeconds: 30 102 | timeoutSeconds: 3 103 | failureThreshold: 3 104 | 105 | readinessProbe: 106 | httpGet: 107 | path: /health 108 | port: http 109 | initialDelaySeconds: 5 110 | periodSeconds: 10 111 | timeoutSeconds: 3 112 | failureThreshold: 3 113 | 114 | startupProbe: 115 | httpGet: 116 | path: /healthz 117 | port: http 118 | initialDelaySeconds: 5 119 | periodSeconds: 5 120 | timeoutSeconds: 3 121 | failureThreshold: 6 -------------------------------------------------------------------------------- /src/routes/health.routes.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const logger = require('../utils/logger'); 4 | const repositoryService = require('../services/repository.service'); 5 | 6 | /** 7 | * @route GET /healthz 8 | * @desc Basic health check endpoint for Kubernetes probes 9 | * @access Public 10 | */ 11 | router.get('/', (req, res) => { 12 | logger.debug('Health check requested'); 13 | res.status(200).json({ 14 | status: 'ok', 15 | timestamp: new Date().toISOString(), 16 | service: 'unizo-repo-subscriber' 17 | }); 18 | }); 19 | 20 | /** 21 | * @route GET /healthz/readiness 22 | * @desc Readiness probe for Kubernetes 23 | * @access Public 24 | */ 25 | router.get('/readiness', (req, res) => { 26 | logger.debug('Readiness check requested'); 27 | 28 | // Here we could add more logic to check connections to external services 29 | // For now we just return OK 30 | res.status(200).json({ 31 | status: 'ok', 32 | timestamp: new Date().toISOString(), 33 | service: 'unizo-repo-subscriber', 34 | message: 'Service is ready to accept traffic' 35 | }); 36 | }); 37 | 38 | /** 39 | * @route GET /healthz/liveness 40 | * @desc Liveness probe for Kubernetes 41 | * @access Public 42 | */ 43 | router.get('/liveness', (req, res) => { 44 | logger.debug('Liveness check requested'); 45 | res.status(200).json({ 46 | status: 'ok', 47 | timestamp: new Date().toISOString(), 48 | service: 'unizo-repo-subscriber', 49 | message: 'Service is running correctly' 50 | }); 51 | }); 52 | 53 | // Basic health check 54 | router.get('/health', (req, res) => { 55 | res.status(200).json({ 56 | status: 'healthy', 57 | timestamp: new Date().toISOString() 58 | }); 59 | }); 60 | 61 | // Detailed health check 62 | router.get('/health/detailed', async (req, res) => { 63 | try { 64 | // Check GitHub API connectivity 65 | const startTime = Date.now(); 66 | await repositoryService.listRepositories(1, 1); 67 | const githubApiLatency = Date.now() - startTime; 68 | 69 | res.status(200).json({ 70 | status: 'healthy', 71 | timestamp: new Date().toISOString(), 72 | checks: { 73 | githubApi: { 74 | status: 'healthy', 75 | latency: `${githubApiLatency}ms` 76 | }, 77 | memory: { 78 | status: 'healthy', 79 | usage: process.memoryUsage() 80 | } 81 | } 82 | }); 83 | } catch (error) { 84 | logger.error('Health check failed:', error); 85 | res.status(503).json({ 86 | status: 'unhealthy', 87 | timestamp: new Date().toISOString(), 88 | error: error.message 89 | }); 90 | } 91 | }); 92 | 93 | module.exports = router; -------------------------------------------------------------------------------- /helm/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "unizo-repo-subscriber.fullname" . }} 5 | labels: 6 | {{- include "unizo-repo-subscriber.labels" . | nindent 4 }} 7 | {{- with .Values.deployment.annotations }} 8 | annotations: 9 | {{- toYaml . | nindent 4 }} 10 | {{- end }} 11 | spec: 12 | {{- if not .Values.autoscaling.enabled }} 13 | replicas: {{ .Values.replicaCount }} 14 | {{- end }} 15 | selector: 16 | matchLabels: 17 | {{- include "unizo-repo-subscriber.selectorLabels" . | nindent 6 }} 18 | template: 19 | metadata: 20 | {{- with .Values.podAnnotations }} 21 | annotations: 22 | {{- toYaml . | nindent 8 }} 23 | {{- end }} 24 | labels: 25 | {{- include "unizo-repo-subscriber.selectorLabels" . | nindent 8 }} 26 | spec: 27 | {{- with .Values.imagePullSecrets }} 28 | imagePullSecrets: 29 | {{- toYaml . | nindent 8 }} 30 | {{- end }} 31 | serviceAccountName: {{ include "unizo-repo-subscriber.serviceAccountName" . }} 32 | securityContext: 33 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 34 | containers: 35 | - name: {{ .Chart.Name }} 36 | securityContext: 37 | {{- toYaml .Values.securityContext | nindent 12 }} 38 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 39 | imagePullPolicy: {{ .Values.image.pullPolicy }} 40 | ports: 41 | - name: http 42 | containerPort: {{ .Values.service.targetPort }} 43 | protocol: TCP 44 | env: 45 | {{- range .Values.env }} 46 | - name: {{ .name }} 47 | value: {{ .value | quote }} 48 | {{- end }} 49 | {{- range .Values.secrets }} 50 | - name: {{ .name }} 51 | valueFrom: 52 | secretKeyRef: 53 | name: {{ include "unizo-repo-subscriber.fullname" $ }}-secrets 54 | key: {{ .key }} 55 | {{- end }} 56 | livenessProbe: 57 | {{- toYaml .Values.livenessProbe | nindent 12 }} 58 | readinessProbe: 59 | {{- toYaml .Values.readinessProbe | nindent 12 }} 60 | resources: 61 | {{- toYaml .Values.resources | nindent 12 }} 62 | {{- with .Values.nodeSelector }} 63 | nodeSelector: 64 | {{- toYaml . | nindent 8 }} 65 | {{- end }} 66 | {{- with .Values.affinity }} 67 | affinity: 68 | {{- toYaml . | nindent 8 }} 69 | {{- end }} 70 | {{- with .Values.tolerations }} 71 | tolerations: 72 | {{- toYaml . | nindent 8 }} 73 | {{- end }} -------------------------------------------------------------------------------- /src/routes/webhook.routes.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const { body, param, header, validationResult } = require('express-validator'); 3 | const router = express.Router(); 4 | const webhookController = require('../controllers/webhook.controller'); 5 | const logger = require('../utils/logger'); 6 | 7 | /** 8 | * @route POST /unizo/v1/organizations/{organizationId}/repositories/register 9 | * @desc Register webhooks for all repositories in an organization 10 | * @access Private 11 | */ 12 | router.post( 13 | '/organizations/:organizationId/repositories/register', 14 | [ 15 | // Validate path parameter 16 | param('organizationId') 17 | .notEmpty() 18 | .withMessage('Organization ID is required') 19 | .isString() 20 | .withMessage('Organization ID must be a string'), 21 | 22 | // Validate headers 23 | header('integrationId') 24 | .notEmpty() 25 | .withMessage('integrationId header is required') 26 | .isString() 27 | .withMessage('integrationId must be a string'), 28 | 29 | // Custom middleware to check validation results 30 | (req, res, next) => { 31 | const errors = validationResult(req); 32 | if (!errors.isEmpty()) { 33 | return res.status(400).json({ 34 | errors: errors.array().map(err => ({ 35 | param: err.param, 36 | message: err.msg 37 | })) 38 | }); 39 | } 40 | next(); 41 | } 42 | ], 43 | webhookController.registerRepositoryWebhooks 44 | ); 45 | 46 | // Middleware to parse raw body for webhook signature verification 47 | const rawBodyParser = (req, res, next) => { 48 | if (req.method === 'POST' && req.headers['x-hub-signature-256']) { 49 | let data = ''; 50 | req.setEncoding('utf8'); 51 | 52 | req.on('data', chunk => { 53 | data += chunk; 54 | }); 55 | 56 | req.on('end', () => { 57 | req.rawBody = data; 58 | next(); 59 | }); 60 | } else { 61 | next(); 62 | } 63 | }; 64 | 65 | // Error handling middleware 66 | const errorHandler = (err, req, res, next) => { 67 | logger.error('Route error:', err); 68 | res.status(500).json({ 69 | error: 'Internal server error', 70 | message: err.message 71 | }); 72 | }; 73 | 74 | // Register routes 75 | router.use(rawBodyParser); 76 | 77 | // Webhook registration routes 78 | router.post('/repositories/:repositoryName/webhooks', webhookController.registerWebhook); 79 | router.delete('/repositories/:repositoryName/webhooks/:webhookId', webhookController.deleteWebhook); 80 | router.get('/repositories/:repositoryName/webhooks', webhookController.listWebhooks); 81 | 82 | // Webhook event handler route 83 | router.post('/webhook', webhookController.handleWebhookEvent); 84 | 85 | // Apply error handling 86 | router.use(errorHandler); 87 | 88 | module.exports = router; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Unizo SCM Repository Webhook Subscriber 2 | 3 | A Node.js service that automates webhook registration on customer repositories using Unizo’s SCM API platform. 4 | 5 | Resources 6 | 7 | ## Features 8 | 9 | - Subscribe to SCM events (repository, branch, commit events) 10 | - Handle event notifications securely 11 | - Event signature verification 12 | - Health check endpoints 13 | - Rate limiting 14 | - Docker support 15 | - Kubernetes deployment via Helm 16 | 17 | ## Prerequisites 18 | 19 | - Node.js 18 or later 20 | - npm or yarn 21 | - Docker (for containerization) 22 | - Kubernetes cluster (for deployment) 23 | - Helm 3 (for deployment) 24 | - Unizo API key with appropriate permissions 25 | 26 | ## Installation 27 | 28 | 1. Clone the repository: 29 | ```bash 30 | git clone https://github.com/unizo/scm-event-listener.git 31 | cd scm-event-listener 32 | ``` 33 | 34 | 2. Install dependencies: 35 | ```bash 36 | npm install 37 | ``` 38 | 39 | 3. Create a `.env` file with the required environment variables: 40 | ```env 41 | NODE_ENV=development 42 | PORT=3000 43 | UNIZO_API_URL=https://api.unizo.ai/api/v1 44 | UNIZO_API_KEY=your_api_key_here 45 | ``` 46 | 47 | ## Development 48 | 49 | Start the development server: 50 | ```bash 51 | npm run dev 52 | ``` 53 | 54 | The server will start on http://localhost:3000 with hot reloading enabled. 55 | 56 | ## API Endpoints 57 | 58 | ### Health Checks 59 | 60 | - `GET /health` - Basic health check 61 | - `GET /health/detailed` - Detailed health check with API connectivity status 62 | 63 | ## Event Types 64 | 65 | The service handles the following SCM events: 66 | - `repository:created` 67 | - `repository:renamed` 68 | - `repository:updated` 69 | - `repository:deleted` 70 | - `branch:created` 71 | - `commit:pushed` 72 | 73 | ## Docker 74 | 75 | Build the Docker image: 76 | ```bash 77 | docker build -t unizo/scm-repo-subscriber:latest . 78 | ``` 79 | 80 | Run the container: 81 | ```bash 82 | docker run -p 3000:3000 --env-file .env unizo/scm-repo-subscriber:latest 83 | ``` 84 | 85 | ## Kubernetes Deployment 86 | 87 | 1. Configure values in `helm/values.yaml`: 88 | ```yaml 89 | image: 90 | repository: unizo/scm-repo-subscriber 91 | tag: latest 92 | 93 | secrets: 94 | - name: UNIZO_API_KEY 95 | key: api-key 96 | value: "your-api-key" 97 | ``` 98 | 99 | 2. Install the Helm chart: 100 | ```bash 101 | helm install scm-event-listener ./helm 102 | ``` 103 | 104 | ## Contributing 105 | 106 | 1. Fork the repository 107 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 108 | 3. Commit your changes (`git commit -m 'Add some amazing feature'`) 109 | 4. Push to the branch (`git push origin feature/amazing-feature`) 110 | 5. Open a Pull Request 111 | 112 | ## License 113 | 114 | This project is licensed under the MIT License - see the LICENSE file for details. 115 | -------------------------------------------------------------------------------- /src/config/index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | const logger = require('../utils/logger'); 4 | 5 | class ConfigurationError extends Error { 6 | constructor(message) { 7 | super(message); 8 | this.name = 'ConfigurationError'; 9 | } 10 | } 11 | 12 | function validateConfig(config) { 13 | const requiredFields = [ 14 | 'PORT', 15 | 'UNIZO_API_URL', 16 | 'UNIZO_API_KEY', 17 | 'UNIZO_AUTH_USER_ID', 18 | 'INTEGRATION_ID', 19 | 'EVENT_SECRET', 20 | 'TARGET_ORGANIZATION' 21 | ]; 22 | 23 | const missingFields = requiredFields.filter(field => !config[field]); 24 | 25 | if (missingFields.length > 0) { 26 | throw new ConfigurationError(`Missing required configuration: ${missingFields.join(', ')}`); 27 | } 28 | } 29 | 30 | const config = { 31 | // Application metadata 32 | NAME: 'scm-repo-subscriber', 33 | VERSION: '1.0', 34 | TYPE: 'application', 35 | STATE: 'active', 36 | 37 | // Server configuration 38 | PORT: process.env.PORT || 3000, 39 | NODE_ENV: process.env.NODE_ENV || 'development', 40 | 41 | // Unizo API configuration 42 | UNIZO_API_URL: process.env.UNIZO_API_URL || 'https://api.unizo.ai/api/v1', 43 | UNIZO_API_KEY: process.env.UNIZO_API_KEY, 44 | UNIZO_AUTH_USER_ID: process.env.UNIZO_AUTH_USER_ID, 45 | INTEGRATION_ID: process.env.INTEGRATION_ID, 46 | EVENT_SECRET: process.env.EVENT_SECRET, 47 | TARGET_ORGANIZATION: process.env.TARGET_ORGANIZATION, 48 | 49 | // Event configuration 50 | EVENT_SELECTORS: [ 51 | 'repository:created', 52 | 'repository:renamed', 53 | 'repository:deleted', 54 | 'repository:archived', 55 | 'branch:created', 56 | 'commit:pushed' 57 | ], 58 | 59 | // Application configuration 60 | EVENT_PATH: process.env.EVENT_PATH || '/events', 61 | HEALTH_CHECK_PATH: process.env.HEALTH_CHECK_PATH || '/health', 62 | 63 | // Rate limiting 64 | RATE_LIMIT_WINDOW_MS: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 15 * 60 * 1000, // 15 minutes 65 | RATE_LIMIT_MAX_REQUESTS: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) || 100, 66 | 67 | // Timeouts 68 | REQUEST_TIMEOUT_MS: parseInt(process.env.REQUEST_TIMEOUT_MS, 10) || 5000, 69 | EVENT_TIMEOUT_MS: parseInt(process.env.EVENT_TIMEOUT_MS, 10) || 10000, 70 | 71 | // Retry configuration 72 | MAX_RETRIES: parseInt(process.env.MAX_RETRIES, 10) || 3, 73 | RETRY_DELAY_MS: parseInt(process.env.RETRY_DELAY_MS, 10) || 1000, 74 | 75 | // Logging configuration 76 | LOG_LEVEL: process.env.LOG_LEVEL || 'info', 77 | LOG_FORMAT: process.env.LOG_FORMAT || 'json', 78 | }; 79 | 80 | try { 81 | validateConfig(config); 82 | 83 | // Log configuration (excluding sensitive values) 84 | const sanitizedConfig = { ...config }; 85 | ['UNIZO_API_KEY', 'EVENT_SECRET', 'UNIZO_AUTH_USER_ID'].forEach(key => { 86 | if (sanitizedConfig[key]) { 87 | sanitizedConfig[key] = '***MASKED***'; 88 | } 89 | }); 90 | 91 | logger.info('Application configuration loaded:', sanitizedConfig); 92 | } catch (error) { 93 | logger.error('Configuration validation failed:', error); 94 | process.exit(1); 95 | } 96 | 97 | module.exports = config; 98 | -------------------------------------------------------------------------------- /src/routes/event.routes.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const { body, param, header, validationResult } = require('express-validator'); 3 | const router = express.Router(); 4 | const eventController = require('../controllers/event.controller'); 5 | const logger = require('../utils/logger'); 6 | 7 | // Middleware to parse raw body for event signature verification 8 | const rawBodyParser = (req, res, next) => { 9 | if (req.method === 'POST' && req.headers['x-unizo-signature']) { 10 | let data = ''; 11 | req.setEncoding('utf8'); 12 | 13 | req.on('data', chunk => { 14 | data += chunk; 15 | }); 16 | 17 | req.on('end', () => { 18 | req.rawBody = data; 19 | next(); 20 | }); 21 | } else { 22 | next(); 23 | } 24 | }; 25 | 26 | // Validation middleware 27 | const validateRequest = (req, res, next) => { 28 | const errors = validationResult(req); 29 | if (!errors.isEmpty()) { 30 | return res.status(400).json({ 31 | errors: errors.array().map(err => ({ 32 | param: err.param, 33 | message: err.msg 34 | })) 35 | }); 36 | } 37 | next(); 38 | }; 39 | 40 | // Error handling middleware 41 | const errorHandler = (err, req, res, next) => { 42 | logger.error('Route error:', err); 43 | res.status(500).json({ 44 | error: 'Internal server error', 45 | message: err.message 46 | }); 47 | }; 48 | 49 | // Register routes 50 | router.use(rawBodyParser); 51 | 52 | // Event subscription routes 53 | router.post( 54 | '/repositories/:repositoryId/subscriptions', 55 | [ 56 | param('repositoryId') 57 | .notEmpty() 58 | .withMessage('Repository ID is required') 59 | .isString() 60 | .withMessage('Repository ID must be a string'), 61 | body('eventTypes') 62 | .optional() 63 | .isArray() 64 | .withMessage('Event types must be an array'), 65 | validateRequest 66 | ], 67 | eventController.registerEventSubscription 68 | ); 69 | 70 | router.delete( 71 | '/subscriptions/:subscriptionId', 72 | [ 73 | param('subscriptionId') 74 | .notEmpty() 75 | .withMessage('Subscription ID is required') 76 | .isString() 77 | .withMessage('Subscription ID must be a string'), 78 | validateRequest 79 | ], 80 | eventController.deleteEventSubscription 81 | ); 82 | 83 | router.get( 84 | '/subscriptions', 85 | [ 86 | body('repositoryId') 87 | .optional() 88 | .isString() 89 | .withMessage('Repository ID must be a string'), 90 | validateRequest 91 | ], 92 | eventController.listEventSubscriptions 93 | ); 94 | 95 | // Event handler route 96 | router.post( 97 | '/events', 98 | [ 99 | header('x-unizo-signature') 100 | .notEmpty() 101 | .withMessage('Event signature is required'), 102 | header('x-unizo-event') 103 | .notEmpty() 104 | .withMessage('Event type is required') 105 | .isString() 106 | .withMessage('Event type must be a string'), 107 | validateRequest 108 | ], 109 | eventController.handleEvent 110 | ); 111 | 112 | // Apply error handling 113 | router.use(errorHandler); 114 | 115 | module.exports = router; -------------------------------------------------------------------------------- /src/services/webhook.service.js: -------------------------------------------------------------------------------- 1 | const logger = require('../utils/logger'); 2 | const axiosInstance = require('../utils/axios-instance'); 3 | const config = require('../config'); 4 | 5 | class WatchService { 6 | constructor() { 7 | this.apiUrl = config.UNIZO_API_URL; 8 | this.authUserId = config.UNIZO_AUTH_USER_ID; 9 | } 10 | 11 | /** 12 | * Register a watch for a repository 13 | */ 14 | async registerWatch(repositoryId, organizationId, webhookUrl, options = {}) { 15 | try { 16 | logger.info(`Registering watch for repository: ${repositoryId}`); 17 | 18 | const watchConfig = { 19 | name: `${repositoryId}-watch`, 20 | description: `Watch for ${repositoryId} repository`, 21 | type: 'HOOK', 22 | resource: { 23 | type: 'REPOSITORY', 24 | repository: { 25 | id: repositoryId 26 | }, 27 | organization: { 28 | id: organizationId 29 | }, 30 | config: { 31 | url: webhookUrl, 32 | securedSSLRequired: options.securedSSLRequired || false, 33 | contentType: 'application/json' 34 | } 35 | } 36 | }; 37 | 38 | const response = await axiosInstance.post( 39 | `${this.apiUrl}/integrations/${config.INTEGRATION_ID}/watches`, 40 | watchConfig, 41 | { 42 | headers: { 43 | 'authuserid': this.authUserId, 44 | 'sourcechannel': 'API', 45 | 'Content-Type': 'application/json' 46 | } 47 | } 48 | ); 49 | 50 | logger.info(`Successfully registered watch for ${repositoryId}`); 51 | return response.data; 52 | } catch (error) { 53 | logger.error(`Failed to register watch for ${repositoryId}:`, error.message); 54 | throw new Error(`Watch registration failed: ${error.message}`); 55 | } 56 | } 57 | 58 | /** 59 | * Delete a watch 60 | */ 61 | async deleteWatch(watchId) { 62 | try { 63 | logger.info(`Deleting watch: ${watchId}`); 64 | 65 | await axiosInstance.delete( 66 | `${this.apiUrl}/integrations/${config.INTEGRATION_ID}/watches/${watchId}`, 67 | { 68 | headers: { 69 | 'authuserid': this.authUserId, 70 | 'sourcechannel': 'API' 71 | } 72 | } 73 | ); 74 | 75 | logger.info(`Successfully deleted watch ${watchId}`); 76 | return true; 77 | } catch (error) { 78 | logger.error(`Failed to delete watch ${watchId}:`, error.message); 79 | throw new Error(`Watch deletion failed: ${error.message}`); 80 | } 81 | } 82 | 83 | /** 84 | * List watches 85 | */ 86 | async listWatches() { 87 | try { 88 | logger.info('Listing watches'); 89 | 90 | const response = await axiosInstance.get( 91 | `${this.apiUrl}/integrations/${config.INTEGRATION_ID}/watches`, 92 | { 93 | headers: { 94 | 'authuserid': this.authUserId, 95 | 'sourcechannel': 'API' 96 | } 97 | } 98 | ); 99 | 100 | return response.data; 101 | } catch (error) { 102 | logger.error('Failed to list watches:', error.message); 103 | throw new Error(`Failed to list watches: ${error.message}`); 104 | } 105 | } 106 | 107 | /** 108 | * Update watch configuration 109 | */ 110 | async updateWatch(watchId, watchConfig) { 111 | try { 112 | logger.info(`Updating watch: ${watchId}`); 113 | 114 | const response = await axiosInstance.put( 115 | `${this.apiUrl}/integrations/${config.INTEGRATION_ID}/watches/${watchId}`, 116 | watchConfig, 117 | { 118 | headers: { 119 | 'authuserid': this.authUserId, 120 | 'sourcechannel': 'API', 121 | 'Content-Type': 'application/json' 122 | } 123 | } 124 | ); 125 | 126 | logger.info(`Successfully updated watch ${watchId}`); 127 | return response.data; 128 | } catch (error) { 129 | logger.error(`Failed to update watch ${watchId}:`, error.message); 130 | throw new Error(`Watch update failed: ${error.message}`); 131 | } 132 | } 133 | } 134 | 135 | module.exports = new WatchService(); -------------------------------------------------------------------------------- /src/services/repository.service.js: -------------------------------------------------------------------------------- 1 | const logger = require('../utils/logger'); 2 | const axiosInstance = require('../utils/axios-instance'); 3 | const config = require('../config'); 4 | 5 | class RepositoryService { 6 | constructor() { 7 | this.apiUrl = config.UNIZO_API_URL; 8 | this.apiKey = config.UNIZO_API_KEY; 9 | this.organization = config.TARGET_ORGANIZATION; 10 | } 11 | 12 | /** 13 | * List all repositories in the organization 14 | */ 15 | async listRepositories(page = 1, perPage = 100) { 16 | try { 17 | logger.info(`Fetching repositories for organization: ${this.organization}, page: ${page}`); 18 | 19 | const response = await axiosInstance.get( 20 | `${this.apiUrl}/organizations/${this.organization}/repositories`, 21 | { 22 | params: { 23 | page, 24 | limit: perPage, 25 | sort: 'name', 26 | order: 'asc' 27 | }, 28 | headers: { 29 | 'X-API-Key': this.apiKey, 30 | 'Accept': 'application/json' 31 | } 32 | } 33 | ); 34 | 35 | return { 36 | repositories: response.data.items, 37 | pagination: response.data.pagination 38 | }; 39 | } catch (error) { 40 | logger.error(`Failed to fetch repositories for organization ${this.organization}:`, error.message); 41 | throw new Error(`Repository fetch failed: ${error.message}`); 42 | } 43 | } 44 | 45 | /** 46 | * Get repository details 47 | */ 48 | async getRepository(repositoryId) { 49 | try { 50 | logger.info(`Fetching details for repository: ${repositoryId}`); 51 | 52 | const response = await axiosInstance.get( 53 | `${this.apiUrl}/repositories/${repositoryId}`, 54 | { 55 | headers: { 56 | 'X-API-Key': this.apiKey, 57 | 'Accept': 'application/json' 58 | } 59 | } 60 | ); 61 | 62 | return response.data; 63 | } catch (error) { 64 | logger.error(`Failed to fetch repository ${repositoryId}:`, error.message); 65 | throw new Error(`Repository fetch failed: ${error.message}`); 66 | } 67 | } 68 | 69 | /** 70 | * Check if repository exists 71 | */ 72 | async repositoryExists(repositoryId) { 73 | try { 74 | await this.getRepository(repositoryId); 75 | return true; 76 | } catch (error) { 77 | if (error.response && error.response.status === 404) { 78 | return false; 79 | } 80 | throw error; 81 | } 82 | } 83 | 84 | /** 85 | * List repository branches 86 | */ 87 | async listBranches(repositoryId) { 88 | try { 89 | logger.info(`Fetching branches for repository: ${repositoryId}`); 90 | 91 | const response = await axiosInstance.get( 92 | `${this.apiUrl}/repositories/${repositoryId}/branches`, 93 | { 94 | headers: { 95 | 'X-API-Key': this.apiKey, 96 | 'Accept': 'application/json' 97 | } 98 | } 99 | ); 100 | 101 | return response.data; 102 | } catch (error) { 103 | logger.error(`Failed to fetch branches for repository ${repositoryId}:`, error.message); 104 | throw new Error(`Branch fetch failed: ${error.message}`); 105 | } 106 | } 107 | 108 | /** 109 | * Get repository events 110 | */ 111 | async listEvents(repositoryId, eventTypes = [], startDate = null, endDate = null) { 112 | try { 113 | logger.info(`Fetching events for repository: ${repositoryId}`); 114 | 115 | const params = { 116 | ...(eventTypes.length > 0 && { types: eventTypes.join(',') }), 117 | ...(startDate && { start_date: startDate }), 118 | ...(endDate && { end_date: endDate }) 119 | }; 120 | 121 | const response = await axiosInstance.get( 122 | `${this.apiUrl}/repositories/${repositoryId}/events`, 123 | { 124 | params, 125 | headers: { 126 | 'X-API-Key': this.apiKey, 127 | 'Accept': 'application/json' 128 | } 129 | } 130 | ); 131 | 132 | return response.data; 133 | } catch (error) { 134 | logger.error(`Failed to fetch events for repository ${repositoryId}:`, error.message); 135 | throw new Error(`Event fetch failed: ${error.message}`); 136 | } 137 | } 138 | } 139 | 140 | module.exports = new RepositoryService(); -------------------------------------------------------------------------------- /src/controllers/event.controller.js: -------------------------------------------------------------------------------- 1 | const logger = require('../utils/logger'); 2 | const repositoryService = require('../services/repository.service'); 3 | const eventService = require('../services/event.service'); 4 | 5 | class EventController { 6 | /** 7 | * Register event subscription for a repository 8 | */ 9 | async registerEventSubscription(req, res) { 10 | try { 11 | const { repositoryId } = req.params; 12 | const { eventTypes } = req.body; 13 | 14 | // Validate repository exists 15 | const exists = await repositoryService.repositoryExists(repositoryId); 16 | if (!exists) { 17 | logger.warn(`Repository ${repositoryId} not found`); 18 | return res.status(404).json({ 19 | error: 'Repository not found' 20 | }); 21 | } 22 | 23 | // Check if subscription already exists 24 | const existingSubscriptions = await eventService.listEventSubscriptions(repositoryId); 25 | const subscriptionExists = existingSubscriptions.some(sub => 26 | sub.repository_id === repositoryId 27 | ); 28 | 29 | if (subscriptionExists) { 30 | logger.warn(`Event subscription already exists for repository ${repositoryId}`); 31 | return res.status(409).json({ 32 | error: 'Event subscription already exists for this repository' 33 | }); 34 | } 35 | 36 | // Register event subscription 37 | const subscription = await eventService.registerEventSubscription( 38 | repositoryId, 39 | eventTypes || undefined 40 | ); 41 | 42 | logger.info(`Event subscription registered successfully for ${repositoryId}`); 43 | return res.status(201).json(subscription); 44 | } catch (error) { 45 | logger.error('Error in registerEventSubscription:', error); 46 | return res.status(500).json({ 47 | error: 'Failed to register event subscription', 48 | message: error.message 49 | }); 50 | } 51 | } 52 | 53 | /** 54 | * Delete event subscription 55 | */ 56 | async deleteEventSubscription(req, res) { 57 | try { 58 | const { subscriptionId } = req.params; 59 | 60 | await eventService.deleteEventSubscription(subscriptionId); 61 | 62 | logger.info(`Event subscription ${subscriptionId} deleted successfully`); 63 | return res.status(204).send(); 64 | } catch (error) { 65 | logger.error('Error in deleteEventSubscription:', error); 66 | return res.status(500).json({ 67 | error: 'Failed to delete event subscription', 68 | message: error.message 69 | }); 70 | } 71 | } 72 | 73 | /** 74 | * List event subscriptions 75 | */ 76 | async listEventSubscriptions(req, res) { 77 | try { 78 | const { repositoryId } = req.query; 79 | 80 | const subscriptions = await eventService.listEventSubscriptions(repositoryId); 81 | 82 | logger.info('Successfully retrieved event subscriptions'); 83 | return res.status(200).json(subscriptions); 84 | } catch (error) { 85 | logger.error('Error in listEventSubscriptions:', error); 86 | return res.status(500).json({ 87 | error: 'Failed to list event subscriptions', 88 | message: error.message 89 | }); 90 | } 91 | } 92 | 93 | /** 94 | * Handle incoming events 95 | */ 96 | async handleEvent(req, res) { 97 | try { 98 | const signature = req.headers['x-unizo-signature']; 99 | const eventType = req.headers['x-unizo-event']; 100 | const payload = req.body; 101 | 102 | // Verify event signature 103 | try { 104 | eventService.verifyEventSignature(JSON.stringify(payload), signature); 105 | } catch (error) { 106 | logger.error('Invalid event signature:', error); 107 | return res.status(401).json({ 108 | error: 'Invalid event signature' 109 | }); 110 | } 111 | 112 | // Process the event 113 | await eventService.processEvent(eventType, payload); 114 | 115 | return res.status(200).json({ 116 | message: 'Event processed successfully' 117 | }); 118 | } catch (error) { 119 | logger.error('Error in handleEvent:', error); 120 | return res.status(500).json({ 121 | error: 'Failed to process event', 122 | message: error.message 123 | }); 124 | } 125 | } 126 | } 127 | 128 | module.exports = new EventController(); -------------------------------------------------------------------------------- /src/services/event.service.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const logger = require('../utils/logger'); 3 | const axiosInstance = require('../utils/axios-instance'); 4 | const config = require('../config'); 5 | 6 | class EventService { 7 | constructor() { 8 | this.apiUrl = config.UNIZO_API_URL; 9 | this.apiKey = config.UNIZO_API_KEY; 10 | this.eventSecret = config.EVENT_SECRET; 11 | } 12 | 13 | /** 14 | * Verify event signature from Unizo API 15 | */ 16 | verifyEventSignature(payload, signature) { 17 | if (!signature) { 18 | throw new Error('No signature provided'); 19 | } 20 | 21 | const sig = Buffer.from(signature); 22 | const hmac = crypto.createHmac('sha256', this.eventSecret); 23 | const digest = Buffer.from('sha256=' + hmac.update(payload).digest('hex')); 24 | 25 | if (sig.length !== digest.length || !crypto.timingSafeEqual(digest, sig)) { 26 | throw new Error('Invalid signature'); 27 | } 28 | 29 | return true; 30 | } 31 | 32 | /** 33 | * Register event subscription for a repository 34 | */ 35 | async registerEventSubscription(repositoryId, eventTypes = config.EVENT_SELECTORS) { 36 | try { 37 | logger.info(`Registering event subscription for repository: ${repositoryId}`); 38 | 39 | const subscriptionConfig = { 40 | repository_id: repositoryId, 41 | event_types: eventTypes, 42 | callback_url: `${process.env.APP_URL}${config.EVENT_PATH}`, 43 | secret: this.eventSecret, 44 | active: true 45 | }; 46 | 47 | const response = await axiosInstance.post( 48 | `${this.apiUrl}/event-subscriptions`, 49 | subscriptionConfig, 50 | { 51 | headers: { 52 | 'X-API-Key': this.apiKey, 53 | 'Accept': 'application/json' 54 | } 55 | } 56 | ); 57 | 58 | logger.info(`Successfully registered event subscription for ${repositoryId}`); 59 | return response.data; 60 | } catch (error) { 61 | logger.error(`Failed to register event subscription for ${repositoryId}:`, error.message); 62 | throw new Error(`Event subscription registration failed: ${error.message}`); 63 | } 64 | } 65 | 66 | /** 67 | * Delete event subscription 68 | */ 69 | async deleteEventSubscription(subscriptionId) { 70 | try { 71 | logger.info(`Deleting event subscription: ${subscriptionId}`); 72 | 73 | await axiosInstance.delete( 74 | `${this.apiUrl}/event-subscriptions/${subscriptionId}`, 75 | { 76 | headers: { 77 | 'X-API-Key': this.apiKey, 78 | 'Accept': 'application/json' 79 | } 80 | } 81 | ); 82 | 83 | logger.info(`Successfully deleted event subscription ${subscriptionId}`); 84 | return true; 85 | } catch (error) { 86 | logger.error(`Failed to delete event subscription ${subscriptionId}:`, error.message); 87 | throw new Error(`Event subscription deletion failed: ${error.message}`); 88 | } 89 | } 90 | 91 | /** 92 | * List event subscriptions 93 | */ 94 | async listEventSubscriptions(repositoryId = null) { 95 | try { 96 | logger.info('Listing event subscriptions'); 97 | 98 | const params = repositoryId ? { repository_id: repositoryId } : {}; 99 | 100 | const response = await axiosInstance.get( 101 | `${this.apiUrl}/event-subscriptions`, 102 | { 103 | params, 104 | headers: { 105 | 'X-API-Key': this.apiKey, 106 | 'Accept': 'application/json' 107 | } 108 | } 109 | ); 110 | 111 | return response.data; 112 | } catch (error) { 113 | logger.error('Failed to list event subscriptions:', error.message); 114 | throw new Error(`Failed to list event subscriptions: ${error.message}`); 115 | } 116 | } 117 | 118 | /** 119 | * Update event subscription 120 | */ 121 | async updateEventSubscription(subscriptionId, subscriptionConfig) { 122 | try { 123 | logger.info(`Updating event subscription: ${subscriptionId}`); 124 | 125 | const response = await axiosInstance.patch( 126 | `${this.apiUrl}/event-subscriptions/${subscriptionId}`, 127 | subscriptionConfig, 128 | { 129 | headers: { 130 | 'X-API-Key': this.apiKey, 131 | 'Accept': 'application/json' 132 | } 133 | } 134 | ); 135 | 136 | logger.info(`Successfully updated event subscription ${subscriptionId}`); 137 | return response.data; 138 | } catch (error) { 139 | logger.error(`Failed to update event subscription ${subscriptionId}:`, error.message); 140 | throw new Error(`Event subscription update failed: ${error.message}`); 141 | } 142 | } 143 | 144 | /** 145 | * Process event payload 146 | */ 147 | async processEvent(eventType, payload) { 148 | try { 149 | logger.info(`Processing ${eventType} event for repository ${payload.repository?.id}`); 150 | 151 | // Handle different event types 152 | switch (eventType) { 153 | case 'repository:created': 154 | await this.handleRepositoryCreated(payload); 155 | break; 156 | 157 | case 'repository:renamed': 158 | await this.handleRepositoryRenamed(payload); 159 | break; 160 | 161 | case 'repository:deleted': 162 | await this.handleRepositoryDeleted(payload); 163 | break; 164 | 165 | case 'repository:archived': 166 | await this.handleRepositoryArchived(payload); 167 | break; 168 | 169 | case 'branch:created': 170 | await this.handleBranchCreated(payload); 171 | break; 172 | 173 | case 'commit:pushed': 174 | await this.handleCommitPushed(payload); 175 | break; 176 | 177 | default: 178 | logger.warn(`Unhandled event type: ${eventType}`); 179 | } 180 | 181 | return true; 182 | } catch (error) { 183 | logger.error(`Failed to process ${eventType} event:`, error.message); 184 | throw error; 185 | } 186 | } 187 | 188 | // Event handlers 189 | async handleRepositoryCreated(payload) { 190 | logger.info(`New repository created: ${payload.repository.name}`); 191 | // Implement repository creation handling logic 192 | } 193 | 194 | async handleRepositoryRenamed(payload) { 195 | logger.info(`Repository renamed from ${payload.old_name} to ${payload.new_name}`); 196 | // Implement repository rename handling logic 197 | } 198 | 199 | async handleRepositoryDeleted(payload) { 200 | logger.info(`Repository deleted: ${payload.repository.name}`); 201 | // Implement repository deletion handling logic 202 | } 203 | 204 | async handleRepositoryArchived(payload) { 205 | logger.info(`Repository archived: ${payload.repository.name}`); 206 | // Implement repository archival handling logic 207 | } 208 | 209 | async handleBranchCreated(payload) { 210 | logger.info(`New branch created: ${payload.branch.name} in ${payload.repository.name}`); 211 | // Implement branch creation handling logic 212 | } 213 | 214 | async handleCommitPushed(payload) { 215 | logger.info(`New commit pushed to ${payload.repository.name}`); 216 | // Implement commit push handling logic 217 | } 218 | } 219 | 220 | module.exports = new EventService(); -------------------------------------------------------------------------------- /src/controllers/webhook.controller.js: -------------------------------------------------------------------------------- 1 | const logger = require('../utils/logger'); 2 | const repositoryService = require('../services/repository.service'); 3 | const configService = require('../services/config.service'); 4 | const webhookService = require('../services/webhook.service'); 5 | 6 | /** 7 | * Register webhooks for all repositories in an organization 8 | * @param {Object} req - Express request object 9 | * @param {Object} res - Express response object 10 | * @param {Function} next - Express next middleware function 11 | */ 12 | const registerRepositoryWebhooks = async (req, res, next) => { 13 | try { 14 | const { organizationId } = req.params; 15 | const integrationId = req.headers.integrationid; 16 | 17 | logger.info({ organizationId, integrationId }, 'Starting webhook registration process'); 18 | 19 | // Step 1: Fetch organization webhook config 20 | const webhookConfig = await configService.getOrganizationWebhookConfig(organizationId); 21 | 22 | if (!webhookConfig) { 23 | logger.warn({ organizationId }, 'No SCM_WATCH_HOOK configuration found for this organization'); 24 | return res.status(404).json({ 25 | error: 'No SCM_WATCH_HOOK configuration found for this organization' 26 | }); 27 | } 28 | 29 | logger.info({ organizationId, webhookUrl: webhookConfig.url }, 'Found webhook configuration'); 30 | 31 | // Step 2: Process repositories in pages 32 | let hasMorePages = true; 33 | let nextPage = null; 34 | let totalRepositories = 0; 35 | let registeredWebhooks = 0; 36 | let failedWebhooks = 0; 37 | 38 | while (hasMorePages) { 39 | // Fetch repositories for the current page 40 | const { repositories, pagination } = await repositoryService.getRepositories( 41 | organizationId, 42 | integrationId, 43 | nextPage 44 | ); 45 | 46 | logger.info({ 47 | organizationId, 48 | page: nextPage, 49 | repositoryCount: repositories.length, 50 | hasMorePages: !!pagination.next 51 | }, 'Retrieved repositories page'); 52 | 53 | // Process repositories in this page 54 | for (const repository of repositories) { 55 | totalRepositories++; 56 | 57 | try { 58 | // Register webhook for this repository 59 | await webhookService.registerWebhook( 60 | integrationId, 61 | organizationId, 62 | repository, 63 | webhookConfig 64 | ); 65 | 66 | registeredWebhooks++; 67 | logger.info({ 68 | organizationId, 69 | repositoryId: repository.id, 70 | repositoryName: repository.fullName 71 | }, 'Successfully registered webhook'); 72 | } catch (error) { 73 | failedWebhooks++; 74 | logger.error({ 75 | organizationId, 76 | repositoryId: repository.id, 77 | repositoryName: repository.fullName, 78 | error: error.message 79 | }, 'Failed to register webhook'); 80 | } 81 | } 82 | 83 | // Check if there are more pages to process 84 | hasMorePages = !!pagination.next; 85 | nextPage = pagination.next; 86 | } 87 | 88 | // Return response with summary 89 | return res.status(200).json({ 90 | message: 'Webhook registration process completed', 91 | summary: { 92 | organizationId, 93 | totalRepositories, 94 | registeredWebhooks, 95 | failedWebhooks 96 | } 97 | }); 98 | } catch (error) { 99 | logger.error({ error: error.message, stack: error.stack }, 'Error in webhook registration process'); 100 | next(error); 101 | } 102 | }; 103 | 104 | class WebhookController { 105 | /** 106 | * Register webhook for a repository 107 | */ 108 | async registerWebhook(req, res) { 109 | try { 110 | const { repositoryName } = req.params; 111 | 112 | // Validate repository exists 113 | const exists = await repositoryService.repositoryExists(repositoryName); 114 | if (!exists) { 115 | logger.warn(`Repository ${repositoryName} not found`); 116 | return res.status(404).json({ 117 | error: 'Repository not found' 118 | }); 119 | } 120 | 121 | // Check if webhook already exists 122 | const existingWebhooks = await webhookService.listWebhooks(repositoryName); 123 | const webhookExists = existingWebhooks.some(hook => 124 | hook.config.url === `${process.env.APP_URL}/webhook` 125 | ); 126 | 127 | if (webhookExists) { 128 | logger.warn(`Webhook already exists for repository ${repositoryName}`); 129 | return res.status(409).json({ 130 | error: 'Webhook already exists for this repository' 131 | }); 132 | } 133 | 134 | // Register webhook 135 | const webhook = await webhookService.registerWebhook(repositoryName); 136 | 137 | logger.info(`Webhook registered successfully for ${repositoryName}`); 138 | return res.status(201).json(webhook); 139 | } catch (error) { 140 | logger.error('Error in registerWebhook:', error); 141 | return res.status(500).json({ 142 | error: 'Failed to register webhook', 143 | message: error.message 144 | }); 145 | } 146 | } 147 | 148 | /** 149 | * Delete webhook from a repository 150 | */ 151 | async deleteWebhook(req, res) { 152 | try { 153 | const { repositoryName, webhookId } = req.params; 154 | 155 | // Validate repository exists 156 | const exists = await repositoryService.repositoryExists(repositoryName); 157 | if (!exists) { 158 | logger.warn(`Repository ${repositoryName} not found`); 159 | return res.status(404).json({ 160 | error: 'Repository not found' 161 | }); 162 | } 163 | 164 | await webhookService.deleteWebhook(repositoryName, webhookId); 165 | 166 | logger.info(`Webhook ${webhookId} deleted successfully from ${repositoryName}`); 167 | return res.status(204).send(); 168 | } catch (error) { 169 | logger.error('Error in deleteWebhook:', error); 170 | return res.status(500).json({ 171 | error: 'Failed to delete webhook', 172 | message: error.message 173 | }); 174 | } 175 | } 176 | 177 | /** 178 | * List webhooks for a repository 179 | */ 180 | async listWebhooks(req, res) { 181 | try { 182 | const { repositoryName } = req.params; 183 | 184 | // Validate repository exists 185 | const exists = await repositoryService.repositoryExists(repositoryName); 186 | if (!exists) { 187 | logger.warn(`Repository ${repositoryName} not found`); 188 | return res.status(404).json({ 189 | error: 'Repository not found' 190 | }); 191 | } 192 | 193 | const webhooks = await webhookService.listWebhooks(repositoryName); 194 | 195 | logger.info(`Successfully retrieved webhooks for ${repositoryName}`); 196 | return res.status(200).json(webhooks); 197 | } catch (error) { 198 | logger.error('Error in listWebhooks:', error); 199 | return res.status(500).json({ 200 | error: 'Failed to list webhooks', 201 | message: error.message 202 | }); 203 | } 204 | } 205 | 206 | /** 207 | * Handle incoming webhook events 208 | */ 209 | async handleWebhookEvent(req, res) { 210 | try { 211 | const signature = req.headers['x-hub-signature-256']; 212 | const event = req.headers['x-github-event']; 213 | const payload = req.body; 214 | 215 | // Verify webhook signature 216 | try { 217 | webhookService.verifyWebhookSignature(JSON.stringify(payload), signature); 218 | } catch (error) { 219 | logger.error('Invalid webhook signature:', error); 220 | return res.status(401).json({ 221 | error: 'Invalid webhook signature' 222 | }); 223 | } 224 | 225 | // Log the event 226 | logger.info(`Received ${event} event from ${payload.repository?.full_name}`); 227 | 228 | // Process different event types 229 | switch (event) { 230 | case 'push': 231 | // Handle push event 232 | logger.info(`Push to ${payload.ref} by ${payload.pusher.name}`); 233 | break; 234 | 235 | case 'pull_request': 236 | // Handle pull request event 237 | logger.info(`Pull request ${payload.action}: ${payload.pull_request.title}`); 238 | break; 239 | 240 | default: 241 | logger.info(`Unhandled event type: ${event}`); 242 | } 243 | 244 | return res.status(200).json({ 245 | message: 'Webhook received successfully' 246 | }); 247 | } catch (error) { 248 | logger.error('Error in handleWebhookEvent:', error); 249 | return res.status(500).json({ 250 | error: 'Failed to process webhook', 251 | message: error.message 252 | }); 253 | } 254 | } 255 | } 256 | 257 | module.exports = { 258 | registerRepositoryWebhooks, 259 | registerWebhook, 260 | deleteWebhook, 261 | listWebhooks, 262 | handleWebhookEvent 263 | }; --------------------------------------------------------------------------------