feat: watch the MCP SDK nightly and file an upgrade issue (#1063)
Staying abreast of SDK releases — OAuth especially — was a habit rather than a mechanism. This adds the third of this repo's issue-filing sweeps, after the monthly dependency refresh (#2229) and the daily Dependabot alert sweep (#2233), following the same shape as both: npm registry -> sweep -> issue -> Opus analysis comment -> maintainer PR scripts/sdk-watch.mjs compares the @modelcontextprotocol/* packages installed on v2/main against the registry and files one tracking issue per upstream that is behind — labeled v2 + chore + dependencies, and milestoned to the nearest dated open bucket. Four design points, each verified against this repo first: * Two upstreams, two issues. client/core/server/server-legacy ship from typescript-sdk in lockstep; ext-apps ships from its own repo. * The comparison is against the INSTALLED version, not the declared range. ext-apps is `^1.7.4` with 1.7.5 already in the lockfile, so comparing the declared string would file an issue for a bump `npm install` has already taken. * A fifth @modelcontextprotocol/* package added to the root manifest and not added to SDK_GROUPS fails the sweep loudly, rather than going unwatched behind a green run. * No board write and no issue closing. No PROJECT_TOKEN exists in this org, so the issue arrives labeled and milestoned for /issue-triage to board — as dependency-refresh.mjs already does. A further release files its own issue and leaves a supersession comment on the older one; closing stays a maintainer act. The analysis half runs Claude, not Copilot. #1063 sketched "a copilot agent running Opus", and neither half of that is reachable from a workflow: assigning copilot-swe-agent produces a pull request — the artifact #2229/#2233/#2235 removed from this repo — and its model cannot be selected programmatically, since replaceActorsForAssignable takes no model parameter. So the analyze job uses anthropics/claude-code-action with --model claude-opus-5, told to post one comment and denied every file-writing tool, running on the ANTHROPIC_API_KEY org secret. It runs only over issues the sweep just created, which holds it to one analysis per release rather than a near-identical comment every night. Upstream release notes are untrusted input to that job, so its system prompt says so and its --allowedTools list is a whitelist with no Edit, Write or git; the job's token carries contents: read only. 36 tests cover the pure halves directly and main() through an injected spawn, with the silent-failure cases first: a registry error must not report a clean sweep, and an already-filed target must not refile. Closes #1063 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019JWUZxLDnPgLQzjBrtk1x9 Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
5402aeafdd
commit
80a013c319
2 changed files with 1401 additions and 2 deletions
559
scripts/sdk-watch.mjs
Normal file
559
scripts/sdk-watch.mjs
Normal file
|
|
@ -0,0 +1,559 @@
|
|||
#!/usr/bin/env node
|
||||
// Nightly MCP SDK watch (#1063).
|
||||
//
|
||||
// Staying abreast of SDK releases had been a manual habit rather than a
|
||||
// mechanism, which is what #1063 was filed to fix. This script is the
|
||||
// mechanism: once a night it compares the `@modelcontextprotocol/*` packages
|
||||
// this repo installs against what the registry publishes, and files ONE
|
||||
// tracking issue per upstream that is behind.
|
||||
//
|
||||
// npm registry -> this sweep -> issue (labeled, milestoned) -> maintainer PR -> v2/main
|
||||
//
|
||||
// It is the third instance of a shape this repo already runs twice
|
||||
// (`dependency-refresh.mjs`, `dependabot-alerts.mjs`) and it deliberately files
|
||||
// an ISSUE rather than opening a PR, for the reason #2229 exists: a
|
||||
// bot-authored PR carries no `Closes #N` and no board card, so the work is
|
||||
// invisible to the project board.
|
||||
//
|
||||
// Four things shape the design, each verified against this repo before it was
|
||||
// written:
|
||||
//
|
||||
// 1. **Two upstreams, not one.** `client`/`core`/`server`/`server-legacy` all
|
||||
// ship from `modelcontextprotocol/typescript-sdk` and release in lockstep;
|
||||
// `ext-apps` ships from its own repo on its own cadence. Treating them as
|
||||
// one group would file an issue naming a version that only some of the
|
||||
// packages have, so `SDK_GROUPS` keeps them separate and each gets its own
|
||||
// issue and its own marker.
|
||||
// 2. **Compare the INSTALLED version, not the declared range.** #1063 phrases
|
||||
// the check as "is the current version > than the one we have in our
|
||||
// package.json", which is exact today only because the four SDK packages
|
||||
// are pinned exactly. `ext-apps` is a caret range (`^1.7.4`) whose lockfile
|
||||
// already resolves higher, so comparing against the declared string would
|
||||
// file an issue for a bump `npm install` has already taken. The declared
|
||||
// range is still reported — it is what says whether the fix is a manifest
|
||||
// edit or a lockfile refresh — but the comparison is against the lockfile.
|
||||
// 3. **A new SDK package must not be watched silently by nobody.** The group
|
||||
// table is a hardcoded list, so a fifth `@modelcontextprotocol/*` package
|
||||
// added to the root manifest would never be checked and nothing would say
|
||||
// so. `assertEveryPackageWatched` turns that into a loud failure instead —
|
||||
// the sweep goes red rather than reporting a clean night over a package it
|
||||
// never looked at.
|
||||
// 4. **No board write.** Both siblings want an org-project PAT for that, and
|
||||
// `PROJECT_TOKEN` is set nowhere in this org — the only org secret
|
||||
// available to this repo is `ANTHROPIC_API_KEY`. So rather than carry ~90
|
||||
// lines of placement code that cannot run (and a second copy of board
|
||||
// #28's node ids, which AGENTS.md explicitly calls worse than one), this
|
||||
// follows `dependency-refresh.mjs`: the issue is filed labeled and
|
||||
// milestoned, and the next `/issue-triage` sweep boards it. That is the
|
||||
// documented normal outcome, not a failure.
|
||||
//
|
||||
// ⚠️ A scheduled workflow only ever runs from the DEFAULT branch (`main`),
|
||||
// while we ship from `v2/main`. So the workflow checks `v2/main` out explicitly
|
||||
// and this script reads the manifests from the working tree — the same shape
|
||||
// both sibling sweeps use, and the reason `TARGET_BRANCH` is named in the issue
|
||||
// body rather than left for the reader to assume.
|
||||
//
|
||||
// Idempotency key is the marker comment at the top of each issue body, which
|
||||
// names the group and the target version. A second run the same night is a
|
||||
// complete no-op. A run after a FURTHER release files a new issue for the new
|
||||
// target and leaves a supersession comment on the old one — it never closes it,
|
||||
// because closing is a maintainer act and the board card may already have moved.
|
||||
//
|
||||
// The pure halves are tested directly and `main()` through an injected spawn
|
||||
// function, the same way both siblings do it; `workflow_dispatch` is a
|
||||
// production trigger, not a test.
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { appendFileSync, readFileSync } from "node:fs";
|
||||
import semver from "semver";
|
||||
|
||||
/** The branch this repo ships from, and whose manifests are read. */
|
||||
export const TARGET_BRANCH = "v2/main";
|
||||
|
||||
/**
|
||||
* The upstreams this sweep watches, and the packages each one publishes.
|
||||
*
|
||||
* Split by REPOSITORY rather than by npm scope: the four `typescript-sdk`
|
||||
* packages are cut from one release and always share a version, so one issue
|
||||
* covers the whole bump, while `ext-apps` moves independently and would
|
||||
* otherwise drag three unrelated packages into its title.
|
||||
*/
|
||||
export const SDK_GROUPS = [
|
||||
{
|
||||
key: "typescript-sdk",
|
||||
label: "MCP TypeScript SDK",
|
||||
repo: "modelcontextprotocol/typescript-sdk",
|
||||
packages: [
|
||||
"@modelcontextprotocol/client",
|
||||
"@modelcontextprotocol/core",
|
||||
"@modelcontextprotocol/server",
|
||||
"@modelcontextprotocol/server-legacy",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "ext-apps",
|
||||
label: "MCP Apps extension SDK",
|
||||
repo: "modelcontextprotocol/ext-apps",
|
||||
packages: ["@modelcontextprotocol/ext-apps"],
|
||||
},
|
||||
];
|
||||
|
||||
/** Every package under this prefix is in scope for the watch. */
|
||||
export const SDK_SCOPE = "@modelcontextprotocol/";
|
||||
|
||||
const MARKER_RE = /^<!-- sdk-watch: group=(.+?); target=(.+?) -->/;
|
||||
|
||||
/** Marker on the comment left when a newer target supersedes an open issue. */
|
||||
const SUPERSEDED_MARKER_RE = /^<!-- sdk-watch:superseded-by (.+?) -->/;
|
||||
|
||||
/**
|
||||
* The issue body's first line: the idempotency key.
|
||||
*
|
||||
* Keyed on `(group, target)` rather than group alone, so a second release
|
||||
* during the same milestone files its own issue instead of silently matching
|
||||
* the first and leaving the sweep reporting a bump nobody was told about.
|
||||
*
|
||||
* @param {{key: string}} group
|
||||
* @param {string} target the version being upgraded TO
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildMarker(group, target) {
|
||||
return `<!-- sdk-watch: group=${group.key}; target=${target} -->`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a marker back off an issue body.
|
||||
*
|
||||
* @param {string | undefined} body
|
||||
* @returns {{key: string, target: string} | null}
|
||||
*/
|
||||
export function parseMarker(body) {
|
||||
const match = MARKER_RE.exec(body ?? "");
|
||||
return match ? { key: match[1], target: match[2] } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | undefined} body
|
||||
* @returns {string | null} the issue number a supersession comment already named
|
||||
*/
|
||||
export function parseSupersededMarker(body) {
|
||||
const match = SUPERSEDED_MARKER_RE.exec(body ?? "");
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail loudly when the root manifest declares an SDK package no group watches.
|
||||
*
|
||||
* The group table is hardcoded, so an added fifth package would be checked by
|
||||
* nobody and the sweep would still print a clean result — a silent blind spot
|
||||
* in the one mechanism that exists to remove a silent blind spot. Throwing
|
||||
* turns "we forgot to add it here" into a red run on the next night.
|
||||
*
|
||||
* @param {Record<string, string> | undefined} dependencies the root manifest's `dependencies`
|
||||
* @throws when an in-scope package is not named in `SDK_GROUPS`
|
||||
*/
|
||||
export function assertEveryPackageWatched(dependencies) {
|
||||
const watched = new Set(SDK_GROUPS.flatMap((g) => g.packages));
|
||||
const unwatched = Object.keys(dependencies ?? {})
|
||||
.filter((name) => name.startsWith(SDK_SCOPE))
|
||||
.filter((name) => !watched.has(name))
|
||||
.sort();
|
||||
if (unwatched.length > 0) {
|
||||
throw new Error(
|
||||
`root package.json declares SDK package(s) no group in SDK_GROUPS watches: ${unwatched.join(", ")} — add them to a group, or this sweep silently never checks them`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The version actually installed, per the lockfile.
|
||||
*
|
||||
* Reads the HOISTED path only. A nested copy of an SDK package would be a
|
||||
* duplicate install and a different problem entirely (`verify:dep-lockstep`
|
||||
* territory); this sweep asks the narrower question of what the root install
|
||||
* resolves to, and answering it from a nested copy would report a version no
|
||||
* client actually loads.
|
||||
*
|
||||
* @param {object} lock parsed `package-lock.json`
|
||||
* @param {string} pkg
|
||||
* @returns {string | null} `null` when the package is not installed at all
|
||||
*/
|
||||
export function installedVersion(lock, pkg) {
|
||||
return lock?.packages?.[`node_modules/${pkg}`]?.version ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether one group is behind, and by how much.
|
||||
*
|
||||
* `target` is the HIGHEST latest among the packages that are actually behind,
|
||||
* rather than any single package's. The four `typescript-sdk` packages are
|
||||
* published from one release and normally agree, but a partially-published
|
||||
* release (one package live, three still uploading) would otherwise put a lower
|
||||
* version in the title than the issue's own table shows.
|
||||
*
|
||||
* @param {typeof SDK_GROUPS[number]} group
|
||||
* @param {Record<string, {declared?: string | null, installed?: string | null, latest?: string | null}>} versions
|
||||
* @returns {{group: typeof SDK_GROUPS[number], rows: Array<{name: string, declared: string, installed: string, latest: string, behind: boolean}>, target: string} | null}
|
||||
* `null` when every package in the group is current
|
||||
*/
|
||||
export function groupState(group, versions) {
|
||||
const rows = group.packages.map((name) => {
|
||||
const {
|
||||
declared = null,
|
||||
installed = null,
|
||||
latest = null,
|
||||
} = versions[name] ?? {};
|
||||
const behind = Boolean(installed && latest && semver.gt(latest, installed));
|
||||
return {
|
||||
name,
|
||||
declared: declared ?? "(undeclared)",
|
||||
installed: installed ?? "(not installed)",
|
||||
latest: latest ?? "(unknown)",
|
||||
behind,
|
||||
};
|
||||
});
|
||||
|
||||
const behindRows = rows.filter((r) => r.behind);
|
||||
if (behindRows.length === 0) return null;
|
||||
|
||||
const target = behindRows.map((r) => r.latest).sort(semver.rcompare)[0];
|
||||
return { group, rows, target };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {NonNullable<ReturnType<typeof groupState>>} state
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildIssueTitle(state) {
|
||||
return `chore(deps): upgrade the ${state.group.label} to ${state.target}`;
|
||||
}
|
||||
|
||||
const cell = (value) => String(value).replace(/\|/g, "\\|");
|
||||
|
||||
/**
|
||||
* @param {NonNullable<ReturnType<typeof groupState>>} state
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildIssueBody(state) {
|
||||
const { group, rows, target } = state;
|
||||
const table = rows
|
||||
.map(
|
||||
(r) =>
|
||||
`| \`${cell(r.name)}\` | ${cell(r.declared)} | ${cell(r.installed)} | ${cell(r.latest)} | ${r.behind ? "**yes**" : "no"} |`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return [
|
||||
buildMarker(group, target),
|
||||
`A new **${group.label}** release is out. What is installed on \`${TARGET_BRANCH}\` is behind what the npm registry publishes.`,
|
||||
"",
|
||||
`| Package | Declared | Installed on \`${TARGET_BRANCH}\` | Latest on npm | Behind |`,
|
||||
"| --- | --- | --- | --- | --- |",
|
||||
table,
|
||||
"",
|
||||
`Release notes: https://github.com/${group.repo}/releases`,
|
||||
"",
|
||||
"### Why this is an issue and not a PR",
|
||||
"",
|
||||
"Filed by the nightly SDK watch (#1063), the third of this repo's issue-filing sweeps alongside the monthly dependency refresh (#2229) and the daily Dependabot alert sweep (#2233). None of them opens a PR: a bot-authored PR carries no `Closes #N` and no board card, so the work would be invisible to the board. A maintainer picks this up and opens a normal PR against `v2/main`.",
|
||||
"",
|
||||
"### Upgrade checklist",
|
||||
"",
|
||||
"- [ ] Bump the version(s) in the **repo-root** `package.json` — every runtime dependency `core/` imports is declared there and nowhere else ([Dependency placement](https://github.com/modelcontextprotocol/inspector/blob/v2/main/AGENTS.md#dependency-placement)). The four `typescript-sdk` packages are pinned **exactly**, so they move together.",
|
||||
"- [ ] `npm install` at the root, and commit the refreshed lockfile.",
|
||||
"- [ ] Re-check the bundler `external` lists (`clients/{cli,tui}/tsup.config.ts`, `clients/web/tsup.runner.config.ts`) if the release adds or renames an entry point; `npm run verify:bundle-externals` enforces this against the built output.",
|
||||
"- [ ] `npm run format`, then `npm run local:gate`.",
|
||||
"",
|
||||
"An automated review of what actually changed upstream — and which parts of this app it touches — is posted as a comment below.",
|
||||
"",
|
||||
"A later run of this sweep will not refile this issue. A **further** SDK release files its own issue and leaves a supersession note here rather than editing this one.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* The comment left on an open issue whose target a newer release has passed.
|
||||
*
|
||||
* It does not close anything: the board card may already have moved, and
|
||||
* closing an issue this sweep cannot verify shipped would make the board claim
|
||||
* work landed that did not. A maintainer closes it.
|
||||
*
|
||||
* @param {number} newer the issue number covering the newer target
|
||||
* @param {string} newerTarget
|
||||
* @param {string} staleTarget
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildSupersededComment(newer, newerTarget, staleTarget) {
|
||||
return [
|
||||
`<!-- sdk-watch:superseded-by ${newer} -->`,
|
||||
`Superseded by #${newer}: the upstream has since released **${newerTarget}**, so upgrading to ${staleTarget} is no longer the current target.`,
|
||||
"",
|
||||
"Left open rather than closed — this sweep does not close issues, since the board card may already have moved and it cannot verify what shipped. Close this one by hand if nothing here is still worth keeping.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* The nearest-due open milestone.
|
||||
*
|
||||
* An undated bucket has no due date and so cannot be the nearest; it is dropped
|
||||
* rather than sorted last, and if nothing dated is open the issue is filed
|
||||
* unmilestoned and triage places it into `Incoming`.
|
||||
*
|
||||
* @param {Array<{title: string, state?: string, due_on?: string | null}>} milestones
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function pickMilestone(milestones) {
|
||||
const dated = (milestones ?? []).filter(
|
||||
(m) => (m.state ?? "open") === "open" && m.due_on,
|
||||
);
|
||||
if (dated.length === 0) return null;
|
||||
return dated.sort((a, b) => a.due_on.localeCompare(b.due_on))[0].title;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `$GITHUB_OUTPUT` line naming what was filed this run.
|
||||
*
|
||||
* Only NEWLY CREATED issues appear here. That is what keeps the analysis job
|
||||
* downstream to exactly one run per SDK version: an issue that already existed
|
||||
* has already been analyzed, and re-running Opus against it nightly would add a
|
||||
* near-identical comment every single night.
|
||||
*
|
||||
* @param {Array<{issue: number, label: string, repo: string, from: string, to: string}>} filed
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatFiledOutput(filed) {
|
||||
return `filed=${JSON.stringify(filed)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Impure half: everything below shells out to `npm` or `gh`. Each takes its
|
||||
// spawn function as a parameter, defaulted to `spawnSync`, so `main()` is
|
||||
// testable with an injected fake — the same shape both sibling sweeps use.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function latestVersion(pkg, spawn) {
|
||||
const result = spawn("npm", ["view", pkg, "version"], { encoding: "utf8" });
|
||||
if (result.error) throw result.error;
|
||||
// A non-zero exit MUST throw. `npm view` also prints nothing to stdout on
|
||||
// failure, so treating it as "no newer version" would turn a registry outage
|
||||
// into a clean all-current report — the silent all-clear this sweep exists to
|
||||
// prevent. The same reasoning covers an unparseable version below: `latest`
|
||||
// feeds a `semver.gt`, which answers `false` for garbage rather than throwing.
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`npm view ${pkg} failed (exit ${result.status}): ${(result.stderr ?? "").trim()}`,
|
||||
);
|
||||
}
|
||||
const version = (result.stdout ?? "").trim();
|
||||
if (!semver.valid(version)) {
|
||||
throw new Error(
|
||||
`npm view ${pkg} returned an unusable version: "${version}"`,
|
||||
);
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
function gh(spawn, args) {
|
||||
const result = spawn("gh", args, { encoding: "utf8" });
|
||||
if (result.error) throw result.error;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every issue this sweep has ever filed, open or closed.
|
||||
*
|
||||
* `--state all` is deliberate: an issue closed as "not planned" must keep
|
||||
* suppressing its target, or the sweep refiles it the very next night and every
|
||||
* night after — turning a maintainer's decision into a nightly argument.
|
||||
*/
|
||||
function sweepIssues(repo, spawn) {
|
||||
const result = gh(spawn, [
|
||||
"issue",
|
||||
"list",
|
||||
"--repo",
|
||||
repo,
|
||||
"--state",
|
||||
"all",
|
||||
"--search",
|
||||
"sdk-watch in:body",
|
||||
"--json",
|
||||
"number,body,state",
|
||||
"--limit",
|
||||
"100",
|
||||
]);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`gh issue list failed: ${(result.stderr ?? "").trim()}`);
|
||||
}
|
||||
return JSON.parse(result.stdout || "[]")
|
||||
.map((issue) => ({ ...issue, marker: parseMarker(issue.body) }))
|
||||
.filter((issue) => issue.marker);
|
||||
}
|
||||
|
||||
function currentMilestone(repo, spawn) {
|
||||
const result = gh(spawn, ["api", `repos/${repo}/milestones?state=open`]);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`milestone lookup failed: ${(result.stderr ?? "").trim()}`);
|
||||
}
|
||||
return pickMilestone(JSON.parse(result.stdout || "[]"));
|
||||
}
|
||||
|
||||
function issueComments(repo, number, spawn) {
|
||||
const result = gh(spawn, [
|
||||
"api",
|
||||
"--paginate",
|
||||
`repos/${repo}/issues/${number}/comments`,
|
||||
"--jq",
|
||||
".[].body",
|
||||
]);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`comment lookup for #${number} failed: ${(result.stderr ?? "").trim()}`,
|
||||
);
|
||||
}
|
||||
return (result.stdout ?? "").split("\n").filter(Boolean);
|
||||
}
|
||||
|
||||
function comment(repo, number, body, spawn) {
|
||||
const result = gh(spawn, [
|
||||
"issue",
|
||||
"comment",
|
||||
String(number),
|
||||
"--repo",
|
||||
repo,
|
||||
"--body",
|
||||
body,
|
||||
]);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`gh issue comment failed: ${(result.stderr ?? "").trim()}`);
|
||||
}
|
||||
}
|
||||
|
||||
function createIssue(repo, state, milestone, spawn) {
|
||||
const args = [
|
||||
"issue",
|
||||
"create",
|
||||
"--repo",
|
||||
repo,
|
||||
"--title",
|
||||
buildIssueTitle(state),
|
||||
"--label",
|
||||
"v2",
|
||||
"--label",
|
||||
"chore",
|
||||
"--label",
|
||||
"dependencies",
|
||||
"--body",
|
||||
buildIssueBody(state),
|
||||
];
|
||||
if (milestone) args.push("--milestone", milestone);
|
||||
const result = gh(spawn, args);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`gh issue create failed: ${(result.stderr ?? "").trim()}`);
|
||||
}
|
||||
const url = result.stdout.trim();
|
||||
const number = Number(url.split("/").pop());
|
||||
if (!Number.isInteger(number)) {
|
||||
throw new Error(`could not read an issue number out of "${url}"`);
|
||||
}
|
||||
return { url, number };
|
||||
}
|
||||
|
||||
export function main(
|
||||
repo = process.env.GITHUB_REPOSITORY,
|
||||
spawn = spawnSync,
|
||||
{
|
||||
readFile = (path) => readFileSync(path, "utf8"),
|
||||
output = process.env.GITHUB_OUTPUT,
|
||||
} = {},
|
||||
) {
|
||||
if (!repo) throw new Error("repo not specified (GITHUB_REPOSITORY unset)");
|
||||
|
||||
const manifest = JSON.parse(readFile("package.json"));
|
||||
const lock = JSON.parse(readFile("package-lock.json"));
|
||||
assertEveryPackageWatched(manifest.dependencies);
|
||||
|
||||
const versions = {};
|
||||
for (const pkg of SDK_GROUPS.flatMap((g) => g.packages)) {
|
||||
versions[pkg] = {
|
||||
declared: manifest.dependencies?.[pkg] ?? null,
|
||||
installed: installedVersion(lock, pkg),
|
||||
latest: latestVersion(pkg, spawn),
|
||||
};
|
||||
}
|
||||
|
||||
const states = SDK_GROUPS.map((group) => groupState(group, versions)).filter(
|
||||
Boolean,
|
||||
);
|
||||
|
||||
const emit = (filed) => {
|
||||
if (output) appendFileSync(output, `${formatFiledOutput(filed)}\n`);
|
||||
};
|
||||
|
||||
if (states.length === 0) {
|
||||
console.log("sdk-watch: every MCP SDK package is current — no-op");
|
||||
emit([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// One lookup covers every group; filed issues are matched client-side.
|
||||
const existing = sweepIssues(repo, spawn);
|
||||
const filed = [];
|
||||
|
||||
for (const state of states) {
|
||||
const forGroup = existing.filter((i) => i.marker.key === state.group.key);
|
||||
if (forGroup.some((i) => i.marker.target === state.target)) {
|
||||
console.log(
|
||||
`sdk-watch: ${state.group.label} ${state.target} already has an issue — no-op`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const milestone = currentMilestone(repo, spawn);
|
||||
const created = createIssue(repo, state, milestone, spawn);
|
||||
console.log(`sdk-watch: filed ${created.url}`);
|
||||
if (!milestone) {
|
||||
// Unmilestoned means unapproved, so triage sweeps it into `Incoming` —
|
||||
// NOT `Todo`, which asserts a maintainer signed off.
|
||||
console.log(
|
||||
"sdk-watch: no dated open milestone — filed unmilestoned, triage will place it in Incoming",
|
||||
);
|
||||
}
|
||||
|
||||
// Any OPEN issue of this group on an older target is now stale. Note it
|
||||
// there rather than closing it; see `buildSupersededComment`.
|
||||
for (const stale of forGroup) {
|
||||
if (stale.state !== "OPEN") continue;
|
||||
if (!semver.lt(stale.marker.target, state.target)) continue;
|
||||
const announced = issueComments(repo, stale.number, spawn).some(
|
||||
(body) => parseSupersededMarker(body) === String(created.number),
|
||||
);
|
||||
if (announced) continue;
|
||||
comment(
|
||||
repo,
|
||||
stale.number,
|
||||
buildSupersededComment(
|
||||
created.number,
|
||||
state.target,
|
||||
stale.marker.target,
|
||||
),
|
||||
spawn,
|
||||
);
|
||||
console.log(
|
||||
`sdk-watch: noted #${created.number} supersedes #${stale.number}`,
|
||||
);
|
||||
}
|
||||
|
||||
filed.push({
|
||||
issue: created.number,
|
||||
label: state.group.label,
|
||||
repo: state.group.repo,
|
||||
from: state.rows.find((r) => r.behind).installed,
|
||||
to: state.target,
|
||||
});
|
||||
}
|
||||
|
||||
emit(filed);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main();
|
||||
}
|
||||
653
scripts/sdk-watch.test.mjs
Normal file
653
scripts/sdk-watch.test.mjs
Normal file
|
|
@ -0,0 +1,653 @@
|
|||
// Tests for sdk-watch.mjs (#1063) — the pure comparison/formatting helpers and
|
||||
// `main()`'s orchestration, the latter driven through an injected spawn function
|
||||
// so no `npm` or `gh` process is ever started.
|
||||
//
|
||||
// `main()` is covered rather than left to `workflow_dispatch` because a
|
||||
// production trigger is not a test — the same reasoning the two sibling sweeps
|
||||
// record. The cases that matter most here are the ones where a wrong answer is
|
||||
// SILENT: a registry failure reported as "everything current", a second issue
|
||||
// filed for a version that already has one, and an SDK package added to the root
|
||||
// manifest that no group watches.
|
||||
//
|
||||
// Run via `npm run test:scripts`.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
assertEveryPackageWatched,
|
||||
buildIssueBody,
|
||||
buildIssueTitle,
|
||||
buildMarker,
|
||||
buildSupersededComment,
|
||||
formatFiledOutput,
|
||||
groupState,
|
||||
installedVersion,
|
||||
main,
|
||||
parseMarker,
|
||||
parseSupersededMarker,
|
||||
pickMilestone,
|
||||
SDK_GROUPS,
|
||||
TARGET_BRANCH,
|
||||
} from "./sdk-watch.mjs";
|
||||
|
||||
const SDK = SDK_GROUPS[0];
|
||||
const EXT = SDK_GROUPS[1];
|
||||
|
||||
/** Every version triple current, so a group is behind only where a test says so. */
|
||||
function currentVersions(overrides = {}) {
|
||||
const versions = {};
|
||||
for (const pkg of SDK_GROUPS.flatMap((g) => g.packages)) {
|
||||
versions[pkg] = { declared: "2.0.0", installed: "2.0.0", latest: "2.0.0" };
|
||||
}
|
||||
return { ...versions, ...overrides };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Markers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("buildMarker and parseMarker round-trip", () => {
|
||||
const marker = buildMarker(SDK, "2.1.0");
|
||||
assert.deepEqual(parseMarker(`${marker}\nrest of body`), {
|
||||
key: "typescript-sdk",
|
||||
target: "2.1.0",
|
||||
});
|
||||
});
|
||||
|
||||
test("parseMarker returns null for a body with no marker, or an undefined body", () => {
|
||||
assert.equal(parseMarker("just some text"), null);
|
||||
assert.equal(parseMarker(undefined), null);
|
||||
});
|
||||
|
||||
test("parseMarker only matches a marker on the FIRST line", () => {
|
||||
// The marker is the idempotency key; matching it mid-body would let an issue
|
||||
// that merely quotes another one suppress a genuine filing.
|
||||
assert.equal(parseMarker(`preamble\n${buildMarker(SDK, "2.1.0")}`), null);
|
||||
});
|
||||
|
||||
test("parseSupersededMarker reads back the issue number it announced", () => {
|
||||
const body = buildSupersededComment(42, "2.2.0", "2.1.0");
|
||||
assert.equal(parseSupersededMarker(body), "42");
|
||||
assert.equal(parseSupersededMarker("unrelated comment"), null);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The unwatched-package guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("assertEveryPackageWatched accepts a manifest whose SDK packages are all grouped", () => {
|
||||
const deps = Object.fromEntries(
|
||||
SDK_GROUPS.flatMap((g) => g.packages).map((p) => [p, "2.0.0"]),
|
||||
);
|
||||
assert.doesNotThrow(() => assertEveryPackageWatched({ ...deps, zod: "^4" }));
|
||||
});
|
||||
|
||||
test("assertEveryPackageWatched throws when an SDK package no group watches is declared", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
assertEveryPackageWatched({
|
||||
"@modelcontextprotocol/client": "2.0.0",
|
||||
"@modelcontextprotocol/brand-new": "1.0.0",
|
||||
}),
|
||||
/@modelcontextprotocol\/brand-new/,
|
||||
);
|
||||
});
|
||||
|
||||
test("assertEveryPackageWatched tolerates a manifest with no dependencies at all", () => {
|
||||
assert.doesNotThrow(() => assertEveryPackageWatched(undefined));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Version reading and comparison
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("installedVersion reads the hoisted entry and ignores a nested copy", () => {
|
||||
const lock = {
|
||||
packages: {
|
||||
"node_modules/@modelcontextprotocol/client": { version: "2.0.0" },
|
||||
"node_modules/other/node_modules/@modelcontextprotocol/client": {
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
};
|
||||
assert.equal(installedVersion(lock, "@modelcontextprotocol/client"), "2.0.0");
|
||||
});
|
||||
|
||||
test("installedVersion returns null for a package that is not installed", () => {
|
||||
assert.equal(
|
||||
installedVersion({ packages: {} }, "@modelcontextprotocol/core"),
|
||||
null,
|
||||
);
|
||||
assert.equal(installedVersion(undefined, "@modelcontextprotocol/core"), null);
|
||||
});
|
||||
|
||||
test("groupState returns null when every package in the group is current", () => {
|
||||
assert.equal(groupState(SDK, currentVersions()), null);
|
||||
});
|
||||
|
||||
test("groupState reports the group behind when one package has a newer latest", () => {
|
||||
const state = groupState(
|
||||
SDK,
|
||||
currentVersions({
|
||||
"@modelcontextprotocol/client": {
|
||||
declared: "2.0.0",
|
||||
installed: "2.0.0",
|
||||
latest: "2.1.0",
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.equal(state.target, "2.1.0");
|
||||
assert.equal(state.rows.filter((r) => r.behind).length, 1);
|
||||
assert.equal(state.rows.length, SDK.packages.length);
|
||||
});
|
||||
|
||||
test("groupState targets the highest version among the packages that are behind", () => {
|
||||
// A partially-published release: one package live at 2.2.0, another still at
|
||||
// 2.1.0. Taking any single package's latest would title the issue below what
|
||||
// its own table shows.
|
||||
const state = groupState(
|
||||
SDK,
|
||||
currentVersions({
|
||||
"@modelcontextprotocol/client": {
|
||||
declared: "2.0.0",
|
||||
installed: "2.0.0",
|
||||
latest: "2.1.0",
|
||||
},
|
||||
"@modelcontextprotocol/core": {
|
||||
declared: "2.0.0",
|
||||
installed: "2.0.0",
|
||||
latest: "2.2.0",
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.equal(state.target, "2.2.0");
|
||||
});
|
||||
|
||||
test("groupState compares against the INSTALLED version, not the declared range", () => {
|
||||
// The ext-apps shape: `^1.7.4` declared, 1.7.5 already resolved by the
|
||||
// lockfile, 1.7.5 latest. Comparing declared-to-latest would file an issue
|
||||
// for a bump `npm install` has already taken.
|
||||
const state = groupState(EXT, {
|
||||
"@modelcontextprotocol/ext-apps": {
|
||||
declared: "^1.7.4",
|
||||
installed: "1.7.5",
|
||||
latest: "1.7.5",
|
||||
},
|
||||
});
|
||||
assert.equal(state, null);
|
||||
});
|
||||
|
||||
test("groupState does not call an uninstalled or unknown-latest package behind", () => {
|
||||
assert.equal(
|
||||
groupState(EXT, {
|
||||
"@modelcontextprotocol/ext-apps": {
|
||||
declared: "^1.7.4",
|
||||
installed: null,
|
||||
latest: "1.7.5",
|
||||
},
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
groupState(EXT, {
|
||||
"@modelcontextprotocol/ext-apps": {
|
||||
declared: "^1.7.4",
|
||||
installed: "1.7.5",
|
||||
latest: null,
|
||||
},
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Issue text
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function behindState(group = SDK, target = "2.1.0") {
|
||||
return groupState(
|
||||
group,
|
||||
currentVersions({
|
||||
[group.packages[0]]: {
|
||||
declared: "2.0.0",
|
||||
installed: "2.0.0",
|
||||
latest: target,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test("buildIssueTitle names the group label and the target version", () => {
|
||||
assert.equal(
|
||||
buildIssueTitle(behindState()),
|
||||
"chore(deps): upgrade the MCP TypeScript SDK to 2.1.0",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildIssueBody leads with the marker so parseMarker can read it back", () => {
|
||||
const body = buildIssueBody(behindState());
|
||||
assert.ok(body.startsWith(buildMarker(SDK, "2.1.0")));
|
||||
assert.deepEqual(parseMarker(body), {
|
||||
key: "typescript-sdk",
|
||||
target: "2.1.0",
|
||||
});
|
||||
});
|
||||
|
||||
test("buildIssueBody tables every package in the group and marks which are behind", () => {
|
||||
const body = buildIssueBody(behindState());
|
||||
for (const pkg of SDK.packages) assert.ok(body.includes(pkg), pkg);
|
||||
assert.equal((body.match(/\*\*yes\*\*/g) ?? []).length, 1);
|
||||
assert.ok(body.includes(TARGET_BRANCH));
|
||||
assert.ok(body.includes(`https://github.com/${SDK.repo}/releases`));
|
||||
});
|
||||
|
||||
test("buildIssueBody escapes a pipe so one value cannot break the table apart", () => {
|
||||
const state = groupState(EXT, {
|
||||
"@modelcontextprotocol/ext-apps": {
|
||||
declared: ">=1.0.0 || ^2.0.0",
|
||||
installed: "1.7.5",
|
||||
latest: "1.8.0",
|
||||
},
|
||||
});
|
||||
assert.ok(buildIssueBody(state).includes("\\|\\|"));
|
||||
});
|
||||
|
||||
test("buildSupersededComment points at the newer issue and does not claim to close", () => {
|
||||
const body = buildSupersededComment(99, "2.2.0", "2.1.0");
|
||||
assert.ok(body.includes("#99"));
|
||||
assert.ok(body.includes("2.2.0"));
|
||||
assert.ok(/Left open rather than closed/.test(body));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Milestone selection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("pickMilestone takes the nearest due date among open milestones", () => {
|
||||
assert.equal(
|
||||
pickMilestone([
|
||||
{ title: "v2.7.0", state: "open", due_on: "2026-10-01T00:00:00Z" },
|
||||
{ title: "v2.6.0", state: "open", due_on: "2026-09-09T00:00:00Z" },
|
||||
]),
|
||||
"v2.6.0",
|
||||
);
|
||||
});
|
||||
|
||||
test("pickMilestone drops undated and closed buckets, and returns null for none", () => {
|
||||
assert.equal(
|
||||
pickMilestone([
|
||||
{ title: "Backlog", state: "open", due_on: null },
|
||||
{ title: "v2.5.0", state: "closed", due_on: "2026-08-01T00:00:00Z" },
|
||||
]),
|
||||
null,
|
||||
);
|
||||
assert.equal(pickMilestone([]), null);
|
||||
assert.equal(pickMilestone(undefined), null);
|
||||
});
|
||||
|
||||
test("formatFiledOutput emits a single-line GITHUB_OUTPUT assignment", () => {
|
||||
const line = formatFiledOutput([
|
||||
{ issue: 7, label: "x", repo: "a/b", from: "1.0.0", to: "2.0.0" },
|
||||
]);
|
||||
assert.ok(line.startsWith("filed="));
|
||||
assert.equal(line.includes("\n"), false);
|
||||
assert.deepEqual(JSON.parse(line.slice("filed=".length))[0].issue, 7);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// main()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A fake `spawnSync` that answers by command shape and records every call.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {Record<string,string>} [opts.latest] `npm view` answer per package
|
||||
* @param {number} [opts.npmStatus] exit status for every `npm view`
|
||||
* @param {string} [opts.npmStdout] override stdout for every `npm view`
|
||||
* @param {Array<{number:number,body:string,state:string}>} [opts.issues] what `gh issue list` returns
|
||||
* @param {Array<{title:string,state:string,due_on:string|null}>} [opts.milestones]
|
||||
* @param {string[]} [opts.comments] existing comment bodies on any issue
|
||||
* @param {number} [opts.createStatus] exit status for `gh issue create`
|
||||
* @param {number} [opts.nextIssue] number the created issue URL ends with
|
||||
*/
|
||||
function fakeSpawn({
|
||||
latest = {},
|
||||
npmStatus,
|
||||
npmStdout,
|
||||
issues = [],
|
||||
milestones = [
|
||||
{ title: "v2.6.0", state: "open", due_on: "2026-09-09T00:00:00Z" },
|
||||
],
|
||||
comments = [],
|
||||
createStatus = 0,
|
||||
nextIssue = 500,
|
||||
} = {}) {
|
||||
const calls = [];
|
||||
let issueCounter = nextIssue;
|
||||
const fn = (cmd, args) => {
|
||||
calls.push({ cmd, args });
|
||||
if (cmd === "npm") {
|
||||
const pkg = args[1];
|
||||
return {
|
||||
status: npmStatus ?? 0,
|
||||
stdout: npmStdout ?? `${latest[pkg] ?? "2.0.0"}\n`,
|
||||
stderr: npmStatus ? "ENOTFOUND registry.npmjs.org" : "",
|
||||
};
|
||||
}
|
||||
if (args[0] === "issue" && args[1] === "list")
|
||||
return { status: 0, stdout: JSON.stringify(issues), stderr: "" };
|
||||
if (args[0] === "issue" && args[1] === "create")
|
||||
return {
|
||||
status: createStatus,
|
||||
stdout: createStatus
|
||||
? ""
|
||||
: `https://github.com/o/r/issues/${issueCounter++}\n`,
|
||||
stderr: createStatus ? "could not create issue" : "",
|
||||
};
|
||||
if (args[0] === "issue" && args[1] === "comment")
|
||||
return { status: 0, stdout: "", stderr: "" };
|
||||
// MUST be tested before the milestone branch: both are `gh api`, so
|
||||
// matching on args[0] alone would hand the comment lookup the milestone
|
||||
// payload and the assertion would silently check nothing.
|
||||
if (args[0] === "api" && args.some((a) => String(a).includes("/comments")))
|
||||
return { status: 0, stdout: comments.join("\n"), stderr: "" };
|
||||
if (args[0] === "api")
|
||||
return { status: 0, stdout: JSON.stringify(milestones), stderr: "" };
|
||||
throw new Error(`unexpected call: ${cmd} ${args.join(" ")}`);
|
||||
};
|
||||
fn.calls = calls;
|
||||
return fn;
|
||||
}
|
||||
|
||||
/** Manifest + lockfile fixtures, with the whole SDK current unless overridden. */
|
||||
function fakeReadFile({ declared = {}, installed = {} } = {}) {
|
||||
const all = SDK_GROUPS.flatMap((g) => g.packages);
|
||||
const manifest = {
|
||||
dependencies: Object.fromEntries(
|
||||
all.map((p) => [p, declared[p] ?? "2.0.0"]),
|
||||
),
|
||||
};
|
||||
const lock = {
|
||||
packages: Object.fromEntries(
|
||||
all.map((p) => [
|
||||
`node_modules/${p}`,
|
||||
{ version: installed[p] ?? "2.0.0" },
|
||||
]),
|
||||
),
|
||||
};
|
||||
return (path) => JSON.stringify(path === "package.json" ? manifest : lock);
|
||||
}
|
||||
|
||||
/** A real temp file, so the GITHUB_OUTPUT append path is exercised end to end. */
|
||||
function outputFile() {
|
||||
return join(mkdtempSync(join(tmpdir(), "sdk-watch-")), "out.txt");
|
||||
}
|
||||
|
||||
function readFiled(path) {
|
||||
const line = readFileSync(path, "utf8").trim();
|
||||
return JSON.parse(line.slice("filed=".length));
|
||||
}
|
||||
|
||||
test("main files nothing and emits an empty list when the whole SDK is current", () => {
|
||||
const spawn = fakeSpawn();
|
||||
const output = outputFile();
|
||||
writeFileSync(output, "");
|
||||
|
||||
main("o/r", spawn, { readFile: fakeReadFile(), output });
|
||||
|
||||
assert.deepEqual(readFiled(output), []);
|
||||
assert.equal(
|
||||
spawn.calls.some((c) => c.cmd === "gh"),
|
||||
false,
|
||||
"a quiet night must not touch the GitHub API at all",
|
||||
);
|
||||
});
|
||||
|
||||
test("main files a labeled, milestoned issue when a group is behind", () => {
|
||||
const spawn = fakeSpawn({
|
||||
latest: { "@modelcontextprotocol/client": "2.1.0" },
|
||||
});
|
||||
const output = outputFile();
|
||||
writeFileSync(output, "");
|
||||
|
||||
main("o/r", spawn, { readFile: fakeReadFile(), output });
|
||||
|
||||
const create = spawn.calls.find(
|
||||
(c) => c.args[0] === "issue" && c.args[1] === "create",
|
||||
);
|
||||
assert.ok(create, "expected an issue to be created");
|
||||
assert.ok(create.args.includes("v2"));
|
||||
assert.ok(create.args.includes("chore"));
|
||||
assert.ok(create.args.includes("dependencies"));
|
||||
assert.ok(create.args.includes("--milestone"));
|
||||
assert.ok(create.args.includes("v2.6.0"));
|
||||
assert.equal(
|
||||
create.args[create.args.indexOf("--title") + 1],
|
||||
"chore(deps): upgrade the MCP TypeScript SDK to 2.1.0",
|
||||
);
|
||||
|
||||
assert.deepEqual(readFiled(output), [
|
||||
{
|
||||
issue: 500,
|
||||
label: "MCP TypeScript SDK",
|
||||
repo: "modelcontextprotocol/typescript-sdk",
|
||||
from: "2.0.0",
|
||||
to: "2.1.0",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("main files one issue per upstream when both groups are behind", () => {
|
||||
const spawn = fakeSpawn({
|
||||
latest: {
|
||||
"@modelcontextprotocol/client": "2.1.0",
|
||||
"@modelcontextprotocol/ext-apps": "1.8.0",
|
||||
},
|
||||
});
|
||||
const output = outputFile();
|
||||
writeFileSync(output, "");
|
||||
|
||||
main("o/r", spawn, {
|
||||
readFile: fakeReadFile({
|
||||
declared: { "@modelcontextprotocol/ext-apps": "^1.7.4" },
|
||||
installed: { "@modelcontextprotocol/ext-apps": "1.7.5" },
|
||||
}),
|
||||
output,
|
||||
});
|
||||
|
||||
const created = spawn.calls.filter(
|
||||
(c) => c.args[0] === "issue" && c.args[1] === "create",
|
||||
);
|
||||
assert.equal(created.length, 2);
|
||||
const filed = readFiled(output);
|
||||
assert.deepEqual(
|
||||
filed.map((f) => f.repo),
|
||||
["modelcontextprotocol/typescript-sdk", "modelcontextprotocol/ext-apps"],
|
||||
);
|
||||
assert.equal(filed[1].from, "1.7.5", "from is the installed version");
|
||||
});
|
||||
|
||||
test("main does not refile when an issue already covers this target", () => {
|
||||
const spawn = fakeSpawn({
|
||||
latest: { "@modelcontextprotocol/client": "2.1.0" },
|
||||
issues: [
|
||||
{
|
||||
number: 400,
|
||||
state: "OPEN",
|
||||
body: `${buildMarker(SDK, "2.1.0")}\nexisting`,
|
||||
},
|
||||
],
|
||||
});
|
||||
const output = outputFile();
|
||||
writeFileSync(output, "");
|
||||
|
||||
main("o/r", spawn, { readFile: fakeReadFile(), output });
|
||||
|
||||
assert.equal(
|
||||
spawn.calls.some((c) => c.args[0] === "issue" && c.args[1] === "create"),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(
|
||||
readFiled(output),
|
||||
[],
|
||||
"an already-filed target must not reach the analysis job again",
|
||||
);
|
||||
});
|
||||
|
||||
test("main respects a CLOSED issue for the same target and does not refile nightly", () => {
|
||||
const spawn = fakeSpawn({
|
||||
latest: { "@modelcontextprotocol/client": "2.1.0" },
|
||||
issues: [
|
||||
{
|
||||
number: 400,
|
||||
state: "CLOSED",
|
||||
body: `${buildMarker(SDK, "2.1.0")}\nwon't fix`,
|
||||
},
|
||||
],
|
||||
});
|
||||
const output = outputFile();
|
||||
writeFileSync(output, "");
|
||||
|
||||
main("o/r", spawn, { readFile: fakeReadFile(), output });
|
||||
|
||||
assert.equal(
|
||||
spawn.calls.some((c) => c.args[0] === "issue" && c.args[1] === "create"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("main comments on an open older-target issue that a new filing supersedes", () => {
|
||||
const spawn = fakeSpawn({
|
||||
latest: { "@modelcontextprotocol/client": "2.2.0" },
|
||||
issues: [
|
||||
{
|
||||
number: 400,
|
||||
state: "OPEN",
|
||||
body: `${buildMarker(SDK, "2.1.0")}\nolder`,
|
||||
},
|
||||
],
|
||||
});
|
||||
const output = outputFile();
|
||||
writeFileSync(output, "");
|
||||
|
||||
main("o/r", spawn, { readFile: fakeReadFile(), output });
|
||||
|
||||
const comment = spawn.calls.find(
|
||||
(c) => c.args[0] === "issue" && c.args[1] === "comment",
|
||||
);
|
||||
assert.ok(comment, "expected a supersession comment");
|
||||
assert.equal(comment.args[2], "400");
|
||||
assert.ok(comment.args[comment.args.indexOf("--body") + 1].includes("#500"));
|
||||
});
|
||||
|
||||
test("main does not repeat a supersession comment it already left", () => {
|
||||
const spawn = fakeSpawn({
|
||||
latest: { "@modelcontextprotocol/client": "2.2.0" },
|
||||
issues: [
|
||||
{
|
||||
number: 400,
|
||||
state: "OPEN",
|
||||
body: `${buildMarker(SDK, "2.1.0")}\nolder`,
|
||||
},
|
||||
],
|
||||
comments: [buildSupersededComment(500, "2.2.0", "2.1.0").split("\n")[0]],
|
||||
});
|
||||
const output = outputFile();
|
||||
writeFileSync(output, "");
|
||||
|
||||
main("o/r", spawn, { readFile: fakeReadFile(), output });
|
||||
|
||||
assert.equal(
|
||||
spawn.calls.some((c) => c.args[0] === "issue" && c.args[1] === "comment"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("main leaves a CLOSED older-target issue alone", () => {
|
||||
const spawn = fakeSpawn({
|
||||
latest: { "@modelcontextprotocol/client": "2.2.0" },
|
||||
issues: [
|
||||
{
|
||||
number: 400,
|
||||
state: "CLOSED",
|
||||
body: `${buildMarker(SDK, "2.1.0")}\nolder`,
|
||||
},
|
||||
],
|
||||
});
|
||||
const output = outputFile();
|
||||
writeFileSync(output, "");
|
||||
|
||||
main("o/r", spawn, { readFile: fakeReadFile(), output });
|
||||
|
||||
assert.equal(
|
||||
spawn.calls.some((c) => c.args[0] === "issue" && c.args[1] === "comment"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("main files without a milestone when nothing dated is open", () => {
|
||||
const spawn = fakeSpawn({
|
||||
latest: { "@modelcontextprotocol/client": "2.1.0" },
|
||||
milestones: [{ title: "Backlog", state: "open", due_on: null }],
|
||||
});
|
||||
const output = outputFile();
|
||||
writeFileSync(output, "");
|
||||
|
||||
main("o/r", spawn, { readFile: fakeReadFile(), output });
|
||||
|
||||
const create = spawn.calls.find(
|
||||
(c) => c.args[0] === "issue" && c.args[1] === "create",
|
||||
);
|
||||
assert.equal(create.args.includes("--milestone"), false);
|
||||
});
|
||||
|
||||
test("main throws when npm view fails rather than reporting a clean sweep", () => {
|
||||
// The silent failure this guards: `npm view` prints nothing on error, so a
|
||||
// tolerated non-zero exit reads as "no newer version" for every package and a
|
||||
// registry outage becomes a green all-current night.
|
||||
const spawn = fakeSpawn({ npmStatus: 1 });
|
||||
assert.throws(
|
||||
() => main("o/r", spawn, { readFile: fakeReadFile() }),
|
||||
/npm view .* failed/,
|
||||
);
|
||||
});
|
||||
|
||||
test("main throws when npm view returns something that is not a version", () => {
|
||||
const spawn = fakeSpawn({ npmStdout: "\n" });
|
||||
assert.throws(
|
||||
() => main("o/r", spawn, { readFile: fakeReadFile() }),
|
||||
/unusable version/,
|
||||
);
|
||||
});
|
||||
|
||||
test("main propagates a failed issue creation", () => {
|
||||
const spawn = fakeSpawn({
|
||||
latest: { "@modelcontextprotocol/client": "2.1.0" },
|
||||
createStatus: 1,
|
||||
});
|
||||
assert.throws(
|
||||
() => main("o/r", spawn, { readFile: fakeReadFile() }),
|
||||
/gh issue create failed/,
|
||||
);
|
||||
});
|
||||
|
||||
test("main refuses to run without a repository", () => {
|
||||
assert.throws(
|
||||
() => main(undefined, fakeSpawn(), { readFile: fakeReadFile() }),
|
||||
/GITHUB_REPOSITORY unset/,
|
||||
);
|
||||
});
|
||||
|
||||
test("main fails on an SDK package the group table does not watch", () => {
|
||||
const readFile = (path) =>
|
||||
path === "package.json"
|
||||
? JSON.stringify({
|
||||
dependencies: { "@modelcontextprotocol/something-new": "1.0.0" },
|
||||
})
|
||||
: JSON.stringify({ packages: {} });
|
||||
assert.throws(() => main("o/r", fakeSpawn(), { readFile }), /something-new/);
|
||||
});
|
||||
Loading…
Reference in a new issue