diff --git a/packages/gittensory-mcp/README.md b/packages/gittensory-mcp/README.md index a6da3b9656..708731d233 100644 --- a/packages/gittensory-mcp/README.md +++ b/packages/gittensory-mcp/README.md @@ -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 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 --json diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 280189ec7c..5c85b34476 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -40,6 +40,7 @@ const CLI_COMMAND_SPEC = { "analyze-branch": [], preflight: [], "lint-pr-text": [], + "issue-slop": [], profile: ["list", "create", "switch", "remove"], cache: ["status", "clear"], agent: ["plan", "status", "explain", "packet"], @@ -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 === "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") { @@ -1479,6 +1481,40 @@ async function lintPrTextCli(args) { for (const fix of payload.fixes ?? []) process.stdout.write(`- ${fix}\n`); } +function printIssueSlopHelp() { + process.stdout.write( + [ + "Usage: gittensory-mcp issue-slop [--title ] [--body ] [--body-file ] [--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 or set GITTENSORY_LOGIN."); @@ -1866,6 +1902,7 @@ function printHelp() { 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 lint-pr-text [--commit ]... [--body ] [--body-file ] [--linked-issue ] [--json] + gittensory-mcp issue-slop [--title ] [--body ] [--body-file ] [--json] gittensory-mcp agent plan --login [--repo owner/repo] [--json] gittensory-mcp agent status [--json] gittensory-mcp agent explain [--json] @@ -2712,6 +2749,13 @@ function parsePositiveIntegerOption(value, flagName) { 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); diff --git a/test/unit/mcp-cli-issue-slop.test.ts b/test/unit/mcp-cli-issue-slop.test.ts new file mode 100644 index 0000000000..ad0ce519dd --- /dev/null +++ b/test/unit/mcp-cli-issue-slop.test.ts @@ -0,0 +1,88 @@ +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 — 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`\?/); + }); +}); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index 1d686e9b98..89bc3a3046 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -234,6 +234,11 @@ export async function startFixtureServer( response.end(JSON.stringify(lintPrTextFixture(body))); 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" }] })); @@ -438,3 +443,24 @@ export function lintPrTextFixture(input: { commitMessages?: string[]; prBody?: s ], }; } + +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.", + }; +}