Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Hono } from 'hono';
import { HELM_VERSION } from '@helm/shared';
import { itemsRouter } from './routes/items.js';
import { productRouter } from './routes/product.js';
import { productsRouter } from './routes/products.js';
import { webhooksRouter } from './routes/webhooks.js';

export const app = new Hono();
Expand All @@ -16,5 +17,6 @@ app.get('/health', (c) =>
);

app.route('/api', productRouter);
app.route('/api', productsRouter);
app.route('/api', itemsRouter);
app.route('/api', webhooksRouter);
171 changes: 171 additions & 0 deletions apps/api/src/routes/products.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { app } from '../app.js';
import { _resetForTests } from '../services/index.js';

// ── Fixtures ──────────────────────────────────────────────────────────────────

const HELM_PRODUCT = {
helm_version: '0',
product: { slug: 'helm', name: 'Helm' },
workflow: { stages_enabled: ['discovery', 'released'], designer_gate: 'skip', qa_gate: 'skip' },
};

const PLAYGROUND_PRODUCT = {
helm_version: '0',
product: { slug: 'helm-playground', name: 'Helm Playground' },
workflow: { stages_enabled: ['discovery', 'released'], designer_gate: 'skip', qa_gate: 'skip' },
};

const HELM_ITEMS = [
{
externalId: 'issue_1',
productSlug: 'helm',
currentStage: 'discovery',
history: [],
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
},
{
externalId: 'issue_2',
productSlug: 'helm',
currentStage: 'released',
history: [],
createdAt: '2026-01-02T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
},
];

const PLAYGROUND_ITEMS = [
{
externalId: 'issue_10',
productSlug: 'helm-playground',
currentStage: 'discovery',
history: [],
createdAt: '2026-01-03T00:00:00Z',
updatedAt: '2026-01-03T00:00:00Z',
},
];

// ── Mocks ─────────────────────────────────────────────────────────────────────

const { mockGetProductRegistry, mockList } = vi.hoisted(() => ({
mockGetProductRegistry: vi.fn(),
mockList: vi.fn(),
}));

vi.mock('../services/index.js', async (importOriginal) => {
const real = await importOriginal<typeof import('../services/index.js')>();
return {
...real,
getProductRegistry: mockGetProductRegistry,
getItemStore: vi.fn().mockResolvedValue({ list: mockList }),
};
});

// ── Tests ─────────────────────────────────────────────────────────────────────

