diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index d15c474bd1..c71bc77b80 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -109,8 +109,10 @@ const currentBranchShape = { .array( z.object({ command: z.string().min(1), - status: z.enum(["passed", "failed", "not_run"]), + status: z.enum(["passed", "failed", "not_run", "skipped", "focused", "unknown"]), summary: z.string().optional(), + durationMs: z.number().int().min(0).optional(), + exitCode: z.number().int().min(0).optional(), }), ) .optional(), @@ -624,7 +626,7 @@ Source upload remains disabled. function parseOptions(args) { const options = {}; - const repeatable = new Set(["label", "issue", "validation", "validationCommand", "validationStatus", "validationSummary", "scenarioNote"]); + const repeatable = new Set(["label", "issue", "validation", "validationCommand", "validationStatus", "validationSummary", "validationDuration", "scenarioNote"]); for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--json") { @@ -908,29 +910,58 @@ function getApiToken() { } function validationFromOptions(options) { - const direct = (options.validation ?? []).map((entry) => { - const [statusOrCommand, commandOrSummary, ...summaryParts] = String(entry).split("|"); - const status = isValidationStatus(statusOrCommand) ? statusOrCommand : "not_run"; - const command = isValidationStatus(statusOrCommand) ? commandOrSummary : statusOrCommand; - return stripUndefined({ - command: command?.trim(), - status, - summary: summaryParts.join("|").trim() || (isValidationStatus(statusOrCommand) ? undefined : commandOrSummary?.trim()), - }); - }); + const direct = (options.validation ?? []).map(parseValidationEntry); const commands = options.validationCommand ?? []; const statuses = options.validationStatus ?? []; const summaries = options.validationSummary ?? []; + const durations = options.validationDuration ?? []; const expanded = commands.map((command, index) => - stripUndefined({ + validationEntry({ command, - status: isValidationStatus(statuses[index]) ? statuses[index] : "not_run", - summary: summaries[index], + statusText: statuses[index], + summaryText: summaries[index], + durationText: durations[index], }), ); return [...direct, ...expanded].filter((entry) => typeof entry.command === "string" && entry.command.length > 0); } +function parseValidationEntry(entry) { + const parts = String(entry ?? "").split("|").map((part) => part.trim()); + const explicitStatus = normalizeValidationStatus(parts[0]); + const command = explicitStatus ? parts[1] : parts[0]; + const rest = explicitStatus ? parts.slice(2) : parts.slice(1); + const inferredStatusText = !explicitStatus && isValidationStatusLike(rest[0]) ? rest[0] : undefined; + const detailParts = inferredStatusText ? rest.slice(1) : rest; + const durationMs = parseDurationMs(detailParts[0]); + const summaryParts = durationMs !== undefined ? detailParts.slice(1) : detailParts; + return validationEntry({ + command, + statusText: explicitStatus ?? inferredStatusText, + summaryText: summaryParts.join("|"), + durationMs, + }); +} + +function validationEntry({ command, statusText, summaryText, durationText, durationMs }) { + const statusSource = nonEmptyString(statusText); + const summarySource = statusSource ? undefined : nonEmptyString(summaryText); + const exitCode = + inferValidationExitCode(statusSource, { allowBareCode: true, allowGenericStatus: true }) ?? + inferValidationExitCode(summarySource, { allowBareCode: false, allowGenericStatus: false }); + const status = + normalizeValidationStatus(statusSource) ?? + normalizeSummaryValidationStatus(summarySource) ?? + (exitCode !== undefined ? (exitCode === 0 ? "passed" : "failed") : "not_run"); + return stripUndefined({ + command: sanitizeValidationText(command, 160), + status, + summary: sanitizeValidationText(summaryText), + durationMs: durationMs ?? parseDurationMs(durationText), + exitCode, + }); +} + function optionalInteger(value) { if (value === undefined || value === true) return undefined; const parsed = Number(value); @@ -944,7 +975,84 @@ function optionalNumber(value) { } function isValidationStatus(value) { - return value === "passed" || value === "failed" || value === "not_run"; + return Boolean(normalizeValidationStatus(value)); +} + +function normalizeValidationStatus(value) { + const text = String(value ?? "").trim().toLowerCase().replace(/[-\s]+/g, "_"); + if (["passed", "pass", "success", "ok", "exit_0", "0"].includes(text)) return "passed"; + if (["failed", "fail", "failure", "error", "nonzero", "non_zero"].includes(text) || /^exit_[1-9]\d*$/.test(text) || /^[1-9]\d*$/.test(text)) return "failed"; + if (["not_run", "notrun", "not_ran", "pending"].includes(text)) return "not_run"; + if (["skipped", "skip"].includes(text)) return "skipped"; + if (["focused", "focus"].includes(text)) return "focused"; + if (["unknown", "unclear"].includes(text)) return "unknown"; + return undefined; +} + +function isValidationStatusLike(value) { + return Boolean( + normalizeValidationStatus(value) ?? + inferValidationExitCode(value, { allowBareCode: true, allowGenericStatus: true }), + ); +} + +function inferValidationExitCode(value, options = {}) { + const text = String(value ?? "").trim().toLowerCase(); + const allowBareCode = options.allowBareCode === true; + const allowGenericStatus = options.allowGenericStatus === true; + if (allowBareCode && /^\d{1,3}$/.test(text)) return Number(text); + const processExitPattern = /\b(?:exit(?:ed)?(?:\s+(?:code|status))?|exitcode|process\s+(?:exit(?:ed)?|status|code)|command\s+(?:exit(?:ed)?|status|code)|shell\s+(?:exit(?:ed)?|status|code))[\s:_-]*(\d{1,3})\b/; + const genericStatusPattern = /^(?:status|code)[\s:_-]*(\d{1,3})\b/; + const match = text.match(processExitPattern) ?? (allowGenericStatus ? text.match(genericStatusPattern) : null); + if (match) return Number(match[1]); + if (!allowBareCode && /^\d{1,3}$/.test(text)) return undefined; + const status = normalizeValidationStatus(text); + if (status === "passed" || status === "focused") return 0; + if (status === "failed") return 1; + return undefined; +} + +function normalizeSummaryValidationStatus(value) { + const text = nonEmptyString(value); + if (!text || /^\d{1,3}$/.test(text)) return undefined; + return normalizeValidationStatus(text); +} + +function nonEmptyString(value) { + const text = String(value ?? "").trim(); + return text ? text : undefined; +} + +function parseDurationMs(value) { + const text = String(value ?? "").trim().toLowerCase(); + const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|s|sec|secs|m|min|mins)?$/); + if (!match) return undefined; + const amount = Number(match[1]); + if (!Number.isFinite(amount)) return undefined; + const unit = match[2] ?? "ms"; + const multiplier = unit.startsWith("m") && unit !== "ms" ? 60000 : unit.startsWith("s") ? 1000 : 1; + return Math.round(amount * multiplier); +} + +function sanitizeValidationText(value, maxLength = 240) { + const text = String(value ?? "").replace(/[\r\n\t]+/g, " ").trim(); + if (!text) return undefined; + const redacted = redactPrivateValidationMetrics(redactLocalValidationPaths(text)); + return redacted.length <= maxLength ? redacted : `${redacted.slice(0, maxLength - 3)}...`; +} + +function redactLocalValidationPaths(text) { + const pathSegment = "[^\\\\/\\s\"'`,;)]+(?:\\s+[^\\\\/\\s\"'`,;)]+)*(?=[\\\\/])"; + const pathTail = "[^\\\\/\\s\"'`,;)]+"; + const localPathPattern = new RegExp(`(^|[\\s"'\\\`=])((?:~[\\\\/]|[A-Za-z]:[\\\\/]|/)(?:${pathSegment}[\\\\/])*${pathTail})`, "g"); + return text.replace(localPathPattern, (_, prefix) => `${prefix}`); +} + +function redactPrivateValidationMetrics(text) { + return text.replace( + /\b(?:wallet|hotkey|coldkey|mnemonic|raw[-_\s]?trust|private[-_\s]?reviewability|trust[-_\s]?score)\b(?:\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s"'`,;)]+))?/gi, + "[redacted]", + ); } function clientSnippet(client, command) { diff --git a/src/api/routes.ts b/src/api/routes.ts index f14f12ac4a..61e7735b4a 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -144,8 +144,10 @@ const localBranchChangedFileSchema = z const localBranchValidationSchema = z .object({ command: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS), - status: z.enum(["passed", "failed", "not_run"]), + status: z.enum(["passed", "failed", "not_run", "skipped", "focused", "unknown"]), summary: z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS).optional(), + durationMs: z.number().int().min(0).optional(), + exitCode: z.number().int().min(0).optional(), }) .strict(); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index bcf3164680..0542615b8c 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -137,8 +137,10 @@ const localBranchAnalysisShape = { z .object({ command: z.string().min(1), - status: z.enum(["passed", "failed", "not_run"]), + status: z.enum(["passed", "failed", "not_run", "skipped", "focused", "unknown"]), summary: z.string().optional(), + durationMs: z.number().int().min(0).optional(), + exitCode: z.number().int().min(0).optional(), }) .strict(), ) diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 0eccc0eadc..0bc37d2f7e 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -1299,7 +1299,15 @@ export const LocalBranchAnalysisSchema = z passed: z.number(), failed: z.number(), notRun: z.number(), - commands: z.array(z.object({ command: z.string(), status: z.enum(["passed", "failed", "not_run"]), summary: z.string().optional() })), + commands: z.array( + z.object({ + command: z.string(), + status: z.enum(["passed", "failed", "not_run", "skipped", "focused", "unknown"]), + summary: z.string().optional(), + durationMs: z.number().optional(), + exitCode: z.number().optional(), + }), + ), }), publicSafeWarnings: z.array(z.string()), }), diff --git a/src/signals/local-branch.ts b/src/signals/local-branch.ts index 8f6b61ebc1..5b39a42a38 100644 --- a/src/signals/local-branch.ts +++ b/src/signals/local-branch.ts @@ -27,8 +27,10 @@ export type LocalBranchChangedFile = { export type LocalBranchValidation = { command: string; - status: "passed" | "failed" | "not_run"; + status: "passed" | "failed" | "not_run" | "skipped" | "focused" | "unknown"; summary?: string | undefined; + durationMs?: number | undefined; + exitCode?: number | undefined; }; export type LocalBranchScorer = { @@ -640,7 +642,7 @@ function buildPublicSafePrPacket(args: { ); const validationLines = args.validationSummary.commands.length > 0 - ? args.validationSummary.commands.map((entry) => `- ${entry.status}: ${entry.command}${entry.summary ? ` (${entry.summary})` : ""}`) + ? args.validationSummary.commands.map((entry) => `- ${entry.status}: ${entry.command}${entry.durationMs !== undefined ? ` [${entry.durationMs}ms]` : ""}${entry.summary ? ` (${entry.summary})` : ""}`) : ["- Not supplied yet."]; const bodySections = [ { @@ -699,16 +701,16 @@ function renderPrPacketMarkdown(title: string, sections: Array<{ heading: string function summarizeValidation(validation: LocalBranchValidation[]): LocalBranchAnalysis["prPacket"]["validationSummary"] { return { - passed: validation.filter((entry) => entry.status === "passed").length, + passed: validation.filter((entry) => entry.status === "passed" || entry.status === "focused").length, failed: validation.filter((entry) => entry.status === "failed").length, - notRun: validation.filter((entry) => entry.status === "not_run").length, + notRun: validation.filter((entry) => entry.status === "not_run" || entry.status === "skipped" || entry.status === "unknown").length, commands: validation, }; } function validationEvidence(validation: LocalBranchValidation[] | undefined): string[] { return (validation ?? []) - .filter((entry) => entry.status === "passed") + .filter((entry) => entry.status === "passed" || entry.status === "focused") .map((entry) => entry.command); } diff --git a/test/unit/local-branch.test.ts b/test/unit/local-branch.test.ts index 81863cb06e..448ac22d8c 100644 --- a/test/unit/local-branch.test.ts +++ b/test/unit/local-branch.test.ts @@ -294,6 +294,35 @@ describe("local branch analysis", () => { expect(analysis.recommendedRerunCondition).toMatch(/git fetch origin/i); }); + it("treats focused validation as evidence and failed validation as actionable", () => { + const analysis = buildLocalBranchAnalysis({ + input: { + login: "oktofeesh1", + repoFullName: repo.fullName, + body: "Fixes #7", + changedFiles: [{ path: "internal/entity/model.go", additions: 10, deletions: 2, status: "modified" }], + validation: [ + { command: "go test ./internal/entity", status: "focused", durationMs: 1240, exitCode: 0, summary: "focused regression passed" }, + { command: "npm run lint", status: "failed", durationMs: 2000, exitCode: 1, summary: "raw_trust=0.4 /Users/example/log.txt" }, + { command: "npm run e2e", status: "skipped", summary: "not relevant for this fixture" }, + ], + }, + repo, + issues: [{ repoFullName: repo.fullName, number: 7, title: "Entity model edge case", state: "open", labels: ["bug"], linkedPrs: [] }], + pullRequests: [], + profile, + outcomeHistory, + scoringSnapshot, + scoringProfile, + }); + + expect(analysis.prPacket.validationSummary).toMatchObject({ passed: 1, failed: 1, notRun: 1 }); + expect(analysis.preflight.findings.map((finding) => finding.code)).not.toContain("missing_test_evidence"); + expect(analysis.localFindings).toEqual(expect.arrayContaining([expect.objectContaining({ code: "failed_local_validation" })])); + expect(analysis.prPacket.markdown).toContain("- focused: go test ./internal/entity [1240ms] (focused regression passed)"); + expect(analysis.prPacket.markdown).not.toMatch(/raw_trust|\/Users\/example/i); + }); + it("includes public-safe overlap caution and hides local absolute paths", () => { const analysis = buildLocalBranchAnalysis({ input: { diff --git a/test/unit/mcp-cli.test.ts b/test/unit/mcp-cli.test.ts index c21fde4b50..92db7cd002 100644 --- a/test/unit/mcp-cli.test.ts +++ b/test/unit/mcp-cli.test.ts @@ -1,5 +1,5 @@ import { execFile, execFileSync } from "node:child_process"; -import { createServer, type Server } from "node:http"; +import { createServer, type IncomingMessage, type Server } from "node:http"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -345,6 +345,158 @@ describe("gittensory-mcp CLI", () => { } }, 10000); + it("sends bounded structured validation summaries without local logs", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + git(tempDir, "init"); + git(tempDir, "config", "user.email", "test@example.com"); + git(tempDir, "config", "user.name", "Gittensory Test"); + git(tempDir, "config", "commit.gpgsign", "false"); + git(tempDir, "remote", "add", "origin", "git@github.com:JSONbored/gittensory.git"); + writeFileSync(join(tempDir, "README.md"), "fixture\n"); + git(tempDir, "add", "README.md"); + git(tempDir, "commit", "-m", "initial commit"); + const requests: unknown[] = []; + const url = await startFixtureServer({ onPacketRequest: (body) => requests.push(body) }); + await runAsync( + [ + "agent", + "packet", + "--login", + "oktofeesh1", + "--cwd", + tempDir, + "--base", + "HEAD", + "--validation", + "focused|npm run test:unit|1234ms|unit passed raw_trust=0.4 /Users/example/log.txt", + "--validation-command", + "npm run lint", + "--validation-status", + "exit code 1", + "--validation-duration", + "2s", + "--validation-summary", + "lint failed at C:/Users/alice/raw.log and /tmp/raw.log", + "--json", + ], + { + GITTENSORY_API_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + }, + ); + + const packet = requests[0] as { validation: Array<{ command: string; status: string; durationMs?: number; exitCode?: number; summary?: string }> }; + expect(packet.validation).toEqual( + expect.arrayContaining([ + expect.objectContaining({ command: "npm run test:unit", status: "focused", durationMs: 1234, exitCode: 0 }), + expect.objectContaining({ command: "npm run lint", status: "failed", durationMs: 2000, exitCode: 1 }), + ]), + ); + expect(JSON.stringify(packet.validation)).not.toMatch(/raw_trust|\/Users\/example|\/tmp\/raw/i); + expect(JSON.stringify(packet.validation)).not.toMatch(/C:\/Users|alice/i); + }); + + it("classifies nonzero validation status phrases as failed", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + git(tempDir, "init"); + git(tempDir, "config", "user.email", "test@example.com"); + git(tempDir, "config", "user.name", "Gittensory Test"); + git(tempDir, "config", "commit.gpgsign", "false"); + git(tempDir, "remote", "add", "origin", "git@github.com:JSONbored/gittensory.git"); + writeFileSync(join(tempDir, "README.md"), "fixture\n"); + git(tempDir, "add", "README.md"); + git(tempDir, "commit", "-m", "initial commit"); + const requests: unknown[] = []; + const url = await startFixtureServer({ onPacketRequest: (body) => requests.push(body) }); + await runAsync( + [ + "agent", + "packet", + "--login", + "oktofeesh1", + "--cwd", + tempDir, + "--base", + "HEAD", + "--validation-command", + "npm test", + "--validation-status", + "status: 2", + "--json", + ], + { + GITTENSORY_API_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + }, + ); + + const packet = requests[0] as { validation: Array<{ command: string; status: string; exitCode?: number }> }; + expect(packet.validation).toEqual(expect.arrayContaining([expect.objectContaining({ command: "npm test", status: "failed", exitCode: 2 })])); + }); + + it("classifies bare nonzero validation statuses as failed", async () => { + tempDir = createPacketRepo(); + const validation = await capturePacketValidation(tempDir, [ + "--validation", + "npm test|1", + "--validation-command", + "npm run lint", + "--validation-status", + "2", + ]); + + expect(validation).toEqual( + expect.arrayContaining([ + expect.objectContaining({ command: "npm test", status: "failed", exitCode: 1 }), + expect.objectContaining({ command: "npm run lint", status: "failed", exitCode: 2 }), + ]), + ); + }); + + it("does not infer HTTP status summaries as process exit codes", async () => { + tempDir = createPacketRepo(); + const validation = await capturePacketValidation(tempDir, ["--validation", "npm run e2e|HTTP status 200 OK"]); + + expect(validation).toEqual( + expect.arrayContaining([expect.objectContaining({ command: "npm run e2e", status: "not_run", summary: "HTTP status 200 OK" })]), + ); + expect(validation[0]).not.toHaveProperty("exitCode"); + }); + + it("infers expanded validation failures from summaries when status is absent", async () => { + tempDir = createPacketRepo(); + const validation = await capturePacketValidation(tempDir, ["--validation-command", "npm test", "--validation-summary", "exit code 1"]); + + expect(validation).toEqual( + expect.arrayContaining([expect.objectContaining({ command: "npm test", status: "failed", exitCode: 1, summary: "exit code 1" })]), + ); + }); + + it("redacts space-containing local paths and private metric values from validation text", async () => { + tempDir = createPacketRepo(); + const validation = await capturePacketValidation(tempDir, [ + "--validation-command", + "node /Users/Alice Smith/project/run.js", + "--validation-status", + "failed", + "--validation-summary", + "log=C:\\Users\\Alice Smith\\raw.log raw_trust=0.72 private_reviewability=ready", + ]); + + expect(validation).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + command: "node ", + status: "failed", + summary: "log= [redacted] [redacted]", + }), + ]), + ); + expect(JSON.stringify(validation)).not.toMatch(/Alice Smith|Smith[\\/]|raw\.log|0\.72|ready|\[redacted\]=/); + }); + it("rejects unsupported client snippets", () => { expect(() => run(["init-client", "--print", "other"])).toThrow(/Unsupported client/); }); @@ -392,8 +544,35 @@ function git(cwd: string, ...args: string[]) { execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); } -async function startFixtureServer(options: { latestVersion?: string; minMcpVersion?: string; npmStatus?: number; packetMarkdown?: string } = {}) { - server = createServer((request, response) => { +function createPacketRepo() { + const cwd = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + git(cwd, "init"); + git(cwd, "config", "user.email", "test@example.com"); + git(cwd, "config", "user.name", "Gittensory Test"); + git(cwd, "config", "commit.gpgsign", "false"); + git(cwd, "remote", "add", "origin", "git@github.com:JSONbored/gittensory.git"); + writeFileSync(join(cwd, "README.md"), "fixture\n"); + git(cwd, "add", "README.md"); + git(cwd, "commit", "-m", "initial commit"); + return cwd; +} + +async function capturePacketValidation(tempDir: string, validationArgs: string[]) { + const requests: unknown[] = []; + const url = await startFixtureServer({ onPacketRequest: (body) => requests.push(body) }); + await runAsync( + ["agent", "packet", "--login", "oktofeesh1", "--cwd", tempDir, "--base", "HEAD", ...validationArgs, "--json"], + { + GITTENSORY_API_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + }, + ); + return (requests[0] as { validation: Array<{ command: string; status: string; exitCode?: number; summary?: string }> }).validation; +} + +async function startFixtureServer(options: { latestVersion?: string; minMcpVersion?: string; npmStatus?: number; packetMarkdown?: string; onPacketRequest?: (body: unknown) => void } = {}) { + server = createServer(async (request, response) => { response.setHeader("content-type", "application/json"); if (request.url && request.url.includes("gittensory-mcp/latest")) { if (options.npmStatus && options.npmStatus >= 400) { @@ -421,6 +600,7 @@ async function startFixtureServer(options: { latestVersion?: string; minMcpVersi return; } if (request.url === "/v1/agent/prepare-pr-packet" && request.method === "POST") { + options.onPacketRequest?.(await readJsonRequest(request)); response.end(JSON.stringify(agentPacketFixture(options.packetMarkdown))); return; } @@ -433,6 +613,20 @@ async function startFixtureServer(options: { latestVersion?: string; minMcpVersi return `http://127.0.0.1:${address.port}`; } +function readJsonRequest(request: IncomingMessage) { + return new Promise((resolve) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}")); + } catch { + resolve({}); + } + }); + }); +} + function agentPacketFixture(markdown = "# Public-safe PR packet\n\n## Linked Context\n- Closes #39\n\n## Validation\n- passed: npm test (packet tests passed)\n") { return { ...agentFixture(),