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
41 changes: 38 additions & 3 deletions packages/gittensory-mcp/bin/gittensory-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
[
Expand Down Expand Up @@ -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`;
Expand Down Expand Up @@ -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 <github-login> [--json]
gittensory-mcp repo-decision --login <github-login> --repo owner/repo [--json]
gittensory-mcp analyze-branch --login <github-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 <github-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 <github-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 <github-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 <github-login> [--repo owner/repo] [--base origin/main] [--commit <message>]... [--body <text>] [--body-file <path>] [--linked-issue <number>] [--json]
gittensory-mcp lint-pr-text [--commit <message>]... [--body <text>] [--body-file <path>] [--linked-issue <number>] [--json]
gittensory-mcp validate-config --file <path> [--source repo_file|api_record|none] [--json]
Expand Down Expand Up @@ -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("--")) {
Expand Down
58 changes: 58 additions & 0 deletions packages/gittensory-mcp/lib/format-table.js
Original file line number Diff line number Diff line change
@@ -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");
}
2 changes: 1 addition & 1 deletion packages/gittensory-mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-mcp-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
61 changes: 61 additions & 0 deletions test/unit/format-table.test.ts
Original file line number Diff line number Diff line change
@@ -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("");
});
});
75 changes: 75 additions & 0 deletions test/unit/mcp-cli-analyze-branch.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});