diff --git a/backend/README.md b/backend/README.md index 1a4c14f..8c2c4a9 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,33 +1,30 @@ -# Backend Documentation +# Backend ## Overview -This is the backend part of the fullstack application built with Node.js, TypeScript, and SQLite. The backend serves as the API for the frontend Angular application. -## Project Structure -- **src/**: Contains the source code for the backend. - - **controllers/**: Handles incoming requests and responses. - - **models/**: Defines data models for interacting with the SQLite database. - - **routes/**: Sets up API routes and links them to controllers. - - **services/**: Contains business logic and data access methods. - - **app.ts**: Entry point for the Node.js backend, setting up the Express app. -## Getting Started -1. Clone the repository. -2. Navigate to the backend directory. -3. Install dependencies: - ``` - npm install - ``` -4. Start the server: - ``` - npm start - ``` +## Development +```bash +npm run dev +``` ## Database -The backend uses SQLite for data storage. The database file is located in the `database/` directory. +The backend uses a MySQL database to store data. -## API Endpoints -Refer to the routes defined in the `src/routes/index.ts` file for available API endpoints. +You can run the docker compose file to start the database. Just shutdown the backend server so you can free up the port. + +You can also run it manually: +```bash +docker run -d \ + --name db \ + --restart always \ + -e MYSQL_PASSWORD=octocat \ + -e MYSQL_DATABASE=value \ + -p 3306:3306 \ + -v db:/var/lib/mysql \ + -v ./db/init.sql:/docker-entrypoint-initdb.d/init.sql \ + mysql +``` -## License -This project is licensed under the MIT License. \ No newline at end of file +## API Endpoints +Refer to the routes defined in the [`src/routes/index.ts`](./src/routes/index.ts) file for available API endpoints. diff --git a/backend/package-lock.json b/backend/package-lock.json index 64a0242..934691c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "bunyan": "^1.8.15", "cors": "^2.8.5", + "cron": "^3.1.7", "dotenv": "^16.4.5", "eventsource": "^2.0.2", "express": "^4.21.1", @@ -19,7 +20,8 @@ "node-cron": "^3.0.3", "octokit": "^4.0.2", "sequelize": "^6.37.5", - "smee-client": "^2.0.4" + "smee-client": "^2.0.4", + "update-dotenv": "^1.1.1" }, "devDependencies": { "@types/bunyan": "^1.8.11", @@ -444,6 +446,12 @@ "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", "dev": true }, + "node_modules/@types/luxon": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", + "integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==", + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -747,6 +755,16 @@ "node": ">= 0.10" } }, + "node_modules/cron": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/cron/-/cron-3.1.7.tgz", + "integrity": "sha512-tlBg7ARsAMQLzgwqVxy8AZl/qlTc5nibqYwtNGoCrd+cV+ugI+tvZC1oT/8dFH8W455YrywGykx/KMmAqOr7Jw==", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.4.0", + "luxon": "~3.4.0" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -800,6 +818,7 @@ "version": "16.4.5", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "license": "BSD-2-Clause", "engines": { "node": ">=12" }, @@ -1240,6 +1259,15 @@ "url": "https://github.com/sponsors/wellwelwel" } }, + "node_modules/luxon": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", + "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -2088,6 +2116,15 @@ "node": ">= 0.8" } }, + "node_modules/update-dotenv": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/update-dotenv/-/update-dotenv-1.1.1.tgz", + "integrity": "sha512-3cIC18In/t0X/yH793c00qqxcKD8jVCgNOPif/fGQkFpYMGecM9YAc+kaAKXuZsM2dE9I9wFI7KvAuNX22SGMQ==", + "license": "ISC", + "peerDependencies": { + "dotenv": "*" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index 9aa86f9..a7d7288 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,15 +13,16 @@ "dependencies": { "bunyan": "^1.8.15", "cors": "^2.8.5", + "cron": "^3.1.7", "dotenv": "^16.4.5", "eventsource": "^2.0.2", "express": "^4.21.1", "express-rate-limit": "^7.4.1", "mysql2": "^3.11.3", - "node-cron": "^3.0.3", "octokit": "^4.0.2", "sequelize": "^6.37.5", - "smee-client": "^2.0.4" + "smee-client": "^2.0.4", + "update-dotenv": "^1.1.1" }, "devDependencies": { "@types/bunyan": "^1.8.11", @@ -29,7 +30,6 @@ "@types/eventsource": "^1.1.15", "@types/express": "^4.17.21", "@types/node": "^22.8.0", - "@types/node-cron": "^3.0.11", "nodemon": "^3.1.7", "typescript": "^5.6.3" }, diff --git a/backend/src/app.ts b/backend/src/app.ts index dec4024..8fbad11 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -9,6 +9,7 @@ import { dbConnect } from './database'; import setup from './services/setup'; import SmeeService from './services/smee'; import logger, { expressLoggerMiddleware } from './services/logger'; +import dotenv from 'dotenv' const PORT = Number(process.env.PORT) || 80; @@ -27,8 +28,13 @@ app.use(expressLoggerMiddleware); logger.info('Failed to create app from environment. This is expected if the app is not yet installed.'); } - // API Routes - app.use('/api', bodyParser.json(), bodyParser.urlencoded({ extended: true }), apiRoutes); + app.use((req, res, next) => { + if (req.path === '/api/github/webhooks') { + return next(); + } + bodyParser.json()(req, res, next); + }, bodyParser.urlencoded({ extended: true })); + app.use('/api', apiRoutes); // Angular Frontend const frontendPath = path.join(__dirname, '../../frontend/dist/github-value/browser'); diff --git a/backend/src/controllers/settings.controller.ts b/backend/src/controllers/settings.controller.ts index 5c18a04..378a685 100644 --- a/backend/src/controllers/settings.controller.ts +++ b/backend/src/controllers/settings.controller.ts @@ -2,7 +2,6 @@ import { Request, Response } from 'express'; import SettingsService from '../services/settings.service'; class SettingsController { - // Get all settings ⚙️ async getAllSettings(req: Request, res: Response) { try { const settings = await SettingsService.getAllSettings(); @@ -13,7 +12,6 @@ class SettingsController { } } - // Get settings by name 🆔 async getSettingsByName(req: Request, res: Response) { try { const { name } = req.params; @@ -28,17 +26,15 @@ class SettingsController { } } - // Create new settings 🆕 async createSettings(req: Request, res: Response) { try { - const newSettings = await SettingsService.updateOrCreateSettings(req.body); + const newSettings = await SettingsService.updateSettings(req.body); res.status(201).json(newSettings); } catch (error) { res.status(500).json(error); } } - // Update settings ✏️ async updateSettings(req: Request, res: Response) { try { const updatedSettings = await SettingsService.updateSettings(req.body); @@ -48,7 +44,6 @@ class SettingsController { } } - // Delete settings 🗑️ async deleteSettings(req: Request, res: Response) { try { const { name } = req.params; diff --git a/backend/src/controllers/setup.controller.ts b/backend/src/controllers/setup.controller.ts index b0dbb7a..ad25ea2 100644 --- a/backend/src/controllers/setup.controller.ts +++ b/backend/src/controllers/setup.controller.ts @@ -22,7 +22,7 @@ class SetupController { throw new Error('installation_id must be a number'); } const app = await setup.createAppFromInstallationId(Number(installation_id)); - + res.redirect(process.env.WEB_URL || '/'); } catch (error) { res.status(500).json(error); @@ -38,6 +38,24 @@ class SetupController { } } + async addExistingApp(req: Request, res: Response) { + try { + const { appId, privateKey, webhookSecret } = req.body; + + if (!appId || !privateKey || !webhookSecret) { + return res.status(400).json({ error: 'All fields are required' }); + } + + const installUrl = await setup.createAppFromExisting(appId, privateKey, webhookSecret); + + console.log('installUrl', installUrl ); + + res.json({ installUrl }); + } catch (error) { + res.status(500).json(error); + } + } + isSetup(req: Request, res: Response) { try { res.json({ isSetup: setup.isSetup() }); diff --git a/backend/src/controllers/webhook.controller.ts b/backend/src/controllers/webhook.controller.ts index 4a10aac..1d142c5 100644 --- a/backend/src/controllers/webhook.controller.ts +++ b/backend/src/controllers/webhook.controller.ts @@ -1,6 +1,7 @@ import { Webhooks } from '@octokit/webhooks'; import { App } from 'octokit'; import logger from '../services/logger'; +import settingsService from '../services/settings.service'; const webhooks = new Webhooks({ secret: process.env.GITHUB_WEBHOOK_SECRET || 'your-secret', @@ -14,7 +15,7 @@ const webhooks = new Webhooks({ export const setupWebhookListeners = (github: App) => { github.webhooks.on("pull_request.opened", ({ octokit, payload }) => { - const surveyUrl = new URL(`/surveys/new`, octokit.request.endpoint.DEFAULTS.baseUrl); + const surveyUrl = new URL(`/surveys/new`, settingsService.baseUrl); surveyUrl.searchParams.append('url', payload.pull_request.html_url); surveyUrl.searchParams.append('author', payload.pull_request.user.login); diff --git a/backend/src/database.ts b/backend/src/database.ts index 291ea33..7bd107e 100644 --- a/backend/src/database.ts +++ b/backend/src/database.ts @@ -12,7 +12,7 @@ const sequelize = new Sequelize({ host: process.env.MYSQL_HOST, port: parseInt(process.env.MYSQL_PORT || '3306'), logging: (sql: string, timing?: number) => { - logger.info(sql, timing && `(${timing}ms)`); + logger.info(sql); } }); const dbConnect = async () => { diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 67b30e6..b07693b 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -29,6 +29,7 @@ router.get('/setup/redirect', setupController.setup); router.get('/setup/install', setupController.install); router.get('/setup/status', setupController.isSetup); router.get('/setup/manifest', setupController.getManifest); +router.post('/setup/existing-app', setupController.addExistingApp); router.get diff --git a/backend/src/services/metrics.service.ts b/backend/src/services/metrics.service.ts index 74055de..a42f33b 100644 --- a/backend/src/services/metrics.service.ts +++ b/backend/src/services/metrics.service.ts @@ -1,73 +1,95 @@ -import cron from "node-cron"; +import { CronJob, CronTime } from 'cron'; import { Metrics, Breakdown } from '../models/metrics.model'; import setup from './setup'; import logger from "./logger"; +import settingsService from "./settings.service"; -export async function queryCopilotMetrics() { - try { - const octokit = await setup.getOctokit(); - const response = await octokit.rest.copilot.usageMetricsForOrg({ - org: "octodemo" - }); - const metricsArray = response.data; +class MetricsService { + private static instance: MetricsService; + private cronJob: CronJob; - for (const metrics of metricsArray) { - const [createdMetrics, created] = await Metrics.findOrCreate({ - where: { day: metrics.day }, - defaults: { - totalSuggestionsCount: metrics.total_suggestions_count, - totalAcceptancesCount: metrics.total_acceptances_count, - totalLinesSuggested: metrics.total_lines_suggested, - totalLinesAccepted: metrics.total_lines_accepted, - totalActiveUsers: metrics.total_active_users, - totalChatAcceptances: metrics.total_chat_acceptances, - totalChatTurns: metrics.total_chat_turns, - totalActiveChatUsers: metrics.total_active_chat_users, - } + private constructor(cronExpression: string, timeZone: string) { + this.cronJob = new CronJob('0 0 * * *', this.queryCopilotMetrics, null, true); + this.queryCopilotMetrics(); + } + + public static createInstance(cronExpression: string, timeZone: string) { + if (!MetricsService.instance) { + MetricsService.instance = new MetricsService(cronExpression, timeZone); + } + } + + public static getInstance(): MetricsService { + return MetricsService.instance; + } + + public async queryCopilotMetrics() { + try { + const octokit = await setup.getOctokit(); + const response = await octokit.rest.copilot.usageMetricsForOrg({ + org: setup.installation.owner?.login }); + const metricsArray = response.data; - if (!created) { - logger.info(`Metrics for ${metrics.day} already exist. Updating... ✏️`); - - await createdMetrics.update({ - totalSuggestionsCount: metrics.total_suggestions_count, - totalAcceptancesCount: metrics.total_acceptances_count, - totalLinesSuggested: metrics.total_lines_suggested, - totalLinesAccepted: metrics.total_lines_accepted, - totalActiveUsers: metrics.total_active_users, - totalChatAcceptances: metrics.total_chat_acceptances, - totalChatTurns: metrics.total_chat_turns, - totalActiveChatUsers: metrics.total_active_chat_users, + for (const metrics of metricsArray) { + const [createdMetrics, created] = await Metrics.findOrCreate({ + where: { day: metrics.day }, + defaults: { + totalSuggestionsCount: metrics.total_suggestions_count, + totalAcceptancesCount: metrics.total_acceptances_count, + totalLinesSuggested: metrics.total_lines_suggested, + totalLinesAccepted: metrics.total_lines_accepted, + totalActiveUsers: metrics.total_active_users, + totalChatAcceptances: metrics.total_chat_acceptances, + totalChatTurns: metrics.total_chat_turns, + totalActiveChatUsers: metrics.total_active_chat_users, + } }); - - await Breakdown.destroy({ where: { metricsDay: metrics.day } }); - } - if (!metrics.breakdown) { - logger.info(`No breakdown data for ${metrics.day}. Skipping...`); - continue; - } - for (const breakdown of metrics.breakdown) { - await Breakdown.create({ - metricsDay: createdMetrics.dataValues.day, - language: breakdown.language, - editor: breakdown.editor, - suggestionsCount: breakdown.suggestions_count, - acceptancesCount: breakdown.acceptances_count, - linesSuggested: breakdown.lines_suggested, - linesAccepted: breakdown.lines_accepted, - activeUsers: breakdown.active_users, - }); + if (!created) { + logger.info(`Metrics for ${metrics.day} already exist. Updating... ✏️`); + + await createdMetrics.update({ + totalSuggestionsCount: metrics.total_suggestions_count, + totalAcceptancesCount: metrics.total_acceptances_count, + totalLinesSuggested: metrics.total_lines_suggested, + totalLinesAccepted: metrics.total_lines_accepted, + totalActiveUsers: metrics.total_active_users, + totalChatAcceptances: metrics.total_chat_acceptances, + totalChatTurns: metrics.total_chat_turns, + totalActiveChatUsers: metrics.total_active_chat_users, + }); + + await Breakdown.destroy({ where: { metricsDay: metrics.day } }); + } + + if (!metrics.breakdown) { + logger.info(`No breakdown data for ${metrics.day}. Skipping...`); + continue; + } + for (const breakdown of metrics.breakdown) { + await Breakdown.create({ + metricsDay: createdMetrics.dataValues.day, + language: breakdown.language, + editor: breakdown.editor, + suggestionsCount: breakdown.suggestions_count, + acceptancesCount: breakdown.acceptances_count, + linesSuggested: breakdown.lines_suggested, + linesAccepted: breakdown.lines_accepted, + activeUsers: breakdown.active_users, + }); + } } + + logger.info("Metrics successfully updated! 📈"); + } catch (error) { + logger.error('Error querying copilot metrics', error); } + } - logger.info("Metrics successfully updated! 📈"); - } catch (error) { - logger.error('Error querying copilot metrics', error); + public updateCronJob(cronExpression: string) { + this.cronJob.setTime(new CronTime(cronExpression)); } } -// Schedule the task to run daily at midnight -cron.schedule('0 0 * * *', queryCopilotMetrics); - -logger.info('Metrics cron job scheduled to run daily at midnight'); \ No newline at end of file +export default MetricsService \ No newline at end of file diff --git a/backend/src/services/settings.service.ts b/backend/src/services/settings.service.ts index f7cbd34..065a3c2 100644 --- a/backend/src/services/settings.service.ts +++ b/backend/src/services/settings.service.ts @@ -1,13 +1,21 @@ import { Settings } from '../models/settings.model'; +import MetricsService from './metrics.service'; +import setup from './setup'; class SettingsService { - // Get all settings ⚙️ + public baseUrl = process.env.BASE_URL || 'http://localhost'; + + constructor() { + this.getSettingsByName('baseUrl').then((baseUrl) => { + this.baseUrl = baseUrl; + }); + } + async getAllSettings() { return await Settings.findAll(); } - // Get settings by name 🆔 - async getSettingsByName(name: string) { + async getSettingsByName(name: string): Promise { const rsp = await Settings.findOne({ where: { name } }); if (!rsp) { throw new Error('Settings not found'); @@ -15,40 +23,24 @@ class SettingsService { return rsp.dataValues.value; } - // Update settings ✏️ - async updateSetting(name: string, data: any) { - const [updated] = await Settings.update(data, { - where: { name } - }); - if (updated) { - return await Settings.findOne({ where: { name } }); - } - throw new Error('Settings not found'); - } - - async updateSettings(obj: any) { - Object.entries(obj).forEach(([name, value]) => { - this.updateSetting(name, { value }); - }); - } - - async updateOrCreateSettings(obj: { [key: string]: string }) { - Object.entries(obj).forEach(([name, value]) => { - this.updateOrCreateSetting(name, value); - }); - } - - async updateOrCreateSetting(name: string, value: string) { + async updateSetting(name: string, value: string) { + if (name === 'webhookProxyUrl') setup.addToEnv({ WEBHOOK_PROXY_URL: value }); + if (name === 'webhookSecret') setup.addToEnv({ GITHUB_WEBHOOK_SECRET: value }); + if (name === 'metricsCronExpression') MetricsService.getInstance().updateCronJob(value); try { await Settings.upsert({ name, value }); return await Settings.findOne({ where: { name } }); } catch (error) { - // 😱 Handle any unexpected errors 😱 throw error; } } - // Delete settings 🗑️ + async updateSettings(obj: { [key: string]: string }) { + Object.entries(obj).forEach(([name, value]) => { + this.updateSetting(name, value); + }); + } + async deleteSettings(name: string) { const deleted = await Settings.destroy({ where: { name } diff --git a/backend/src/services/setup.ts b/backend/src/services/setup.ts index fee16b7..aa4beac 100644 --- a/backend/src/services/setup.ts +++ b/backend/src/services/setup.ts @@ -3,9 +3,11 @@ import { appendFileSync, readFileSync } from "fs"; import { App, createNodeMiddleware, Octokit } from "octokit"; import { setupWebhookListeners } from '../controllers/webhook.controller'; import { app as expressApp } from '../app'; -import { queryCopilotMetrics } from "./metrics.service"; +import metricsService from "./metrics.service"; import SmeeService from './smee'; import logger from "./logger"; +import updateDotenv from 'update-dotenv'; +import settingsService from './settings.service'; class Setup { private static instance: Setup; @@ -31,16 +33,64 @@ class Setup { }) const data = response.data; - appendFileSync('.env', `GITHUB_WEBHOOK_SECRET=${data.webhook_secret}\n`); - appendFileSync('.env', `GITHUB_APP_ID=${data.id}\n`); - appendFileSync('.env', `GITHUB_APP_PRIVATE_KEY="${data.pem}"\n`); - process.env.GITHUB_WEBHOOK_SECRET = data.webhook_secret; - process.env.GITHUB_APP_ID = data.id.toString(); - process.env.GITHUB_APP_PRIVATE_KEY = data.pem; + this.addToEnv({ + GITHUB_WEBHOOK_SECRET: data.webhook_secret, + GITHUB_APP_ID: data.id.toString(), + GITHUB_APP_PRIVATE_KEY: data.pem + }); return data; } + createAppFromExisting = async (appId: string, privateKey: string, webhookSecret: string) => { + const _app = new App({ + appId: appId, + privateKey: privateKey, + webhooks: { + secret: webhookSecret + }, + oauth: { + clientId: null!, + clientSecret: null! + } + }) + + const installUrl = await _app.getInstallationUrl(); + if (!installUrl) { + throw new Error('Failed to get installation URL'); + } + + const installation: any = await (new Promise((resolve, reject) => { + _app.eachInstallation((install) => { + if (install && install.installation && install.installation.id) { + resolve(install.installation); + } else { + reject(new Error("No installation found")); + } + return false; // Stop after the first installation + }); + })); + + this.installationId = installation.id; + this.addToEnv({ + GITHUB_APP_ID: appId, + GITHUB_APP_PRIVATE_KEY: privateKey, + GITHUB_WEBHOOK_SECRET: webhookSecret, + GITHUB_APP_INSTALLATION_ID: installation.id.toString() + }) + + await this.createAppFromEnv(); + + return installUrl; + } + + addToEnv = (obj: { [key: string]: string }) => { + updateDotenv(obj); + Object.entries(obj).forEach(([key, value]) => { + process.env[key] = value; + }); + } + createAppFromInstallationId = async (installationId: number) => { dotenv.config(); if (!process.env.GITHUB_APP_ID) throw new Error('GITHUB_APP_ID is not set'); @@ -60,8 +110,9 @@ class Setup { }, }); - appendFileSync('.env', `GITHUB_APP_INSTALLATION_ID=${installationId.toString()}\n`); - process.env.GITHUB_APP_INSTALLATION_ID = installationId.toString(); + this.addToEnv({ + GITHUB_APP_INSTALLATION_ID: installationId.toString() + }) this.installationId = installationId; this.start(); @@ -120,7 +171,15 @@ class Setup { const authenticated = await octokit.rest.apps.getAuthenticated(); this.installation = authenticated.data; this.createWebhookMiddleware(); - queryCopilotMetrics(); + + const metricsCronExpression = await settingsService.getSettingsByName('metricsCronExpression').catch(() => { + return '0 0 * * *'; + }); + const timezone = await settingsService.getSettingsByName('timezone').catch(() => { + return 'UTC'; + }); + metricsService.createInstance(metricsCronExpression, timezone); + logger.info(`GitHub App ${this.installation.slug} is ready to use`); } diff --git a/backend/src/services/smee.ts b/backend/src/services/smee.ts index faefa1b..1c9259d 100644 --- a/backend/src/services/smee.ts +++ b/backend/src/services/smee.ts @@ -26,7 +26,7 @@ class SmeeService { if (!this.webhookProxyUrl) { this.webhookProxyUrl = await this.createWebhookChannel(); if (!this.webhookProxyUrl) throw new Error('Unable to create webhook channel'); - await settingsService.updateOrCreateSetting('webhookProxyUrl', this.webhookProxyUrl); + await settingsService.updateSetting('webhookProxyUrl', this.webhookProxyUrl); } let eventSource: EventSource | undefined; try { @@ -39,7 +39,7 @@ class SmeeService { logger.error('Unable to connect to smee.io. recreating webhook.', error); this.webhookProxyUrl = await this.createWebhookChannel(); if (!this.webhookProxyUrl) throw new Error('Unable to create webhook channel'); - await settingsService.updateOrCreateSetting('webhookProxyUrl', this.webhookProxyUrl); + await settingsService.updateSetting('webhookProxyUrl', this.webhookProxyUrl); eventSource = await this.createWebhookProxy({ url: this.webhookProxyUrl, port, diff --git a/frontend/src/app/material.module.ts b/frontend/src/app/material.module.ts index 24918eb..7cf1a31 100644 --- a/frontend/src/app/material.module.ts +++ b/frontend/src/app/material.module.ts @@ -57,7 +57,8 @@ import { MatToolbarModule } from '@angular/material/toolbar'; MatCardModule, MatRadioModule, MatIconModule, - MatToolbarModule + MatToolbarModule, + MatDialogModule ] }) export class MaterialModule { } diff --git a/frontend/src/app/services/setup.service.ts b/frontend/src/app/services/setup.service.ts index 7ac62f2..f9e0f77 100644 --- a/frontend/src/app/services/setup.service.ts +++ b/frontend/src/app/services/setup.service.ts @@ -11,7 +11,7 @@ export class SetupService { constructor(private http: HttpClient) { } - getSetupStatus(): Observable { + getSetupStatus(): Observable<{ isSetup: boolean }> { return this.http.get(`${this.apiUrl}/status`); } @@ -19,4 +19,12 @@ export class SetupService { return this.http.get(`${this.apiUrl}/manifest`); } + addExistingApp(request: { + appId: string, + privateKey: string, + webhookSecret: string + }): Observable { + return this.http.post(`${this.apiUrl}/existing-app`, request); + } + } diff --git a/frontend/src/app/settings/settings.component.html b/frontend/src/app/settings/settings.component.html index 4514547..70bb049 100644 --- a/frontend/src/app/settings/settings.component.html +++ b/frontend/src/app/settings/settings.component.html @@ -1,9 +1,25 @@
- Webhook Url + Metrics Polling Rate + + Repository is required + + + + + Base URL + + Base URL is required + Please enter a valid URL + + + + Webhook URL - Webhook Url is required + Webhook URL is required Please enter a valid URL @@ -13,22 +29,5 @@ placeholder="Ex: password"> - - App ID - - App ID is required - App ID must be a number - App ID must be 7 digits - - - - Private Key - - Private Key is required - Please enter a valid private key - - - +
\ No newline at end of file diff --git a/frontend/src/app/settings/settings.component.ts b/frontend/src/app/settings/settings.component.ts index 05c0b42..a853319 100644 --- a/frontend/src/app/settings/settings.component.ts +++ b/frontend/src/app/settings/settings.component.ts @@ -5,6 +5,7 @@ import { FormControl, FormGroup, FormGroupDirective, NgForm, Validators } from ' import { ErrorStateMatcher } from '@angular/material/core'; import { SettingsHttpService } from '../services/settings.service'; import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; export class MyErrorStateMatcher implements ErrorStateMatcher { isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { @@ -26,45 +27,44 @@ export class MyErrorStateMatcher implements ErrorStateMatcher { }) export class SettingsComponent implements OnInit { form = new FormGroup({ - webhookProxyUrl: new FormControl('', [ - Validators.required, - // any https or http url - Validators.pattern(/^(https?:\/\/)[^\s/$.?#].[^\s]*$/) // Matches HTTP and HTTPS URLs, but not FTP - ]), - webhookSecret: new FormControl('', []), - appId: new FormControl('', [ + metricsCronExpression: new FormControl('', []), + baseUrl: new FormControl('', [ Validators.required, - Validators.pattern(/^\d+$/), - Validators.minLength(7), - Validators.maxLength(7) + Validators.pattern(/^(https?:\/\/)[^\s/$.?#].[^\s]*$/) ]), - privateKey: new FormControl('', [ + webhookProxyUrl: new FormControl('', [ Validators.required, - Validators.pattern(/-----BEGIN RSA PRIVATE KEY-----\n(?:.|\n)+\n-----END RSA PRIVATE KEY-----\n?/), // Matches only valid private keys + Validators.pattern(/^(https?:\/\/)[^\s/$.?#].[^\s]*$/) ]), + webhookSecret: new FormControl('', []) }); matcher = new MyErrorStateMatcher(); constructor( - private settingsService: SettingsHttpService + private settingsService: SettingsHttpService, + private router: Router ) { } ngOnInit() { this.settingsService.getAllSettings().subscribe((settings) => { console.log(settings); this.form.setValue({ + metricsCronExpression: settings.metricsCronExpression || '', + baseUrl: settings.baseUrl || '', webhookProxyUrl: settings.webhookProxyUrl || '', webhookSecret: settings.webhookSecret || '', - appId: settings.appId || '', - privateKey: settings.privateKey || '' }); }); } onSubmit() { + this.form.markAllAsTouched(); + if (this.form.invalid) { + return; + } this.settingsService.createSettings(this.form.value).subscribe((response) => { - console.log(response); + this.router.navigate(['/']); }); } } diff --git a/frontend/src/app/welcome/dialog-create-app.css b/frontend/src/app/welcome/dialog-create-app.css new file mode 100644 index 0000000..e4a4b37 --- /dev/null +++ b/frontend/src/app/welcome/dialog-create-app.css @@ -0,0 +1,22 @@ + +#manifest { + display: none; +} + +.example-full-width { + width: 100%; +} + +#existingAppForm { + .button-margin-bottom { + margin-bottom: 5px; + } + mat-form-field { + margin-bottom: 10px; + } +} + +.mat-error-container { + padding: 0 16px; + font-size:12px; +} \ No newline at end of file diff --git a/frontend/src/app/welcome/dialog-create-app.html b/frontend/src/app/welcome/dialog-create-app.html new file mode 100644 index 0000000..05b681b --- /dev/null +++ b/frontend/src/app/welcome/dialog-create-app.html @@ -0,0 +1,72 @@ +