describe('GET /api/products', () => {
beforeEach(() => {
_resetForTests();
vi.clearAllMocks();
});

afterEach(() => {
vi.clearAllMocks();
});

describe('GET /api/products', () => {
it('returns all registered products', async () => {
mockGetProductRegistry.mockResolvedValue([HELM_PRODUCT, PLAYGROUND_PRODUCT]);
const res = await app.request('/api/products');
expect(res.status).toBe(200);
const body = (await res.json()) as (typeof HELM_PRODUCT)[];
expect(body).toHaveLength(2);
expect(body.map((p) => p.product.slug).sort()).toEqual(['helm', 'helm-playground']);
});

it('returns 500 when registry loading fails', async () => {
mockGetProductRegistry.mockRejectedValue(new Error('disk error'));
const res = await app.request('/api/products');
expect(res.status).toBe(500);
});
});

describe('GET /api/products/:slug', () => {
it('returns 400 for invalid slug format', async () => {
const res = await app.request('/api/products/INVALID_SLUG!');
expect(res.status).toBe(400);
});

it('returns the matching product', async () => {
mockGetProductRegistry.mockResolvedValue([HELM_PRODUCT, PLAYGROUND_PRODUCT]);
const res = await app.request('/api/products/helm-playground');
expect(res.status).toBe(200);
const body = (await res.json()) as typeof PLAYGROUND_PRODUCT;
expect(body.product.slug).toBe('helm-playground');
});

it('returns 404 for unknown slug', async () => {
mockGetProductRegistry.mockResolvedValue([HELM_PRODUCT]);
const res = await app.request('/api/products/nonexistent');
expect(res.status).toBe(404);
});

it('returns 500 when registry loading fails', async () => {
mockGetProductRegistry.mockRejectedValue(new Error('disk error'));
const res = await app.request('/api/products/helm');
expect(res.status).toBe(500);
});
});

describe('GET /api/products/:slug/items', () => {
it('returns only items belonging to the requested product', async () => {
mockGetProductRegistry.mockResolvedValue([HELM_PRODUCT, PLAYGROUND_PRODUCT]);
mockList.mockResolvedValue([...HELM_ITEMS, ...PLAYGROUND_ITEMS]);

const res = await app.request('/api/products/helm/items');
expect(res.status).toBe(200);
const body = (await res.json()) as typeof HELM_ITEMS;
expect(body).toHaveLength(2);
expect(body.every((i) => i.productSlug === 'helm')).toBe(true);
});

it('returns only playground items when requesting helm-playground', async () => {
mockGetProductRegistry.mockResolvedValue([HELM_PRODUCT, PLAYGROUND_PRODUCT]);
mockList.mockResolvedValue([...HELM_ITEMS, ...PLAYGROUND_ITEMS]);

const res = await app.request('/api/products/helm-playground/items');
expect(res.status).toBe(200);
const body = (await res.json()) as typeof PLAYGROUND_ITEMS;
expect(body).toHaveLength(1);
expect(body[0]!.externalId).toBe('issue_10');
});

it('returns empty array when the product has no items', async () => {
mockGetProductRegistry.mockResolvedValue([HELM_PRODUCT, PLAYGROUND_PRODUCT]);
mockList.mockResolvedValue(HELM_ITEMS);

const res = await app.request('/api/products/helm-playground/items');
expect(res.status).toBe(200);
expect(await res.json()).toEqual([]);
});

it('returns 400 for invalid slug format', async () => {
const res = await app.request('/api/products/BAD_SLUG!/items');
expect(res.status).toBe(400);
});

it('returns 404 when the product slug does not exist', async () => {
mockGetProductRegistry.mockResolvedValue([HELM_PRODUCT]);
const res = await app.request('/api/products/ghost/items');
expect(res.status).toBe(404);
});

it('returns 500 when item store fails', async () => {
mockGetProductRegistry.mockResolvedValue([HELM_PRODUCT]);
mockList.mockRejectedValue(new Error('disk error'));
const res = await app.request('/api/products/helm/items');
expect(res.status).toBe(500);
});
});
});
70 changes: 70 additions & 0 deletions apps/api/src/routes/products.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Hono } from 'hono';
import { z } from 'zod';
import { getProductRegistry, getItemStore } from '../services/index.js';

export const productsRouter = new Hono();

// Slug format mirrors product.schema.ts: lowercase alphanumeric + hyphens.
const SlugParamsSchema = z
.object({
slug: z
.string()
.min(1)
.regex(/^[a-z0-9-]+$/, 'Invalid product slug format'),
})
.strict();

function parseSlug(raw: string): string | null {
const result = SlugParamsSchema.safeParse({ slug: raw });
return result.success ? result.data.slug : null;
}

// ── GET /api/products ─────────────────────────────────────────────────────────

productsRouter.get('/products', async (c) => {
try {
const products = await getProductRegistry();
return c.json(products);
} catch (err) {
console.error('[products] Failed to load product registry:', err);
return c.json({ error: 'Failed to load product registry' }, 500);
}
});

// ── GET /api/products/:slug ───────────────────────────────────────────────────

productsRouter.get('/products/:slug', async (c) => {
const slug = parseSlug(c.req.param('slug'));
if (!slug) return c.json({ error: 'Invalid product slug' }, 400);

try {
const products = await getProductRegistry();
const product = products.find((p) => p.product.slug === slug);
if (!product) return c.json({ error: `Product not found: ${slug}` }, 404);
return c.json(product);
} catch (err) {
console.error(`[products] Failed to load product ${slug}:`, err);
return c.json({ error: 'Failed to load product registry' }, 500);
}
});

// ── GET /api/products/:slug/items ─────────────────────────────────────────────

