Skip to content
189 changes: 189 additions & 0 deletions backend/src/controllers/setup.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,61 @@ import app from '../index.js';
import StatusService from '../services/status.service.js';
import logger from '../services/logger.js';

// Type definitions for the diagnostic response
interface OctokitTestResult {
success: boolean;
appName?: string;
appOwner?: string;
permissions?: Record<string, string | undefined>;
error?: string;
}

interface InstallationDiagnostic {
index: number;
installationId: number;
accountLogin: string;
accountId: string | number;
accountType: string;
accountAvatarUrl: string;
appId: number;
appSlug: string;
targetType: string;
permissions: Record<string, string | undefined>;
events: string[];
createdAt: string;
updatedAt: string;
suspendedAt: string | null;
suspendedBy: { login: string; id: number } | null;
hasOctokit: boolean;
octokitTest: OctokitTestResult | null;
isValid: boolean;
validationErrors: string[];
}

interface AppInfo {
name: string;
description: string;
owner: string;
htmlUrl: string;
permissions: Record<string, string | undefined>;
events: string[];
}

interface DiagnosticsResponse {
timestamp: string;
appConnected: boolean;
totalInstallations: number;
installations: InstallationDiagnostic[];
errors: string[];
appInfo: AppInfo | null;
summary: {
validInstallations: number;
invalidInstallations: number;
organizationNames: string[];
accountTypes: Record<string, number>;
};
}

