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 @@ -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 <run-id> --json
Expand Down
49 changes: 48 additions & 1 deletion packages/gittensory-mcp/bin/gittensory-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -1440,6 +1442,43 @@ async function runCli(args) {
writeBranchAnalysisCli(result, command);
}

function printLintPrTextHelp() {
process.stdout.write(
[
"Usage: gittensory-mcp lint-pr-text [--commit <message>]... [--body <text>] [--body-file <path>] [--linked-issue <number>] [--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 <github-login> or set GITTENSORY_LOGIN.");
Expand Down Expand Up @@ -1826,6 +1865,7 @@ function printHelp() {
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 lint-pr-text [--commit <message>]... [--body <text>] [--body-file <path>] [--linked-issue <number>] [--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 @@ -1880,7 +1920,7 @@ Use --profile <name> 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") {
Expand Down Expand Up @@ -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);
Expand Down
95 changes: 95 additions & 0 deletions test/unit/mcp-cli-lint-pr-text.test.ts
Original file line number Diff line number Diff line change
@@ -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`\?/);
});
});
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 @@ -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" }] }));
Expand Down Expand Up @@ -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}.`,
},
],
};
}
Loading