Scaffold dd0c/portal: AWS+GitHub discovery, catalog service, ownership resolution
- AWS scanner: ECS/Lambda/RDS discovery with partial failure handling - GitHub scanner: CODEOWNERS parsing, commit-based heuristic ownership, rate limit resilience - Catalog service: ownership resolution (config > codeowners > aws-tag > heuristic), staged updates for partial scans - Ownership tests: 6 cases covering full priority chain - PostgreSQL schema with RLS: services, staged_updates, scan_history, free tier (50 services) - Fly.io config, Dockerfile
This commit is contained in:
14
products/04-lightweight-idp/Dockerfile
Normal file
14
products/04-lightweight-idp/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM node:22-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/package.json ./
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
27
products/04-lightweight-idp/fly.toml
Normal file
27
products/04-lightweight-idp/fly.toml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
app = "dd0c-portal"
|
||||||
|
primary_region = "iad"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
dockerfile = "Dockerfile"
|
||||||
|
|
||||||
|
[env]
|
||||||
|
NODE_ENV = "production"
|
||||||
|
PORT = "3000"
|
||||||
|
LOG_LEVEL = "info"
|
||||||
|
|
||||||
|
[http_service]
|
||||||
|
internal_port = 3000
|
||||||
|
force_https = true
|
||||||
|
auto_stop_machines = true
|
||||||
|
auto_start_machines = true
|
||||||
|
min_machines_running = 0
|
||||||
|
|
||||||
|
[http_service.concurrency]
|
||||||
|
type = "requests"
|
||||||
|
hard_limit = 100
|
||||||
|
soft_limit = 80
|
||||||
|
|
||||||
|
[[vm]]
|
||||||
|
cpu_kind = "shared"
|
||||||
|
cpus = 1
|
||||||
|
memory_mb = 256
|
||||||
73
products/04-lightweight-idp/migrations/001_init.sql
Normal file
73
products/04-lightweight-idp/migrations/001_init.sql
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
-- dd0c/portal V1 schema — PostgreSQL with RLS + Meilisearch for search
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- Tenants
|
||||||
|
CREATE TABLE tenants (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
tier TEXT NOT NULL DEFAULT 'free' CHECK (tier IN ('free', 'pro')),
|
||||||
|
service_count INT NOT NULL DEFAULT 0,
|
||||||
|
max_services INT NOT NULL DEFAULT 50, -- Free tier: 50
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Service catalog
|
||||||
|
CREATE TABLE services (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL DEFAULT 'unknown',
|
||||||
|
owner TEXT NOT NULL DEFAULT 'unknown',
|
||||||
|
owner_source TEXT NOT NULL DEFAULT 'heuristic' CHECK (owner_source IN ('config', 'codeowners', 'aws-tag', 'heuristic')),
|
||||||
|
description TEXT,
|
||||||
|
tier TEXT DEFAULT 'medium' CHECK (tier IN ('critical', 'high', 'medium', 'low')),
|
||||||
|
lifecycle TEXT DEFAULT 'active' CHECK (lifecycle IN ('active', 'deprecated', 'decommissioned')),
|
||||||
|
links JSONB NOT NULL DEFAULT '{}',
|
||||||
|
tags JSONB NOT NULL DEFAULT '{}',
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}',
|
||||||
|
last_discovered_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE(tenant_id, name)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_services_tenant ON services(tenant_id);
|
||||||
|
CREATE INDEX idx_services_owner ON services(tenant_id, owner);
|
||||||
|
CREATE INDEX idx_services_type ON services(tenant_id, type);
|
||||||
|
|
||||||
|
-- Staged updates (partial scan results awaiting review)
|
||||||
|
CREATE TABLE staged_updates (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
service_name TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL CHECK (source IN ('aws', 'github', 'manual')),
|
||||||
|
changes JSONB NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'applied', 'rejected')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_staged_tenant ON staged_updates(tenant_id, status);
|
||||||
|
|
||||||
|
-- Discovery scan history
|
||||||
|
CREATE TABLE scan_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
scanner TEXT NOT NULL CHECK (scanner IN ('aws', 'github')),
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('success', 'partial_failure', 'failed')),
|
||||||
|
discovered INT NOT NULL DEFAULT 0,
|
||||||
|
errors TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
completed_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE services ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE staged_updates ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE scan_history ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_iso_services ON services
|
||||||
|
USING (tenant_id::text = current_setting('app.tenant_id', true));
|
||||||
|
CREATE POLICY tenant_iso_staged ON staged_updates
|
||||||
|
USING (tenant_id::text = current_setting('app.tenant_id', true));
|
||||||
|
CREATE POLICY tenant_iso_scans ON scan_history
|
||||||
|
USING (tenant_id::text = current_setting('app.tenant_id', true));
|
||||||
40
products/04-lightweight-idp/package.json
Normal file
40
products/04-lightweight-idp/package.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "dd0c-portal",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"lint": "eslint src/ tests/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"fastify": "^4.28.0",
|
||||||
|
"@fastify/cors": "^9.0.0",
|
||||||
|
"@fastify/helmet": "^11.1.0",
|
||||||
|
"pg": "^8.12.0",
|
||||||
|
"ioredis": "^5.4.0",
|
||||||
|
"meilisearch": "^0.41.0",
|
||||||
|
"zod": "^3.23.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"pino": "^9.1.0",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
|
"@aws-sdk/client-organizations": "^3.600.0",
|
||||||
|
"@aws-sdk/client-ecs": "^3.600.0",
|
||||||
|
"@aws-sdk/client-lambda": "^3.600.0",
|
||||||
|
"@aws-sdk/client-rds": "^3.600.0",
|
||||||
|
"@octokit/rest": "^20.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.5.0",
|
||||||
|
"tsx": "^4.15.0",
|
||||||
|
"vitest": "^1.6.0",
|
||||||
|
"@types/node": "^20.14.0",
|
||||||
|
"@types/pg": "^8.11.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.6",
|
||||||
|
"@types/uuid": "^9.0.8",
|
||||||
|
"eslint": "^9.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
152
products/04-lightweight-idp/src/catalog/service.ts
Normal file
152
products/04-lightweight-idp/src/catalog/service.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import pino from 'pino';
|
||||||
|
import type { DiscoveredService } from './aws-scanner.js';
|
||||||
|
import type { GitHubRepo } from './github-scanner.js';
|
||||||
|
|
||||||
|
const logger = pino({ name: 'catalog' });
|
||||||
|
|
||||||
|
// --- Ownership Resolution (BMad must-have: explicit > implicit > heuristic) ---
|
||||||
|
|
||||||
|
export type OwnerSource = 'config' | 'codeowners' | 'aws-tag' | 'heuristic';
|
||||||
|
|
||||||
|
export interface OwnershipRecord {
|
||||||
|
owner: string;
|
||||||
|
source: OwnerSource;
|
||||||
|
confidence: number; // 0-1
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCE_PRIORITY: Record<OwnerSource, number> = {
|
||||||
|
config: 4,
|
||||||
|
codeowners: 3,
|
||||||
|
'aws-tag': 2,
|
||||||
|
heuristic: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveOwnership(candidates: OwnershipRecord[]): OwnershipRecord {
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return { owner: 'unknown', source: 'heuristic', confidence: 0 };
|
||||||
|
}
|
||||||
|
// Highest priority source wins
|
||||||
|
return candidates.sort((a, b) => SOURCE_PRIORITY[b.source] - SOURCE_PRIORITY[a.source])[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Catalog Service ---
|
||||||
|
|
||||||
|
export interface CatalogEntry {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
owner: string;
|
||||||
|
ownerSource: OwnerSource;
|
||||||
|
description?: string;
|
||||||
|
tier?: 'critical' | 'high' | 'medium' | 'low';
|
||||||
|
lifecycle?: 'active' | 'deprecated' | 'decommissioned';
|
||||||
|
links: Record<string, string>; // repo, dashboard, runbook, etc.
|
||||||
|
tags: Record<string, string>;
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
lastDiscoveredAt: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StagedUpdate {
|
||||||
|
serviceName: string;
|
||||||
|
changes: Partial<CatalogEntry>;
|
||||||
|
source: 'aws' | 'github' | 'manual';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catalog service handles merging discovery results into the catalog.
|
||||||
|
* Partial scan failures stage results without committing (BMad must-have).
|
||||||
|
*/
|
||||||
|
export class CatalogService {
|
||||||
|
private pool: any;
|
||||||
|
|
||||||
|
constructor(pool: any) {
|
||||||
|
this.pool = pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
async mergeAwsDiscovery(tenantId: string, services: DiscoveredService[], isPartial: boolean): Promise<number> {
|
||||||
|
if (isPartial) {
|
||||||
|
// Stage results — don't delete or overwrite existing entries
|
||||||
|
logger.warn({ tenantId, count: services.length }, 'Partial scan — staging results');
|
||||||
|
return this.stageUpdates(tenantId, services.map(s => ({
|
||||||
|
serviceName: s.name,
|
||||||
|
changes: {
|
||||||
|
type: s.type,
|
||||||
|
tags: s.tags,
|
||||||
|
owner: s.owner,
|
||||||
|
ownerSource: s.ownerSource as OwnerSource,
|
||||||
|
lastDiscoveredAt: s.discoveredAt,
|
||||||
|
},
|
||||||
|
source: 'aws' as const,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full scan — upsert all discovered services
|
||||||
|
let upserted = 0;
|
||||||
|
for (const svc of services) {
|
||||||
|
await this.upsertService(tenantId, {
|
||||||
|
name: svc.name,
|
||||||
|
type: svc.type,
|
||||||
|
tags: svc.tags,
|
||||||
|
metadata: svc.metadata,
|
||||||
|
owner: svc.owner ?? 'unknown',
|
||||||
|
ownerSource: (svc.ownerSource as OwnerSource) ?? 'aws-tag',
|
||||||
|
lastDiscoveredAt: svc.discoveredAt,
|
||||||
|
});
|
||||||
|
upserted++;
|
||||||
|
}
|
||||||
|
return upserted;
|
||||||
|
}
|
||||||
|
|
||||||
|
async mergeGitHubDiscovery(tenantId: string, repos: GitHubRepo[], isPartial: boolean): Promise<number> {
|
||||||
|
if (isPartial) {
|
||||||
|
return this.stageUpdates(tenantId, repos.map(r => ({
|
||||||
|
serviceName: r.name,
|
||||||
|
changes: {
|
||||||
|
owner: r.owner,
|
||||||
|
ownerSource: r.ownerSource as OwnerSource,
|
||||||
|
links: { repo: `https://github.com/${r.fullName}` },
|
||||||
|
tags: { language: r.language },
|
||||||
|
},
|
||||||
|
source: 'github' as const,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
let upserted = 0;
|
||||||
|
for (const repo of repos) {
|
||||||
|
// Only update ownership if GitHub source has higher priority
|
||||||
|
const existing = await this.getService(tenantId, repo.name);
|
||||||
|
if (existing) {
|
||||||
|
const resolved = resolveOwnership([
|
||||||
|
{ owner: existing.owner, source: existing.ownerSource, confidence: 1 },
|
||||||
|
{ owner: repo.owner, source: repo.ownerSource as OwnerSource, confidence: 0.8 },
|
||||||
|
]);
|
||||||
|
await this.updateOwner(tenantId, repo.name, resolved.owner, resolved.source);
|
||||||
|
}
|
||||||
|
upserted++;
|
||||||
|
}
|
||||||
|
return upserted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async stageUpdates(tenantId: string, updates: StagedUpdate[]): Promise<number> {
|
||||||
|
// Write to staging table — admin reviews before committing
|
||||||
|
// TODO: INSERT INTO staged_updates
|
||||||
|
logger.info({ tenantId, count: updates.length }, 'Staged updates for review');
|
||||||
|
return updates.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertService(tenantId: string, data: Partial<CatalogEntry>): Promise<void> {
|
||||||
|
// TODO: INSERT ... ON CONFLICT (tenant_id, name) DO UPDATE
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getService(tenantId: string, name: string): Promise<CatalogEntry | null> {
|
||||||
|
// TODO: SELECT from catalog
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateOwner(tenantId: string, name: string, owner: string, source: OwnerSource): Promise<void> {
|
||||||
|
// TODO: UPDATE catalog SET owner, owner_source
|
||||||
|
}
|
||||||
|
}
|
||||||
95
products/04-lightweight-idp/src/discovery/aws-scanner.ts
Normal file
95
products/04-lightweight-idp/src/discovery/aws-scanner.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import pino from 'pino';
|
||||||
|
|
||||||
|
const logger = pino({ name: 'discovery-aws' });
|
||||||
|
|
||||||
|
export interface DiscoveredService {
|
||||||
|
name: string;
|
||||||
|
type: string; // 'ecs-service', 'lambda', 'rds', 'ec2', etc.
|
||||||
|
arn: string;
|
||||||
|
region: string;
|
||||||
|
account: string;
|
||||||
|
tags: Record<string, string>;
|
||||||
|
owner?: string; // From tags or CODEOWNERS
|
||||||
|
ownerSource?: 'aws-tag' | 'codeowners' | 'config' | 'heuristic';
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
discoveredAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanResult {
|
||||||
|
status: 'success' | 'partial_failure' | 'failed';
|
||||||
|
discovered: number;
|
||||||
|
errors: string[];
|
||||||
|
services: DiscoveredService[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AWS Discovery Scanner.
|
||||||
|
* Scans ECS services, Lambda functions, RDS instances.
|
||||||
|
* Partial failures preserve existing catalog (BMad must-have).
|
||||||
|
*/
|
||||||
|
export class AwsDiscoveryScanner {
|
||||||
|
private ecsClient: any;
|
||||||
|
private lambdaClient: any;
|
||||||
|
private rdsClient: any;
|
||||||
|
|
||||||
|
constructor(clients: { ecs: any; lambda: any; rds: any }) {
|
||||||
|
this.ecsClient = clients.ecs;
|
||||||
|
this.lambdaClient = clients.lambda;
|
||||||
|
this.rdsClient = clients.rds;
|
||||||
|
}
|
||||||
|
|
||||||
|
async scan(region: string, account: string): Promise<ScanResult> {
|
||||||
|
const services: DiscoveredService[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// Scan ECS
|
||||||
|
try {
|
||||||
|
const ecsServices = await this.scanEcs(region, account);
|
||||||
|
services.push(...ecsServices);
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`ECS scan failed: ${(err as Error).message}`);
|
||||||
|
logger.warn({ region, error: (err as Error).message }, 'ECS scan failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan Lambda
|
||||||
|
try {
|
||||||
|
const lambdaFns = await this.scanLambda(region, account);
|
||||||
|
services.push(...lambdaFns);
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`Lambda scan failed: ${(err as Error).message}`);
|
||||||
|
logger.warn({ region, error: (err as Error).message }, 'Lambda scan failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan RDS
|
||||||
|
try {
|
||||||
|
const rdsInstances = await this.scanRds(region, account);
|
||||||
|
services.push(...rdsInstances);
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`RDS scan failed: ${(err as Error).message}`);
|
||||||
|
logger.warn({ region, error: (err as Error).message }, 'RDS scan failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = errors.length === 0
|
||||||
|
? 'success'
|
||||||
|
: services.length > 0
|
||||||
|
? 'partial_failure'
|
||||||
|
: 'failed';
|
||||||
|
|
||||||
|
return { status, discovered: services.length, errors, services };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async scanEcs(region: string, account: string): Promise<DiscoveredService[]> {
|
||||||
|
// TODO: List clusters → list services → describe services → extract tags
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async scanLambda(region: string, account: string): Promise<DiscoveredService[]> {
|
||||||
|
// TODO: List functions → get tags → map to DiscoveredService
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async scanRds(region: string, account: string): Promise<DiscoveredService[]> {
|
||||||
|
// TODO: Describe DB instances → extract tags
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
139
products/04-lightweight-idp/src/discovery/github-scanner.ts
Normal file
139
products/04-lightweight-idp/src/discovery/github-scanner.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import pino from 'pino';
|
||||||
|
import type { DiscoveredService } from './aws-scanner.js';
|
||||||
|
|
||||||
|
const logger = pino({ name: 'discovery-github' });
|
||||||
|
|
||||||
|
export interface GitHubScanResult {
|
||||||
|
status: 'success' | 'partial_failure' | 'failed';
|
||||||
|
discovered: number;
|
||||||
|
errors: string[];
|
||||||
|
repos: GitHubRepo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitHubRepo {
|
||||||
|
name: string;
|
||||||
|
fullName: string;
|
||||||
|
owner: string;
|
||||||
|
ownerSource: 'codeowners' | 'heuristic';
|
||||||
|
language: string;
|
||||||
|
defaultBranch: string;
|
||||||
|
topics: string[];
|
||||||
|
lastPush: Date;
|
||||||
|
codeownersContent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitHub Discovery Scanner.
|
||||||
|
* Reads repos, CODEOWNERS, and infers ownership from commit history.
|
||||||
|
* Partial failures (rate limits) preserve existing catalog entries.
|
||||||
|
*/
|
||||||
|
export class GitHubDiscoveryScanner {
|
||||||
|
private octokit: any;
|
||||||
|
|
||||||
|
constructor(octokit: any) {
|
||||||
|
this.octokit = octokit;
|
||||||
|
}
|
||||||
|
|
||||||
|
async scan(org: string): Promise<GitHubScanResult> {
|
||||||
|
const repos: GitHubRepo[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await this.octokit.repos.listForOrg({
|
||||||
|
org,
|
||||||
|
per_page: 100,
|
||||||
|
sort: 'pushed',
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const repo of data) {
|
||||||
|
try {
|
||||||
|
const owner = await this.resolveOwner(org, repo.name, repo.default_branch);
|
||||||
|
repos.push({
|
||||||
|
name: repo.name,
|
||||||
|
fullName: repo.full_name,
|
||||||
|
owner: owner.owner,
|
||||||
|
ownerSource: owner.source,
|
||||||
|
language: repo.language ?? 'unknown',
|
||||||
|
defaultBranch: repo.default_branch,
|
||||||
|
topics: repo.topics ?? [],
|
||||||
|
lastPush: new Date(repo.pushed_at),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`Repo ${repo.name}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as Error).message;
|
||||||
|
if (msg.includes('rate limit')) {
|
||||||
|
logger.warn({ org }, 'GitHub rate limited during scan');
|
||||||
|
return { status: 'partial_failure', discovered: repos.length, errors: [msg], repos };
|
||||||
|
}
|
||||||
|
return { status: 'failed', discovered: 0, errors: [msg], repos: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: errors.length > 0 ? 'partial_failure' : 'success',
|
||||||
|
discovered: repos.length,
|
||||||
|
errors,
|
||||||
|
repos,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveOwner(org: string, repo: string, branch: string): Promise<{ owner: string; source: 'codeowners' | 'heuristic' }> {
|
||||||
|
// Try CODEOWNERS first (explicit > heuristic)
|
||||||
|
try {
|
||||||
|
const { data } = await this.octokit.repos.getContent({
|
||||||
|
owner: org,
|
||||||
|
repo,
|
||||||
|
path: '.github/CODEOWNERS',
|
||||||
|
ref: branch,
|
||||||
|
});
|
||||||
|
const content = Buffer.from(data.content, 'base64').toString();
|
||||||
|
const owner = parseCodeowners(content);
|
||||||
|
if (owner) return { owner, source: 'codeowners' };
|
||||||
|
} catch {
|
||||||
|
// No CODEOWNERS file — fall through to heuristic
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heuristic: top committer in last 90 days
|
||||||
|
try {
|
||||||
|
const { data } = await this.octokit.repos.listCommits({
|
||||||
|
owner: org,
|
||||||
|
repo,
|
||||||
|
per_page: 50,
|
||||||
|
since: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
});
|
||||||
|
const authors = data
|
||||||
|
.map((c: any) => c.author?.login)
|
||||||
|
.filter(Boolean);
|
||||||
|
const topAuthor = mode(authors);
|
||||||
|
if (topAuthor) return { owner: topAuthor, source: 'heuristic' };
|
||||||
|
} catch {
|
||||||
|
// Can't determine owner
|
||||||
|
}
|
||||||
|
|
||||||
|
return { owner: 'unknown', source: 'heuristic' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCodeowners(content: string): string | null {
|
||||||
|
const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('#'));
|
||||||
|
// Last matching rule wins in CODEOWNERS — take the global rule (* @team)
|
||||||
|
const globalRule = lines.find(l => l.startsWith('*'));
|
||||||
|
if (globalRule) {
|
||||||
|
const parts = globalRule.split(/\s+/);
|
||||||
|
return parts[1]?.replace('@', '') ?? null;
|
||||||
|
}
|
||||||
|
return lines[0]?.split(/\s+/)[1]?.replace('@', '') ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mode(arr: string[]): string | null {
|
||||||
|
const freq: Record<string, number> = {};
|
||||||
|
for (const item of arr) freq[item] = (freq[item] ?? 0) + 1;
|
||||||
|
let max = 0;
|
||||||
|
let result: string | null = null;
|
||||||
|
for (const [key, count] of Object.entries(freq)) {
|
||||||
|
if (count > max) { max = count; result = key; }
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
60
products/04-lightweight-idp/tests/unit/ownership.test.ts
Normal file
60
products/04-lightweight-idp/tests/unit/ownership.test.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { resolveOwnership, type OwnershipRecord } from '../../src/catalog/service.js';
|
||||||
|
|
||||||
|
describe('Ownership Resolution', () => {
|
||||||
|
it('explicit config overrides AWS tag', () => {
|
||||||
|
const candidates: OwnershipRecord[] = [
|
||||||
|
{ owner: 'team-infra', source: 'aws-tag', confidence: 1 },
|
||||||
|
{ owner: 'team-platform', source: 'config', confidence: 1 },
|
||||||
|
];
|
||||||
|
const result = resolveOwnership(candidates);
|
||||||
|
expect(result.owner).toBe('team-platform');
|
||||||
|
expect(result.source).toBe('config');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CODEOWNERS overrides AWS tag', () => {
|
||||||
|
const candidates: OwnershipRecord[] = [
|
||||||
|
{ owner: 'team-infra', source: 'aws-tag', confidence: 1 },
|
||||||
|
{ owner: 'team-platform', source: 'codeowners', confidence: 1 },
|
||||||
|
];
|
||||||
|
const result = resolveOwnership(candidates);
|
||||||
|
expect(result.owner).toBe('team-platform');
|
||||||
|
expect(result.source).toBe('codeowners');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AWS tag overrides heuristic', () => {
|
||||||
|
const candidates: OwnershipRecord[] = [
|
||||||
|
{ owner: 'dev@other.com', source: 'heuristic', confidence: 0.5 },
|
||||||
|
{ owner: 'team-infra', source: 'aws-tag', confidence: 1 },
|
||||||
|
];
|
||||||
|
const result = resolveOwnership(candidates);
|
||||||
|
expect(result.owner).toBe('team-infra');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('heuristic does not override explicit config', () => {
|
||||||
|
const candidates: OwnershipRecord[] = [
|
||||||
|
{ owner: 'team-platform', source: 'config', confidence: 1 },
|
||||||
|
{ owner: 'dev@other.com', source: 'heuristic', confidence: 0.8 },
|
||||||
|
];
|
||||||
|
const result = resolveOwnership(candidates);
|
||||||
|
expect(result.owner).toBe('team-platform');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unknown for empty candidates', () => {
|
||||||
|
const result = resolveOwnership([]);
|
||||||
|
expect(result.owner).toBe('unknown');
|
||||||
|
expect(result.source).toBe('heuristic');
|
||||||
|
expect(result.confidence).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('config > codeowners > aws-tag > heuristic (full chain)', () => {
|
||||||
|
const candidates: OwnershipRecord[] = [
|
||||||
|
{ owner: 'heuristic-team', source: 'heuristic', confidence: 0.3 },
|
||||||
|
{ owner: 'aws-team', source: 'aws-tag', confidence: 0.8 },
|
||||||
|
{ owner: 'codeowners-team', source: 'codeowners', confidence: 0.9 },
|
||||||
|
{ owner: 'config-team', source: 'config', confidence: 1 },
|
||||||
|
];
|
||||||
|
const result = resolveOwnership(candidates);
|
||||||
|
expect(result.owner).toBe('config-team');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user