productsRouter.get('/products/:slug/items', async (c) => {
const slug = parseSlug(c.req.param('slug'));
if (!slug) return c.json({ error: 'Invalid product slug' }, 400);

try {
const products = await getProductRegistry();
const product = products.find((p) => p.product.slug === slug);
if (!product) return c.json({ error: `Product not found: ${slug}` }, 404);

const store = await getItemStore();
const allItems = await store.list();
const items = allItems.filter((item) => item.productSlug === slug);
return c.json(items);
} catch (err) {
console.error(`[products] Failed to load items for ${slug}:`, err);
return c.json({ error: 'Failed to load items' }, 500);
}
});
71 changes: 70 additions & 1 deletion apps/api/src/services/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { join } from 'node:path';
import { ensureDataDir } from '@helm/storage';
import { parseProductConfigFromFile } from '@helm/shared';
import { parseProductConfigFromFile, loadProductRegistry, ProductConfigError } from '@helm/shared';
import type { Product } from '@helm/shared';
import { GitHubProjectsAdapter } from '@helm/adapters';
import { ItemStore } from './item-store.js';
Expand Down Expand Up @@ -101,6 +101,73 @@ export async function getGitHubAdapter(): Promise<GitHubProjectsAdapter> {
}
}

// ── Product registry (multi-product) ─────────────────────────────────────────

let _productRegistry: Product[] | null = null;
let _productRegistryPromise: Promise<Product[]> | null = null;

/**
* Returns all registered Products, initializing from the registry on first call.
* Single-flight: concurrent callers await the same promise.
*
* Discovery order:
* 1. If $HELM_KNOWLEDGE_REPO_PATH/.helm/products.yaml exists → load all products listed there.
* 2. Otherwise (ENOENT) → fall back to [getProductConfig()] for single-product backward compat.
*
* Paths in products.yaml are resolved relative to HELM_KNOWLEDGE_REPO_PATH itself
* (the knowledge repo root). Example: path "." → the repo itself; path
* "../helm-playground-knowledge" → a sibling repo. See ADR-004.
*/
export async function getProductRegistry(): Promise<Product[]> {
if (_productRegistry !== null) return _productRegistry.map((p) => structuredClone(p));
if (_productRegistryPromise !== null) {
return _productRegistryPromise.then((products) => products.map((p) => structuredClone(p)));
}

_productRegistryPromise = (async () => {
const knowledgePath = process.env.HELM_KNOWLEDGE_REPO_PATH?.trim();
if (!knowledgePath) throw new Error('HELM_KNOWLEDGE_REPO_PATH environment variable not set');
const SAFE_FS_PATH_REGEX = /^[A-Za-z0-9._/\-]+$/;
if (!SAFE_FS_PATH_REGEX.test(knowledgePath)) {
throw new Error('HELM_KNOWLEDGE_REPO_PATH contains invalid characters');
}

const registryFilePath = join(knowledgePath, '.helm', 'products.yaml');
const baseDir = knowledgePath; // paths in products.yaml are relative to the knowledge repo itself

let products: Product[];
try {
products = await loadProductRegistry(registryFilePath, baseDir);
} catch (err) {
// Fall back to single-product mode ONLY when the registry file itself is absent.
// ProductConfigError wraps the fs error: .code is preserved directly, but .path
// lives on .cause (the original NodeJS.ErrnoException). Checking .cause.path
// ensures we don't swallow ENOENT errors from a referenced product.yaml being
// missing — those must propagate so the operator can diagnose the broken entry.
const isMissingRegistryFile =
err instanceof ProductConfigError &&
err.code === 'ENOENT' &&
(err.cause as NodeJS.ErrnoException | undefined)?.path === registryFilePath;

if (isMissingRegistryFile) {
products = [await getProductConfig()];
} else {
throw err;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

_productRegistry = products.map((p) => structuredClone(p));
return _productRegistry.map((p) => structuredClone(p));
})();

try {
const products = await _productRegistryPromise;
return products.map((p) => structuredClone(p));
} finally {
_productRegistryPromise = null;
}
}

// ── Test utilities ────────────────────────────────────────────────────────────

/**
Expand All @@ -114,4 +181,6 @@ export function _resetForTests(): void {
_productInitPromise = null;
_githubAdapter = null;
_githubAdapterPromise = null;
_productRegistry = null;
_productRegistryPromise = null;
}
Loading