class SetupController {
async registrationComplete(req: Request, res: Response) {
try {
Expand Down Expand Up @@ -112,6 +167,140 @@ class SetupController {
}
}

async validateInstallations(req: Request, res: Response) {
try {
const diagnostics: DiagnosticsResponse = {
timestamp: new Date().toISOString(),
appConnected: !!app.github.app,
totalInstallations: app.github.installations.length,
installations: [],
errors: [],
appInfo: null,
summary: {
validInstallations: 0,
invalidInstallations: 0,
organizationNames: [],
accountTypes: {}
}
};

// Basic app validation
if (!app.github.app) {
diagnostics.errors.push('GitHub App is not initialized');
return res.json(diagnostics);
}

// Validate each installation
for (let i = 0; i < app.github.installations.length; i++) {
const { installation, octokit } = app.github.installations[i];

const installationDiag: InstallationDiagnostic = {
index: i,
installationId: installation.id,
accountLogin: installation.account?.login || 'MISSING',
accountId: installation.account?.id || 'MISSING',
accountType: installation.account?.type || 'MISSING',
accountAvatarUrl: installation.account?.avatar_url || 'MISSING',
appId: installation.app_id,
appSlug: installation.app_slug,
targetType: installation.target_type,
permissions: installation.permissions || {},
events: installation.events || [],
createdAt: installation.created_at,
updatedAt: installation.updated_at,
suspendedAt: installation.suspended_at,
suspendedBy: installation.suspended_by,
hasOctokit: !!octokit,
octokitTest: null,
isValid: true,
validationErrors: []
};

// Validate required fields
if (!installation.account?.login) {
installationDiag.isValid = false;
installationDiag.validationErrors.push('Missing account.login (organization name)');
}

if (!installation.account?.id) {
installationDiag.isValid = false;
installationDiag.validationErrors.push('Missing account.id');
}

if (!installation.account?.type) {
installationDiag.isValid = false;
installationDiag.validationErrors.push('Missing account.type');
}

// Test Octokit functionality
if (octokit) {
try {
// Test basic API call with the installation's octokit
const authTest = await octokit.rest.apps.getAuthenticated();
installationDiag.octokitTest = {
success: true,
appName: authTest.data?.name || 'Unknown',
appOwner: (authTest.data?.owner && 'login' in authTest.data.owner) ? authTest.data.owner.login : 'Unknown',
permissions: authTest.data?.permissions || {}
};
} catch (error) {
installationDiag.octokitTest = {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
installationDiag.isValid = false;
installationDiag.validationErrors.push(`Octokit API test failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
} else {
installationDiag.isValid = false;
installationDiag.validationErrors.push('Octokit instance is missing');
}

// Update summary
if (installationDiag.isValid) {
diagnostics.summary.validInstallations++;
if (installation.account?.login) {
diagnostics.summary.organizationNames.push(installation.account.login);
}
} else {
diagnostics.summary.invalidInstallations++;
}

// Track account types
const accountType = installation.account?.type || 'Unknown';
diagnostics.summary.accountTypes[accountType] = (diagnostics.summary.accountTypes[accountType] || 0) + 1;

diagnostics.installations.push(installationDiag);
}

// Additional app-level diagnostics
try {
const appInfo = await app.github.app.octokit.rest.apps.getAuthenticated();
diagnostics.appInfo = {
name: appInfo.data?.name || 'Unknown',
description: appInfo.data?.description || 'No description',
owner: (appInfo.data?.owner && 'login' in appInfo.data.owner) ? appInfo.data.owner.login : 'Unknown',
htmlUrl: appInfo.data?.html_url || 'Unknown',
permissions: appInfo.data?.permissions || {},
events: appInfo.data?.events || []
};
} catch (error) {
diagnostics.errors.push(`Failed to get app info: ${error instanceof Error ? error.message : 'Unknown error'}`);
}

// Sort organization names for easier reading
diagnostics.summary.organizationNames.sort();

res.json(diagnostics);
} catch (error) {
logger.error('Installation validation failed', error);
res.status(500).json({
error: 'Installation validation failed',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
}


}

Expand Down
1 change: 1 addition & 0 deletions backend/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ router.get('/setup/manifest', setupController.getManifest);
router.post('/setup/existing-app', setupController.addExistingApp);
router.post('/setup/db', setupController.setupDB);
router.get('/setup/status', setupController.setupStatus);
router.get('/setup/validate-installations', setupController.validateInstallations);

router.get('/status', setupController.getStatus);

Expand Down
8 changes: 3 additions & 5 deletions backend/src/services/status.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export interface StatusType {
};
installations: {
installation: Endpoints["GET /app/installations"]["response"]["data"][0]
repos: Endpoints["GET /installation/repositories"]["response"]["data"]["repositories"];
repos: Endpoints["GET /app/installations"]["response"]["data"];
}[];
surveyCount: number;
auth?: {
Expand Down Expand Up @@ -56,12 +56,10 @@ class StatusService {

status.installations = [];
for (const installation of app.github.installations) {
const repos = await installation.octokit.paginate<Endpoints["GET /installation/repositories"]["response"]["data"]["repositories"][0]>(
installation.installation.repositories_url
);
const repos = await installation.octokit.request(installation.installation.repositories_url);
status.installations.push({
installation: installation.installation,
repos: repos
repos: repos.data.repositories
});
}

Expand Down
58 changes: 34 additions & 24 deletions backend/src/services/target-calculation-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,22 +687,23 @@
* Calculate weekly time saved in hours per developer
*/
calculateWeeklyTimeSavedHrs(): Target {
// If no surveys, return default values
// If no surveys, return default values with 2 hrs current
if (this.surveysWeekly.length === 0) {
return { current: 0, target: 0, max: 10 };
return { current: 2, target: 2, max: 10 };
}

// Get distinct users who submitted surveys
const distinctUsers = this.getDistinctSurveyUsers(this.surveysWeekly);
if (distinctUsers.length === 0) {
return { current: 0, target: 0, max: 10 };
return { current: 2, target: 2, max: 10 };
Copy link

Copilot AI Aug 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The magic number 2 for default hours appears multiple times in this method. Consider defining this as a named constant at the class level to improve maintainability.

Note: See the diff below for a potential fix:

@@ -703,15 +708,15 @@
    * Calculate weekly time saved in hours per developer
    */
   calculateWeeklyTimeSavedHrs(): Target {
-    // If no surveys, return default values with 2 hrs current
+    // If no surveys, return default values with DEFAULT_WEEKLY_TIME_SAVED_HRS current
     if (this.surveysWeekly.length === 0) {
-      return { current: 2, target: 2, max: 10 };
+      return { current: TargetCalculationService.DEFAULT_WEEKLY_TIME_SAVED_HRS, target: TargetCalculationService.DEFAULT_WEEKLY_TIME_SAVED_HRS, max: 10 };
     }
     
     // Get distinct users who submitted surveys
     const distinctUsers = this.getDistinctSurveyUsers(this.surveysWeekly);
     if (distinctUsers.length === 0) {
-      return { current: 2, target: 2, max: 10 };
+      return { current: TargetCalculationService.DEFAULT_WEEKLY_TIME_SAVED_HRS, target: TargetCalculationService.DEFAULT_WEEKLY_TIME_SAVED_HRS, max: 10 };
     }
     
     // Group surveys by user to get average time saved per user

Copilot uses AI. Check for mistakes.
}

// Group surveys by user to get average time saved per user
const userTimeSavings = distinctUsers.map(userId => {
const userSurveys = this.surveysWeekly.filter(survey => survey.userId === userId);
const totalPercent = userSurveys.reduce((sum, survey) => {
const percentTimeSaved = typeof survey.percentTimeSaved === 'number' ? survey.percentTimeSaved : 0;
// Always parse percentTimeSaved as float
const percentTimeSaved = survey.percentTimeSaved != null ? parseFloat(survey.percentTimeSaved as any) : 0;
return sum + percentTimeSaved;
}, 0);
return totalPercent / userSurveys.length; // Average percent time saved per user
Expand All @@ -711,22 +712,26 @@
// Average across all users
const avgPercentTimeSaved = userTimeSavings.reduce((sum, percent) => sum + percent, 0) / userTimeSavings.length;

// Convert settings values to numbers
const hoursPerYear = typeof this.settings.hoursPerYear === 'number' ? this.settings.hoursPerYear : 2000;
const percentCoding = typeof this.settings.percentCoding === 'number' ? this.settings.percentCoding : 50;
// Convert settings values to numbers (parse from string if needed)
const hoursPerYear = this.settings.hoursPerYear != null ? parseFloat(this.settings.hoursPerYear as any) : 2000;
const percentCoding = this.settings.percentCoding != null ? parseFloat(this.settings.percentCoding as any) : 50;

// Calculate weekly hours saved based on settings and average percent
const weeklyHours = hoursPerYear / 50; // Assuming 50 working weeks
const weeklyDevHours = weeklyHours * (percentCoding / 100);
const avgWeeklyTimeSaved = weeklyDevHours * (avgPercentTimeSaved / 100);

// Calculate max based on settings
const maxPercentTimeSaved = typeof this.settings.percentTimeSaved === 'number' ? this.settings.percentTimeSaved : 20;
const maxPercentTimeSaved = this.settings.percentTimeSaved != null ? parseFloat(this.settings.percentTimeSaved as any) : 20;
const maxWeeklyTimeSaved = weeklyDevHours * (maxPercentTimeSaved / 100);

// Use default value of 2 if calculated value is 0 or very small
const currentValue = avgWeeklyTimeSaved < 0.1 ? 2 : this.roundToDecimal(avgWeeklyTimeSaved);
const targetValue = avgWeeklyTimeSaved < 0.1 ? 3 : this.roundToDecimal(Math.min(avgWeeklyTimeSaved * 1.5, maxWeeklyTimeSaved * 0.8));
Copy link

Copilot AI Aug 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The magic numbers 2, 3, and 0.1 should be defined as named constants. This will improve code readability and make it easier to adjust these thresholds in the future.

Suggested change
const targetValue = avgWeeklyTimeSaved < 0.1 ? 3 : this.roundToDecimal(Math.min(avgWeeklyTimeSaved * 1.5, maxWeeklyTimeSaved * 0.8));
const currentValue = avgWeeklyTimeSaved < MIN_WEEKLY_TIME_SAVED_THRESHOLD ? DEFAULT_WEEKLY_TIME_SAVED : this.roundToDecimal(avgWeeklyTimeSaved);
const targetValue = avgWeeklyTimeSaved < MIN_WEEKLY_TIME_SAVED_THRESHOLD ? DEFAULT_TARGET_WEEKLY_TIME_SAVED : this.roundToDecimal(Math.min(avgWeeklyTimeSaved * 1.5, maxWeeklyTimeSaved * 0.8));

Copilot uses AI. Check for mistakes.

const result = {
current: this.roundToDecimal(avgWeeklyTimeSaved),
target: this.roundToDecimal(Math.min(avgWeeklyTimeSaved * 1.5, maxWeeklyTimeSaved * 0.8)), // Target is 50% increase, capped at 80% of max
current: currentValue,
target: targetValue, // Target is 50% increase, capped at 80% of max
max: this.roundToDecimal(maxWeeklyTimeSaved || 10) // Provide a fallback
};

Expand All @@ -739,9 +744,11 @@
userPercentages: userTimeSavings,
hoursPerYear: hoursPerYear,
percentCoding: percentCoding,
weeklyDevHours: weeklyDevHours
weeklyDevHours: weeklyDevHours,
calculatedWeeklyTimeSaved: avgWeeklyTimeSaved,
usedDefaultValue: avgWeeklyTimeSaved < 0.1
},
'Calculate average time saved percentage per user, then weeklyDevHours * (avgPercentTimeSaved / 100)',
'Calculate average time saved percentage per user, then weeklyDevHours * (avgPercentTimeSaved / 100), use default value of 2 if result is < 0.1',
result
);

Expand Down Expand Up @@ -769,9 +776,11 @@
{
adoptedDevsCount: adoptedDevs,
weeklyTimeSavedHrs: weeklyTimeSavedHrs,
monthlyCalculation: `${adoptedDevs} * ${weeklyTimeSavedHrs} * 4 = ${monthlyTimeSavings}`,
calculatedMonthlyTimeSavings: monthlyTimeSavings,
seatsCount: this.calculateSeats().current
},
'Calculate adoptedDevs * weeklyTimeSavedHrs * 4, set current = monthlyTimeSavings, max = 80 * seats',
'Calculate adoptedDevs * weeklyTimeSavedHrs * 4 (weeklyTimeSavedHrs already includes default of 2 if needed), max = 80 * seats',
result
);

Expand All @@ -783,13 +792,13 @@
*/
calculateAnnualTimeSavingsAsDollars(): Target {
const adoptedDevs = this.calculateAdoptedDevs().current;
const weeklyTimeSavedHrs = this.calculateWeeklyTimeSavedHrs().current;
const weeklyTimeSavedHrs = this.calculateWeeklyTimeSavedHrs().current; // This now includes default of 2 if needed

// Ensure all values are properly typed as numbers
const hoursPerYear = typeof this.settings.hoursPerYear === 'number' ? this.settings.hoursPerYear : 2000;
// Always parse settings values as numbers (from string if needed)
const hoursPerYear = this.settings.hoursPerYear != null ? parseFloat(this.settings.hoursPerYear as any) : 2000;
const weeksInYear = Math.round(hoursPerYear / 40) || 50; // Calculate weeks and ensure it's a number

const devCostPerYear = typeof this.settings.devCostPerYear === 'number' ? this.settings.devCostPerYear : 0;
const devCostPerYear = this.settings.devCostPerYear != null ? parseFloat(this.settings.devCostPerYear as any) : 0;
const hourlyRate = devCostPerYear > 0 ? (devCostPerYear / hoursPerYear) : 50;

const annualSavings = weeklyTimeSavedHrs * weeksInYear * hourlyRate * adoptedDevs;
Expand All @@ -798,7 +807,7 @@
const result = {
current: Math.round(annualSavings || 0), // Round to whole dollars
target: 0,
max: Math.round(12 * this.calculateSeats().current * weeksInYear * hourlyRate || 10000) // Provide fallback
max: Math.round(weeksInYear * this.calculateSeats().current * hourlyRate * 40 || 10000) // Max assumes 40 hours per week saved per seat
};

this.logCalculation(
Expand All @@ -808,9 +817,10 @@
weeklyTimeSavedHrs: weeklyTimeSavedHrs,
weeksInYear: weeksInYear,
hourlyRate: hourlyRate,
annualSavingsCalculation: `${weeklyTimeSavedHrs} * ${weeksInYear} * ${hourlyRate} * ${adoptedDevs} = ${annualSavings}`,
seatsCount: this.calculateSeats().current
},
'Calculate weeklyTimeSavedHrs * weeksInYear * hourlyRate * adoptedDevs, set current = annualSavings, max = 80 * seats * 50',
'Calculate weeklyTimeSavedHrs * weeksInYear * hourlyRate * adoptedDevs (weeklyTimeSavedHrs includes default of 2 if needed)',
result
);

Expand All @@ -822,10 +832,10 @@
*/
calculateProductivityOrThroughputBoostPercent(): Target {
const adoptedDevs = this.calculateAdoptedDevs().current;
const weeklyTimeSavedHrs = this.calculateWeeklyTimeSavedHrs().current;
const weeklyTimeSavedHrs = this.calculateWeeklyTimeSavedHrs().current; // This now includes default of 2 if needed

// Convert hours per year to number
const hoursPerYear = typeof this.settings.hoursPerYear === 'number' ? this.settings.hoursPerYear : 2000;
// Always parse hours per year as number
const hoursPerYear = this.settings.hoursPerYear != null ? parseFloat(this.settings.hoursPerYear as any) : 2000;
const hoursPerWeek = hoursPerYear / 50 || 40; // Default to 40 if undefined

// Calculate productivity boost factor (not percentage)
Expand All @@ -843,13 +853,13 @@
this.logCalculation(
'PRODUCTIVITY OR THROUGHPUT BOOST PERCENT',
{
adoptedDevsCount: adoptedDevs,
adoptedDevsCount: adoptedDevs,
weeklyTimeSavedHrs: weeklyTimeSavedHrs,
hoursPerWeek: hoursPerWeek,
productivityBoostFactor: productivityBoost,
productivityBoostPercent: productivityBoostPercent
},
'Calculate boost factor as (hoursPerWeek + weeklyTimeSavedHrs) / hoursPerWeek, then convert to percentage by (factor - 1) * 100',
'Calculate boost factor as (hoursPerWeek + weeklyTimeSavedHrs) / hoursPerWeek, then convert to percentage by (factor - 1) * 100 (weeklyTimeSavedHrs includes default of 2 if needed)',
result
);

Expand Down
Loading
Loading