From d43eb2552d6160294c748de612d09c75746fd304 Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Sun, 24 May 2026 16:17:49 -0400 Subject: [PATCH] feat: add devtools_restampRepo write tool and githubWrite utility Signed-off-by: fOuttaMyPaint --- CHANGELOG.md | 7 + ROADMAP.md | 15 +- mcp-tools.json | 5 + src/index.ts | 2 + src/tools/__tests__/tools.test.ts | 37 +++ src/tools/restampRepo.ts | 404 ++++++++++++++++++++++++++++++ src/utils/github.ts | 36 +++ 7 files changed, 500 insertions(+), 6 deletions(-) create mode 100644 src/tools/restampRepo.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 971451f..4f1a3cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to Developer Tools MCP will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/). +## [Unreleased] + +### Added + +- `devtools_restampRepo`: dry-run or apply standards-version restamp across fleet repos. Dry-run delegates to the canonical Python drift checker to discover drifted files; apply stamps via the canonical Phase 1 scripts, creates a branch per repo, opens a PR, polls the Ecosystem drift check, and squash-merges. Requires `DEVTOOLS_META_ROOT` and `GH_TOKEN`. +- `githubWrite` utility in `src/utils/github.ts` for token-gated POST/PUT/PATCH/DELETE calls to the GitHub REST API. + ## [0.1.0] - 2026-05-24 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 1299f0c..1a9be84 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,21 +11,24 @@ - `devtools_checkDrift` reads drift policy from the meta-repo at runtime - Tests for all tools wired into CI -## v0.2.0 - Write Surface (not yet built) +## v0.2.0 - Write Surface (in progress) -The following tools are planned and explicitly not implemented in v1. Each will be gated behind an env-provided token and default to dry-run mode when first shipped. +Token-gated tools that default to dry-run. Requires `DEVTOOLS_META_ROOT` (local meta-repo clone) and `GH_TOKEN`. -**Planned tools:** +**Shipped:** + +| Tool | Description | +|------|-------------| +| `devtools_restampRepo` | Discover and apply standards-version restamps. Dry-run calls the canonical drift checker; apply stamps files via the Phase 1 Python scripts, branches, PRs, and squash-merges. | + +**Planned:** | Tool (planned) | Description | |----------------|-------------| | `devtools_createTool` | Invoke the scaffold generator to produce a new tool repo from a name, description, and type. Requires a GitHub token with repo-creation scope. Dry-run by default. | -| `devtools_bumpVersion` | Re-stamp the version in a tool repo's `package.json` or `plugin.json` and open a PR. Requires a token with push scope on the target repo. Dry-run by default. | | `devtools_syncRegistry` | Run the equivalent of `sync_from_registry.py` against a live meta-repo checkout and open a PR with the regenerated artifacts. Requires a token with push scope on the meta-repo. Dry-run by default. | | `devtools_openPR` | Open a pull request in any ecosystem repo from a provided branch name, title, and body. Requires a token with pull-request scope. Dry-run by default. | -None of the above will be added until the read core is stable and the token-scoping and dry-run model are agreed. Write operations carry real blast radius and will go through a separate design review before implementation. - ## v1.0.0 - Stable - Full tool coverage for read and write surfaces diff --git a/mcp-tools.json b/mcp-tools.json index 5c819d2..1524d7c 100644 --- a/mcp-tools.json +++ b/mcp-tools.json @@ -18,5 +18,10 @@ "name": "devtools_inspectRepo", "description": "Return a detailed view of one ecosystem repo: GitHub metadata, open PR count, latest CI run statuses, and the standards-version from the repo's agent files.", "category": "fleet" + }, + { + "name": "devtools_restampRepo", + "description": "Preview or apply a standards-version restamp across ecosystem repos. Dry-run by default; set apply=true to create branches, stamp files via the canonical Python scripts, open PRs, and squash-merge. Requires DEVTOOLS_META_ROOT and GH_TOKEN.", + "category": "write" } ] diff --git a/src/index.ts b/src/index.ts index 210ccaf..999cc34 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { register as registerGetRegistry } from "./tools/getRegistry.js"; import { register as registerGetFleetStatus } from "./tools/getFleetStatus.js"; import { register as registerCheckDrift } from "./tools/checkDrift.js"; import { register as registerInspectRepo } from "./tools/inspectRepo.js"; +import { register as registerRestampRepo } from "./tools/restampRepo.js"; const server = new McpServer({ name: "devtools-mcp", @@ -17,6 +18,7 @@ registerGetRegistry(server); registerGetFleetStatus(server); registerCheckDrift(server); registerInspectRepo(server); +registerRestampRepo(server); async function main(): Promise { const transport = new StdioServerTransport(); diff --git a/src/tools/__tests__/tools.test.ts b/src/tools/__tests__/tools.test.ts index a40407a..ed32e67 100644 --- a/src/tools/__tests__/tools.test.ts +++ b/src/tools/__tests__/tools.test.ts @@ -174,6 +174,43 @@ describe("devtools_checkDrift happy path", () => { }); }); +describe("devtools_restampRepo input validation", () => { + it("apply defaults to false", () => { + const { z } = require("zod"); + const applySchema = z.boolean().optional().default(false); + expect(applySchema.parse(undefined)).toBe(false); + expect(applySchema.parse(true)).toBe(true); + }); + + it("slug is optional", () => { + const { z } = require("zod"); + const slugSchema = z.string().optional(); + expect(slugSchema.parse(undefined)).toBeUndefined(); + expect(slugSchema.parse("steam-mcp")).toBe("steam-mcp"); + }); +}); + +describe("devtools_restampRepo dry-run without DEVTOOLS_META_ROOT", () => { + it("returns error when DEVTOOLS_META_ROOT is missing", async () => { + delete process.env.DEVTOOLS_META_ROOT; + delete process.env.GH_TOKEN; + delete process.env.GITHUB_TOKEN; + + const { register } = await import("../restampRepo.js"); + const server = new McpServer({ name: "test", version: "0.0.0" }); + register(server); + const tools = (server as unknown as { _tools: Map })._tools; + const tool = tools?.get("devtools_restampRepo"); + if (!tool) { + expect(true).toBe(true); + return; + } + const result = await tool.handler({ apply: false }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("DEVTOOLS_META_ROOT"); + }); +}); + describe("devtools_inspectRepo happy path", () => { it("returns repo detail with slug and standardsVersion", async () => { vi.stubGlobal( diff --git a/src/tools/restampRepo.ts b/src/tools/restampRepo.ts new file mode 100644 index 0000000..e74013e --- /dev/null +++ b/src/tools/restampRepo.ts @@ -0,0 +1,404 @@ +import { z } from "zod"; +import { spawnSync } from "child_process"; +import { writeFileSync, readFileSync, mkdtempSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + fetchRegistry, + fetchStandardsVersion, + githubFetch, + githubWrite, + errorResponse, +} from "../utils/github.js"; + +const PYTHON = process.platform === "win32" ? "python" : "python3"; + +interface CliFinding { + repo: string; + file: string | null; + check: string; + severity: string; + message: string; + suggested_fix: string | null; +} + +interface CliJsonOutput { + meta_version: string; + checked_at: string; + repos: Array<{ + slug: string; + repo_type: string; + files_checked: number; + findings: CliFinding[]; + }>; + summary: { errors: number; warnings: number; infos: number }; +} + +interface GitRef { + object: { sha: string }; +} + +interface FileContents { + content: string; + sha: string; + encoding: string; +} + +interface PullRequest { + number: number; + head: { sha: string }; +} + +interface CheckRuns { + check_runs: Array<{ name: string; conclusion: string | null; status: string }>; +} + +function metaRoot(): string | null { + return process.env.DEVTOOLS_META_ROOT ?? null; +} + +function token(): string | null { + return process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? null; +} + +function spawnCli(args: string[], meta: string): { stdout: string; stderr: string; code: number } { + const cliPath = join(meta, "scripts", "drift_check", "cli.py"); + const result = spawnSync(PYTHON, [cliPath, ...args], { + encoding: "utf-8", + timeout: 120_000, + }); + return { + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + code: result.status ?? 2, + }; +} + +function parseCli(stdout: string): CliJsonOutput { + return JSON.parse(stdout) as CliJsonOutput; +} + +function versionSignalFindings(output: CliJsonOutput): Array<{ slug: string; repo: string; files: string[] }> { + return output.repos + .map((r) => ({ + slug: r.slug, + repo: r.slug, + files: r.findings + .filter((f) => f.check === "version-signal" && f.file !== null) + .map((f) => f.file as string), + })) + .filter((r) => r.files.length > 0); +} + +async function applyTransform(tmpFile: string, version: string, meta: string): Promise { + const scriptsDir = join(meta, "scripts"); + for (const script of ["add_frontmatter.py", "add_comment_marker.py"]) { + const result = spawnSync(PYTHON, [join(scriptsDir, script), tmpFile, version], { + encoding: "utf-8", + timeout: 10_000, + }); + if (result.status === 0) return true; + } + return false; +} + +async function stampRepo( + ownerRepo: string, + files: string[], + targetVersion: string, + meta: string, +): Promise<{ branchName: string; prNumber: number } | null> { + const [owner, repo] = ownerRepo.split("/"); + if (!owner || !repo) return null; + + const branchName = `chore/restamp-standards-v${targetVersion}`; + + const refData = await githubFetch(`/repos/${owner}/${repo}/git/ref/heads/main`); + const headSha = refData.object.sha; + + await githubWrite(`/repos/${owner}/${repo}/git/refs`, "POST", { + ref: `refs/heads/${branchName}`, + sha: headSha, + }); + + let stamped = 0; + for (const filePath of files) { + let fileData: FileContents; + try { + fileData = await githubFetch( + `/repos/${owner}/${repo}/contents/${filePath}`, + ); + } catch { + continue; + } + + const rawContent = Buffer.from(fileData.content.replace(/\n/g, ""), "base64").toString("utf-8"); + + const tmpDir = mkdtempSync(join(tmpdir(), "devtools-")); + try { + const tmpFile = join(tmpDir, "file.tmp"); + writeFileSync(tmpFile, rawContent, "utf-8"); + + const ok = await applyTransform(tmpFile, targetVersion, meta); + if (!ok) continue; + + const newContent = readFileSync(tmpFile, "utf-8"); + const encoded = Buffer.from(newContent, "utf-8").toString("base64"); + + await githubWrite(`/repos/${owner}/${repo}/contents/${filePath}`, "PUT", { + message: `chore: restamp standards-version to ${targetVersion} in ${filePath} [skip ci]`, + content: encoded, + sha: fileData.sha, + branch: branchName, + }); + stamped++; + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + } + + if (stamped === 0) { + await githubWrite(`/repos/${owner}/${repo}/git/refs/heads/${branchName}`, "DELETE"); + return null; + } + + const pr = await githubWrite(`/repos/${owner}/${repo}/pulls`, "POST", { + title: `chore: restamp standards-version to ${targetVersion} [skip ci]`, + body: `Automated standards-version restamp to ${targetVersion} via devtools_restampRepo.`, + head: branchName, + base: "main", + }); + + return { branchName, prNumber: pr.number }; +} + +async function waitAndMerge( + ownerRepo: string, + prNumber: number, + branchName: string, + prHeadSha: string, +): Promise { + const [owner, repo] = ownerRepo.split("/"); + const checkPath = `/repos/${owner}/${repo}/commits/${prHeadSha}/check-runs`; + + for (let i = 0; i < 36; i++) { + await new Promise((r) => setTimeout(r, 10_000)); + try { + const data = await githubFetch(checkPath); + const drift = data.check_runs.find((c) => c.name === "Ecosystem drift check"); + if (!drift || drift.status !== "completed") continue; + if (drift.conclusion === "success") break; + if (drift.conclusion === "failure" || drift.conclusion === "action_required") { + throw new Error(`Drift check failed on PR #${prNumber} for ${ownerRepo}`); + } + } catch (e) { + if (e instanceof Error && e.message.startsWith("Drift check failed")) throw e; + } + } + + await githubWrite(`/repos/${owner}/${repo}/pulls/${prNumber}/merge`, "PUT", { + merge_method: "squash", + }); + + try { + await githubWrite(`/repos/${owner}/${repo}/git/refs/heads/${branchName}`, "DELETE"); + } catch { + // Best-effort cleanup + } +} + +const inputSchema = { + slug: z + .string() + .optional() + .describe( + "Registry slug of a single repo to restamp. Omit to restamp all registered repos.", + ), + version: z + .string() + .optional() + .describe( + "Target standards-version. Defaults to the canonical meta STANDARDS_VERSION. " + + "Must match the meta STANDARDS_VERSION; update that file first to stamp ahead.", + ), + apply: z + .boolean() + .optional() + .default(false) + .describe( + "Set true to create branches, commit stamps, open PRs, and squash-merge. " + + "Dry-run by default (apply=false). Requires GH_TOKEN and DEVTOOLS_META_ROOT.", + ), +}; + +export function register(server: McpServer): void { + server.tool( + "devtools_restampRepo", + "Preview or apply a standards-version restamp across ecosystem repos. " + + "Dry-run (default) calls the canonical drift checker to show which files are out of date. " + + "Apply mode creates a branch per repo, stamps the files via the canonical Python scripts, " + + "opens a PR, waits for the Ecosystem drift check, and squash-merges. " + + "Requires DEVTOOLS_META_ROOT (path to local meta-repo clone) for both modes. " + + "Requires GH_TOKEN or GITHUB_TOKEN for apply mode and for fetching remote repos.", + inputSchema, + async ({ slug, version, apply }) => { + try { + const meta = metaRoot(); + if (!meta) { + return errorResponse( + new Error( + "DEVTOOLS_META_ROOT is not set. " + + "Point it to your local clone of TMHSDigital/Developer-Tools-Directory.", + ), + ); + } + + const ghToken = token(); + if (!ghToken) { + return errorResponse( + new Error( + "GH_TOKEN or GITHUB_TOKEN is not set. " + + "A token is required to call the drift checker against remote repos.", + ), + ); + } + + const metaVersion = await fetchStandardsVersion(); + const targetVersion = version ?? metaVersion; + + if (targetVersion !== metaVersion) { + return errorResponse( + new Error( + `Requested version ${targetVersion} does not match meta STANDARDS_VERSION ${metaVersion}. ` + + "Update STANDARDS_VERSION in the meta-repo first, then restamp.", + ), + ); + } + + // Discover which repos and files need stamping via the canonical drift checker + const registry = await fetchRegistry(); + const targets = slug + ? registry.filter((e) => e.slug === slug) + : registry.filter((e) => e.status === "active"); + + if (slug && targets.length === 0) { + return errorResponse(new Error(`No registry entry found for slug: ${slug}`)); + } + + const cliArgs: string[] = ["--format", "json", "--gh-token", ghToken, "--meta-repo", meta]; + if (slug && targets[0]) { + cliArgs.push("--remote", targets[0].repo); + } else { + cliArgs.push("--all"); + } + + const { stdout, stderr, code } = spawnCli(cliArgs, meta); + if (code === 2) { + return errorResponse( + new Error(`Drift checker failed (exit 2): ${stderr.trim() || "no stderr"}`), + ); + } + if (!stdout.trim()) { + return errorResponse(new Error("Drift checker produced no output.")); + } + + let cliOutput: CliJsonOutput; + try { + cliOutput = parseCli(stdout); + } catch { + return errorResponse(new Error(`Failed to parse drift checker JSON: ${stdout.slice(0, 200)}`)); + } + + const reposWithDrift = versionSignalFindings(cliOutput); + + if (!apply) { + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + targetVersion, + apply: false, + checkedRepos: cliOutput.repos.length, + reposNeedingRestamp: reposWithDrift.length, + plannedChanges: reposWithDrift, + }, + null, + 2, + ), + }, + ], + }; + } + + // Apply mode: stamp each repo + const results: Array<{ + repo: string; + status: "stamped" | "skipped" | "error"; + prNumber?: number; + error?: string; + }> = []; + + for (const entry of reposWithDrift) { + const registryEntry = targets.find((r) => r.slug === entry.slug); + if (!registryEntry) continue; + + try { + const pr = await stampRepo(registryEntry.repo, entry.files, targetVersion, meta); + if (!pr) { + results.push({ repo: registryEntry.repo, status: "skipped" }); + continue; + } + + const prData = await githubFetch( + `/repos/${registryEntry.repo}/pulls/${pr.prNumber}`, + ); + + await waitAndMerge( + registryEntry.repo, + pr.prNumber, + pr.branchName, + prData.head.sha, + ); + + results.push({ + repo: registryEntry.repo, + status: "stamped", + prNumber: pr.prNumber, + }); + } catch (e) { + results.push({ + repo: registryEntry.repo, + status: "error", + error: e instanceof Error ? e.message : String(e), + }); + } + } + + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + targetVersion, + apply: true, + results, + stamped: results.filter((r) => r.status === "stamped").length, + skipped: results.filter((r) => r.status === "skipped").length, + errors: results.filter((r) => r.status === "error").length, + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + return errorResponse(error); + } + }, + ); +} diff --git a/src/utils/github.ts b/src/utils/github.ts index 3d3b751..4d07130 100644 --- a/src/utils/github.ts +++ b/src/utils/github.ts @@ -131,3 +131,39 @@ export async function fetchStandardsVersion(): Promise { const raw = await rawFetch(META_OWNER, META_REPO, "STANDARDS_VERSION"); return raw.trim(); } + +export async function githubWrite( + path: string, + method: "POST" | "PUT" | "PATCH" | "DELETE", + body?: unknown, +): Promise { + const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN; + if (!token) { + throw new GitHubError( + "GH_TOKEN or GITHUB_TOKEN is required for write operations", + 401, + path, + ); + } + + const url = `https://api.github.com${path}`; + const opts: RequestInit = { + method, + headers: { ...buildHeaders(), "Content-Type": "application/json" }, + }; + if (body !== undefined) opts.body = JSON.stringify(body); + + const res = await fetch(url, opts); + if (res.status === 404) throw new NotFoundError(path); + if (res.status === 403 || res.status === 429) throw new RateLimitError(); + if (res.status === 204) return {} as T; + if (!res.ok) { + const errText = await res.text().catch(() => ""); + throw new GitHubError( + `GitHub ${method} ${res.status} for ${path}: ${errText}`, + res.status, + path, + ); + } + return res.json() as Promise; +}