diff --git a/backend/__tests__/survey.test.ts b/backend/__tests__/survey.test.ts deleted file mode 100644 index e1507a7..0000000 --- a/backend/__tests__/survey.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import 'dotenv/config' -import Survey from '../src/models/survey.model' -import sequelize from '../src/database'; -import logger from '../src/services/logger'; - -beforeAll(async () => { - try { - await sequelize.authenticate(); - logger.info('Connection has been established successfully.'); - } catch (error) { - console.error('Unable to connect to the database:', error); - } - await sequelize.sync({ force: true });}); - -afterAll(async () => { - await sequelize.close(); -}); - -describe('Survey Model', () => { - it('should create a survey with valid data', async () => { - const survey = await Survey.create({ - daytime: new Date(), - userId: 1044, - usedCopilot: true, - pctTimesaved: 50, - timeUsedFor: 'Releases', - }); - - expect(survey).toBeDefined(); - expect(survey.userId).toBe(1044); - expect(survey.usedCopilot).toBe(true); - expect(survey.pctTimesaved).toBe(50); - expect(survey.timeUsedFor).toBe('Releases'); - }); - - it('should calculate timeSaved correctly', async () => { - const survey = await Survey.create({ - daytime: new Date(), - userId: 1044, - usedCopilot: true, - pctTimesaved: 50, - timeUsedFor: 'Releases', - }); - - expect(survey.timeSaved).toBe('50% saved for Releases'); - }); -}); \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 79d0cb7..9592b45 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -20,7 +20,8 @@ "octokit": "^4.0.2", "sequelize": "^6.37.5", "smee-client": "^2.0.4", - "update-dotenv": "^1.1.1" + "update-dotenv": "^1.1.1", + "why-is-node-running": "^3.2.1" }, "devDependencies": { "@eslint/js": "^9.14.0", @@ -7563,6 +7564,18 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, "node_modules/wkx": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz", diff --git a/backend/package.json b/backend/package.json index d8158bd..d3483a6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,7 +25,8 @@ "octokit": "^4.0.2", "sequelize": "^6.37.5", "smee-client": "^2.0.4", - "update-dotenv": "^1.1.1" + "update-dotenv": "^1.1.1", + "why-is-node-running": "^3.2.1" }, "devDependencies": { "@eslint/js": "^9.14.0", diff --git a/backend/src/app.ts b/backend/src/app.ts index 20c7d96..260106c 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,59 +1,170 @@ import 'dotenv/config' -import express from 'express'; +import express, { Express } from 'express'; import rateLimit from 'express-rate-limit'; import bodyParser from 'body-parser'; import cors from 'cors'; import path, { dirname } from 'path'; +import { fileURLToPath } from 'url'; +import * as http from 'http'; +import { AddressInfo } from 'net'; import apiRoutes from "./routes/index.js" -import { dbConnect } from './database.js'; -import setup from './services/setup.js'; -import settingsService from './services/settings.service.js'; -import SmeeService from './services/smee.js'; +import Database from './database.js'; import logger, { expressLoggerMiddleware } from './services/logger.js'; -import { fileURLToPath } from 'url'; +import GitHub from './github.js'; +import WebhookService from './services/smee.js'; +import SettingsService from './services/settings.service.js'; +import whyIsNodeRunning from 'why-is-node-running'; + +class App { + eListener?: http.Server; + baseUrl?: string; -const PORT = Number(process.env.PORT) || 80; - -export const app = express(); -app.use(cors()); -app.use(expressLoggerMiddleware); - -(async () => { - await dbConnect(); - logger.info('DB Connected ✅'); - await settingsService.initializeSettings(); - logger.info('Settings loaded ✅'); - await SmeeService.createSmeeWebhookProxy(PORT); - logger.info('Created Smee webhook proxy ✅'); - - try { - await setup.createAppFromEnv(); - logger.info('Created GitHub App from environment ✅'); - } catch (error) { - logger.info('Failed to create app from environment. This is expected if the app is not yet installed.', error); + constructor( + public e: Express, + public port: number, + public database: Database, + public github: GitHub, + public settingsService: SettingsService + ) { + this.e = e; + this.port = port; } - 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); - - const __filename = fileURLToPath(import.meta.url); - const __dirname = dirname(__filename); - const frontendPath = path.resolve(__dirname, '../../frontend/dist/github-value/browser'); - - app.use(express.static(frontendPath)); - app.get('*', rateLimit({ - windowMs: 15 * 60 * 1000, max: 5000, - }), (_, res) => res.sendFile(path.join(frontendPath, 'index.html'))); - - app.listen(PORT, () => { - logger.info(`Server is running at http://localhost:${PORT} 🚀`); - if (process.env.WEB_URL) { - logger.debug(`Frontend is running at ${process.env.WEB_URL} 🚀`); + public async start() { + try { + this.setupExpress(); + await this.database.connect(); + + await this.initializeSettings(); + logger.info('Settings initialized'); + + await this.github.connect(); + logger.info('Created GitHub App from environment'); + + return this.e; + } catch (error) { + await this.github.smee.connect(); + logger.debug(error); + logger.error('Failed to start application ❌'); + if (error instanceof Error) { + logger.error(error.message); + } } + } + + public stop() { + whyIsNodeRunning() + this.database.disconnect(); + this.github.disconnect(); + this.eListener?.close(() => { + logger.info('Server closed'); + process.exit(0); + }); + } + + private setupExpress() { + this.e.use(cors()); + this.e.use(expressLoggerMiddleware); + this.e.use((req, res, next) => { + if (req.path === '/api/github/webhooks') { + return next(); + } + bodyParser.json()(req, res, next); + }, bodyParser.urlencoded({ extended: true })); + + this.e.use('/api', apiRoutes); + + const __filename = fileURLToPath(import.meta.url); + const __dirname = dirname(__filename); + const frontendPath = path.resolve(__dirname, '../../frontend/dist/github-value/browser'); + this.e.use(express.static(frontendPath)); + this.e.get('*', rateLimit({ + windowMs: 15 * 60 * 1000, max: 5000, + }), (_, res) => res.sendFile(path.join(frontendPath, 'index.html'))); + + const listener = this.e.listen(this.port, () => { + const address = listener.address() as AddressInfo; + logger.info(`Server is running at http://${address.address === '::' ? 'localhost' : address.address}:${address.port} 🚀`); + }); + this.eListener = listener; + } + + private initializeSettings() { + this.settingsService.initialize() + .then(async (settings) => { + if (settings.webhookProxyUrl) { + this.github.smee.options.url = settings.webhookProxyUrl + } + if (settings.webhookSecret) { + this.github.setInput({ + webhooks: { + secret: settings.webhookSecret + } + }); + } + if (settings.metricsCronExpression) { + this.github.cronExpression = settings.metricsCronExpression; + } + if (settings.baseUrl) { + this.baseUrl = settings.baseUrl; + } + }) + .finally(async () => { + await this.github.smee.connect() + await this.settingsService.updateSetting('webhookSecret', this.github.input.webhooks?.secret || ''); + await this.settingsService.updateSetting('webhookProxyUrl', this.github.smee.options.url!); + await this.settingsService.updateSetting('metricsCronExpression', this.github.cronExpression!); + }); + } +} + +const port = Number(process.env.PORT) || 80; +const e = express(); +const app = new App( + e, + port, + new Database(process.env.JAWSDB_URL ? process.env.JAWSDB_URL : { + host: process.env.MYSQL_HOST, + port: Number(process.env.MYSQL_PORT) || 3306, + username: process.env.MYSQL_USER, + password: process.env.MYSQL_PASSWORD, + database: process.env.MYSQL_DATABASE || 'value' + }), + new GitHub( + { + appId: process.env.GITHUB_APP_ID, + privateKey: process.env.GITHUB_APP_PRIVATE_KEY, + webhooks: { + secret: process.env.GITHUB_WEBHOOK_SECRET + } + }, + e, + new WebhookService({ + url: process.env.WEBHOOK_PROXY_URL, + path: '/api/github/webhooks', + port + }) + ), new SettingsService({ + baseUrl: process.env.BASE_URL, + webhookProxyUrl: process.env.GITHUB_WEBHOOK_PROXY_URL, + webhookSecret: process.env.GITHUB_WEBHOOK_SECRET, + metricsCronExpression: '0 0 * * *', + devCostPerYear: '100000', + developerCount: '100', + hoursPerYear: '2080', + percentTimeSaved: '20', + percentCoding: '20' + }) +); +app.start(); +logger.info('App started'); + +export default app; + +['SIGTERM', 'SIGINT', 'SIGQUIT'].forEach(signal => { + process.on(signal, () => { + logger.info(`Received ${signal}. Stopping the app...`); + app.stop(); + process.exit(signal === 'uncaughtException' ? 1 : 0); }); -})(); \ No newline at end of file +}); diff --git a/backend/src/controllers/metrics.controller.ts b/backend/src/controllers/metrics.controller.ts index 809aec8..8d69897 100644 --- a/backend/src/controllers/metrics.controller.ts +++ b/backend/src/controllers/metrics.controller.ts @@ -4,7 +4,7 @@ import MetricsService from '../services/metrics.service.js'; class MetricsController { async getMetrics(req: Request, res: Response): Promise { try { - const metrics = await MetricsService.queryMetrics(req.query) + const metrics = await MetricsService.getMetrics(req.query) res.status(200).json(metrics); } catch (error) { res.status(500).json(error); @@ -13,7 +13,7 @@ class MetricsController { async getMetricsTotals(req: Request, res: Response): Promise { try { - const metrics = await MetricsService.queryMetricsTotals(req.query) + const metrics = await MetricsService.getMetricsTotals(req.query) res.status(200).json(metrics); } catch (error) { res.status(500).json(error); diff --git a/backend/src/controllers/seats.controller.ts b/backend/src/controllers/seats.controller.ts index c0e3c4d..e1c8862 100644 --- a/backend/src/controllers/seats.controller.ts +++ b/backend/src/controllers/seats.controller.ts @@ -3,8 +3,9 @@ import SeatsService from '../services/copilot.seats.service.js'; class SeatsController { async getAllSeats(req: Request, res: Response): Promise { + const org = req.query.org?.toString() try { - const seats = await SeatsService.getAllSeats(); + const seats = await SeatsService.getAllSeats(org); res.status(200).json(seats); } catch (error) { res.status(500).json(error); @@ -23,6 +24,7 @@ class SeatsController { } async getActivity(req: Request, res: Response): Promise { + const org = req.query.org?.toString() const { daysInactive, precision } = req.query; const _daysInactive = Number(daysInactive); if (!daysInactive || isNaN(_daysInactive)) { @@ -30,7 +32,7 @@ class SeatsController { return; } try { - const activityDays = await SeatsService.getAssigneesActivity(_daysInactive, precision as 'hour' | 'day' | 'minute'); + const activityDays = await SeatsService.getMembersActivity(org, _daysInactive, precision as 'hour' | 'day' | 'minute'); res.status(200).json(activityDays); } catch (error) { res.status(500).json(error); @@ -38,33 +40,14 @@ class SeatsController { } async getActivityTotals(req: Request, res: Response): Promise { + const org = req.query.org?.toString() try { - const totals = await SeatsService.getAssigneesActivityTotals(); + const totals = await SeatsService.getMembersActivityTotals(org); res.status(200).json(totals); } catch (error) { res.status(500).json(error); } } - - async getActivityHighcharts(req: Request, res: Response): Promise { - try { - const { daysInactive } = req.query; - const _daysInactive = Number(daysInactive); - if (!daysInactive || isNaN(_daysInactive)) { - res.status(400).json({ error: 'daysInactive query parameter is required' }); - return; - } - const activityDays = await SeatsService.getAssigneesActivity(_daysInactive); - const activeData = Object.entries(activityDays).reduce((acc, [date, data]) => { - acc.push([new Date(date).getTime(), data.totalActive]); - return acc; - }, [] as [number, number][]); - res.status(200).json(activeData); - } catch (error) { - res.status(500).json(error); - } - } - } export default new SeatsController(); \ No newline at end of file diff --git a/backend/src/controllers/settings.controller.ts b/backend/src/controllers/settings.controller.ts index 8972bba..f81ee1c 100644 --- a/backend/src/controllers/settings.controller.ts +++ b/backend/src/controllers/settings.controller.ts @@ -1,10 +1,10 @@ +import app from '../app.js'; import { Request, Response } from 'express'; -import SettingsService from '../services/settings.service.js'; class SettingsController { async getAllSettings(req: Request, res: Response) { try { - const settings = await SettingsService.getAllSettings(); + const settings = await app.settingsService.getAllSettings(); const settingsRsp = Object.fromEntries(settings.map(setting => [setting.dataValues.name, setting.dataValues.value])); res.json(settingsRsp); } catch (error) { @@ -15,7 +15,7 @@ class SettingsController { async getSettingsByName(req: Request, res: Response) { try { const { name } = req.params; - const settings = await SettingsService.getSettingsByName(name); + const settings = await app.settingsService.getSettingsByName(name); if (settings) { res.json(settings); } else { @@ -28,7 +28,7 @@ class SettingsController { async createSettings(req: Request, res: Response) { try { - const newSettings = await SettingsService.updateSettings(req.body); + const newSettings = await app.settingsService.updateSettings(req.body); res.status(201).json(newSettings); } catch (error) { res.status(500).json(error); @@ -37,7 +37,7 @@ class SettingsController { async updateSettings(req: Request, res: Response) { try { - await SettingsService.updateSettings(req.body); + await app.settingsService.updateSettings(req.body); res.status(200).end(); } catch (error) { res.status(500).json(error); @@ -47,7 +47,7 @@ class SettingsController { async deleteSettings(req: Request, res: Response) { try { const { name } = req.params; - await SettingsService.deleteSettings(name); + await app.settingsService.deleteSettings(name); res.status(200).end(); } catch (error) { res.status(500).json(error); diff --git a/backend/src/controllers/setup.controller.ts b/backend/src/controllers/setup.controller.ts index 39b5b63..526c5f8 100644 --- a/backend/src/controllers/setup.controller.ts +++ b/backend/src/controllers/setup.controller.ts @@ -1,14 +1,12 @@ import { Request, Response } from 'express'; -import setup, { SetupStatus } from '../services/setup.js'; +import app from '../app.js'; class SetupController { async registrationComplete(req: Request, res: Response) { try { const { code } = req.query; - - const data = await setup.createFromManifest(code as string); - - res.redirect(`${data.html_url}/installations/new`); + const { html_url } = await app.github.createAppFromManifest(code as string); + res.redirect(`${html_url}/installations/new`); } catch (error) { res.status(500).json(error); } @@ -16,10 +14,9 @@ class SetupController { async installComplete(req: Request, res: Response) { try { - const { installation_id } = req.query; - setup.addToEnv({ GITHUB_APP_INSTALLATION_ID: installation_id as string }); - await setup.createAppFromEnv(); - res.redirect(process.env.WEB_URL || '/'); + const installationUrl = await app.github.app?.getInstallationUrl(); + if (!installationUrl) throw new Error('No installation URL found'); + res.redirect(installationUrl); } catch (error) { res.status(500).json(error); } @@ -27,7 +24,7 @@ class SetupController { getManifest(req: Request, res: Response) { try { - const manifest = setup.getManifest(`${req.protocol}://${req.hostname}`); + const manifest = app.github.getAppManifest(`${req.protocol}://${req.hostname}`); res.json(manifest); } catch (error) { res.status(500).json(error); @@ -41,11 +38,16 @@ class SetupController { if (!appId || !privateKey || !webhookSecret) { return res.status(400).json({ error: 'All fields are required' }); } + + await app.github.connect({ + appId: appId, + privateKey: privateKey, + webhooks: { + secret: webhookSecret + } + }); - await setup.findFirstInstallation(appId, privateKey, webhookSecret); - await setup.createAppFromEnv(); - - res.json({ installUrl: setup.installUrl }); + res.json({ installUrl: await app.github.app?.getInstallationUrl() }); } catch (error) { res.status(500).json(error); } @@ -53,50 +55,56 @@ class SetupController { isSetup(req: Request, res: Response) { try { - res.json({ isSetup: setup.isSetup() }); + res.json({ isSetup: app.github.app !== undefined }); } catch (error) { res.status(500).json(error); } } - setupStatus(req: Request, res: Response) { + async setupStatus(req: Request, res: Response) { try { - const requestedFields = req.query.fields ? (req.query.fields as string).split(',') : []; - const fullStatus = setup.getSetupStatus(); - if (!requestedFields.length) { - return res.json(fullStatus); - } - const filteredStatus: SetupStatus = {}; - requestedFields.forEach(field => { - if (field === 'isSetup') { - filteredStatus.isSetup = fullStatus.isSetup; - } - if (field === 'dbInitialized') { - filteredStatus.dbInitialized = fullStatus.dbInitialized; - } - if (field === 'dbsInitialized') { - filteredStatus.dbsInitialized = fullStatus.dbsInitialized; - } - if (field === 'installation') { - filteredStatus.installation = fullStatus.installation; - } - }); - return res.json(filteredStatus); + const status = { + dbConnected: await app.database.sequelize?.authenticate().then(() => true).catch(() => false), + isSetup: app.github.app !== undefined, + installations: app.github.installations.map(i => ({ + installation: i.installation, + ...i.queryService.status + })) + }; + return res.json(status); } catch (error) { res.status(500).json(error); } } - getInstall(req: Request, res: Response) { + async getInstall(req: Request, res: Response) { try { - if (!setup.installation) { + const { installation } = await app.github.getInstallation(req.body.id || req.body.owner) + if (!installation) { throw new Error('No installation found'); } - res.json(setup.installation); + res.json(installation); } catch (error) { res.status(500).json(error); } } + + async setupDB(req: Request, res: Response) { + try { + await app.database.connect(req.body.url || { + database: req.body.database || 'value', + host: req.body.host, + port: req.body.port, + username: req.body.username, + password: req.body.password + }); + res.json({ message: 'DB setup started' }); + } catch (error) { + res.status(500).json(error); + } + } + + } export default new SetupController(); \ No newline at end of file diff --git a/backend/src/controllers/survey.controller.ts b/backend/src/controllers/survey.controller.ts index 326bb8b..ff12909 100644 --- a/backend/src/controllers/survey.controller.ts +++ b/backend/src/controllers/survey.controller.ts @@ -1,59 +1,61 @@ import { Request, Response } from 'express'; import { Survey } from '../models/survey.model.js'; -import setup from '../services/setup.js'; import logger from '../services/logger.js'; -import settingsService from '../services/settings.service.js'; import surveyService from '../services/survey.service.js'; +import app from '../app.js'; class SurveyController { async createSurvey(req: Request, res: Response): Promise { + let survey: Survey; try { - const survey = await surveyService.updateSurvey({ + const _survey = await surveyService.updateSurvey({ ...req.body, status: 'completed' }) - if (!survey) throw new Error('Survey not found'); + if (!_survey) throw new Error('Survey not found'); + survey = _survey; res.status(201).json(survey); - try { - const surveyUrl = new URL(`copilot/surveys/${survey.id}`, settingsService.baseUrl); - const octokit = await setup.getOctokit(); - if (!survey.repo || !survey.owner || !survey.prNumber) { - logger.warn('Cannot process survey comment: missing survey data'); - return; - } - const comments = await octokit.rest.issues.listComments({ - owner: survey.owner, + } catch (error) { + res.status(500).json(error); + return; + } + try { + const { installation, octokit } = await app.github.getInstallation(survey.org); + const surveyUrl = new URL(`copilot/surveys/${survey.id}`, app.baseUrl); + + if (!survey.repo || !survey.org || !survey.prNumber) { + logger.warn('Cannot process survey comment: missing survey data'); + return; + } + const comments = await octokit.rest.issues.listComments({ + owner: survey.org, + repo: survey.repo, + issue_number: survey.prNumber + }); + const comment = comments.data.find(comment => comment.user?.login.startsWith(installation.app_slug)); + if (comment) { + octokit.rest.issues.updateComment({ + owner: survey.org, repo: survey.repo, - issue_number: survey.prNumber + comment_id: comment.id, + body: `Thanks for filling out the [copilot survey](${surveyUrl.toString()}) @${survey.userId}!` }); - if (!setup.installation?.slug) { - logger.warn('Cannot process survey comment: GitHub App installation or slug not found'); - return; - } - const comment = comments.data.find(comment => comment.user?.login.startsWith(setup.installation!.slug!)); - if (comment) { - octokit.rest.issues.updateComment({ - owner: survey.owner, - repo: survey.repo, - comment_id: comment.id, - body: `Thanks for filling out the [copilot survey](${surveyUrl.toString()}) @${survey.userId}!` - }); - } else { - logger.info(`No comment found for survey from ${setup.installation?.slug}`) - } - } catch (error) { - logger.error('Error updating survey comment', error); - throw error; + } else { + logger.info(`No comment found for survey from ${survey.org}`); } } catch (error) { - res.status(500).json(error); + logger.error('Error updating survey comment', error); + throw error; } } async getAllSurveys(req: Request, res: Response): Promise { try { const surveys = await Survey.findAll({ - order: [['updatedAt', 'DESC']] + order: [['updatedAt', 'DESC']], + where: { + ...req.query.org ? { userId: req.query.org as string } : {} + } }); res.status(200).json(surveys); } catch (error) { diff --git a/backend/src/controllers/teams.controller.ts b/backend/src/controllers/teams.controller.ts index e39d479..e753258 100644 --- a/backend/src/controllers/teams.controller.ts +++ b/backend/src/controllers/teams.controller.ts @@ -5,6 +5,9 @@ class TeamsController { async getAllTeams(req: Request, res: Response): Promise { try { const teams = await Team.findAll({ + where: { + ...req.query.org ? { '$Team.org$': req.query.org as string } : {} + }, include: [ { model: Member, @@ -25,10 +28,10 @@ class TeamsController { }, attributes: ['login', 'avatar_url'] }], - attributes: ['name', 'slug', 'description', 'html_url'] + attributes: ['name', 'org', 'slug', 'description', 'html_url'] } ], - attributes: ['name', 'slug', 'description', 'html_url'], + attributes: ['name', 'org', 'slug', 'description', 'html_url'], order: [ ['name', 'ASC'], [{ model: Member, as: 'members' }, 'login', 'ASC'] diff --git a/backend/src/controllers/usage.controller.ts b/backend/src/controllers/usage.controller.ts index ba9e85a..9ce2c6e 100644 --- a/backend/src/controllers/usage.controller.ts +++ b/backend/src/controllers/usage.controller.ts @@ -5,7 +5,10 @@ class UsageController { async getUsage(req: Request, res: Response): Promise { try { const metrics = await Usage.findAll({ - include: [UsageBreakdown] + include: [UsageBreakdown], + where: { + ...req.query.org ? { userId: req.query.org as string } : {} + } }); res.status(200).json(metrics); } catch (error) { res.status(500).json(error); } diff --git a/backend/src/controllers/webhook.controller.ts b/backend/src/controllers/webhook.controller.ts index e29b565..0412b65 100644 --- a/backend/src/controllers/webhook.controller.ts +++ b/backend/src/controllers/webhook.controller.ts @@ -1,20 +1,9 @@ -import { Webhooks } from '@octokit/webhooks'; import { App } from 'octokit'; import logger from '../services/logger.js'; -import settingsService from '../services/settings.service.js'; -import { QueryService } from '../services/query.service.js'; -import { deleteMember, deleteMemberFromTeam, deleteTeam } from '../models/teams.model.js'; import surveyService from '../services/survey.service.js'; - -const webhooks = new Webhooks({ - secret: process.env.GITHUB_WEBHOOK_SECRET || 'your-secret', - log: { - debug: logger.debug, - info: logger.info, - warn: logger.warn, - error: logger.error - } -}); +import app from '../app.js'; +import teamsService from '../services/teams.service.js'; +import { Endpoints } from '@octokit/types'; export const setupWebhookListeners = (github: App) => { github.webhooks.on("pull_request.opened", async ({ octokit, payload }) => { @@ -22,7 +11,7 @@ export const setupWebhookListeners = (github: App) => { status: 'pending', hits: 0, userId: payload.pull_request.user.login, - owner: payload.repository.owner.login, + org: payload.repository.owner.login, repo: payload.repository.name, prNumber: payload.pull_request.number, usedCopilot: false, @@ -30,8 +19,8 @@ export const setupWebhookListeners = (github: App) => { reason: '', timeUsedFor: '', }) - - const surveyUrl = new URL(`copilot/surveys/new/${survey.id}`, settingsService.baseUrl); + + const surveyUrl = new URL(`copilot/surveys/new/${survey.id}`, app.baseUrl); surveyUrl.searchParams.append('url', payload.pull_request.html_url); surveyUrl.searchParams.append('author', payload.pull_request.user.login); @@ -55,10 +44,10 @@ export const setupWebhookListeners = (github: App) => { switch (payload.action) { case 'created': case 'edited': - await QueryService.getInstance().queryTeamsAndMembers(payload.team.slug); + await teamsService.updateTeams(payload.organization.login, [payload.team] as Endpoints["GET /orgs/{org}/teams"]["response"]["data"]); break; case 'deleted': - await deleteTeam(payload.team.id); + await teamsService.deleteTeam(payload.team.id); break; } } catch (error) { @@ -67,16 +56,18 @@ export const setupWebhookListeners = (github: App) => { }); github.webhooks.on("membership", async ({ payload }) => { + const queryService = app.github.installations.find(i => i.installation.id === payload.installation?.id)?.queryService; + if (!queryService) throw new Error('No query service found'); try { - switch (payload.action) { - case 'added': - await QueryService.getInstance().queryTeamsAndMembers(payload.team.slug); - break; - case 'removed': - if (payload.member) { - await deleteMemberFromTeam(payload.team.id, payload.member.id) - } - break; + if (payload.member) { + switch (payload.action) { + case 'added': + await teamsService.addMemberToTeam(payload.team.id, payload.member.id); + break; + case 'removed': + await teamsService.deleteMemberFromTeam(payload.team.id, payload.member.id) + break; + } } } catch (error) { logger.error('Error processing membership event', error); @@ -85,21 +76,21 @@ export const setupWebhookListeners = (github: App) => { github.webhooks.on("member", async ({ payload }) => { try { - switch (payload.action) { - case 'added': - case 'edited': - await QueryService.getInstance().queryTeamsAndMembers(undefined, payload.member?.login); - break; - case 'removed': - if (payload.member) { - await deleteMember(payload.member.id) - } - break; + if (payload.member) { + switch (payload.action) { + case 'added': + case 'edited': + if (payload.organization?.login) { + await teamsService.updateMembers(payload.organization?.login, [payload.member] as Endpoints["GET /orgs/{org}/teams/{team_slug}/members"]["response"]["data"]); + } + break; + case 'removed': + await teamsService.deleteMember(payload.member.id) + break; + } } } catch (error) { logger.error('Error processing member event', error); } }); } - -export default webhooks; diff --git a/backend/src/database.ts b/backend/src/database.ts index a39d283..b9c28e4 100644 --- a/backend/src/database.ts +++ b/backend/src/database.ts @@ -1,63 +1,121 @@ -import { Sequelize } from 'sequelize'; +import { Options, Sequelize } from 'sequelize'; import mysql2 from 'mysql2/promise'; +import updateDotenv from 'update-dotenv'; import logger from './services/logger.js'; +import { TargetValues } from './models/target-values.model.js'; +import { Settings } from './models/settings.model.js'; +import { Usage } from './models/usage.model.js'; +import { Seat } from './models/copilot.seats.model.js'; +import { Team } from './models/teams.model.js'; +import { MetricDaily } from './models/metrics.model.js'; +import { Survey } from './models/survey.model.js'; -const sequelize = process.env.JAWSDB_URL ? - new Sequelize(process.env.JAWSDB_URL, { - dialect: 'mysql', - pool: { - max: 10, - acquire: 30000, - idle: 10000 - }, - logging: (sql) => logger.debug(sql), - timezone: '+00:00', // Force UTC timezone - dialectOptions: { - timezone: '+00:00' // Force UTC for MySQL connection - }, - }) : - new Sequelize({ +// CACHE +// https://github.com/sequelize-transparent-cache/sequelize-transparent-cache?tab=readme-ov-file + +class Database { + sequelize?: Sequelize; + options: Options = { dialect: 'mysql', - host: process.env.MYSQL_HOST || 'localhost', - port: parseInt(process.env.MYSQL_PORT || '3306'), - username: process.env.MYSQL_USER || 'root', - password: process.env.MYSQL_PASSWORD || 'octocat', - database: process.env.MYSQL_DATABASE || 'value', logging: (sql) => logger.debug(sql), timezone: '+00:00', // Force UTC timezone dialectOptions: { timezone: '+00:00' // Force UTC for MySQL connection }, - }); - -const dbConnect = async () => { - try { - if (!process.env.JAWSDB_URL) { // If we are not using JAWSDB, we need to create the database - const connection = await mysql2.createConnection({ - host: process.env.MYSQL_HOST || 'localhost', - port: parseInt(process.env.MYSQL_PORT || '3306'), - user: process.env.MYSQL_USER || 'root', - password: process.env.MYSQL_PASSWORD || 'octocat', - }); - - await connection.query(`CREATE DATABASE IF NOT EXISTS \`${process.env.MYSQL_DATABASE}\`;`,); - await connection.end(); + } + input: string | Options; + + constructor(input: string | Options) { + this.input = input; + } + + async connect(options?: Options) { + this.input = options || this.input; + if (typeof this.input !== 'string') { + if (this.input.host) await updateDotenv({ MYSQL_HOST: this.input.host }) + if (this.input.port) await updateDotenv({ MYSQL_PORT: String(this.input.port) }) + if (this.input.username) await updateDotenv({ MYSQL_USER: this.input.username }) + if (this.input.password) await updateDotenv({ MYSQL_PASSWORD: this.input.password }) + if (this.input.database) await updateDotenv({ MYSQL_DATABASE: this.input.database }) + } + try { + let sequelize; + try { + sequelize = typeof this.input === 'string' ? + new Sequelize(this.input, { + pool: { + max: 10, + acquire: 30000, + idle: 10000 + }, + ...this.options + }) : + new Sequelize({ + ...this.input, + ...this.options + }); + } catch (error) { + logger.error('Unable to connect to the database'); + throw error; + } + logger.info('Connection to the database has been established successfully'); + + if (typeof this.input !== 'string') { + try { + const connection = await mysql2.createConnection({ + host: this.input.host, + port: this.input.port, + user: this.input.username, + password: this.input.password, + }); + + await connection.query('CREATE DATABASE IF NOT EXISTS ??', [this.input.database]); + await connection.end(); + await connection.destroy(); + } catch (error) { + logger.error('Unable to create the database'); + throw error; + } + logger.info('Database created successfully'); + } + + try { + await sequelize.authenticate() + await this.initializeModels(sequelize); + this.sequelize = sequelize; + await sequelize.sync({ alter: true }).then(() => { + logger.info('Database models were synchronized successfully'); + }) + } catch (error) { + logger.info('Unable to initialize the database'); + throw error; + } + logger.info('Database setup completed successfully'); + return this.sequelize; + } catch (error) { + logger.debug(error); + if (error instanceof Error) { + logger.error(error.message); + } + throw error; } - } catch (error) { - logger.error('Unable to connect to the database', error); - throw error; } - try { - await sequelize.authenticate() - await sequelize.sync({ alter: true }).then(() => { - logger.info('All models were synchronized successfully. 🚀'); - }).catch((error) => { - logger.error('Error synchronizing models', error); - }); - } catch (error) { - logger.info('Unable to initialize the database', error); - throw error; + + disconnect() { + this.sequelize?.connectionManager.close(); + this.sequelize?.close(); } -}; -export { dbConnect, sequelize }; \ No newline at end of file + initializeModels(sequelize: Sequelize) { + Settings.initModel(sequelize); + Team.initModel(sequelize); + Seat.initModel(sequelize); + Survey.initModel(sequelize); + Usage.initModel(sequelize); + MetricDaily.initModel(sequelize); + TargetValues.initModel(sequelize); + } + +} + +export default Database; diff --git a/backend/src/github.ts b/backend/src/github.ts new file mode 100644 index 0000000..2cbcfb0 --- /dev/null +++ b/backend/src/github.ts @@ -0,0 +1,173 @@ +import { readFileSync } from "fs"; +import { App, Octokit } from "octokit"; +import { QueryService } from "./services/query.service.js"; +import WebhookService from './services/smee.js'; +import logger from "./services/logger.js"; +import updateDotenv from 'update-dotenv'; +import { Express } from 'express'; +import { Endpoints } from '@octokit/types'; + +interface SetupStatusDbsInitialized { + usage?: boolean; + metrics?: boolean; + copilotSeats?: boolean; + teamsAndMembers?: boolean; + [key: string]: boolean | undefined; +} +export interface SetupStatus { + isSetup?: boolean; + dbConnected?: boolean; + dbInitialized?: boolean; + dbsInitialized?: SetupStatusDbsInitialized, + installation?: Endpoints["GET /app"]["response"]['data']; +} + +export interface GitHubInput { + appId?: string; + privateKey?: string; + webhooks?: { + secret?: string; + }; + oauth?: { + clientId: never; + clientSecret: never; + }; +} +class GitHub { + app?: App; + webhooks?: Express; + input: GitHubInput; + expressApp: Express; + installations = [] as { + installation: Endpoints["GET /app/installations"]["response"]["data"][0], + octokit: Octokit + queryService: QueryService + }[]; + status = 'starting'; + cronExpression = '0 * * * * *'; + + constructor( + input: GitHubInput, + expressApp: Express, + public smee: WebhookService + ) { + this.input = input; + this.expressApp = expressApp; + } + + connect = async (input?: GitHubInput) => { + this.disconnect(); + if (input) this.setInput(input); + if (!this.input.appId) throw new Error('App ID is required'); + if (!this.input.privateKey) throw new Error('Private key is required'); + + this.app = new App({ + appId: this.input.appId, + privateKey: this.input.privateKey, + ...this.input.webhooks?.secret ? { webhooks: { secret: this.input.webhooks.secret } } : {}, + // oauth: { + // clientId: null!, + // clientSecret: null! + // } + }); + + await updateDotenv({ GITHUB_APP_ID: this.input.appId }) + await updateDotenv({ GITHUB_APP_PRIVATE_KEY: String(this.input.privateKey) }) + if (this.input.webhooks?.secret) await updateDotenv({ GITHUB_WEBHOOK_SECRET: this.input.webhooks.secret }) + + try { + this.webhooks = this.smee.webhookMiddlewareCreate(this.app, this.expressApp); + } catch (error) { + logger.debug(error); + logger.error('Failed to create webhook middleware') + } + + for await (const { octokit, installation } of this.app.eachInstallation.iterator()) { + if (!installation.account?.login) return; + const queryService = new QueryService(installation.account.login, octokit, { + cronTime: this.cronExpression + }); + this.installations.push({ + installation, + octokit, + queryService + }); + logger.info(`${installation.account?.login} cron task ${this.cronExpression} started`); + } + + return this.app; + } + + disconnect = () => { + this.installations.forEach((i) => i.queryService.delete()) + this.installations = []; + } + + getAppManifest(baseUrl: string) { + const manifest = JSON.parse(readFileSync('github-manifest.json', 'utf8')); + const base = new URL(baseUrl); + manifest.url = base.href; + manifest.hook_attributes.url = new URL('/api/github/webhooks', base).href; + manifest.setup_url = new URL('/api/setup/install/complete', base).href; + manifest.redirect_url = new URL('/api/setup/registration/complete', base).href; + manifest.hook_attributes.url = this.smee.options.url; + // manifest.name = 'Copilot'; + return manifest; + }; + + async createAppFromManifest(code: string) { + const { + data: { + id, + pem, + webhook_secret, + html_url + } + } = await new Octokit().rest.apps.createFromManifest({ code }); + + if (!id || !pem) throw new Error('Failed to create app from manifest'); + + this.input.appId = id.toString(); + this.input.privateKey = pem; + if (webhook_secret) { + this.input.webhooks = { + secret: webhook_secret + } + } + + await updateDotenv({ + GITHUB_APP_ID: id.toString(), + GITHUB_APP_PRIVATE_KEY: pem + }); + if (webhook_secret) { + await updateDotenv({ + GITHUB_WEBHOOK_SECRET: webhook_secret, + }); + } + + return { id, pem, webhook_secret, html_url }; + } + + async getInstallation(id: string | number) { + if (!this.app) throw new Error('App is not initialized'); + return new Promise<{ + installation: Endpoints["GET /app/installations"]["response"]["data"][0], + octokit: Octokit + }>((resolve, reject) => { + this.app?.eachInstallation(async ({ installation, octokit }) => { + if ( + (typeof id === 'string' && id === installation.account?.login) || + id === installation.id + ) { + resolve({ installation, octokit }); + } + }).finally(() => reject('Installation not found')); + }); + } + + setInput(input: GitHubInput) { + this.input = { ...this.input, ...input }; + } +} + +export default GitHub; \ No newline at end of file diff --git a/backend/src/models/copilot.seats.model.ts b/backend/src/models/copilot.seats.model.ts index 28d3982..b141b05 100644 --- a/backend/src/models/copilot.seats.model.ts +++ b/backend/src/models/copilot.seats.model.ts @@ -1,138 +1,81 @@ -import { Model, DataTypes } from 'sequelize'; -import { sequelize } from '../database.js'; -class Seat extends Model { - public created_at!: Date; - public updated_at!: Date; - public pending_cancellation_date!: Date | null; - public last_activity_at!: Date; - public last_activity_editor!: string; - public plan_type!: string; - public assignee_id!: number; - public assigning_team_id!: number; - public createdAt!: Date; - public updatedAt!: Date; -} +import { Model, DataTypes, Sequelize, CreationOptional } from 'sequelize'; +import { Member, Team } from './teams.model.js'; -class Assignee extends Model { - public login!: string; - public id!: number; - public node_id!: string; - public avatar_url!: string; - public gravatar_id!: string; - public url!: string; - public html_url!: string; - public followers_url!: string; - public following_url!: string; - public gists_url!: string; - public starred_url!: string; - public subscriptions_url!: string; - public organizations_url!: string; - public repos_url!: string; - public events_url!: string; - public received_events_url!: string; - public type!: string; - public site_admin!: boolean; - public activity!: Seat[]; -} +type SeatType = { + id?: number; + org: string; + team?: string; + queryAt: Date; + created_at: string | null; + updated_at: string | null; + pending_cancellation_date: string | null; + last_activity_at: string | null; + last_activity_editor: string | null; + plan_type: string; + assignee_id: number; + assigning_team_id: number | null; +}; -class AssigningTeam extends Model { - public id!: number; - public node_id!: string; - public url!: string; - public html_url!: string; - public name!: string; - public slug!: string; - public description!: string; - public privacy!: string; - public notification_setting!: string; - public permission!: string; - public members_url!: string; - public repositories_url!: string; - public parent!: string | null; -} +class Seat extends Model { + declare id: number; + declare org: string; + declare team: string; + declare created_at: Date; + declare updated_at: Date; + declare pending_cancellation_date: Date | null; + declare last_activity_at: Date; + declare last_activity_editor: string; + declare plan_type: string; + declare assignee_id: number; + declare assigning_team_id: number; + declare queryAt: Date; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; -Seat.init({ - created_at: DataTypes.DATE, - updated_at: DataTypes.DATE, - pending_cancellation_date: DataTypes.DATE, - last_activity_at: DataTypes.DATE, - last_activity_editor: DataTypes.STRING, - plan_type: DataTypes.STRING, - assignee_id: { - type: DataTypes.INTEGER, - }, - assigning_team_id: { - type: DataTypes.INTEGER, + static initModel(sequelize: Sequelize) { + Seat.init({ + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true + }, + queryAt: DataTypes.DATE, + org: DataTypes.STRING, + team: DataTypes.STRING, + created_at: DataTypes.DATE, + updated_at: DataTypes.DATE, + pending_cancellation_date: DataTypes.DATE, + last_activity_at: DataTypes.DATE, + last_activity_editor: DataTypes.STRING, + plan_type: DataTypes.STRING, + assignee_id: { + type: DataTypes.INTEGER, + }, + assigning_team_id: { + type: DataTypes.INTEGER, + } + }, { + sequelize, + timestamps: true + }); + + Seat.belongsTo(Member, { + as: 'assignee', + foreignKey: 'assignee_id' + }); + Seat.belongsTo(Team, { + as: 'assigning_team', + foreignKey: 'assigning_team_id' + }); + Member.hasMany(Seat, { + as: 'activity', + foreignKey: 'assignee_id' + }); + Team.hasMany(Seat, { + as: 'activity', + foreignKey: 'assigning_team_id' + }); } -}, { - sequelize, - timestamps: true -}); - -Assignee.init({ - login: DataTypes.STRING, - id: { - type: DataTypes.INTEGER, - primaryKey: true - }, - node_id: DataTypes.STRING, - avatar_url: DataTypes.STRING, - gravatar_id: DataTypes.STRING, - url: DataTypes.STRING, - html_url: DataTypes.STRING, - followers_url: DataTypes.STRING, - following_url: DataTypes.STRING, - gists_url: DataTypes.STRING, - starred_url: DataTypes.STRING, - subscriptions_url: DataTypes.STRING, - organizations_url: DataTypes.STRING, - repos_url: DataTypes.STRING, - events_url: DataTypes.STRING, - received_events_url: DataTypes.STRING, - type: DataTypes.STRING, - site_admin: DataTypes.BOOLEAN -}, { - sequelize, - timestamps: false -}); - -AssigningTeam.init({ - id: { - type: DataTypes.INTEGER, - primaryKey: true - }, - node_id: DataTypes.STRING, - url: DataTypes.STRING, - html_url: DataTypes.STRING, - name: DataTypes.STRING, - slug: DataTypes.STRING, - description: DataTypes.STRING, - privacy: DataTypes.STRING, - notification_setting: DataTypes.STRING, - permission: DataTypes.STRING, - members_url: DataTypes.STRING, - repositories_url: DataTypes.STRING, - parent: DataTypes.STRING -}, { - sequelize, - timestamps: false -}); - -Seat.belongsTo(Assignee, { - as: 'assignee', - foreignKey: 'assignee_id' -}); -Seat.belongsTo(AssigningTeam, { - as: 'assigning_team', - foreignKey: 'assigning_team_id' -}); -Assignee.hasMany(Seat, { - as: 'activity', - foreignKey: 'assignee_id' -}); -AssigningTeam.hasMany(Seat, { - as: 'activity', - foreignKey: 'assigning_team_id' -}); +} -export { Seat, Assignee, AssigningTeam }; \ No newline at end of file +export { Seat }; \ No newline at end of file diff --git a/backend/src/models/metrics.model.interfaces.ts b/backend/src/models/metrics.model.interfaces.ts deleted file mode 100644 index a0bbed0..0000000 --- a/backend/src/models/metrics.model.interfaces.ts +++ /dev/null @@ -1,82 +0,0 @@ -interface LanguageMetrics { - name: string; - total_engaged_users: number; - total_code_suggestions?: number; - total_code_acceptances?: number; - total_code_lines_suggested?: number; - total_code_lines_accepted?: number; -} - -interface ModelBase { - name: string; - is_custom_model: boolean; - custom_model_training_date: string | null; - total_engaged_users: number; -} - -interface CodeModel extends ModelBase { - languages: LanguageMetrics[]; -} - -interface ChatModel extends ModelBase { - total_chats: number; - total_chat_insertion_events?: number; - total_chat_copy_events?: number; -} - -interface DotComChatModel extends ModelBase { - total_chats: number; -} - -interface PullRequestModel extends ModelBase { - total_pr_summaries_created: number; -} - -type Editor = { - name: string; - total_engaged_users: number; - models: T[]; -} - -interface Repository { - name: string; - total_engaged_users: number; - models: PullRequestModel[]; -} - -interface IdeCodeCompletions { - total_engaged_users: number; - languages: LanguageMetrics[]; - editors: Editor[]; -} - -interface IdeChat { - total_engaged_users: number; - editors: Editor[]; -} - -interface DotComChat { - total_engaged_users: number; - models: DotComChatModel[]; -} - -interface DotComPullRequests { - total_engaged_users: number; - repositories: Repository[]; -} - -interface CopilotMetrics { - date: string; - total_active_users: number; - total_engaged_users: number; - copilot_ide_code_completions: IdeCodeCompletions | null; - copilot_ide_chat: IdeChat | null; - copilot_dotcom_chat: DotComChat | null; - copilot_dotcom_pull_requests: DotComPullRequests | null; -} - -export { - CopilotMetrics, - ChatModel, - CodeModel, -} \ No newline at end of file diff --git a/backend/src/models/metrics.model.ts b/backend/src/models/metrics.model.ts index 262a896..5e84fec 100644 --- a/backend/src/models/metrics.model.ts +++ b/backend/src/models/metrics.model.ts @@ -1,628 +1,634 @@ -import { Model, DataTypes, BaseError } from 'sequelize'; -import { sequelize } from '../database.js'; -import { CopilotMetrics } from './metrics.model.interfaces.js'; -import logger from '../services/logger.js'; - -export class MetricDaily extends Model { - public date!: Date; - public total_active_users!: number; - public total_engaged_users!: number; - copilot_ide_code_completions?: MetricIdeCompletions; - copilot_ide_chat?: MetricIdeChatMetrics; - copilot_dotcom_chat?: MetricDotcomChatMetrics; - copilot_dotcom_pull_requests?: MetricPrMetrics; +import { Model, DataTypes, Sequelize } from 'sequelize'; + +type MetricDailyResponseType = { + date: string; + total_active_users: number; + total_engaged_users: number; + copilot_ide_code_completions?: MetricIdeCompletionsType; + copilot_dotcom_pull_requests?: MetricPrMetricsType; + copilot_dotcom_chat?: MetricDotcomChatMetricsType; + copilot_ide_chat?: MetricIdeChatMetricsType; } -export class MetricIdeCompletions extends Model { - public id!: number; - public total_engaged_users!: number; - public total_code_acceptances!: number; - public total_code_suggestions!: number; - public total_code_lines_accepted!: number; - public total_code_lines_suggested!: number; - public daily_metric_id!: Date; - editors?: MetricEditor[]; + +type MetricDailyType = { + org: string; + team?: string; + date: Date; + total_active_users: number; + total_engaged_users: number; + copilot_ide_code_completions?: MetricIdeCompletionsType; + copilot_dotcom_pull_requests?: MetricPrMetricsType; + copilot_dotcom_chat?: MetricDotcomChatMetricsType; + copilot_ide_chat?: MetricIdeChatMetricsType; } -export class MetricEditor extends Model { - public id!: number; - public name!: string; - public total_engaged_users!: number; - public total_code_acceptances!: number; - public total_code_suggestions!: number; - public total_code_lines_accepted!: number; - public total_code_lines_suggested!: number; - public ide_completion_id!: number; - models?: MetricModelStats[]; + +type MetricIdeCompletionsType = { + id?: number; + total_engaged_users: number; + total_code_acceptances: number; + total_code_suggestions: number; + total_code_lines_accepted: number; + total_code_lines_suggested: number; + daily_metric_id: Date; + editors?: MetricEditorType[]; } -export class MetricModelStats extends Model { - public id!: number; - public name!: string; - public is_custom_model!: boolean; - public total_engaged_users!: number; - public total_code_acceptances!: number; - public total_code_suggestions!: number; - public total_code_lines_accepted!: number; - public total_code_lines_suggested!: number; - public editor_id!: number; - languages?: MetricLanguageStats[]; + +type MetricEditorType = { + id?: number; + name: string; + total_engaged_users: number; + total_code_acceptances: number; + total_code_suggestions: number; + total_code_lines_accepted: number; + total_code_lines_suggested: number; + ide_completion_id: number; + models?: MetricModelStatsType[]; } -export class MetricLanguageStats extends Model { - public id!: number; - public name!: string; - public total_engaged_users!: number; - public total_code_acceptances!: number; - public total_code_suggestions!: number; - public total_code_lines_accepted!: number; - public total_code_lines_suggested!: number; - public model_stat_id!: number; + +type MetricModelStatsType = { + id?: number; + name: string; + is_custom_model: boolean; + total_engaged_users: number; + total_code_acceptances: number; + total_code_suggestions: number; + total_code_lines_accepted: number; + total_code_lines_suggested: number; + editor_id: number; + languages?: MetricLanguageStatsType[]; } -export class MetricPrRepository extends Model { - public id!: number; - public name!: string; - public total_engaged_users!: number; - public total_pr_summaries_created!: number; - models?: MetricPrModelStats[]; + +type MetricLanguageStatsType = { + id?: number; + name: string; + total_engaged_users: number; + total_code_acceptances: number; + total_code_suggestions: number; + total_code_lines_accepted: number; + total_code_lines_suggested: number; + model_stat_id: number; +} + +type MetricPrRepositoryType = { + id?: number; + name: string; + total_engaged_users: number; + total_pr_summaries_created: number; + pr_metrics_id: number; + models?: MetricPrModelStatsType[]; } -export class MetricPrModelStats extends Model { - public id!: number; - public name!: string; - public is_custom_model!: boolean; - public total_engaged_users!: number; - public total_pr_summaries_created!: number; + +type MetricPrModelStatsType = { + id?: number; + name: string; + is_custom_model: boolean; + total_engaged_users: number; + total_pr_summaries_created: number; + repository_id: number; } -export class MetricPrMetrics extends Model { - public id!: number; - public total_engaged_users!: number; - public total_pr_summaries_created!: number; - repositories?: MetricPrRepository[]; + +type MetricPrMetricsType = { + id?: number; + total_engaged_users: number; + total_pr_summaries_created: number; + daily_metric_id: Date; + repositories?: MetricPrRepositoryType[]; } -export class MetricDotcomChatMetrics extends Model { - public id!: number; - public total_engaged_users!: number; - public total_chats!: number; - models?: MetricDotcomChatModelStats[]; + +type MetricDotcomChatMetricsType = { + id?: number; + total_engaged_users: number; + total_chats: number; + daily_metric_id: Date; + models?: MetricDotcomChatModelStatsType[]; } -export class MetricDotcomChatModelStats extends Model { - public id!: number; - public name!: string; - public is_custom_model!: boolean; - public total_engaged_users!: number; - public total_chats!: number; + +type MetricDotcomChatModelStatsType = { + id?: number; + name: string; + is_custom_model: boolean; + total_engaged_users: number; + total_chats: number; + chat_metrics_id: number; } -export class MetricIdeChatMetrics extends Model { - public id!: number; - public total_engaged_users!: number; - public total_chats!: number; - public total_chat_copy_events!: number; - public total_chat_insertion_events!: number; - editors?: MetricIdeChatEditor[]; + +type MetricIdeChatMetricsType = { + id?: number; + total_engaged_users: number; + total_chats: number; + total_chat_copy_events: number; + total_chat_insertion_events: number; + daily_metric_id: Date; + editors?: MetricIdeChatEditorType[]; } -export class MetricIdeChatEditor extends Model { - public id!: number; - public name!: string; - public total_engaged_users!: number; - public total_chats!: number; - public total_chat_copy_events!: number; - public total_chat_insertion_events!: number; - models?: MetricIdeChatModelStats[]; + +type MetricIdeChatEditorType = { + id?: number; + name: string; + total_engaged_users: number; + total_chats: number; + total_chat_copy_events: number; + total_chat_insertion_events: number; + chat_metrics_id: number; + models?: MetricIdeChatModelStatsType[]; } -export class MetricIdeChatModelStats extends Model { - public id!: number; - public name!: string; - public is_custom_model!: boolean; - public total_engaged_users!: number; - public total_chats!: number; - public total_chat_copy_events!: number; - public total_chat_insertion_events!: number; + +type MetricIdeChatModelStatsType = { + id?: number; + name: string; + is_custom_model: boolean; + total_engaged_users: number; + total_chats: number; + total_chat_copy_events: number; + total_chat_insertion_events: number; + editor_id: number; } -MetricDaily.init({ - date: { - type: DataTypes.DATE, - primaryKey: true, - }, - total_active_users: DataTypes.INTEGER, - total_engaged_users: DataTypes.INTEGER -}, { - sequelize, - timestamps: false -}); - -MetricIdeChatMetrics.init({ - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true - }, - total_engaged_users: DataTypes.INTEGER, - total_chats: DataTypes.INTEGER, - total_chat_copy_events: DataTypes.INTEGER, - total_chat_insertion_events: DataTypes.INTEGER -}, { - sequelize, - timestamps: false -}); -MetricIdeChatEditor.init({ - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true - }, - name: DataTypes.STRING, - total_engaged_users: DataTypes.INTEGER, - total_chats: DataTypes.INTEGER, - total_chat_copy_events: DataTypes.INTEGER, - total_chat_insertion_events: DataTypes.INTEGER -}, { - sequelize, - timestamps: false -}); -MetricIdeChatModelStats.init({ - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true - }, - name: DataTypes.STRING, - is_custom_model: DataTypes.BOOLEAN, - total_engaged_users: DataTypes.INTEGER, - total_chats: DataTypes.INTEGER, - total_chat_copy_events: DataTypes.INTEGER, - total_chat_insertion_events: DataTypes.INTEGER -}, { - sequelize, - timestamps: false -}); - -MetricIdeCompletions.init({ - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true - }, - total_engaged_users: DataTypes.INTEGER, - total_code_acceptances: DataTypes.INTEGER, - total_code_suggestions: DataTypes.INTEGER, - total_code_lines_accepted: DataTypes.INTEGER, - total_code_lines_suggested: DataTypes.INTEGER -}, { - sequelize, - timestamps: false -}); -MetricEditor.init({ - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true - }, - name: DataTypes.STRING, - total_engaged_users: DataTypes.INTEGER, - total_code_acceptances: DataTypes.INTEGER, - total_code_suggestions: DataTypes.INTEGER, - total_code_lines_accepted: DataTypes.INTEGER, - total_code_lines_suggested: DataTypes.INTEGER -}, { - sequelize, - timestamps: false -}); -MetricModelStats.init({ - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true - }, - name: DataTypes.STRING, - is_custom_model: DataTypes.BOOLEAN, - total_engaged_users: DataTypes.INTEGER, - total_code_acceptances: DataTypes.INTEGER, - total_code_suggestions: DataTypes.INTEGER, - total_code_lines_accepted: DataTypes.INTEGER, - total_code_lines_suggested: DataTypes.INTEGER -}, { - sequelize, - timestamps: false -}); -MetricLanguageStats.init({ - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true - }, - name: DataTypes.STRING, - total_engaged_users: DataTypes.INTEGER, - total_code_acceptances: DataTypes.INTEGER, - total_code_suggestions: DataTypes.INTEGER, - total_code_lines_accepted: DataTypes.INTEGER, - total_code_lines_suggested: DataTypes.INTEGER -}, { - sequelize, - timestamps: false -}); - -MetricPrMetrics.init({ - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true - }, - total_engaged_users: DataTypes.INTEGER, - total_pr_summaries_created: DataTypes.INTEGER -}, { - sequelize, - timestamps: false -}); -MetricPrRepository.init({ - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true - }, - name: DataTypes.STRING, - total_engaged_users: DataTypes.INTEGER, - total_pr_summaries_created: DataTypes.INTEGER -}, { - sequelize, - timestamps: false -}); -MetricPrModelStats.init({ - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true - }, - name: DataTypes.STRING, - is_custom_model: DataTypes.BOOLEAN, - total_engaged_users: DataTypes.INTEGER, - total_pr_summaries_created: DataTypes.INTEGER -}, { - sequelize, - timestamps: false -}); - -MetricDotcomChatMetrics.init({ - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true - }, - total_engaged_users: DataTypes.INTEGER, - total_chats: DataTypes.INTEGER -}, { - sequelize, - timestamps: false -}); -MetricDotcomChatModelStats.init({ - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true - }, - name: DataTypes.STRING, - is_custom_model: DataTypes.BOOLEAN, - total_engaged_users: DataTypes.INTEGER, - total_chats: DataTypes.INTEGER -}, { - sequelize, - timestamps: false -}); -MetricDaily.hasOne(MetricIdeCompletions, { - as: 'copilot_ide_code_completions', foreignKey: 'daily_metric_id', - sourceKey: 'date' -}); -MetricDaily.hasOne(MetricPrMetrics, { - as: 'copilot_dotcom_pull_requests', - foreignKey: 'daily_metric_id' -}); -MetricDaily.hasOne(MetricDotcomChatMetrics, { - as: 'copilot_dotcom_chat', - foreignKey: 'daily_metric_id' -}); -MetricDaily.hasOne(MetricIdeChatMetrics, { - as: 'copilot_ide_chat', - foreignKey: 'daily_metric_id' -}); - -MetricIdeChatMetrics.belongsTo(MetricDaily, { - foreignKey: 'daily_metric_id' -}); -MetricIdeChatMetrics.hasMany(MetricIdeChatEditor, { - as: 'editors', - foreignKey: 'chat_metrics_id' -}); -MetricIdeChatEditor.belongsTo(MetricIdeChatMetrics, { - foreignKey: 'chat_metrics_id' -}); -MetricIdeChatEditor.hasMany(MetricIdeChatModelStats, { - as: 'models', - foreignKey: 'editor_id' -}); -MetricIdeChatModelStats.belongsTo(MetricIdeChatEditor, { - foreignKey: 'editor_id' -}); - -MetricIdeCompletions.belongsTo(MetricDaily, { - as: 'DailyMetric', - foreignKey: 'daily_metric_id', - targetKey: 'date' -}); -MetricIdeCompletions.hasMany(MetricEditor, { - as: 'editors', foreignKey: 'ide_completion_id' -}); -MetricEditor.belongsTo(MetricIdeCompletions, { - as: 'copilot_ide_code_completions', - foreignKey: 'ide_completion_id' -}); -MetricEditor.hasMany(MetricModelStats, { - as: 'models', foreignKey: 'editor_id' -}); -MetricModelStats.belongsTo(MetricEditor, { - as: 'editor', - foreignKey: 'editor_id' -}); -MetricModelStats.hasMany(MetricLanguageStats, { - as: 'languages', - foreignKey: 'model_stat_id' -}); -MetricLanguageStats.belongsTo(MetricModelStats, { - as: 'model', - foreignKey: 'model_stat_id' -}); - -MetricPrMetrics.belongsTo(MetricDaily, { - foreignKey: 'daily_metric_id', - targetKey: 'date' -}); -MetricPrMetrics.hasMany(MetricPrRepository, { - as: 'repositories', - foreignKey: 'pr_metrics_id' -}); -MetricPrRepository.belongsTo(MetricPrMetrics, { - foreignKey: 'pr_metrics_id' -}); -MetricPrRepository.hasMany(MetricPrModelStats, { - as: 'models', - foreignKey: 'repository_id' -}); -MetricPrModelStats.belongsTo(MetricPrRepository, { - foreignKey: 'repository_id' -}); - -MetricDotcomChatMetrics.belongsTo(MetricDaily, { - foreignKey: 'daily_metric_id', - targetKey: 'date' -}); -MetricDotcomChatMetrics.hasMany(MetricDotcomChatModelStats, { - as: 'models', - foreignKey: 'chat_metrics_id' -}); -MetricDotcomChatModelStats.belongsTo(MetricDotcomChatMetrics, { - foreignKey: 'chat_metrics_id' -}); - -export async function insertMetrics(data: CopilotMetrics[]) { - for (const day of data) { - const parts = day.date.split('-').map(Number); - const date = new Date(Date.UTC(parts[0], parts[1] - 1, parts[2] + 1)); - let metric: MetricDaily; - try { - metric = await MetricDaily.create({ - date: date, - total_active_users: day.total_active_users, - total_engaged_users: day.total_engaged_users, - }); - logger.info(`Metrics for ${day.date} inserted successfully! ✅`); - } catch (error) { - if (error instanceof BaseError && error.name === 'SequelizeUniqueConstraintError') { - logger.info(`Metrics for ${day.date} already exist. Skipping... ⏭️`); - } else { - logger.error(error); - } - continue; - } - - if (day.copilot_ide_chat) { - const chatTotals = { - chats: 0, - copyEvents: 0, - insertionEvents: 0 - }; - - const chatMetrics = await MetricIdeChatMetrics.create({ - daily_metric_id: metric.date, - total_engaged_users: day.copilot_ide_chat.total_engaged_users - }); - - for (const editor of day.copilot_ide_chat.editors) { - const chatTotalsEditor = { chats: 0, copyEvents: 0, insertionEvents: 0 }; - - const editorRecord = await MetricIdeChatEditor.create({ - chat_metrics_id: chatMetrics.id, - name: editor.name, - total_engaged_users: editor.total_engaged_users - }); - - // Sum up totals for each model in this editor - for (const model of editor.models) { - chatTotalsEditor.chats += model.total_chats; - chatTotalsEditor.copyEvents += model.total_chat_copy_events || 0; - chatTotalsEditor.insertionEvents += model.total_chat_insertion_events || 0; - - // Add to overall totals - chatTotals.chats += model.total_chats; - chatTotals.copyEvents += model.total_chat_copy_events || 0; - chatTotals.insertionEvents += model.total_chat_insertion_events || 0; - - await MetricIdeChatModelStats.create({ - editor_id: editorRecord.id, - name: model.name, - is_custom_model: model.is_custom_model, - total_engaged_users: model.total_engaged_users, - total_chats: model.total_chats, - total_chat_copy_events: model.total_chat_copy_events, - total_chat_insertion_events: model.total_chat_insertion_events - }); - } - - await editorRecord.update({ - total_chats: chatTotalsEditor.chats, - total_chat_copy_events: chatTotalsEditor.copyEvents, - total_chat_insertion_events: chatTotalsEditor.insertionEvents - }); - } - - await chatMetrics.update({ - total_chats: chatTotals.chats, - total_chat_copy_events: chatTotals.copyEvents, - total_chat_insertion_events: chatTotals.insertionEvents - }); - } - - if (day.copilot_ide_code_completions) { - const completions = await MetricIdeCompletions.create({ - total_engaged_users: day.copilot_ide_code_completions.total_engaged_users, - daily_metric_id: metric.date, - total_code_acceptances: 0, - total_code_suggestions: 0, - total_code_lines_accepted: 0, - total_code_lines_suggested: 0 - }); - - const dailyTotals = { acceptances: 0, suggestions: 0, linesAccepted: 0, linesSuggested: 0 }; - - for (const editor of day.copilot_ide_code_completions.editors) { - const editorRecord = await MetricEditor.create({ - name: editor.name, - total_engaged_users: editor.total_engaged_users, - ide_completion_id: completions.id, - total_code_acceptances: 0, - total_code_suggestions: 0, - total_code_lines_accepted: 0, - total_code_lines_suggested: 0 - }); - - const editorTotals = { acceptances: 0, suggestions: 0, linesAccepted: 0, linesSuggested: 0 }; - - for (const model of editor.models) { - const modelRecord = await MetricModelStats.create({ - name: model.name, - is_custom_model: model.is_custom_model, - total_engaged_users: model.total_engaged_users, - editor_id: editorRecord.id, - total_code_acceptances: 0, - total_code_suggestions: 0, - total_code_lines_accepted: 0, - total_code_lines_suggested: 0 - }); - - const modelTotals = { acceptances: 0, suggestions: 0, linesAccepted: 0, linesSuggested: 0 }; - - for (const lang of model.languages) { - await MetricLanguageStats.create({ - name: lang.name, - total_engaged_users: lang.total_engaged_users, - total_code_acceptances: lang.total_code_acceptances, - total_code_suggestions: lang.total_code_suggestions, - total_code_lines_accepted: lang.total_code_lines_accepted, - total_code_lines_suggested: lang.total_code_lines_suggested, - model_stat_id: modelRecord.id - }); - - modelTotals.acceptances += lang.total_code_acceptances || 0; - modelTotals.suggestions += lang.total_code_suggestions || 0; - modelTotals.linesAccepted += lang.total_code_lines_accepted || 0; - modelTotals.linesSuggested += lang.total_code_lines_suggested || 0; - } - - await modelRecord.update({ - total_code_acceptances: modelTotals.acceptances, - total_code_suggestions: modelTotals.suggestions, - total_code_lines_accepted: modelTotals.linesAccepted, - total_code_lines_suggested: modelTotals.linesSuggested - }); - - editorTotals.acceptances += modelTotals.acceptances; - editorTotals.suggestions += modelTotals.suggestions; - editorTotals.linesAccepted += modelTotals.linesAccepted; - editorTotals.linesSuggested += modelTotals.linesSuggested; - } - - await editorRecord.update({ - total_code_acceptances: editorTotals.acceptances, - total_code_suggestions: editorTotals.suggestions, - total_code_lines_accepted: editorTotals.linesAccepted, - total_code_lines_suggested: editorTotals.linesSuggested - }); - - dailyTotals.acceptances += editorTotals.acceptances; - dailyTotals.suggestions += editorTotals.suggestions; - dailyTotals.linesAccepted += editorTotals.linesAccepted; - dailyTotals.linesSuggested += editorTotals.linesSuggested; - } - - await completions.update({ - total_code_acceptances: dailyTotals.acceptances, - total_code_suggestions: dailyTotals.suggestions, - total_code_lines_accepted: dailyTotals.linesAccepted, - total_code_lines_suggested: dailyTotals.linesSuggested - }); - } - - if (day.copilot_dotcom_pull_requests) { - let totalPrSummaries = 0; - const prMetrics = await MetricPrMetrics.create({ - daily_metric_id: metric.date, - total_engaged_users: day.copilot_dotcom_pull_requests.total_engaged_users - }); - - if (day.copilot_dotcom_pull_requests.repositories) { - for (const repo of day.copilot_dotcom_pull_requests.repositories) { - let totalPrSummariesRepo = 0; - const repository = await MetricPrRepository.create({ - pr_metrics_id: prMetrics.id, - name: repo.name, - total_engaged_users: repo.total_engaged_users - }); - - await Promise.all(repo.models.map(model => { - totalPrSummaries += model.total_pr_summaries_created || 0; totalPrSummariesRepo += model.total_pr_summaries_created || 0; - - MetricPrModelStats.create({ - repository_id: repository.id, - name: model.name, - is_custom_model: model.is_custom_model, - total_engaged_users: model.total_engaged_users, - total_pr_summaries_created: model.total_pr_summaries_created - }) - })); - repository.update({ - total_pr_summaries_created: totalPrSummariesRepo - }); - } - } - - await prMetrics.update({ - total_pr_summaries_created: totalPrSummaries - }); - } - - if (day.copilot_dotcom_chat) { - let totalChats = 0; - const chatMetrics = await MetricDotcomChatMetrics.create({ - daily_metric_id: metric.date, - total_engaged_users: day.copilot_dotcom_chat.total_engaged_users - }); - - await Promise.all(day.copilot_dotcom_chat.models.map(model => { - totalChats += model.total_chats || 0; MetricDotcomChatModelStats.create({ - chat_metrics_id: chatMetrics.id, - name: model.name, - is_custom_model: model.is_custom_model, - total_engaged_users: model.total_engaged_users, - total_chats: model.total_chats - }) - })); - - await chatMetrics.update({ - total_chats: totalChats - }); - } + +class MetricDaily extends Model { + declare org: string; + declare team: string; + declare date: Date; + declare total_active_users: number; + declare total_engaged_users: number; + declare copilot_ide_code_completions: MetricIdeCompletionsType | null; + declare copilot_dotcom_pull_requests: MetricPrMetricsType | null; + declare copilot_dotcom_chat: MetricDotcomChatMetricsType | null; + declare copilot_ide_chat: MetricIdeChatMetricsType | null; + + static initModel(sequelize: Sequelize) { + MetricDaily.init({ + org: DataTypes.STRING, + team: DataTypes.STRING, + date: { + type: DataTypes.DATE, + primaryKey: true, + }, + total_active_users: DataTypes.INTEGER, + total_engaged_users: DataTypes.INTEGER, + }, { + sequelize, + timestamps: false + }); + + MetricIdeCompletions.initModel(sequelize); + MetricEditor.initModel(sequelize); + MetricModelStats.initModel(sequelize); + MetricLanguageStats.initModel(sequelize); + MetricPrRepository.initModel(sequelize); + MetricPrModelStats.initModel(sequelize); + MetricPrMetrics.initModel(sequelize); + MetricDotcomChatMetrics.initModel(sequelize); + MetricDotcomChatModelStats.initModel(sequelize); + MetricIdeChatMetrics.initModel(sequelize); + MetricIdeChatEditor.initModel(sequelize); + MetricIdeChatModelStats.initModel(sequelize); + + MetricDaily.hasOne(MetricIdeCompletions, { + as: 'copilot_ide_code_completions', foreignKey: 'daily_metric_id', + sourceKey: 'date' + }); + MetricDaily.hasOne(MetricPrMetrics, { + as: 'copilot_dotcom_pull_requests', + foreignKey: 'daily_metric_id' + }); + MetricDaily.hasOne(MetricDotcomChatMetrics, { + as: 'copilot_dotcom_chat', + foreignKey: 'daily_metric_id' + }); + MetricDaily.hasOne(MetricIdeChatMetrics, { + as: 'copilot_ide_chat', + foreignKey: 'daily_metric_id' + }); + + MetricIdeChatMetrics.belongsTo(MetricDaily, { + foreignKey: 'daily_metric_id' + }); + MetricIdeChatMetrics.hasMany(MetricIdeChatEditor, { + as: 'editors', + foreignKey: 'chat_metrics_id' + }); + MetricIdeChatEditor.belongsTo(MetricIdeChatMetrics, { + foreignKey: 'chat_metrics_id' + }); + MetricIdeChatEditor.hasMany(MetricIdeChatModelStats, { + as: 'models', + foreignKey: 'editor_id' + }); + MetricIdeChatModelStats.belongsTo(MetricIdeChatEditor, { + foreignKey: 'editor_id' + }); + + MetricIdeCompletions.belongsTo(MetricDaily, { + as: 'DailyMetric', + foreignKey: 'daily_metric_id', + targetKey: 'date' + }); + MetricIdeCompletions.hasMany(MetricEditor, { + as: 'editors', foreignKey: 'ide_completion_id' + }); + MetricEditor.belongsTo(MetricIdeCompletions, { + as: 'copilot_ide_code_completions', + foreignKey: 'ide_completion_id' + }); + MetricEditor.hasMany(MetricModelStats, { + as: 'models', foreignKey: 'editor_id' + }); + MetricModelStats.belongsTo(MetricEditor, { + as: 'editor', + foreignKey: 'editor_id' + }); + MetricModelStats.hasMany(MetricLanguageStats, { + as: 'languages', + foreignKey: 'model_stat_id' + }); + MetricLanguageStats.belongsTo(MetricModelStats, { + as: 'model', + foreignKey: 'model_stat_id' + }); + + MetricPrMetrics.belongsTo(MetricDaily, { + foreignKey: 'daily_metric_id', + targetKey: 'date' + }); + MetricPrMetrics.hasMany(MetricPrRepository, { + as: 'repositories', + foreignKey: 'pr_metrics_id' + }); + MetricPrRepository.belongsTo(MetricPrMetrics, { + foreignKey: 'pr_metrics_id' + }); + MetricPrRepository.hasMany(MetricPrModelStats, { + as: 'models', + foreignKey: 'repository_id' + }); + MetricPrModelStats.belongsTo(MetricPrRepository, { + foreignKey: 'repository_id' + }); + + MetricDotcomChatMetrics.belongsTo(MetricDaily, { + foreignKey: 'daily_metric_id', + targetKey: 'date' + }); + MetricDotcomChatMetrics.hasMany(MetricDotcomChatModelStats, { + as: 'models', + foreignKey: 'chat_metrics_id' + }); + MetricDotcomChatModelStats.belongsTo(MetricDotcomChatMetrics, { + foreignKey: 'chat_metrics_id' + }); + } +} + +class MetricIdeCompletions extends Model { + declare id?: number; + declare total_engaged_users: number; + declare total_code_acceptances: number; + declare total_code_suggestions: number; + declare total_code_lines_accepted: number; + declare total_code_lines_suggested: number; + declare daily_metric_id: Date; + + static initModel(sequelize: Sequelize) { + MetricIdeCompletions.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + total_engaged_users: DataTypes.INTEGER, + total_code_acceptances: DataTypes.INTEGER, + total_code_suggestions: DataTypes.INTEGER, + total_code_lines_accepted: DataTypes.INTEGER, + total_code_lines_suggested: DataTypes.INTEGER, + daily_metric_id: DataTypes.DATE + }, { + sequelize, + timestamps: false + }); + } +} + +class MetricEditor extends Model { + declare id?: number; + declare name: string; + declare total_engaged_users: number; + declare total_code_acceptances: number; + declare total_code_suggestions: number; + declare total_code_lines_accepted: number; + declare total_code_lines_suggested: number; + declare ide_completion_id: number; + + static initModel(sequelize: Sequelize) { + MetricEditor.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: DataTypes.STRING, + total_engaged_users: DataTypes.INTEGER, + total_code_acceptances: DataTypes.INTEGER, + total_code_suggestions: DataTypes.INTEGER, + total_code_lines_accepted: DataTypes.INTEGER, + total_code_lines_suggested: DataTypes.INTEGER, + ide_completion_id: DataTypes.INTEGER + }, { + sequelize, + timestamps: false + }); + } +} + +class MetricModelStats extends Model { + declare id: number; + declare name: string; + declare is_custom_model: boolean; + declare total_engaged_users: number; + declare total_code_acceptances: number; + declare total_code_suggestions: number; + declare total_code_lines_accepted: number; + declare total_code_lines_suggested: number; + declare editor_id: number; + + static initModel(sequelize: Sequelize) { + MetricModelStats.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: DataTypes.STRING, + is_custom_model: DataTypes.BOOLEAN, + total_engaged_users: DataTypes.INTEGER, + total_code_acceptances: DataTypes.INTEGER, + total_code_suggestions: DataTypes.INTEGER, + total_code_lines_accepted: DataTypes.INTEGER, + total_code_lines_suggested: DataTypes.INTEGER, + editor_id: DataTypes.INTEGER + }, { + sequelize, + timestamps: false + }); + } +} + +class MetricLanguageStats extends Model { + declare id: number; + declare name: string; + declare total_engaged_users: number; + declare total_code_acceptances: number; + declare total_code_suggestions: number; + declare total_code_lines_accepted: number; + declare total_code_lines_suggested: number; + declare model_stat_id: number; + + static initModel(sequelize: Sequelize) { + MetricLanguageStats.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: DataTypes.STRING, + total_engaged_users: DataTypes.INTEGER, + total_code_acceptances: DataTypes.INTEGER, + total_code_suggestions: DataTypes.INTEGER, + total_code_lines_accepted: DataTypes.INTEGER, + total_code_lines_suggested: DataTypes.INTEGER, + model_stat_id: DataTypes.INTEGER + }, { + sequelize, + timestamps: false + }); } -} \ No newline at end of file +} + +class MetricPrRepository extends Model { + declare id: number; + declare name: string; + declare total_engaged_users: number; + declare total_pr_summaries_created: number; + declare pr_metrics_id: number; + + static initModel(sequelize: Sequelize) { + MetricPrRepository.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: DataTypes.STRING, + total_engaged_users: DataTypes.INTEGER, + total_pr_summaries_created: DataTypes.INTEGER, + pr_metrics_id: DataTypes.INTEGER + }, { + sequelize, + timestamps: false + }); + } +} + +class MetricPrModelStats extends Model { + declare id: number; + declare name: string; + declare is_custom_model: boolean; + declare total_engaged_users: number; + declare total_pr_summaries_created: number; + declare repository_id: number; + + static initModel(sequelize: Sequelize) { + MetricPrModelStats.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: DataTypes.STRING, + is_custom_model: DataTypes.BOOLEAN, + total_engaged_users: DataTypes.INTEGER, + total_pr_summaries_created: DataTypes.INTEGER, + repository_id: DataTypes.INTEGER + }, { + sequelize, + timestamps: false + }); + } +} + +class MetricPrMetrics extends Model { + declare id: number; + declare total_engaged_users: number; + declare total_pr_summaries_created: number; + declare daily_metric_id: Date; + + static initModel(sequelize: Sequelize) { + MetricPrMetrics.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + total_engaged_users: DataTypes.INTEGER, + total_pr_summaries_created: DataTypes.INTEGER, + daily_metric_id: DataTypes.DATE + }, { + sequelize, + timestamps: false + }); + } +} + +class MetricDotcomChatMetrics extends Model { + declare id: number; + declare total_engaged_users: number; + declare total_chats: number; + declare daily_metric_id: Date; + + static initModel(sequelize: Sequelize) { + MetricDotcomChatMetrics.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + total_engaged_users: DataTypes.INTEGER, + total_chats: DataTypes.INTEGER, + daily_metric_id: DataTypes.DATE + }, { + sequelize, + timestamps: false + }); + } +} + +class MetricDotcomChatModelStats extends Model { + declare id: number; + declare name: string; + declare is_custom_model: boolean; + declare total_engaged_users: number; + declare total_chats: number; + declare chat_metrics_id: number; + + static initModel(sequelize: Sequelize) { + MetricDotcomChatModelStats.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: DataTypes.STRING, + is_custom_model: DataTypes.BOOLEAN, + total_engaged_users: DataTypes.INTEGER, + total_chats: DataTypes.INTEGER, + chat_metrics_id: DataTypes.INTEGER + }, { + sequelize, + timestamps: false + }); + } +} + +class MetricIdeChatMetrics extends Model { + declare id?: number; + declare total_engaged_users: number; + declare total_chats: number; + declare total_chat_copy_events: number; + declare total_chat_insertion_events: number; + declare daily_metric_id: Date; + + static initModel(sequelize: Sequelize) { + MetricIdeChatMetrics.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + total_engaged_users: DataTypes.INTEGER, + total_chats: DataTypes.INTEGER, + total_chat_copy_events: DataTypes.INTEGER, + total_chat_insertion_events: DataTypes.INTEGER, + daily_metric_id: DataTypes.DATE + }, { + sequelize, + timestamps: false + }); + } +} + +class MetricIdeChatEditor extends Model { + declare id: number; + declare name: string; + declare total_engaged_users: number; + declare total_chats: number; + declare total_chat_copy_events: number; + declare total_chat_insertion_events: number; + declare chat_metrics_id: number; + + static initModel(sequelize: Sequelize) { + MetricIdeChatEditor.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: DataTypes.STRING, + total_engaged_users: DataTypes.INTEGER, + total_chats: DataTypes.INTEGER, + total_chat_copy_events: DataTypes.INTEGER, + total_chat_insertion_events: DataTypes.INTEGER, + chat_metrics_id: DataTypes.INTEGER + }, { + sequelize, + timestamps: false + }); + } +} + +class MetricIdeChatModelStats extends Model { + declare id?: number; + declare name: string; + declare is_custom_model: boolean; + declare total_engaged_users: number; + declare total_chats: number; + declare total_chat_copy_events: number; + declare total_chat_insertion_events: number; + declare editor_id: number; + + static initModel(sequelize: Sequelize) { + MetricIdeChatModelStats.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: DataTypes.STRING, + is_custom_model: DataTypes.BOOLEAN, + total_engaged_users: DataTypes.INTEGER, + total_chats: DataTypes.INTEGER, + total_chat_copy_events: DataTypes.INTEGER, + total_chat_insertion_events: DataTypes.INTEGER, + editor_id: DataTypes.INTEGER + }, { + sequelize, + timestamps: false + }); + } +} + +export { + MetricDaily, + MetricIdeCompletions, + MetricEditor, + MetricModelStats, + MetricLanguageStats, + MetricPrRepository, + MetricPrModelStats, + MetricPrMetrics, + MetricDotcomChatMetrics, + MetricDotcomChatModelStats, + MetricIdeChatMetrics, + MetricIdeChatEditor, + MetricIdeChatModelStats, + MetricDailyType, + MetricDailyResponseType +}; \ No newline at end of file diff --git a/backend/src/models/settings.model.ts b/backend/src/models/settings.model.ts index 96501ea..bdffac2 100644 --- a/backend/src/models/settings.model.ts +++ b/backend/src/models/settings.model.ts @@ -1,17 +1,30 @@ -import { DataTypes } from 'sequelize'; -import { sequelize } from '../database.js'; +import { Model, DataTypes, Sequelize } from 'sequelize'; -const Settings = sequelize.define('Settings', { - name: { - type: DataTypes.STRING, - primaryKey: true, - }, - value: { - type: DataTypes.TEXT, - allowNull: false, +type SettingsType = { + name: string; + value: string; +} + +class Settings extends Model { + declare name: string; + declare value: string; + + static initModel(sequelize: Sequelize) { + Settings.init({ + name: { + type: DataTypes.STRING, + primaryKey: true, + }, + value: { + type: DataTypes.TEXT, + allowNull: false, + } + }, { + sequelize, + modelName: 'Settings', + timestamps: false, + }); } -}, { - timestamps: false, -}); +} export { Settings }; \ No newline at end of file diff --git a/backend/src/models/survey.model.ts b/backend/src/models/survey.model.ts index 4985b42..588a269 100644 --- a/backend/src/models/survey.model.ts +++ b/backend/src/models/survey.model.ts @@ -1,9 +1,8 @@ -import { Model, DataTypes } from 'sequelize'; -import { sequelize } from '../database.js'; +import { Model, DataTypes, Sequelize, CreationOptional } from 'sequelize'; type SurveyType = { id?: number; - owner: string; + org: string; repo: string; prNumber: number; status: 'pending' | 'completed'; @@ -19,7 +18,7 @@ type SurveyType = { class Survey extends Model { declare id?: number; - declare owner: string; + declare org: string; declare repo: string; declare prNumber: number; declare status: 'pending' | 'completed'; @@ -29,65 +28,67 @@ class Survey extends Model { declare percentTimeSaved: number; declare timeUsedFor: string; declare reason: string; - declare createdAt?: Date; - declare updatedAt?: Date; -} + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; -Survey.init({ - id: { - type: DataTypes.INTEGER, - autoIncrement: true, - primaryKey: true, - }, - status: { - type: DataTypes.STRING, - allowNull: true, - }, - hits: { - type: DataTypes.INTEGER, - allowNull: true, - }, - userId: { - type: DataTypes.STRING, - allowNull: true, - }, - usedCopilot: { - type: DataTypes.BOOLEAN, - allowNull: false, - }, - percentTimeSaved: { - type: DataTypes.INTEGER, - allowNull: false, - set(value: number) { - this.setDataValue('percentTimeSaved', !this.usedCopilot ? 0 : value); - } - }, - reason: { - type: DataTypes.STRING(4096), - allowNull: true, - }, - timeUsedFor: { - type: DataTypes.STRING, - allowNull: false, - }, - owner: { - type: DataTypes.STRING, - allowNull: false, - }, - repo: { - type: DataTypes.STRING, - allowNull: false, - }, - prNumber: { - type: DataTypes.INTEGER, - allowNull: true, - }, - createdAt: DataTypes.DATE, - updatedAt: DataTypes.DATE -}, { - sequelize, - modelName: 'Survey', - timestamps: true, -}); + static initModel(sequelize: Sequelize) { + Survey.init({ + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + status: { + type: DataTypes.STRING, + allowNull: true, + }, + hits: { + type: DataTypes.INTEGER, + allowNull: true, + }, + userId: { + type: DataTypes.STRING, + allowNull: true, + }, + usedCopilot: { + type: DataTypes.BOOLEAN, + allowNull: false, + }, + percentTimeSaved: { + type: DataTypes.INTEGER, + allowNull: false, + set(value: number) { + this.setDataValue('percentTimeSaved', !this.usedCopilot ? 0 : value); + } + }, + reason: { + type: DataTypes.STRING(4096), + allowNull: true, + }, + timeUsedFor: { + type: DataTypes.STRING, + allowNull: false, + }, + org: { + type: DataTypes.STRING, + allowNull: false, + }, + repo: { + type: DataTypes.STRING, + allowNull: false, + }, + prNumber: { + type: DataTypes.INTEGER, + allowNull: true, + }, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE + }, { + sequelize, + modelName: 'Survey', + timestamps: true, + }); + } +} export { Survey, SurveyType }; \ No newline at end of file diff --git a/backend/src/models/target-values.model.ts b/backend/src/models/target-values.model.ts index dcf8a77..64cb94e 100644 --- a/backend/src/models/target-values.model.ts +++ b/backend/src/models/target-values.model.ts @@ -1,29 +1,36 @@ -import { Model, DataTypes } from 'sequelize'; -import { sequelize } from '../database.js'; +import { Model, DataTypes, Sequelize } from 'sequelize'; -class TargetValues extends Model { - public targetedRoomForImprovement!: number; - public targetedNumberOfDevelopers!: number; - public targetedPercentOfTimeSaved!: number; +type TargetValuesType = { + targetedRoomForImprovement: number; + targetedNumberOfDevelopers: number; + targetedPercentOfTimeSaved: number; } -TargetValues.init({ - targetedRoomForImprovement: { - type: DataTypes.FLOAT, - allowNull: false, - }, - targetedNumberOfDevelopers: { - type: DataTypes.INTEGER, - allowNull: false, - }, - targetedPercentOfTimeSaved: { - type: DataTypes.FLOAT, - allowNull: false, +class TargetValues extends Model { + declare targetedRoomForImprovement: number; + declare targetedNumberOfDevelopers: number; + declare targetedPercentOfTimeSaved: number; + + static initModel(sequelize: Sequelize) { + TargetValues.init({ + targetedRoomForImprovement: { + type: DataTypes.FLOAT, + allowNull: false, + }, + targetedNumberOfDevelopers: { + type: DataTypes.INTEGER, + allowNull: false, + }, + targetedPercentOfTimeSaved: { + type: DataTypes.FLOAT, + allowNull: false, + } + }, { + sequelize, + modelName: 'TargetValues', + timestamps: false, + }); } -}, { - sequelize, - modelName: 'TargetValues', - timestamps: false, -}); +} export { TargetValues }; diff --git a/backend/src/models/teams.model.ts b/backend/src/models/teams.model.ts index 877659e..a8d2d28 100644 --- a/backend/src/models/teams.model.ts +++ b/backend/src/models/teams.model.ts @@ -1,241 +1,217 @@ -import { Model, DataTypes } from 'sequelize'; -import { sequelize } from '../database.js'; - -class Team extends Model { - public id!: number; - public node_id!: string; - public name!: string; - public slug!: string; - public description!: string | null; - public privacy!: string; - public notification_setting!: string; - public permission!: string; - public url!: string; - public html_url!: string; - public members_url!: string; - public repositories_url!: string; - public updatedAt!: Date; - public createdAt!: Date; -} - -class Member extends Model { - public login!: string; - public id!: number; - public node_id!: string; - public avatar_url!: string; - public gravatar_id!: string | null; - public url!: string; - public html_url!: string; - public followers_url!: string; - public following_url!: string; - public gists_url!: string; - public starred_url!: string; - public subscriptions_url!: string; - public organizations_url!: string; - public repos_url!: string; - public events_url!: string; - public received_events_url!: string; - public type!: string; - public site_admin!: boolean; - public name!: string | null; - public email!: string | null; - public starred_at?: string; - public user_view_type?: string; -} - -class TeamMemberAssociation extends Model { - public TeamId!: number; - public MemberId!: number; -} - -Team.init({ - id: { - type: DataTypes.INTEGER, - primaryKey: true - }, - node_id: DataTypes.STRING, - name: DataTypes.STRING, - slug: DataTypes.STRING, - description: DataTypes.STRING, - privacy: DataTypes.STRING, - notification_setting: DataTypes.STRING, - permission: DataTypes.STRING, - url: DataTypes.STRING, - html_url: DataTypes.STRING, - members_url: DataTypes.STRING, - repositories_url: DataTypes.STRING, - parent_id: { - type: DataTypes.INTEGER, - allowNull: true - }, - createdAt: DataTypes.DATE, - updatedAt: DataTypes.DATE -}, { - sequelize -}); - -Member.init({ - login: DataTypes.STRING, - id: { - type: DataTypes.INTEGER, - primaryKey: true - }, - node_id: DataTypes.STRING, - avatar_url: DataTypes.STRING, - gravatar_id: DataTypes.STRING, - url: DataTypes.STRING, - html_url: DataTypes.STRING, - followers_url: DataTypes.STRING, - following_url: DataTypes.STRING, - gists_url: DataTypes.STRING, - starred_url: DataTypes.STRING, - subscriptions_url: DataTypes.STRING, - organizations_url: DataTypes.STRING, - repos_url: DataTypes.STRING, - events_url: DataTypes.STRING, - received_events_url: DataTypes.STRING, - type: DataTypes.STRING, - site_admin: DataTypes.BOOLEAN, - name: DataTypes.STRING, - email: DataTypes.STRING, - starred_at: DataTypes.STRING, - user_view_type: DataTypes.STRING, - createdAt: DataTypes.DATE, - updatedAt: DataTypes.DATE -}, { - sequelize -}); - -TeamMemberAssociation.init({ - TeamId: { - type: DataTypes.INTEGER, - primaryKey: true, - references: { - model: Team, - key: 'id' - } - }, - MemberId: { - type: DataTypes.INTEGER, - primaryKey: true, - references: { - model: Member, - key: 'id' - } - } -}, { - sequelize, - timestamps: false -}); - -Team.belongsToMany(Member, { - through: TeamMemberAssociation, - foreignKey: 'TeamId', - otherKey: 'MemberId', - as: 'members' -}); -Member.belongsToMany(Team, { - through: TeamMemberAssociation, - foreignKey: 'MemberId', - otherKey: 'TeamId', - as: 'teams' -}); -Team.belongsTo(Team, { as: 'parent', foreignKey: 'parent_id' }); -Team.hasMany(Team, { as: 'children', foreignKey: 'parent_id' }); +import { Model, DataTypes, Sequelize, CreationOptional } from 'sequelize'; +import { Seat } from './copilot.seats.model.js'; +import { components } from "@octokit/openapi-types"; + +export type TeamType = Omit & { + org: string; + team?: string; + parent_id?: number | null; + createdAt?: Date; + updatedAt?: Date; + parent?: TeamType | null; +}; -const deleteTeam = async (teamId: number) => { - const team = await Team.findByPk(teamId); - if (!team) { - throw new Error(`Team with ID ${teamId} not found`); +class Team extends Model> { + declare org: string; + declare team: string; + declare id: number; + declare node_id: string; + declare name: string; + declare slug: string; + declare description: string | null; + declare privacy: string; + declare notification_setting: string; + declare permission: string; + declare url: string; + declare html_url: string; + declare members_url: string; + declare repositories_url: string; + declare parent_id: number | null; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + + static initModel(sequelize: Sequelize) { + Team.init({ + org: DataTypes.STRING, + team: DataTypes.STRING, + id: { + type: DataTypes.INTEGER, + primaryKey: true + }, + node_id: DataTypes.STRING, + name: DataTypes.STRING, + slug: DataTypes.STRING, + description: DataTypes.STRING, + privacy: DataTypes.STRING, + notification_setting: DataTypes.STRING, + permission: DataTypes.STRING, + url: DataTypes.STRING, + html_url: DataTypes.STRING, + members_url: DataTypes.STRING, + repositories_url: DataTypes.STRING, + parent_id: { + type: DataTypes.INTEGER, + allowNull: true, + references: { + model: Team, + key: 'id' + } + }, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE + }, { + sequelize + }); + + Member.initModel(sequelize); + TeamMemberAssociation.initModel(sequelize); + + Team.belongsToMany(Member, { + through: TeamMemberAssociation, + foreignKey: 'TeamId', + otherKey: 'MemberId', + as: 'members' + }); + Member.belongsToMany(Team, { + through: TeamMemberAssociation, + foreignKey: 'MemberId', + otherKey: 'TeamId', + as: 'teams' + }); + Team.belongsTo(Team, { as: 'parent', foreignKey: 'parent_id' }); + Team.hasMany(Team, { as: 'children', foreignKey: 'parent_id' }); } - - await TeamMemberAssociation.destroy({ - where: { - TeamId: teamId - } - }); - - await Team.update( - { parent_id: null }, - { - where: { - parent_id: teamId - } - } - ); - - await Team.destroy({ - where: { - id: teamId - } - }); - - return true; } -const deleteMemberFromTeam = async (teamId: number, memberId: number) => { - const team = await Team.findByPk(teamId); - const member = await Member.findByPk(memberId); - - if (!team) { - throw new Error(`Team with ID ${teamId} not found`); - } - if (!member) { - throw new Error(`Member with ID ${memberId} not found`); - } - - const deleted = await TeamMemberAssociation.destroy({ - where: { - TeamId: teamId, - MemberId: memberId - } - }); - - if (deleted === 0) { - throw new Error(`Member ${memberId} is not part of team ${teamId}`); - } - - return true; +// IMember +type MemberType = { + org: string; + login: string; + id: number; + node_id: string; + avatar_url: string; + gravatar_id: string | null; + url: string; + html_url: string; + followers_url: string; + following_url: string; + gists_url: string; + starred_url: string; + subscriptions_url: string; + organizations_url: string; + repos_url: string; + events_url: string; + received_events_url: string; + type: string; + site_admin: boolean; + name: string | null; + email: string | null; + starred_at?: string; + user_view_type?: string; + createdAt?: Date; + updatedAt?: Date; + activity?: Seat[]; }; -const deleteMember = async (memberId: number) => { - const member = await Member.findByPk(memberId); - if (!member) { - throw new Error(`Member with ID ${memberId} not found`); +class Member extends Model { + declare org: string; + declare login: string; + declare id: number; + declare node_id: string; + declare avatar_url: string; + declare gravatar_id: string | null; + declare url: string; + declare html_url: string; + declare followers_url: string; + declare following_url: string; + declare gists_url: string; + declare starred_url: string; + declare subscriptions_url: string; + declare organizations_url: string; + declare repos_url: string; + declare events_url: string; + declare received_events_url: string; + declare type: string; + declare site_admin: boolean; + declare name: string | null; + declare email: string | null; + declare starred_at?: string; + declare user_view_type?: string; + declare createdAt: Date; + declare updatedAt: Date; + declare activity: Seat[]; + + static initModel(sequelize: Sequelize) { + Member.init({ + org: DataTypes.STRING, + login: DataTypes.STRING, + id: { + type: DataTypes.INTEGER, + primaryKey: true + }, + node_id: DataTypes.STRING, + avatar_url: DataTypes.STRING, + gravatar_id: DataTypes.STRING, + url: DataTypes.STRING, + html_url: DataTypes.STRING, + followers_url: DataTypes.STRING, + following_url: DataTypes.STRING, + gists_url: DataTypes.STRING, + starred_url: DataTypes.STRING, + subscriptions_url: DataTypes.STRING, + organizations_url: DataTypes.STRING, + repos_url: DataTypes.STRING, + events_url: DataTypes.STRING, + received_events_url: DataTypes.STRING, + type: DataTypes.STRING, + site_admin: DataTypes.BOOLEAN, + name: DataTypes.STRING, + email: DataTypes.STRING, + starred_at: DataTypes.STRING, + user_view_type: DataTypes.STRING, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE + }, { + sequelize, + }); } +} - await TeamMemberAssociation.destroy({ - where: { - MemberId: memberId - } - }); - - await Member.destroy({ - where: { - id: memberId - } - }); - - return true; +type TeamMemberAssociationType = { + TeamId: number; + MemberId: number; }; -const getLastUpdatedAt = async () => { - const team = await Team.findOne({ - order: [ - ['updatedAt', 'DESC'] - ] - }); - if (!team?.updatedAt) { - return new Date(0); +class TeamMemberAssociation extends Model { + declare TeamId: number; + declare MemberId: number; + + static initModel(sequelize: Sequelize) { + TeamMemberAssociation.init({ + TeamId: { + type: DataTypes.INTEGER, + primaryKey: true, + references: { + model: Team, + key: 'id' + } + }, + MemberId: { + type: DataTypes.INTEGER, + primaryKey: true, + references: { + model: Member, + key: 'id' + } + } + }, { + sequelize, + timestamps: false + }); } - return team.updatedAt; } export { - deleteTeam, - deleteMemberFromTeam, - deleteMember, - getLastUpdatedAt, Team, Member, TeamMemberAssociation diff --git a/backend/src/models/usage.model.ts b/backend/src/models/usage.model.ts index f325d82..0efb872 100644 --- a/backend/src/models/usage.model.ts +++ b/backend/src/models/usage.model.ts @@ -1,116 +1,183 @@ -import { DataTypes } from 'sequelize'; -import { sequelize } from '../database.js'; +import { DataTypes, Model, Sequelize } from 'sequelize'; import logger from '../services/logger.js'; import { Endpoints } from '@octokit/types'; -const Usage = sequelize.define('Usage', { - day: { - type: DataTypes.DATEONLY, - primaryKey: true, - allowNull: false, - }, - totalSuggestionsCount: { - type: DataTypes.INTEGER, - allowNull: false, - }, - totalAcceptancesCount: { - type: DataTypes.INTEGER, - allowNull: false, - }, - totalLinesSuggested: { - type: DataTypes.INTEGER, - allowNull: false, - }, - totalLinesAccepted: { - type: DataTypes.INTEGER, - allowNull: false, - }, - totalActiveUsers: { - type: DataTypes.INTEGER, - allowNull: false, - }, - totalChatAcceptances: { - type: DataTypes.INTEGER, - allowNull: false, - }, - totalChatTurns: { - type: DataTypes.INTEGER, - allowNull: false, - }, - totalActiveChatUsers: { - type: DataTypes.INTEGER, - allowNull: false, - }, -}, { - timestamps: false, -}); +type UsageType = { + org: string; + team?: string; + day: string; + totalSuggestionsCount: number; + totalAcceptancesCount: number; + totalLinesSuggested: number; + totalLinesAccepted: number; + totalActiveUsers: number; + totalChatAcceptances: number; + totalChatTurns: number; + totalActiveChatUsers: number; +} + +type UsageBreakdownType = { + id?: number; + usage_day: string; + language: string; + editor: string; + suggestionsCount: number; + acceptancesCount: number; + linesSuggested: number; + linesAccepted: number; + activeUsers: number; +} -const UsageBreakdown = sequelize.define('UsageBreakdown', { - id: { - type: DataTypes.INTEGER, - autoIncrement: true, - primaryKey: true, - }, - usage_day: { - type: DataTypes.DATEONLY, - references: { - model: Usage, - key: 'day', - }, - }, - language: { - type: DataTypes.STRING, - allowNull: false, - }, - editor: { - type: DataTypes.STRING, - allowNull: false, - }, - suggestionsCount: { - type: DataTypes.INTEGER, - allowNull: false, - }, - acceptancesCount: { - type: DataTypes.INTEGER, - allowNull: false, - }, - linesSuggested: { - type: DataTypes.INTEGER, - allowNull: false, - }, - linesAccepted: { - type: DataTypes.INTEGER, - allowNull: false, - }, - activeUsers: { - type: DataTypes.INTEGER, - allowNull: false, - }, -}, { - timestamps: false, -}); +class Usage extends Model { + declare org: string; + declare team: string; + declare day: string; + declare totalSuggestionsCount: number; + declare totalAcceptancesCount: number; + declare totalLinesSuggested: number; + declare totalLinesAccepted: number; + declare totalActiveUsers: number; + declare totalChatAcceptances: number; + declare totalChatTurns: number; + declare totalActiveChatUsers: number; -Usage.hasMany(UsageBreakdown, { foreignKey: 'usage_day' }); -UsageBreakdown.belongsTo(Usage, { foreignKey: 'usage_day' }); + static initModel(sequelize: Sequelize) { + Usage.init({ + org: DataTypes.STRING, + team: DataTypes.STRING, + day: { + type: DataTypes.DATEONLY, + primaryKey: true, + allowNull: false, + }, + totalSuggestionsCount: { + type: DataTypes.INTEGER, + allowNull: false, + }, + totalAcceptancesCount: { + type: DataTypes.INTEGER, + allowNull: false, + }, + totalLinesSuggested: { + type: DataTypes.INTEGER, + allowNull: false, + }, + totalLinesAccepted: { + type: DataTypes.INTEGER, + allowNull: false, + }, + totalActiveUsers: { + type: DataTypes.INTEGER, + allowNull: false, + }, + totalChatAcceptances: { + type: DataTypes.INTEGER, + allowNull: false, + }, + totalChatTurns: { + type: DataTypes.INTEGER, + allowNull: false, + }, + totalActiveChatUsers: { + type: DataTypes.INTEGER, + allowNull: false, + }, + }, { + sequelize, + modelName: 'Usage', + timestamps: false, + }); -async function insertUsage(data: Endpoints["GET /orgs/{org}/copilot/usage"]["response"]["data"]) { + UsageBreakdown.initModel(sequelize); + } +} + +class UsageBreakdown extends Model { + declare id: number; + declare usage_day: string; + declare language: string; + declare editor: string; + declare suggestionsCount: number; + declare acceptancesCount: number; + declare linesSuggested: number; + declare linesAccepted: number; + declare activeUsers: number; + + static initModel(sequelize: Sequelize) { + UsageBreakdown.init({ + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + usage_day: { + type: DataTypes.DATEONLY, + references: { + model: Usage, + key: 'day', + }, + }, + language: { + type: DataTypes.STRING, + allowNull: false, + }, + editor: { + type: DataTypes.STRING, + allowNull: false, + }, + suggestionsCount: { + type: DataTypes.INTEGER, + allowNull: false, + }, + acceptancesCount: { + type: DataTypes.INTEGER, + allowNull: false, + }, + linesSuggested: { + type: DataTypes.INTEGER, + allowNull: false, + }, + linesAccepted: { + type: DataTypes.INTEGER, + allowNull: false, + }, + activeUsers: { + type: DataTypes.INTEGER, + allowNull: false, + }, + }, { + sequelize, + modelName: 'UsageBreakdown', + timestamps: false, + }); + + // Set up associations + Usage.hasMany(UsageBreakdown, { foreignKey: 'usage_day' }); + UsageBreakdown.belongsTo(Usage, { foreignKey: 'usage_day' }); + } +} + +async function insertUsage(org: string, data: Endpoints["GET /orgs/{org}/copilot/usage"]["response"]["data"], team?: string) { for (const metrics of data) { const [createdMetrics, created] = await Usage.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, + org, + ...team ? { team } : undefined, + totalSuggestionsCount: metrics.total_suggestions_count || 0, + totalAcceptancesCount: metrics.total_acceptances_count || 0, + totalLinesSuggested: metrics.total_lines_suggested || 0, + totalLinesAccepted: metrics.total_lines_accepted || 0, + totalActiveUsers: metrics.total_active_users || 0, + totalChatAcceptances: metrics.total_chat_acceptances || 0, + totalChatTurns: metrics.total_chat_turns || 0, + totalActiveChatUsers: metrics.total_active_chat_users || 0, + day: metrics.day, } }); if (!created) { - logger.info(`Usage for ${metrics.day} already exist. Updating... ✏️`); + logger.debug(`Usage for ${metrics.day} already exist`); await createdMetrics.update({ totalSuggestionsCount: metrics.total_suggestions_count, @@ -127,19 +194,19 @@ async function insertUsage(data: Endpoints["GET /orgs/{org}/copilot/usage"]["res } if (!metrics.breakdown) { - logger.info(`No breakdown data for ${metrics.day}. Skipping...`); + logger.warn(`No breakdown data for ${metrics.day}. Skipping...`); continue; } for (const breakdown of metrics.breakdown) { await UsageBreakdown.create({ usage_day: 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, + language: breakdown.language || 'unknown', + editor: breakdown.editor || 'unknown', + suggestionsCount: breakdown.suggestions_count || 0, + acceptancesCount: breakdown.acceptances_count || 0, + linesSuggested: breakdown.lines_suggested || 0, + linesAccepted: breakdown.lines_accepted || 0, + activeUsers: breakdown.active_users || 0, }); } } diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 908e6fc..db40f85 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -29,8 +29,6 @@ router.get('/seats', SeatsController.getAllSeats); router.get('/seats/activity', SeatsController.getActivity); router.get('/seats/activity/totals', SeatsController.getActivityTotals); router.get('/seats/:id', SeatsController.getSeat); -// TODO - remove this route -router.get('/seats/activity/highcharts', SeatsController.getActivityHighcharts); router.get('/teams', teamsController.getAllTeams); router.get('/members', teamsController.getAllMembers); @@ -44,9 +42,10 @@ router.delete('/settings/:name', settingsController.deleteSettings); router.get('/setup/registration/complete', setupController.registrationComplete); router.get('/setup/install/complete', setupController.installComplete); router.get('/setup/install', setupController.getInstall); -router.get('/setup/status', setupController.setupStatus); router.get('/setup/manifest', setupController.getManifest); router.post('/setup/existing-app', setupController.addExistingApp); +router.post('/setup/db', setupController.setupDB); +router.get('/setup/status', setupController.setupStatus); router.get('/predictive-modeling/targets', targetValuesController.getTargetValues); router.post('/predictive-modeling/targets', targetValuesController.updateTargetValues); diff --git a/backend/src/services/copilot.seats.service.ts b/backend/src/services/copilot.seats.service.ts index 28eb9eb..f1a5b3a 100644 --- a/backend/src/services/copilot.seats.service.ts +++ b/backend/src/services/copilot.seats.service.ts @@ -1,13 +1,17 @@ import { Endpoints } from '@octokit/types'; -import { Assignee, AssigningTeam, Seat } from "../models/copilot.seats.model.js"; -import { Op, Sequelize } from 'sequelize'; +import { Seat } from "../models/copilot.seats.model.js"; +import { Sequelize } from 'sequelize'; +import { components } from "@octokit/openapi-types"; +import { Member, Team } from '../models/teams.model.js'; +import app from '../app.js'; type _Seat = NonNullable[0]; export interface SeatEntry extends _Seat { plan_type: string; + assignee: components['schemas']['simple-user']; } -type AssigneeDailyActivity = { +type MemberDailyActivity = { [date: string]: { totalSeats: number, totalActive: number, @@ -22,24 +26,23 @@ type AssigneeDailyActivity = { }; class SeatsService { - async getAllSeats() { + async getAllSeats(org?: string) { + const latestQuery = await Seat.findOne({ + attributes: [[Sequelize.fn('MAX', Sequelize.col('queryAt')), 'queryAt']], + where: { + ...(org ? { org } : {}), + } + }); + return Seat.findAll({ - attributes: { - exclude: ['id', 'assignee_id', 'assigning_team_id'] - }, include: [{ - model: Assignee, + model: Member, as: 'assignee', attributes: ['login', 'id', 'avatar_url'] }], where: { - id: { - [Op.in]: Sequelize.literal(`( - SELECT MAX(id) - FROM Seats - GROUP BY assignee_id - )`) - } + ...(org ? { org } : {}), + queryAt: latestQuery?.queryAt }, order: [['last_activity_at', 'DESC']] }); @@ -48,7 +51,7 @@ class SeatsService { async getAssignee(id: number) { return Seat.findAll({ include: [{ - model: Assignee, + model: Member, as: 'assignee' }], where: { @@ -58,7 +61,7 @@ class SeatsService { } async getAssigneeByLogin(login: string) { - const assignee = await Assignee.findOne({ + const assignee = await Member.findOne({ where: { login } @@ -66,7 +69,7 @@ class SeatsService { if (!assignee) throw new Error(`Assignee ${login} not found`); return Seat.findAll({ include: [{ - model: Assignee, + model: Member, as: 'assignee' }], where: { @@ -75,15 +78,19 @@ class SeatsService { }); } - async insertSeats(data: SeatEntry[]) { + async insertSeats(org: string, data: SeatEntry[], team?: string) { + const queryAt = new Date(); for (const seat of data) { - const assignee = await Assignee.findOrCreate({ + const [assignee] = await Member.findOrCreate({ where: { id: seat.assignee.id }, defaults: { + org, + ...team ? { team } : undefined, + id: seat.assignee.id, login: seat.assignee.login, node_id: seat.assignee.node_id, avatar_url: seat.assignee.avatar_url, - gravatar_id: seat.assignee.gravatar_id, + gravatar_id: seat.assignee.gravatar_id || '', url: seat.assignee.url, html_url: seat.assignee.html_url, followers_url: seat.assignee.followers_url, @@ -100,9 +107,11 @@ class SeatsService { } }); - const assigningTeam = seat.assigning_team ? await AssigningTeam.findOrCreate({ + const [assigningTeam] = seat.assigning_team ? await Team.findOrCreate({ where: { id: seat.assigning_team.id }, defaults: { + org, + id: seat.assigning_team.id, node_id: seat.assigning_team.node_id, url: seat.assigning_team.url, html_url: seat.assigning_team.html_url, @@ -113,41 +122,67 @@ class SeatsService { notification_setting: seat.assigning_team.notification_setting, permission: seat.assigning_team.permission, members_url: seat.assigning_team.members_url, - repositories_url: seat.assigning_team.repositories_url, - parent: seat.assigning_team.parent, + repositories_url: seat.assigning_team.repositories_url } - }) : null; + }) : [null]; await Seat.create({ + queryAt, + org, + team, created_at: seat.created_at, updated_at: seat.updated_at, pending_cancellation_date: seat.pending_cancellation_date, last_activity_at: seat.last_activity_at, last_activity_editor: seat.last_activity_editor, plan_type: seat.plan_type, - assignee_id: assignee[0].id, - assigning_team_id: assigningTeam?.[0].id + assignee_id: assignee.id, + assigning_team_id: assigningTeam?.id }); } } - async getAssigneesActivity(daysInactive: number, precision: 'hour' | 'day' | 'minute' = 'day'): Promise { - const assignees = await Assignee.findAll({ + async getMembersActivity(org?: string, daysInactive = 30, precision = 'day' as 'hour' | 'day' | 'minute'): Promise { + if (!app.database.sequelize) throw new Error('No database connection available'); + // const assignees = await app.database.sequelize.query( + // `SELECT + // Member.login, + // Member.id, + // activity.id AS 'activity.id', + // activity.createdAt AS 'activity.createdAt', + // activity.last_activity_at AS 'activity.last_activity_at', + // activity.last_activity_editor AS 'activity.last_activity_editor' + // FROM Members AS Member + // INNER JOIN Seats AS activity ON Member.id = activity.assignee_id + // ${org ? 'WHERE activity.org = :org' : ''} + // ORDER BY activity.last_activity_at ASC`, + // { + // replacements: { + // org + // }, + // type: QueryTypes.SELECT, + // nest: true, + // mapToModel: true // 🎯 Maps results to the Model + // } + // ); + const assignees = await Member.findAll({ attributes: ['login', 'id'], include: [ { model: Seat, as: 'activity', - required: false, attributes: ['createdAt', 'last_activity_at', 'last_activity_editor'], order: [['last_activity_at', 'ASC']], + where: { + ...(org ? { org } : {}), + } } ], order: [ [{ model: Seat, as: 'activity' }, 'last_activity_at', 'ASC'] ] }); - const activityDays: AssigneeDailyActivity = {}; + const activityDays: MemberDailyActivity = {}; assignees.forEach((assignee) => { assignee.activity.forEach((activity) => { const fromTime = activity.last_activity_at?.getTime() || 0; @@ -195,15 +230,17 @@ class SeatsService { return sortedActivityDays; } - async getAssigneesActivityTotals() { - // Get all assignees with their activity - const assignees = await Assignee.findAll({ + async getMembersActivityTotals(org?: string) { + const assignees = await Member.findAll({ attributes: ['login', 'id'], include: [{ model: Seat, as: 'activity', attributes: ['last_activity_at'], - order: [['last_activity_at', 'ASC']] + order: [['last_activity_at', 'ASC']], + where: { + ...(org ? { org } : {}), + } }] }); @@ -229,6 +266,7 @@ class SeatsService { } export default new SeatsService(); + export { - AssigneeDailyActivity + MemberDailyActivity } \ No newline at end of file diff --git a/backend/src/services/logger.ts b/backend/src/services/logger.ts index c0cffe9..d460a20 100644 --- a/backend/src/services/logger.ts +++ b/backend/src/services/logger.ts @@ -9,7 +9,7 @@ const __dirname = dirname(__filename); const logsDir = path.resolve(__dirname, '../../logs'); if (!existsSync(logsDir)) { - mkdirSync(logsDir, { recursive: true }); + mkdirSync(logsDir, { recursive: true }); } const packageJsonPath = path.resolve(__dirname, '../../package.json'); @@ -18,17 +18,18 @@ export const appName = packageJson.name || 'GitHub Value'; const logger = bunyan.createLogger({ name: appName, + level: 'debug', serializers: { ...bunyan.stdSerializers, req: (req: Request) => ({ method: req.method, url: req.url, - remoteAddress: req.connection.remoteAddress, + remoteAddress: req.connection.remoteAddress, remotePort: req.connection.remotePort }), res: (res: Response) => ({ statusCode: res.statusCode - }) + }), }, streams: [ { @@ -40,16 +41,17 @@ const logger = bunyan.createLogger({ stream: process.stderr }, { - path: `${logsDir}/app.log`, + path: `${logsDir}/debug.json`, period: '1d', - count: 14 + count: 14, + level: 'debug' } ] }); export const expressLoggerMiddleware = (req: Request, res: Response, next: NextFunction) => { - logger.info({ req }, 'request'); - res.on('finish', () => logger.info({ res }, 'response')); + logger.debug(req); + res.on('finish', () => logger.debug(res)); next(); }; diff --git a/backend/src/services/metrics.service.ts b/backend/src/services/metrics.service.ts index b040e61..c32fafb 100644 --- a/backend/src/services/metrics.service.ts +++ b/backend/src/services/metrics.service.ts @@ -1,5 +1,6 @@ -import { MetricDaily, MetricDotcomChatMetrics, MetricDotcomChatModelStats, MetricEditor, MetricIdeChatEditor, MetricIdeChatMetrics, MetricIdeChatModelStats, MetricIdeCompletions, MetricLanguageStats, MetricModelStats, MetricPrMetrics, MetricPrModelStats, MetricPrRepository } from "../models/metrics.model.js"; -import { Op } from "sequelize"; +import { BaseError, Op } from "sequelize"; +import logger from "./logger.js"; +import { MetricDaily, MetricDailyResponseType, MetricDotcomChatMetrics, MetricDotcomChatModelStats, MetricEditor, MetricIdeChatEditor, MetricIdeChatMetrics, MetricIdeChatModelStats, MetricIdeCompletions, MetricLanguageStats, MetricModelStats, MetricPrMetrics, MetricPrModelStats, MetricPrRepository } from "../models/metrics.model.js"; export interface MetricsQueryParams { type?: string, @@ -8,109 +9,154 @@ export interface MetricsQueryParams { editor?: string, language?: string, model?: string + org?: string }; class MetricsService { - async queryMetrics(params: MetricsQueryParams) { - const { type, since, until, editor, language, model } = params; + async getMetrics(params: MetricsQueryParams) { + const { org, type, since, until, editor, language, model } = params; // consider the fact that these are UTC dates... const dateFilter = { ...(since && { [Op.gte]: new Date(since as string) }), ...(until && { [Op.lte]: new Date(until as string) }) }; + const where = { + ...(org ? { org } : {}), + ...Object.getOwnPropertySymbols(dateFilter).length ? { date: dateFilter } : {} + } - const include = []; const types = type ? (type as string).split(/[ ,]+/) : []; + const findAlls = {} as { + copilot_ide_code_completions: Promise, + copilot_ide_chat: Promise, + copilot_dotcom_chat: Promise, + copilot_dotcom_pull_requests: Promise, + } if (types.length === 0 || types.includes('copilot_ide_code_completions')) { - include.push({ - attributes: { exclude: ['id', 'daily_metric_id'] }, - model: MetricIdeCompletions, - as: 'copilot_ide_code_completions', - include: [{ - attributes: { exclude: ['id', 'ide_completion_id'] }, - model: MetricEditor, - as: 'editors', - where: editor ? { name: editor } : {}, - required: false, + findAlls.copilot_ide_code_completions = MetricDaily.findAll({ + where, + include: { + attributes: { exclude: ['id', 'daily_metric_id'] }, + model: MetricIdeCompletions, + as: 'copilot_ide_code_completions', include: [{ - attributes: { exclude: ['id', 'editor_id'] }, - model: MetricModelStats, - as: 'models', - where: model ? { name: model } : {}, - required: false, + attributes: { exclude: ['id', 'ide_completion_id'] }, + model: MetricEditor, + as: 'editors', + where: editor ? { name: editor } : {}, + required: true, include: [{ - attributes: { exclude: ['id', 'model_stat_id'] }, - model: MetricLanguageStats, - as: 'languages', - where: language ? { name: language } : {}, - required: false, + attributes: { exclude: ['id', 'editor_id'] }, + model: MetricModelStats, + as: 'models', + where: model ? { name: model } : {}, + required: true, + include: [{ + attributes: { exclude: ['id', 'model_stat_id'] }, + model: MetricLanguageStats, + as: 'languages', + where: language ? { name: language } : {}, + required: true, + }] }] }] - }] - }); + } + }) } if (types.length === 0 || types.includes('copilot_ide_chat')) { - include.push({ - attributes: { exclude: ['id', 'daily_metric_id'] }, - model: MetricIdeChatMetrics, - as: 'copilot_ide_chat', - include: [{ - attributes: { exclude: ['id', 'chat_metrics_id'] }, - model: MetricIdeChatEditor, - as: 'editors', - where: editor ? { name: editor } : {}, - required: false, - include: [{ - attributes: { exclude: ['id', 'editor_id'] }, - model: MetricIdeChatModelStats, - as: 'models', - where: model ? { name: model } : {}, - required: false, - }] - }] - }); + findAlls.copilot_ide_chat = + MetricDaily.findAll({ + where, + include: { + attributes: { exclude: ['id', 'daily_metric_id'] }, + model: MetricIdeChatMetrics, + as: 'copilot_ide_chat', + required: true, + include: [{ + attributes: { exclude: ['id', 'chat_metrics_id'] }, + model: MetricIdeChatEditor, + as: 'editors', + where: editor ? { name: editor } : {}, + required: true, + include: [{ + attributes: { exclude: ['id', 'editor_id'] }, + model: MetricIdeChatModelStats, + as: 'models', + where: model ? { name: model } : {}, + required: true, + }] + }] + } + }) } if (types.length === 0 || types.includes('copilot_dotcom_chat')) { - include.push({ - attributes: { exclude: ['id', 'daily_metric_id'] }, - model: MetricDotcomChatMetrics, - as: 'copilot_dotcom_chat', - include: [{ - attributes: { exclude: ['id', 'chat_metrics_id'] }, - model: MetricDotcomChatModelStats, - as: 'models', - where: model ? { name: model } : {}, - required: false, - }] - }); + findAlls.copilot_dotcom_chat = + MetricDaily.findAll({ + where, + include: { + attributes: { exclude: ['id', 'daily_metric_id'] }, + model: MetricDotcomChatMetrics, + as: 'copilot_dotcom_chat', + include: [{ + attributes: { exclude: ['id', 'chat_metrics_id'] }, + model: MetricDotcomChatModelStats, + as: 'models', + where: model ? { name: model } : {}, + required: false, + }] + } + }) } if (types.length === 0 || types.includes('copilot_dotcom_pull_requests')) { - include.push({ - attributes: { exclude: ['id', 'daily_metric_id'] }, - model: MetricPrMetrics, - as: 'copilot_dotcom_pull_requests', - include: [{ - attributes: { exclude: ['id', 'pr_metrics_id'] }, - model: MetricPrRepository, - as: 'repositories', - include: [{ - attributes: { exclude: ['id', 'repository_id'] }, - model: MetricPrModelStats, - as: 'models', - where: model ? { name: model } : {}, - required: false, - }] - }] - }); + findAlls.copilot_dotcom_pull_requests = + MetricDaily.findAll({ + where, + include: { + attributes: { exclude: ['id', 'daily_metric_id'] }, + model: MetricPrMetrics, + as: 'copilot_dotcom_pull_requests', + include: [{ + attributes: { exclude: ['id', 'pr_metrics_id'] }, + model: MetricPrRepository, + as: 'repositories', + include: [{ + attributes: { exclude: ['id', 'repository_id'] }, + model: MetricPrModelStats, + as: 'models', + where: model ? { name: model } : {}, + required: false, + }] + }] + } + }) } - return await MetricDaily.findAll({ - where: Object.getOwnPropertySymbols(dateFilter).length ? { date: dateFilter } : {}, - include - }); + + const rsps = await Promise.all([ + findAlls.copilot_ide_code_completions, + findAlls.copilot_ide_chat, + findAlls.copilot_dotcom_chat, + findAlls.copilot_dotcom_pull_requests + ]); + + const result = rsps[0] as MetricDaily[] + rsps[1].reduce((acc, val, i) => { + if (val.copilot_ide_chat) acc[i].setDataValue('copilot_ide_chat', val.copilot_ide_chat); + return acc; + }, result); + rsps[2].reduce((acc, val, i) => { + if (val.copilot_dotcom_chat) acc[i].setDataValue('copilot_dotcom_chat', val.copilot_dotcom_chat); + return acc; + }, result); + rsps[3].reduce((acc, val, i) => { + if (val.copilot_dotcom_pull_requests) acc[i].setDataValue('copilot_dotcom_pull_requests', val.copilot_dotcom_pull_requests); + return acc; + }, result); + + return result; } - async queryMetricsTotals(params: MetricsQueryParams) { - const metrics = await this.queryMetrics(params); + async getMetricsTotals(params: MetricsQueryParams) { + const metrics = await this.getMetrics(params); // Initialize aggregated totals const periodMetrics = { @@ -330,6 +376,273 @@ class MetricsService { return periodMetrics; } + + async insertMetrics(org: string, data: MetricDailyResponseType[], team?: string) { + const where = { + org: org, + ...team ? { team } : undefined, + } + for (const day of data) { + const parts = day.date.split('-').map(Number); + const date = new Date(Date.UTC(parts[0], parts[1] - 1, parts[2] + 1)); + let metric: MetricDaily; + try { + await MetricDaily.upsert({ + ...where, + date: date, + total_active_users: day.total_active_users, + total_engaged_users: day.total_engaged_users, + }); + const _metric = await MetricDaily.findOne({ + where: { + ...where, + date: date, + } + }) + if (!_metric) throw new Error('Metric not found'); + metric = _metric; + logger.debug(`Metrics for ${day.date} inserted successfully`); + } catch (error) { + if (error instanceof BaseError && error.name === 'SequelizeUniqueConstraintError') { + logger.debug(`Metrics for ${day.date} already exist. Skipping... ⏭️`); + } else { + logger.error(error); + } + continue; + } + + if (day.copilot_ide_chat) { + const chatTotals = { + chats: 0, + copyEvents: 0, + insertionEvents: 0 + }; + + const chatMetrics = await MetricIdeChatMetrics.create({ + daily_metric_id: metric.date, + total_engaged_users: day.copilot_ide_chat.total_engaged_users, + total_chats: 0, + total_chat_copy_events: 0, + total_chat_insertion_events: 0 + }); + + if (day.copilot_ide_chat.editors) { + for (const editor of day.copilot_ide_chat.editors) { + const chatTotalsEditor = { chats: 0, copyEvents: 0, insertionEvents: 0 }; + + const editorRecord = await MetricIdeChatEditor.create({ + chat_metrics_id: (chatMetrics.id || -1), + name: editor.name, + total_engaged_users: editor.total_engaged_users, + total_chats: 0, + total_chat_copy_events: 0, + total_chat_insertion_events: 0, + }); + + if (editor.models) { + for (const model of editor.models) { + chatTotalsEditor.chats += model.total_chats; + chatTotalsEditor.copyEvents += model.total_chat_copy_events || 0; + chatTotalsEditor.insertionEvents += model.total_chat_insertion_events || 0; + + // Add to overall totals + chatTotals.chats += model.total_chats; + chatTotals.copyEvents += model.total_chat_copy_events || 0; + chatTotals.insertionEvents += model.total_chat_insertion_events || 0; + + await MetricIdeChatModelStats.create({ + editor_id: editorRecord.id, + name: model.name, + is_custom_model: model.is_custom_model, + total_engaged_users: model.total_engaged_users, + total_chats: model.total_chats, + total_chat_copy_events: model.total_chat_copy_events || 0, + total_chat_insertion_events: model.total_chat_insertion_events || 0 + }); + } + } + + await editorRecord.update({ + total_chats: chatTotalsEditor.chats, + total_chat_copy_events: chatTotalsEditor.copyEvents, + total_chat_insertion_events: chatTotalsEditor.insertionEvents + }); + } + } + + await chatMetrics.update({ + total_chats: chatTotals.chats, + total_chat_copy_events: chatTotals.copyEvents, + total_chat_insertion_events: chatTotals.insertionEvents + }); + } + + if (day.copilot_ide_code_completions) { + const completions = await MetricIdeCompletions.create({ + total_engaged_users: day.copilot_ide_code_completions.total_engaged_users, + daily_metric_id: metric.date, + total_code_acceptances: 0, + total_code_suggestions: 0, + total_code_lines_accepted: 0, + total_code_lines_suggested: 0 + }); + + const dailyTotals = { acceptances: 0, suggestions: 0, linesAccepted: 0, linesSuggested: 0 }; + + if (day.copilot_ide_code_completions.editors) { + for (const editor of day.copilot_ide_code_completions.editors) { + const editorRecord = await MetricEditor.create({ + name: editor.name, + total_engaged_users: editor.total_engaged_users, + ide_completion_id: completions.id || -1, + total_code_acceptances: 0, + total_code_suggestions: 0, + total_code_lines_accepted: 0, + total_code_lines_suggested: 0 + }); + + const editorTotals = { acceptances: 0, suggestions: 0, linesAccepted: 0, linesSuggested: 0 }; + + if (editor.models) { + for (const model of editor.models) { + const modelRecord = await MetricModelStats.create({ + name: model.name, + is_custom_model: model.is_custom_model, + total_engaged_users: model.total_engaged_users, + editor_id: editorRecord.id || -1, + total_code_acceptances: 0, + total_code_suggestions: 0, + total_code_lines_accepted: 0, + total_code_lines_suggested: 0 + }); + + const modelTotals = { acceptances: 0, suggestions: 0, linesAccepted: 0, linesSuggested: 0 }; + + if (model.languages) { + for (const lang of model.languages) { + await MetricLanguageStats.create({ + name: lang.name, + total_engaged_users: lang.total_engaged_users, + total_code_acceptances: lang.total_code_acceptances || 0, + total_code_suggestions: lang.total_code_suggestions || 0, + total_code_lines_accepted: lang.total_code_lines_accepted || 0, + total_code_lines_suggested: lang.total_code_lines_suggested || 0, + model_stat_id: modelRecord.id + }); + + modelTotals.acceptances += lang.total_code_acceptances || 0; + modelTotals.suggestions += lang.total_code_suggestions || 0; + modelTotals.linesAccepted += lang.total_code_lines_accepted || 0; + modelTotals.linesSuggested += lang.total_code_lines_suggested || 0; + } + } + + await modelRecord.update({ + total_code_acceptances: modelTotals.acceptances, + total_code_suggestions: modelTotals.suggestions, + total_code_lines_accepted: modelTotals.linesAccepted, + total_code_lines_suggested: modelTotals.linesSuggested + }); + + editorTotals.acceptances += modelTotals.acceptances; + editorTotals.suggestions += modelTotals.suggestions; + editorTotals.linesAccepted += modelTotals.linesAccepted; + editorTotals.linesSuggested += modelTotals.linesSuggested; + } + } + + await editorRecord.update({ + total_code_acceptances: editorTotals.acceptances, + total_code_suggestions: editorTotals.suggestions, + total_code_lines_accepted: editorTotals.linesAccepted, + total_code_lines_suggested: editorTotals.linesSuggested + }); + + dailyTotals.acceptances += editorTotals.acceptances; + dailyTotals.suggestions += editorTotals.suggestions; + dailyTotals.linesAccepted += editorTotals.linesAccepted; + dailyTotals.linesSuggested += editorTotals.linesSuggested; + } + } + + await completions.update({ + total_code_acceptances: dailyTotals.acceptances, + total_code_suggestions: dailyTotals.suggestions, + total_code_lines_accepted: dailyTotals.linesAccepted, + total_code_lines_suggested: dailyTotals.linesSuggested + }); + } + + if (day.copilot_dotcom_pull_requests) { + let totalPrSummaries = 0; + const prMetrics = await MetricPrMetrics.create({ + daily_metric_id: metric.date, + total_engaged_users: day.copilot_dotcom_pull_requests.total_engaged_users, + total_pr_summaries_created: 0 + }); + + if (day.copilot_dotcom_pull_requests.repositories) { + for (const repo of day.copilot_dotcom_pull_requests.repositories) { + let totalPrSummariesRepo = 0; + const repository = await MetricPrRepository.create({ + pr_metrics_id: prMetrics.id, + name: repo.name, + total_engaged_users: repo.total_engaged_users, + total_pr_summaries_created: 0 + }); + + if (repo.models) { + repo.models.map(async (model) => { + totalPrSummaries += model.total_pr_summaries_created || 0; totalPrSummariesRepo += model.total_pr_summaries_created || 0; + + await MetricPrModelStats.create({ + repository_id: repository.id, + name: model.name, + is_custom_model: model.is_custom_model, + total_engaged_users: model.total_engaged_users, + total_pr_summaries_created: model.total_pr_summaries_created + }) + }); + } + repository.update({ + total_pr_summaries_created: totalPrSummariesRepo + }); + } + } + + await prMetrics.update({ + total_pr_summaries_created: totalPrSummaries + }); + } + + if (day.copilot_dotcom_chat) { + let totalChats = 0; + const chatMetrics = await MetricDotcomChatMetrics.create({ + daily_metric_id: metric.date, + total_engaged_users: day.copilot_dotcom_chat.total_engaged_users, + total_chats: 0 + }); + + if (day.copilot_dotcom_chat.models) { + day.copilot_dotcom_chat.models.map(async (model) => { + totalChats += model.total_chats || 0; + await MetricDotcomChatModelStats.create({ + chat_metrics_id: chatMetrics.id, + name: model.name, + is_custom_model: model.is_custom_model, + total_engaged_users: model.total_engaged_users, + total_chats: model.total_chats + }) + }); + } + + await chatMetrics.update({ + total_chats: totalChats + }); + } + } + } + } export default new MetricsService(); \ No newline at end of file diff --git a/backend/src/services/query.service.ts b/backend/src/services/query.service.ts index 98fc8ca..7727fc0 100644 --- a/backend/src/services/query.service.ts +++ b/backend/src/services/query.service.ts @@ -1,108 +1,112 @@ -import { CronJob, CronTime } from 'cron'; +import { CronJob, CronJobParams, CronTime } from 'cron'; import logger from './logger.js'; -import setup from './setup.js'; import { insertUsage } from '../models/usage.model.js'; -import SeatService from '../services/copilot.seats.service.js'; -import { insertMetrics } from '../models/metrics.model.js'; -import { CopilotMetrics } from '../models/metrics.model.interfaces.js'; -import { getLastUpdatedAt, Member, Team, TeamMemberAssociation } from '../models/teams.model.js'; +import SeatService, { SeatEntry } from '../services/copilot.seats.service.js'; +import { Octokit } from 'octokit'; +import metricsService from './metrics.service.js'; +import { MetricDailyResponseType } from '../models/metrics.model.js'; +import teamsService from './teams.service.js'; const DEFAULT_CRON_EXPRESSION = '0 * * * *'; class QueryService { - private static instance: QueryService; - private cronJob: CronJob; - - private constructor(cronExpression: string, timeZone: string) { - try { - this.cronJob = new CronJob(cronExpression, this.task.bind(this), null, true, timeZone); - } catch { - logger.error('Invalid cron expression. Using default cron expression.'); - this.cronJob = new CronJob(DEFAULT_CRON_EXPRESSION, this.task, null, true, timeZone); + cronJob: CronJob; + status = { + usage: false, + metrics: false, + copilotSeats: false, + teamsAndMembers: false, + dbInitialized: false + }; + + constructor( + public org: string, + public octokit: Octokit, + options?: Partial + ) { + // Consider Timezone + const _options: CronJobParams = { + cronTime: DEFAULT_CRON_EXPRESSION, + onTick: () => { + this.task(this.org); + }, + start: true, + ...options } - this.task(); + this.cronJob = CronJob.from(_options); + this.task(this.org); } - private async task() { - const queries = [ - this.queryCopilotUsageMetrics().then(() => - setup.setSetupStatusDbInitialized({ usage: true })), - this.queryCopilotUsageMetricsNew().then(() => - setup.setSetupStatusDbInitialized({ metrics: true })), - this.queryCopilotSeatAssignments().then(() => - setup.setSetupStatusDbInitialized({ copilotSeats: true })), - ] - - const lastUpdated = await getLastUpdatedAt(); - const elapsedHours = (new Date().getTime() - lastUpdated.getTime()) / (1000 * 60 * 60); - logger.info(`It's been ${Math.floor(elapsedHours)} hours since last update 🕒`); - if (elapsedHours > 24) { - queries.push( - this.queryTeamsAndMembers().then(() => - setup.setSetupStatusDbInitialized({ teamsAndMembers: true })) - ); - } - await Promise.all(queries); - setup.setSetupStatus({ - dbInitialized: true - }) + delete() { + this.cronJob.stop(); } - public static createInstance(cronExpression: string, timeZone: string) { - if (!QueryService.instance) { - QueryService.instance = new QueryService(cronExpression, timeZone); - } - return QueryService.instance; - } + private async task(org: string) { + logger.info(`${org} task started`); + try { + const queries = [ + this.queryCopilotUsageMetrics(org).then(() => this.status.usage = true), + this.queryCopilotUsageMetricsNew(org).then(() => this.status.metrics = true), + this.queryCopilotSeatAssignments(org).then(() => this.status.copilotSeats = true), + ] + + const lastUpdated = await teamsService.getLastUpdatedAt(); + const elapsedHours = (new Date().getTime() - lastUpdated.getTime()) / (1000 * 60 * 60); + if (elapsedHours > 24) { + queries.push( + this.queryTeamsAndMembers(org).then(() => + this.status.teamsAndMembers = true + ) + ); + } else { + this.status.teamsAndMembers = true + } - public static getInstance(): QueryService { - return QueryService.instance; + await Promise.all(queries).then(() => { + this.status.dbInitialized = true; + }); + } catch (error) { + logger.error(error); + } + logger.info(`${org} finished task`); } - public async queryCopilotUsageMetricsNew() { - if (!setup.installation?.owner?.login) throw new Error('No installation found') + public async queryCopilotUsageMetricsNew(org: string, team?: string) { try { - const octokit = await setup.getOctokit(); - const response = await octokit.paginate('GET /orgs/{org}/copilot/metrics', { - org: setup.installation.owner?.login - }); - const metricsArray = response; - - await insertMetrics(metricsArray); - - logger.info("Metrics successfully updated! 📈"); + const metricsArray = await this.octokit.paginate( + 'GET /orgs/{org}/copilot/metrics', + { + org: org + } + ); + await metricsService.insertMetrics(org, metricsArray, team); + logger.info(`${org} metrics updated`); } catch (error) { - logger.error('Error querying copilot metrics', error); + logger.error(org, `Error updating ${org} metrics`, error); } } - public async queryCopilotUsageMetrics() { - if (!setup.installation?.owner?.login) throw new Error('No installation found') + public async queryCopilotUsageMetrics(org: string) { try { - const octokit = await setup.getOctokit(); - const response = await octokit.rest.copilot.usageMetricsForOrg({ - org: setup.installation.owner?.login + const rsp = await this.octokit.rest.copilot.usageMetricsForOrg({ + org }); - insertUsage(response.data); - - logger.info("Usage successfully updated! 📈"); + insertUsage(org, rsp.data); + logger.info(`${this.org} usage metrics updated`); } catch (error) { - logger.error('Error querying copilot metrics', error); + logger.error(`Error updating ${this.org} usage metrics`, error); } } - public async queryCopilotSeatAssignments() { - if (!setup.installation?.owner?.login) throw new Error('No installation found') + public async queryCopilotSeatAssignments(org: string) { try { - const octokit = await setup.getOctokit(); - const _seatAssignments = await octokit.paginate(octokit.rest.copilot.listCopilotSeats, { - org: setup.installation.owner?.login - }) as { total_seats: number, seats: object[] }[]; + const rsp = await this.octokit.paginate(this.octokit.rest.copilot.listCopilotSeats, { + org + }) as { total_seats: number, seats: SeatEntry[] }[]; + const seatAssignments = { - total_seats: _seatAssignments[0]?.total_seats || 0, - // octokit paginate returns an array of objects (bug) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - seats: (_seatAssignments).reduce((acc, rsp) => acc.concat(rsp.seats), [] as any[]) + total_seats: rsp[0]?.total_seats || 0, + seats: (rsp).reduce((acc, rsp) => acc.concat(rsp.seats), [] as SeatEntry[]) }; if (!seatAssignments.seats) { @@ -110,141 +114,39 @@ class QueryService { return; } - SeatService.insertSeats(seatAssignments.seats); + await SeatService.insertSeats(org, seatAssignments.seats); - logger.info("Seat assignments successfully updated! 🪑"); + logger.info(`${org} seat assignments updated`); } catch (error) { - logger.error('Error querying copilot seat assignments', error); + logger.debug(error) + logger.error('Error querying copilot seat assignments'); } } - public async queryTeamsAndMembers(team_slug?: string, member_login?: string) { - if (!setup.installation?.owner?.login) throw new Error('No installation found') + public async queryTeamsAndMembers(org: string) { + const members = await this.octokit.paginate("GET /orgs/{org}/members", { + org + }); + await teamsService.updateMembers(org, members); try { - const octokit = await setup.getOctokit(); - const teams = team_slug ? [(await octokit.rest.teams.getByName({ - org: setup.installation.owner?.login, - team_slug, - })).data] : await octokit.paginate(octokit.rest.teams.list, { - org: setup.installation.owner?.login + const teams = await this.octokit.paginate(this.octokit.rest.teams.list, { + org }); + await teamsService.updateTeams(org, teams); - // First pass: Create all teams without parent relationships 🏗️ - for (const team of teams) { - await Team.upsert({ - id: team.id, - node_id: team.node_id, - name: team.name, - slug: team.slug, - description: team.description, - privacy: team.privacy, - notification_setting: team.notification_setting, - permission: team.permission, - url: team.url, - html_url: team.html_url, - members_url: team.members_url, - repositories_url: team.repositories_url - }); - } - - // Second pass: Update parent relationships 👨‍👦 - for (const team of teams) { - if (team.parent?.id) { - await Team.update( - { parent_id: team.parent.id }, - { where: { id: team.id } } - ); - } - } - - // Third pass: Add team members 👥 - for (const team of teams) { - const members = await octokit.paginate(octokit.rest.teams.listMembersInOrg, { - org: setup.installation.owner?.login, + await Promise.all( + teams.map(async (team) => this.octokit.paginate(this.octokit.rest.teams.listMembersInOrg, { + org, team_slug: team.slug - }); - - if (members?.length) { - await Promise.all(members.map(async member => { - const [dbMember] = await Member.upsert({ - id: member.id, - login: member.login, - node_id: member.node_id, - avatar_url: member.avatar_url, - gravatar_id: member.gravatar_id || null, - url: member.url, - html_url: member.html_url, - followers_url: member.followers_url, - following_url: member.following_url, - gists_url: member.gists_url, - starred_url: member.starred_url, - subscriptions_url: member.subscriptions_url, - organizations_url: member.organizations_url, - repos_url: member.repos_url, - events_url: member.events_url, - received_events_url: member.received_events_url, - type: member.type, - site_admin: member.site_admin - }); - - // Create team-member association 🤝 - await TeamMemberAssociation.upsert({ - TeamId: team.id, - MemberId: dbMember.id - }); - })); - } - - logger.info(`Team ${team.name} successfully updated! ✏️`); - } - - if (!team_slug) { - await Team.upsert({ - name: 'No Team', - slug: 'no-team', - description: 'No team assigned', - id: -1 - }); - - const members = member_login ? [(await octokit.rest.orgs.getMembershipForUser({ - org: setup.installation?.owner?.login, - username: member_login - })).data.user] : await octokit.paginate(octokit.rest.orgs.listMembers, { - org: setup.installation?.owner?.login - }); - if (members?.length) { - await Promise.all(members.map(async member => { - if (!member) return; - const [dbMember] = await Member.upsert({ - id: member.id, - login: member.login, - node_id: member.node_id, - avatar_url: member.avatar_url, - gravatar_id: member.gravatar_id || null, - url: member.url, - html_url: member.html_url, - followers_url: member.followers_url, - following_url: member.following_url, - gists_url: member.gists_url, - starred_url: member.starred_url, - subscriptions_url: member.subscriptions_url, - organizations_url: member.organizations_url, - repos_url: member.repos_url, - events_url: member.events_url, - received_events_url: member.received_events_url, - type: member.type, - site_admin: member.site_admin - }); - - // Create team-member association 🤝 - await TeamMemberAssociation.upsert({ - TeamId: -1, - MemberId: dbMember.id - }); - })); - } - } - + }).then(async (members) => + await Promise.all( + members.map(async (member) => teamsService.addMemberToTeam(team.id, member.id)) + )).catch((error) => { + logger.debug(error); + logger.error('Error updating team members for team', { team: team, error: error }); + }) + ) + ) logger.info("Teams & Members successfully updated! 🧑‍🤝‍🧑"); } catch (error) { logger.error('Error querying teams', error); diff --git a/backend/src/services/settings.service.ts b/backend/src/services/settings.service.ts index 757b993..d960552 100644 --- a/backend/src/services/settings.service.ts +++ b/backend/src/services/settings.service.ts @@ -1,64 +1,42 @@ +import { CronTime } from 'cron'; +import app from '../app.js'; import { Settings } from '../models/settings.model.js'; -import { QueryService } from './query.service.js'; -import setup from './setup.js'; -import SmeeService from './smee.js'; -import SeatsService from '../services/copilot.seats.service.js'; + +export interface SettingsType { + baseUrl?: string, + webhookProxyUrl?: string, + webhookSecret?: string, + metricsCronExpression: string, + devCostPerYear: string, + developerCount: string, + hoursPerYear: string, + percentTimeSaved: string, + percentCoding: string, +} class SettingsService { - public baseUrl = process.env.BASE_URL || 'http://localhost'; + settings: SettingsType; - constructor() { + constructor( + private defaultSettings: SettingsType + ) { + this.settings = defaultSettings; } - async initializeSettings() { - try { - const baseUrl = await this.getSettingsByName('baseUrl'); - if (!baseUrl) throw new Error('Base URL is not set'); - this.baseUrl = baseUrl; - } catch { - this.updateSetting('baseUrl', this.baseUrl); - } - try { - await this.getSettingsByName('webhookProxyUrl') - } catch { - this.updateSetting('webhookProxyUrl', SmeeService.getWebhookProxyUrl()); - } - try { - const webhookSecret = await this.getSettingsByName('webhookSecret') - if (webhookSecret !== process.env.GITHUB_WEBHOOK_SECRET) throw new Error('Webhook secret does not match environment variable'); - } catch { - this.updateSetting('webhookSecret', process.env.GITHUB_WEBHOOK_SECRET || ''); - } - try { - if (!await this.getSettingsByName('devCostPerYear')) throw new Error('Developer cost per year is not set'); - } catch { - this.updateSetting('devCostPerYear', '100000'); - } - try { - if (!await this.getSettingsByName('developerCount')) throw new Error('Developer count is not set'); - } catch { - this.updateSetting('developerCount', (await SeatsService.getAllSeats()).length.toString()); - } - try { - if (!await this.getSettingsByName('hoursPerYear')) throw new Error('Hours per year is not set'); - } catch { - this.updateSetting('hoursPerYear', '2080'); - } - try { - if (!await this.getSettingsByName('percentTimeSaved')) throw new Error('Percent time saved is not set'); - } catch { - this.updateSetting('percentTimeSaved', '20'); - } - try { - if (!await this.getSettingsByName('percentCoding')) throw new Error('Percent coding is not set'); - } catch { - this.updateSetting('percentCoding', '20'); - } - try { - if (!await this.getSettingsByName('metricsCronExpression')) throw new Error('Metrics cron expression is not set'); - } catch { - this.updateSetting('metricsCronExpression', '0 0 * * *'); + async initialize() { + for (const [name, value] of Object.entries(this.defaultSettings)) { + try { + const setting = await this.getSettingsByName(name); + if (setting) { + this.settings[name as keyof SettingsType] = setting + } + } catch { + if (value) { + await this.updateSetting(name as keyof SettingsType, value); + } + } } + return this.settings; } async getAllSettings() { @@ -66,38 +44,43 @@ class SettingsService { } async getSettingsByName(name: string): Promise { - const rsp = await Settings.findOne({ where: { name } }); - if (!rsp) { + try { + const rsp = await Settings.findOne({ where: { name } }); + if (!rsp) { + return undefined; + } + return rsp.dataValues.value; + } catch { return undefined; } - return rsp.dataValues.value; } - async updateSetting(name: string, value: string) { - if (value === await this.getSettingsByName(name)) return await Settings.findOne({ where: { name } }); - await Settings.upsert({ name, value }); - if (name === 'webhookProxyUrl') { - setup.addToEnv({ WEBHOOK_PROXY_URL: value }); - await SmeeService.createSmeeWebhookProxy(); - } - if (name === 'webhookSecret') { - setup.addToEnv({ GITHUB_WEBHOOK_SECRET: value }); - try { - await setup.createAppFromEnv(); - } catch { - console.warn('failed to create app from env') - } + async updateSetting(name: keyof SettingsType, value: string) { + const lastValue = await this.getSettingsByName(name); + if (value === lastValue) { + return await Settings.findOne({ where: { name } }); } - if (name === 'baseUrl') { - this.baseUrl = value; + if (name === 'webhookProxyUrl') { + app.github.smee.options.url = value; + await app.github.smee.connect() + } else if (name === 'webhookSecret') { + // await app.github.connect({ + // webhooks: { + // secret: value + // } + // }) + } else if (name === 'metricsCronExpression') { + app.github.installations.forEach(install => { + install.queryService.cronJob.setTime(new CronTime(value)); + }); } - if (name === 'metricsCronExpression') QueryService.getInstance()?.updateCronJob(value); - return await Settings.findOne({ where: { name } }); + await Settings.upsert({ name, value }); + return this.getSettingsByName(name); } async updateSettings(obj: { [key: string]: string }) { Object.entries(obj).forEach(([name, value]) => { - this.updateSetting(name, value); + this.updateSetting(name as keyof SettingsType, value); }); } @@ -111,4 +94,4 @@ class SettingsService { } } -export default new SettingsService(); \ No newline at end of file +export default SettingsService; \ No newline at end of file diff --git a/backend/src/services/setup.ts b/backend/src/services/setup.ts deleted file mode 100644 index 02ab0d2..0000000 --- a/backend/src/services/setup.ts +++ /dev/null @@ -1,240 +0,0 @@ -import dotenv from 'dotenv'; -import { readFileSync } from "fs"; -import { App, createNodeMiddleware, Octokit } from "octokit"; -import { setupWebhookListeners } from '../controllers/webhook.controller.js'; -import { app as expressApp } from '../app.js'; -import { QueryService } from "./query.service.js"; -import SmeeService from './smee.js'; -import logger from "./logger.js"; -import updateDotenv from 'update-dotenv'; -import settingsService from './settings.service.js'; -import { Express } from 'express'; -import { Endpoints } from '@octokit/types'; - -interface SetupStatusDbsInitialized { - usage?: boolean; - metrics?: boolean; - copilotSeats?: boolean; - teamsAndMembers?: boolean; - [key: string]: boolean | undefined; -} -export interface SetupStatus { - isSetup?: boolean; - dbInitialized?: boolean; - dbsInitialized?: SetupStatusDbsInitialized, - installation?: Endpoints["GET /app"]["response"]['data']; -} - -class Setup { - private static instance: Setup; - app?: App; - webhooks?: Express; - installationId?: number; - installation?: Endpoints["GET /app"]["response"]['data']; - installUrl?: string; - setupStatus: SetupStatus = { - isSetup: false, - dbInitialized: false, - dbsInitialized: { - usage: false, - metrics: false, - copilotSeats: false, - teamsAndMembers: false - }, - installation: undefined - }; - - private constructor() { } - public static getInstance(): Setup { - if (!Setup.instance) { - Setup.instance = new Setup(); - } - return Setup.instance; - } - - createFromManifest = async (code: string) => { - dotenv.config(); - const _octokit = new Octokit(); - const response = await _octokit.rest.apps.createFromManifest({ code }); - const data = response.data; - - await this.addToEnv({ - GITHUB_APP_ID: data.id.toString(), - GITHUB_APP_PRIVATE_KEY: data.pem - }); - if (data.webhook_secret) { - await this.addToEnv({ - GITHUB_WEBHOOK_SECRET: data.webhook_secret, - }); - } - - return data; - } - - findFirstInstallation = async (appId: string, privateKey: string, webhookSecret: string) => { - const _app = new App({ - appId: appId, - privateKey: privateKey, - webhooks: { - secret: webhookSecret - }, - oauth: { - clientId: null!, - clientSecret: null! - } - }) - - const installation = await new Promise((resolve) => { - _app.eachInstallation((install) => - install && install.installation && install.installation.id ? resolve(install.installation) : null - ); - }); - if (!installation?.id) throw new Error('Failed to get installation'); - this.installUrl = await _app.getInstallationUrl(); - if (!this.installUrl) { - throw new Error('Failed to get installation URL'); - } - - this.installationId = installation.id; - await this.addToEnv({ - GITHUB_APP_ID: appId, - GITHUB_APP_PRIVATE_KEY: privateKey, - GITHUB_WEBHOOK_SECRET: webhookSecret, - GITHUB_APP_INSTALLATION_ID: installation.id.toString() - }) - } - - createAppFromEnv = async () => { - if (process.env.GH_APP_ID) await this.addToEnv({ GITHUB_APP_ID: process.env.GH_APP_ID }); - if (process.env.GH_APP_PRIVATE_KEY) await this.addToEnv({ GITHUB_APP_PRIVATE_KEY: process.env.GH_APP_PRIVATE_KEY }); - if (process.env.GH_APP_WEBHOOK_SECRET) await this.addToEnv({ GITHUB_WEBHOOK_SECRET: process.env.GH_APP_WEBHOOK_SECRET }); - if (process.env.GH_APP_INSTALLATION_ID) await this.addToEnv({ GITHUB_APP_INSTALLATION_ID: process.env.GH_APP_INSTALLATION_ID }); - if (!process.env.GITHUB_APP_ID) throw new Error('GITHUB_APP_ID is not set'); - if (!process.env.GITHUB_APP_PRIVATE_KEY) throw new Error('GITHUB_APP_PRIVATE_KEY is not set'); - if (!process.env.GITHUB_WEBHOOK_SECRET) throw new Error('GITHUB_WEBHOOK_SECRET is not set'); - if (!process.env.GITHUB_APP_INSTALLATION_ID) { - this.findFirstInstallation(process.env.GITHUB_APP_ID, process.env.GITHUB_APP_PRIVATE_KEY, process.env.GITHUB_WEBHOOK_SECRET); - } - const installationId = Number(process.env.GITHUB_APP_INSTALLATION_ID); - if (isNaN(installationId)) { - throw new Error('GITHUB_APP_INSTALLATION_ID is not a valid number'); - } - this.installationId = installationId; - - this.app = new App({ - appId: process.env.GITHUB_APP_ID, - privateKey: process.env.GITHUB_APP_PRIVATE_KEY, - installationId: process.env.GITHUB_APP_INSTALLATION_ID, - webhooks: { - secret: process.env.GITHUB_WEBHOOK_SECRET - }, - oauth: { - clientId: null!, - clientSecret: null! - }, - log: logger - }); - - await this.start(); - return this.app; - } - - createWebhookMiddleware = () => { - const webhookMiddlewareIndex = expressApp._router.stack.findIndex((layer: { - name: string; - }) => layer.name === 'bound middleware'); - if (webhookMiddlewareIndex > -1) { - expressApp._router.stack.splice(webhookMiddlewareIndex, 1); - } - if (this.webhooks) { - logger.debug('Webhook middleware already created'); - } - if (!this.app) { - throw new Error('App is not initialized'); - } - setupWebhookListeners(this.app); - const web = expressApp.use(createNodeMiddleware(this.app)); - return web; - }; - - addToEnv = async (obj: { [key: string]: string }) => { - await updateDotenv(obj); - Object.entries(obj).forEach(([key, value]) => { - process.env[key] = value; - }); - } - - getEnv = (key: string) => { - return process.env[key]; - } - - getOctokit = async () => { - if (!this.app || !this.installationId) { - throw new Error('App is not initialized'); - } - const octokit = await this.app.getInstallationOctokit(this.installationId); - octokit.log = logger; - return octokit; - } - - start = async () => { - if (!this.installationId) throw new Error('Installation ID is not set'); - const octokit = await this.getOctokit(); - const authenticated = await octokit.rest.apps.getAuthenticated(); - if (!authenticated.data) throw new Error('Failed to get app'); - this.installation = authenticated.data; - this.webhooks = this.createWebhookMiddleware(); - - const metricsCronExpression = await settingsService.getSettingsByName('metricsCronExpression').catch(() => { - return '0 0 * * *'; - }) || '0 0 * * *'; - const timezone = await settingsService.getSettingsByName('timezone').catch(() => { - return 'UTC'; - }) || 'UTC'; - QueryService.createInstance(metricsCronExpression, timezone); - - logger.info(`GitHub App ${this.installation.slug} is ready to use`); - } - - isSetup = () => { - return !!this.app; - } - - getSetupStatus = (): SetupStatus => { - return { - ...this.setupStatus, - isSetup: this.isSetup(), - installation: this.installation - }; - } - - setSetupStatus = (obj: SetupStatus) => { - this.setupStatus = { - ...this.setupStatus, - ...obj - }; - } - - setSetupStatusDbInitialized = (dbsInitialized: SetupStatusDbsInitialized) => { - Object.entries(dbsInitialized).forEach(([key, value]) => { - if (!this.setupStatus?.dbsInitialized) return; - if (value) { - this.setupStatus.dbsInitialized[key] = value; - } - }); - } - - getManifest = (baseUrl: string) => { - const manifest = JSON.parse(readFileSync('github-manifest.json', 'utf8')); - const base = new URL(baseUrl); - manifest.url = base.href; - manifest.hook_attributes.url = new URL('/api/github/webhooks', base).href; - manifest.setup_url = new URL('/api/setup/install/complete', base).href; - manifest.redirect_url = new URL('/api/setup/registration/complete', base).href; - manifest.hook_attributes.url = SmeeService.getWebhookProxyUrl(); - return manifest; - }; - -} - -export default Setup.getInstance(); \ No newline at end of file diff --git a/backend/src/services/smee.ts b/backend/src/services/smee.ts index 8f2ce31..0ac70fc 100644 --- a/backend/src/services/smee.ts +++ b/backend/src/services/smee.ts @@ -1,25 +1,60 @@ +import { App, createNodeMiddleware } from "octokit"; +import { Express } from "express"; import logger from "./logger.js"; -import settingsService from "./settings.service.js"; +import { setupWebhookListeners } from "../controllers/webhook.controller.js"; +import Client from "smee-client"; +import EventSource from "eventsource"; -class SmeeService { - private static instance: SmeeService; - private webhookProxyUrl?: string; - private port?: number; +export interface WebhookServiceOptions { + url?: string, + port: number, + path: string +} + +class WebhookService { + eventSource?: EventSource; + options: WebhookServiceOptions; + smee?: Client; - private constructor() { } + constructor(options: WebhookServiceOptions) { + this.options = options; + } - public static getInstance(): SmeeService { - if (!SmeeService.instance) { - SmeeService.instance = new SmeeService(); + public async connect(options?: WebhookServiceOptions) { + if (options) { + this.options = { + ...this.options, + ...options + } } - return SmeeService.instance; + + if (!this.options.url) { + this.options.url = await this.createSmeeWebhookUrl(); + } + + try { + const SmeeClient = (await import("smee-client")).default; + this.smee = new SmeeClient({ + source: this.options.url, + target: `http://localhost:${this.options.port}${this.options.path}`, + logger: { + info: (msg: string, ...args) => logger.info('Smee', msg, ...args), + error: (msg: string, ...args) => logger.error('Smee', msg, ...args), + } + }); + + this.eventSource = await this.smee.start() + } catch { + logger.error('Failed to create Smee client'); + }; + + return this.eventSource; } - getWebhookProxyUrl = () => { - if (!this.webhookProxyUrl) { - throw new Error('Webhook proxy URL is not set'); + public async disconnect() { + if (this.eventSource) { + this.eventSource.close(); } - return this.webhookProxyUrl; } private async createSmeeWebhookUrl() { @@ -27,64 +62,23 @@ class SmeeService { if (!webhookProxyUrl) { throw new Error('Unable to create webhook channel'); } - this.webhookProxyUrl = webhookProxyUrl; return webhookProxyUrl; } - public async createSmeeWebhookProxy(port?: number) { - if (!port) port = this.port; - try { - this.webhookProxyUrl = process.env.WEBHOOK_PROXY_URL || await settingsService.getSettingsByName('webhookProxyUrl'); - if (!this.webhookProxyUrl) { - throw new Error('Webhook proxy URL is not set'); - } - } catch { - this.webhookProxyUrl = await this.createSmeeWebhookUrl(); - } - let eventSource: EventSource | undefined; - try { - eventSource = await this.createWebhookProxy({ - url: this.webhookProxyUrl, - port, - path: '/api/github/webhooks' - }); - } catch (error) { - logger.error(`Unable to connect to ${this.webhookProxyUrl}. recreating webhook.`, error); - this.webhookProxyUrl = await this.createSmeeWebhookUrl(); - eventSource = await this.createWebhookProxy({ - url: this.webhookProxyUrl, - port, - path: '/api/github/webhooks' - }); - if (!eventSource) throw new Error('Unable to connect to smee.io'); - } - this.port = port; - return { url: this.webhookProxyUrl, eventSource }; - } - - createWebhookProxy = async ( - opts: { - url: string; - port?: number; - path?: string; - }, - ): Promise => { - try { - const SmeeClient = (await import("smee-client")).default; - const smee = new SmeeClient({ - source: opts.url, - target: `http://localhost:${opts.port}${opts.path}`, - logger: { - info: (msg: string, ...args) => logger.info('Smee', msg, ...args), - error: (msg: string, ...args) => logger.error('Smee', msg, ...args), - } - }); - return smee.start() as EventSource; - } catch (error) { - logger.error('Unable to connect to smee.io', error); + webhookMiddlewareCreate(app: App, e: Express) { + if (!app) throw new Error('GitHub App is not initialized') + if (!e) throw new Error('Express app is not initialized') + const webhookMiddlewareIndex = e._router.stack.findIndex((layer: { + name: string; + }) => layer.name === 'bound middleware'); + if (webhookMiddlewareIndex > -1) { + e._router.stack.splice(webhookMiddlewareIndex, 1); } + setupWebhookListeners(app); + const web = e.use(createNodeMiddleware(app)); + return web; }; } -export default SmeeService.getInstance(); \ No newline at end of file +export default WebhookService; \ No newline at end of file diff --git a/backend/src/services/survey.service.ts b/backend/src/services/survey.service.ts index 75b7651..a23c70c 100644 --- a/backend/src/services/survey.service.ts +++ b/backend/src/services/survey.service.ts @@ -1,11 +1,6 @@ import { Survey, SurveyType } from "../models/survey.model.js"; class SurveyService { - - constructor() { - - } - async createSurvey(survey: SurveyType) { return await Survey.create(survey); } diff --git a/backend/src/services/target-values.service.ts b/backend/src/services/target-values.service.ts index 0f904f8..752e7f5 100644 --- a/backend/src/services/target-values.service.ts +++ b/backend/src/services/target-values.service.ts @@ -5,7 +5,11 @@ class TargetValuesService { return await TargetValues.findAll(); } - async updateTargetValues(data: { targetedRoomForImprovement: number, targetedNumberOfDevelopers: number, targetedPercentOfTimeSaved: number }) { + async updateTargetValues(data: { + targetedRoomForImprovement: number, + targetedNumberOfDevelopers: number, + targetedPercentOfTimeSaved: number + }) { const [targetValues] = await TargetValues.findOrCreate({ where: {}, defaults: data diff --git a/backend/src/services/teams.service.ts b/backend/src/services/teams.service.ts new file mode 100644 index 0000000..49dece6 --- /dev/null +++ b/backend/src/services/teams.service.ts @@ -0,0 +1,159 @@ +import { Member, Team, TeamMemberAssociation } from "../models/teams.model.js"; +import { Endpoints } from "@octokit/types"; + +class TeamsService { + async updateTeams(org: string, teams: Endpoints["GET /orgs/{org}/teams"]["response"]["data"]) { + // First pass: Create all teams without parent relationships + for (const team of teams) { + await Team.upsert({ + org, + team: team.slug, + id: team.id, + node_id: team.node_id, + name: team.name, + slug: team.slug, + description: team.description, + privacy: team.privacy || 'unknown', + notification_setting: team.notification_setting || 'unknown', + permission: team.permission, + url: team.url, + html_url: team.html_url, + members_url: team.members_url, + repositories_url: team.repositories_url + }); + } + + // Second pass: Update parent relationships + for (const team of teams) { + if (team.parent?.id) { + await Team.update( + { parent_id: team.parent.id }, + { where: { id: team.id } } + ); + } + } + + await Team.upsert({ + org, + name: 'No Team', + slug: 'no-team', + description: 'No team assigned', + node_id: '', + permission: '', + url: '', + html_url: '', + members_url: '', + repositories_url: '', + id: -1 + }); + } + + async updateMembers(org: string, members: Endpoints["GET /orgs/{org}/teams/{team_slug}/members"]["response"]["data"], teamId?: number) { + return Promise.all(members.map(async member => { + const [dbMember] = await Member.upsert({ + org, + id: member.id, + login: member.login, + node_id: member.node_id, + avatar_url: member.avatar_url, + gravatar_id: member.gravatar_id || null, + url: member.url, + html_url: member.html_url, + followers_url: member.followers_url, + following_url: member.following_url, + gists_url: member.gists_url, + starred_url: member.starred_url, + subscriptions_url: member.subscriptions_url, + organizations_url: member.organizations_url, + repos_url: member.repos_url, + events_url: member.events_url, + received_events_url: member.received_events_url, + type: member.type, + site_admin: member.site_admin + }); + + if (teamId) { + await this.addMemberToTeam(dbMember.id, teamId); + } + })); + } + + async addMemberToTeam(teamId: number, memberId: number) { + return TeamMemberAssociation.upsert({ + TeamId: teamId, + MemberId: memberId + }); + } + + async deleteMemberFromTeam(teamId: number, memberId: number) { + const team = await Team.findByPk(teamId); + const member = await Member.findByPk(memberId); + if (!team) throw new Error(`Team with ID ${teamId} not found`) + if (!member) throw new Error(`Member with ID ${memberId} not found`) + + const deleted = await TeamMemberAssociation.destroy({ + where: { TeamId: teamId, MemberId: memberId } + }); + if (deleted === 0) { + throw new Error(`Member ${memberId} is not part of team ${teamId}`); + } + + return true; + }; + + async deleteMember(memberId: number) { + const member = await Member.findByPk(memberId); + if (!member) { + throw new Error(`Member with ID ${memberId} not found`); + } + + await TeamMemberAssociation.destroy({ + where: { + MemberId: memberId + } + }); + + await Member.destroy({ + where: { + id: memberId + } + }); + + return true; + }; + + async deleteTeam(teamId: number) { + const team = await Team.findByPk(teamId); + if (!team) { + throw new Error(`Team with ID ${teamId} not found`); + } + + await TeamMemberAssociation.destroy({ + where: { + TeamId: teamId + } + }); + + await Team.destroy({ + where: { + id: teamId + } + }); + + return true; + } + + async getLastUpdatedAt() { + const team = await Team.findOne({ + order: [ + ['updatedAt', 'DESC'] + ] + }); + if (!team?.updatedAt) { + return new Date(0); + } + return team.updatedAt; + } +} + +export default new TeamsService(); \ No newline at end of file diff --git a/compose.yml b/compose.yml index 84ea10d..8f21155 100644 --- a/compose.yml +++ b/compose.yml @@ -4,7 +4,7 @@ services: build: dockerfile: Dockerfile ports: - - "80:80" + - "8080:8080" environment: NODE_ENV: production NODE_OPTIONS: --enable-source-maps diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 88d85d0..4e72eab 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "@angular/platform-browser-dynamic": "^18.2.12", "@angular/router": "^18.2.12", "@octokit/types": "^13.6.1", + "canvas-confetti": "^1.9.3", "cronstrue": "^2.51.0", "dayjs": "^1.11.13", "highcharts": "^11.4.8", @@ -29,6 +30,7 @@ "@angular-devkit/build-angular": "^18.2.12", "@angular/cli": "^18.2.12", "@angular/compiler-cli": "^18.2.12", + "@types/canvas-confetti": "^1.6.4", "@types/jasmine": "~5.1.0", "angular-eslint": "18.4.0", "eslint": "9.14", @@ -4700,6 +4702,13 @@ "@types/node": "*" } }, + "node_modules/@types/canvas-confetti": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.6.4.tgz", + "integrity": "sha512-fNyZ/Fdw/Y92X0vv7B+BD6ysHL4xVU5dJcgzgxLdGbn8O3PezZNIJpml44lKM0nsGur+o/6+NZbZeNTt00U1uA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -6099,6 +6108,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-confetti": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.3.tgz", + "integrity": "sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 327057c..9bff941 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "@angular/platform-browser-dynamic": "^18.2.12", "@angular/router": "^18.2.12", "@octokit/types": "^13.6.1", + "canvas-confetti": "^1.9.3", "cronstrue": "^2.51.0", "dayjs": "^1.11.13", "highcharts": "^11.4.8", @@ -34,6 +35,7 @@ "@angular-devkit/build-angular": "^18.2.12", "@angular/cli": "^18.2.12", "@angular/compiler-cli": "^18.2.12", + "@types/canvas-confetti": "^1.6.4", "@types/jasmine": "~5.1.0", "angular-eslint": "18.4.0", "eslint": "9.14", diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index 64affb3..967a7b7 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -5,9 +5,10 @@ import { AppModule } from './app.module'; import './highcharts.theme'; const GITHUB_MARK = ` - - + + + + `; const GITHUB_COPILOT_MARK = ` diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 6678624..526285a 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -3,25 +3,27 @@ import { NewCopilotSurveyComponent } from './main/copilot/copilot-surveys/new-co import { CopilotSurveysComponent } from './main/copilot/copilot-surveys/copilot-surveys.component'; import { SettingsComponent } from './main/settings/settings.component'; import { InstallComponent } from './install/install.component'; -import { SetupGuard } from './guards/setup.guard'; +import { SetupStatusGuard } from './guards/setup.guard'; import { MainComponent } from './main/main.component'; import { CopilotDashboardComponent } from './main/copilot/copilot-dashboard/dashboard.component'; import { CopilotValueComponent } from './main/copilot/copilot-value/value.component'; import { CopilotMetricsComponent } from './main/copilot/copilot-metrics/copilot-metrics.component'; import { CopilotSeatsComponent } from './main/copilot/copilot-seats/copilot-seats.component'; -import { DbLoadingComponent } from './install/db-loading/db-loading.component'; +import { DbLoadingComponent } from './database/db-loading.component'; import { CopilotSurveyComponent } from './main/copilot/copilot-surveys/copilot-survey-details/copilot-survey.component'; import { CopilotSeatComponent } from './main/copilot/copilot-seats/copilot-seat/copilot-seat.component'; import { PredictiveModelingComponent } from './main/copilot/predictive-modeling/predictive-modeling.component'; +import { DatabaseComponent } from './database/database.component'; export const routes: Routes = [ { path: 'setup', component: InstallComponent }, - { path: 'setup/loading', component: DbLoadingComponent }, + { path: 'setup/loading', component: DbLoadingComponent, canActivate: [SetupStatusGuard] }, + { path: 'setup/db', component: DatabaseComponent }, { path: '', component: MainComponent, - canActivate: [SetupGuard], - canActivateChild: [SetupGuard], + canActivate: [SetupStatusGuard], + canActivateChild: [SetupStatusGuard], children: [ { path: 'copilot', component: CopilotDashboardComponent, title: 'Dashboard' }, { path: 'copilot/value', component: CopilotValueComponent, title: 'Value' }, diff --git a/frontend/src/app/database/confetti.service.ts b/frontend/src/app/database/confetti.service.ts new file mode 100644 index 0000000..feefa63 --- /dev/null +++ b/frontend/src/app/database/confetti.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@angular/core'; +import confetti from 'canvas-confetti'; + +@Injectable({ + providedIn: 'root' +}) +export class ConfettiService { + celebrate() { + const duration = 15 * 1000; + const animationEnd = Date.now() + duration; + const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 999 }; + + function randomInRange(min: number, max: number) { + return Math.random() * (max - min) + min; + } + + const interval = setInterval(function () { + const timeLeft = animationEnd - Date.now(); + + if (timeLeft <= 0) { + return clearInterval(interval); + } + + const particleCount = 50 * (timeLeft / duration); + // since particles fall down, start a bit higher than random + confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 } }); + confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 } }); + }, 250); + } +} diff --git a/frontend/src/app/database/database.component.html b/frontend/src/app/database/database.component.html new file mode 100644 index 0000000..7795f2a --- /dev/null +++ b/frontend/src/app/database/database.component.html @@ -0,0 +1,57 @@ +
+ + +
+

