CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
intelligence.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 2c34075 | 1 | /** |
| 2 | * Repository Intelligence Engine | |
| 3 | * | |
| 4 | * The brain of gluecron. Runs on every push and computes: | |
| 5 | * - Health score (0-100) | |
| 6 | * - Security signals | |
| 7 | * - Complexity trends | |
| 8 | * - Dependency freshness | |
| 9 | * - Test coverage estimate | |
| 10 | * - Documentation coverage | |
| 11 | * - Hot file detection (churn) | |
| 12 | * | |
| 13 | * This is what makes gluecron 30 years ahead of GitHub. | |
| 14 | * GitHub shows you code. Gluecron UNDERSTANDS your code. | |
| 15 | */ | |
| 16 | ||
| 17 | import { getRepoPath, getDefaultBranch } from "../git/repository"; | |
| 18 | ||
| 19 | export interface RepoHealthReport { | |
| 20 | score: number; // 0-100 | |
| 21 | grade: "A+" | "A" | "B" | "C" | "D" | "F"; | |
| 22 | breakdown: { | |
| 23 | security: { score: number; issues: SecurityIssue[] }; | |
| 24 | testing: { score: number; hasTests: boolean; testFileCount: number; estimatedCoverage: string }; | |
| 25 | complexity: { score: number; avgFileSize: number; largestFiles: FileMetric[]; totalFiles: number }; | |
| 26 | dependencies: { score: number; total: number; outdatedEstimate: number; lockfileExists: boolean }; | |
| 27 | documentation: { score: number; hasReadme: boolean; hasLicense: boolean; hasContributing: boolean; hasChangelog: boolean; docFileCount: number }; | |
| 28 | activity: { score: number; recentCommits: number; uniqueContributors: number; lastPushDaysAgo: number }; | |
| 29 | }; | |
| 30 | insights: string[]; | |
| 31 | generatedAt: string; | |
| 32 | } | |
| 33 | ||
| 34 | export interface SecurityIssue { | |
| 35 | severity: "critical" | "high" | "medium" | "low" | "info"; | |
| 36 | file: string; | |
| 37 | line?: number; | |
| 38 | message: string; | |
| 39 | rule: string; | |
| 40 | } | |
| 41 | ||
| 42 | interface FileMetric { | |
| 43 | path: string; | |
| 44 | lines: number; | |
| 45 | } | |
| 46 | ||
| 47 | interface PushAnalysis { | |
| 48 | filesChanged: number; | |
| 49 | linesAdded: number; | |
| 50 | linesRemoved: number; | |
| 51 | riskScore: number; // 0-100 | |
| 52 | riskFactors: string[]; | |
| 53 | breakingChangeSignals: string[]; | |
| 54 | securityIssues: SecurityIssue[]; | |
| 55 | hotFiles: string[]; | |
| 56 | summary: string; | |
| 57 | } | |
| 58 | ||
| 59 | async function exec( | |
| 60 | cmd: string[], | |
| 61 | cwd: string | |
| 62 | ): Promise<{ stdout: string; exitCode: number }> { | |
| 63 | const proc = Bun.spawn(cmd, { | |
| 64 | cwd, | |
| 65 | stdout: "pipe", | |
| 66 | stderr: "pipe", | |
| 67 | }); | |
| 68 | const stdout = await new Response(proc.stdout).text(); | |
| 69 | const exitCode = await proc.exited; | |
| 70 | return { stdout, exitCode }; | |
| 71 | } | |
| 72 | ||
| 73 | // ─── HEALTH SCORE ──────────────────────────────────────────── | |
| 74 | ||
| 75 | export async function computeHealthScore( | |
| 76 | owner: string, | |
| 77 | repo: string | |
| 78 | ): Promise<RepoHealthReport> { | |
| 79 | const repoDir = getRepoPath(owner, repo); | |
| 80 | const ref = (await getDefaultBranch(owner, repo)) || "main"; | |
| 81 | ||
| 82 | const [ | |
| 83 | security, | |
| 84 | testing, | |
| 85 | complexity, | |
| 86 | dependencies, | |
| 87 | documentation, | |
| 88 | activity, | |
| 89 | ] = await Promise.all([ | |
| 90 | analyzeSecurityScore(repoDir, ref), | |
| 91 | analyzeTestingScore(repoDir, ref), | |
| 92 | analyzeComplexityScore(repoDir, ref), | |
| 93 | analyzeDependencyScore(repoDir, ref), | |
| 94 | analyzeDocumentationScore(repoDir, ref), | |
| 95 | analyzeActivityScore(repoDir, ref), | |
| 96 | ]); | |
| 97 | ||
| 98 | const weights = { | |
| 99 | security: 0.25, | |
| 100 | testing: 0.20, | |
| 101 | complexity: 0.15, | |
| 102 | dependencies: 0.15, | |
| 103 | documentation: 0.10, | |
| 104 | activity: 0.15, | |
| 105 | }; | |
| 106 | ||
| 107 | const score = Math.round( | |
| 108 | security.score * weights.security + | |
| 109 | testing.score * weights.testing + | |
| 110 | complexity.score * weights.complexity + | |
| 111 | dependencies.score * weights.dependencies + | |
| 112 | documentation.score * weights.documentation + | |
| 113 | activity.score * weights.activity | |
| 114 | ); | |
| 115 | ||
| 116 | const grade = score >= 95 ? "A+" : score >= 85 ? "A" : score >= 70 ? "B" : score >= 55 ? "C" : score >= 40 ? "D" : "F"; | |
| 117 | ||
| 118 | const insights = generateInsights({ security, testing, complexity, dependencies, documentation, activity }); | |
| 119 | ||
| 120 | return { | |
| 121 | score, | |
| 122 | grade, | |
| 123 | breakdown: { security, testing, complexity, dependencies, documentation, activity }, | |
| 124 | insights, | |
| 125 | generatedAt: new Date().toISOString(), | |
| 126 | }; | |
| 127 | } | |
| 128 | ||
| 129 | // ─── SECURITY ANALYSIS ─────────────────────────────────────── | |
| 130 | ||
| 131 | const SECURITY_PATTERNS: Array<{ | |
| 132 | pattern: RegExp; | |
| 133 | severity: SecurityIssue["severity"]; | |
| 134 | message: string; | |
| 135 | rule: string; | |
| 136 | }> = [ | |
| 137 | // Hardcoded secrets | |
| 138 | { pattern: /(?:password|passwd|pwd)\s*[:=]\s*["'][^"']{4,}/i, severity: "critical", message: "Possible hardcoded password", rule: "no-hardcoded-secrets" }, | |
| 139 | { pattern: /(?:api[_-]?key|apikey|secret[_-]?key)\s*[:=]\s*["'][^"']{8,}/i, severity: "critical", message: "Possible hardcoded API key", rule: "no-hardcoded-secrets" }, | |
| 140 | { pattern: /(?:AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}/, severity: "critical", message: "Possible AWS access key", rule: "no-aws-keys" }, | |
| 141 | { pattern: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/, severity: "critical", message: "Private key in source code", rule: "no-private-keys" }, | |
| 142 | // Injection vulnerabilities | |
| 143 | { pattern: /eval\s*\(/, severity: "high", message: "Use of eval() — potential code injection", rule: "no-eval" }, | |
| 144 | { pattern: /innerHTML\s*=/, severity: "medium", message: "Direct innerHTML assignment — potential XSS", rule: "no-inner-html" }, | |
| 145 | { pattern: /document\.write\s*\(/, severity: "medium", message: "document.write usage — potential XSS", rule: "no-document-write" }, | |
| 146 | { pattern: /exec\s*\(\s*[`"'].*\$\{/, severity: "high", message: "Shell command with template literal — potential injection", rule: "no-shell-injection" }, | |
| 147 | // SQL injection | |
| 148 | { pattern: /query\s*\(\s*[`"'].*\$\{/, severity: "high", message: "SQL query with interpolation — potential SQL injection", rule: "no-sql-injection" }, | |
| 149 | { pattern: /\.raw\s*\(\s*[`"'].*\$\{/, severity: "medium", message: "Raw query with interpolation", rule: "no-raw-sql-injection" }, | |
| 150 | // Crypto issues | |
| 151 | { pattern: /createHash\s*\(\s*["']md5["']\)/, severity: "medium", message: "MD5 hash used — cryptographically weak", rule: "no-weak-crypto" }, | |
| 152 | { pattern: /createHash\s*\(\s*["']sha1["']\)/, severity: "low", message: "SHA1 hash — consider SHA-256+", rule: "weak-hash" }, | |
| 153 | // Misc | |
| 154 | { pattern: /TODO.*(?:security|hack|fixme|unsafe|vulnerable)/i, severity: "info", message: "Security-related TODO found", rule: "security-todo" }, | |
| 155 | { pattern: /(?:disable|ignore).*(?:eslint|tslint|security)/i, severity: "low", message: "Security linter rule disabled", rule: "no-security-disable" }, | |
| 156 | ]; | |
| 157 | ||
| 2415bdf | 158 | // Committed env *templates* are the documented, expected way to ship an |
| 159 | // example config — flagging them as leaked secrets is a false positive. | |
| 160 | const ENV_FILE_ALLOWLIST = /\.env\.(example|sample|template|dist|test)$/i; | |
| 161 | ||
| 162 | function isTestOrFixtureFile(filePath: string): boolean { | |
| 163 | return ( | |
| 164 | /(^|\/)(tests?|__tests__|e2e|fixtures?|mocks?|specs?)(\/|$)/i.test(filePath) || | |
| 165 | /\.(test|spec)\.[jt]sx?$/i.test(filePath) | |
| 166 | ); | |
| 167 | } | |
| 168 | ||
| 169 | // Rejects matches where the "secret" is actually a variable reference | |
| 170 | // (`${VAR}`, template literals) or a plain identifier/slug (e.g. a | |
| 171 | // SecretStorage lookup KEY like "gluecron.pat") rather than a literal value. | |
| 172 | function looksLikeRealSecret(value: string): boolean { | |
| 173 | if (!value) return false; | |
| 174 | if (/[$`{]/.test(value)) return false; | |
| 175 | if (value.length < 8) return false; | |
| 176 | if (/^[a-z][a-z0-9._-]*$/.test(value)) return false; | |
| 177 | return true; | |
| 178 | } | |
| 179 | ||
| 2c34075 | 180 | async function analyzeSecurityScore( |
| 181 | repoDir: string, | |
| 182 | ref: string | |
| 183 | ): Promise<{ score: number; issues: SecurityIssue[] }> { | |
| 184 | const issues: SecurityIssue[] = []; | |
| 185 | ||
| 186 | // Get all text files | |
| 187 | const { stdout: files } = await exec( | |
| 188 | ["git", "ls-tree", "-r", "--name-only", ref], | |
| 189 | repoDir | |
| 190 | ); | |
| 191 | ||
| 192 | const filePaths = files.trim().split("\n").filter(Boolean); | |
| 193 | ||
| 2415bdf | 194 | // Check for .env files committed (excluding committed *templates*) |
| 2c34075 | 195 | for (const f of filePaths) { |
| 2415bdf | 196 | const base = f.split("/").pop() || ""; |
| 197 | if (/^\.env(?:\.|$)/.test(base) && !ENV_FILE_ALLOWLIST.test(base)) { | |
| 2c34075 | 198 | issues.push({ |
| 199 | severity: "critical", | |
| 200 | file: f, | |
| 201 | message: "Environment file committed to repository", | |
| 202 | rule: "no-env-files", | |
| 203 | }); | |
| 204 | } | |
| 205 | } | |
| 206 | ||
| 207 | // Scan source files for patterns (sample up to 100 files) | |
| 208 | const sourceFiles = filePaths | |
| 209 | .filter((f) => /\.(ts|tsx|js|jsx|py|rb|go|rs|java|php|sh|yaml|yml|json)$/.test(f)) | |
| 210 | .slice(0, 100); | |
| 211 | ||
| 212 | for (const filePath of sourceFiles) { | |
| 213 | const { stdout: content, exitCode } = await exec( | |
| 214 | ["git", "show", `${ref}:${filePath}`], | |
| 215 | repoDir | |
| 216 | ); | |
| 217 | if (exitCode !== 0) continue; | |
| 218 | ||
| 2415bdf | 219 | const isFixture = isTestOrFixtureFile(filePath); |
| 2c34075 | 220 | const lines = content.split("\n"); |
| 221 | for (let i = 0; i < lines.length; i++) { | |
| 2415bdf | 222 | const line = lines[i]; |
| 223 | if (line.includes("secrets-ok")) continue; | |
| 224 | // Test/fixture data (e.g. a hardcoded TEST_PASSWORD for e2e login) | |
| 225 | // isn't a real leak at any severity — this is a health heuristic, | |
| 226 | // not the push-time secret gate. | |
| 227 | if (isFixture) continue; | |
| 228 | ||
| 2c34075 | 229 | for (const rule of SECURITY_PATTERNS) { |
| 2415bdf | 230 | const match = line.match(rule.pattern); |
| 231 | if (!match) continue; | |
| 232 | ||
| 233 | if (rule.rule === "no-hardcoded-secrets") { | |
| 234 | const valueMatch = line.match(/["']([^"']+)["']/); | |
| 235 | if (!valueMatch || !looksLikeRealSecret(valueMatch[1])) continue; | |
| 2c34075 | 236 | } |
| 2415bdf | 237 | |
| 238 | issues.push({ | |
| 239 | severity: rule.severity, | |
| 240 | file: filePath, | |
| 241 | line: i + 1, | |
| 242 | message: rule.message, | |
| 243 | rule: rule.rule, | |
| 244 | }); | |
| 2c34075 | 245 | } |
| 246 | } | |
| 247 | } | |
| 248 | ||
| 249 | const criticals = issues.filter((i) => i.severity === "critical").length; | |
| 250 | const highs = issues.filter((i) => i.severity === "high").length; | |
| 251 | const mediums = issues.filter((i) => i.severity === "medium").length; | |
| 252 | ||
| 253 | let score = 100; | |
| 254 | score -= criticals * 25; | |
| 255 | score -= highs * 10; | |
| 256 | score -= mediums * 3; | |
| 257 | ||
| 258 | return { score: Math.max(0, Math.min(100, score)), issues }; | |
| 259 | } | |
| 260 | ||
| 261 | // ─── TESTING ───────────────────────────────────────────────── | |
| 262 | ||
| 263 | async function analyzeTestingScore( | |
| 264 | repoDir: string, | |
| 265 | ref: string | |
| 266 | ): Promise<{ | |
| 267 | score: number; | |
| 268 | hasTests: boolean; | |
| 269 | testFileCount: number; | |
| 270 | estimatedCoverage: string; | |
| 271 | }> { | |
| 272 | const { stdout: files } = await exec( | |
| 273 | ["git", "ls-tree", "-r", "--name-only", ref], | |
| 274 | repoDir | |
| 275 | ); | |
| 276 | const allFiles = files.trim().split("\n").filter(Boolean); | |
| 277 | const sourceFiles = allFiles.filter((f) => | |
| 278 | /\.(ts|tsx|js|jsx|py|rb|go|rs|java|php)$/.test(f) && !f.includes("test") && !f.includes("spec") | |
| 279 | ); | |
| 280 | const testFiles = allFiles.filter((f) => | |
| 281 | /\.(test|spec)\.(ts|tsx|js|jsx)$/.test(f) || | |
| 282 | f.includes("__tests__/") || | |
| 283 | f.includes("test_") || | |
| 284 | f.endsWith("_test.go") || | |
| 285 | f.endsWith("_test.py") | |
| 286 | ); | |
| 287 | ||
| 288 | const hasTests = testFiles.length > 0; | |
| 289 | const ratio = sourceFiles.length > 0 ? testFiles.length / sourceFiles.length : 0; | |
| 290 | ||
| 291 | let estimatedCoverage: string; | |
| 292 | let score: number; | |
| 293 | ||
| 294 | if (!hasTests) { | |
| 295 | estimatedCoverage = "None"; | |
| 296 | score = 0; | |
| 297 | } else if (ratio >= 0.8) { | |
| 298 | estimatedCoverage = "High (>80%)"; | |
| 299 | score = 95; | |
| 300 | } else if (ratio >= 0.5) { | |
| 301 | estimatedCoverage = "Good (50-80%)"; | |
| 302 | score = 75; | |
| 303 | } else if (ratio >= 0.2) { | |
| 304 | estimatedCoverage = "Moderate (20-50%)"; | |
| 305 | score = 50; | |
| 306 | } else { | |
| 307 | estimatedCoverage = "Low (<20%)"; | |
| 308 | score = 25; | |
| 309 | } | |
| 310 | ||
| 311 | return { score, hasTests, testFileCount: testFiles.length, estimatedCoverage }; | |
| 312 | } | |
| 313 | ||
| 314 | // ─── COMPLEXITY ────────────────────────────────────────────── | |
| 315 | ||
| 316 | async function analyzeComplexityScore( | |
| 317 | repoDir: string, | |
| 318 | ref: string | |
| 319 | ): Promise<{ | |
| 320 | score: number; | |
| 321 | avgFileSize: number; | |
| 322 | largestFiles: FileMetric[]; | |
| 323 | totalFiles: number; | |
| 324 | }> { | |
| 325 | const { stdout } = await exec( | |
| 326 | ["git", "ls-tree", "-r", "-l", ref], | |
| 327 | repoDir | |
| 328 | ); | |
| 329 | ||
| 330 | const entries = stdout | |
| 331 | .trim() | |
| 332 | .split("\n") | |
| 333 | .filter(Boolean) | |
| 334 | .map((line) => { | |
| 335 | const match = line.match(/^\d+ blob [0-9a-f]+ +(\d+)\t(.+)$/); | |
| 336 | if (!match) return null; | |
| 337 | return { path: match[2], lines: parseInt(match[1], 10) }; | |
| 338 | }) | |
| 339 | .filter((e): e is FileMetric => e !== null) | |
| 340 | .filter((e) => /\.(ts|tsx|js|jsx|py|rb|go|rs|java|php|c|cpp|h)$/.test(e.path)); | |
| 341 | ||
| 342 | if (entries.length === 0) { | |
| 343 | return { score: 100, avgFileSize: 0, largestFiles: [], totalFiles: 0 }; | |
| 344 | } | |
| 345 | ||
| 346 | // Get actual line counts for top files by size | |
| 347 | const sorted = entries.sort((a, b) => b.lines - a.lines); | |
| 348 | const largestFiles = sorted.slice(0, 5); | |
| 349 | ||
| 350 | const avgSize = entries.reduce((s, e) => s + e.lines, 0) / entries.length; | |
| 351 | ||
| 352 | // Penalize large average file size and mega-files | |
| 353 | let score = 100; | |
| 354 | if (avgSize > 10000) score -= 30; | |
| 355 | else if (avgSize > 5000) score -= 20; | |
| 356 | else if (avgSize > 2000) score -= 10; | |
| 357 | else if (avgSize > 1000) score -= 5; | |
| 358 | ||
| 359 | // Penalize any file over 500 lines (by byte size as proxy) | |
| 360 | const megaFiles = entries.filter((e) => e.lines > 50000); | |
| 361 | score -= megaFiles.length * 10; | |
| 362 | ||
| 363 | return { | |
| 364 | score: Math.max(0, Math.min(100, score)), | |
| 365 | avgFileSize: Math.round(avgSize), | |
| 366 | largestFiles, | |
| 367 | totalFiles: entries.length, | |
| 368 | }; | |
| 369 | } | |
| 370 | ||
| 371 | // ─── DEPENDENCIES ──────────────────────────────────────────── | |
| 372 | ||
| 373 | async function analyzeDependencyScore( | |
| 374 | repoDir: string, | |
| 375 | ref: string | |
| 376 | ): Promise<{ | |
| 377 | score: number; | |
| 378 | total: number; | |
| 379 | outdatedEstimate: number; | |
| 380 | lockfileExists: boolean; | |
| 381 | }> { | |
| 382 | const { stdout: tree } = await exec( | |
| 383 | ["git", "ls-tree", "--name-only", ref], | |
| 384 | repoDir | |
| 385 | ); | |
| 386 | const files = tree.trim().split("\n"); | |
| 387 | ||
| 388 | const hasLockfile = | |
| 389 | files.includes("bun.lock") || | |
| 390 | files.includes("package-lock.json") || | |
| 391 | files.includes("yarn.lock") || | |
| 392 | files.includes("pnpm-lock.yaml") || | |
| 393 | files.includes("Cargo.lock") || | |
| 394 | files.includes("go.sum") || | |
| 395 | files.includes("Gemfile.lock") || | |
| 396 | files.includes("poetry.lock"); | |
| 397 | ||
| 398 | const hasPackageJson = files.includes("package.json"); | |
| 399 | const hasCargoToml = files.includes("Cargo.toml"); | |
| 400 | const hasGoMod = files.includes("go.mod"); | |
| 401 | const hasRequirements = files.includes("requirements.txt"); | |
| 402 | ||
| 403 | let total = 0; | |
| 404 | ||
| 405 | if (hasPackageJson) { | |
| 406 | try { | |
| 407 | const { stdout: content } = await exec( | |
| 408 | ["git", "show", `${ref}:package.json`], | |
| 409 | repoDir | |
| 410 | ); | |
| 411 | const pkg = JSON.parse(content); | |
| 412 | total = | |
| 413 | Object.keys(pkg.dependencies || {}).length + | |
| 414 | Object.keys(pkg.devDependencies || {}).length; | |
| 415 | } catch { | |
| 416 | // parse error | |
| 417 | } | |
| 418 | } | |
| 419 | ||
| 420 | let score = 100; | |
| 421 | if (!hasLockfile && total > 0) score -= 20; // No lockfile with deps is bad | |
| 422 | if (total > 100) score -= 10; // Too many deps | |
| 423 | if (total > 200) score -= 10; | |
| 424 | ||
| 425 | return { | |
| 426 | score: Math.max(0, score), | |
| 427 | total, | |
| 428 | outdatedEstimate: 0, // Would need network access to check | |
| 429 | lockfileExists: hasLockfile, | |
| 430 | }; | |
| 431 | } | |
| 432 | ||
| 433 | // ─── DOCUMENTATION ─────────────────────────────────────────── | |
| 434 | ||
| 435 | async function analyzeDocumentationScore( | |
| 436 | repoDir: string, | |
| 437 | ref: string | |
| 438 | ): Promise<{ | |
| 439 | score: number; | |
| 440 | hasReadme: boolean; | |
| 441 | hasLicense: boolean; | |
| 442 | hasContributing: boolean; | |
| 443 | hasChangelog: boolean; | |
| 444 | docFileCount: number; | |
| 445 | }> { | |
| 446 | const { stdout: tree } = await exec( | |
| 447 | ["git", "ls-tree", "--name-only", ref], | |
| 448 | repoDir | |
| 449 | ); | |
| 450 | const files = tree.trim().split("\n").map((f) => f.toLowerCase()); | |
| 451 | ||
| 452 | const hasReadme = files.some((f) => f.startsWith("readme")); | |
| 453 | const hasLicense = files.some((f) => f.startsWith("license") || f.startsWith("licence")); | |
| 454 | const hasContributing = files.some((f) => f.startsWith("contributing")); | |
| 455 | const hasChangelog = files.some((f) => f.startsWith("changelog") || f.startsWith("changes")); | |
| 456 | ||
| 457 | const { stdout: allFiles } = await exec( | |
| 458 | ["git", "ls-tree", "-r", "--name-only", ref], | |
| 459 | repoDir | |
| 460 | ); | |
| 461 | const docFiles = allFiles | |
| 462 | .trim() | |
| 463 | .split("\n") | |
| 464 | .filter((f) => /\.(md|rst|txt|adoc)$/i.test(f)); | |
| 465 | ||
| 466 | let score = 0; | |
| 467 | if (hasReadme) score += 40; | |
| 468 | if (hasLicense) score += 20; | |
| 469 | if (hasContributing) score += 15; | |
| 470 | if (hasChangelog) score += 15; | |
| 471 | score += Math.min(10, docFiles.length * 2); | |
| 472 | ||
| 473 | return { | |
| 474 | score: Math.min(100, score), | |
| 475 | hasReadme, | |
| 476 | hasLicense, | |
| 477 | hasContributing, | |
| 478 | hasChangelog, | |
| 479 | docFileCount: docFiles.length, | |
| 480 | }; | |
| 481 | } | |
| 482 | ||
| 483 | // ─── ACTIVITY ──────────────────────────────────────────────── | |
| 484 | ||
| 485 | async function analyzeActivityScore( | |
| 486 | repoDir: string, | |
| 487 | ref: string | |
| 488 | ): Promise<{ | |
| 489 | score: number; | |
| 490 | recentCommits: number; | |
| 491 | uniqueContributors: number; | |
| 492 | lastPushDaysAgo: number; | |
| 493 | }> { | |
| 494 | // Recent commits (last 30 days) | |
| 495 | const { stdout: recent } = await exec( | |
| 496 | ["git", "rev-list", "--count", "--since=30 days ago", ref], | |
| 497 | repoDir | |
| 498 | ); | |
| 499 | const recentCommits = parseInt(recent.trim(), 10) || 0; | |
| 500 | ||
| 501 | // Unique contributors | |
| 502 | const { stdout: authors } = await exec( | |
| 503 | ["git", "shortlog", "-sn", ref], | |
| 504 | repoDir | |
| 505 | ); | |
| 506 | const uniqueContributors = authors.trim().split("\n").filter(Boolean).length; | |
| 507 | ||
| 508 | // Last commit date | |
| 509 | const { stdout: lastDate } = await exec( | |
| 510 | ["git", "log", "-1", "--format=%aI", ref], | |
| 511 | repoDir | |
| 512 | ); | |
| 513 | const lastPush = new Date(lastDate.trim()); | |
| 514 | const lastPushDaysAgo = Math.floor( | |
| 515 | (Date.now() - lastPush.getTime()) / (1000 * 60 * 60 * 24) | |
| 516 | ); | |
| 517 | ||
| 518 | let score = 0; | |
| 519 | // Recent activity | |
| 520 | if (recentCommits >= 20) score += 40; | |
| 521 | else if (recentCommits >= 10) score += 30; | |
| 522 | else if (recentCommits >= 3) score += 20; | |
| 523 | else if (recentCommits >= 1) score += 10; | |
| 524 | ||
| 525 | // Contributors | |
| 526 | if (uniqueContributors >= 5) score += 30; | |
| 527 | else if (uniqueContributors >= 3) score += 20; | |
| 528 | else if (uniqueContributors >= 2) score += 15; | |
| 529 | else score += 5; | |
| 530 | ||
| 531 | // Freshness | |
| 532 | if (lastPushDaysAgo <= 7) score += 30; | |
| 533 | else if (lastPushDaysAgo <= 30) score += 20; | |
| 534 | else if (lastPushDaysAgo <= 90) score += 10; | |
| 535 | ||
| 536 | return { | |
| 537 | score: Math.min(100, score), | |
| 538 | recentCommits, | |
| 539 | uniqueContributors, | |
| 540 | lastPushDaysAgo, | |
| 541 | }; | |
| 542 | } | |
| 543 | ||
| 544 | // ─── PUSH ANALYSIS ─────────────────────────────────────────── | |
| 545 | ||
| 546 | export async function analyzePush( | |
| 547 | owner: string, | |
| 548 | repo: string, | |
| 549 | beforeSha: string, | |
| 550 | afterSha: string | |
| 551 | ): Promise<PushAnalysis> { | |
| 552 | const repoDir = getRepoPath(owner, repo); | |
| 553 | const isInitial = beforeSha.startsWith("0000"); | |
| 554 | ||
| 555 | // Diff stats | |
| 556 | let filesChanged = 0; | |
| 557 | let linesAdded = 0; | |
| 558 | let linesRemoved = 0; | |
| 559 | ||
| 560 | if (!isInitial) { | |
| 561 | const { stdout: stat } = await exec( | |
| 562 | ["git", "diff", "--shortstat", `${beforeSha}..${afterSha}`], | |
| 563 | repoDir | |
| 564 | ); | |
| 565 | const match = stat.match( | |
| 566 | /(\d+) files? changed(?:, (\d+) insertions?)?(?:, (\d+) deletions?)?/ | |
| 567 | ); | |
| 568 | if (match) { | |
| 569 | filesChanged = parseInt(match[1], 10) || 0; | |
| 570 | linesAdded = parseInt(match[2], 10) || 0; | |
| 571 | linesRemoved = parseInt(match[3], 10) || 0; | |
| 572 | } | |
| 573 | } | |
| 574 | ||
| 575 | const riskFactors: string[] = []; | |
| 576 | const breakingChangeSignals: string[] = []; | |
| 577 | const securityIssues: SecurityIssue[] = []; | |
| 578 | ||
| 579 | // Get changed files | |
| 580 | let changedFiles: string[] = []; | |
| 581 | if (!isInitial) { | |
| 582 | const { stdout: diff } = await exec( | |
| 583 | ["git", "diff", "--name-only", `${beforeSha}..${afterSha}`], | |
| 584 | repoDir | |
| 585 | ); | |
| 586 | changedFiles = diff.trim().split("\n").filter(Boolean); | |
| 587 | } | |
| 588 | ||
| 589 | // Risk factors | |
| 590 | if (filesChanged > 50) riskFactors.push(`Large changeset: ${filesChanged} files`); | |
| 591 | if (linesAdded + linesRemoved > 2000) riskFactors.push(`High churn: ${linesAdded + linesRemoved} lines`); | |
| 592 | ||
| 593 | // Check for high-risk file changes | |
| 594 | const riskFiles = changedFiles.filter((f) => | |
| 595 | /^(\.env|docker-compose|Dockerfile|\.github\/workflows|package\.json|Cargo\.toml|go\.mod)/i.test(f) | |
| 596 | ); | |
| 597 | if (riskFiles.length > 0) { | |
| 598 | riskFactors.push(`Infrastructure files changed: ${riskFiles.join(", ")}`); | |
| 599 | } | |
| 600 | ||
| 601 | // Breaking change detection | |
| 602 | if (!isInitial) { | |
| 603 | const { stdout: diffContent } = await exec( | |
| 604 | ["git", "diff", `${beforeSha}..${afterSha}`], | |
| 605 | repoDir | |
| 606 | ); | |
| 607 | ||
| 608 | // Detect removed exports | |
| 609 | const removedExports = (diffContent.match(/^-export /gm) || []).length; | |
| 610 | const addedExports = (diffContent.match(/^\+export /gm) || []).length; | |
| 611 | if (removedExports > addedExports) { | |
| 612 | breakingChangeSignals.push( | |
| 613 | `${removedExports - addedExports} exports removed — potential breaking change` | |
| 614 | ); | |
| 615 | } | |
| 616 | ||
| 617 | // Detect renamed/removed public functions | |
| 618 | const removedFunctions = (diffContent.match(/^-(?:export )?(?:async )?function \w+/gm) || []).length; | |
| 619 | if (removedFunctions > 0) { | |
| 620 | breakingChangeSignals.push(`${removedFunctions} function(s) removed or renamed`); | |
| 621 | } | |
| 622 | ||
| 623 | // Detect API route changes | |
| 624 | const routeChanges = (diffContent.match(/^[+-].*\.(get|post|put|delete|patch)\s*\(/gm) || []).length; | |
| 625 | if (routeChanges > 0) { | |
| 626 | breakingChangeSignals.push(`API route changes detected (${routeChanges} modifications)`); | |
| 627 | } | |
| 628 | ||
| 629 | // Security scan the diff | |
| 630 | for (const rule of SECURITY_PATTERNS) { | |
| 631 | const matches = diffContent.match(new RegExp(`^\\+.*${rule.pattern.source}`, "gm")); | |
| 632 | if (matches && matches.length > 0) { | |
| 633 | securityIssues.push({ | |
| 634 | severity: rule.severity, | |
| 635 | file: "diff", | |
| 636 | message: rule.message, | |
| 637 | rule: rule.rule, | |
| 638 | }); | |
| 639 | } | |
| 640 | } | |
| 641 | } | |
| 642 | ||
| 643 | // Hot files (most changed in last 30 days) | |
| 644 | const { stdout: hotOutput } = await exec( | |
| 645 | [ | |
| 646 | "git", | |
| 647 | "log", | |
| 648 | "--since=30 days ago", | |
| 649 | "--format=", | |
| 650 | "--name-only", | |
| 651 | afterSha, | |
| 652 | ], | |
| 653 | repoDir | |
| 654 | ); | |
| 655 | const fileCounts: Record<string, number> = {}; | |
| 656 | for (const f of hotOutput.trim().split("\n").filter(Boolean)) { | |
| 657 | fileCounts[f] = (fileCounts[f] || 0) + 1; | |
| 658 | } | |
| 659 | const hotFiles = Object.entries(fileCounts) | |
| 660 | .sort((a, b) => b[1] - a[1]) | |
| 661 | .slice(0, 5) | |
| 662 | .map(([f]) => f); | |
| 663 | ||
| 664 | // Risk score | |
| 665 | let riskScore = 0; | |
| 666 | riskScore += Math.min(30, filesChanged * 0.5); | |
| 667 | riskScore += Math.min(20, (linesAdded + linesRemoved) * 0.005); | |
| 668 | riskScore += riskFactors.length * 10; | |
| 669 | riskScore += breakingChangeSignals.length * 15; | |
| 670 | riskScore += securityIssues.filter((i) => i.severity === "critical").length * 25; | |
| 671 | riskScore += securityIssues.filter((i) => i.severity === "high").length * 10; | |
| 672 | riskScore = Math.min(100, Math.round(riskScore)); | |
| 673 | ||
| 674 | const summary = generatePushSummary( | |
| 675 | filesChanged, | |
| 676 | linesAdded, | |
| 677 | linesRemoved, | |
| 678 | riskScore, | |
| 679 | breakingChangeSignals, | |
| 680 | securityIssues | |
| 681 | ); | |
| 682 | ||
| 683 | return { | |
| 684 | filesChanged, | |
| 685 | linesAdded, | |
| 686 | linesRemoved, | |
| 687 | riskScore, | |
| 688 | riskFactors, | |
| 689 | breakingChangeSignals, | |
| 690 | securityIssues, | |
| 691 | hotFiles, | |
| 692 | summary, | |
| 693 | }; | |
| 694 | } | |
| 695 | ||
| 696 | // ─── ZERO-CONFIG CI ────────────────────────────────────────── | |
| 697 | ||
| 698 | export interface CIConfig { | |
| 699 | projectType: string; | |
| 700 | runtime: string; | |
| 701 | commands: { name: string; command: string }[]; | |
| 702 | detected: string[]; | |
| 703 | } | |
| 704 | ||
| 705 | export async function detectCIConfig( | |
| 706 | owner: string, | |
| 707 | repo: string, | |
| 708 | ref: string | |
| 709 | ): Promise<CIConfig> { | |
| 710 | const repoDir = getRepoPath(owner, repo); | |
| 711 | ||
| 712 | const { stdout: tree } = await exec( | |
| 713 | ["git", "ls-tree", "--name-only", ref], | |
| 714 | repoDir | |
| 715 | ); | |
| 716 | const rootFiles = tree.trim().split("\n"); | |
| 717 | ||
| 718 | const detected: string[] = []; | |
| 719 | const commands: { name: string; command: string }[] = []; | |
| 720 | let projectType = "unknown"; | |
| 721 | let runtime = "unknown"; | |
| 722 | ||
| 723 | // Node.js / Bun | |
| 724 | if (rootFiles.includes("package.json")) { | |
| 725 | const { stdout: pkg } = await exec( | |
| 726 | ["git", "show", `${ref}:package.json`], | |
| 727 | repoDir | |
| 728 | ); | |
| 729 | try { | |
| 730 | const parsed = JSON.parse(pkg); | |
| 731 | const scripts = parsed.scripts || {}; | |
| 732 | ||
| 733 | if (rootFiles.includes("bun.lock") || rootFiles.includes("bunfig.toml")) { | |
| 734 | runtime = "bun"; | |
| 735 | detected.push("Bun project detected"); | |
| 736 | } else if (rootFiles.includes("yarn.lock")) { | |
| 737 | runtime = "yarn"; | |
| 738 | detected.push("Yarn project detected"); | |
| 739 | } else if (rootFiles.includes("pnpm-lock.yaml")) { | |
| 740 | runtime = "pnpm"; | |
| 741 | detected.push("pnpm project detected"); | |
| 742 | } else { | |
| 743 | runtime = "npm"; | |
| 744 | detected.push("Node.js project detected"); | |
| 745 | } | |
| 746 | ||
| 747 | projectType = "javascript"; | |
| 748 | ||
| 749 | // TypeScript? | |
| 750 | if (rootFiles.includes("tsconfig.json") || parsed.devDependencies?.typescript) { | |
| 751 | detected.push("TypeScript detected"); | |
| 752 | projectType = "typescript"; | |
| 753 | commands.push({ name: "Type check", command: `${runtime === "bun" ? "bun" : "npx"} tsc --noEmit` }); | |
| 754 | } | |
| 755 | ||
| 756 | // Framework detection | |
| 757 | if (parsed.dependencies?.hono) detected.push("Hono framework"); | |
| 758 | if (parsed.dependencies?.next) detected.push("Next.js framework"); | |
| 759 | if (parsed.dependencies?.react) detected.push("React"); | |
| 760 | if (parsed.dependencies?.vue) detected.push("Vue.js"); | |
| 761 | if (parsed.dependencies?.express) detected.push("Express.js"); | |
| 762 | ||
| 763 | // Add available scripts | |
| 764 | if (scripts.lint) commands.push({ name: "Lint", command: `${runtime} run lint` }); | |
| 765 | if (scripts.test) commands.push({ name: "Test", command: `${runtime} ${runtime === "bun" ? "test" : "run test"}` }); | |
| 766 | if (scripts.build) commands.push({ name: "Build", command: `${runtime} run build` }); | |
| 767 | if (scripts.typecheck) commands.push({ name: "Type check", command: `${runtime} run typecheck` }); | |
| 768 | ||
| 769 | // If no lint but eslint exists | |
| 770 | if (!scripts.lint && (parsed.devDependencies?.eslint || parsed.dependencies?.eslint)) { | |
| 771 | commands.push({ name: "Lint", command: `${runtime === "bun" ? "bun" : "npx"} eslint .` }); | |
| 772 | } | |
| 773 | ||
| 774 | // If no test script but test framework exists | |
| 775 | if (!scripts.test) { | |
| 776 | if (parsed.devDependencies?.vitest) { | |
| 777 | commands.push({ name: "Test", command: `${runtime === "bun" ? "bun" : "npx"} vitest run` }); | |
| 778 | } else if (parsed.devDependencies?.jest) { | |
| 779 | commands.push({ name: "Test", command: `${runtime === "bun" ? "bun" : "npx"} jest` }); | |
| 780 | } | |
| 781 | } | |
| 782 | } catch { | |
| 783 | // JSON parse error | |
| 784 | } | |
| 785 | } | |
| 786 | ||
| 787 | // Rust | |
| 788 | if (rootFiles.includes("Cargo.toml")) { | |
| 789 | projectType = "rust"; | |
| 790 | runtime = "cargo"; | |
| 791 | detected.push("Rust project detected"); | |
| 792 | commands.push({ name: "Check", command: "cargo check" }); | |
| 793 | commands.push({ name: "Test", command: "cargo test" }); | |
| 794 | commands.push({ name: "Clippy", command: "cargo clippy -- -D warnings" }); | |
| 795 | commands.push({ name: "Format check", command: "cargo fmt -- --check" }); | |
| 796 | } | |
| 797 | ||
| 798 | // Go | |
| 799 | if (rootFiles.includes("go.mod")) { | |
| 800 | projectType = "go"; | |
| 801 | runtime = "go"; | |
| 802 | detected.push("Go project detected"); | |
| 803 | commands.push({ name: "Build", command: "go build ./..." }); | |
| 804 | commands.push({ name: "Test", command: "go test ./..." }); | |
| 805 | commands.push({ name: "Vet", command: "go vet ./..." }); | |
| 806 | } | |
| 807 | ||
| 808 | // Python | |
| 809 | if ( | |
| 810 | rootFiles.includes("requirements.txt") || | |
| 811 | rootFiles.includes("pyproject.toml") || | |
| 812 | rootFiles.includes("setup.py") | |
| 813 | ) { | |
| 814 | projectType = "python"; | |
| 815 | runtime = "python"; | |
| 816 | detected.push("Python project detected"); | |
| 817 | if (rootFiles.includes("pyproject.toml")) { | |
| 818 | detected.push("pyproject.toml found"); | |
| 819 | } | |
| 820 | commands.push({ name: "Test", command: "python -m pytest" }); | |
| 821 | commands.push({ name: "Type check", command: "python -m mypy ." }); | |
| 822 | } | |
| 823 | ||
| 824 | return { projectType, runtime, commands, detected }; | |
| 825 | } | |
| 826 | ||
| 827 | // ─── HELPERS ───────────────────────────────────────────────── | |
| 828 | ||
| 829 | function generateInsights(breakdown: RepoHealthReport["breakdown"]): string[] { | |
| 830 | const insights: string[] = []; | |
| 831 | ||
| 832 | if (breakdown.security.issues.length === 0) { | |
| 833 | insights.push("No security issues detected — clean codebase"); | |
| 834 | } else { | |
| 835 | const criticals = breakdown.security.issues.filter((i) => i.severity === "critical").length; | |
| 836 | if (criticals > 0) { | |
| 837 | insights.push(`${criticals} critical security issue${criticals > 1 ? "s" : ""} found — immediate action recommended`); | |
| 838 | } | |
| 839 | } | |
| 840 | ||
| 841 | if (!breakdown.testing.hasTests) { | |
| 842 | insights.push("No tests found — adding tests would significantly improve code reliability"); | |
| 843 | } else if (breakdown.testing.testFileCount > 10) { | |
| 844 | insights.push(`Strong test suite with ${breakdown.testing.testFileCount} test files`); | |
| 845 | } | |
| 846 | ||
| 847 | if (!breakdown.documentation.hasReadme) { | |
| 848 | insights.push("No README — new contributors won't know how to get started"); | |
| 849 | } | |
| 850 | ||
| 851 | if (!breakdown.documentation.hasLicense) { | |
| 852 | insights.push("No LICENSE file — open source projects need a license to be usable"); | |
| 853 | } | |
| 854 | ||
| 855 | if (!breakdown.dependencies.lockfileExists && breakdown.dependencies.total > 0) { | |
| 856 | insights.push("No lockfile — builds may not be reproducible"); | |
| 857 | } | |
| 858 | ||
| 859 | if (breakdown.activity.lastPushDaysAgo > 90) { | |
| 860 | insights.push("No activity in 90+ days — project may be dormant"); | |
| 861 | } else if (breakdown.activity.recentCommits > 20) { | |
| 862 | insights.push("Very active project — strong development momentum"); | |
| 863 | } | |
| 864 | ||
| 865 | if (breakdown.activity.uniqueContributors === 1) { | |
| 866 | insights.push("Single contributor — consider inviting collaborators for code review"); | |
| 867 | } | |
| 868 | ||
| 869 | return insights; | |
| 870 | } | |
| 871 | ||
| 872 | function generatePushSummary( | |
| 873 | filesChanged: number, | |
| 874 | linesAdded: number, | |
| 875 | linesRemoved: number, | |
| 876 | riskScore: number, | |
| 877 | breakingChanges: string[], | |
| 878 | securityIssues: SecurityIssue[] | |
| 879 | ): string { | |
| 880 | const parts: string[] = []; | |
| 881 | parts.push(`${filesChanged} files changed (+${linesAdded} -${linesRemoved})`); | |
| 882 | ||
| 883 | if (riskScore <= 20) parts.push("Low risk push"); | |
| 884 | else if (riskScore <= 50) parts.push("Moderate risk — review recommended"); | |
| 885 | else parts.push("High risk — careful review required"); | |
| 886 | ||
| 887 | if (breakingChanges.length > 0) { | |
| 888 | parts.push(`${breakingChanges.length} potential breaking change(s)`); | |
| 889 | } | |
| 890 | ||
| 891 | const critSec = securityIssues.filter((i) => i.severity === "critical" || i.severity === "high").length; | |
| 892 | if (critSec > 0) { | |
| 893 | parts.push(`${critSec} security concern(s)`); | |
| 894 | } | |
| 895 | ||
| 896 | return parts.join(" | "); | |
| 897 | } |