{{ existingApp ? 'Add Existing Github App' : 'Register GitHub App'}}

+
+ +

+ Nice! You're using an existing GitHub App. +

+

+ Replace your app's Webhook URL with
+ {{manifest?.hook_attributes?.url}} +

+

+ + You can do that here + +

+ +
+

+ Fill out your app's details +

+
+ + App ID + + + The App ID is required + + + + Webhook secret + + + The webhook secret is required + + + + +
+ + The pem file is required + +
+
+
+ +
+

Enter the name of the organization if you don't want to register the app under your personal account

+ + + Organization Name + + +
+
+ + + +
+
+ + + + + + + +
\ No newline at end of file diff --git a/frontend/src/app/welcome/welcome.component.html b/frontend/src/app/welcome/welcome.component.html index 11ad8c6..82809e5 100644 --- a/frontend/src/app/welcome/welcome.component.html +++ b/frontend/src/app/welcome/welcome.component.html @@ -1,28 +1,23 @@
- +
- Welcome to the GitHub Value App - + + - Photo of Copilot Head +

GitHub Value App

+
+ GitHub Mark +

- To get start you'll need to register a new app on GitHub. + To get started you'll need a GitHub App.

-
-
- - Organization Name - - - - - -
+ +
- +
https://github.com/settings/apps/manifest \ No newline at end of file diff --git a/frontend/src/app/welcome/welcome.component.scss b/frontend/src/app/welcome/welcome.component.scss index 765440a..7d27a3e 100644 --- a/frontend/src/app/welcome/welcome.component.scss +++ b/frontend/src/app/welcome/welcome.component.scss @@ -14,19 +14,23 @@ height: 100%; } -mat-card { +.intro { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; width: 450px; + img-container { + height: 18em; + } + img { + display: block; + margin: 20px; + height: 18em; + } } .example-header-image { background-image: url('https://material.angular.io/assets/img/examples/shiba1.jpg'); background-size: cover; } - -#manifest { - display: none; -} - -.example-full-width { - width: 100%; -} \ No newline at end of file diff --git a/frontend/src/app/welcome/welcome.component.ts b/frontend/src/app/welcome/welcome.component.ts index febac5f..fa64a7a 100644 --- a/frontend/src/app/welcome/welcome.component.ts +++ b/frontend/src/app/welcome/welcome.component.ts @@ -1,7 +1,10 @@ -import { Component, ElementRef, ViewChild } from '@angular/core'; +import { Component, ElementRef, Inject, ViewChild } from '@angular/core'; import { MaterialModule } from '../material.module'; -import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { SetupService } from '../services/setup.service'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; `` @Component({ selector: 'app-welcome', @@ -14,25 +17,122 @@ import { SetupService } from '../services/setup.service'; styleUrl: './welcome.component.scss' }) export class WelcomeComponent { + + constructor( + public dialog: MatDialog, + private router: Router, + private setupService: SetupService + ) { } + + ngOnInit(): void { + this.checkIfSetup(); + } + + checkIfSetup(): void { + this.setupService.getSetupStatus().subscribe((response) => { + if (response.isSetup) this.router.navigate(['/']); + }); + } + + openDialog(existingApp: boolean): void { + this.dialog.open(DialogOverviewExampleDialog, { + width: '325px', + data: existingApp + }).afterClosed().subscribe(() => { + this.checkIfSetup(); + }); + } +} + +@Component({ + selector: 'dialog-create-app', + templateUrl: './dialog-create-app.html', + styleUrl: './dialog-create-app.scss', + standalone: true, + imports: [ + MaterialModule, + ReactiveFormsModule, + CommonModule + ] +}) +export class DialogOverviewExampleDialog { // Manifest Parameters: https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest#github-app-manifest-parameters @ViewChild('form') form!: ElementRef; - formAction: string = 'https://github.com/organizations/ORGANIZATION/settings/apps/new?state=abc123'; - manifest = {}; - manifestString: string; + formAction: string = 'https://github.com/settings/apps/new?state=abc123'; + manifest: { + name: string; + url: string; + hook_attributes: { + url: string; + }; + setup_url: string; + redirect_url: string; + public: boolean; + default_permissions: { + pull_requests: string; + organization_copilot_seat_management: string; + }; + default_events: string[]; + } | undefined; + manifestString: string | undefined; organizationFormControl = new FormControl('', []); + existingApp: boolean; + existingAppForm = new FormGroup({ + appIdFormControl: new FormControl('', [Validators.required]), + webhookSecretFormControl: new FormControl('', [Validators.required]), + privateKeyFormControl: new FormControl('', [Validators.required]), + privateKey: new FormControl('', [Validators.required]) + }); constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: boolean, private setupService: SetupService ) { - this.manifestString = JSON.stringify(this.manifest); + this.existingApp = data; this.setupService.getManifest().subscribe((manifest: any) => { + this.manifest = manifest; this.manifestString = JSON.stringify(manifest); - console.log('manifest', manifest); }); } - onSubmit() { - this.form.nativeElement.action = `https://github.com/organizations/${this.organizationFormControl.value}/settings/apps/new?state=abc123` + onNoClick(): void { + this.dialogRef.close(); + } + + registerNewApp() { + if (this.organizationFormControl.value) { + this.form.nativeElement.action = `https://github.com/organizations/${this.organizationFormControl.value}/settings/apps/new?state=abc123` + } this.form.nativeElement.submit(); + this.dialogRef.close(); } -} + + addExistingApp() { + this.existingAppForm.markAllAsTouched(); + if (this.existingAppForm.invalid) { + return; + } + this.setupService.addExistingApp({ + appId: this.existingAppForm.value.appIdFormControl!, + webhookSecret: this.existingAppForm.value.webhookSecretFormControl!, + privateKey: this.existingAppForm.value.privateKey! + }).subscribe((response: any) => { + this.dialogRef.close(); + }); + } + + onFileSelected(event: Event) { + const fileInput = event.target as HTMLInputElement; + if (!fileInput || !fileInput.files || fileInput.files.length === 0) { + console.error("No file selected or file input is null"); + return; + } + + const file = fileInput.files[0]; + + file.text().then((text) => { + this.existingAppForm.controls.privateKey.setValue(text); + }); + } +} \ No newline at end of file diff --git a/frontend/src/assets/images/GitHub_Logo.png b/frontend/src/assets/images/GitHub_Logo.png new file mode 100644 index 0000000..e03d8dd Binary files /dev/null and b/frontend/src/assets/images/GitHub_Logo.png differ diff --git a/frontend/src/assets/images/GitHub_Logo_White.png b/frontend/src/assets/images/GitHub_Logo_White.png new file mode 100644 index 0000000..c61ab9d Binary files /dev/null and b/frontend/src/assets/images/GitHub_Logo_White.png differ diff --git a/frontend/src/assets/images/github-mark-white.png b/frontend/src/assets/images/github-mark-white.png new file mode 100644 index 0000000..50b8175 Binary files /dev/null and b/frontend/src/assets/images/github-mark-white.png differ diff --git a/frontend/src/assets/images/github-mark-white.svg b/frontend/src/assets/images/github-mark-white.svg new file mode 100644 index 0000000..d5e6491 --- /dev/null +++ b/frontend/src/assets/images/github-mark-white.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/images/github-mark.png b/frontend/src/assets/images/github-mark.png new file mode 100644 index 0000000..6cb3b70 Binary files /dev/null and b/frontend/src/assets/images/github-mark.png differ diff --git a/frontend/src/assets/images/github-mark.svg b/frontend/src/assets/images/github-mark.svg new file mode 100644 index 0000000..37fa923 --- /dev/null +++ b/frontend/src/assets/images/github-mark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 50da854..cce5c7e 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -38,3 +38,13 @@ $github-value-theme: mat.define-theme(( html, body { height: 100%; } body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } + +.noselect { + -webkit-touch-callout: none; /* iOS Safari */ + -webkit-user-select: none; /* Safari */ + -khtml-user-select: none; /* Konqueror HTML */ + -moz-user-select: none; /* Old versions of Firefox */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; /* Non-prefixed version, currently + supported by Chrome, Edge, Opera and Firefox */ +} \ No newline at end of file