[object Object]

← back to build-pages

csv: /p/:slug/commits.csv spreadsheet export (RFC-4180 quoting) + 4 smoke

fedcbface5cc4a05f7459bf1f0e733ab2d83ba08 · 2026-05-13 14:44:53 -0700 · SteveStudio2

Files touched

Diff

commit fedcbface5cc4a05f7459bf1f0e733ab2d83ba08
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 13 14:44:53 2026 -0700

    csv: /p/:slug/commits.csv spreadsheet export (RFC-4180 quoting) + 4 smoke
---
 server.js     | 20 ++++++++++++++++++++
 test/smoke.js |  7 +++++++
 2 files changed, 27 insertions(+)

diff --git a/server.js b/server.js
index e496ba4..c9d2065 100644
--- a/server.js
+++ b/server.js
@@ -648,6 +648,25 @@ app.get('/api/projects/:slug/commits', async (req, res, next) => {
   } catch (e) { next(e); }
 });
 
+// CSV export — every commit in spreadsheet form. Useful for the "drop this
+// into Numbers and slice by date / author" workflow. Header row + RFC-4180
+// quoting (double-quote inside double-quoted field).
+app.get('/p/:slug/commits.csv', async (req, res, next) => {
+  const p = PROJECTS[req.params.slug];
+  if (!p) return res.status(404).type('text/plain').send('no-such-project\n');
+  try {
+    const { commits } = await getProjectBundle(req.params.slug, p);
+    const q = (s) => `"${String(s ?? '').replace(/"/g, '""')}"`;
+    const rows = ['hash,date,author,subject,body_len'];
+    for (const c of commits) {
+      rows.push([q(c.shortHash), q(c.dateISO), q(c.author), q(c.subject), c.body.length].join(','));
+    }
+    res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=120');
+    res.setHeader('Content-Disposition', `attachment; filename="${req.params.slug}-commits.csv"`);
+    res.type('text/csv').send(rows.join('\n') + '\n');
+  } catch (e) { next(e); }
+});
+
 // Atom feed per project — recent N commits in standards-compliant Atom 1.0.
 // Lets external tools subscribe to a project's build journal (e.g. Feedly,
 // NetNewsWire). Pulls from the same bundle cache as the HTML page.
@@ -725,6 +744,7 @@ app.get('/api', (_req, res) => {
       'GET /p/:slug':                          'Project journal HTML page (?q= deep-link supported)',
       'GET /p/:slug/c/:hash':                  'Commit detail page — full diff + body',
       'GET /p/:slug/feed.atom':                'Atom 1.0 feed — up to 50 recent commits, autodiscovered on project page',
+      'GET /p/:slug/commits.csv':              'Spreadsheet export — every commit as CSV (hash, date, author, subject, body_len)',
       'GET /p/:slug/file?path=<path>':         'View any tracked file at HEAD',
       'GET /sitemap.xml':                      'XML sitemap — home + every /p/:slug',
       'GET /robots.txt':                       'Crawler policy (allow all, sitemap advertised)',
diff --git a/test/smoke.js b/test/smoke.js
index 7a598b8..9ecd240 100644
--- a/test/smoke.js
+++ b/test/smoke.js
@@ -167,6 +167,13 @@ function check(name, cond, hint = '') {
   r = await fetch('/p/butlr');
   check('butlr: feed autodiscover',/rel="alternate" type="application\/atom\+xml"/.test(r.body));
 
+  // 11b. CSV export
+  r = await fetch('/p/butlr/commits.csv');
+  check('csv: 200',                r.status === 200);
+  check('csv: text/csv',           /text\/csv/.test(r.headers['content-type'] || ''));
+  check('csv: header row',         /^hash,date,author,subject,body_len/.test(r.body));
+  check('csv: attachment',         /attachment;.*butlr-commits/.test(r.headers['content-disposition'] || ''));
+
   // 12. /api/projects/:slug/commits — paginated raw JSON
   r = await fetch('/api/projects/butlr/commits?limit=3');
   check('commits api: 200',        r.status === 200);

← 0cb94e6 api: per-project facet endpoints (/agents, /skills, /ideas)  ·  back to build-pages  ·  csv: /fleet.csv — every commit across every project in one f f2d7363 →