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-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ gittensory-mcp repo-decision --login jsonbored --repo we-promise/sure --json
gittensory-mcp analyze-branch --login jsonbored --json
gittensory-mcp preflight --login jsonbored --json
gittensory-mcp lint-pr-text --commit "feat(mcp): add doctor grouping" --body "Fixes #160. Validated with npm test." --linked-issue 160 --json
gittensory-mcp slop-risk --changed-file src/widget.ts:80:2 --description "Adds retry handling." --test-file test/unit/widget.test.ts --json
gittensory-mcp agent plan --login jsonbored --json
gittensory-mcp agent packet --login jsonbored --json
gittensory-mcp agent status <run-id> --json
Expand Down
66 changes: 65 additions & 1 deletion packages/gittensory-mcp/bin/gittensory-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const CLI_COMMAND_SPEC = {
"analyze-branch": [],
preflight: [],
"lint-pr-text": [],
"slop-risk": [],
profile: ["list", "create", "switch", "remove"],
cache: ["status", "clear"],
agent: ["plan", "status", "explain", "packet"],
Expand Down Expand Up @@ -1404,6 +1405,7 @@ async function runCli(args) {
if (command === "doctor") return doctor(options);
if (command === "init-client") return initClient(options);
if (command === "lint-pr-text") return lintPrTextCli(args.slice(1));
if (command === "slop-risk") return slopRiskCli(args.slice(1));
if (command === "decision-pack") return decisionPackCli(options);
if (command === "repo-decision") return repoDecisionCli(options);
if (command !== "analyze-branch" && command !== "preflight") {
Expand Down Expand Up @@ -1479,6 +1481,67 @@ async function lintPrTextCli(args) {
for (const fix of payload.fixes ?? []) process.stdout.write(`- ${fix}\n`);
}

function printSlopRiskHelp() {
process.stdout.write(
[
"Usage: gittensory-mcp slop-risk [--description <text>] [--description-file <path>] [--changed-file <path[:additions:deletions]>]... [--test <command>]... [--test-file <path>]... [--json]",
"",
"Assess deterministic slop risk from local diff metadata and a PR description.",
"Mirrors the gittensory_check_slop_risk MCP tool and POST /v1/lint/slop-risk. No source upload.",
"",
"Pass --json for machine-readable output.",
].join("\n") + "\n",
);
}

function stringArrayOption(value) {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}

function parseChangedFileSpec(raw) {
const [path, additions, deletions] = String(raw).split(":");
if (!path) throw new Error(`Invalid --changed-file value: ${raw}`);
const entry = { path };
if (additions !== undefined && additions !== "") {
const parsedAdditions = Number(additions);
if (!Number.isInteger(parsedAdditions) || parsedAdditions < 0) throw new Error(`Invalid additions in --changed-file: ${raw}`);
entry.additions = parsedAdditions;
}
if (deletions !== undefined && deletions !== "") {
const parsedDeletions = Number(deletions);
if (!Number.isInteger(parsedDeletions) || parsedDeletions < 0) throw new Error(`Invalid deletions in --changed-file: ${raw}`);
entry.deletions = parsedDeletions;
}
return entry;
}

async function slopRiskCli(args) {
if (!args.length || args[0] === "--help" || args[0] === "help") return printSlopRiskHelp();
const options = parseOptions(args);
let description = options.description ?? options.body;
const descriptionFile = options.descriptionFile ?? options.bodyFile;
if (descriptionFile) {
if (!existsSync(descriptionFile)) throw new Error(`Description file not found: ${descriptionFile}`);
description = readFileSync(descriptionFile, "utf8");
}
const changedFiles = stringArrayOption(options.changedFile).map(parseChangedFileSpec);
const tests = stringArrayOption(options.test);
const testFiles = stringArrayOption(options.testFile);
const payload = await apiPost("/v1/lint/slop-risk", {
...(changedFiles.length ? { changedFiles } : {}),
...(description !== undefined ? { description } : {}),
...(tests.length ? { tests } : {}),
...(testFiles.length ? { testFiles } : {}),
});
if (options.json) {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
return;
}
process.stdout.write(`Slop risk: ${payload.slopRisk} (${payload.band})\n`);
for (const finding of payload.findings ?? []) process.stdout.write(`- ${finding.title}: ${finding.detail}\n`);
}

async function decisionPackCli(options) {
const login = options.login ?? process.env.GITTENSORY_LOGIN ?? process.env.GITHUB_LOGIN;
if (!login) throw new Error("Pass --login <github-login> or set GITTENSORY_LOGIN.");
Expand Down Expand Up @@ -1866,6 +1929,7 @@ function printHelp() {
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 lint-pr-text [--commit <message>]... [--body <text>] [--body-file <path>] [--linked-issue <number>] [--json]
gittensory-mcp slop-risk [--description <text>] [--description-file <path>] [--changed-file <path[:additions:deletions]>]... [--test <command>]... [--test-file <path>]... [--json]
gittensory-mcp agent plan --login <github-login> [--repo owner/repo] [--json]
gittensory-mcp agent status <run-id> [--json]
gittensory-mcp agent explain <run-id> [--json]
Expand Down Expand Up @@ -1920,7 +1984,7 @@ Use --profile <name> or GITTENSORY_PROFILE to run login, logout, whoami, status,

function parseOptions(args) {
const options = {};
const repeatable = new Set(["label", "issue", "commit", "validation", "validationCommand", "validationStatus", "validationSummary", "validationDuration", "scenarioNote"]);
const repeatable = new Set(["label", "issue", "commit", "changedFile", "test", "testFile", "validation", "validationCommand", "validationStatus", "validationSummary", "validationDuration", "scenarioNote"]);
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--json") {
Expand Down
106 changes: 106 additions & 0 deletions test/unit/mcp-cli-slop-risk.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { closeFixtureServer, run, runAsync, startFixtureServer } from "./support/mcp-cli-harness";

describe("gittensory-mcp CLI — slop-risk", () => {
let tempDir: string | null = null;

afterEach(async () => {
await closeFixtureServer();
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
tempDir = null;
});

async function env() {
tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-"));
const url = await startFixtureServer();
return { GITTENSORY_API_URL: url, GITTENSORY_TOKEN: "session-token", GITTENSORY_CONFIG_DIR: tempDir, GITTENSORY_API_TIMEOUT_MS: "1000" };
}

it("assesses slop risk via the API and prints plain or json output", async () => {
const e = await env();
const plain = await runAsync(
[
"slop-risk",
"--changed-file",
"src/widget.ts:80:2",
"--description",
"Adds retry handling for transient widget failures. Validated with npm test.",
"--test",
"npm test",
],
e,
);
expect(plain).toMatch(/Slop risk: 0 \(clean\)/);

const json = JSON.parse(
await runAsync(
[
"slop-risk",
"--changed-file",
"src/widget.ts:80:2",
"--description",
"Adds retry handling for transient widget failures.",
"--test-file",
"test/unit/widget.test.ts",
"--json",
],
e,
),
) as { slopRisk: number; band: string; findings: unknown[]; rubric: string };
expect(json).toMatchObject({ slopRisk: 0, band: "clean", findings: [], rubric: expect.any(String) });
expect(JSON.stringify(json)).not.toMatch(/wallet|hotkey|reward|trust score/i);
});

it("reads descriptions from --description-file and supports repeated changed files", async () => {
const e = await env();
const descriptionPath = join(tempDir!, "description.md");
writeFileSync(descriptionPath, "Fixes #7 with focused retry handling.", "utf8");
const json = JSON.parse(
await runAsync(
[
"slop-risk",
"--changed-file",
"src/widget.ts:12:1",
"--changed-file",
"test/unit/widget.test.ts:40:0",
"--description-file",
descriptionPath,
"--json",
],
e,
),
) as { band: string };
expect(json.band).toBe("clean");
});

it("surfaces elevated slop findings in plain output", async () => {
const e = await env();
const json = JSON.parse(await runAsync(["slop-risk", "--changed-file", "src/widget.ts:80:2", "--json"], e)) as {
slopRisk: number;
band: string;
findings: Array<{ title: string }>;
};
expect(json).toMatchObject({ slopRisk: 45, band: "elevated" });
const plain = await runAsync(["slop-risk", "--changed-file", "src/widget.ts:80:2"], e);
expect(plain).toMatch(/Slop risk: 45 \(elevated\)/);
expect(plain).toMatch(/Empty PR description/);
});

it("validates inputs and prints help", async () => {
const e = await env();
await expect(runAsync(["slop-risk", "--changed-file", ":1:2"], e)).rejects.toThrow(/Invalid --changed-file/);
await expect(runAsync(["slop-risk", "--changed-file", "src/a.ts:-1"], e)).rejects.toThrow(/Invalid additions/);
await expect(runAsync(["slop-risk", "--description-file", "/tmp/missing-gittensory-slop-description.md"], e)).rejects.toThrow(/Description file not found/);
const help = run(["slop-risk", "--help"]);
expect(help).toMatch(/Usage: gittensory-mcp slop-risk/);
expect(help).toMatch(/gittensory_check_slop_risk/);
expect(help).toMatch(/--changed-file/);
});

it("suggests slop-risk for close typos", () => {
expect(() => run(["slop-rsk"])).toThrow(/Did you mean `slop-risk`\?/);
});
});
36 changes: 36 additions & 0 deletions test/unit/support/mcp-cli-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,16 @@ export async function startFixtureServer(
response.end(JSON.stringify(lintPrTextFixture(body)));
return;
}
if (request.url === "/v1/lint/slop-risk" && request.method === "POST") {
const body = (await readJsonRequest(request)) as {
changedFiles?: Array<{ path: string; additions?: number; deletions?: number }>;
description?: string;
tests?: string[];
testFiles?: string[];
};
response.end(JSON.stringify(slopRiskFixture(body)));
return;
}
// #784 maintainer controls (agent approval queue + kill-switch).
if (request.url === "/v1/repos/owner/repo/agent/pending-actions" && request.method === "GET") {
response.end(JSON.stringify({ repoFullName: "owner/repo", pendingActions: [{ id: "pa-1", actionClass: "merge", pullNumber: 7, reason: "clean", status: "pending" }] }));
Expand Down Expand Up @@ -438,3 +448,29 @@ export function lintPrTextFixture(input: { commitMessages?: string[]; prBody?: s
],
};
}

export function slopRiskFixture(input: {
changedFiles?: Array<{ path: string; additions?: number; deletions?: number }>;
description?: string;
tests?: string[];
testFiles?: string[];
} = {}) {
const changedFiles = input.changedFiles ?? [];
const hasCodeChange = changedFiles.some((file) => !file.path.includes(".test."));
const hasTestEvidence = changedFiles.some((file) => file.path.includes(".test.")) || (input.testFiles?.length ?? 0) > 0 || (input.tests?.length ?? 0) > 0;
const emptyDescription = !input.description?.trim();
const elevated = hasCodeChange && (!hasTestEvidence || emptyDescription);
const slopRisk = elevated ? 45 : 0;
const findings =
elevated && emptyDescription
? [{ code: "empty_description", title: "Empty PR description", severity: "warning", detail: "Add a specific summary of what changed and why." }]
: elevated
? [{ code: "missing_test_evidence", title: "Missing test evidence", severity: "warning", detail: "Add or update tests for the changed behavior." }]
: [];
return {
slopRisk,
band: slopRisk <= 0 ? "clean" : slopRisk < 25 ? "low" : slopRisk < 60 ? "elevated" : "high",
findings,
rubric: "Fixture slop rubric.",
};
}
Loading