Skip to content
Closed
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 @@ -55,9 +55,10 @@
gittensory-mcp completion powershell
gittensory-mcp decision-pack --login jsonbored --json
gittensory-mcp repo-decision --login jsonbored --repo we-promise/sure --json
gittensory-mcp analyze-branch --login jsonbored --json

Check notice on line 58 in packages/gittensory-mcp/README.md

View check run for this annotation

Loopover ORB / Gittensory Context

Review queue is busy

This repo has a busy review queue in the local Gittensory cache.
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 issue-slop --title "Add retry handling" --body "Widget reconnects fail without bounded retries." --json
gittensory-mcp agent plan --login jsonbored --json
gittensory-mcp agent packet --login jsonbored --json
gittensory-mcp agent status <run-id> --json
Expand Down
44 changes: 44 additions & 0 deletions packages/gittensory-mcp/bin/gittensory-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@
"init-client": [],
"decision-pack": [],
"repo-decision": [],
"analyze-branch": [],

Check notice on line 40 in packages/gittensory-mcp/bin/gittensory-mcp.js

View check run for this annotation

Loopover ORB / Gittensory Context

Review queue is busy

This repo has a busy review queue in the local Gittensory cache.
preflight: [],
"lint-pr-text": [],
"issue-slop": [],
profile: ["list", "create", "switch", "remove"],
cache: ["status", "clear"],
agent: ["plan", "status", "explain", "packet"],
Expand Down Expand Up @@ -1404,6 +1405,7 @@
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 === "issue-slop") return issueSlopCli(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,40 @@
for (const fix of payload.fixes ?? []) process.stdout.write(`- ${fix}\n`);
}

function printIssueSlopHelp() {
process.stdout.write(
[
"Usage: gittensory-mcp issue-slop [--title <text>] [--body <text>] [--body-file <path>] [--json]",
"",
"Assess deterministic issue slop risk from an issue title and body alone.",
"Mirrors the gittensory_check_issue_slop MCP tool and POST /v1/lint/issue-slop. Advisory only; no source upload.",
"",
"Pass --json for machine-readable output.",
].join("\n") + "\n",
);
}

