diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index a46b9b550b..e9ccb35aeb 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -7,6 +7,7 @@ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mc import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { buildBranchAnalysisPayload, collectLocalDiff, collectLocalBranchMetadata, probeLocalScorer, referenceScorePreviewExample, resolveScorePreviewCommand, resolveWorkspaceCwd, sanitizeLocalScorerStatus, setupGuidanceForLocalScorer, isTestFile } from "../lib/local-branch.js"; +import { formatTable } from "../lib/format-table.js"; // Read name/version from this package's own package.json (always present in any install -- // global, npx, or local -- npm ships it regardless of the "files" allowlist) instead of hand-synced @@ -1525,9 +1526,33 @@ async function runCli(args) { process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); return; } + if (options.format === "table") { + writeBranchAnalysisTable(result, command); + return; + } writeBranchAnalysisCli(result, command); } +// Render the report-shaped branch analysis (next actions, plus score blockers for analyze-branch) as +// aligned monospace tables when `--format table` is passed. Default and `--json` output are untouched. +function writeBranchAnalysisTable(result, command) { + const analysis = result.analysis; + const actionRows = (analysis.nextActions ?? []).map((action) => ({ + action: action.actionKind ?? "—", + priority: action.priorityScore === undefined || action.priorityScore === null ? "—" : String(action.priorityScore), + why: (action.whyThisHelps ?? []).join("; ") || "—", + })); + process.stdout.write( + `${formatTable( + { headers: [{ key: "action", label: "Action" }, { key: "priority", label: "Priority", align: "right" }, { key: "why", label: "Why this helps" }], rows: actionRows }, + )}\n`, + ); + if (command === "analyze-branch" && analysis.scoreBlockers?.length) { + process.stdout.write("\n"); + process.stdout.write(`${formatTable({ headers: [{ key: "blocker", label: "Score blocker" }], rows: analysis.scoreBlockers.map((blocker) => ({ blocker })) })}\n`); + } +} + function printReviewPrHelp() { process.stdout.write( [ @@ -2078,7 +2103,7 @@ _gittensory_mcp() { fi case "\${COMP_WORDS[1]}" in ${subcommandCases} - *) COMPREPLY=( $(compgen -W "--json --login --repo --profile --agent-profile --base --cwd" -- "$cur") ); return 0;; + *) COMPREPLY=( $(compgen -W "--json --format --login --repo --profile --agent-profile --base --cwd" -- "$cur") ); return 0;; esac } complete -F _gittensory_mcp gittensory-mcp`; @@ -2164,8 +2189,8 @@ function printHelp() { gittensory-mcp init-client --print codex|claude|cursor|mcp|vscode [--agent-profile miner-planner|maintainer-triage|repo-owner-intake] [--json] gittensory-mcp decision-pack --login [--json] gittensory-mcp repo-decision --login --repo owner/repo [--json] - gittensory-mcp analyze-branch --login [--repo owner/repo] [--base origin/main] [--branch-eligibility eligible|ineligible|unknown] [--pending-merged-prs 3] [--expected-open-prs 0] [--projected-credibility 0.8] [--scenario-note "..."] [--validation "passed|npm test|summary"] [--json] - gittensory-mcp preflight --login [--repo owner/repo] [--base origin/main] [--branch-eligibility eligible|ineligible|unknown] [--pending-merged-prs 3] [--expected-open-prs 0] [--projected-credibility 0.8] [--validation "passed|npm test|summary"] [--json] + gittensory-mcp analyze-branch --login [--repo owner/repo] [--base origin/main] [--branch-eligibility eligible|ineligible|unknown] [--pending-merged-prs 3] [--expected-open-prs 0] [--projected-credibility 0.8] [--scenario-note "..."] [--validation "passed|npm test|summary"] [--format table] [--json] + gittensory-mcp preflight --login [--repo owner/repo] [--base origin/main] [--branch-eligibility eligible|ineligible|unknown] [--pending-merged-prs 3] [--expected-open-prs 0] [--projected-credibility 0.8] [--validation "passed|npm test|summary"] [--format table] [--json] gittensory-mcp review-pr --login [--repo owner/repo] [--base origin/main] [--commit ]... [--body ] [--body-file ] [--linked-issue ] [--json] gittensory-mcp lint-pr-text [--commit ]... [--body ] [--body-file ] [--linked-issue ] [--json] gittensory-mcp validate-config --file [--source repo_file|api_record|none] [--json] @@ -2233,6 +2258,16 @@ function parseOptions(args) { continue; } if (!arg?.startsWith("--")) continue; + // Support the inline `--key=value` form (e.g. `--format=table`) alongside the space-separated + // `--key value` form; splitting here keeps every existing space-separated option unchanged (#2231). + const equals = arg.indexOf("="); + if (equals !== -1) { + const inlineKey = camel(arg.slice(2, equals)); + const inlineValue = arg.slice(equals + 1); + if (repeatable.has(inlineKey)) options[inlineKey] = [...(options[inlineKey] ?? []), inlineValue]; + else options[inlineKey] = inlineValue; + continue; + } const key = camel(arg.slice(2)); const value = args[index + 1]; if (!value || value.startsWith("--")) { diff --git a/packages/gittensory-mcp/lib/format-table.js b/packages/gittensory-mcp/lib/format-table.js new file mode 100644 index 0000000000..da589fc771 --- /dev/null +++ b/packages/gittensory-mcp/lib/format-table.js @@ -0,0 +1,58 @@ +// Pure, dependency-free monospace table renderer shared by the stdio CLI's report-shaped commands +// (#2231). Kept in lib/ (not the bin) so it can be unit-tested in isolation: the bin auto-runs its +// CLI/MCP entrypoint on import, so importable helpers live here instead. + +// Normalize either an array of row objects or an explicit { headers, rows } shape into a common +// { headers, rows } form. For an array of objects the column set is the union of keys in first-seen +// order, and each key doubles as its own header label. +function normalizeInput(input) { + if (Array.isArray(input)) { + const keys = []; + for (const row of input) { + for (const key of Object.keys(row ?? {})) if (!keys.includes(key)) keys.push(key); + } + return { headers: keys.map((key) => ({ key, label: key })), rows: input }; + } + const headers = (input?.headers ?? []).map((header) => + typeof header === "string" ? { key: header, label: header } : { key: header.key, label: header.label ?? header.key, align: header.align }, + ); + return { headers, rows: input?.rows ?? [] }; +} + +function stringifyCell(value) { + return value === undefined || value === null ? "" : String(value); +} + +// A row is either an object keyed by column key or a positional array; read the matching cell. +function readCell(row, header, columnIndex) { + if (Array.isArray(row)) return row[columnIndex]; + return row?.[header.key]; +} + +function resolveAlign(header, opts) { + const fromOpts = opts.align && (opts.align[header.key] ?? opts.align[header.label]); + return header.align ?? fromOpts ?? "left"; +} + +/** + * Render tabular data as an aligned, monospace plain-text table (header row + one line per row). + * Accepts an array of row objects, or `{ headers, rows }` with string/`{ key, label, align }` + * headers and object/array rows. `opts.align` maps a column key/label to `"left"`|`"right"`; + * `opts.gap` sets the space count between columns (default 2). Pure — no I/O, no dependencies. + * Returns "" when there are no columns. + */ +export function formatTable(input, opts = {}) { + const { headers, rows } = normalizeInput(input); + if (headers.length === 0) return ""; + const gap = " ".repeat(Math.max(1, opts.gap ?? 2)); + const aligns = headers.map((header) => resolveAlign(header, opts)); + // Precompute every cell's text so column widths and the rendered rows read the same strings. + const bodyCells = rows.map((row) => headers.map((header, column) => stringifyCell(readCell(row, header, column)))); + const widths = headers.map((header, column) => + Math.max(header.label.length, ...bodyCells.map((cells) => cells[column].length), 0), + ); + const renderRow = (cells) => + // Trim trailing padding so a left-aligned final column never emits dangling spaces. + cells.map((text, column) => (aligns[column] === "right" ? text.padStart(widths[column]) : text.padEnd(widths[column]))).join(gap).replace(/\s+$/, ""); + return [renderRow(headers.map((header) => header.label)), ...bodyCells.map(renderRow)].join("\n"); +} diff --git a/packages/gittensory-mcp/package.json b/packages/gittensory-mcp/package.json index f623d7bca7..2bdf3a232b 100644 --- a/packages/gittensory-mcp/package.json +++ b/packages/gittensory-mcp/package.json @@ -35,7 +35,7 @@ "CHANGELOG.md" ], "scripts": { - "build": "node --check bin/gittensory-mcp.js && node --check lib/local-branch.js && node --check scripts/gittensor-score-preview.mjs" + "build": "node --check bin/gittensory-mcp.js && node --check lib/local-branch.js && node --check lib/format-table.js && node --check scripts/gittensor-score-preview.mjs" }, "dependencies": { "@jsonbored/gittensory-engine": ">=0.1.0 <1.0.0", diff --git a/scripts/check-mcp-package.mjs b/scripts/check-mcp-package.mjs index dfab02831a..55e1eef325 100644 --- a/scripts/check-mcp-package.mjs +++ b/scripts/check-mcp-package.mjs @@ -14,7 +14,7 @@ if (result.status !== 0) { const [pack] = JSON.parse(result.stdout); const files = pack.files.map((file) => file.path).sort(); -const allowed = [/^bin\/gittensory-mcp\.js$/, /^lib\/local-branch\.js$/, /^scripts\/gittensor-score-preview\.(mjs|py)$/, /^package\.json$/, /^README\.md$/, /^CHANGELOG\.md$/, /^LICENSE$/]; +const allowed = [/^bin\/gittensory-mcp\.js$/, /^lib\/local-branch\.js$/, /^lib\/format-table\.js$/, /^scripts\/gittensor-score-preview\.(mjs|py)$/, /^package\.json$/, /^README\.md$/, /^CHANGELOG\.md$/, /^LICENSE$/]; const forbiddenPath = /(^|\/)(\.dev\.vars|\.env|\.npmrc|.*\.pem|.*private.*key.*|.*secret.*)$/i; const forbiddenContent = /(BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY|github_pat_[A-Za-z0-9_]+|gh[pousr]_[A-Za-z0-9_]+|gts_[0-9a-f]{64}|[A-Z0-9_]*(TOKEN|SECRET|PRIVATE_KEY)=)/; const stalePackageText = /(private beta|zeronode\.workers\.dev|preview URL)/i; diff --git a/test/unit/format-table.test.ts b/test/unit/format-table.test.ts new file mode 100644 index 0000000000..8da43270ae --- /dev/null +++ b/test/unit/format-table.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +// The helper ships in the MCP package's lib/ (the bin auto-runs on import, so it cannot be imported); +// mirror the local-branch.test.ts pattern of dynamically importing the packaged .js module. +async function loadFormatTable() { + // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. + return (await import("../../packages/gittensory-mcp/lib/format-table.js")).formatTable; +} + +describe("formatTable", () => { + it("aligns columns inferred from an array of row objects", async () => { + const formatTable = await loadFormatTable(); + const table = formatTable([ + { name: "a", count: 1 }, + { name: "bbbb", count: 22 }, + ]); + expect(table.split("\n")).toEqual(["name count", "a 1", "bbbb 22"]); + }); + + it("honours explicit headers, labels, and right alignment", async () => { + const formatTable = await loadFormatTable(); + const table = formatTable({ + headers: [ + { key: "action", label: "Action" }, + { key: "priority", label: "Priority", align: "right" }, + ], + rows: [ + { action: "prepare_pr_packet", priority: 12 }, + { action: "add_tests", priority: 3 }, + ], + }); + const lines = table.split("\n"); + expect(lines[0]).toBe("Action Priority"); + // Right alignment pins the numbers' trailing digits to the same column. + expect(lines[1]).toBe("prepare_pr_packet 12"); + expect(lines[2]).toBe("add_tests 3"); + }); + + it("accepts positional array rows with string headers and a custom gap", async () => { + const formatTable = await loadFormatTable(); + const table = formatTable({ headers: ["A", "B", "C"], rows: [["x", "yy", "zzz"]] }, { gap: 1 }); + expect(table.split("\n")).toEqual(["A B C", "x yy zzz"]); + }); + + it("renders missing keys as blank cells without leaking undefined", async () => { + const formatTable = await loadFormatTable(); + const table = formatTable([{ a: "one", b: "two" }, { a: "three" }]); + expect(table.split("\n")).toEqual(["a b", "one two", "three"]); + }); + + it("returns a header-only table when there are no rows", async () => { + const formatTable = await loadFormatTable(); + expect(formatTable({ headers: ["Score blocker"], rows: [] })).toBe("Score blocker"); + }); + + it("returns an empty string when no columns can be determined", async () => { + const formatTable = await loadFormatTable(); + expect(formatTable([])).toBe(""); + expect(formatTable({ headers: [], rows: [{ a: 1 }] })).toBe(""); + }); +}); diff --git a/test/unit/mcp-cli-analyze-branch.test.ts b/test/unit/mcp-cli-analyze-branch.test.ts new file mode 100644 index 0000000000..c53836439e --- /dev/null +++ b/test/unit/mcp-cli-analyze-branch.test.ts @@ -0,0 +1,75 @@ +import { rmSync } from "node:fs"; +import { afterEach, describe, expect, it } from "vitest"; +import { closeFixtureServer, createPacketRepo, localBranchAnalysisFixture, runAsync, startFixtureServer } from "./support/mcp-cli-harness"; + +describe("gittensory-mcp CLI — analyze-branch --format table", () => { + let tempDir: string | null = null; + + afterEach(async () => { + await closeFixtureServer(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + }); + + // A richer analysis than the default fixture so the table exercises multiple next-action rows, + // a right-aligned numeric priority column, and the analyze-branch-only score-blockers table. + const analysisFixture = () => ({ + ...localBranchAnalysisFixture(), + nextActions: [ + { actionKind: "prepare_pr_packet", priorityScore: 12, whyThisHelps: ["Keeps public packet safe."] }, + { actionKind: "add_tests", whyThisHelps: ["Raise branch coverage."] }, + ], + scoreBlockers: ["Add a linked issue.", "Increase test evidence."], + }); + + const env = (url: string) => ({ GITTENSORY_API_URL: url, GITTENSORY_TOKEN: "session-token", GITTENSORY_SKIP_NPM_VERSION_CHECK: "true" }); + const args = (extra: string[]) => ["analyze-branch", "--login", "JSONbored", "--cwd", tempDir as string, "--repo", "JSONbored/gittensory", ...extra]; + + it("renders next actions and score blockers as aligned tables", async () => { + tempDir = createPacketRepo(); + const url = await startFixtureServer({ localBranchAnalysis: analysisFixture() }); + const output = await runAsync(args(["--format", "table"]), env(url)); + const lines = output.split("\n"); + + // Next-actions table: header, then one aligned row per action with a right-aligned priority. + expect(lines).toContain("Action Priority Why this helps"); + expect(lines).toContain("prepare_pr_packet 12 Keeps public packet safe."); + expect(lines).toContain("add_tests — Raise branch coverage."); + // Score-blockers table (analyze-branch only). + expect(lines).toContain("Score blocker"); + expect(lines).toContain("Add a linked issue."); + expect(lines).toContain("Increase test evidence."); + // The line-summary renderer's labels must not appear in table mode. + expect(output).not.toContain("Top action:"); + }); + + it("accepts the inline --format=table form", async () => { + tempDir = createPacketRepo(); + const url = await startFixtureServer({ localBranchAnalysis: analysisFixture() }); + const output = await runAsync(args(["--format=table"]), env(url)); + expect(output).toContain("Action Priority Why this helps"); + expect(output).toContain("prepare_pr_packet 12 Keeps public packet safe."); + }); + + it("leaves the default line-summary output unchanged", async () => { + tempDir = createPacketRepo(); + const url = await startFixtureServer({ localBranchAnalysis: analysisFixture() }); + const output = await runAsync(args([]), env(url)); + // Existing behavior: summary + "Top action:" line + colon-labelled sections, and no aligned table. + expect(output).toContain("Local branch preflight fixture."); + expect(output).toContain("Top action: prepare_pr_packet"); + expect(output).toContain("Score blockers:"); + expect(output).not.toContain("Action Priority Why this helps"); + expect(output).not.toContain("prepare_pr_packet 12"); + }); + + it("leaves the --json output unchanged", async () => { + tempDir = createPacketRepo(); + const url = await startFixtureServer({ localBranchAnalysis: analysisFixture() }); + const output = await runAsync(args(["--json"]), env(url)); + const payload = JSON.parse(output) as { analysis: { summary: string; scoreBlockers: string[] } }; + expect(payload.analysis.summary).toBe("Local branch preflight fixture."); + expect(payload.analysis.scoreBlockers).toEqual(["Add a linked issue.", "Increase test evidence."]); + expect(output).not.toContain("Action Priority Why this helps"); + }); +});