diff --git a/github-activity-tracker/.env.example b/github-activity-tracker/.env.example index a91a297..46b3371 100644 --- a/github-activity-tracker/.env.example +++ b/github-activity-tracker/.env.example @@ -10,6 +10,10 @@ RDS_DATABASE=github_tracker RDS_USER=postgres RDS_PASSWORD=your-password +# Single source of truth for lookup tables (seeded into DB on migrate/start). +GITHUB_ORG=mosip,inji +USER_ROLES=Developer,Tech Lead,Architect,Product Owner,Leadership,QA Engineer,DevOps Engineer + # Variables used by the Postgres container itself: # POSTGRES_DB=github_data # POSTGRES_USER=github_user diff --git a/github-activity-tracker/backend/.env.example b/github-activity-tracker/backend/.env.example index 0823082..6861e83 100644 --- a/github-activity-tracker/backend/.env.example +++ b/github-activity-tracker/backend/.env.example @@ -3,4 +3,12 @@ RDS_PORT=5432 RDS_DATABASE=github_data RDS_USER=your-rds-user RDS_PASSWORD=your-rds-password -GITHUB_TOKEN=your-github-token \ No newline at end of file +GITHUB_TOKEN=your-github-token + +# Read from .env on backend start and stored in user_roles / organizations tables. +GITHUB_ORG=mosip,inji +USER_ROLES=Developer,Tech Lead,Architect,Product Owner,Leadership,QA Engineer,DevOps Engineer + +# Bearer token required to call /admin/* endpoints (sync + role management). +# Generate a strong random value, e.g. `openssl rand -hex 32`. +ADMIN_API_TOKEN=your-admin-api-token diff --git a/github-activity-tracker/backend/app.js b/github-activity-tracker/backend/app.js index 202c523..f74bd9e 100644 --- a/github-activity-tracker/backend/app.js +++ b/github-activity-tracker/backend/app.js @@ -2,7 +2,7 @@ * GitHub Activity Tracker – Backend API * * Express server that exposes admin sync endpoints to pull repository, commit, - * PR, and review data from GitHub into PostgreSQL. Run migrations first (npm run migrate). + * PR, and review data from GitHub into PostgreSQL. */ require('dotenv').config(); const express = require('express'); @@ -16,6 +16,11 @@ const orgActivityRoute = require('./routes/orgActivityRoute'); const orgSummaryRoute = require('./routes/orgSummaryRoute'); const orgUsersRoute = require('./routes/orgUsersRoute'); const userDetailsRoute = require('./routes/userDetailsRoute'); +const userNameSyncRoute = require('./routes/userNameSyncRoute'); +const userRoleRoute = require('./routes/userRoleRoute'); +const userRolesRoute = require('./routes/userRolesRoute'); +const organizationsRoute = require('./routes/organizationsRoute'); +const { ensureLookupTables } = require('./db/initLookupTables'); const app = express(); const PORT = process.env.PORT || 3000; @@ -34,6 +39,11 @@ app.get('/', (req, res) => { 'POST /admin/sync/commits': 'Sync commits for all repositories in DB', 'POST /admin/sync/prs': 'Sync PRs for all repositories in DB', 'POST /admin/sync/reviews': 'Sync PR reviews for all repositories in DB', + 'POST /admin/sync/user-names': 'Backfill GitHub profile names for users in DB', + 'POST /admin/users/role': 'Assign or change job role for a GitHub user', + 'GET /admin/users/:login/role': 'Fetch job role for a GitHub user', + 'GET /user-roles': 'List assignable user job roles', + 'GET /organizations': 'List tracked GitHub organizations', }, }); }); @@ -43,12 +53,33 @@ app.use(repoSyncRoute); app.use(commitSyncRoute); app.use(prSyncRoute); app.use(reviewSyncRoute); +app.use(userNameSyncRoute); +app.use(userRoleRoute); +app.use(userRolesRoute); +app.use(organizationsRoute); app.use(orgUsersRoute); app.use(orgSummaryRoute); app.use(userDetailsRoute); app.use(orgActivityRoute); app.use(leaderboardRoute); -app.listen(PORT, () => { +app.listen(PORT, async () => { + try { + const result = await ensureLookupTables(); + + if (result.createdTables.length > 0) { + console.log(`Created tables: ${result.createdTables.join(', ')}`); + } + + if (result.rolesAdded > 0 || result.orgsAdded > 0) { + console.log(`Added from .env: ${result.rolesAdded} role(s), ${result.orgsAdded} organization(s)`); + } else if (result.createdTables.length === 0) { + console.log('Lookup tables already exist; no new roles or organizations to add.'); + } + } catch (error) { + console.error('Failed to initialize lookup tables:', error.message); + process.exit(1); + } + console.log(`Server running on http://localhost:${PORT}`); }); diff --git a/github-activity-tracker/backend/config/defaultUserRoles.js b/github-activity-tracker/backend/config/defaultUserRoles.js new file mode 100644 index 0000000..7115a48 --- /dev/null +++ b/github-activity-tracker/backend/config/defaultUserRoles.js @@ -0,0 +1,16 @@ +function parseUserRolesFromEnv() { + const fromEnv = process.env.USER_ROLES; + + if (!fromEnv || !fromEnv.trim()) { + return []; + } + + return fromEnv + .split(',') + .map((role) => role.trim()) + .filter(Boolean); +} + +module.exports = { + parseUserRolesFromEnv, +}; diff --git a/github-activity-tracker/backend/config/organizations.js b/github-activity-tracker/backend/config/organizations.js new file mode 100644 index 0000000..0a85d60 --- /dev/null +++ b/github-activity-tracker/backend/config/organizations.js @@ -0,0 +1,15 @@ +const { + getAllOrganizations, + getOrganizationSlugs, + getOrganizationNames, + normalizeOrganization, + isValidOrganization, +} = require('../services/organizationsService'); + +module.exports = { + getAllOrganizations, + getOrganizationSlugs, + getOrganizationNames, + normalizeOrganization, + isValidOrganization, +}; \ No newline at end of file diff --git a/github-activity-tracker/backend/config/syncConfig.js b/github-activity-tracker/backend/config/syncConfig.js index 45b8b97..389a420 100644 --- a/github-activity-tracker/backend/config/syncConfig.js +++ b/github-activity-tracker/backend/config/syncConfig.js @@ -5,6 +5,18 @@ /** Delay in ms between processing each repo when syncing commits/PRs/reviews for all repos. */ const DELAY_BETWEEN_REPOS_MS = 300; +/** Default batch size for backfilling missing GitHub user display names per request. */ +const NAME_BACKFILL_BATCH_SIZE = 50; + +/** Delay in ms between GitHub profile lookups when backfilling user names. */ +const NAME_FETCH_DELAY_MS = 120; + +/** Maximum batch size allowed for a single user-name backfill request. */ +const NAME_BACKFILL_MAX_BATCH_SIZE = 500; + module.exports = { DELAY_BETWEEN_REPOS_MS, + NAME_BACKFILL_BATCH_SIZE, + NAME_FETCH_DELAY_MS, + NAME_BACKFILL_MAX_BATCH_SIZE, }; diff --git a/github-activity-tracker/backend/config/userRoles.js b/github-activity-tracker/backend/config/userRoles.js new file mode 100644 index 0000000..747ee23 --- /dev/null +++ b/github-activity-tracker/backend/config/userRoles.js @@ -0,0 +1,11 @@ +const { + getAllUserRoles, + getUserRoleNames, + isValidUserRole, +} = require('../services/userRolesService'); + +module.exports = { + getAllUserRoles, + getUserRoleNames, + isValidUserRole, +}; \ No newline at end of file diff --git a/github-activity-tracker/backend/db/initLookupTables.js b/github-activity-tracker/backend/db/initLookupTables.js new file mode 100644 index 0000000..642cbf3 --- /dev/null +++ b/github-activity-tracker/backend/db/initLookupTables.js @@ -0,0 +1,96 @@ +const pool = require('./dbPool'); +const { parseUserRolesFromEnv } = require('../config/defaultUserRoles'); + +function parseOrganizationsFromEnv(value = process.env.GITHUB_ORG) { + return (value || '') + .split(',') + .map((slug) => slug.trim().toLowerCase()) + .filter(Boolean); +} + +async function tableExists(tableName) { + const result = await pool.query( + ` + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = $1 + LIMIT 1 + `, + [tableName] + ); + return result.rowCount > 0; +} + +async function ensureLookupTables() { + const roles = parseUserRolesFromEnv(); + const orgs = parseOrganizationsFromEnv(); + const createdTables = []; + + if (!(await tableExists('user_roles'))) { + await pool.query(` + CREATE TABLE user_roles ( + id SERIAL PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + createdTables.push('user_roles'); + } + + if (!(await tableExists('organizations'))) { + await pool.query(` + CREATE TABLE organizations ( + id SERIAL PRIMARY KEY, + slug VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + createdTables.push('organizations'); + } + + let rolesAdded = 0; + for (const name of roles) { + const result = await pool.query( + ` + INSERT INTO user_roles (name) + VALUES ($1) + ON CONFLICT (name) DO NOTHING + RETURNING id + `, + [name] + ); + if (result.rowCount > 0) { + rolesAdded += 1; + } + } + + let orgsAdded = 0; + for (const slug of orgs) { + const result = await pool.query( + ` + INSERT INTO organizations (slug, name) + VALUES ($1, $2) + ON CONFLICT (slug) DO NOTHING + RETURNING id + `, + [slug, slug.toUpperCase()] + ); + if (result.rowCount > 0) { + orgsAdded += 1; + } + } + + return { + roles, + orgs, + createdTables, + rolesAdded, + orgsAdded, + }; +} + +module.exports = { + ensureLookupTables, + parseOrganizationsFromEnv, +}; diff --git a/github-activity-tracker/backend/migrations/005_add_name_to_github_users.sql b/github-activity-tracker/backend/migrations/005_add_name_to_github_users.sql new file mode 100644 index 0000000..0bc3407 --- /dev/null +++ b/github-activity-tracker/backend/migrations/005_add_name_to_github_users.sql @@ -0,0 +1,3 @@ +-- GitHub profile display name (from GET /users/{login} or GraphQL User.name). +ALTER TABLE github_users + ADD COLUMN IF NOT EXISTS name VARCHAR(255); diff --git a/github-activity-tracker/backend/migrations/006_add_role_to_github_users.sql b/github-activity-tracker/backend/migrations/006_add_role_to_github_users.sql new file mode 100644 index 0000000..6e53e4d --- /dev/null +++ b/github-activity-tracker/backend/migrations/006_add_role_to_github_users.sql @@ -0,0 +1,6 @@ +-- Job role for team members (set manually until admin update API exists). +-- Allowed values: Developer, Tech Lead, Architect, Product Owner, Leadership, QA Engineer, DevOps Engineer +ALTER TABLE github_users + ADD COLUMN IF NOT EXISTS role VARCHAR(50); + +CREATE INDEX IF NOT EXISTS idx_github_users_role ON github_users(role); diff --git a/github-activity-tracker/backend/migrations/007_create_user_details_table.sql b/github-activity-tracker/backend/migrations/007_create_user_details_table.sql new file mode 100644 index 0000000..a9ad9f8 --- /dev/null +++ b/github-activity-tracker/backend/migrations/007_create_user_details_table.sql @@ -0,0 +1,87 @@ +-- User role/organization assignments with history. One active row per user at a time. +-- Lookup tables (user_roles, organizations) are created at app startup from .env. + +CREATE TABLE IF NOT EXISTS user_details ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES github_users(id) ON DELETE CASCADE, + role_id INTEGER REFERENCES user_roles(id), + organization_id INTEGER REFERENCES organizations(id), + active BOOLEAN NOT NULL DEFAULT true, + active_from TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + active_to TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +ALTER TABLE user_details + ADD COLUMN IF NOT EXISTS role_id INTEGER REFERENCES user_roles(id), + ADD COLUMN IF NOT EXISTS organization_id INTEGER REFERENCES organizations(id); + +ALTER TABLE user_details + DROP COLUMN IF EXISTS login, + DROP COLUMN IF EXISTS name, + DROP COLUMN IF EXISTS github_user_id; + +CREATE INDEX IF NOT EXISTS idx_user_details_user_id ON user_details(user_id); +CREATE INDEX IF NOT EXISTS idx_user_details_role_id ON user_details(role_id); +CREATE INDEX IF NOT EXISTS idx_user_details_organization_id ON user_details(organization_id); +CREATE INDEX IF NOT EXISTS idx_user_details_active_from ON user_details(active_from); +CREATE INDEX IF NOT EXISTS idx_user_details_active_to ON user_details(active_to); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_details_one_active_per_user + ON user_details(user_id) + WHERE active = true; + +-- GitHub profile name lives on github_users; assignments live in user_details. +ALTER TABLE github_users + ADD COLUMN IF NOT EXISTS name VARCHAR(255); + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'user_details' + AND column_name = 'name' + ) THEN + UPDATE github_users u + SET + name = ud.name, + updated_at = CURRENT_TIMESTAMP + FROM user_details ud + WHERE ud.user_id = u.id + AND ud.active = true + AND ud.name IS NOT NULL + AND (u.name IS NULL OR u.name = ''); + END IF; +END $$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'github_users' + AND column_name = 'role' + ) THEN + INSERT INTO user_details (user_id, role_id, active, active_from, active_to) + SELECT + u.id, + ur.id, + true, + COALESCE(u.updated_at, u.inserted_at, CURRENT_TIMESTAMP), + NULL + FROM github_users u + JOIN user_roles ur ON ur.name = u.role + WHERE u.role IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM user_details ud WHERE ud.user_id = u.id AND ud.active = true + ); + END IF; +END $$; + +DROP INDEX IF EXISTS idx_github_users_role; +ALTER TABLE github_users + DROP COLUMN IF EXISTS role; diff --git a/github-activity-tracker/backend/migrations/008_backfill_user_details_active_from.sql b/github-activity-tracker/backend/migrations/008_backfill_user_details_active_from.sql new file mode 100644 index 0000000..7ce53a5 --- /dev/null +++ b/github-activity-tracker/backend/migrations/008_backfill_user_details_active_from.sql @@ -0,0 +1,41 @@ +-- Backdate user_details.active_from so historical activity before user sync +-- is included when a role is first assigned. +UPDATE user_details ud +SET + active_from = LEAST( + ud.active_from, + COALESCE( + (SELECT MIN(e.created_at) FROM activity_events e WHERE e.user_id = ud.user_id), + ud.active_from + ) + ), + updated_at = CURRENT_TIMESTAMP +WHERE EXISTS ( + SELECT 1 FROM activity_events e WHERE e.user_id = ud.user_id +); + +-- First role assignment rows should cover all prior activity, not only from assignment time. +UPDATE user_details current_ud +SET + active_from = LEAST( + current_ud.active_from, + COALESCE( + (SELECT MIN(e.created_at) FROM activity_events e WHERE e.user_id = current_ud.user_id), + current_ud.active_from + ), + COALESCE( + (SELECT MIN(h.active_from) FROM user_details h WHERE h.user_id = current_ud.user_id), + current_ud.active_from + ) + ), + updated_at = CURRENT_TIMESTAMP +WHERE current_ud.role_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM user_details prior_role + WHERE prior_role.user_id = current_ud.user_id + AND prior_role.role_id IS NOT NULL + AND prior_role.id <> current_ud.id + AND prior_role.active_to IS NOT NULL + AND prior_role.active_to <= current_ud.active_from + ); diff --git a/github-activity-tracker/backend/migrations/009_user_details_role_organization_ids.sql b/github-activity-tracker/backend/migrations/009_user_details_role_organization_ids.sql new file mode 100644 index 0000000..5ef17cb --- /dev/null +++ b/github-activity-tracker/backend/migrations/009_user_details_role_organization_ids.sql @@ -0,0 +1,49 @@ +-- Store role/organization assignments as FKs to user_roles and organizations. + +ALTER TABLE user_details + ADD COLUMN IF NOT EXISTS role_id INTEGER REFERENCES user_roles(id), + ADD COLUMN IF NOT EXISTS organization_id INTEGER REFERENCES organizations(id); + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'user_details' + AND column_name = 'role' + ) THEN + UPDATE user_details ud + SET role_id = ur.id + FROM user_roles ur + WHERE ud.role IS NOT NULL + AND ur.name = ud.role + AND ud.role_id IS NULL; + END IF; +END $$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'user_details' + AND column_name = 'organization' + ) THEN + UPDATE user_details ud + SET organization_id = o.id + FROM organizations o + WHERE ud.organization IS NOT NULL + AND o.slug = LOWER(ud.organization) + AND ud.organization_id IS NULL; + END IF; +END $$; + +ALTER TABLE user_details + DROP COLUMN IF EXISTS role, + DROP COLUMN IF EXISTS organization; + +DROP INDEX IF EXISTS idx_user_details_role; +CREATE INDEX IF NOT EXISTS idx_user_details_role_id ON user_details(role_id); +CREATE INDEX IF NOT EXISTS idx_user_details_organization_id ON user_details(organization_id); diff --git a/github-activity-tracker/backend/migrations/runMigrations.js b/github-activity-tracker/backend/migrations/runMigrations.js index f433363..d09a6d6 100644 --- a/github-activity-tracker/backend/migrations/runMigrations.js +++ b/github-activity-tracker/backend/migrations/runMigrations.js @@ -1,17 +1,27 @@ /** - * Run all SQL migrations in order (001_*.sql, 002_*.sql, ...). - * Use: npm run migrate (from backend directory). - * Requires RDS_* env vars in .env. Run once per environment (local, staging, prod). + * Run SQL migrations in order (001_*.sql, 002_*.sql, ...). + * Migrations are idempotent. Run manually: npm run migrate */ require('dotenv').config(); const fs = require('fs'); const path = require('path'); const pool = require('../db/dbPool'); +const { ensureLookupTables } = require('../db/initLookupTables'); + +async function runMigrations({ closePool = true } = {}) { + const lookupResult = await ensureLookupTables(); + if (lookupResult.createdTables.length > 0) { + console.log(`Created lookup tables: ${lookupResult.createdTables.join(', ')}`); + } + if (lookupResult.rolesAdded > 0 || lookupResult.orgsAdded > 0) { + console.log( + `Seeded from env: ${lookupResult.rolesAdded} role(s), ${lookupResult.orgsAdded} organization(s)` + ); + } -async function runMigrations() { const migrationsDir = path.join(__dirname); const files = fs.readdirSync(migrationsDir) - .filter(file => file.endsWith('.sql')) + .filter((file) => file.endsWith('.sql')) .sort(); console.log(`Found ${files.length} migration file(s)`); @@ -31,10 +41,17 @@ async function runMigrations() { } console.log('All migrations completed successfully!'); - await pool.end(); + + if (closePool) { + await pool.end(); + } } -runMigrations().catch(error => { - console.error('Migration failed:', error); - process.exit(1); -}); +module.exports = { runMigrations }; + +if (require.main === module) { + runMigrations().catch((error) => { + console.error('Migration failed:', error); + process.exit(1); + }); +} diff --git a/github-activity-tracker/backend/routes/leaderBoardRoute.js b/github-activity-tracker/backend/routes/leaderBoardRoute.js index 7f7e518..f6adff3 100644 --- a/github-activity-tracker/backend/routes/leaderBoardRoute.js +++ b/github-activity-tracker/backend/routes/leaderBoardRoute.js @@ -14,7 +14,7 @@ router.get("/orgs/:org_id/leaderboard", async (req, res) => { return res.status(400).json({ error: "Invalid org_id" }); } - if (!["daily", "weekly", "monthly", "all"].includes(period)) { + if (!["daily", "weekly", "monthly", "yearly", "all"].includes(period)) { return res.status(400).json({ error: "Invalid period value" }); } diff --git a/github-activity-tracker/backend/routes/orgActivityRoute.js b/github-activity-tracker/backend/routes/orgActivityRoute.js index 3e41e77..e4f2bb2 100644 --- a/github-activity-tracker/backend/routes/orgActivityRoute.js +++ b/github-activity-tracker/backend/routes/orgActivityRoute.js @@ -1,21 +1,27 @@ const express = require("express"); const router = express.Router(); const { getOrgActivity } = require("../services/orgActivityService"); +const { isValidUserRole } = require("../config/userRoles"); router.get("/orgs/:org_id/activity", async (req, res) => { const { org_id } = req.params; - const { period = "weekly" } = req.query; + const { period = "weekly", role } = req.query; if (!org_id || typeof org_id !== "string") { return res.status(400).json({ error: "Invalid org_id" }); } - if (!["daily", "weekly", "monthly"].includes(period)) { + if (!["daily", "weekly", "monthly", "yearly"].includes(period)) { return res.status(400).json({ error: "Invalid period value" }); } + if (role && role !== "all" && !(await isValidUserRole(role))) { + return res.status(400).json({ error: "Invalid role value" }); + } + try { - const data = await getOrgActivity(org_id, period); + const roleFilter = role && role !== "all" ? role : null; + const data = await getOrgActivity(org_id, period, roleFilter); return res.json(data); } catch (err) { console.error("Error fetching org activity:", err); diff --git a/github-activity-tracker/backend/routes/orgSummaryRoute.js b/github-activity-tracker/backend/routes/orgSummaryRoute.js index a5799b8..b139e2c 100644 --- a/github-activity-tracker/backend/routes/orgSummaryRoute.js +++ b/github-activity-tracker/backend/routes/orgSummaryRoute.js @@ -1,21 +1,27 @@ const express = require('express'); const router = express.Router(); const { getOrgSummary } = require('../services/orgSummaryService'); +const { isValidUserRole } = require('../config/userRoles'); router.get('/orgs/:org_id/summary', async (req, res) => { try { const { org_id } = req.params; - const { period = 'weekly' } = req.query; + const { period = 'weekly', role } = req.query; if (!org_id) { return res.status(400).json({ error: 'Invalid org_id' }); } - if (!['daily', 'weekly', 'monthly'].includes(period)) { + if (!['daily', 'weekly', 'monthly', 'yearly'].includes(period)) { return res.status(400).json({ error: 'Invalid period value' }); } - const summary = await getOrgSummary(org_id, period); + if (role && role !== 'all' && !(await isValidUserRole(role))) { + return res.status(400).json({ error: 'Invalid role value' }); + } + + const roleFilter = role && role !== 'all' ? role : null; + const summary = await getOrgSummary(org_id, period, roleFilter); return res.status(200).json(summary); } catch (err) { diff --git a/github-activity-tracker/backend/routes/orgUsersRoute.js b/github-activity-tracker/backend/routes/orgUsersRoute.js index 1252de9..cca4aed 100644 --- a/github-activity-tracker/backend/routes/orgUsersRoute.js +++ b/github-activity-tracker/backend/routes/orgUsersRoute.js @@ -2,6 +2,7 @@ const express = require("express"); const router = express.Router(); const { getOrgUsers } = require("../services/orgUsersService"); +const { isValidUserRole } = require("../config/userRoles"); router.get("/orgs/:org_id/users", async (req, res) => { try { @@ -10,17 +11,43 @@ router.get("/orgs/:org_id/users", async (req, res) => { const period = req.query.period || "weekly"; const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 20; + const { role, search, sortBy, sortOrder } = req.query; if (!org_id || typeof org_id !== "string") { return res.status(400).json({ error: "Invalid org_id" }); } - if (!["daily", "weekly", "monthly"].includes(period)) { + if (!["daily", "weekly", "monthly", "yearly"].includes(period)) { return res.status(400).json({ error: "Invalid period value" }); } - // pass pagination to service - const users = await getOrgUsers(org_id, period, page, limit); + if (role && role !== "all" && !(await isValidUserRole(role))) { + return res.status(400).json({ error: "Invalid role value" }); + } + + const roleFilter = role && role !== "all" ? role : null; + + const searchFilter = + typeof search === "string" && search.trim() ? search.trim() : null; + + const allowedSortFields = ["prs", "reviews"]; + const sortByFilter = + typeof sortBy === "string" && allowedSortFields.includes(sortBy) + ? sortBy + : null; + const sortOrderFilter = + sortOrder === "asc" || sortOrder === "desc" ? sortOrder : "desc"; + + const users = await getOrgUsers( + org_id, + period, + page, + limit, + roleFilter, + searchFilter, + sortByFilter, + sortOrderFilter, + ); return res.status(200).json(users); diff --git a/github-activity-tracker/backend/routes/organizationsRoute.js b/github-activity-tracker/backend/routes/organizationsRoute.js new file mode 100644 index 0000000..da0fe90 --- /dev/null +++ b/github-activity-tracker/backend/routes/organizationsRoute.js @@ -0,0 +1,15 @@ +const express = require('express'); +const router = express.Router(); +const { getAllOrganizations } = require('../services/organizationsService'); + +router.get('/organizations', async (req, res) => { + try { + const organizations = await getAllOrganizations(); + return res.status(200).json(organizations); + } catch (error) { + console.error('Error fetching organizations:', error); + return res.status(500).json({ error: 'Failed to fetch organizations' }); + } +}); + +module.exports = router; diff --git a/github-activity-tracker/backend/routes/userDetailsRoute.js b/github-activity-tracker/backend/routes/userDetailsRoute.js index b7d5871..35cb9a2 100644 --- a/github-activity-tracker/backend/routes/userDetailsRoute.js +++ b/github-activity-tracker/backend/routes/userDetailsRoute.js @@ -1,22 +1,29 @@ const express = require('express'); const router = express.Router(); const { getUserDetails } = require('../services/userDetailsService'); +const { isValidUserRole } = require('../config/userRoles'); -// GET /orgs/:org_id/users/:login?period=daily|weekly|monthly +// GET /orgs/:org_id/users/:login?period=daily|weekly|monthly|yearly&role=Developer router.get('/orgs/:org_id/users/:login', async (req, res) => { const { org_id, login } = req.params; - const { period="weekly" } = req.query; + const { period = 'weekly', role } = req.query; if (!login) { return res.status(400).json({ error: 'Missing user login' }); } - if (!['daily', 'weekly', 'monthly'].includes(period)) { + if (!['daily', 'weekly', 'monthly', 'yearly'].includes(period)) { return res.status(400).json({ error: 'Invalid period value' }); } + if (role && role !== 'all' && !(await isValidUserRole(role))) { + return res.status(400).json({ error: 'Invalid role value' }); + } + + const roleFilter = role && role !== 'all' ? role : null; + try { - const data = await getUserDetails(org_id, login, period); + const data = await getUserDetails(org_id, login, period, roleFilter); return res.json(data); } catch (err) { console.error('Error in User Details API:', err); diff --git a/github-activity-tracker/backend/routes/userNameSyncRoute.js b/github-activity-tracker/backend/routes/userNameSyncRoute.js new file mode 100644 index 0000000..49f0d00 --- /dev/null +++ b/github-activity-tracker/backend/routes/userNameSyncRoute.js @@ -0,0 +1,39 @@ +/** + * Route: POST /admin/sync/user-names + * Backfills GitHub profile display names for users missing name in github_users. + */ +const express = require('express'); +const { backfillMissingUserNames } = require('../services/githubUserService'); +const { HTTP, STATUS } = require('../config/errorCodes'); + +const router = express.Router(); + +router.post('/admin/sync/user-names', async (req, res) => { + try { + const result = await backfillMissingUserNames({ + limit: req.body?.limit, + }); + + return res.json({ + status: STATUS.SUCCESS, + ...result, + }); + } catch (error) { + if (error.statusCode === HTTP.BAD_REQUEST) { + return res.status(HTTP.BAD_REQUEST).json({ + status: STATUS.ERROR, + message: error.message, + }); + } + + console.error('Error backfilling GitHub user names:', error); + + return res.status(HTTP.INTERNAL_SERVER_ERROR).json({ + status: STATUS.ERROR, + message: 'Failed to backfill GitHub user names', + error: process.env.NODE_ENV === 'development' ? error.message : undefined, + }); + } +}); + +module.exports = router; diff --git a/github-activity-tracker/backend/routes/userRoleRoute.js b/github-activity-tracker/backend/routes/userRoleRoute.js new file mode 100644 index 0000000..dd48a80 --- /dev/null +++ b/github-activity-tracker/backend/routes/userRoleRoute.js @@ -0,0 +1,117 @@ +/** + * Routes: + * GET /admin/users/:login/role – fetch role for a GitHub user + * POST /admin/users/role – assign or change role for a GitHub user + */ +const express = require('express'); +const { getUserRole, setUserRole } = require('../services/userRoleService'); +const { getUserRoleNames } = require('../config/userRoles'); +const { getOrganizationNames } = require('../config/organizations'); +const { HTTP, STATUS } = require('../config/errorCodes'); + +const router = express.Router(); + +router.get('/admin/users/:login/role', async (req, res) => { + try { + const { login } = req.params; + + if (!login) { + return res.status(HTTP.BAD_REQUEST).json({ + status: STATUS.ERROR, + message: 'login is required', + }); + } + + const user = await getUserRole(login); + + if (!user) { + return res.status(HTTP.NOT_FOUND).json({ + status: STATUS.ERROR, + message: 'User not found', + }); + } + + return res.json({ + status: STATUS.SUCCESS, + user, + }); + } catch (error) { + console.error('Error fetching user role:', error); + + return res.status(HTTP.INTERNAL_SERVER_ERROR).json({ + status: STATUS.ERROR, + message: 'Failed to fetch user role', + error: process.env.NODE_ENV === 'development' ? error.message : undefined, + }); + } +}); + +router.post('/admin/users/role', async (req, res) => { + try { + const { login, role, organization } = req.body || {}; + + if (!login) { + return res.status(HTTP.BAD_REQUEST).json({ + status: STATUS.ERROR, + message: 'login is required in request body', + }); + } + + if (!role) { + return res.status(HTTP.BAD_REQUEST).json({ + status: STATUS.ERROR, + message: 'role is required in request body', + allowed_roles: await getUserRoleNames(), + }); + } + + if (!organization) { + return res.status(HTTP.BAD_REQUEST).json({ + status: STATUS.ERROR, + message: 'organization is required in request body', + allowed_organizations: await getOrganizationNames(), + }); + } + + const user = await setUserRole({ login, role, organization }); + + if (!user) { + return res.status(HTTP.NOT_FOUND).json({ + status: STATUS.ERROR, + message: 'User not found', + }); + } + + return res.json({ + status: STATUS.SUCCESS, + message: 'Role assigned successfully', + user, + }); + } catch (error) { + if (error.message === 'Invalid role value') { + return res.status(HTTP.BAD_REQUEST).json({ + status: STATUS.ERROR, + message: 'Invalid role value', + allowed_roles: await getUserRoleNames(), + }); + } + + if (error.message === 'Invalid organization value') { + return res.status(HTTP.BAD_REQUEST).json({ + status: STATUS.ERROR, + message: 'Invalid organization value', + allowed_organizations: await getOrganizationNames(), + }); + } + + console.error('Error assigning user role:', error); + + return res.status(HTTP.INTERNAL_SERVER_ERROR).json({ + status: STATUS.ERROR, + message: 'Failed to assign user role', + error: process.env.NODE_ENV === 'development' ? error.message : undefined, + }); + } +}); + +module.exports = router; diff --git a/github-activity-tracker/backend/routes/userRolesRoute.js b/github-activity-tracker/backend/routes/userRolesRoute.js new file mode 100644 index 0000000..264a9ad --- /dev/null +++ b/github-activity-tracker/backend/routes/userRolesRoute.js @@ -0,0 +1,15 @@ +const express = require('express'); +const router = express.Router(); +const { getAllUserRoles } = require('../services/userRolesService'); + +router.get('/user-roles', async (req, res) => { + try { + const roles = await getAllUserRoles(); + return res.status(200).json(roles); + } catch (error) { + console.error('Error fetching user roles:', error); + return res.status(500).json({ error: 'Failed to fetch user roles' }); + } +}); + +module.exports = router; diff --git a/github-activity-tracker/backend/scripts/runSync.js b/github-activity-tracker/backend/scripts/runSync.js new file mode 100644 index 0000000..32e4814 --- /dev/null +++ b/github-activity-tracker/backend/scripts/runSync.js @@ -0,0 +1,98 @@ +/** + * Full GitHub data sync: repos → commits → PRs → reviews → user names. + */ +require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') }); +const { syncRepos } = require('../services/syncRepos'); +const { syncCommits } = require('../services/commitSyncService'); +const { syncPRs } = require('../services/prSyncService'); +const { syncReviews } = require('../services/reviewSyncService'); +const { backfillMissingUserNames } = require('../services/githubUserService'); +const pool = require('../db/dbPool'); +const { DELAY_BETWEEN_REPOS_MS } = require('../config/syncConfig'); + +function parseOrgs() { + return (process.env.GITHUB_ORG || '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); +} + +async function loadRepos() { + const result = await pool.query( + 'SELECT github_repo_id, owner, name, full_name FROM repos ORDER BY github_repo_id' + ); + return result.rows; +} + +async function syncAllRepos(orgs) { + let reposProcessed = 0; + for (const org of orgs) { + reposProcessed += await syncRepos(org); + } + return reposProcessed; +} + +async function syncForAllRepos(label, syncFn) { + const repos = await loadRepos(); + let processed = 0; + let total = 0; + + for (let i = 0; i < repos.length; i += 1) { + const repo = repos[i]; + const repoName = repo.full_name || `${repo.owner}/${repo.name}`; + + try { + console.log(`[${label} ${i + 1}/${repos.length}] ${repoName}`); + const count = await syncFn(repo.github_repo_id); + processed += count; + console.log(` done: ${count} ${label}`); + } catch (error) { + console.error(` error for ${repoName}:`, error.message); + } + + if (i < repos.length - 1) { + await new Promise((resolve) => setTimeout(resolve, DELAY_BETWEEN_REPOS_MS)); + } + } + + return { repos_processed: repos.length, total_repos: repos.length, [`${label}_processed`]: processed }; +} + +async function main() { + const orgs = parseOrgs(); + if (orgs.length === 0) { + throw new Error('Set GITHUB_ORG in backend/.env'); + } + + console.log('=== GitHub Activity Tracker – full sync ==='); + console.log(`Organizations: ${orgs.join(', ')}\n`); + + console.log('1/5 Syncing repositories...'); + const reposProcessed = await syncAllRepos(orgs); + console.log(`Repos result: ${reposProcessed} repos\n`); + + console.log('2/5 Syncing commits...'); + const commitsResult = await syncForAllRepos('commits', syncCommits); + console.log('Commits result:', commitsResult, '\n'); + + console.log('3/5 Syncing pull requests...'); + const prsResult = await syncForAllRepos('prs', syncPRs); + console.log('PRs result:', prsResult, '\n'); + + console.log('4/5 Syncing reviews...'); + const reviewsResult = await syncForAllRepos('reviews', syncReviews); + console.log('Reviews result:', reviewsResult, '\n'); + + console.log('5/5 Backfilling user names...'); + const namesUpdated = await backfillMissingUserNames(); + console.log(`User names updated: ${namesUpdated}\n`); + + console.log('=== Sync complete ==='); +} + +main() + .catch((error) => { + console.error('Sync failed:', error); + process.exitCode = 1; + }) + .finally(() => pool.end()); diff --git a/github-activity-tracker/backend/services/commitSyncService.js b/github-activity-tracker/backend/services/commitSyncService.js index be02328..312c1cc 100644 --- a/github-activity-tracker/backend/services/commitSyncService.js +++ b/github-activity-tracker/backend/services/commitSyncService.js @@ -2,6 +2,7 @@ const githubClient = require('../utils/githubClient'); const pool = require('../db/dbPool'); const { POSTGRES } = require('../config/errorCodes'); const { isExcludedGitHubLogin } = require('../config/excludedGitHubLogins'); +const { upsertGitHubUser } = require('./githubUserService'); /** * Sync commits for a single repository. @@ -120,23 +121,13 @@ async function syncCommits(repoId) { continue; } - // Ensure user exists; get our internal id for foreign keys - const userResult = await pool.query( - ` - INSERT INTO github_users (github_user_id, login, avatar_url, html_url, type) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (github_user_id) - DO UPDATE SET - login = EXCLUDED.login, - avatar_url = EXCLUDED.avatar_url, - html_url = EXCLUDED.html_url, - type = EXCLUDED.type - RETURNING id - `, - [github_user_id, login, avatar_url || null, html_url || null, type || null] - ); - - const userId = userResult.rows[0].id; + const userId = await upsertGitHubUser({ + github_user_id, + login, + avatar_url, + html_url, + type, + }); // Record event first; only increment commit counters when the event is newly inserted. const eventInsert = await pool.query( diff --git a/github-activity-tracker/backend/services/githubUserService.js b/github-activity-tracker/backend/services/githubUserService.js new file mode 100644 index 0000000..9cd0284 --- /dev/null +++ b/github-activity-tracker/backend/services/githubUserService.js @@ -0,0 +1,194 @@ +const githubClient = require('../utils/githubClient'); +const pool = require('../db/dbPool'); + +const NAME_FETCH_DELAY_MS = 120; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isBotType(type) { + return String(type || '').toLowerCase() === 'bot'; +} + +function normalizeDisplayName(name) { + if (!name || !String(name).trim()) { + return null; + } + + return String(name).trim(); +} + +/** + * Fetch GitHub profile display name for a login. Returns null when unset or on failure. + */ +async function fetchGitHubUserName(login) { + if (!login) return null; + + try { + const { data } = await githubClient.get(`/users/${encodeURIComponent(login)}`); + const name = data?.name; + return name && String(name).trim() ? String(name).trim() : null; + } catch (error) { + const status = error?.response?.status; + if (status === 404) { + console.warn(`GitHub user not found while fetching name: ${login}`); + return null; + } + console.warn(`Failed to fetch GitHub name for ${login}:`, error.message); + return null; + } +} + +/** + * Resolve display name: use provided value, existing github_users value, or fetch from GitHub. + */ +async function resolveGitHubUserName({ github_user_id, login, type, name }) { + const providedName = normalizeDisplayName(name); + if (providedName) { + return providedName; + } + + if (!login || isBotType(type)) { + return null; + } + + const existing = await pool.query( + ` + SELECT name + FROM github_users + WHERE github_user_id = $1 + `, + [github_user_id] + ); + + const storedName = normalizeDisplayName(existing.rows[0]?.name); + if (storedName) { + return storedName; + } + + return fetchGitHubUserName(login); +} + +/** + * Ensure the user has an active user_details row. + */ +async function ensureActiveUserDetails(userId) { + const activeResult = await pool.query( + ` + SELECT id + FROM user_details + WHERE user_id = $1 AND active = true + `, + [userId] + ); + + if (activeResult.rows[0]) { + return; + } + + await pool.query( + ` + INSERT INTO user_details (user_id, role_id, active, active_from, active_to) + VALUES ($1, NULL, true, '1970-01-01'::timestamp, NULL) + `, + [userId] + ); +} + +/** + * Upsert a GitHub user and return the internal github_users.id. + */ +async function upsertGitHubUser({ + github_user_id, + login, + avatar_url, + html_url, + type, + name, +}) { + const displayName = await resolveGitHubUserName({ + github_user_id, + login, + type, + name, + }); + + const userResult = await pool.query( + ` + INSERT INTO github_users (github_user_id, login, avatar_url, html_url, type, name) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (github_user_id) + DO UPDATE SET + login = EXCLUDED.login, + avatar_url = EXCLUDED.avatar_url, + html_url = EXCLUDED.html_url, + type = EXCLUDED.type, + name = COALESCE(EXCLUDED.name, github_users.name), + updated_at = CURRENT_TIMESTAMP + RETURNING id + `, + [ + github_user_id, + login, + avatar_url || null, + html_url || null, + type || null, + displayName, + ] + ); + + const userId = userResult.rows[0].id; + await ensureActiveUserDetails(userId); + return userId; +} + +/** + * Backfill profile names for users that do not have one stored in github_users yet. + */ +async function backfillMissingUserNames() { + const result = await pool.query( + ` + SELECT u.id, u.login, u.type, u.github_user_id + FROM github_users u + WHERE u.name IS NULL + AND u.login IS NOT NULL + AND (u.type IS NULL OR LOWER(u.type) <> 'bot') + ORDER BY u.id + ` + ); + + let namesFetched = 0; + + for (const user of result.rows) { + const name = await fetchGitHubUserName(user.login); + + if (name) { + await pool.query( + ` + UPDATE github_users + SET name = $1, updated_at = CURRENT_TIMESTAMP + WHERE id = $2 + `, + [name, user.id] + ); + namesFetched += 1; + } + + await ensureActiveUserDetails(user.id); + + await sleep(NAME_FETCH_DELAY_MS); + } + + return { + users_checked: result.rows.length, + names_fetched: namesFetched, + }; +} + +module.exports = { + fetchGitHubUserName, + upsertGitHubUser, + backfillMissingUserNames, + ensureActiveUserDetails, +}; diff --git a/github-activity-tracker/backend/services/leaderBoardService.js b/github-activity-tracker/backend/services/leaderBoardService.js index 12983e4..91704da 100644 --- a/github-activity-tracker/backend/services/leaderBoardService.js +++ b/github-activity-tracker/backend/services/leaderBoardService.js @@ -9,7 +9,7 @@ function getDateRange(period) { return { start: null, end: null }; } - const periods = { daily: 1, weekly: 7, monthly: 30 }; + const periods = { daily: 1, weekly: 7, monthly: 30, yearly: 365 }; const days = periods[period]; if (!days) { throw new Error('Invalid period'); @@ -36,6 +36,7 @@ const getLeaderboard = async (orgId, period = "weekly", limit = 10) => { let query = ` SELECT u.login, + u.name AS name, u.avatar_url AS avatar, COUNT(*) FILTER (WHERE e.event_type = 'commit') AS commits, COUNT(*) FILTER (WHERE e.event_type = 'pr') AS prs, @@ -69,7 +70,7 @@ const getLeaderboard = async (orgId, period = "weekly", limit = 10) => { } query += ` - GROUP BY u.id, u.login, u.avatar_url + GROUP BY u.id, u.login, u.name, u.avatar_url ORDER BY score DESC LIMIT ${limit}; `; @@ -79,6 +80,7 @@ const getLeaderboard = async (orgId, period = "weekly", limit = 10) => { const leaderboard = result.rows.map((row, index) => ({ rank: index + 1, login: row.login, + name: row.name || null, avatar: row.avatar, commits: Number(row.commits), prs: Number(row.prs), diff --git a/github-activity-tracker/backend/services/orgActivityService.js b/github-activity-tracker/backend/services/orgActivityService.js index 2dc8c49..4f27038 100644 --- a/github-activity-tracker/backend/services/orgActivityService.js +++ b/github-activity-tracker/backend/services/orgActivityService.js @@ -1,76 +1,163 @@ const pool = require("../db/dbPool"); + const dayjs = require("dayjs"); + const { EXCLUDED_GITHUB_LOGINS } = require("../config/excludedGitHubLogins"); +const { userDetailsJoinSql } = require("../utils/userRoleSql"); + + /** + * Returns org-wide daily activity for chosen period, scoped to repos owned by orgId. + */ -async function getOrgActivity(orgId, period) { - const periods = { daily: 1, weekly: 7, monthly: 30 }; + +async function getOrgActivity(orgId, period, role) { + + const periods = { daily: 1, weekly: 7, monthly: 30, yearly: 365 }; + const days = periods[period]; + if (!days) { + throw new Error("Invalid period"); + } + + const end = dayjs().endOf("day"); + const start = end.subtract(days - 1, "day").startOf("day"); + + const params = [String(orgId).toLowerCase()]; + const whereClauses = [`LOWER(r.owner) = $${params.length}`]; + + if (Array.isArray(EXCLUDED_GITHUB_LOGINS) && EXCLUDED_GITHUB_LOGINS.length > 0) { + params.push(EXCLUDED_GITHUB_LOGINS.map((l) => String(l).toLowerCase())); + whereClauses.push(`LOWER(u.login) <> ALL($${params.length})`); + } + + params.push(start.toDate(), end.toDate()); + whereClauses.push(`e.created_at BETWEEN $${params.length - 1} AND $${params.length}`); + + + let userDetailsJoin = userDetailsJoinSql(null); + + if (role) { + params.push(role); + userDetailsJoin = userDetailsJoinSql(`$${params.length}`); + } + + + const result = await pool.query( + ` + SELECT + DATE(e.created_at) AS date, + COUNT(*) FILTER (WHERE e.event_type = 'commit') AS commits, + COUNT(*) FILTER (WHERE e.event_type = 'pr') AS prs, + COUNT(*) FILTER (WHERE e.event_type = 'review') AS reviews + FROM activity_events e + JOIN github_users u ON u.id = e.user_id + + ${userDetailsJoin} + JOIN repos r ON r.github_repo_id = e.repo_id + WHERE ${whereClauses.join(" AND ")} + GROUP BY DATE(e.created_at) + ORDER BY DATE(e.created_at); + `, + params + ); - // Generate empty date → fill zeros + + const labels = []; + const commits = []; + const prs = []; + const reviews = []; + const total = []; + + const map = {}; + result.rows.forEach((r) => { + map[dayjs(r.date).format("YYYY-MM-DD")] = r; + }); + + for (let i = 0; i < days; i++) { + const d = start.add(i, "day").format("YYYY-MM-DD"); + labels.push(d); + + const row = map[d] || { commits: 0, prs: 0, reviews: 0 }; + + const c = Number(row.commits); + const p = Number(row.prs); + const r = Number(row.reviews); + commits.push(c); + prs.push(p); + reviews.push(r); + total.push(c + p + r); + } + + return { labels, commits, prs, reviews, total }; + } -module.exports = { getOrgActivity }; \ No newline at end of file + + +module.exports = { getOrgActivity }; + + diff --git a/github-activity-tracker/backend/services/orgSummaryService.js b/github-activity-tracker/backend/services/orgSummaryService.js index 59509eb..a8c45e0 100644 --- a/github-activity-tracker/backend/services/orgSummaryService.js +++ b/github-activity-tracker/backend/services/orgSummaryService.js @@ -1,137 +1,309 @@ const db = require("../db/dbPool"); + const { EXCLUDED_GITHUB_LOGINS } = require("../config/excludedGitHubLogins"); +const { userDetailsJoinSql } = require("../utils/userRoleSql"); + + function getDateRanges(period) { + const now = new Date(); + + let currentStart, previousStart, currentEnd, previousEnd; + + switch (period) { + case "daily": + currentStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + previousStart = new Date(currentStart); + previousStart.setDate(previousStart.getDate() - 1); - currentEnd = new Date(now); // now + + + currentEnd = new Date(now); + previousEnd = new Date(currentStart); + break; + + case "weekly": + currentStart = new Date(); + currentStart.setDate(currentStart.getDate() - 7); + + previousStart = new Date(); + previousStart.setDate(previousStart.getDate() - 14); - currentEnd = new Date(); // now + + + currentEnd = new Date(now); + previousEnd = new Date(currentStart); + break; + + case "monthly": + currentStart = new Date(); + currentStart.setDate(currentStart.getDate() - 30); + + previousStart = new Date(); + previousStart.setDate(previousStart.getDate() - 60); - currentEnd = new Date(); // now + + + currentEnd = new Date(now); + previousEnd = new Date(currentStart); + break; + + + case "yearly": + + currentStart = new Date(); + + currentStart.setDate(currentStart.getDate() - 365); + + + + previousStart = new Date(); + + previousStart.setDate(previousStart.getDate() - 730); + + + + currentEnd = new Date(now); + + previousEnd = new Date(currentStart); + + break; + + + default: + throw new Error("Invalid period value"); + } + + return { + currentStart, + currentEnd, + previousStart, + previousEnd, + }; + } -async function fetchCounts(orgId, start, end) { + + +async function fetchCounts(orgId, start, end, role) { + const params = []; + let query = ` + SELECT event_type, COUNT(*) AS count + FROM activity_events e + JOIN github_users u ON u.id = e.user_id + JOIN repos r ON r.github_repo_id = e.repo_id + `; + + const whereClauses = []; + + params.push(String(orgId).toLowerCase()); + whereClauses.push(`LOWER(r.owner) = $${params.length}`); + + if (Array.isArray(EXCLUDED_GITHUB_LOGINS) && EXCLUDED_GITHUB_LOGINS.length > 0) { + params.push(EXCLUDED_GITHUB_LOGINS.map((l) => String(l).toLowerCase())); + whereClauses.push(`LOWER(u.login) <> ALL($${params.length})`); + } + + params.push(start, end); + whereClauses.push(`e.created_at BETWEEN $${params.length - 1} AND $${params.length}`); + + + let userDetailsJoin = userDetailsJoinSql(null); + + if (role) { + params.push(role); + userDetailsJoin = userDetailsJoinSql(`$${params.length}`); + } + + + query += ` + + ${userDetailsJoin} + WHERE ${whereClauses.join(" AND ")} + GROUP BY event_type; + `; + + const result = await db.query(query, params); + + const summary = { + commits: 0, + prs: 0, + reviews: 0, + activity: 0, + }; + + result.rows.forEach((row) => { + if (row.event_type === "commit") summary.commits = Number(row.count); + if (row.event_type === "pr") summary.prs = Number(row.count); + if (row.event_type === "review") summary.reviews = Number(row.count); + }); - summary.activity = summary.commits + summary.prs + summary.reviews; + + + summary.activity = summary.prs + summary.reviews; + + return summary; + } + + function calculateChange(current, previous) { + const safePercent = (c, p) => { + if (p === 0) { + return c === 0 ? 0 : 100; + } + + return Number((((c - p) / p) * 100).toFixed(1)); + }; + + return { + commits: safePercent(current.commits, previous.commits), + prs: safePercent(current.prs, previous.prs), + reviews: safePercent(current.reviews, previous.reviews), + activity: safePercent(current.activity, previous.activity), + }; + } -async function getOrgSummary(orgId, period) { + + +async function getOrgSummary(orgId, period, role) { + const { currentStart, currentEnd, previousStart, previousEnd } = + getDateRanges(period); - const current = await fetchCounts(orgId, currentStart, currentEnd); - const previous = await fetchCounts(orgId, previousStart, previousEnd); + + + const current = await fetchCounts(orgId, currentStart, currentEnd, role); + + const previous = await fetchCounts(orgId, previousStart, previousEnd, role); + + const change = calculateChange(current, previous); + + return { + total_commits: current.commits, + total_prs: current.prs, + total_reviews: current.reviews, + total_activity: current.activity, + change, + }; + } + + module.exports = { + getOrgSummary, -}; \ No newline at end of file + +}; + + diff --git a/github-activity-tracker/backend/services/orgUsersService.js b/github-activity-tracker/backend/services/orgUsersService.js index 7065945..9713c6b 100644 --- a/github-activity-tracker/backend/services/orgUsersService.js +++ b/github-activity-tracker/backend/services/orgUsersService.js @@ -3,19 +3,17 @@ const { EXCLUDED_GITHUB_LOGINS } = require("../config/excludedGitHubLogins"); const DEFAULT_LIMIT = 20; -/* ------------------------------------------------ - Determine date ranges for daily/weekly/monthly ------------------------------------------------- */ function getDateRanges(period) { const periods = { daily: 1, weekly: 7, monthly: 30, + yearly: 365, }; const days = periods[period]; if (!days) { - throw new Error('Invalid period'); + throw new Error("Invalid period"); } const end = new Date(); @@ -26,7 +24,6 @@ function getDateRanges(period) { start.setUTCHours(0, 0, 0, 0); const prevEnd = new Date(start.getTime() - 1); - const prevStart = new Date(prevEnd); prevStart.setUTCDate(prevEnd.getUTCDate() - (days - 1)); prevStart.setUTCHours(0, 0, 0, 0); @@ -34,144 +31,174 @@ function getDateRanges(period) { return { start, end, prevStart, prevEnd }; } -/* ----------------------------------------------- - Difference helper ------------------------------------------------- */ function diff(current, previous) { return current - previous; } -/* ------------------------------------------------ - MAIN FUNCTION WITH PAGINATION ------------------------------------------------- */ -const getOrgUsers = async ( - orgId, - period = "weekly", - page = 1, - limit = DEFAULT_LIMIT, -) => { - // ensure numbers - page = parseInt(page) || 1; - limit = parseInt(limit) || DEFAULT_LIMIT; +function sortUsers(results, sortBy, sortOrder) { + const direction = sortOrder === "asc" ? 1 : -1; - const { start, end, prevStart, prevEnd } = getDateRanges(period); + if (sortBy === "prs") { + results.sort((a, b) => (a.prs - b.prs) * direction); + return; + } + + if (sortBy === "reviews") { + results.sort((a, b) => (a.reviews - b.reviews) * direction); + return; + } - /* 1️⃣ Fetch users */ - const usersParams = []; - let usersQuery = ` + results.sort((a, b) => { + if (b.total_activity !== a.total_activity) { + return b.total_activity - a.total_activity; + } + if (a.is_active !== b.is_active) { + return a.is_active ? -1 : 1; + } + return String(a.login).localeCompare(String(b.login)); + }); +} + +async function fetchAssignments(role) { + const params = []; + let query = ` SELECT - u.id, + ud.id AS assignment_id, + u.id AS user_id, u.login AS login, - u.avatar_url AS avatar + u.name AS name, + u.avatar_url AS avatar, + ur.name AS role, + ud.active AS is_active, + ud.active_from AS active_from, + ud.active_to AS active_to FROM github_users u + JOIN user_details ud ON ud.user_id = u.id + LEFT JOIN user_roles ur ON ur.id = ud.role_id `; + const whereClauses = ["(ud.active = true OR ud.role_id IS NOT NULL)"]; + if (Array.isArray(EXCLUDED_GITHUB_LOGINS) && EXCLUDED_GITHUB_LOGINS.length > 0) { - usersParams.push(EXCLUDED_GITHUB_LOGINS.map((l) => String(l).toLowerCase())); - usersQuery += ` WHERE LOWER(u.login) <> ALL($${usersParams.length}) `; + params.push(EXCLUDED_GITHUB_LOGINS.map((l) => String(l).toLowerCase())); + whereClauses.push(`LOWER(u.login) <> ALL($${params.length})`); } - usersQuery += ` - ORDER BY u.login ASC; - `; + if (role) { + params.push(role); + whereClauses.push(`ur.name = $${params.length}`); + } + + query += ` WHERE ${whereClauses.join(" AND ")} `; + query += " ORDER BY u.login ASC, ud.active DESC, ud.active_from DESC, ud.id DESC"; - const usersRes = await db.query(usersQuery, usersParams); - const users = usersRes.rows; + const result = await db.query(query, params); + return result.rows; +} - /* 2️⃣ Current period activity */ - const activityParamsBase = []; - let activityQuery = ` +async function fetchAssignmentActivityMap(orgId, role, start, end) { + const params = [String(orgId).toLowerCase()]; + let query = ` SELECT - e.user_id AS user_id, - COUNT(*) FILTER (WHERE event_type = 'commit') AS commits, - COUNT(*) FILTER (WHERE event_type = 'pr') AS prs, - COUNT(*) FILTER (WHERE event_type = 'review') AS reviews + ud.id AS assignment_id, + COUNT(*) FILTER (WHERE e.event_type = 'commit') AS commits, + COUNT(*) FILTER (WHERE e.event_type = 'pr') AS prs, + COUNT(*) FILTER (WHERE e.event_type = 'review') AS reviews FROM activity_events e JOIN github_users u ON u.id = e.user_id JOIN repos r ON r.github_repo_id = e.repo_id + JOIN user_details ud ON ud.user_id = e.user_id + AND ud.active_from <= e.created_at + AND (ud.active_to IS NULL OR e.created_at < ud.active_to) + LEFT JOIN user_roles ur ON ur.id = ud.role_id + WHERE LOWER(r.owner) = $1 + AND (ud.active = true OR ud.role_id IS NOT NULL) `; - activityParamsBase.push(String(orgId).toLowerCase()); - activityQuery += ` WHERE LOWER(r.owner) = $${activityParamsBase.length} `; - if (Array.isArray(EXCLUDED_GITHUB_LOGINS) && EXCLUDED_GITHUB_LOGINS.length > 0) { - activityParamsBase.push(EXCLUDED_GITHUB_LOGINS.map((l) => String(l).toLowerCase())); - activityQuery += ` AND LOWER(u.login) <> ALL($${activityParamsBase.length}) `; + params.push(EXCLUDED_GITHUB_LOGINS.map((l) => String(l).toLowerCase())); + query += ` AND LOWER(u.login) <> ALL($${params.length})`; } - activityQuery += ` AND e.created_at BETWEEN $${activityParamsBase.length + 1} AND $${activityParamsBase.length + 2} `; - - activityQuery += ` - GROUP BY e.user_id; - `; + if (role) { + params.push(role); + query += ` AND ur.name = $${params.length}`; + } - const currentRes = await db.query(activityQuery, [ - ...activityParamsBase, - start.toISOString(), - end.toISOString(), - ]); + params.push(start.toISOString(), end.toISOString()); + query += ` AND e.created_at BETWEEN $${params.length - 1} AND $${params.length}`; + query += " GROUP BY ud.id"; - const currentMap = {}; + const result = await db.query(query, params); + const map = {}; - currentRes.rows.forEach((row) => { - currentMap[row.user_id] = { - commits: Number(row.commits), - prs: Number(row.prs), - reviews: Number(row.reviews), + result.rows.forEach((row) => { + map[row.assignment_id] = { + commits: Number(row.commits) || 0, + prs: Number(row.prs) || 0, + reviews: Number(row.reviews) || 0, }; }); - /* 3️⃣ Previous period activity */ - const previousRes = await db.query(activityQuery, [ - ...activityParamsBase, - prevStart.toISOString(), - prevEnd.toISOString(), - ]); + return map; +} - const previousMap = {}; +const getOrgUsers = async ( + orgId, + period = "weekly", + page = 1, + limit = DEFAULT_LIMIT, + role = null, + search = null, + sortBy = null, + sortOrder = "desc" +) => { + page = parseInt(page, 10) || 1; + limit = parseInt(limit, 10) || DEFAULT_LIMIT; - previousRes.rows.forEach((row) => { - previousMap[row.user_id] = { - commits: Number(row.commits), - prs: Number(row.prs), - reviews: Number(row.reviews), - }; - }); + const { start, end, prevStart, prevEnd } = getDateRanges(period); + const assignments = await fetchAssignments(role); + const currentMap = await fetchAssignmentActivityMap(orgId, role, start, end); + const previousMap = await fetchAssignmentActivityMap(orgId, role, prevStart, prevEnd); - /* 4️⃣ Construct final user list */ - const final = users.map((u) => { - const current = currentMap[u.id] || { commits: 0, prs: 0, reviews: 0 }; - const previous = previousMap[u.id] || { commits: 0, prs: 0, reviews: 0 }; + const final = assignments.map((row) => { + const current = currentMap[row.assignment_id] || { commits: 0, prs: 0, reviews: 0 }; + const previous = previousMap[row.assignment_id] || { commits: 0, prs: 0, reviews: 0 }; return { - login: u.login, - avatar: u.avatar, - + assignment_id: row.assignment_id, + login: row.login, + name: row.name || null, + avatar: row.avatar, + role: row.role || null, + is_active: Boolean(row.is_active), + active_from: row.active_from, + active_to: row.active_to, commits: current.commits, prs: current.prs, reviews: current.reviews, - diffCommits: diff(current.commits, previous.commits), diffPRs: diff(current.prs, previous.prs), diffReviews: diff(current.reviews, previous.reviews), - - total_activity: current.commits + current.prs + current.reviews, + total_activity: current.prs + current.reviews, }; }); - /* 5️⃣ Sort by activity */ - final.sort((a, b) => b.total_activity - a.total_activity); + sortUsers(final, sortBy, sortOrder); - /* 6️⃣ Pagination */ - const totalUsers = final.length; - const totalPages = Math.ceil(totalUsers / limit); + const term = search ? String(search).trim().toLowerCase() : ""; + const results = term + ? final.filter( + (u) => + (u.login || "").toLowerCase().includes(term) + || (u.name || "").toLowerCase().includes(term) + ) + : final; + const totalUsers = results.length; + const totalPages = Math.ceil(totalUsers / limit); const startIndex = (page - 1) * limit; - const endIndex = startIndex + limit; - - const usersPage = final.slice(startIndex, endIndex); - - /* 7️⃣ Return paginated response */ + const usersPage = results.slice(startIndex, startIndex + limit); return { users: usersPage, @@ -185,3 +212,5 @@ const getOrgUsers = async ( module.exports = { getOrgUsers, }; + + diff --git a/github-activity-tracker/backend/services/organizationsService.js b/github-activity-tracker/backend/services/organizationsService.js new file mode 100644 index 0000000..61d8ede --- /dev/null +++ b/github-activity-tracker/backend/services/organizationsService.js @@ -0,0 +1,57 @@ +const pool = require('../db/dbPool'); +const { parseOrganizationsFromEnv } = require('../db/initLookupTables'); + +async function getAllOrganizations() { + const result = await pool.query( + 'SELECT id, slug, name FROM organizations ORDER BY name ASC' + ); + return result.rows; +} + +async function getOrganizationSlugs() { + const organizations = await getAllOrganizations(); + return organizations.map((organization) => organization.slug); +} + +async function getOrganizationNames() { + return getOrganizationSlugs(); +} + +function normalizeOrganization(organization) { + return String(organization).trim().toLowerCase(); +} + +async function isValidOrganization(organization) { + if (!organization || typeof organization !== 'string') { + return false; + } + + const normalized = normalizeOrganization(organization); + const result = await pool.query( + 'SELECT 1 FROM organizations WHERE slug = $1 LIMIT 1', + [normalized] + ); + return result.rowCount > 0; +} + +async function getOrganizationIdBySlug(organization) { + if (!organization || typeof organization !== 'string') { + return null; + } + + const result = await pool.query( + 'SELECT id FROM organizations WHERE slug = $1 LIMIT 1', + [normalizeOrganization(organization)] + ); + return result.rows[0]?.id || null; +} + +module.exports = { + getAllOrganizations, + getOrganizationSlugs, + getOrganizationNames, + normalizeOrganization, + isValidOrganization, + getOrganizationIdBySlug, + parseOrganizationsFromEnv, +}; diff --git a/github-activity-tracker/backend/services/prSyncService.js b/github-activity-tracker/backend/services/prSyncService.js index 9c543bd..80dc549 100644 --- a/github-activity-tracker/backend/services/prSyncService.js +++ b/github-activity-tracker/backend/services/prSyncService.js @@ -2,6 +2,7 @@ const githubClient = require('../utils/githubClient'); const pool = require('../db/dbPool'); const { GITHUB, POSTGRES } = require('../config/errorCodes'); const { isExcludedGitHubLogin } = require('../config/excludedGitHubLogins'); +const { upsertGitHubUser } = require('./githubUserService'); /** * Sync pull requests for a single repository. @@ -102,23 +103,13 @@ async function syncPRs(repoId) { type, } = author; - // Upsert into github_users using github_user_id as unique key - const userResult = await pool.query( - ` - INSERT INTO github_users (github_user_id, login, avatar_url, html_url, type) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (github_user_id) - DO UPDATE SET - login = EXCLUDED.login, - avatar_url = EXCLUDED.avatar_url, - html_url = EXCLUDED.html_url, - type = EXCLUDED.type - RETURNING id - `, - [github_user_id, login, avatar_url || null, html_url || null, type || null] - ); - - const userId = userResult.rows[0].id; + const userId = await upsertGitHubUser({ + github_user_id, + login, + avatar_url, + html_url, + type, + }); // Insert event first; only increment prs_count if this PR is new. const eventInsert = await pool.query( diff --git a/github-activity-tracker/backend/services/reviewSyncService.js b/github-activity-tracker/backend/services/reviewSyncService.js index 62a18f6..3669164 100644 --- a/github-activity-tracker/backend/services/reviewSyncService.js +++ b/github-activity-tracker/backend/services/reviewSyncService.js @@ -3,6 +3,7 @@ require('dotenv').config(); const pool = require('../db/dbPool'); const { POSTGRES } = require('../config/errorCodes'); const { isExcludedGitHubLogin } = require('../config/excludedGitHubLogins'); +const { upsertGitHubUser } = require('./githubUserService'); const GRAPHQL_URL = 'https://api.github.com/graphql'; const PR_PAGE_SIZE = 50; @@ -50,7 +51,7 @@ const QUERY_PR_PAGE = ` author { __typename login - ... on User { databaseId avatarUrl url } + ... on User { databaseId avatarUrl url name } ... on Bot { databaseId avatarUrl url } } } @@ -74,7 +75,7 @@ const QUERY_PR_REVIEWS_PAGE = ` author { login __typename - ... on User { databaseId avatarUrl url } + ... on User { databaseId avatarUrl url name } ... on Bot { databaseId avatarUrl url } } } @@ -179,23 +180,16 @@ async function syncReviews(repoId) { const avatarUrl = reviewer.avatarUrl || null; const htmlUrl = reviewer.url || null; const type = reviewer.__typename === 'Bot' ? 'Bot' : 'User'; + const profileName = reviewer.name || null; - // Upsert reviewer into github_users - const userResult = await pool.query( - ` - INSERT INTO github_users (github_user_id, login, avatar_url, html_url, type) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (github_user_id) - DO UPDATE SET - login = EXCLUDED.login, - avatar_url = EXCLUDED.avatar_url, - html_url = EXCLUDED.html_url, - type = EXCLUDED.type - RETURNING id - `, - [githubUserId, login, avatarUrl, htmlUrl, type] - ); - const userId = userResult.rows[0].id; + const userId = await upsertGitHubUser({ + github_user_id: githubUserId, + login, + avatar_url: avatarUrl, + html_url: htmlUrl, + type, + name: profileName, + }); // Insert event first; only increment reviews_count if this review is new. const eventInsert = await pool.query( diff --git a/github-activity-tracker/backend/services/userDetailsService.js b/github-activity-tracker/backend/services/userDetailsService.js index 5290bd5..2783995 100644 --- a/github-activity-tracker/backend/services/userDetailsService.js +++ b/github-activity-tracker/backend/services/userDetailsService.js @@ -1,5 +1,6 @@ const pool = require('../db/dbPool'); const { isExcludedGitHubLogin } = require('../config/excludedGitHubLogins'); +const { userDetailsJoinSql } = require('../utils/userRoleSql'); /* ----------------------------------------------- Helper: Generate continuous UTC calendar-day keys @@ -26,15 +27,23 @@ function percentChange(current, previous) { /* ------------------------------------------------ MAIN SERVICE ------------------------------------------------ */ -async function getUserDetails(orgId, login, period) { +async function getUserDetails(orgId, login, period, role = null) { if (isExcludedGitHubLogin(login)) { throw new Error('User not found'); } /* 1. Get user details */ const userQuery = ` - SELECT id, login, avatar_url, NULL as name, NULL as email - FROM github_users - WHERE login = $1 + SELECT + u.id, + u.login, + u.avatar_url, + u.name, + ur.name AS role, + NULL AS email + FROM github_users u + LEFT JOIN user_details ud ON ud.user_id = u.id AND ud.active = true + LEFT JOIN user_roles ur ON ur.id = ud.role_id + WHERE u.login = $1 `; const userRes = await pool.query(userQuery, [login]); @@ -50,6 +59,7 @@ async function getUserDetails(orgId, login, period) { daily: 1, weekly: 7, monthly: 30, + yearly: 365, }; const days = periods[period]; @@ -70,30 +80,40 @@ async function getUserDetails(orgId, login, period) { prevStart.setUTCDate(prevEnd.getUTCDate() - (days - 1)); prevStart.setUTCHours(0, 0, 0, 0); + const orgOwner = String(orgId).toLowerCase(); + /* 3. Fetch daily activity for selected range (scoped to org repos) */ - const dailyQuery = ` + let dailyQuery = ` SELECT DATE(e.created_at) as date, COUNT(*) FILTER (WHERE e.event_type = 'commit') AS commits, COUNT(*) FILTER (WHERE e.event_type = 'pr') AS prs, COUNT(*) FILTER (WHERE e.event_type = 'review') AS reviews FROM activity_events e + JOIN github_users u ON u.id = e.user_id JOIN repos r ON r.github_repo_id = e.repo_id + `; + + const dailyParams = [userId, start.toISOString(), end.toISOString(), orgOwner]; + + dailyQuery += userDetailsJoinSql(role ? '$5' : null); + + if (role) { + dailyParams.push(role); + } + + dailyQuery += ` WHERE e.user_id = $1 AND LOWER(r.owner) = $4 AND e.created_at BETWEEN $2 AND $3 + `; + + dailyQuery += ` GROUP BY DATE(e.created_at) ORDER BY DATE(e.created_at) `; - const orgOwner = String(orgId).toLowerCase(); - - const dailyRes = await pool.query(dailyQuery, [ - userId, - start.toISOString(), - end.toISOString(), - orgOwner, - ]); + const dailyRes = await pool.query(dailyQuery, dailyParams); /* 4. Fill missing days */ const dateRange = generateDateRange(start, days); @@ -120,12 +140,11 @@ async function getUserDetails(orgId, login, period) { const totalReviews = dailyActivity.reduce((a, b) => a + b.reviews, 0); /* 6. Fetch previous period totals */ - const prevRes = await pool.query(dailyQuery, [ - userId, - prevStart.toISOString(), - prevEnd.toISOString(), - orgOwner, - ]); + const prevParams = [userId, prevStart.toISOString(), prevEnd.toISOString(), orgOwner]; + if (role) { + prevParams.push(role); + } + const prevRes = await pool.query(dailyQuery, prevParams); const prevCommits = prevRes.rows.reduce((a, b) => a + Number(b.commits), 0); const prevPRs = prevRes.rows.reduce((a, b) => a + Number(b.prs), 0); @@ -175,6 +194,7 @@ async function getUserDetails(orgId, login, period) { name: user.name || null, email: user.email || null, avatar: user.avatar_url, + role: user.role || null, }, summary: { commits: totalCommits, diff --git a/github-activity-tracker/backend/services/userRoleService.js b/github-activity-tracker/backend/services/userRoleService.js new file mode 100644 index 0000000..411e776 --- /dev/null +++ b/github-activity-tracker/backend/services/userRoleService.js @@ -0,0 +1,216 @@ +const pool = require('../db/dbPool'); +const { isValidUserRole, getUserRoleIdByName } = require('./userRolesService'); +const { + isValidOrganization, + normalizeOrganization, + getOrganizationIdBySlug, +} = require('./organizationsService'); + +async function findUserByLogin(login) { + const result = await pool.query( + ` + SELECT id, github_user_id, login, name + FROM github_users + WHERE LOWER(login) = LOWER($1) + `, + [login] + ); + + return result.rows[0] || null; +} + +function formatUserRoleResponse(user, details) { + return { + user_id: user.id, + github_user_id: user.github_user_id, + login: user.login, + name: user.name || null, + role: details?.role || null, + organization: details?.organization || null, + }; +} + +async function getUserRole(login) { + const user = await findUserByLogin(login); + + if (!user) { + return null; + } + + const detailsResult = await pool.query( + ` + SELECT ur.name AS role, o.slug AS organization + FROM user_details ud + LEFT JOIN user_roles ur ON ur.id = ud.role_id + LEFT JOIN organizations o ON o.id = ud.organization_id + WHERE ud.user_id = $1 AND ud.active = true + `, + [user.id] + ); + + return formatUserRoleResponse(user, detailsResult.rows[0] || null); +} + +async function resolveFirstAssignmentActiveFrom(client, userId) { + const earliestResult = await client.query( + ` + SELECT MIN(active_from) AS earliest + FROM user_details + WHERE user_id = $1 + `, + [userId] + ); + + const activityResult = await client.query( + ` + SELECT MIN(e.created_at) AS earliest + FROM activity_events e + WHERE e.user_id = $1 + `, + [userId] + ); + + const candidates = [ + earliestResult.rows[0]?.earliest, + activityResult.rows[0]?.earliest, + ].filter(Boolean); + + if (candidates.length === 0) { + return new Date(); + } + + return new Date( + Math.min(...candidates.map((value) => new Date(value).getTime())) + ); +} + +async function setUserRole({ login, role, organization }) { + if (!login || typeof login !== 'string') { + throw new Error('login is required'); + } + + if (!role || typeof role !== 'string') { + throw new Error('role is required'); + } + + if (!organization || typeof organization !== 'string') { + throw new Error('organization is required'); + } + + const trimmedRole = role.trim(); + const normalizedOrganization = normalizeOrganization(organization); + + if (!isValidUserRole(trimmedRole)) { + throw new Error('Invalid role value'); + } + + if (!isValidOrganization(normalizedOrganization)) { + throw new Error('Invalid organization value'); + } + + const roleId = await getUserRoleIdByName(trimmedRole); + const organizationId = await getOrganizationIdBySlug(normalizedOrganization); + + if (!roleId || !organizationId) { + throw new Error('Invalid role or organization value'); + } + + const user = await findUserByLogin(login); + + if (!user) { + return null; + } + + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + const activeResult = await client.query( + ` + SELECT id, role_id, organization_id, active_from + FROM user_details + WHERE user_id = $1 AND active = true + ORDER BY active_from DESC, id DESC + FOR UPDATE + `, + [user.id] + ); + + const activeRows = activeResult.rows; + const active = activeRows[0] || null; + + if ( + active + && active.role_id === roleId + && active.organization_id === organizationId + ) { + await client.query('COMMIT'); + return formatUserRoleResponse(user, { + role: trimmedRole, + organization: normalizedOrganization, + }); + } + + const roleHistoryResult = await client.query( + ` + SELECT 1 + FROM user_details + WHERE user_id = $1 + AND role_id IS NOT NULL + LIMIT 1 + `, + [user.id] + ); + const hasEverAssignedRole = roleHistoryResult.rowCount > 0; + const isFirstAssignment = !hasEverAssignedRole; + const activeFrom = isFirstAssignment + ? await resolveFirstAssignmentActiveFrom(client, user.id) + : new Date(); + + if (activeRows.length > 0) { + await client.query( + ` + UPDATE user_details + SET active = false, + active_to = $2, + updated_at = CURRENT_TIMESTAMP + WHERE user_id = $1 + AND active = true + `, + [user.id, activeFrom] + ); + } + + await client.query( + ` + INSERT INTO user_details ( + user_id, + role_id, + organization_id, + active, + active_from, + active_to + ) + VALUES ($1, $2, $3, true, $4, NULL) + `, + [user.id, roleId, organizationId, activeFrom] + ); + + await client.query('COMMIT'); + return formatUserRoleResponse(user, { + role: trimmedRole, + organization: normalizedOrganization, + }); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +} + +module.exports = { + getUserRole, + setUserRole, +}; diff --git a/github-activity-tracker/backend/services/userRolesService.js b/github-activity-tracker/backend/services/userRolesService.js new file mode 100644 index 0000000..a2f82d4 --- /dev/null +++ b/github-activity-tracker/backend/services/userRolesService.js @@ -0,0 +1,46 @@ +const pool = require('../db/dbPool'); +const { parseUserRolesFromEnv } = require('../config/defaultUserRoles'); + +async function getAllUserRoles() { + const result = await pool.query( + 'SELECT id, name FROM user_roles ORDER BY name ASC' + ); + return result.rows; +} + +async function getUserRoleNames() { + const roles = await getAllUserRoles(); + return roles.map((role) => role.name); +} + +async function isValidUserRole(roleName) { + if (!roleName || typeof roleName !== 'string') { + return false; + } + + const trimmed = roleName.trim(); + const result = await pool.query( + 'SELECT 1 FROM user_roles WHERE name = $1 LIMIT 1', + [trimmed] + ); + return result.rowCount > 0; +} + +async function getUserRoleIdByName(roleName) { + if (!roleName || typeof roleName !== 'string') { + return null; + } + + const result = await pool.query( + 'SELECT id FROM user_roles WHERE name = $1 LIMIT 1', + [roleName.trim()] + ); + return result.rows[0]?.id || null; +} + +module.exports = { + getAllUserRoles, + getUserRoleNames, + isValidUserRole, + getUserRoleIdByName, +}; diff --git a/github-activity-tracker/backend/utils/userRoleSql.js b/github-activity-tracker/backend/utils/userRoleSql.js new file mode 100644 index 0000000..d168807 --- /dev/null +++ b/github-activity-tracker/backend/utils/userRoleSql.js @@ -0,0 +1,54 @@ +function userAssignmentWindowSql() { + return ` + ud.user_id = u.id + AND ud.active = true + AND ud.active_from <= e.created_at + AND (ud.active_to IS NULL OR e.created_at < ud.active_to)`; +} + +function userDetailsRoleNameMatchSql(roleParam) { + return ` + ud.role_id IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM user_roles ur_match + WHERE ur_match.id = ud.role_id + AND ur_match.name = ${roleParam} + )`; +} + +function userDetailsExistsSql(roleParam = null) { + const roleClause = roleParam + ? `AND ${userDetailsRoleNameMatchSql(roleParam)}` + : ''; + + return ` + EXISTS ( + SELECT 1 + FROM user_details ud + WHERE ${userAssignmentWindowSql()} + ${roleClause} + )`; +} + +function userDetailsJoinSql(roleParam = null) { + const roleClause = roleParam + ? `AND ${userDetailsRoleNameMatchSql(roleParam)}` + : ''; + + return ` + JOIN user_details ud ON ${userAssignmentWindowSql()} + ${roleClause}`; +} + +// Used by userDetailsService when scoping a single known user. +function userDetailsRoleFilterSql(roleParam) { + return userDetailsRoleNameMatchSql(roleParam); +} + +module.exports = { + userAssignmentWindowSql, + userDetailsRoleFilterSql, + userDetailsJoinSql, + userDetailsExistsSql, +}; diff --git a/github-activity-tracker/deploy/gh-tracker-values.yaml b/github-activity-tracker/deploy/gh-tracker-values.yaml index f142f05..21e49ce 100644 --- a/github-activity-tracker/deploy/gh-tracker-values.yaml +++ b/github-activity-tracker/deploy/gh-tracker-values.yaml @@ -6,6 +6,10 @@ ghtrackerservice: rds_port: "5432" rds_username: "" # provide username secrets: + # Rancher/K8s equivalent of backend/.env for lookup seed data. + app-config: + github_org: mosip,inji + user_roles: Developer,Tech Lead,Architect,Product Owner,Leadership,QA Engineer,DevOps Engineer rds-secret: rds_password: # Provide rds password github-token: diff --git a/github-activity-tracker/docker-compose.yml b/github-activity-tracker/docker-compose.yml index 7890b75..cbcd0a0 100644 --- a/github-activity-tracker/docker-compose.yml +++ b/github-activity-tracker/docker-compose.yml @@ -26,6 +26,8 @@ services: RDS_USER: ${RDS_USER:-github_user} RDS_PASSWORD: ${RDS_PASSWORD:-github_password} GITHUB_TOKEN: ${GITHUB_TOKEN} + GITHUB_ORG: ${GITHUB_ORG} + USER_ROLES: ${USER_ROLES} ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost} NODE_ENV: production depends_on: @@ -33,6 +35,7 @@ services: condition: service_healthy ports: - "3000:3000" + command: ["sh", "-c", "npm run migrate && node app.js"] restart: unless-stopped # Run once per environment: @@ -46,6 +49,8 @@ services: RDS_DATABASE: ${RDS_DATABASE:-github_data} RDS_USER: ${RDS_USER:-github_user} RDS_PASSWORD: ${RDS_PASSWORD:-github_password} + GITHUB_ORG: ${GITHUB_ORG} + USER_ROLES: ${USER_ROLES} NODE_ENV: production depends_on: db: diff --git a/github-activity-tracker/frontend/.env.example b/github-activity-tracker/frontend/.env.example index 201de63..99cbcd5 100644 --- a/github-activity-tracker/frontend/.env.example +++ b/github-activity-tracker/frontend/.env.example @@ -1,2 +1 @@ VITE_API_BASE_URL=http://localhost:3000 -VITE_ORGANIZATIONS=mosip,inji diff --git a/github-activity-tracker/frontend/index.html b/github-activity-tracker/frontend/index.html index 1cdc46c..fae0537 100644 --- a/github-activity-tracker/frontend/index.html +++ b/github-activity-tracker/frontend/index.html @@ -3,7 +3,7 @@ - + GitHub Activity Dashboard diff --git a/github-activity-tracker/frontend/src/App.tsx b/github-activity-tracker/frontend/src/App.tsx index b8f8b44..7553120 100644 --- a/github-activity-tracker/frontend/src/App.tsx +++ b/github-activity-tracker/frontend/src/App.tsx @@ -7,15 +7,15 @@ import TeamMembers from "./components/TeamMembers"; import LeaderboardCard from "./components/LeaderboardCard"; import UserProfile from "./components/UserProfile"; -import { DEFAULT_ORG } from "./lib/organizations"; +import { DEFAULT_PERIOD, type PeriodValue } from "./lib/periods"; import { fetchOrgSummary, fetchOrgActivity, fetchLeaderboard, + fetchOrganizations, } from "./lib/api"; /* SVG ICON IMPORTS */ -import CommitIcon from "./assets/CommitIcon.svg"; import PRIcon from "./assets/PRIcon.svg"; import CodeReviewIcon from "./assets/CodeReviewIcon.svg"; import TotalActivityIcon from "./assets/TotalActivityIcon.svg"; @@ -27,9 +27,9 @@ function App() { const [selectedUser, setSelectedUser] = useState(null); - const [selectedOrg, setSelectedOrg] = useState(DEFAULT_ORG); - const [period, setPeriod] = useState<"daily" | "weekly" | "monthly">("weekly"); - const [team, setTeam] = useState("all"); + const [selectedOrg, setSelectedOrg] = useState(""); + const [period, setPeriod] = useState(DEFAULT_PERIOD); + const [role, setRole] = useState("all"); const [project, setProject] = useState("all"); const [summary, setSummary] = useState(null); @@ -41,14 +41,36 @@ function App() { useEffect(() => { let cancelled = false; + async function loadOrganizations() { + try { + const orgs = await fetchOrganizations(); + if (!cancelled && orgs.length > 0) { + setSelectedOrg((current) => current || orgs[0]!.slug); + } + } catch (err) { + console.error("Error loading organizations:", err); + } + } + + loadOrganizations(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!selectedOrg) return; + + let cancelled = false; + async function loadDashboard() { setDashboardLoading(true); setDashboardError(null); try { const [summaryData, activityData] = await Promise.all([ - fetchOrgSummary(selectedOrg, period), - fetchOrgActivity(selectedOrg, period), + fetchOrgSummary(selectedOrg, period, role), + fetchOrgActivity(selectedOrg, period, role), ]); if (cancelled) return; @@ -68,9 +90,11 @@ function App() { return () => { cancelled = true; }; - }, [selectedOrg, period]); + }, [selectedOrg, period, role]); useEffect(() => { + if (!selectedOrg) return; + async function loadLeaderboard() { try { const data = await fetchLeaderboard(selectedOrg, period, 10); @@ -81,7 +105,6 @@ function App() { name: u.login, team: "—", project: "—", - commits: u.commits, prs: u.prs, reviews: u.reviews, total: u.score, @@ -99,6 +122,7 @@ function App() { const handleOrganizationChange = (org: string) => { setSelectedOrg(org); setProject("all"); + setRole("all"); }; const handleSelectUser = (name: string) => { @@ -117,8 +141,8 @@ function App() { onOrganizationChange={handleOrganizationChange} period={period} onPeriodChange={setPeriod} - team={team} - onTeamChange={setTeam} + role={role} + onRoleChange={setRole} project={project} onProjectChange={setProject} onDownloadCSV={() => {}} @@ -141,14 +165,7 @@ function App() { {!dashboardLoading && !dashboardError && ( <> -
- - +
a + b, 0)); + weekReviews.push(reviews.slice(i, i + 7).reduce((a, b) => a + b, 0)); + } + + return { labels: weekLabels, pullRequests: weekPRs, reviews: weekReviews }; +} + +function aggregateDailyIntoMonths( + labels: string[], + pullRequests: number[], + reviews: number[], +) { + const monthLabels: string[] = []; + const monthPRs: number[] = []; + const monthReviews: number[] = []; + + let currentMonth = ""; + let prSum = 0; + let reviewSum = 0; + + const flush = (monthKey: string) => { + monthLabels.push(formatMonthLabel(monthKey)); + monthPRs.push(prSum); + monthReviews.push(reviewSum); + prSum = 0; + reviewSum = 0; + }; + + for (let i = 0; i < labels.length; i++) { + const monthKey = labels[i].slice(0, 7); + if (currentMonth && monthKey !== currentMonth) { + flush(currentMonth); + } + currentMonth = monthKey; + prSum += pullRequests[i]; + reviewSum += reviews[i]; + } + + if (currentMonth) { + flush(currentMonth); + } + + return { + labels: monthLabels, + pullRequests: monthPRs, + reviews: monthReviews, + }; +} + const ActivityChart: React.FC = ({ data, period, showTitle = true, }) => { let labels = data?.labels ?? []; - let commits = data?.commits ?? []; let pullRequests = data?.prs ?? []; let reviews = data?.reviews ?? []; @@ -111,44 +181,30 @@ const ActivityChart: React.FC = ({ const isPreAggregatedWeekly = labels.length > 0 && labels.every((l) => /^Week\s+\d+$/i.test(l)); - if (period === "monthly" && labels.length > 0 && !isPreAggregatedWeekly) { - const weekLabels: string[] = []; - const weekCommits: number[] = []; - const weekPRs: number[] = []; - const weekReviews: number[] = []; - - for (let i = 0; i < labels.length; i += 7) { - const weekIndex = Math.floor(i / 7) + 1; + const isPreAggregatedMonthly = + labels.length > 0 && + labels.every((l) => !ISO_DATE.test(l)); - weekLabels.push(`Week ${weekIndex}`); - - weekCommits.push( - commits.slice(i, i + 7).reduce((a, b) => a + b, 0), - ); - - weekPRs.push( - pullRequests.slice(i, i + 7).reduce((a, b) => a + b, 0), - ); - - weekReviews.push( - reviews.slice(i, i + 7).reduce((a, b) => a + b, 0), - ); - } + if (period === "monthly" && labels.length > 0 && !isPreAggregatedWeekly) { + const aggregated = aggregateDailyIntoWeeks(labels, pullRequests, reviews); + labels = aggregated.labels; + pullRequests = aggregated.pullRequests; + reviews = aggregated.reviews; + } - labels = weekLabels; - commits = weekCommits; - pullRequests = weekPRs; - reviews = weekReviews; + if (period === "yearly" && labels.length > 0 && !isPreAggregatedMonthly) { + const aggregated = aggregateDailyIntoMonths(labels, pullRequests, reviews); + labels = aggregated.labels; + pullRequests = aggregated.pullRequests; + reviews = aggregated.reviews; } - const COLOR_COMMITS = "#3B82F6"; const COLOR_PULLS = "#10B981"; const COLOR_REVIEWS = "#F59E0B"; const chartData = { labels, datasets: [ - { label: "Commits", data: commits, backgroundColor: COLOR_COMMITS }, { label: "Pull Requests", data: pullRequests, @@ -185,7 +241,6 @@ const ActivityChart: React.FC = ({ const label = context.dataset.label; const value = context.raw; - if (label === "Commits") return `Commits : ${value}`; if (label === "Pull Requests") return `Pull Requests : ${value}`; if (label === "Reviews") return `Reviews : ${value}`; @@ -194,7 +249,6 @@ const ActivityChart: React.FC = ({ labelTextColor: function (context: any) { const label = context.dataset.label; - if (label === "Commits") return COLOR_COMMITS; if (label === "Pull Requests") return COLOR_PULLS; if (label === "Reviews") return COLOR_REVIEWS; return "#111"; @@ -220,8 +274,7 @@ const ActivityChart: React.FC = ({ {showTitle && (

- Activity Overview –{" "} - {period.charAt(0).toUpperCase() + period.slice(1)} + Activity Overview – {formatPeriodLabel(period)}

)} diff --git a/github-activity-tracker/frontend/src/components/ActivityItem.tsx b/github-activity-tracker/frontend/src/components/ActivityItem.tsx index 8b937ef..371f7f5 100644 --- a/github-activity-tracker/frontend/src/components/ActivityItem.tsx +++ b/github-activity-tracker/frontend/src/components/ActivityItem.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { GitCommit, GitPullRequest, AlertCircle, MessageSquare, User } from 'lucide-react'; +import { GitPullRequest, AlertCircle, MessageSquare, User } from 'lucide-react'; import type { ActivityItem } from '../lib/database.types'; interface ActivityItemProps { @@ -11,7 +11,6 @@ interface ActivityItemProps { } const icons = { - commit: GitCommit, pull_request: GitPullRequest, issue: AlertCircle, review: MessageSquare, diff --git a/github-activity-tracker/frontend/src/components/ActivityTable.tsx b/github-activity-tracker/frontend/src/components/ActivityTable.tsx index 130e183..c5f1e10 100644 --- a/github-activity-tracker/frontend/src/components/ActivityTable.tsx +++ b/github-activity-tracker/frontend/src/components/ActivityTable.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { GitCommit, GitPullRequest, AlertCircle, MessageSquare, User, Calendar } from 'lucide-react'; +import { GitPullRequest, AlertCircle, MessageSquare, User, Calendar } from 'lucide-react'; import { format } from 'date-fns'; import type { ActivityItem } from '../lib/database.types'; @@ -8,13 +8,13 @@ interface ActivityTableProps { } const icons = { - commit: GitCommit, pull_request: GitPullRequest, issue: AlertCircle, review: MessageSquare, }; export function ActivityTable({ activities }: ActivityTableProps) { + const visibleActivities = activities.filter((activity) => activity.type !== 'commit'); console.log('Activities received in UI:', activities); return ( @@ -47,14 +47,14 @@ export function ActivityTable({ activities }: ActivityTableProps) { - {activities.length === 0 ? ( + {visibleActivities.length === 0 ? ( No activities found ) : ( - activities.map((activity, index) => { + visibleActivities.map((activity, index) => { const Icon = icons[activity.type]; return ( diff --git a/github-activity-tracker/frontend/src/components/ActivityTrend.tsx b/github-activity-tracker/frontend/src/components/ActivityTrend.tsx index 24625f2..f8658e4 100644 --- a/github-activity-tracker/frontend/src/components/ActivityTrend.tsx +++ b/github-activity-tracker/frontend/src/components/ActivityTrend.tsx @@ -21,7 +21,6 @@ ChartJS.register( interface TrendPoint { date: string; - commits: number; prs: number; reviews: number; } @@ -36,15 +35,6 @@ const ActivityTrend: React.FC = ({ data }) => { const chartData = { labels, datasets: [ - { - label: "Commits", - data: data.map((d) => d.commits), - borderColor: "#3b82f6", - backgroundColor: "#3b82f6", - tension: 0.4, - pointRadius: 4, - pointBorderWidth: 2, - }, { label: "Pull Requests", data: data.map((d) => d.prs), diff --git a/github-activity-tracker/frontend/src/components/DetailView.tsx b/github-activity-tracker/frontend/src/components/DetailView.tsx index 9fc1d3d..0212047 100644 --- a/github-activity-tracker/frontend/src/components/DetailView.tsx +++ b/github-activity-tracker/frontend/src/components/DetailView.tsx @@ -4,7 +4,7 @@ import type { ActivityItem } from '../lib/database.types'; import Modal from './Modal'; interface DetailViewProps { - type: 'commit' | 'pull_request' | 'issue' | 'review' | null; + type: 'pull_request' | 'issue' | 'review' | null; data: ActivityItem[] | null; onClose: () => void; } @@ -22,7 +22,6 @@ const DetailView: React.FC = ({ type, data, onClose }) => { } const typeLabels: Record, string> = { - commit: 'Commits', pull_request: 'Pull Requests', issue: 'Issues', review: 'Reviews', diff --git a/github-activity-tracker/frontend/src/components/LeaderboardCard.tsx b/github-activity-tracker/frontend/src/components/LeaderboardCard.tsx index 75be67d..7c4d045 100644 --- a/github-activity-tracker/frontend/src/components/LeaderboardCard.tsx +++ b/github-activity-tracker/frontend/src/components/LeaderboardCard.tsx @@ -6,9 +6,9 @@ import BronzeIcon from "../assets/BronzeIcon.svg"; interface Leader { name: string; + login?: string; team: string; project: string; - commits: number; prs: number; reviews: number; total: number; @@ -68,7 +68,7 @@ const LeaderboardCard: React.FC = ({ leaders }) => {

{user.name}

- {user.team} • {user.project} + {user.login ? `@${user.login}` : `${user.team} • ${user.project}`}

@@ -81,13 +81,6 @@ const LeaderboardCard: React.FC = ({ leaders }) => { {/* METRICS ROW */}
-
- Commits: - - {user.commits} - -
-
PRs: diff --git a/github-activity-tracker/frontend/src/components/TeamMembers.tsx b/github-activity-tracker/frontend/src/components/TeamMembers.tsx index 5f55f12..6566ce8 100644 --- a/github-activity-tracker/frontend/src/components/TeamMembers.tsx +++ b/github-activity-tracker/frontend/src/components/TeamMembers.tsx @@ -1,6 +1,10 @@ import React, { useEffect, useState } from "react"; +import { ArrowDown, ArrowUp } from "lucide-react"; import { fetchOrgUsers } from "../lib/api"; +import type { PeriodValue } from "../lib/periods"; +type SortField = "prs" | "reviews"; +type SortOrder = "asc" | "desc"; const UserIcon = () => (
👤 @@ -9,9 +13,9 @@ const UserIcon = () => ( interface TeamMembersProps { org: string; - team: string; + role: string; project: string; - period: "daily" | "weekly" | "monthly"; + period: PeriodValue; onSelectUser?: (name: string) => void; } @@ -21,25 +25,87 @@ const getDiffColor = (diff: number) => { return "#155DFC"; }; +interface SortableHeaderProps { + label: string; + field: SortField; + sortBy: SortField | null; + sortOrder: SortOrder; + onSort: (field: SortField, order: SortOrder) => void; +} + +const SortableHeader: React.FC = ({ + label, + field, + sortBy, + sortOrder, + onSort, +}) => { + const isActive = sortBy === field; + + return ( + +
+ {label} +
+ + +
+
+ + ); +}; const TeamMembers: React.FC = ({ org, - team, + role, project, period, onSelectUser, }) => { const [members, setMembers] = useState([]); + const [userSearchTerm, setUserSearchTerm] = useState(""); + const [appliedSearch, setAppliedSearch] = useState(""); const [page, setPage] = useState(1); const [limit, setLimit] = useState(10); const [totalUsers, setTotalUsers] = useState(0); const [totalPages, setTotalPages] = useState(1); - + const [sortBy, setSortBy] = useState(null); + const [sortOrder, setSortOrder] = useState("desc"); useEffect(() => { async function loadUsers() { try { - const data = await fetchOrgUsers(org, period, page, limit); - + const data = await fetchOrgUsers( + org, + period, + page, + limit, + role, + appliedSearch, + sortBy, + sortOrder, + ); console.log("API RESPONSE:", data); if (Array.isArray(data)) { @@ -63,21 +129,27 @@ const TeamMembers: React.FC = ({ } loadUsers(); - }, [org, period, page, limit]); - + }, [org, period, page, limit, role, appliedSearch, sortBy, sortOrder]); useEffect(() => { setPage(1); - }, [org]); + }, [org, role, project]); - const filtered = members.filter((m) => { - const matchTeam = - team === "all" || (m.team || "").toLowerCase() === team.toLowerCase(); + const handleSearch = () => { + setPage(1); + setAppliedSearch(userSearchTerm.trim()); + }; + const handleSort = (field: SortField, order: SortOrder) => { + setSortBy(field); + setSortOrder(order); + setPage(1); + }; + const filtered = members.filter((m) => { const matchProject = project === "all" || (m.project || "").toLowerCase() === project.toLowerCase(); - return matchTeam && matchProject; + return matchProject; }); const startItem = totalUsers === 0 ? 0 : (page - 1) * limit + 1; @@ -85,33 +157,67 @@ const TeamMembers: React.FC = ({ return (
-

Team Members

+
+

Team Members

+
+ setUserSearchTerm(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + className="flex-1 sm:w-64 border border-gray-300 rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500" + /> + +
+
- +
+ + + + + + - - - - - - - + + + + {filtered.map((m, index) => ( onSelectUser?.(m.login)} role={onSelectUser ? "button" : undefined} tabIndex={onSelectUser ? 0 : undefined} aria-label={ onSelectUser - ? `View profile for ${m.login}` + ? `View profile for ${m.name || m.login}` : undefined } onKeyDown={(e) => { @@ -122,35 +228,26 @@ const TeamMembers: React.FC = ({ } }} > - - - - - - - - - @@ -73,7 +67,7 @@ export function UserActivityStats({ activities }: UserActivityStatsProps) { {sortedUsers.length === 0 ? ( - @@ -86,12 +80,6 @@ export function UserActivityStats({ activities }: UserActivityStatsProps) {
{user.name}
- - @@ -33,7 +31,6 @@ const UserActivityTable: React.FC = ({ rows }) => { {rows.map((r, i) => ( - diff --git a/github-activity-tracker/frontend/src/components/UserProfile.tsx b/github-activity-tracker/frontend/src/components/UserProfile.tsx index f133f57..d81200f 100644 --- a/github-activity-tracker/frontend/src/components/UserProfile.tsx +++ b/github-activity-tracker/frontend/src/components/UserProfile.tsx @@ -4,8 +4,13 @@ import { StatsCard } from "./StatsCard"; import ActivityChart from "./ActivityChart"; import ActivityTrend from "./ActivityTrend"; import { fetchUserDetails } from "../lib/api"; +import { + DEFAULT_PERIOD, + formatPeriodLabel, + PERIOD_OPTIONS, + type PeriodValue, +} from "../lib/periods"; -import CommitIcon from "../assets/CommitIcon.svg"; import PRIcon from "../assets/PRIcon.svg"; import CodeReviewIcon from "../assets/CodeReviewIcon.svg"; import DownloadIcon from "../assets/DownloadIcon.svg"; @@ -18,15 +23,12 @@ interface UserProfileProps { interface DailyActivityRow { date: string; - commits: number; prs: number; reviews: number; } const UserProfile: React.FC = ({ org, userName, onBack }) => { - const [period, setPeriod] = useState<"daily" | "weekly" | "monthly">( - "weekly", - ); + const [period, setPeriod] = useState(DEFAULT_PERIOD); const [userData, setUserData] = useState(null); @@ -52,17 +54,14 @@ const UserProfile: React.FC = ({ org, userName, onBack }) => { project: userData?.project || "Project Alpha", }; - const commits = userData?.summary?.commits || 0; const prs = userData?.summary?.prs || 0; const reviews = userData?.summary?.reviews || 0; - const changeCommits = userData?.summary?.change?.commits; const changePRs = userData?.summary?.change?.prs; const changeReviews = userData?.summary?.change?.reviews; const chartData = { labels: userData?.overview?.labels || [], - commits: userData?.overview?.commits || [], prs: userData?.overview?.prs || [], reviews: userData?.overview?.reviews || [], }; @@ -98,38 +97,19 @@ const UserProfile: React.FC = ({ org, userName, onBack }) => {
- - - - - + {PERIOD_OPTIONS.map(({ value, label }) => ( + + ))}
@@ -148,14 +128,7 @@ const UserProfile: React.FC = ({ org, userName, onBack }) => {
{/* Stats Cards */} -
- - +
= ({ org, userName, onBack }) => { {/* Activity Chart */}

- Activity Overview –{" "} - {period.charAt(0).toUpperCase() + period.slice(1)} + Activity Overview – {formatPeriodLabel(period)}

@@ -186,7 +158,6 @@ const UserProfile: React.FC = ({ org, userName, onBack }) => { data={ userData?.trend?.labels?.map((label: string, i: number) => ({ date: label, - commits: userData.trend.commits[i], prs: userData.trend.prs[i], reviews: userData.trend.reviews[i], })) || [] @@ -201,7 +172,6 @@ const UserProfile: React.FC = ({ org, userName, onBack }) => {
- @@ -213,10 +183,6 @@ const UserProfile: React.FC = ({ org, userName, onBack }) => { - - @@ -226,7 +192,7 @@ const UserProfile: React.FC = ({ org, userName, onBack }) => { ))} diff --git a/github-activity-tracker/frontend/src/lib/api.ts b/github-activity-tracker/frontend/src/lib/api.ts index 6168254..4e654f5 100644 --- a/github-activity-tracker/frontend/src/lib/api.ts +++ b/github-activity-tracker/frontend/src/lib/api.ts @@ -1,4 +1,5 @@ import axios from "axios"; +import type { PeriodValue } from "./periods"; // Production: use relative URLs (nginx proxies /orgs/, /admin/ to backend - no CORS) // Development: use direct backend URL @@ -75,11 +76,37 @@ export const fetchRepositoryStats = async (repositoryId: string) => { } }; +// Fetch tracked GitHub organizations +export const fetchOrganizations = async (): Promise< + Array<{ id: number; slug: string; name: string }> +> => { + try { + const response = await axios.get(`${API_BASE_URL}/organizations`); + return response.data; + } catch (error) { + throw new Error("Failed to fetch organizations"); + } +}; + +// Fetch assignable user job roles +export const fetchUserRoles = async (): Promise> => { + try { + const response = await axios.get(`${API_BASE_URL}/user-roles`); + return response.data; + } catch (error) { + throw new Error("Failed to fetch user roles"); + } +}; + // Fetch org-wide activity chart data -export const fetchOrgActivity = async (orgId: string, period: string) => { +export const fetchOrgActivity = async ( + orgId: string, + period: string, + role: string = "all", +) => { try { const response = await axios.get(`${API_BASE_URL}/orgs/${orgId}/activity`, { - params: { period }, + params: { period, role }, }); return response.data; } catch (error) { @@ -93,6 +120,10 @@ export const fetchOrgUsers = async ( period: string, page: number = 1, limit: number = 20, + role: string = "all", + search: string = "", + sortBy?: "prs" | "reviews" | null, + sortOrder: "asc" | "desc" = "desc", ) => { try { const response = await axios.get(`${API_BASE_URL}/orgs/${org}/users`, { @@ -100,6 +131,9 @@ export const fetchOrgUsers = async ( period, page, limit, + role, + ...(search ? { search } : {}), + ...(sortBy ? { sortBy, sortOrder } : {}), }, }); @@ -130,10 +164,14 @@ export const fetchLeaderboard = async ( }; // Fetch organization summary (commits, PRs, reviews) -export const fetchOrgSummary = async (orgId: string, period: string) => { +export const fetchOrgSummary = async ( + orgId: string, + period: string, + role: string = "all", +) => { try { const response = await axios.get(`${API_BASE_URL}/orgs/${orgId}/summary`, { - params: { period }, + params: { period, role }, }); return response.data; } catch (error) { @@ -145,7 +183,7 @@ export const fetchOrgSummary = async (orgId: string, period: string) => { export const fetchUserDetails = async ( orgId: string, login: string, - period: "daily" | "weekly" | "monthly", + period: PeriodValue, ) => { try { const response = await axios.get( diff --git a/github-activity-tracker/frontend/src/lib/hooks.ts b/github-activity-tracker/frontend/src/lib/hooks.ts index b1c946b..4108428 100644 --- a/github-activity-tracker/frontend/src/lib/hooks.ts +++ b/github-activity-tracker/frontend/src/lib/hooks.ts @@ -63,11 +63,11 @@ export function useGitHubActivity( if (signal.aborted) return; // Map Activity to ActivityItem - const mappedActivities: ActivityItem[] = data.map((activity: Activity) => ({ + const mappedActivities: ActivityItem[] = data + .filter((activity: Activity) => activity.type !== 'commit') + .map((activity: Activity) => ({ type: - activity.type === 'commit' - ? 'commit' - : activity.type === 'pull_request' + activity.type === 'pull_request' ? 'pull_request' : activity.type === 'issue' ? 'issue' diff --git a/github-activity-tracker/frontend/src/lib/organizations.ts b/github-activity-tracker/frontend/src/lib/organizations.ts index 0c82d89..9c0245c 100644 --- a/github-activity-tracker/frontend/src/lib/organizations.ts +++ b/github-activity-tracker/frontend/src/lib/organizations.ts @@ -1,16 +1,5 @@ export interface Organization { - id: string; - label: string; + id: number; + slug: string; + name: string; } - -// Org IDs are read from VITE_ORGANIZATIONS (comma-separated) in frontend/.env. -// IDs are kept lowercase to match the `owner` column; labels are uppercased for display. -const organizationsEnv = import.meta.env.VITE_ORGANIZATIONS!; - -export const ORGANIZATIONS: Organization[] = organizationsEnv - .split(",") - .map((id: string) => id.trim().toLowerCase()) - .filter((id: string) => id.length > 0) - .map((id: string) => ({ id, label: id.toUpperCase() })); - -export const DEFAULT_ORG: string = ORGANIZATIONS[0]!.id; diff --git a/github-activity-tracker/frontend/src/lib/periods.ts b/github-activity-tracker/frontend/src/lib/periods.ts new file mode 100644 index 0000000..b566fd5 --- /dev/null +++ b/github-activity-tracker/frontend/src/lib/periods.ts @@ -0,0 +1,14 @@ +export const PERIOD_OPTIONS = [ + { value: "weekly", label: "Week" }, + { value: "monthly", label: "Month" }, + { value: "yearly", label: "Year" }, +] as const; + +export type PeriodValue = (typeof PERIOD_OPTIONS)[number]["value"]; + +export const DEFAULT_PERIOD: PeriodValue = "weekly"; + +export function formatPeriodLabel(period: PeriodValue): string { + const match = PERIOD_OPTIONS.find((option) => option.value === period); + return match?.label ?? period; +} diff --git a/github-activity-tracker/frontend/src/vite-env.d.ts b/github-activity-tracker/frontend/src/vite-env.d.ts index c6e0e02..d43868c 100644 --- a/github-activity-tracker/frontend/src/vite-env.d.ts +++ b/github-activity-tracker/frontend/src/vite-env.d.ts @@ -2,7 +2,6 @@ interface ImportMetaEnv { readonly VITE_API_BASE_URL?: string; - readonly VITE_ORGANIZATIONS: string; } interface ImportMeta { diff --git a/github-activity-tracker/helm/gh-tracker-service/templates/_helpers.tpl b/github-activity-tracker/helm/gh-tracker-service/templates/_helpers.tpl index 290c5bb..13bc481 100644 --- a/github-activity-tracker/helm/gh-tracker-service/templates/_helpers.tpl +++ b/github-activity-tracker/helm/gh-tracker-service/templates/_helpers.tpl @@ -34,6 +34,52 @@ Create the name of the service account to use Compile all warnings into a single message. */}} +{{/* +Shared backend environment variables for app and migration job. +*/}} +{{- define "gh-tracker-service.env" -}} +- name: RDS_HOST + valueFrom: + configMapKeyRef: + key: rds-host + name: rds-config +- name: RDS_PORT + valueFrom: + configMapKeyRef: + key: rds-port + name: rds-config +- name: RDS_DATABASE + valueFrom: + configMapKeyRef: + key: rds-database + name: rds-config +- name: RDS_USER + valueFrom: + configMapKeyRef: + key: rds-username + name: rds-config +- name: RDS_PASSWORD + valueFrom: + secretKeyRef: + key: rds_password + name: rds-secret +- name: GITHUB_TOKEN + valueFrom: + secretKeyRef: + key: token + name: github-token +- name: GITHUB_ORG + valueFrom: + secretKeyRef: + key: github_org + name: app-config +- name: USER_ROLES + valueFrom: + secretKeyRef: + key: user_roles + name: app-config +{{- end -}} + {{/* Return podAnnotations */}} diff --git a/github-activity-tracker/helm/gh-tracker-service/templates/migrate-job.yaml b/github-activity-tracker/helm/gh-tracker-service/templates/migrate-job.yaml new file mode 100644 index 0000000..dd0abcb --- /dev/null +++ b/github-activity-tracker/helm/gh-tracker-service/templates/migrate-job.yaml @@ -0,0 +1,31 @@ +{{- if .Values.migration.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ template "common.names.fullname" . }}-migrate-{{ .Release.Revision }} + namespace: {{ .Release.Namespace }} + labels: {{- include "common.labels.standard" . | nindent 4 }} + annotations: + helm.sh/hook: pre-install,pre-upgrade + helm.sh/hook-weight: "-5" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + backoffLimit: {{ .Values.migration.backoffLimit }} + template: + metadata: + labels: {{- include "common.labels.standard" . | nindent 8 }} + spec: + restartPolicy: Never + serviceAccountName: {{ template "gh-tracker-service.serviceAccountName" . }} + {{- include "gh-tracker-service.imagePullSecrets" . | nindent 6 }} + containers: + - name: migrate + image: {{ template "gh-tracker-service.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: ["npm", "run", "migrate"] + env: + {{- include "gh-tracker-service.env" . | nindent 12 }} + {{- if .Values.migration.resources }} + resources: {{- toYaml .Values.migration.resources | nindent 12 }} + {{- end }} +{{- end }} diff --git a/github-activity-tracker/helm/gh-tracker-service/values.yaml b/github-activity-tracker/helm/gh-tracker-service/values.yaml index 58a9b1a..6c8ecb0 100644 --- a/github-activity-tracker/helm/gh-tracker-service/values.yaml +++ b/github-activity-tracker/helm/gh-tracker-service/values.yaml @@ -244,36 +244,7 @@ updateStrategy: ## value: "bar" ## extraEnvVars: - - name: RDS_HOST - valueFrom: - configMapKeyRef: - key: rds-host - name: rds-config - - name: RDS_PORT - valueFrom: - configMapKeyRef: - key: rds-port - name: rds-config - - name: RDS_DATABASE - valueFrom: - configMapKeyRef: - key: rds-database - name: rds-config - - name: RDS_USER - valueFrom: - configMapKeyRef: - key: rds-username - name: rds-config - - name: RDS_PASSWORD - valueFrom: - secretKeyRef: - key: rds_password - name: rds-secret - - name: GITHUB_TOKEN - valueFrom: - secretKeyRef: - key: token - name: github-token + {{- include "gh-tracker-service.env" . | nindent 2 }} ## ConfigMap with extra environment variables ## @@ -303,6 +274,17 @@ extraVolumeMounts: [] ## initContainers: {} +migration: + enabled: true + backoffLimit: 3 + resources: + limits: + cpu: 300m + memory: 512Mi + requests: + cpu: 100m + memory: 256Mi + ## Add sidecars to the pods. ## Example: ## sidecars: @@ -450,6 +432,9 @@ ghtrackerservice: rds_port: "5432" rds_username: "" # provide username secrets: + app-config: + github_org: # e.g. mosip,inji + user_roles: # e.g. Developer,Tech Lead,QA Engineer rds-secret: rds_password: # Provide rds password github-token:
Team MemberTeamProjectRoleCommitsPRsReviewsTeam MemberRole
+ +
-
-

{m.login}

-

{m.email || "—"}

+
+

+ {m.name || m.login} +

+

{m.login}

+
{m.team || "—"}{m.project || "—"}{m.role || "—"} -
- {m.commits} -
-
- ({m.diffCommits > 0 ? "+" : ""} - {m.diffCommits}) -
+
+ {m.role || "—"} + {m.role && m.is_active === false && ( + (inactive) + )} +
= ({
+
void; @@ -27,8 +21,8 @@ interface TopNavProps { organization: string; onOrganizationChange: (value: string) => void; - team: string; - onTeamChange: (value: string) => void; + role: string; + onRoleChange: (value: string) => void; project: string; onProjectChange: (value: string) => void; @@ -45,13 +39,56 @@ const TopNav: React.FC = ({ onPeriodChange, organization, onOrganizationChange, - team, - onTeamChange, + role, + onRoleChange, project, onProjectChange, onDownloadCSV, onDownloadJSON, }) => { + const [userRoles, setUserRoles] = useState([]); + const [organizations, setOrganizations] = useState([]); + + useEffect(() => { + let cancelled = false; + + async function loadRoles() { + try { + const roles = await fetchUserRoles(); + if (!cancelled) { + setUserRoles(roles.map((role) => role.name)); + } + } catch (error) { + console.error("Failed to load user roles:", error); + } + } + + loadRoles(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + let cancelled = false; + + async function loadOrganizations() { + try { + const orgs = await fetchOrganizations(); + if (!cancelled) { + setOrganizations(orgs); + } + } catch (error) { + console.error("Failed to load organizations:", error); + } + } + + loadOrganizations(); + return () => { + cancelled = true; + }; + }, []); + const tabStyle = (active: boolean) => `px-4 py-2 rounded-lg font-medium transition-all ${ active @@ -160,9 +197,9 @@ const TopNav: React.FC = ({ onChange={(e) => onOrganizationChange(e.target.value)} className={filterSelectClass} > - {ORGANIZATIONS.map((org) => ( - ))} @@ -170,21 +207,23 @@ const TopNav: React.FC = ({
- {/* TEAM */} + {/* ROLE */}
diff --git a/github-activity-tracker/frontend/src/components/UserActivityStats.tsx b/github-activity-tracker/frontend/src/components/UserActivityStats.tsx index 6a893a1..a70d82b 100644 --- a/github-activity-tracker/frontend/src/components/UserActivityStats.tsx +++ b/github-activity-tracker/frontend/src/components/UserActivityStats.tsx @@ -1,10 +1,9 @@ import React from 'react'; -import { User, GitCommit, GitPullRequest, MessageSquare } from 'lucide-react'; +import { User, GitPullRequest, MessageSquare } from 'lucide-react'; import type { ActivityItem } from '../lib/database.types'; interface UserStats { name: string; - commits: number; pullRequests: number; reviews: number; } @@ -16,19 +15,17 @@ interface UserActivityStatsProps { export function UserActivityStats({ activities }: UserActivityStatsProps) { const userStats = activities.reduce>((acc, activity) => { + if (activity.type === 'commit') return acc; + if (!acc[activity.author]) { acc[activity.author] = { name: activity.author, - commits: 0, pullRequests: 0, reviews: 0, }; } switch (activity.type) { - case 'commit': - acc[activity.author].commits++; - break; case 'pull_request': acc[activity.author].pullRequests++; break; @@ -42,8 +39,8 @@ export function UserActivityStats({ activities }: UserActivityStatsProps) { const sortedUsers = Object.values(userStats).sort((a, b) => { - const totalA = a.commits + a.pullRequests + a.reviews; - const totalB = b.commits + b.pullRequests + b.reviews; + const totalA = a.pullRequests + a.reviews; + const totalB = b.pullRequests + b.reviews; return totalB - totalA; }); @@ -59,9 +56,6 @@ export function UserActivityStats({ activities }: UserActivityStatsProps) {
User - Commits - Pull Requests
+ No user activity found
-
- - {user.commits} -
-
diff --git a/github-activity-tracker/frontend/src/components/UserActivityTable.tsx b/github-activity-tracker/frontend/src/components/UserActivityTable.tsx index 1b2427a..884658d 100644 --- a/github-activity-tracker/frontend/src/components/UserActivityTable.tsx +++ b/github-activity-tracker/frontend/src/components/UserActivityTable.tsx @@ -3,7 +3,6 @@ import React from "react"; interface Row { date: string; - commits: number; prs: number; reviews: number; total: number; @@ -22,7 +21,6 @@ const UserActivityTable: React.FC = ({ rows }) => {
DateCommits Pull Requests Reviews Total
{r.date}{r.commits} {r.prs} {r.reviews} {r.total}
DateCommits Pull Requests Reviews Total
{row.date} - {row.commits} - {row.prs} - {row.commits + row.prs + row.reviews} + {row.prs + row.reviews}