From db39dcccb2ce1b9c6921fe8a8811f12173b015de Mon Sep 17 00:00:00 2001 From: jimcody1995 Date: Thu, 2 Jul 2026 16:47:05 +0000 Subject: [PATCH] feat(mcp): add lint-pr-text CLI for pre-push PR text checks Co-authored-by: Cursor --- packages/gittensory-mcp/README.md | 1 + packages/gittensory-mcp/bin/gittensory-mcp.js | 49 +++++++++- test/unit/mcp-cli-lint-pr-text.test.ts | 95 +++++++++++++++++++ test/unit/support/mcp-cli-harness.ts | 26 +++++ 4 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 test/unit/mcp-cli-lint-pr-text.test.ts diff --git a/packages/gittensory-mcp/README.md b/packages/gittensory-mcp/README.md index 45b542f701..a6da3b9656 100644 --- a/packages/gittensory-mcp/README.md +++ b/packages/gittensory-mcp/README.md @@ -57,6 +57,7 @@ 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 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 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 29d9290900..280189ec7c 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -39,6 +39,7 @@ const CLI_COMMAND_SPEC = { "repo-decision": [], "analyze-branch": [], preflight: [], + "lint-pr-text": [], profile: ["list", "create", "switch", "remove"], cache: ["status", "clear"], agent: ["plan", "status", "explain", "packet"], @@ -1402,6 +1403,7 @@ async function runCli(args) { if (command === "changelog") return changelog(options); 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 === "decision-pack") return decisionPackCli(options); if (command === "repo-decision") return repoDecisionCli(options); if (command !== "analyze-branch" && command !== "preflight") { @@ -1440,6 +1442,43 @@ async function runCli(args) { writeBranchAnalysisCli(result, command); } +function printLintPrTextHelp() { + process.stdout.write( + [ + "Usage: gittensory-mcp lint-pr-text [--commit ]... [--body ] [--body-file ] [--linked-issue ] [--json]", + "", + "Lint a commit message and PR body against the Gittensory traceability and Conventional Commit rubric.", + "Mirrors the gittensory_lint_pr_text MCP tool and POST /v1/lint/pr-text. No source upload.", + "", + "Pass --json for machine-readable output.", + ].join("\n") + "\n", + ); +} + +async function lintPrTextCli(args) { + if (!args.length || args[0] === "--help" || args[0] === "help") return printLintPrTextHelp(); + const options = parseOptions(args); + const commitMessages = Array.isArray(options.commit) ? options.commit : options.commit ? [options.commit] : undefined; + let prBody = options.body; + if (options.bodyFile) { + if (!existsSync(options.bodyFile)) throw new Error(`Body file not found: ${options.bodyFile}`); + prBody = readFileSync(options.bodyFile, "utf8"); + } + const linkedIssue = parsePositiveIntegerOption(options.linkedIssue, "--linked-issue"); + const payload = await apiPost("/v1/lint/pr-text", { + ...(commitMessages?.length ? { commitMessages } : {}), + ...(prBody !== undefined ? { prBody } : {}), + ...(linkedIssue !== undefined ? { linkedIssue } : {}), + }); + if (options.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return; + } + process.stdout.write(`PR text lint: ${payload.verdict} (score ${payload.score})\n`); + process.stdout.write(`${payload.summary}\n`); + for (const fix of payload.fixes ?? []) process.stdout.write(`- ${fix}\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."); @@ -1826,6 +1865,7 @@ function printHelp() { 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 lint-pr-text [--commit ]... [--body ] [--body-file ] [--linked-issue ] [--json] gittensory-mcp agent plan --login [--repo owner/repo] [--json] gittensory-mcp agent status [--json] gittensory-mcp agent explain [--json] @@ -1880,7 +1920,7 @@ Use --profile or GITTENSORY_PROFILE to run login, logout, whoami, status, function parseOptions(args) { const options = {}; - const repeatable = new Set(["label", "issue", "validation", "validationCommand", "validationStatus", "validationSummary", "validationDuration", "scenarioNote"]); + const repeatable = new Set(["label", "issue", "commit", "validation", "validationCommand", "validationStatus", "validationSummary", "validationDuration", "scenarioNote"]); for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--json") { @@ -2665,6 +2705,13 @@ function optionalInteger(value) { return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined; } +function parsePositiveIntegerOption(value, flagName) { + if (value === undefined) return undefined; + const parsed = optionalInteger(value); + if (parsed === undefined || parsed <= 0) throw new Error(`Pass ${flagName} as a positive integer.`); + return parsed; +} + function optionalNumber(value) { if (value === undefined || value === true) return undefined; const parsed = Number(value); diff --git a/test/unit/mcp-cli-lint-pr-text.test.ts b/test/unit/mcp-cli-lint-pr-text.test.ts new file mode 100644 index 0000000000..df60548bc9 --- /dev/null +++ b/test/unit/mcp-cli-lint-pr-text.test.ts @@ -0,0 +1,95 @@ +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 — lint-pr-text", () => { + 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("lints commit + body via the API and prints plain or json output", async () => { + const e = await env(); + const plain = await runAsync( + [ + "lint-pr-text", + "--commit", + "feat(mcp): add lint-pr-text cli", + "--body", + "Adds a shell wrapper for POST /v1/lint/pr-text. Validated with npm run test:ci.", + "--linked-issue", + "160", + ], + e, + ); + expect(plain).toMatch(/PR text lint: strong \(score 100\)/); + expect(plain).toMatch(/Fixture PR-text lint verdict: strong/); + + const json = JSON.parse( + await runAsync( + [ + "lint-pr-text", + "--commit", + "feat(mcp): add lint-pr-text cli", + "--body", + "Adds a shell wrapper for POST /v1/lint/pr-text.", + "--linked-issue", + "160", + "--json", + ], + e, + ), + ) as { verdict: string; score: number; fixes: string[] }; + expect(json).toMatchObject({ verdict: "strong", score: 100, fixes: [] }); + expect(JSON.stringify(json)).not.toMatch(/wallet|hotkey|reward|trust score/i); + }); + + it("reads PR bodies from --body-file and supports repeated --commit flags", async () => { + const e = await env(); + const bodyPath = join(tempDir!, "pr-body.md"); + writeFileSync(bodyPath, "Fixes #7\n\nValidated with npm test.", "utf8"); + const json = JSON.parse( + await runAsync( + ["lint-pr-text", "--commit", "fix(api): handle reconnect", "--commit", "chore: follow-up", "--body-file", bodyPath, "--linked-issue", "7", "--json"], + e, + ), + ) as { verdict: string; components: Array<{ key: string }> }; + expect(json.verdict).toBe("strong"); + expect(json.components[0]).toMatchObject({ key: "traceability" }); + }); + + it("surfaces weak verdicts and actionable fixes in plain output", async () => { + const e = await env(); + const out = await runAsync(["lint-pr-text", "--commit", "wip", "--json"], e); + const json = JSON.parse(out) as { verdict: string; fixes: string[] }; + expect(json.verdict).toBe("weak"); + const plain = await runAsync(["lint-pr-text", "--commit", "wip"], e); + expect(plain).toMatch(/PR text lint: weak/); + expect(plain).toMatch(/Conventional Commit subject/); + }); + + it("validates inputs and prints help", async () => { + const e = await env(); + await expect(runAsync(["lint-pr-text", "--linked-issue", "0"], e)).rejects.toThrow(/positive integer/); + await expect(runAsync(["lint-pr-text", "--body-file", "/tmp/missing-gittensory-pr-body.md"], e)).rejects.toThrow(/Body file not found/); + const help = run(["lint-pr-text", "--help"]); + expect(help).toMatch(/Usage: gittensory-mcp lint-pr-text/); + expect(help).toMatch(/gittensory_lint_pr_text/); + expect(help).toMatch(/--body-file/); + }); + + it("suggests lint-pr-text for close typos", () => { + expect(() => run(["lint-pr-txt"])).toThrow(/Did you mean `lint-pr-text`\?/); + }); +}); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index 0c58c02b6c..1d686e9b98 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -229,6 +229,11 @@ export async function startFixtureServer( response.end(JSON.stringify(options.localBranchAnalysis ?? localBranchAnalysisFixture())); return; } + 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))); + 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" }] })); @@ -412,3 +417,24 @@ export function agentFixture() { summary: "fixture", }; } + +export function lintPrTextFixture(input: { commitMessages?: string[]; prBody?: string; linkedIssue?: number } = {}) { + const weakCommit = (input.commitMessages ?? []).some((message) => /^wip$/i.test(message.trim())); + const missingTraceability = input.linkedIssue === undefined && !/no issue needed|no issue applies/i.test(input.prBody ?? ""); + const verdict = weakCommit || !input.prBody ? "weak" : missingTraceability ? "adequate" : "strong"; + return { + generatedAt: "2026-06-01T00:00:00.000Z", + verdict, + score: verdict === "strong" ? 100 : verdict === "adequate" ? 81 : 45, + summary: `Fixture PR-text lint verdict: ${verdict}.`, + fixes: verdict === "strong" ? [] : ["Use a Conventional Commit subject with a specific scope and summary."], + components: [ + { + key: "traceability", + label: "Traceability", + status: missingTraceability ? "weak" : "ok", + evidence: missingTraceability ? "No linked issue or no-issue rationale." : `Linked issue #${input.linkedIssue}.`, + }, + ], + }; +}