diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index f12c7f25f5..3a6d53f181 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -12136,9 +12136,49 @@ }, "/v1/app/analytics/weekly-value-report": { "get": { + "parameters": [ + { + "schema": { + "type": "string", + "enum": [ + "public", + "operator" + ], + "example": "public" + }, + "required": false, + "description": "Report variant. Operator reports require the operator app role.", + "name": "variant", + "in": "query" + }, + { + "schema": { + "type": "string", + "example": "7" + }, + "required": false, + "description": "Report window in days, clamped from 1 to 31.", + "name": "days", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "json", + "markdown" + ], + "example": "markdown" + }, + "required": false, + "description": "Response format. Omit or use json for the structured report; use markdown for copy-ready text.", + "name": "format", + "in": "query" + } + ], "responses": { "200": { - "description": "Live app API response", + "description": "Weekly value report as structured JSON or copy-ready Markdown", "content": { "application/json": { "schema": { @@ -12147,11 +12187,20 @@ "nullable": true } } + }, + "text/markdown": { + "schema": { + "type": "string", + "example": "# Weekly Gittensory value report\n\n## Adoption metrics\n- Active users: 4\n" + } } } }, "401": { "description": "Unauthorized" + }, + "403": { + "description": "Insufficient app role for requested report variant" } }, "security": [ diff --git a/apps/gittensory-ui/src/routes/app.operator.tsx b/apps/gittensory-ui/src/routes/app.operator.tsx index abe31018ab..514e9a0362 100644 --- a/apps/gittensory-ui/src/routes/app.operator.tsx +++ b/apps/gittensory-ui/src/routes/app.operator.tsx @@ -1,4 +1,7 @@ +import { useState } from "react"; import { createFileRoute } from "@tanstack/react-router"; +import { Check, Copy, FileJson } from "lucide-react"; +import { toast } from "sonner"; import { BoundaryBadge, @@ -8,6 +11,8 @@ import { } from "@/components/site/control-primitives"; import { NotificationReadinessCard } from "@/components/site/notification-readiness-card"; import { StateBoundary } from "@/components/site/state-views"; +import { getApiOrigin } from "@/lib/api/origin"; +import { apiFetch } from "@/lib/api/request"; import { useApiResource } from "@/lib/api/use-api-resource"; export const Route = createFileRoute("/app/operator")({ @@ -26,12 +31,40 @@ type OperatorDashboardResponse = { upstreamDrift?: { status?: string } | null; }; +type ReportExportFormat = "markdown" | "json"; + function OperatorDashboard() { const dashboard = useApiResource( "/v1/app/operator-dashboard", "Operator dashboard", ); + const [copiedExport, setCopiedExport] = useState(null); const data = dashboard.status === "ready" ? dashboard.data : null; + const copyWeeklyReport = async (format: ReportExportFormat) => { + if (!data?.weeklyValueReport) return; + try { + const text = + format === "json" + ? JSON.stringify(data.weeklyValueReport, null, 2) + : await loadWeeklyReportMarkdown(); + if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) { + throw new Error("Clipboard API unavailable"); + } + await navigator.clipboard.writeText(text); + setCopiedExport(format); + toast.success("Weekly report copied", { + description: `${format === "json" ? "JSON" : "Markdown"} export copied.`, + }); + window.setTimeout(() => setCopiedExport(null), 1400); + } catch (error) { + toast.error("Copy failed", { + description: + error instanceof Error && error.message + ? `${error.message}. Select the report text and copy manually.` + : "Select the report text and copy manually.", + }); + } + }; return (
-

Weekly value report

-

- Rollup-backed summary across usage, maintenance, and drift signals. -

+
+
+

Weekly value report

+

+ Rollup-backed summary across usage, maintenance, and drift signals. +

+
+ {data.weeklyValueReport ? ( +
+ + +
+ ) : null} +
{data.weeklyValueReport ? (
); } + +async function loadWeeklyReportMarkdown(): Promise { + const result = await apiFetch( + `${getApiOrigin().replace(/\/$/, "")}/v1/app/analytics/weekly-value-report?variant=operator&format=markdown`, + { + label: "Weekly report export", + credentials: "include", + headers: { Accept: "text/markdown" }, + parse: (res) => res.text(), + }, + ); + if (!result.ok) throw new Error(result.message); + return result.data; +} diff --git a/src/api/routes.ts b/src/api/routes.ts index ff43c882a8..9c4b6694ad 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -135,7 +135,12 @@ import { LATEST_RECOMMENDED_MCP_VERSION, MINIMUM_SUPPORTED_MCP_VERSION, } from "../services/mcp-compatibility"; -import { buildWeeklyValueReport, generateWeeklyValueReport, loadWeeklyValueReport } from "../services/weekly-value-report"; +import { + buildWeeklyValueReport, + formatWeeklyValueReportMarkdown, + generateWeeklyValueReport, + loadWeeklyValueReport, +} from "../services/weekly-value-report"; import { loadOrComputeIssueQualityResponse } from "../services/issue-quality"; import { loadOrComputeBurdenForecastResponse } from "../services/burden-forecast"; import { loadOrComputeRepoOutcomePatternsResponse } from "../services/repo-outcome-patterns"; @@ -969,11 +974,18 @@ export function createApp() { app.get("/v1/app/analytics/weekly-value-report", async (c) => { const variant = c.req.query("variant") === "operator" ? "operator" : "public"; - const allowedRoles: ControlPanelRoleName[] = variant === "operator" ? ["operator"] : ["miner", "maintainer", "owner", "operator"]; + const allowedRoles: ControlPanelRoleName[] = + variant === "operator" ? ["operator"] : ["miner", "maintainer", "owner", "operator"]; const forbidden = await requireAppRole(c, allowedRoles); if (forbidden) return forbidden; const days = Math.max(1, Math.min(31, Number(c.req.query("days") ?? 7) || 7)); - return c.json(await loadWeeklyValueReport(c.env, { variant, days })); + const report = await loadWeeklyValueReport(c.env, { variant, days }); + if (c.req.query("format") === "markdown") { + return c.text(formatWeeklyValueReportMarkdown(report), 200, { + "Content-Type": "text/markdown; charset=utf-8", + }); + } + return c.json(report); }); app.get("/v1/app/commands", async (c) => diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 95145d5389..97142c8896 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -587,7 +587,6 @@ export function buildOpenApiSpec() { "/v1/app/digest", "/v1/app/analytics/daily-rollups", "/v1/app/analytics/mcp-compatibility", - "/v1/app/analytics/weekly-value-report", ]) { registry.registerPath({ method: "get", @@ -598,6 +597,45 @@ export function buildOpenApiSpec() { }, }); } + registry.registerPath({ + method: "get", + path: "/v1/app/analytics/weekly-value-report", + request: { + query: z.object({ + variant: z.enum(["public", "operator"]).optional().openapi({ + param: { + description: "Report variant. Operator reports require the operator app role.", + }, + example: "public", + }), + days: z.string().optional().openapi({ + param: { description: "Report window in days, clamped from 1 to 31." }, + example: "7", + }), + format: z.enum(["json", "markdown"]).optional().openapi({ + param: { + description: "Response format. Omit or use json for the structured report; use markdown for copy-ready text.", + }, + example: "markdown", + }), + }), + }, + responses: { + 200: { + description: "Weekly value report as structured JSON or copy-ready Markdown", + content: { + "application/json": { schema: z.record(z.string(), z.unknown()) }, + "text/markdown": { + schema: z.string().openapi({ + example: "# Weekly Gittensory value report\n\n## Adoption metrics\n- Active users: 4\n", + }), + }, + }, + }, + 401: { description: "Unauthorized" }, + 403: { description: "Insufficient app role for requested report variant" }, + }, + }); registry.registerPath({ method: "post", path: "/v1/app/commands/preview", diff --git a/src/services/weekly-value-report.ts b/src/services/weekly-value-report.ts index d63db85c29..ed39b43296 100644 --- a/src/services/weekly-value-report.ts +++ b/src/services/weekly-value-report.ts @@ -203,6 +203,37 @@ export function buildWeeklyValueReport(args: WeeklyValueReportInputs): WeeklyVal }; } +export function formatWeeklyValueReportMarkdown(report: WeeklyValueReport): string { + const lines = [ + "# Weekly Gittensory value report", + "", + `- Generated: ${markdownText(report.generatedAt)}`, + `- Variant: ${markdownText(report.variant)}`, + `- Window: ${report.period.days} day(s)${report.period.startDay && report.period.endDay ? `, ${markdownText(report.period.startDay)} to ${markdownText(report.period.endDay)}` : ""}`, + `- Public-safe: ${report.publicSafe ? "yes" : "operator-only"}`, + "", + "## Summary", + ...listLines(report.summary), + "", + "## Adoption metrics", + ...metricLines(report, ["active_users", "active_repos", "product_events", "active_sessions", "digest_subscriptions"]), + "", + "## Miner utility", + ...metricLines(report, ["mcp_usage", "pr_preflights", "pr_packets"]), + "", + "## Maintainer trust", + ...metricLines(report, ["github_commands", "quiet_skips", "maintainer_signals", "drift_reports"]), + "", + "## Repo-owner readiness", + ...metricLines(report, ["registered_repos", "installed_repos", "installations", "install_issues", "active_repos"]), + "", + "## Known blockers", + ...knownBlockerLines(report), + ...operatorDetailLines(report), + ]; + return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`; +} + function buildWeeklyMetrics(args: { activeActors: number; aggregate: WeeklyAggregate; @@ -322,6 +353,48 @@ function countDimensions(entries: ProductUsageDimensionCount[], limit = 10): Pro .slice(0, limit); } +function metricLines(report: WeeklyValueReport, ids: string[]): string[] { + const metrics = new Map(report.metrics.map((metric) => [metric.id, metric])); + const lines = ids.flatMap((id) => { + const metric = metrics.get(id); + if (!metric) return []; + const detail = markdownText(metric.detail); + return [`- ${markdownText(metric.label)}: ${metric.value}${detail ? ` (${detail})` : ""}`]; + }); + return lines.length > 0 ? lines : ["- No rollup-backed metric is available for this section."]; +} + +function knownBlockerLines(report: WeeklyValueReport): string[] { + const blockers = [...report.warnings, ...report.freshness.warnings]; + return blockers.length > 0 ? listLines([...new Set(blockers)]) : ["- No known blocker surfaced by the current report window."]; +} + +function operatorDetailLines(report: WeeklyValueReport): string[] { + if (!report.operatorDetails) return []; + return [ + "", + "## Operator detail", + `- Activation: ${report.operatorDetails.activation.fullyActivatedActors} fully activated actor(s), ${report.operatorDetails.activation.githubActivatedRepos} GitHub activated repo(s).`, + ...dimensionLines("Top repos", report.operatorDetails.topRepos), + ...dimensionLines("Top commands", report.operatorDetails.topCommands), + ...dimensionLines("Top tools", report.operatorDetails.topTools), + ...dimensionLines("Top route classes", report.operatorDetails.topRouteClasses), + ]; +} + +function dimensionLines(title: string, entries: ProductUsageDimensionCount[]): string[] { + if (entries.length === 0) return []; + return [`- ${title}: ${entries.slice(0, 5).map((entry) => `${markdownText(entry.key)} (${entry.count})`).join(", ")}`]; +} + +function listLines(items: string[]): string[] { + return items.length > 0 ? items.map((item) => `- ${markdownText(item)}`) : ["- No report data available."]; +} + +function markdownText(value: string): string { + return sanitizeReportText(value).replace(/\s+/g, " ").trim(); +} + function sum(values: number[]): number { return values.reduce((total, value) => total + value, 0); } @@ -339,6 +412,11 @@ function sanitizeReportText(value: string): string { .replace(/(?:\/Users|\/home|\/tmp)\/[^\s"',;)]*|[A-Za-z]:\\Users\\[^\s"',;)]*/g, "") .replace(/\b(?:ghp_|github_pat_|gts_|glpat-|sk-)[A-Za-z0-9_=-]{8,}/g, "") .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi, "Bearer "); - if (/\b(seed phrase|mnemonic|private key|raw trust|trust score|wallet|hotkey|coldkey|payout|reward estimate|farming|private reviewability|public score estimate)\b/i.test(redacted)) return ""; + if ( + /\b(seed phrase|mnemonic|private key|raw[-\s]?trust|trust[-\s]?score|wallet|hotkey|coldkey|payout|reward(?:[-\s]?(?:estimate|prediction|claim|score|payout|risk))?|farming|private[-\s]?reviewability|private[-\s]?scoreability|scoreability|public[-\s]?score[-\s]?(?:estimate|prediction|claim)|score[-\s]?(?:estimate|prediction|preview))\b/i.test( + redacted, + ) + ) + return ""; return redacted.slice(0, 240); } diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 0a5258c72e..b6e96a7c65 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -36,6 +36,9 @@ import { persistRegistrySnapshot } from "../../src/registry/sync"; import { createTestEnv } from "../helpers/d1"; import type { JsonValue } from "../../src/types"; +const FORBIDDEN_PUBLIC_REPORT_TERMS = + /wallet|hotkey|raw trust|trust[-\s]?score|payout|reward[-\s]?estimate|farming|private[-\s]?reviewability|public[-\s]?score[-\s]?(?:estimate|prediction)|private[-\s]?scoreability|scoreability/i; + describe("api routes", () => { // Freshness/readiness fixtures are dated relative to late May 2026; pin the clock so freshness SLO // windows stay deterministic regardless of when CI runs (fixtures otherwise tip "stale" after 7 days). @@ -1380,6 +1383,14 @@ describe("api routes", () => { const ownerWeeklyReportBody = await ownerWeeklyReport.json(); expect(ownerWeeklyReportBody).toMatchObject({ variant: "public", publicSafe: true }); expect(ownerWeeklyReportBody).not.toHaveProperty("operatorDetails"); + const ownerWeeklyReportMarkdown = await app.request("/v1/app/analytics/weekly-value-report?format=markdown", { headers: ownerHeaders }, ownerEnv); + expect(ownerWeeklyReportMarkdown.status).toBe(200); + expect(ownerWeeklyReportMarkdown.headers.get("content-type")).toContain("text/markdown"); + const ownerWeeklyReportMarkdownText = await ownerWeeklyReportMarkdown.text(); + expect(ownerWeeklyReportMarkdownText).toContain("# Weekly Gittensory value report"); + expect(ownerWeeklyReportMarkdownText).toContain("## Maintainer trust"); + expect(ownerWeeklyReportMarkdownText).not.toContain("## Operator detail"); + expect(ownerWeeklyReportMarkdownText).not.toMatch(FORBIDDEN_PUBLIC_REPORT_TERMS); expect((await app.request("/v1/app/analytics/weekly-value-report?variant=operator", { headers: ownerHeaders }, ownerEnv)).status).toBe(403); const ownerExtensionSession = await app.request("/v1/auth/extension/session", { method: "POST", headers: ownerHeaders }, ownerEnv); expect(ownerExtensionSession.status).toBe(201); @@ -2575,6 +2586,14 @@ describe("api routes", () => { period: expect.objectContaining({ days: 7 }), operatorDetails: expect.any(Object), }); + const operatorWeeklyReportMarkdown = await app.request("/v1/app/analytics/weekly-value-report?variant=operator&format=markdown", { headers: apiHeaders(env) }, env); + expect(operatorWeeklyReportMarkdown.status).toBe(200); + expect(operatorWeeklyReportMarkdown.headers.get("content-type")).toContain("text/markdown"); + const operatorWeeklyReportMarkdownText = await operatorWeeklyReportMarkdown.text(); + expect(operatorWeeklyReportMarkdownText).toContain("## Adoption metrics"); + expect(operatorWeeklyReportMarkdownText).toContain("## Operator detail"); + expect(operatorWeeklyReportMarkdownText).toContain("- Product events:"); + expect(operatorWeeklyReportMarkdownText).not.toMatch(FORBIDDEN_PUBLIC_REPORT_TERMS); }); it("covers live app auth, validation, and internal job queue edge routes", async () => { diff --git a/test/unit/maintainer-settings-preview-ui.test.ts b/test/unit/maintainer-settings-preview-ui.test.ts index 9e024d054b..1a7eba7f36 100644 --- a/test/unit/maintainer-settings-preview-ui.test.ts +++ b/test/unit/maintainer-settings-preview-ui.test.ts @@ -106,4 +106,24 @@ describe("maintainer settings preview UI helpers", () => { minerStatus: "unavailable", }); }); + + it("falls back safely for sparse preview helper inputs", () => { + expect(findPreviewScenario("unknown-scenario" as never).id).toBe("confirmed-miner"); + expect( + extractPreviewRepoOptions([ + { pr: { split: () => [] } as unknown as string }, + { pr: "JSONbored/gittensory#251" }, + ]), + ).toEqual(["JSONbored/gittensory"]); + + const request = buildSettingsPreviewRequest({ + repoFullName: "JSONbored/gittensory", + scenarioId: "confirmed-miner", + title: "Export weekly report", + labels: "", + linkedIssues: "", + body: " ", + }); + expect(request.sample).not.toHaveProperty("body"); + }); }); diff --git a/test/unit/weekly-value-report.test.ts b/test/unit/weekly-value-report.test.ts index 7bc48f6a93..8c3889a49b 100644 --- a/test/unit/weekly-value-report.test.ts +++ b/test/unit/weekly-value-report.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildWeeklyValueReport, generateWeeklyValueReport } from "../../src/services/weekly-value-report"; +import { buildWeeklyValueReport, formatWeeklyValueReportMarkdown, generateWeeklyValueReport } from "../../src/services/weekly-value-report"; import type { InstallationHealthRecord, InstallationRecord, @@ -13,6 +13,9 @@ import type { import type { UpstreamStatus } from "../../src/upstream/ruleset"; import { createTestEnv } from "../helpers/d1"; +const FORBIDDEN_EXPORT_TERMS = + /wallet|hotkey|raw trust|trust[-\s]?score|payout|reward[-\s]?estimate|farming|private[-\s]?reviewability|public[-\s]?score[-\s]?(?:estimate|prediction)|private[-\s]?scoreability|scoreability/i; + describe("weekly value reports", () => { it("builds public-safe adoption and maintainer-value summaries from daily rollups", () => { const report = buildWeeklyValueReport({ @@ -87,6 +90,18 @@ describe("weekly value reports", () => { ); expect(report.freshness.warnings).toEqual(["Product usage rollups have 1 freshness warning(s)."]); expect(JSON.stringify(report)).not.toMatch(/wallet|hotkey|raw trust|payout|reward estimate|farming|private reviewability|public score estimate|\/Users|github_pat/i); + + const markdown = formatWeeklyValueReportMarkdown(report); + expect(markdown).toContain("# Weekly Gittensory value report"); + expect(markdown).toContain("## Adoption metrics"); + expect(markdown).toContain("## Miner utility"); + expect(markdown).toContain("## Maintainer trust"); + expect(markdown).toContain("## Repo-owner readiness"); + expect(markdown).toContain("## Known blockers"); + expect(markdown).toContain("- Active users: 4"); + expect(markdown).toContain("- PR packets: 3"); + expect(markdown).not.toContain("## Operator detail"); + expect(markdown).not.toMatch(FORBIDDEN_EXPORT_TERMS); }); it("adds operator details, freshness warnings, and redacts unsafe rollup dimensions", () => { @@ -97,8 +112,8 @@ describe("weekly value reports", () => { repositories: [repo("JSONbored/gittensory", true, true)], installations: [installation(1)], health: [health(1, "needs_attention")], - registry: registry(["source mirror stale"]), - scoring: scoring(["fallback model"]), + registry: registry(["source mirror stale", "wallet hotkey reward-estimate trust-score public score prediction private scoreability farming"]), + scoring: scoring(["fallback model", "private reviewability signal"]), upstreamDrift: upstream({ status: "drift_detected", openReportCount: 2 }), usageSummary: usageSummary({ totalEvents: 2, activeActors: 1 }), usageRollups: [ @@ -150,6 +165,14 @@ describe("weekly value reports", () => { ]), ); expect(JSON.stringify(report)).not.toMatch(/\/Users|github_pat|wallet|raw trust|abcdef/i); + + const markdown = formatWeeklyValueReportMarkdown(report); + expect(markdown).toContain("## Operator detail"); + expect(markdown).toContain("## Known blockers"); + expect(markdown).toContain("- Product events: 2"); + expect(markdown).toContain("Top repos: (2)"); + expect(markdown).toContain(""); + expect(markdown).not.toMatch(FORBIDDEN_EXPORT_TERMS); }); it("keeps clean complete windows marked ready", () => { @@ -169,6 +192,34 @@ describe("weekly value reports", () => { expect(report.period.days).toBe(1); expect(report.dataQuality).toEqual({ status: "ready", warnings: [] }); + + const markdown = formatWeeklyValueReportMarkdown({ + ...report, + summary: [], + metrics: [], + warnings: [], + freshness: { ...report.freshness, warnings: [] }, + operatorDetails: { + ...report.operatorDetails!, + topRepos: [], + topCommands: [], + topTools: [], + topRouteClasses: [], + }, + }); + expect(markdown).toContain("- No report data available."); + expect(markdown).toContain("- No rollup-backed metric is available for this section."); + expect(markdown).toContain("- No known blocker surfaced by the current report window."); + expect(markdown).toContain("## Operator detail"); + + const { operatorDetails: _operatorDetails, ...reportWithoutOperatorDetails } = report; + const sparseMarkdown = formatWeeklyValueReportMarkdown({ + ...reportWithoutOperatorDetails, + period: { ...report.period, startDay: null, endDay: null }, + metrics: [{ id: "active_users", label: "Active users", value: 1, detail: "", visibility: "public" }], + }); + expect(sparseMarkdown).toContain("- Window: 1 day(s)"); + expect(sparseMarkdown).toContain("- Active users: 1\n"); }); it("normalizes report windows and records public scheduled generations without operator details", async () => {