async function issueSlopCli(args) {
if (!args.length || args[0] === "--help" || args[0] === "help") return printIssueSlopHelp();
const options = parseOptions(args);
let body = normalizeOptionalStringOption(options.body);
if (options.bodyFile) {
if (!existsSync(options.bodyFile)) throw new Error(`Body file not found: ${options.bodyFile}`);
body = readFileSync(options.bodyFile, "utf8");
}
const title = normalizeOptionalStringOption(options.title);
const payload = await apiPost("/v1/lint/issue-slop", {
...(title !== undefined ? { title } : {}),
...(body !== undefined ? { body } : {}),
});
if (options.json) {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
return;
}
process.stdout.write(`Issue 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 +1902,7 @@
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 issue-slop [--title <text>] [--body <text>] [--body-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 @@ -2712,6 +2749,13 @@
return parsed;
}

function normalizeOptionalStringOption(value) {
if (value === undefined) return undefined;
if (value === true) return "";
if (typeof value === "string") return value;
throw new Error("Expected a string flag value.");
}

function optionalNumber(value) {
if (value === undefined || value === true) return undefined;
const parsed = Number(value);
Expand Down
88 changes: 88 additions & 0 deletions test/unit/mcp-cli-issue-slop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";

Check notice on line 1 in test/unit/mcp-cli-issue-slop.test.ts

View check run for this annotation

Loopover ORB / Gittensory Context

Review queue is busy

This repo has a busy review queue in the local Gittensory cache.
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 — issue-slop", () => {
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 issue slop via the API and prints plain or json output", async () => {
const e = await env();
const plain = await runAsync(
[
"issue-slop",
"--title",
"Add retry handling for widget reconnects",
"--body",
"The widget client drops transient failures without retrying. Add bounded retries with jitter and cover the reconnect path in unit tests.",
],
e,
);
expect(plain).toMatch(/Issue slop risk: 0 \(clean\)/);

const json = JSON.parse(
await runAsync(
[
"issue-slop",
"--title",
"Add retry handling for widget reconnects",
"--body",
"The widget client drops transient failures without retrying.",
"--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 issue bodies from --body-file", async () => {
const e = await env();
const bodyPath = join(tempDir!, "issue-body.md");
writeFileSync(bodyPath, "Retry widget reconnects with bounded backoff and add unit coverage.", "utf8");
const json = JSON.parse(await runAsync(["issue-slop", "--title", "Improve widget reconnects", "--body-file", bodyPath, "--json"], e)) as {
band: string;
};
expect(json.band).toBe("clean");
});

it("surfaces elevated issue slop findings in plain output", async () => {
const e = await env();
const json = JSON.parse(await runAsync(["issue-slop", "--title", "Add retries", "--body", "", "--json"], e)) as {
slopRisk: number;
band: string;
findings: Array<{ title: string }>;
};
expect(json).toMatchObject({ slopRisk: 30, band: "elevated" });
const plain = await runAsync(["issue-slop", "--title", "Add retries", "--body", ""], e);
expect(plain).toMatch(/Issue slop risk: 30 \(elevated\)/);
expect(plain).toMatch(/Issue has no description/);
});

it("validates inputs and prints help", async () => {
const e = await env();
await expect(runAsync(["issue-slop", "--body-file", "/tmp/missing-gittensory-issue-body.md"], e)).rejects.toThrow(/Body file not found/);
const help = run(["issue-slop", "--help"]);
expect(help).toMatch(/Usage: gittensory-mcp issue-slop/);
expect(help).toMatch(/gittensory_check_issue_slop/);
expect(help).toMatch(/--body-file/);
});

it("suggests issue-slop for close typos", () => {
expect(() => run(["issue-slopx"])).toThrow(/Did you mean `issue-slop`\?/);
});
});
26 changes: 26 additions & 0 deletions test/unit/support/mcp-cli-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,9 +231,14 @@
}
if (request.url === "/v1/lint/pr-text" && request.method === "POST") {
const body = (await readJsonRequest(request)) as { commitMessages?: string[]; prBody?: string; linkedIssue?: number };
response.end(JSON.stringify(lintPrTextFixture(body)));

Check notice on line 234 in test/unit/support/mcp-cli-harness.ts

View check run for this annotation

Loopover ORB / Gittensory Context

Review queue is busy

This repo has a busy review queue in the local Gittensory cache.
return;
}
if (request.url === "/v1/lint/issue-slop" && request.method === "POST") {
const body = (await readJsonRequest(request)) as { title?: string; body?: string };
response.end(JSON.stringify(issueSlopFixture(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 +443,24 @@
],
};
}

export function issueSlopFixture(input: { title?: string; body?: string } = {}) {
const bodyText = typeof input.body === "string" ? input.body : "";
const emptyBody = !bodyText.trim();
const unfilledTemplate = !emptyBody && /##\s*summary/i.test(bodyText) && /-\s*\[?\s*\]?\s*$/m.test(bodyText);
const titleOnly = !emptyBody && !unfilledTemplate && input.title && bodyText.trim().toLowerCase() === input.title.trim().toLowerCase();
const slopRisk = emptyBody ? 30 : unfilledTemplate ? 40 : titleOnly ? 25 : 0;
const findings = emptyBody
? [{ code: "empty_issue_body", title: "Issue has no description", severity: "warning", detail: "This issue was opened with an empty body." }]
: unfilledTemplate
? [{ code: "unfilled_issue_template", title: "Issue template left unfilled", severity: "warning", detail: "Fill in the issue template sections with concrete detail." }]
: titleOnly
? [{ code: "title_restatement", title: "Issue body only restates the title", severity: "warning", detail: "Add specific detail beyond the title." }]
: [];
return {
slopRisk,
band: slopRisk <= 0 ? "clean" : slopRisk < 25 ? "low" : slopRisk < 60 ? "elevated" : "high",
findings,
rubric: "Fixture issue slop rubric.",
};
}
Loading