Enter your database credentials

+
+ Database + + Hostname + + + + Port + + + + Username + + + + Password + + +
+
+
+ +
+
+
+
+ +
+ +
+
+ +

All Setup!

+
+ + + + table + + + + + + rocket_launch + +
+
\ No newline at end of file diff --git a/frontend/src/app/database/database.component.scss b/frontend/src/app/database/database.component.scss new file mode 100644 index 0000000..f6b833e --- /dev/null +++ b/frontend/src/app/database/database.component.scss @@ -0,0 +1,25 @@ +:host { + display: block; + width: 100%; + height: 100%; +} + +.stepper-container { + padding: 20px; +} + +.mat-horizontal-stepper-header-container { + margin-bottom: 40px; +} + +.stepper-content { + margin: 10px 0px; + width: 100%; + height: 100%; +} + +.database-form { + display: flex; + flex-direction: column; + width: 100%; +} \ No newline at end of file diff --git a/frontend/src/app/database/database.component.ts b/frontend/src/app/database/database.component.ts new file mode 100644 index 0000000..41d5576 --- /dev/null +++ b/frontend/src/app/database/database.component.ts @@ -0,0 +1,108 @@ +import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ViewChild } from '@angular/core'; +import { MatStepper, MatStepperModule } from '@angular/material/stepper'; +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { STEPPER_GLOBAL_OPTIONS } from '@angular/cdk/stepper'; +import { MatButtonModule } from '@angular/material/button'; +import { MatInputModule } from '@angular/material/input'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { InstallComponent } from '../install/install.component'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; +import { finalize, takeWhile, timer } from 'rxjs'; +import { InstallationsService, statusResponse } from '../services/api/installations.service'; +import { SetupService } from '../services/api/setup.service'; + +@Component({ + selector: 'app-database', + standalone: true, + imports: [ + MatButtonModule, + MatStepperModule, + MatInputModule, + MatFormFieldModule, + FormsModule, + ReactiveFormsModule, + MatIconModule, + InstallComponent, + MatProgressSpinnerModule, + CommonModule + ], + providers: [ + { + provide: STEPPER_GLOBAL_OPTIONS, + useValue: { displayDefaultIndicatorType: false }, + }, + ], + templateUrl: './database.component.html', + styleUrl: './database.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DatabaseComponent implements AfterViewInit { + @ViewChild('stepper') private stepper!: MatStepper; + status?: statusResponse; + isDbConnecting = false; + dbFormGroup = new FormGroup({ + hostname: new FormControl('', Validators.required), + port: new FormControl(3306, [Validators.required, Validators.min(1), Validators.max(65535)]), + username: new FormControl('root', Validators.required), + password: new FormControl(''), + }); + + constructor( + private cdr: ChangeDetectorRef, + private installationService: InstallationsService, + private router: Router, + private setupService: SetupService + ) { } + + ngAfterViewInit() { + timer(0, 1000).pipe( + takeWhile(() => { + return !this.status || !this.status.isSetup; + }), + finalize(async () => { + await this.router.navigate(['/copilot'], { + queryParams: { celebrate: true } + }) + }) + ).subscribe(() => this.checkStatus()); + } + + dbConnect() { + if(this.dbFormGroup.invalid) return; + this.isDbConnecting = true; + this.setupService.setupDB({ + host: this.dbFormGroup.value.hostname!, + port: this.dbFormGroup.value.port!, + username: this.dbFormGroup.value.username!, + password: this.dbFormGroup.value.password!, + }).subscribe(() => { + this.isDbConnecting = false; + this.cdr.detectChanges(); + this.checkStatus(); + }); + } + + checkStatus() { + this.installationService.refreshStatus().subscribe(status => { + this.status = status; + if (this.status.dbConnected && this.stepper.selectedIndex === 0) { + const step = this.stepper.steps.get(0); + if (step) step.completed = true; + this.stepper.next(); + } + if (this.status.isSetup && this.stepper.selectedIndex === 1) { + const step = this.stepper.steps.get(1); + if (step) step.completed = true; + this.stepper.next(); + } + if (this.status.isSetup && this.stepper.selectedIndex === 2) { + const step = this.stepper.steps.get(2); + if (step) step.interacted = true; + } + }) + } + +} diff --git a/frontend/src/app/install/db-loading/db-loading.component.ts b/frontend/src/app/database/db-loading.component.ts similarity index 70% rename from frontend/src/app/install/db-loading/db-loading.component.ts rename to frontend/src/app/database/db-loading.component.ts index eaedccb..ed75d3b 100644 --- a/frontend/src/app/install/db-loading/db-loading.component.ts +++ b/frontend/src/app/database/db-loading.component.ts @@ -1,8 +1,9 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; -import { SetupService } from '../../services/setup.service'; +import { InstallationStatus } from '../services/api/setup.service'; import { Router } from '@angular/router'; -import { Subscription, timer } from 'rxjs'; +import { finalize, Subscription, takeWhile, timer } from 'rxjs'; import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { InstallationsService } from '../services/api/installations.service'; @Component({ selector: 'app-db-loading', @@ -90,37 +91,47 @@ export class DbLoadingComponent implements OnInit, OnDestroy { private statusSubscription?: Subscription; statusText = 'Please wait while we set up your database...'; statusProgress = 0; - dbStatus = { + dbStatus: InstallationStatus = { usage: false, metrics: false, copilotSeats: false, - teamsAndMembers: false + teamsAndMembers: false, + installation: undefined }; constructor( - private setupService: SetupService, + private installationsService: InstallationsService, private router: Router ) { } ngOnInit(): void { - this.statusSubscription = timer(0, 5000) - .subscribe(() => { - this.setupService.getSetupStatus().subscribe((response) => { - if (!response.isSetup) { - this.router.navigate(['/setup']); - return; - } - if (response.dbsInitialized) { - this.dbStatus = response.dbsInitialized; - this.updateProgress(); - } + this.statusSubscription = timer(0, 5000).pipe( + takeWhile(() => Object.values(this.dbStatus).every(value => value)), + finalize(() => this.router.navigate(['/'])) + ).subscribe(() => { + this.installationsService.refreshStatus().subscribe((response) => { + if (!response.isSetup) { + this.statusSubscription?.unsubscribe(); + this.router.navigate(['/setup/db']); + return; + } - if (response.dbInitialized) { - this.statusSubscription?.unsubscribe() - this.router.navigate(['/']); // 🚀 Navigate - } - }); + this.dbStatus = response.installations.reduce((acc, intallation) => { + acc.usage = acc.usage || intallation.usage; + acc.metrics = acc.metrics || intallation.metrics; + acc.copilotSeats = acc.copilotSeats || intallation.copilotSeats; + acc.teamsAndMembers = acc.teamsAndMembers || intallation.teamsAndMembers; + return acc; + }, { + usage: false, + metrics: false, + copilotSeats: false, + teamsAndMembers: false + }) + + this.updateProgress(); }); + }); } private updateProgress(): void { diff --git a/frontend/src/app/guards/setup.guard.ts b/frontend/src/app/guards/setup.guard.ts index 8f8c573..e9f8b5d 100644 --- a/frontend/src/app/guards/setup.guard.ts +++ b/frontend/src/app/guards/setup.guard.ts @@ -1,48 +1,39 @@ import { Injectable, isDevMode } from '@angular/core'; -import { CanActivate, CanActivateChild, GuardResult, MaybeAsync, Router } from '@angular/router'; +import { CanActivate, GuardResult, MaybeAsync, Router } from '@angular/router'; import { of } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; -import { SetupService, SetupStatusResponse } from '../services/setup.service'; +import { InstallationsService } from '../services/api/installations.service'; @Injectable({ providedIn: 'root' }) -export class SetupGuard implements CanActivate, CanActivateChild { - cache: SetupStatusResponse = { - isSetup: false, - dbInitialized: false - }; - +export class SetupStatusGuard implements CanActivate { constructor( - private setupService: SetupService, + private installationsService: InstallationsService, private router: Router - ) { } + ) {} canActivate(): MaybeAsync { - if (this.cache.isSetup && this.cache.dbInitialized) { - return of(true); - } - return this.setupService.getSetupStatus().pipe( + return this.installationsService.getStatus().pipe( map((response) => { - this.cache = response; - if (!response.isSetup) { - this.router.navigate(['/setup']) - return false; - } - if (!response.dbInitialized && !isDevMode()) { + if (!response.dbConnected) throw new Error('DB not connected'); + if (!response.installations?.some(i => Object.values(i).some(j => !j)) && !isDevMode()) { this.router.navigate(['/setup/loading']); return false; } + if (!response.isSetup) { + throw new Error('Not setup'); + } return true; }), catchError(() => { - this.router.navigate(['/setup']); + this.router.navigate(['/setup/db']); return of(false); }) ); } - canActivateChild(): MaybeAsync { + canActivateChild() { return this.canActivate(); } -} +} \ No newline at end of file diff --git a/frontend/src/app/highcharts.theme.ts b/frontend/src/app/highcharts.theme.ts index a31be56..1b488a3 100644 --- a/frontend/src/app/highcharts.theme.ts +++ b/frontend/src/app/highcharts.theme.ts @@ -278,8 +278,8 @@ const theme: Highcharts.Options = { } } }, - } as any - }, + } + } as Highcharts.NavigationButtonOptions, menuStyle: { background: 'var(--sys-surface-container)', color: 'var(--sys-on-surface)', diff --git a/frontend/src/app/install/install.component.html b/frontend/src/app/install/install.component.html index 2108e80..02598a2 100644 --- a/frontend/src/app/install/install.component.html +++ b/frontend/src/app/install/install.component.html @@ -8,8 +8,8 @@

GitHub Value App

GitHub Mark + 'assets/images/github-mark-white.svg' : + 'assets/images/github-mark.svg'" alt="GitHub Mark" draggable="false">

@@ -21,5 +21,4 @@

GitHub Value App

- -https://github.com/settings/apps/manifest \ No newline at end of file + \ No newline at end of file diff --git a/frontend/src/app/install/install.component.ts b/frontend/src/app/install/install.component.ts index 3993419..7ffd5c5 100644 --- a/frontend/src/app/install/install.component.ts +++ b/frontend/src/app/install/install.component.ts @@ -1,10 +1,9 @@ -import { Component, ElementRef, Inject, OnInit, ViewChild } from '@angular/core'; +import { Component, ElementRef, EventEmitter, Inject, Output, ViewChild } from '@angular/core'; import { MaterialModule } from '../material.module'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; -import { SetupService } from '../services/setup.service'; +import { SetupService } from '../services/api/setup.service'; import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; import { CommonModule } from '@angular/common'; -import { Router } from '@angular/router'; import { ClipboardModule } from '@angular/cdk/clipboard'; import { ThemeService } from '../services/theme.service'; @@ -20,31 +19,20 @@ import { ThemeService } from '../services/theme.service'; templateUrl: './install.component.html', styleUrl: './install.component.scss' }) -export class InstallComponent implements OnInit { +export class InstallComponent { + @Output() finishedChange = new EventEmitter(); constructor( public themeService: ThemeService, - public dialog: MatDialog, - private router: Router, - private setupService: SetupService + public dialog: MatDialog ) { } - ngOnInit(): void { - this.checkIfSetup(); - } - - checkIfSetup(): void { - this.setupService.getSetupStatus().subscribe((response) => { - if (response.isSetup) this.router.navigate(['/']); - }); - } - openDialog(existingApp: boolean): void { this.dialog.open(DialogAppComponent, { width: '400px', data: existingApp }).afterClosed().subscribe(() => { - this.checkIfSetup(); + this.finishedChange.emit(); }); } } @@ -108,7 +96,7 @@ export class DialogAppComponent { registerNewApp() { if (this.organizationFormControl.value) { - this.form.nativeElement.action = `https://github.com/organizations/${this.organizationFormControl.value}/settings/apps/new?state=abc123` + this.form.nativeElement.action = `https://github.com/enterprises/${this.organizationFormControl.value}/settings/apps/new?state=abc123` } this.form.nativeElement.submit(); this.dialogRef.close(); diff --git a/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/active-users-chart/active-users-chart.component.ts b/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/active-users-chart/active-users-chart.component.ts index 51bacc9..3bbdbcd 100644 --- a/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/active-users-chart/active-users-chart.component.ts +++ b/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/active-users-chart/active-users-chart.component.ts @@ -1,4 +1,4 @@ -import { Component, Input } from '@angular/core'; +import { Component, Input, OnChanges } from '@angular/core'; import { HighchartsChartModule } from 'highcharts-angular'; import * as Highcharts from 'highcharts'; import { Router } from '@angular/router'; @@ -14,7 +14,7 @@ import { Router } from '@angular/router'; style="width: 200px; height: 200px;"> ` }) -export class ActiveUsersChartComponent { +export class ActiveUsersChartComponent implements OnChanges { @Input() data?: Record; @Input() chartOptions?: Highcharts.Options; Highcharts: typeof Highcharts = Highcharts; diff --git a/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/dashboard-card-bars/dashboard-card-bars.component.ts b/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/dashboard-card-bars/dashboard-card-bars.component.ts index 0e1b5c5..4e9d264 100644 --- a/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/dashboard-card-bars/dashboard-card-bars.component.ts +++ b/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/dashboard-card-bars/dashboard-card-bars.component.ts @@ -3,7 +3,7 @@ import { Component, Input, OnChanges } from '@angular/core'; import { MatCardModule } from '@angular/material/card'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { CopilotMetrics } from '../../../../../services/metrics.service.interfaces'; +import { CopilotMetrics } from '../../../../../services/api/metrics.service.interfaces'; import { HighchartsService } from '../../../../../services/highcharts.service'; import { LoadingSpinnerComponent } from '../../../../../shared/loading-spinner/loading-spinner.component'; diff --git a/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/dashboard-card-drilldown-bar-chart/dashboard-card-drilldown-bar-chart.component.ts b/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/dashboard-card-drilldown-bar-chart/dashboard-card-drilldown-bar-chart.component.ts index b5940ed..60488c8 100644 --- a/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/dashboard-card-drilldown-bar-chart/dashboard-card-drilldown-bar-chart.component.ts +++ b/frontend/src/app/main/copilot/copilot-dashboard/dashboard-card/dashboard-card-drilldown-bar-chart/dashboard-card-drilldown-bar-chart.component.ts @@ -6,7 +6,7 @@ HC_drilldown(Highcharts); import { HighchartsChartModule } from 'highcharts-angular'; import { CommonModule } from '@angular/common'; import { HighchartsService } from '../../../../../services/highcharts.service'; -import { CopilotMetrics } from '../../../../../services/metrics.service.interfaces'; +import { CopilotMetrics } from '../../../../../services/api/metrics.service.interfaces'; import { LoadingSpinnerComponent } from '../../../../../shared/loading-spinner/loading-spinner.component'; @Component({ diff --git a/frontend/src/app/main/copilot/copilot-dashboard/dashboard.component.ts b/frontend/src/app/main/copilot/copilot-dashboard/dashboard.component.ts index e542092..d8f468a 100644 --- a/frontend/src/app/main/copilot/copilot-dashboard/dashboard.component.ts +++ b/frontend/src/app/main/copilot/copilot-dashboard/dashboard.component.ts @@ -1,19 +1,20 @@ -import { Component, OnInit } from '@angular/core'; +import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; import { AppModule } from '../../../app.module'; import { DashboardCardBarsComponent } from "./dashboard-card/dashboard-card-bars/dashboard-card-bars.component"; import { DashboardCardValueComponent } from './dashboard-card/dashboard-card-value/dashboard-card-value.component'; import { DashboardCardDrilldownBarChartComponent } from './dashboard-card/dashboard-card-drilldown-bar-chart/dashboard-card-drilldown-bar-chart.component'; -import { MetricsService } from '../../../services/metrics.service'; -import { CopilotMetrics } from '../../../services/metrics.service.interfaces'; -import { ActivityResponse, Seat, SeatService } from '../../../services/seat.service'; -import { MembersService } from '../../../services/members.service'; -import { CopilotSurveyService, Survey } from '../../../services/copilot-survey.service'; -import { forkJoin } from 'rxjs'; +import { MetricsService } from '../../../services/api/metrics.service'; +import { CopilotMetrics } from '../../../services/api/metrics.service.interfaces'; +import { ActivityResponse, Seat, SeatService } from '../../../services/api/seat.service'; +import { MembersService } from '../../../services/api/members.service'; +import { CopilotSurveyService, Survey } from '../../../services/api/copilot-survey.service'; +import { forkJoin, takeUntil } from 'rxjs'; import { AdoptionChartComponent } from '../copilot-value/adoption-chart/adoption-chart.component'; import { DailyActivityChartComponent } from '../copilot-value/daily-activity-chart/daily-activity-chart.component'; import { TimeSavedChartComponent } from '../copilot-value/time-saved-chart/time-saved-chart.component'; import { LoadingSpinnerComponent } from '../../../shared/loading-spinner/loading-spinner.component'; import { ActiveUsersChartComponent } from './dashboard-card/active-users-chart/active-users-chart.component'; +import { InstallationsService } from '../../../services/api/installations.service'; @Component({ selector: 'app-dashboard', @@ -90,7 +91,9 @@ export class CopilotDashboardComponent implements OnInit { private metricsService: MetricsService, private membersService: MembersService, private seatService: SeatService, - private surveyService: CopilotSurveyService + private surveyService: CopilotSurveyService, + private installationsService: InstallationsService, + private cdr: ChangeDetectorRef ) { } ngOnInit() { @@ -98,54 +101,78 @@ export class CopilotDashboardComponent implements OnInit { since.setDate(since.getDate() - 30); const formattedSince = since.toISOString().split('T')[0]; - this.surveyService.getAllSurveys().subscribe(data => { - this.surveysData = data; - this.totalSurveys = data.length; - this.totalSurveysThisWeek = data.reduce((acc, survey) => { - const surveyDate = new Date(survey.createdAt!); - const oneWeekAgo = new Date(); - oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); - return surveyDate > oneWeekAgo ? acc + 1 : acc; - }, 0); - }); + this.installationsService.currentInstallation.pipe( + takeUntil(this.installationsService.destroy$) + ).subscribe(installation => { + this.activityTotals = undefined; + this.allSeats = undefined; + this.totalMembers = undefined; + this.totalSeats = undefined; + this.seatPercentage = undefined; + this.activeToday = undefined; + this.activeWeeklyChangePercent = undefined; + this.activeCurrentWeekAverage = undefined; + this.activeLastWeekAverage = undefined; + this.totalSurveys = undefined; + this.totalSurveysThisWeek = undefined; + this.metricsData = undefined; + this.activityData = undefined; + this.surveysData = undefined; + + this.surveyService.getAllSurveys().subscribe(data => { + this.surveysData = data; + this.totalSurveys = data.length; + this.totalSurveysThisWeek = data.reduce((acc, survey) => { + const surveyDate = new Date(survey.createdAt!); + const oneWeekAgo = new Date(); + oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); + return surveyDate > oneWeekAgo ? acc + 1 : acc; + }, 0); + this.cdr.detectChanges(); + }); - forkJoin({ - members: this.membersService.getAllMembers(), - seats: this.seatService.getAllSeats() - }).subscribe(result => { - this.allSeats = result.seats; - this.totalMembers = result.members.length; - this.totalSeats = result.seats.length; - this.seatPercentage = (this.totalSeats / this.totalMembers) * 100; - }); + forkJoin({ + members: this.membersService.getAllMembers(), + seats: this.seatService.getAllSeats(installation?.account?.login) + }).subscribe(result => { + this.allSeats = result.seats; + this.totalMembers = result.members.length; + this.totalSeats = result.seats.length; + this.seatPercentage = (this.totalSeats / this.totalMembers) * 100; + }); - this.seatService.getActivity(30).subscribe((activity) => { - this.activityData = activity; - }) + this.seatService.getActivity(installation?.account?.login, 30).subscribe((activity) => { + this.activityData = activity; + this.cdr.detectChanges(); + }) - this.seatService.getActivityTotals().subscribe(totals => { - Object.keys(totals).forEach((key, index) => index > 10 ? delete totals[key] : null); - this.activityTotals = totals; - }); + this.seatService.getActivityTotals(installation?.account?.login).subscribe(totals => { + Object.keys(totals).forEach((key, index) => index > 10 ? delete totals[key] : null); + this.activityTotals = totals; + this.cdr.detectChanges(); + }); - this.metricsService.getMetrics({ - since: formattedSince, - }).subscribe(data => { - this.metricsData = data; - this.activeToday = data[data.length - 1].total_active_users; - const currentWeekData = data.slice(-7); - this.activeCurrentWeekAverage = currentWeekData.reduce((sum, day) => - sum + day.total_active_users, 0) / currentWeekData.length; - const lastWeekData = data.slice(-14, -7); - this.activeLastWeekAverage = lastWeekData.length > 0 - ? lastWeekData.reduce((sum, day) => sum + day.total_active_users, 0) / lastWeekData.length - : 0; + this.metricsService.getMetrics({ + org: installation?.account?.login, + since: formattedSince, + }).subscribe(data => { + this.metricsData = data; + this.activeToday = data[data.length - 1]?.total_active_users || 0; + const currentWeekData = data.slice(-7); + this.activeCurrentWeekAverage = currentWeekData.reduce((sum, day) => + sum + day.total_active_users, 0) / currentWeekData.length; + const lastWeekData = data.slice(-14, -7); + this.activeLastWeekAverage = lastWeekData.length > 0 + ? lastWeekData.reduce((sum, day) => sum + day.total_active_users, 0) / lastWeekData.length + : 0; - const percentChange = this.activeLastWeekAverage === 0 - ? 100 - : ((this.activeCurrentWeekAverage - this.activeLastWeekAverage) / this.activeLastWeekAverage) * 100; + const percentChange = this.activeLastWeekAverage === 0 + ? 100 + : ((this.activeCurrentWeekAverage - this.activeLastWeekAverage) / this.activeLastWeekAverage) * 100; - this.activeWeeklyChangePercent = Math.round(percentChange * 10) / 10; + this.activeWeeklyChangePercent = Math.round(percentChange * 10) / 10; + this.cdr.detectChanges(); + }); }); } } diff --git a/frontend/src/app/main/copilot/copilot-metrics/copilot-metrics-pie-chart/copilot-metrics-pie-chart.component.ts b/frontend/src/app/main/copilot/copilot-metrics/copilot-metrics-pie-chart/copilot-metrics-pie-chart.component.ts index 3708053..6268aaa 100644 --- a/frontend/src/app/main/copilot/copilot-metrics/copilot-metrics-pie-chart/copilot-metrics-pie-chart.component.ts +++ b/frontend/src/app/main/copilot/copilot-metrics/copilot-metrics-pie-chart/copilot-metrics-pie-chart.component.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges } from '@angular/core'; -import { CopilotMetrics } from '../../../../services/metrics.service.interfaces'; +import { CopilotMetrics } from '../../../../services/api/metrics.service.interfaces'; import * as Highcharts from 'highcharts'; import { HighchartsChartModule } from 'highcharts-angular'; import { HighchartsService } from '../../../../services/highcharts.service'; diff --git a/frontend/src/app/main/copilot/copilot-metrics/copilot-metrics.component.ts b/frontend/src/app/main/copilot/copilot-metrics/copilot-metrics.component.ts index ecfb3e5..42c0b86 100644 --- a/frontend/src/app/main/copilot/copilot-metrics/copilot-metrics.component.ts +++ b/frontend/src/app/main/copilot/copilot-metrics/copilot-metrics.component.ts @@ -1,9 +1,11 @@ -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { DateRangeSelectComponent } from "../../../shared/date-range-select/date-range-select.component"; -import { MetricsService } from '../../../services/metrics.service'; -import { CopilotMetrics } from '../../../services/metrics.service.interfaces'; +import { MetricsService } from '../../../services/api/metrics.service'; +import { CopilotMetrics } from '../../../services/api/metrics.service.interfaces'; import { CopilotMetricsPieChartComponent } from './copilot-metrics-pie-chart/copilot-metrics-pie-chart.component'; import { MatCardModule } from '@angular/material/card'; +import { Installation, InstallationsService } from '../../../services/api/installations.service'; +import { takeUntil } from 'rxjs'; @Component({ selector: 'app-metrics', @@ -19,26 +21,39 @@ import { MatCardModule } from '@angular/material/card'; '../copilot-dashboard/dashboard.component.scss' ] }) -export class CopilotMetricsComponent { +export class CopilotMetricsComponent implements OnInit { metrics?: CopilotMetrics[]; metricsTotals?: CopilotMetrics; + installation?: Installation = undefined; constructor( - private metricsService: MetricsService + private metricsService: MetricsService, + private installationsService: InstallationsService ) { } + ngOnInit() { + this.installationsService.currentInstallation.pipe( + takeUntil(this.installationsService.destroy$) + ).subscribe(installation => { + this.installation = installation; + }); + } + dateRangeChange(event: {start: Date, end: Date}) { const utcStart = Date.UTC(event.start.getFullYear(), event.start.getMonth(), event.start.getDate()); const utcEnd = Date.UTC(event.end.getFullYear(), event.end.getMonth(), event.end.getDate()); const startModified = new Date(utcStart - 1); const endModified = new Date(utcEnd + 1); + this.metricsService.getMetrics({ + org: this.installation?.account?.login, since: startModified.toISOString(), until: endModified.toISOString() }).subscribe((metrics) => { this.metrics = metrics; }); this.metricsService.getMetricsTotals({ + org: this.installation?.account?.login, since: startModified.toISOString(), until: endModified.toISOString() }).subscribe((metricsTotals) => { diff --git a/frontend/src/app/main/copilot/copilot-seats/copilot-seat/copilot-seat.component.ts b/frontend/src/app/main/copilot/copilot-seats/copilot-seat/copilot-seat.component.ts index 745f64d..d8f72e9 100644 --- a/frontend/src/app/main/copilot/copilot-seats/copilot-seat/copilot-seat.component.ts +++ b/frontend/src/app/main/copilot/copilot-seats/copilot-seat/copilot-seat.component.ts @@ -3,7 +3,7 @@ import * as Highcharts from 'highcharts'; import HC_gantt from 'highcharts/modules/gantt'; HC_gantt(Highcharts); import { HighchartsChartModule } from 'highcharts-angular'; -import { Seat, SeatService } from '../../../../services/seat.service'; +import { Seat, SeatService } from '../../../../services/api/seat.service'; import { ActivatedRoute } from '@angular/router'; import { HighchartsService } from '../../../../services/highcharts.service'; import { MatCardModule } from '@angular/material/card'; diff --git a/frontend/src/app/main/copilot/copilot-seats/copilot-seats.component.ts b/frontend/src/app/main/copilot/copilot-seats/copilot-seats.component.ts index 6a3040a..08f3419 100644 --- a/frontend/src/app/main/copilot/copilot-seats/copilot-seats.component.ts +++ b/frontend/src/app/main/copilot/copilot-seats/copilot-seats.component.ts @@ -1,8 +1,10 @@ import { Component, OnInit } from '@angular/core'; import { ColumnOptions, TableComponent } from '../../../shared/table/table.component'; -import { Seat, SeatService } from '../../../services/seat.service'; +import { Seat, SeatService } from '../../../services/api/seat.service'; import { SortDirection } from '@angular/material/sort'; import { Router } from '@angular/router'; +import { InstallationsService } from '../../../services/api/installations.service'; +import { takeUntil } from 'rxjs'; @Component({ selector: 'app-seats', @@ -70,12 +72,17 @@ export class CopilotSeatsComponent implements OnInit { constructor( private seatsService: SeatService, - private router: Router + private router: Router, + private installationsService: InstallationsService ) {} ngOnInit() { - this.seatsService.getAllSeats().subscribe(seats => { - this.seats = seats as Seat[]; + this.installationsService.currentInstallation.pipe( + takeUntil(this.installationsService.destroy$) + ).subscribe(installation => { + this.seatsService.getAllSeats(installation?.account?.login).subscribe(seats => { + this.seats = seats as Seat[]; + }); }); } diff --git a/frontend/src/app/main/copilot/copilot-surveys/copilot-survey-details/copilot-survey.component.html b/frontend/src/app/main/copilot/copilot-surveys/copilot-survey-details/copilot-survey.component.html index 1fd32ca..72cd19c 100644 --- a/frontend/src/app/main/copilot/copilot-surveys/copilot-survey-details/copilot-survey.component.html +++ b/frontend/src/app/main/copilot/copilot-surveys/copilot-survey-details/copilot-survey.component.html @@ -20,7 +20,7 @@

Estimate of Copilot Impact #{{ survey.id }}

Reason: {{ survey.reason }}

Time Used For: {{ survey.timeUsedFor }}

-

PR: {{ survey.repo }}#{{ survey.prNumber }}

+

PR: {{ survey.repo }}#{{ survey.prNumber }}

diff --git a/frontend/src/app/main/copilot/copilot-surveys/copilot-survey-details/copilot-survey.component.ts b/frontend/src/app/main/copilot/copilot-surveys/copilot-survey-details/copilot-survey.component.ts index 25d61c6..2b40eb8 100644 --- a/frontend/src/app/main/copilot/copilot-surveys/copilot-survey-details/copilot-survey.component.ts +++ b/frontend/src/app/main/copilot/copilot-surveys/copilot-survey-details/copilot-survey.component.ts @@ -1,6 +1,6 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { CopilotSurveyService, Survey } from '../../../../services/copilot-survey.service'; +import { CopilotSurveyService, Survey } from '../../../../services/api/copilot-survey.service'; import { MatCardModule } from '@angular/material/card'; import { CommonModule } from '@angular/common'; import { MatChipsModule } from '@angular/material/chips'; diff --git a/frontend/src/app/main/copilot/copilot-surveys/copilot-surveys.component.ts b/frontend/src/app/main/copilot/copilot-surveys/copilot-surveys.component.ts index 2fde90e..5a60cf8 100644 --- a/frontend/src/app/main/copilot/copilot-surveys/copilot-surveys.component.ts +++ b/frontend/src/app/main/copilot/copilot-surveys/copilot-surveys.component.ts @@ -1,9 +1,11 @@ import { Component, OnInit } from '@angular/core'; import { MaterialModule } from '../../../material.module'; import { AppModule } from '../../../app.module'; -import { CopilotSurveyService, Survey } from '../../../services/copilot-survey.service'; +import { CopilotSurveyService, Survey } from '../../../services/api/copilot-survey.service'; import { ColumnOptions } from '../../../shared/table/table.component'; import { Router } from '@angular/router'; +import { InstallationsService } from '../../../services/api/installations.service'; +import { takeUntil } from 'rxjs'; @Component({ selector: 'app-copilot-surveys', @@ -23,7 +25,7 @@ export class CopilotSurveysComponent implements OnInit { { columnDef: 'usedCopilot', header: 'Used Copilot', cell: (element: Survey) => element.status === 'pending' ? 'more_horiz' : element.usedCopilot ? 'svg:github-copilot' : 'close', isIcon: true, iconColor: () => 'var(--sys-on-surface)' }, { columnDef: 'percentTimeSaved', header: 'Time Saved', cell: (element: Survey) => element.percentTimeSaved < 0 ? '-' : `${element.percentTimeSaved}%` }, { columnDef: 'timeUsedFor', header: 'Time Used For', cell: (element: Survey) => this.formatTimeUsedFor(element.timeUsedFor) }, - { columnDef: 'prNumber', header: 'PR', cell: (element: Survey) => `${element.repo}#${element.prNumber}`, link: (element: Survey) => `https://github.com/${element.owner}/${element.repo}/pull/${element.prNumber}` }, + { columnDef: 'prNumber', header: 'PR', cell: (element: Survey) => `${element.repo}#${element.prNumber}`, link: (element: Survey) => `https://github.com/${element.org}/${element.repo}/pull/${element.prNumber}` }, { columnDef: 'createdAt', header: 'Updated', cell: (element: Survey) => new Date(element.updatedAt!).toLocaleString([], { dateStyle: 'short', timeStyle: 'short' }) }, { columnDef: 'status', header: 'Status', cell: (element: Survey) => `${element.status}`, chipList: true, chipListIcon: (el: Survey) => el.status === 'pending' ? 'pending' : el.status === 'completed' ? 'check' : 'close' }, // { columnDef: 'reason', header: 'Reason', cell: (element: Survey) => element.reason || '-' }, @@ -31,12 +33,17 @@ export class CopilotSurveysComponent implements OnInit { constructor( private copilotSurveyService: CopilotSurveyService, - private router: Router + private router: Router, + private installationsService: InstallationsService ) { } ngOnInit() { - this.copilotSurveyService.getAllSurveys().subscribe((surveys) => { - this.surveys = surveys; + this.installationsService.currentInstallation.pipe( + takeUntil(this.installationsService.destroy$) + ).subscribe(installation => { + this.copilotSurveyService.getAllSurveys(installation?.account?.login).subscribe((surveys) => { + this.surveys = surveys; + }); }); } diff --git a/frontend/src/app/main/copilot/copilot-surveys/new-copilot-survey/new-copilot-survey.component.ts b/frontend/src/app/main/copilot/copilot-surveys/new-copilot-survey/new-copilot-survey.component.ts index 5796b4e..00959df 100644 --- a/frontend/src/app/main/copilot/copilot-surveys/new-copilot-survey/new-copilot-survey.component.ts +++ b/frontend/src/app/main/copilot/copilot-surveys/new-copilot-survey/new-copilot-survey.component.ts @@ -1,7 +1,7 @@ import { Component, forwardRef, OnInit } from '@angular/core'; import { AppModule } from '../../../../app.module'; import { FormBuilder, FormControl, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; -import { CopilotSurveyService } from '../../../../services/copilot-survey.service'; +import { CopilotSurveyService } from '../../../../services/api/copilot-survey.service'; import { ActivatedRoute, Params } from '@angular/router'; @Component({ @@ -65,22 +65,24 @@ export class NewCopilotSurveyComponent implements OnInit { try { urlObj = new URL(url); } catch { - return { owner: '', repo: '', prNumber: NaN }; + return { org: '', repo: '', prNumber: NaN }; } const pathSegments = urlObj.pathname.split('/'); - - const owner = pathSegments[1]; const repo = pathSegments[2]; const prNumber = Number(pathSegments[4]); - return { owner, repo, prNumber }; + + const org = pathSegments[1]; + const repo = pathSegments[2]; + const prNumber = Number(pathSegments[4]); + return { org, repo, prNumber }; } onSubmit() { - const { owner, repo, prNumber } = this.parseGitHubPRUrl(this.params['url']); + const { org, repo, prNumber } = this.parseGitHubPRUrl(this.params['url']); this.copilotSurveyService.createSurvey({ id: this.id, userId: this.params['author'], - owner: owner, - repo: repo, - prNumber: prNumber, + org, + repo, + prNumber, usedCopilot: this.surveyForm.value.usedCopilot, percentTimeSaved: Number(this.surveyForm.value.percentTimeSaved), reason: this.surveyForm.value.reason, diff --git a/frontend/src/app/main/copilot/copilot-value/adoption-chart/adoption-chart.component.ts b/frontend/src/app/main/copilot/copilot-value/adoption-chart/adoption-chart.component.ts index 58e4501..c19052d 100644 --- a/frontend/src/app/main/copilot/copilot-value/adoption-chart/adoption-chart.component.ts +++ b/frontend/src/app/main/copilot/copilot-value/adoption-chart/adoption-chart.component.ts @@ -1,7 +1,7 @@ import { Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; import * as Highcharts from 'highcharts'; import { HighchartsChartModule } from 'highcharts-angular'; -import { ActivityResponse } from '../../../../services/seat.service'; +import { ActivityResponse } from '../../../../services/api/seat.service'; import { HighchartsService } from '../../../../services/highcharts.service'; import { DatePipe } from '@angular/common'; diff --git a/frontend/src/app/main/copilot/copilot-value/daily-activity-chart/daily-activity-chart.component.ts b/frontend/src/app/main/copilot/copilot-value/daily-activity-chart/daily-activity-chart.component.ts index 258fb7a..a05ec61 100644 --- a/frontend/src/app/main/copilot/copilot-value/daily-activity-chart/daily-activity-chart.component.ts +++ b/frontend/src/app/main/copilot/copilot-value/daily-activity-chart/daily-activity-chart.component.ts @@ -1,8 +1,8 @@ import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'; import * as Highcharts from 'highcharts'; import { HighchartsChartModule } from 'highcharts-angular'; -import { ActivityResponse } from '../../../../services/seat.service'; -import { CopilotMetrics } from '../../../../services/metrics.service.interfaces'; +import { ActivityResponse } from '../../../../services/api/seat.service'; +import { CopilotMetrics } from '../../../../services/api/metrics.service.interfaces'; import { HighchartsService } from '../../../../services/highcharts.service'; @Component({ diff --git a/frontend/src/app/main/copilot/copilot-value/time-saved-chart/time-saved-chart.component.ts b/frontend/src/app/main/copilot/copilot-value/time-saved-chart/time-saved-chart.component.ts index 5625390..84c8b25 100644 --- a/frontend/src/app/main/copilot/copilot-value/time-saved-chart/time-saved-chart.component.ts +++ b/frontend/src/app/main/copilot/copilot-value/time-saved-chart/time-saved-chart.component.ts @@ -1,9 +1,9 @@ import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'; import * as Highcharts from 'highcharts'; import { HighchartsChartModule } from 'highcharts-angular'; -import { Survey } from '../../../../services/copilot-survey.service'; +import { Survey } from '../../../../services/api/copilot-survey.service'; import { HighchartsService } from '../../../../services/highcharts.service'; -import { ActivityResponse } from '../../../../services/seat.service'; +import { ActivityResponse } from '../../../../services/api/seat.service'; @Component({ selector: 'app-time-saved-chart', diff --git a/frontend/src/app/main/copilot/copilot-value/value.component.ts b/frontend/src/app/main/copilot/copilot-value/value.component.ts index 0c66c1c..d8a51a7 100644 --- a/frontend/src/app/main/copilot/copilot-value/value.component.ts +++ b/frontend/src/app/main/copilot/copilot-value/value.component.ts @@ -1,18 +1,19 @@ import { Component, OnInit } from '@angular/core'; import { AppModule } from '../../../app.module'; import { AdoptionChartComponent } from "./adoption-chart/adoption-chart.component"; -import { ActivityResponse, SeatService } from '../../../services/seat.service'; +import { ActivityResponse, SeatService } from '../../../services/api/seat.service'; import { DailyActivityChartComponent } from './daily-activity-chart/daily-activity-chart.component'; import { TimeSavedChartComponent } from './time-saved-chart/time-saved-chart.component'; -import { CopilotMetrics } from '../../../services/metrics.service.interfaces'; -import { MetricsService } from '../../../services/metrics.service'; +import { CopilotMetrics } from '../../../services/api/metrics.service.interfaces'; +import { MetricsService } from '../../../services/api/metrics.service'; import { FormControl } from '@angular/forms'; -import { combineLatest, startWith } from 'rxjs'; -import { CopilotSurveyService, Survey } from '../../../services/copilot-survey.service'; +import { combineLatest, startWith, takeUntil } from 'rxjs'; +import { CopilotSurveyService, Survey } from '../../../services/api/copilot-survey.service'; import * as Highcharts from 'highcharts'; import HC_exporting from 'highcharts/modules/exporting'; HC_exporting(Highcharts); import HC_full_screen from 'highcharts/modules/full-screen'; +import { InstallationsService } from '../../../services/api/installations.service'; HC_full_screen(Highcharts); @Component({ @@ -74,23 +75,30 @@ export class CopilotValueComponent implements OnInit { constructor( private seatService: SeatService, private metricsService: MetricsService, - private copilotSurveyService: CopilotSurveyService + private copilotSurveyService: CopilotSurveyService, + private installationsService: InstallationsService ) { } ngOnInit() { - combineLatest([ - this.daysInactive.valueChanges.pipe(startWith(this.daysInactive.value || 30)), - this.adoptionFidelity.valueChanges.pipe(startWith(this.adoptionFidelity.value || 'day')) - ]).subscribe(([days, fidelity]) => { - this.seatService.getActivity(days || 30, fidelity || 'day').subscribe(data => { - this.activityData = data; + this.installationsService.currentInstallation.pipe( + takeUntil(this.installationsService.destroy$) + ).subscribe(installation => { + combineLatest([ + this.daysInactive.valueChanges.pipe(startWith(this.daysInactive.value || 30)), + this.adoptionFidelity.valueChanges.pipe(startWith(this.adoptionFidelity.value || 'day')) + ]).subscribe(([days, fidelity]) => { + this.seatService.getActivity(installation?.account?.login, days || 30, fidelity || 'day').subscribe(data => { + this.activityData = data; + }); + }); + this.metricsService.getMetrics({ + org: installation?.account?.login, + }).subscribe(data => { + this.metricsData = data; + }); + this.copilotSurveyService.getAllSurveys(installation?.account?.login).subscribe(data => { + this.surveysData = data; }); - }); - this.metricsService.getMetrics().subscribe(data => { - this.metricsData = data; - }); - this.copilotSurveyService.getAllSurveys().subscribe(data => { - this.surveysData = data; }); } @@ -106,8 +114,8 @@ export class CopilotValueComponent implements OnInit { .filter(otherChart => otherChart !== _chart) .forEach(otherChart => { if (otherChart.xAxis?.[0] && - (otherChart.xAxis[0].min !== event.min || - otherChart.xAxis[0].max !== event.max)) { + (otherChart.xAxis[0].min !== event.min || + otherChart.xAxis[0].max !== event.max)) { otherChart.xAxis[0].setExtremes(event.min, event.max); } }); diff --git a/frontend/src/app/main/copilot/predictive-modeling/predictive-modeling.component.ts b/frontend/src/app/main/copilot/predictive-modeling/predictive-modeling.component.ts index 58300e4..6879616 100644 --- a/frontend/src/app/main/copilot/predictive-modeling/predictive-modeling.component.ts +++ b/frontend/src/app/main/copilot/predictive-modeling/predictive-modeling.component.ts @@ -1,10 +1,10 @@ import { Component, OnInit } from '@angular/core'; import { FormControl, FormGroup, Validators } from '@angular/forms'; -import { PredictiveModelingService } from '../../../services/predictive-modeling.service'; +import { PredictiveModelingService } from '../../../services/api/predictive-modeling.service'; import { MatSnackBar } from '@angular/material/snack-bar'; import { AppModule } from '../../../app.module'; import { CommonModule } from '@angular/common'; -import { SettingsHttpService } from '../../../services/settings.service'; +import { SettingsHttpService } from '../../../services/api/settings.service'; @Component({ standalone: true, diff --git a/frontend/src/app/main/main.component.html b/frontend/src/app/main/main.component.html index 026192e..d7bc45d 100644 --- a/frontend/src/app/main/main.component.html +++ b/frontend/src/app/main/main.component.html @@ -1,8 +1,10 @@ - + - - @if (isHandset$ | async) { + @if (isHandset$ | async) { - } - + }
-
-

{{owner.login}}

+
+
+

+ + Enterprise + @for (installation of installationsService.getInstallations() | async; track installation.id) { + {{ installation.account?.login }} + } + +

-
- -
+ +
-
+ \ No newline at end of file diff --git a/frontend/src/app/main/main.component.scss b/frontend/src/app/main/main.component.scss index 036159b..fe4f93a 100644 --- a/frontend/src/app/main/main.component.scss +++ b/frontend/src/app/main/main.component.scss @@ -86,7 +86,15 @@ mat-divider { margin: 0; } h1 { + min-width: 200px; + .mat-mdc-select { + font-size: 32px; + line-height: 32px; + } padding: 0 16px; font-size: 32px; + ::ng-deep .mat-mdc-select-arrow-wrapper { + margin-left: 10px !important; + } } } \ No newline at end of file diff --git a/frontend/src/app/main/main.component.ts b/frontend/src/app/main/main.component.ts index 34c20e9..0e5be10 100644 --- a/frontend/src/app/main/main.component.ts +++ b/frontend/src/app/main/main.component.ts @@ -10,10 +10,14 @@ import { Observable } from 'rxjs'; import { filter, map, shareReplay, tap } from 'rxjs/operators'; import { AppModule } from '../app.module'; import { ThemeService } from '../services/theme.service'; -import { NavigationEnd, Router } from '@angular/router'; -import { Endpoints } from '@octokit/types'; -import { SetupService } from '../services/setup.service'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; +import { SetupService } from '../services/api/setup.service'; import { MatCardModule } from '@angular/material/card'; +import { ConfettiService } from '../database/confetti.service'; +import { MatSelectModule } from '@angular/material/select'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { InstallationsService } from '../services/api/installations.service'; @Component({ selector: 'app-main', @@ -28,7 +32,10 @@ import { MatCardModule } from '@angular/material/card'; MatIconModule, AsyncPipe, AppModule, - MatCardModule + MatCardModule, + MatFormFieldModule, + MatSelectModule, + MatInputModule ] }) export class MainComponent { @@ -43,22 +50,26 @@ export class MainComponent { map(result => result.matches), shareReplay() ); - installation?: any; constructor( public themeService: ThemeService, + private route: ActivatedRoute, private router: Router, - private setupService: SetupService + private confettiService: ConfettiService, + public setupService: SetupService, + public installationsService: InstallationsService ) { this.hideNavText = localStorage.getItem('hideNavText') === 'true'; - + this.route.queryParams.subscribe(params => { + if (params['celebrate'] === 'true') { + this.confettiService.celebrate() + } + }); this.router.events.pipe( filter(event => event instanceof NavigationEnd) ).subscribe(() => { this.closeSidenav(); }); - - this.installation = this.setupService.installation; } toggleNavText(): void { @@ -71,4 +82,10 @@ export class MainComponent { this.drawer.close(); } } + + installationChanged(installation: number): void { + if (installation) { + this.installationsService.setInstallation(installation); + } + } } diff --git a/frontend/src/app/main/settings/settings.component.html b/frontend/src/app/main/settings/settings.component.html index e1528c7..5ae5ea7 100644 --- a/frontend/src/app/main/settings/settings.component.html +++ b/frontend/src/app/main/settings/settings.component.html @@ -104,16 +104,23 @@

Theme

- +
-

GitHub App Installation

+

GitHub App Installations

+ + @for (install of installs; track install) { + + + +
+ {{install.account.type}}: {{install.account.login}} +
+
+ } +
-

{{install.name}}

- -

{{install.description}}

-

Owner: {{install.owner.login}}