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
1 change: 1 addition & 0 deletions packages/gittensory-miner/lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export function printHelp(input) {
" gittensory-miner claim release <owner/repo> <issue#> [--json]",
" gittensory-miner claim list [--repo <owner/repo>] [--status active|released|expired] [--json]",
" gittensory-miner ledger list [--repo <owner/repo>] [--since <seq>] [--type <eventType>] [--json]",
" gittensory-miner ledger metrics Print event-ledger counters in Prometheus text format",
" gittensory-miner plan list [--status pending|running|completed|failed] [--json]",
" gittensory-miner plan show <planId> [--json]",
" gittensory-miner governor list [--repo <owner/repo>] [--type allowed|denied|throttled|kill_switch] [--json]",
Expand Down
7 changes: 7 additions & 0 deletions packages/gittensory-miner/lib/event-ledger-cli.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,18 @@ export function collectEventLedgerAuditFeed(

export function renderLedgerTable(events: LedgerEntry[]): string;

export function renderEventLedgerMetrics(events: readonly LedgerEntry[]): string;

export function runLedgerList(
args: string[],
options?: { initEventLedger?: () => EventLedger },
): number;

export function runLedgerMetrics(
args: string[],
options?: { initEventLedger?: () => EventLedger },
): number;

export function runLedgerCli(
subcommand: string | undefined,
args: string[],
Expand Down
60 changes: 60 additions & 0 deletions packages/gittensory-miner/lib/event-ledger-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,46 @@ export function renderLedgerTable(events) {
return [header, ...lines].join("\n");
}

const EVENT_LEDGER_METRICS_USAGE = "Usage: gittensory-miner ledger metrics";

// Prometheus metric name for the per-type event-ledger counter. Mirrors the `gittensory_miner_*_total` naming and
// the HELP/TYPE/label conventions of the engine's renderMinerPredictionMetrics
// (packages/gittensory-engine/src/miner-prediction-metrics.ts) rather than importing across the package boundary.
const MINER_EVENTS_TOTAL = "gittensory_miner_events_total";

/** HELP-text escaping — backslash + newline (mirrors miner-prediction-metrics.ts's escapeHelpText). */
function escapeHelpText(help) {
return help.replace(/\\/g, "\\\\").replace(/\n/g, "\\n");
}

/** Prometheus label-value escaping — backslash, double-quote, newline — so an arbitrary event `type` string can
* never break the metric line (mirrors miner-prediction-metrics.ts's escapeLabelValue). */
function escapeLabelValue(value) {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
}

/**
* Render event-ledger activity as Prometheus text-exposition counters: one `gittensory_miner_events_total{type}`
* series per event type, so a self-hoster's own Grafana/alerting can scrape ledger activity instead of polling
* `ledger list --json` (#4841). Pure + side-effect-free — the caller supplies the rows and prints the result;
* deterministic (series emitted in sorted type order); always emits HELP/TYPE so an empty ledger is still a
* well-formed exposition document.
*/
export function renderEventLedgerMetrics(events) {
const totalByType = new Map();
for (const entry of events) {
totalByType.set(entry.type, (totalByType.get(entry.type) ?? 0) + 1);
}
const lines = [
`# HELP ${MINER_EVENTS_TOTAL} ${escapeHelpText("Event-ledger entries the miner has recorded, by event type.")}`,
`# TYPE ${MINER_EVENTS_TOTAL} counter`,
];
for (const [type, count] of [...totalByType.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
lines.push(`${MINER_EVENTS_TOTAL}{type="${escapeLabelValue(type)}"} ${count}`);
}
return `${lines.join("\n")}\n`;
}

function withEventLedger(options, run) {
const ownsLedger = options.initEventLedger === undefined;
const eventLedger = (options.initEventLedger ?? initEventLedger)();
Expand Down Expand Up @@ -203,8 +243,28 @@ export function runLedgerList(args, options = {}) {
}
}

export function runLedgerMetrics(args, options = {}) {
if (args.length > 0) {
console.error(EVENT_LEDGER_METRICS_USAGE);
return 2;
}

try {
return withEventLedger(options, (eventLedger) => {
// renderEventLedgerMetrics returns a newline-terminated document; console.log re-adds the terminator, so
// trim it to emit exactly one trailing newline (mirrors metrics-cli.js's runMetrics).
console.log(renderEventLedgerMetrics(eventLedger.readEvents()).trimEnd());
return 0;
});
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
return 2;
}
}

export function runLedgerCli(subcommand, args, options = {}) {
if (subcommand === "list") return runLedgerList(args, options);
if (subcommand === "metrics") return runLedgerMetrics(args, options);
console.error(`Unknown ledger subcommand: ${subcommand ?? ""}. ${LEDGER_LIST_USAGE}`);
return 2;
}
1 change: 1 addition & 0 deletions test/unit/miner-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ describe("gittensory-miner CLI helpers", () => {
expect(text).toContain("gittensory-miner --help");
expect(text).toContain("gittensory-miner version");
expect(text).toContain("gittensory-miner metrics");
expect(text).toContain("gittensory-miner ledger metrics");
expect(text).toContain("--no-update-check");
});

Expand Down
117 changes: 117 additions & 0 deletions test/unit/miner-event-ledger-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import {
import {
filterLedgerEvents,
parseLedgerListArgs,
renderEventLedgerMetrics,
renderLedgerTable,
runLedgerCli,
runLedgerList,
runLedgerMetrics,
} from "../../packages/gittensory-miner/lib/event-ledger-cli.js";
import type { LedgerEntry } from "../../packages/gittensory-miner/lib/event-ledger.d.ts";

Expand All @@ -26,6 +28,16 @@ function tempLedger() {
return ledger;
}

function tempEventDbPath() {
const root = mkdtempSync(join(tmpdir(), "gittensory-miner-event-ledger-metrics-"));
roots.push(root);
return join(root, "event-ledger.sqlite3");
}

function metricEntry(seq: number, type: string): LedgerEntry {
return { id: seq, seq, type, repoFullName: null, payload: {}, createdAt: "2026-07-04T12:00:00.000Z" };
}

afterEach(() => {
for (const ledger of ledgers.splice(0)) ledger.close();
closeDefaultEventLedger();
Expand Down Expand Up @@ -129,3 +141,108 @@ describe("gittensory-miner event ledger CLI (#2290)", () => {
expect(error).toHaveBeenCalledWith("since must be a non-negative integer seq cursor.");
});
});

describe("gittensory-miner ledger metrics CLI (#4841)", () => {
it("renderEventLedgerMetrics emits one sorted gittensory_miner_events_total series per type", () => {
const text = renderEventLedgerMetrics([
metricEntry(1, "manage_pr_update"),
metricEntry(2, "discovered_issue"),
metricEntry(3, "manage_pr_update"),
]);
expect(text).toContain(
"# HELP gittensory_miner_events_total Event-ledger entries the miner has recorded, by event type.",
);
expect(text).toContain("# TYPE gittensory_miner_events_total counter");
// Series are emitted in sorted type order, so "discovered_issue" precedes "manage_pr_update".
expect(text).toContain('gittensory_miner_events_total{type="discovered_issue"} 1');
expect(text).toContain('gittensory_miner_events_total{type="manage_pr_update"} 2');
expect(text.indexOf("discovered_issue")).toBeLessThan(text.indexOf("manage_pr_update"));
expect(text.endsWith("\n")).toBe(true);
});

it("renderEventLedgerMetrics still emits a well-formed document for an empty ledger", () => {
expect(renderEventLedgerMetrics([])).toBe(
"# HELP gittensory_miner_events_total Event-ledger entries the miner has recorded, by event type.\n" +
"# TYPE gittensory_miner_events_total counter\n",
);
});

it("renderEventLedgerMetrics escapes label-breaking characters in the event type", () => {
expect(renderEventLedgerMetrics([metricEntry(1, 'weird"type')])).toContain(
'gittensory_miner_events_total{type="weird\\"type"} 1',
);
});

it("runLedgerMetrics renders event counters as Prometheus text and returns 0", () => {
const eventLedger = tempLedger();
eventLedger.appendEvent({ type: "discovered_issue", repoFullName: "acme/widgets", payload: { issueNumber: 1 } });
eventLedger.appendEvent({ type: "manage_pr_update", repoFullName: "acme/widgets", payload: { prNumber: 2 } });
eventLedger.appendEvent({ type: "manage_pr_update", repoFullName: "acme/other", payload: { prNumber: 3 } });

const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
expect(runLedgerMetrics([], { initEventLedger: () => eventLedger })).toBe(0);

const text = String(log.mock.calls[0]?.[0]);
expect(text).toContain("# TYPE gittensory_miner_events_total counter");
expect(text).toContain('gittensory_miner_events_total{type="discovered_issue"} 1');
expect(text).toContain('gittensory_miner_events_total{type="manage_pr_update"} 2');
// The output is a single, once-terminated document (no doubled trailing blank line).
expect(text.endsWith("\n")).toBe(false);
});

it("runLedgerMetrics opens and closes its own default ledger when none is injected", () => {
const dbPath = tempEventDbPath();
const seed = initEventLedger(dbPath);
seed.appendEvent({ type: "plan_built", payload: { steps: 1 } });
seed.close();

const prev = process.env.GITTENSORY_MINER_EVENT_LEDGER_DB;
process.env.GITTENSORY_MINER_EVENT_LEDGER_DB = dbPath;
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
try {
expect(runLedgerMetrics([])).toBe(0);
} finally {
if (prev === undefined) delete process.env.GITTENSORY_MINER_EVENT_LEDGER_DB;
else process.env.GITTENSORY_MINER_EVENT_LEDGER_DB = prev;
}
expect(String(log.mock.calls[0]?.[0])).toContain('gittensory_miner_events_total{type="plan_built"} 1');
});

it("runLedgerMetrics rejects unexpected arguments with a usage error", () => {
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
expect(runLedgerMetrics(["--json"], { initEventLedger: () => tempLedger() })).toBe(2);
expect(error).toHaveBeenCalledWith("Usage: gittensory-miner ledger metrics");
});

it("runLedgerMetrics surfaces a thrown Error message and exits non-zero", () => {
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
expect(
runLedgerMetrics([], {
initEventLedger: () => {
throw new Error("event ledger is locked");
},
}),
).toBe(2);
expect(error).toHaveBeenCalledWith("event ledger is locked");
});

it("runLedgerMetrics stringifies a non-Error throw", () => {
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
expect(
runLedgerMetrics([], {
initEventLedger: () => {
throw "event-ledger-unavailable";
},
}),
).toBe(2);
expect(error).toHaveBeenCalledWith("event-ledger-unavailable");
});

it("runLedgerCli dispatches the metrics subcommand", () => {
const eventLedger = tempLedger();
eventLedger.appendEvent({ type: "plan_built", payload: { steps: 1 } });
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
expect(runLedgerCli("metrics", [], { initEventLedger: () => eventLedger })).toBe(0);
expect(String(log.mock.calls[0]?.[0])).toContain('gittensory_miner_events_total{type="plan_built"} 1');
});
});