diff --git a/.github/reports/migration/reporting-agent-v2-migration-guide-2026-08-29.md b/.github/reports/migration/reporting-agent-v2-migration-guide-2026-08-29.md new file mode 100644 index 0000000000..0545fa7598 --- /dev/null +++ b/.github/reports/migration/reporting-agent-v2-migration-guide-2026-08-29.md @@ -0,0 +1,219 @@ +--- +file_type: "report" +title: "Reporting Agent v2 Migration Guide" +description: "How to migrate from Reporting Agent v1 to v2, covering multi-repo support, session caching, and pluggable storage." +category: "migration" +created_date: "2026-08-29" +last_updated: "2026-08-29" +author: "automation" +tags: ["reporting-agent", "v2", "migration", "multi-repo", "caching", "storage"] +--- + +# Reporting Agent v2 — Migration Guide + +## Overview + +Reporting Agent v2 (`scripts/agents/reporting.agent.js` v2.0.0) adds three major +capabilities on top of the original v1 API while preserving **full backwards +compatibility**: + +| Feature | v1 | v2 | +|---------|----|----| +| Single-repo report generation | ✅ | ✅ | +| Multi-repo aggregate reports | ❌ | ✅ | +| Session cache | ❌ | ✅ | +| Pluggable storage backend | ❌ | ✅ | +| `AGENT_VERSION` export | ❌ | ✅ | + +--- + +## Backwards Compatibility + +All v1 exports continue to work without modification: + +```js +import { + runAgent, + generateReport, + generateSpecFile, + generateFrontmatter, + determineCategory, + getReportPath, + sanitiseFilename, + saveReport, + validateReport, + archiveReport, + CATEGORIES, +} from "./scripts/agents/reporting.agent.js"; +``` + +The `runAgent` default action now returns a `version` field alongside the +existing `categories` and `message` fields. All existing `action` values +(`generate`, `spec`, `validate`, `archive`, `save`) behave identically. + +--- + +## New v2 Exports + +```js +import { + // multi-repo + generateMultiRepoReport, + parseRepoRef, + buildRepoCacheKey, + // session cache + cacheGet, + cacheSet, + cacheClear, + cacheSize, + // pluggable storage + setStorage, + resetStorage, + createMemoryStorage, + // version + AGENT_VERSION, +} from "./scripts/agents/reporting.agent.js"; +``` + +--- + +## Multi-Repository Reports + +Generate a single Markdown report that aggregates data across multiple +repositories: + +```js +import { generateMultiRepoReport } from "./scripts/agents/reporting.agent.js"; + +const report = generateMultiRepoReport({ + title: "Cross-Repo Coverage Summary", + description: "Coverage across all LightSpeed repositories", + category: "coverage", + repos: [ + "lightspeedwp/.github", + "lightspeedwp/lsx", + { owner: "lightspeedwp", repo: "lsx-blocks" }, + ], + metrics: [ + { metric: "Total tests", value: "1,240", status: "✅" }, + { metric: "Coverage", value: "87%", status: "✅" }, + ], + summary: "All repositories are above the 80% coverage threshold.", +}); +``` + +`repos` entries can be either `"owner/repo"` strings or +`{ owner, repo }` objects — both are normalised via `parseRepoRef()`. + +Via `runAgent`: + +```js +runAgent({ + action: "generate:multi-repo", + options: { + title: "Cross-Repo Summary", + description: "...", + category: "agents", + repos: ["lightspeedwp/.github", "lightspeedwp/lsx"], + }, +}); +``` + +--- + +## Session Cache + +The session cache lives in memory for the lifetime of the current Node process. +It is useful for avoiding repeated computation within a single agent run. + +```js +import { cacheSet, cacheGet, cacheClear, cacheSize } from "./scripts/agents/reporting.agent.js"; + +// Store a value (default TTL: 15 minutes) +cacheSet("coverage:lightspeedwp/.github", { pct: 87 }); + +// Retrieve it +const data = cacheGet("coverage:lightspeedwp/.github"); // { pct: 87 } + +// Custom TTL (milliseconds) +cacheSet("short-lived", "value", 30_000); // 30 s + +// Inspect size +console.log(cacheSize()); // 2 + +// Clear all +cacheClear(); +``` + +`buildRepoCacheKey(parsed, category)` produces a standardised key: + +```js +buildRepoCacheKey({ owner: "lightspeedwp", repo: ".github" }, "coverage"); +// → "repo:lightspeedwp/.github:coverage" +``` + +Via `runAgent`: + +```js +runAgent({ action: "cache:set", options: { key: "k", value: 42 } }); +runAgent({ action: "cache:get", options: { key: "k" } }); +// → { ok: true, value: 42, hit: true } +runAgent({ action: "cache:size", options: {} }); +runAgent({ action: "cache:clear", options: {} }); +``` + +--- + +## Pluggable Storage Backend + +Replace the default filesystem backend with an in-memory backend (e.g. for +testing) or any custom implementation: + +```js +import { + createMemoryStorage, + setStorage, + resetStorage, + saveReport, +} from "./scripts/agents/reporting.agent.js"; + +// Swap in memory storage +const mem = createMemoryStorage(); +setStorage(mem); + +saveReport(reportContent, "my-report.md", "agents"); + +// Inspect stored files +console.log([...mem.store.keys()]); + +// Restore filesystem backend +resetStorage(); +``` + +A custom storage backend must implement: + +```ts +interface StorageBackend { + write(filePath: string, content: string): void; + exists(filePath: string): boolean; + mkdirp(dir: string): void; +} +``` + +--- + +## Checklist for Consumers + +- [x] No changes needed for existing v1 callers. +- [x] Adopt `generateMultiRepoReport` for cross-repo aggregation. +- [x] Use `cacheSet` / `cacheGet` to memoize expensive computations within a run. +- [x] Inject `createMemoryStorage()` in unit tests to avoid touching the filesystem. +- [x] Use `AGENT_VERSION` for audit logs and report frontmatter. + +--- + +## Related + +- `agents/reporting.agent.md` — Agent specification +- `.github/projects/active/reporting-agent-v2-multirepository-2026-08-12/PLANNING.md` — Phase 2 plan +- `scripts/agents/__tests__/reporting.agent.test.js` — Regression test suite (49 tests) diff --git a/.gitignore b/.gitignore index 8f1ee0d77e..f1545a8515 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,7 @@ tmp/ *.zip *.tar.gz *.tgz +node_modules/ .claude/settings.local.json # Prevent local Claude Code settings from being committed diff --git a/scripts/agents/__tests__/reporting.agent.test.js b/scripts/agents/__tests__/reporting.agent.test.js index 343bf6a4ce..60589adb23 100644 --- a/scripts/agents/__tests__/reporting.agent.test.js +++ b/scripts/agents/__tests__/reporting.agent.test.js @@ -1,13 +1,465 @@ /** - * Jest suite verifying the baseline behaviour of `reporting.agent.js`. + * Jest suite verifying the baseline behaviour of `reporting.agent.js` v2. + * + * Covers: + * - All original v1 exports (backwards compatibility) + * - v2 session cache helpers + * - v2 multi-repo report generation + * - v2 pluggable storage backend + * - runAgent action dispatcher + * * @see ../reporting.agent.js */ const fs = require("fs"); const path = require("path"); -describe("reporting.agent", () => { - it("agent module file exists", () => { +// ── helpers ────────────────────────────────────────────────────────────────── + +let agent; + +beforeAll(async () => { + agent = await import("../reporting.agent.js"); +}); + +// ── 1. Module file exists ──────────────────────────────────────────────────── + +describe("module", () => { + it("agent module file exists on disk", () => { const agentPath = path.join(__dirname, "../reporting.agent.js"); expect(fs.existsSync(agentPath)).toBe(true); }); + + it("exports AGENT_VERSION string", () => { + expect(typeof agent.AGENT_VERSION).toBe("string"); + expect(agent.AGENT_VERSION).toMatch(/^\d+\.\d+\.\d+$/); + }); +}); + +// ── 2. CATEGORIES (v1 backwards compat) ───────────────────────────────────── + +describe("CATEGORIES", () => { + it("exports CATEGORIES object", () => { + expect(typeof agent.CATEGORIES).toBe("object"); + }); + + it("contains expected v1 categories", () => { + const keys = Object.keys(agent.CATEGORIES); + expect(keys).toContain("agents"); + expect(keys).toContain("linting"); + expect(keys).toContain("labeling"); + expect(keys).toContain("coverage"); + expect(keys).toContain("meta"); + expect(keys).toContain("issue-metrics"); + }); +}); + +// ── 3. sanitiseFilename (v1) ───────────────────────────────────────────────── + +describe("sanitiseFilename", () => { + it("lowercases and replaces spaces with hyphens", () => { + expect(agent.sanitiseFilename("My Report")).toBe("my-report"); + }); + + it("strips illegal characters", () => { + expect(agent.sanitiseFilename("report<>?.md")).toBe("report.md"); + }); + + it("collapses multiple hyphens", () => { + expect(agent.sanitiseFilename("a--b")).toBe("a-b"); + }); +}); + +// ── 4. determineCategory (v1) ─────────────────────────────────────────────── + +describe("determineCategory", () => { + it("returns 'linting' for lint content", () => { + expect(agent.determineCategory("ESLint report")).toBe("linting"); + }); + + it("returns 'coverage' for coverage content", () => { + expect(agent.determineCategory("Test coverage summary")).toBe("coverage"); + }); + + it("returns 'labeling' for labeling content", () => { + expect(agent.determineCategory("Label automation run")).toBe("labeling"); + }); + + it("returns default 'agents' for unknown content", () => { + expect(agent.determineCategory("random stuff")).toBe("agents"); + }); +}); + +// ── 5. getReportPath (v1) ─────────────────────────────────────────────────── + +describe("getReportPath", () => { + it("returns a string path", () => { + const p = agent.getReportPath("coverage", "report.md"); + expect(typeof p).toBe("string"); + }); + + it("path ends with the given filename", () => { + const p = agent.getReportPath("linting", "eslint.md"); + expect(p.endsWith("eslint.md")).toBe(true); + }); +}); + +// ── 6. generateFrontmatter (v1) ───────────────────────────────────────────── + +describe("generateFrontmatter", () => { + it("returns a string starting with ---", () => { + const fm = agent.generateFrontmatter({ + title: "Test", + description: "Desc", + category: "agents", + }); + expect(fm.startsWith("---")).toBe(true); + }); + + it("includes title, description, category fields", () => { + const fm = agent.generateFrontmatter({ + title: "My Title", + description: "My Desc", + category: "coverage", + }); + expect(fm).toContain('title: "My Title"'); + expect(fm).toContain('description: "My Desc"'); + expect(fm).toContain('category: "coverage"'); + }); + + it("defaults author to automation", () => { + const fm = agent.generateFrontmatter({ + title: "T", + description: "D", + category: "meta", + }); + expect(fm).toContain('author: "automation"'); + }); +}); + +// ── 7. generateReport (v1) ────────────────────────────────────────────────── + +describe("generateReport", () => { + const base = { + title: "Test Report", + description: "A test", + category: "agents", + summary: "All good", + }; + + it("returns a string containing the title", () => { + const r = agent.generateReport(base); + expect(r).toContain("# Test Report"); + }); + + it("includes metrics table when metrics provided", () => { + const r = agent.generateReport({ + ...base, + metrics: [{ metric: "Tests", value: "100", status: "✅" }], + }); + expect(r).toContain("## Key Metrics"); + expect(r).toContain("Tests"); + }); + + it("includes recommendations section when provided", () => { + const r = agent.generateReport({ + ...base, + recommendations: ["Fix all the things"], + }); + expect(r).toContain("## Recommendations"); + expect(r).toContain("Fix all the things"); + }); +}); + +// ── 8. validateReport (v1) ────────────────────────────────────────────────── + +describe("validateReport", () => { + it("returns valid=false for content without frontmatter", () => { + const result = agent.validateReport("# No frontmatter"); + expect(result.valid).toBe(false); + expect(result.errors).toContain("Missing YAML frontmatter"); + }); + + it("returns valid=true for a well-formed report", () => { + const content = `--- +file_type: "report" +title: "T" +description: "D" +category: "agents" +created_date: "2026-08-29" +last_updated: "2026-08-29" +--- + +# T +`; + const result = agent.validateReport(content); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("warns about missing last_updated", () => { + const content = `--- +file_type: "report" +title: "T" +description: "D" +category: "agents" +created_date: "2026-08-29" +--- + +# T +`; + const result = agent.validateReport(content); + expect(result.warnings).toContain("Missing last_updated field"); + }); +}); + +// ── 9. Session cache (v2) ─────────────────────────────────────────────────── + +describe("session cache", () => { + beforeEach(() => { + agent.cacheClear(); + }); + + it("cacheSet and cacheGet round-trips a value", () => { + agent.cacheSet("my-key", { foo: "bar" }); + expect(agent.cacheGet("my-key")).toEqual({ foo: "bar" }); + }); + + it("cacheGet returns undefined for missing keys", () => { + expect(agent.cacheGet("nonexistent")).toBeUndefined(); + }); + + it("cacheSize reflects live entries", () => { + agent.cacheSet("k1", 1); + agent.cacheSet("k2", 2); + expect(agent.cacheSize()).toBe(2); + }); + + it("cacheClear empties the cache", () => { + agent.cacheSet("x", 42); + agent.cacheClear(); + expect(agent.cacheSize()).toBe(0); + }); + + it("expired entries are not returned", () => { + agent.cacheSet("exp-key", "value", 1); // 1 ms TTL + return new Promise((resolve) => + setTimeout(() => { + expect(agent.cacheGet("exp-key")).toBeUndefined(); + resolve(); + }, 10), + ); + }); +}); + +// ── 10. parseRepoRef (v2) ─────────────────────────────────────────────────── + +describe("parseRepoRef", () => { + it("parses 'owner/repo' string", () => { + expect(agent.parseRepoRef("lightspeedwp/.github")).toEqual({ + owner: "lightspeedwp", + repo: ".github", + }); + }); + + it("accepts { owner, repo } object", () => { + expect(agent.parseRepoRef({ owner: "acme", repo: "widget" })).toEqual({ + owner: "acme", + repo: "widget", + }); + }); + + it("throws on malformed string", () => { + expect(() => agent.parseRepoRef("no-slash")).toThrow(); + }); + + it("throws on three-segment string", () => { + expect(() => agent.parseRepoRef("owner/org/repo")).toThrow(); + }); + + it("throws on object missing owner", () => { + expect(() => agent.parseRepoRef({ repo: "widget" })).toThrow(); + }); +}); + +// ── 11. buildRepoCacheKey (v2) ────────────────────────────────────────────── + +describe("buildRepoCacheKey", () => { + it("returns a predictable key", () => { + const key = agent.buildRepoCacheKey( + { owner: "acme", repo: "test" }, + "coverage", + ); + expect(key).toBe("repo:acme/test:coverage"); + }); +}); + +// ── 12. generateMultiRepoReport (v2) ──────────────────────────────────────── + +describe("generateMultiRepoReport", () => { + const base = { + title: "Multi-Repo Report", + description: "Cross-repo summary", + category: "agents", + repos: ["lightspeedwp/.github", "lightspeedwp/lsx"], + }; + + it("returns a string containing the title", () => { + const r = agent.generateMultiRepoReport(base); + expect(r).toContain("# Multi-Repo Report"); + }); + + it("lists all repositories in the output", () => { + const r = agent.generateMultiRepoReport(base); + expect(r).toContain("lightspeedwp/.github"); + expect(r).toContain("lightspeedwp/lsx"); + }); + + it("shows repository count", () => { + const r = agent.generateMultiRepoReport(base); + expect(r).toContain("## Repositories (2)"); + }); + + it("includes aggregate metrics when provided", () => { + const r = agent.generateMultiRepoReport({ + ...base, + metrics: [{ metric: "PRs", value: "12", status: "✅" }], + }); + expect(r).toContain("## Aggregate Metrics"); + expect(r).toContain("PRs"); + }); + + it("adds multi-repo tag to frontmatter", () => { + const r = agent.generateMultiRepoReport(base); + expect(r).toContain("multi-repo"); + }); + + it("throws when repos array is empty", () => { + expect(() => + agent.generateMultiRepoReport({ ...base, repos: [] }), + ).toThrow(); + }); +}); + +// ── 13. Pluggable storage (v2) ────────────────────────────────────────────── + +describe("pluggable storage", () => { + afterEach(() => { + agent.resetStorage(); + }); + + it("createMemoryStorage returns a backend with write/exists/mkdirp", () => { + const mem = agent.createMemoryStorage(); + expect(typeof mem.write).toBe("function"); + expect(typeof mem.exists).toBe("function"); + expect(typeof mem.mkdirp).toBe("function"); + }); + + it("memory storage write and exists round-trip", () => { + const mem = agent.createMemoryStorage(); + mem.write("/tmp/report.md", "hello"); + expect(mem.exists("/tmp/report.md")).toBe(true); + }); + + it("setStorage + saveReport uses the injected backend", () => { + const mem = agent.createMemoryStorage(); + agent.setStorage(mem); + + const content = `--- +file_type: "report" +title: "T" +description: "D" +category: "agents" +created_date: "2026-08-29" +last_updated: "2026-08-29" +--- +# T`; + const result = agent.saveReport(content, "test-report.md", "agents"); + expect(result.success).toBe(true); + expect(mem.store.size).toBeGreaterThan(0); + }); + + it("setStorage throws for invalid backend", () => { + expect(() => agent.setStorage({})).toThrow(); + }); +}); + +// ── 14. runAgent dispatcher ───────────────────────────────────────────────── + +describe("runAgent", () => { + it("default action returns version and categories", () => { + const result = agent.runAgent({}); + expect(result.ok).toBe(true); + expect(result.version).toBe(agent.AGENT_VERSION); + expect(Array.isArray(result.categories)).toBe(true); + }); + + it("generate action returns a report string", () => { + const result = agent.runAgent({ + action: "generate", + options: { + title: "CI Report", + description: "desc", + category: "agents", + summary: "All good", + }, + }); + expect(result.ok).toBe(true); + expect(typeof result.report).toBe("string"); + }); + + it("generate:multi-repo action returns repos array", () => { + const result = agent.runAgent({ + action: "generate:multi-repo", + options: { + title: "Multi", + description: "desc", + category: "agents", + repos: ["acme/one", "acme/two"], + }, + }); + expect(result.ok).toBe(true); + expect(result.repos).toHaveLength(2); + }); + + it("validate action returns validation object", () => { + const result = agent.runAgent({ + action: "validate", + options: { content: "# No frontmatter" }, + }); + expect(result.ok).toBe(true); + expect(result.validation.valid).toBe(false); + }); + + it("cache:set and cache:get round-trip via runAgent", () => { + agent.runAgent({ + action: "cache:set", + options: { key: "rk", value: 99 }, + }); + const result = agent.runAgent({ + action: "cache:get", + options: { key: "rk" }, + }); + expect(result.ok).toBe(true); + expect(result.value).toBe(99); + expect(result.hit).toBe(true); + }); + + it("cache:clear clears the cache", () => { + agent.runAgent({ action: "cache:set", options: { key: "z", value: 1 } }); + agent.runAgent({ action: "cache:clear", options: {} }); + const result = agent.runAgent({ + action: "cache:get", + options: { key: "z" }, + }); + expect(result.hit).toBe(false); + }); + + it("cache:size returns numeric size", () => { + agent.runAgent({ action: "cache:clear", options: {} }); + agent.runAgent({ action: "cache:set", options: { key: "s1", value: 1 } }); + const result = agent.runAgent({ action: "cache:size", options: {} }); + expect(result.ok).toBe(true); + expect(typeof result.size).toBe("number"); + expect(result.size).toBeGreaterThanOrEqual(1); + }); }); diff --git a/scripts/agents/reporting.agent.js b/scripts/agents/reporting.agent.js index fa7f5af753..5332b80b63 100644 --- a/scripts/agents/reporting.agent.js +++ b/scripts/agents/reporting.agent.js @@ -1,11 +1,12 @@ /** * reporting.agent.js * - * Reporting Agent implementation for LightSpeed. + * Reporting Agent v2 implementation for LightSpeed. * Automates report creation, organisation, and maintenance. + * v2 adds multi-repository support, session caching, and flexible storage. * * @file reporting.agent.js - * @version 1.0.0 + * @version 2.0.0 * @author LightSpeed Team * @license GPL-3.0 * @module scripts/agents/reporting.agent.js @@ -14,10 +15,9 @@ import fs from "fs"; import path from "path"; -import { fileURLToPath } from "url"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +/** @type {string} Current semantic version of the Reporting Agent */ +const AGENT_VERSION = "2.0.0"; /** * Report categories and their paths @@ -32,6 +32,268 @@ const CATEGORIES = { "issue-metrics": ".github/reports/issue-metrics", }; +// --------------------------------------------------------------------------- +// v2: Session cache +// --------------------------------------------------------------------------- + +/** + * In-memory session cache. + * Stores keyed report data within a single process lifetime. + * + * @type {Map} + */ +const _sessionCache = new Map(); + +/** Default cache TTL: 15 minutes */ +const DEFAULT_CACHE_TTL_MS = 15 * 60 * 1000; + +/** + * Store a value in the session cache. + * + * @param {string} key - Cache key + * @param {*} data - Value to cache + * @param {number} [ttlMs=DEFAULT_CACHE_TTL_MS] - Time-to-live in milliseconds + */ +function cacheSet(key, data, ttlMs = DEFAULT_CACHE_TTL_MS) { + _sessionCache.set(key, { data, cachedAt: new Date(), ttlMs }); +} + +/** + * Retrieve a value from the session cache, returning undefined if missing or + * expired. + * + * @param {string} key - Cache key + * @returns {*} Cached value or undefined + */ +function cacheGet(key) { + const entry = _sessionCache.get(key); + if (!entry) return undefined; + const age = Date.now() - entry.cachedAt.getTime(); + if (age > entry.ttlMs) { + _sessionCache.delete(key); + return undefined; + } + return entry.data; +} + +/** + * Clear all entries from the session cache. + */ +function cacheClear() { + _sessionCache.clear(); +} + +/** + * Return the number of live (non-expired) entries in the session cache. + * + * @returns {number} Live entry count + */ +function cacheSize() { + let count = 0; + for (const [key, entry] of _sessionCache.entries()) { + if (Date.now() - entry.cachedAt.getTime() <= entry.ttlMs) { + count++; + } else { + _sessionCache.delete(key); + } + } + return count; +} + +// --------------------------------------------------------------------------- +// v2: Multi-repository helpers +// --------------------------------------------------------------------------- + +/** + * Normalise a repository identifier to `{ owner, repo }`. + * + * Accepts `"owner/repo"` strings or plain `{ owner, repo }` objects. + * + * @param {string|{owner: string, repo: string}} repoRef - Repository reference + * @returns {{ owner: string, repo: string }} + */ +function parseRepoRef(repoRef) { + if (typeof repoRef === "string") { + const parts = repoRef.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error( + `Invalid repository reference "${repoRef}". Expected "owner/repo".`, + ); + } + const [owner, repo] = parts; + return { owner: owner.trim(), repo: repo.trim() }; + } + if (repoRef && typeof repoRef === "object") { + const { owner, repo } = repoRef; + if (!owner || !repo) { + throw new Error( + 'Repository reference object must have "owner" and "repo" properties.', + ); + } + return { owner: String(owner).trim(), repo: String(repo).trim() }; + } + throw new Error("Repository reference must be a string or object."); +} + +/** + * Build a canonical cache key for a per-repository report. + * + * @param {{ owner: string, repo: string }} parsed - Parsed repo ref + * @param {string} category - Report category + * @returns {string} + */ +function buildRepoCacheKey(parsed, category) { + return `repo:${parsed.owner}/${parsed.repo}:${category}`; +} + +/** + * Generate a multi-repository summary report that aggregates data from + * multiple repositories into a single Markdown document. + * + * @param {object} options - Report options + * @param {string} options.title - Report title + * @param {string} options.description - Description + * @param {string} options.category - Report category + * @param {Array} options.repos - Repository list + * @param {Array<{metric: string, value: string, status: string}>} [options.metrics] - Aggregate metrics + * @param {string} [options.summary] - Executive summary + * @param {string} [options.author] - Report author + * @param {string[]} [options.tags] - Tags + * @returns {string} Multi-repo Markdown report + */ +function generateMultiRepoReport(options) { + const { + title, + description, + category, + repos = [], + metrics = [], + summary = "", + author, + tags = [], + } = options; + + if (!Array.isArray(repos) || repos.length === 0) { + throw new Error("generateMultiRepoReport requires at least one repository."); + } + + const parsed = repos.map(parseRepoRef); + + const repoList = parsed + .map((r) => `| \`${r.owner}/${r.repo}\` | — |`) + .join("\n"); + + const metricsTable = + metrics.length > 0 + ? `## Aggregate Metrics\n\n| Metric | Value | Status |\n|--------|-------|--------|\n${metrics.map((m) => `| ${m.metric} | ${m.value} | ${m.status} |`).join("\n")}` + : ""; + + const frontmatter = generateFrontmatter({ + title, + description, + category, + author, + tags: ["multi-repo", ...tags], + }); + + return `${frontmatter} + +# ${title} + +## Repositories (${parsed.length}) + +| Repository | Notes | +|------------|-------| +${repoList} + +## Summary + +${summary || "Multi-repository report generated by Reporting Agent v2."} + +${metricsTable} +`.trim(); +} + +// --------------------------------------------------------------------------- +// v2: Pluggable storage backend +// --------------------------------------------------------------------------- + +/** + * @typedef {object} StorageBackend + * @property {function(string, string): void} write - Write content to path + * @property {function(string): boolean} exists - Check path existence + * @property {function(string): void} mkdirp - Ensure directory exists + */ + +/** + * Default filesystem storage backend (production use). + * + * @type {StorageBackend} + */ +const fsStorage = { + write(filePath, content) { + fs.writeFileSync(filePath, content, "utf-8"); + }, + exists(filePath) { + return fs.existsSync(filePath); + }, + mkdirp(dir) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + }, +}; + +/** + * Create an in-memory storage backend (useful for testing). + * + * @returns {StorageBackend & {store: Map}} + */ +function createMemoryStorage() { + const store = new Map(); + return { + store, + write(filePath, content) { + store.set(filePath, content); + }, + exists(filePath) { + return store.has(filePath); + }, + mkdirp() { + /* no-op for in-memory */ + }, + }; +} + +/** Active storage backend (defaults to filesystem, replaceable in tests) */ +let _storage = fsStorage; + +/** + * Replace the active storage backend. + * + * @param {StorageBackend} backend - New storage backend + */ +function setStorage(backend) { + if ( + !backend || + typeof backend.write !== "function" || + typeof backend.exists !== "function" || + typeof backend.mkdirp !== "function" + ) { + throw new Error( + "Storage backend must implement { write, exists, mkdirp }.", + ); + } + _storage = backend; +} + +/** + * Reset storage backend to the default filesystem implementation. + */ +function resetStorage() { + _storage = fsStorage; +} + /** * Generate frontmatter for a report * @@ -321,14 +583,11 @@ function saveReport(content, filename, category) { const sanitisedFilename = sanitiseFilename(filename); const reportPath = getReportPath(category, sanitisedFilename); - // Ensure directory exists + // Ensure directory exists via pluggable storage backend const dir = path.dirname(reportPath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - try { - fs.writeFileSync(reportPath, content, "utf-8"); + _storage.mkdirp(dir); + _storage.write(reportPath, content); return { success: true, path: reportPath }; } catch (error) { return { success: false, error: error.message }; @@ -436,6 +695,14 @@ function runAgent(context = {}) { category: options.category || determineCategory(options.title || ""), }; + case "generate:multi-repo": + return { + ok: true, + report: generateMultiRepoReport(options), + category: options.category || "agents", + repos: (options.repos || []).map(parseRepoRef), + }; + case "spec": return { ok: true, @@ -454,12 +721,33 @@ function runAgent(context = {}) { case "save": return saveReport(options.content, options.filename, options.category); + case "cache:get": { + const cached = cacheGet(options.key); + return { + ok: true, + value: cached, + hit: cached !== undefined, + }; + } + + case "cache:set": + cacheSet(options.key, options.value, options.ttlMs); + return { ok: true }; + + case "cache:clear": + cacheClear(); + return { ok: true }; + + case "cache:size": + return { ok: true, size: cacheSize() }; + default: return { ok: true, timestamp: new Date().toISOString(), + version: AGENT_VERSION, categories: Object.keys(CATEGORIES), - message: "Reporting Agent ready", + message: "Reporting Agent v2 ready", }; } } @@ -467,6 +755,7 @@ function runAgent(context = {}) { export { runAgent, generateReport, + generateMultiRepoReport, generateSpecFile, generateFrontmatter, determineCategory, @@ -475,7 +764,17 @@ export { saveReport, validateReport, archiveReport, + parseRepoRef, + buildRepoCacheKey, + cacheGet, + cacheSet, + cacheClear, + cacheSize, + setStorage, + resetStorage, + createMemoryStorage, CATEGORIES, + AGENT_VERSION, }; export default { runAgent }; diff --git a/scripts/automation/__tests__/integration-workflow-pr-triage.test.js b/scripts/automation/__tests__/integration-workflow-pr-triage.test.js index 2e68700e78..b3e43b90d9 100644 --- a/scripts/automation/__tests__/integration-workflow-pr-triage.test.js +++ b/scripts/automation/__tests__/integration-workflow-pr-triage.test.js @@ -8,7 +8,7 @@ import { triageAndExtractIssues, syncLabelsBasedOnIssues, generateTriageSummary, -} from "../lib/integration-workflow-helpers.js"; +} from "../includes/integration-workflow-helpers.js"; describe("integration: pr triage workflow", () => { describe("triage to label sync workflow", () => { diff --git a/scripts/automation/__tests__/pr-triage-orchestrator.test.js b/scripts/automation/__tests__/pr-triage-orchestrator.test.js index 904b55d5bd..a00c222118 100644 --- a/scripts/automation/__tests__/pr-triage-orchestrator.test.js +++ b/scripts/automation/__tests__/pr-triage-orchestrator.test.js @@ -9,7 +9,7 @@ import { determineTriage, buildMetadata, generateSummary, -} from "../lib/pr-triage-helpers.js"; +} from "../includes/pr-triage-helpers.js"; describe("pr-triage-orchestrator", () => { describe("parseConfig", () => {