[object Object]

← back to build-pages

api: /api/search?q= — cross-project commit search (min 2 chars, limit 1-200) + 4 smoke

eb2ab403954a2f165f42dc8faf399111817dfd4f · 2026-05-13 13:41:43 -0700 · SteveStudio2

Files touched

Diff

commit eb2ab403954a2f165f42dc8faf399111817dfd4f
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 13 13:41:43 2026 -0700

    api: /api/search?q= — cross-project commit search (min 2 chars, limit 1-200) + 4 smoke
---
 server.js     | 29 +++++++++++++++++++++++++++++
 test/smoke.js |  8 ++++++++
 2 files changed, 37 insertions(+)

diff --git a/server.js b/server.js
index 2e39f87..d436ffa 100644
--- a/server.js
+++ b/server.js
@@ -434,6 +434,35 @@ app.get('/p/:slug/c/:hash', async (req, res, next) => {
   } catch (e) { next(e); }
 });
 
+// Cross-project search — case-insensitive substring match on commit
+// subject + body across every registered project. Used to power a "search
+// the whole fleet" experience on the home page. ?q= required (min 2 chars),
+// ?limit= clamps results (default 50, max 200).
+app.get('/api/search', async (req, res, next) => {
+  const q = (typeof req.query.q === 'string' ? req.query.q : '').trim().toLowerCase();
+  const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 200);
+  if (q.length < 2) return res.json({ q, results: [], note: 'min 2 chars' });
+  try {
+    const hits = [];
+    for (const [slug, p] of Object.entries(PROJECTS)) {
+      const bundle = await getProjectBundle(slug, p).catch(() => null);
+      if (!bundle) continue;
+      for (const c of bundle.commits) {
+        const blob = (c.subject + ' ' + c.body).toLowerCase();
+        if (blob.includes(q)) {
+          hits.push({
+            slug, project: p.name, hash: c.shortHash, date: c.dateISO.slice(0, 10), subject: c.subject,
+          });
+          if (hits.length >= limit) break;
+        }
+      }
+      if (hits.length >= limit) break;
+    }
+    res.setHeader('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
+    res.json({ q, total: hits.length, limit, results: hits });
+  } catch (e) { next(e); }
+});
+
 // Raw commits JSON — paginated. Supports ?limit=N (default 100, max 500) and
 // ?offset=N. The bundle has the full list cached; this just slices it.
 app.get('/api/projects/:slug/commits', async (req, res, next) => {
diff --git a/test/smoke.js b/test/smoke.js
index 59f7ceb..897bb83 100644
--- a/test/smoke.js
+++ b/test/smoke.js
@@ -146,6 +146,14 @@ function check(name, cond, hint = '') {
   check('404: branded HTML',       /build-pages/.test(r.body));
   check('404: back-link',          /back to all projects/.test(r.body));
 
+  // 14. cross-project search
+  r = await fetch('/api/search?q=whisper&limit=10');
+  check('search: 200',             r.status === 200);
+  check('search: results array',   /"results":\[/.test(r.body));
+  check('search: total field',     /"total":\d+/.test(r.body));
+  r = await fetch('/api/search?q=a');
+  check('search: <2 chars → empty',/"results":\[\]/.test(r.body));
+
   // Report
   if (process.argv.includes('--json')) {
     console.log(JSON.stringify({ base: BASE, pass, fail, total: pass + fail, checks, as_of: new Date().toISOString() }));

← 0158ff5 404: branded page on unknown routes (fleet parity) + 3 smoke  ·  back to build-pages  ·  ui: fleet-wide search input on home (wired to /api/search, 1 9494934 →