@4da/mcp-server 6.1.0 → 6.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +183 -0
- package/README.md +14 -9
- package/dist/db.d.ts +15 -0
- package/dist/db.js +37 -2
- package/dist/live/changelog-classify.d.ts +9 -3
- package/dist/live/changelog-classify.js +122 -12
- package/dist/live/changelog.d.ts +8 -4
- package/dist/live/changelog.js +67 -29
- package/dist/live/fix-paths.js +1 -1
- package/dist/live/github-releases.d.ts +69 -0
- package/dist/live/github-releases.js +252 -0
- package/dist/live/go-releases.d.ts +48 -0
- package/dist/live/go-releases.js +196 -0
- package/dist/live/index.d.ts +4 -5
- package/dist/live/index.js +7 -1
- package/dist/live/osv-ranges.d.ts +6 -0
- package/dist/live/osv-ranges.js +3 -0
- package/dist/live/package-archive.d.ts +2 -0
- package/dist/live/package-archive.js +7 -4
- package/dist/live/pypi-releases.d.ts +44 -0
- package/dist/live/pypi-releases.js +103 -0
- package/dist/live/release-metadata.d.ts +17 -8
- package/dist/live/release-metadata.js +6 -3
- package/dist/live/upgrade-go.d.ts +62 -0
- package/dist/live/upgrade-go.js +130 -0
- package/dist/live/upgrade-pypi.d.ts +42 -0
- package/dist/live/upgrade-pypi.js +85 -0
- package/dist/live/upgrade-sources.d.ts +28 -11
- package/dist/live/upgrade-sources.js +86 -19
- package/dist/live/zip-archive.d.ts +22 -0
- package/dist/live/zip-archive.js +98 -0
- package/dist/schema-registry.js +3 -3
- package/dist/server-instructions.d.ts +6 -0
- package/dist/server-instructions.js +20 -15
- package/dist/tool-dispatch.d.ts +2 -0
- package/dist/tool-dispatch.js +41 -1
- package/dist/tools/briefing-stored-advisories.js +1 -1
- package/dist/tools/briefing-task-scope.js +2 -2
- package/dist/tools/dependency-check-signals.d.ts +16 -5
- package/dist/tools/dependency-check-signals.js +46 -18
- package/dist/tools/dependency-check-versions.d.ts +94 -0
- package/dist/tools/dependency-check-versions.js +213 -0
- package/dist/tools/dependency-check.d.ts +26 -10
- package/dist/tools/dependency-check.js +321 -74
- package/dist/tools/knowledge-gap-citations.js +1 -1
- package/dist/tools/upgrade-impact-callsites.d.ts +1 -0
- package/dist/tools/upgrade-impact-callsites.js +23 -5
- package/dist/tools/upgrade-impact-changelog.d.ts +41 -0
- package/dist/tools/upgrade-impact-changelog.js +79 -0
- package/dist/tools/upgrade-impact-imports.d.ts +42 -0
- package/dist/tools/upgrade-impact-imports.js +260 -0
- package/dist/tools/upgrade-impact-report.d.ts +13 -0
- package/dist/tools/upgrade-impact-report.js +13 -4
- package/dist/tools/upgrade-impact.d.ts +13 -4
- package/dist/tools/upgrade-impact.js +235 -84
- package/dist/tools/what-should-i-know.js +1 -1
- package/package.json +6 -6
package/dist/live/changelog.d.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* conventional `## [7.0.0](https://…/compare/…) (2025-06-24)`
|
|
8
8
|
* plain / prefixed `## 1.2.3`, `# v1.2.3 (2024-01-01)`, `### Version 1.2.3`, `## fastembed 5.0.0`
|
|
9
9
|
* setext `1.2.3` underlined with `===` / `---`
|
|
10
|
+
* reStructuredText `Version 3.0.0` underlined (or over- and underlined)
|
|
11
|
+
* with any of `= - ~ ^ * + # " '`, then `Released 2023-09-30`
|
|
10
12
|
* History.md (express) `1.2.3 / 2020-01-01`
|
|
11
13
|
* A heading only opens a section when the rest of it looks like a trailer
|
|
12
14
|
* (date, link, "yanked") — `## Upgrading from 1.x` or `#### Rust 1.70 support`
|
|
@@ -23,7 +25,7 @@ import { type EntryKind } from "./changelog-classify.js";
|
|
|
23
25
|
* for a week unless this changes: bump it with every change to parseChangelog
|
|
24
26
|
* or changelog-classify.ts that can alter a section or an entry's kind.
|
|
25
27
|
*/
|
|
26
|
-
export declare const CHANGELOG_PARSER_VERSION =
|
|
28
|
+
export declare const CHANGELOG_PARSER_VERSION = 4;
|
|
27
29
|
export interface ChangelogEntry {
|
|
28
30
|
kind: EntryKind;
|
|
29
31
|
text: string;
|
|
@@ -64,8 +66,10 @@ export declare function parseVersionHeading(raw: string): {
|
|
|
64
66
|
} | null;
|
|
65
67
|
/** Parse a changelog into version sections, in document order (usually newest first). */
|
|
66
68
|
export declare function parseChangelog(text: string): ChangelogSection[];
|
|
67
|
-
/**
|
|
68
|
-
export
|
|
69
|
+
/** Version order: 1, -1, 0, or null when either side is unreadable. Semver precedence by default. */
|
|
70
|
+
export type VersionOrder = (a: string, b: string) => number | null;
|
|
71
|
+
/** True when `version` lies in (from, to] by `order`; unreadable versions are out. */
|
|
72
|
+
export declare function inRange(version: string, from: string, to: string, order?: VersionOrder): boolean;
|
|
69
73
|
/**
|
|
70
74
|
* Sections in (from, to], plus whether the changelog reaches both ends of the
|
|
71
75
|
* range: its newest section is at or past `to`, and its oldest at or before
|
|
@@ -73,7 +77,7 @@ export declare function inRange(version: string, from: string, to: string): bool
|
|
|
73
77
|
* stopped being maintained two majors ago is reported as not covering the
|
|
74
78
|
* range instead of silently presenting a partial history as the whole one.
|
|
75
79
|
*/
|
|
76
|
-
export declare function selectRange(sections: ChangelogSection[], from: string, to: string, firstInRange: string): {
|
|
80
|
+
export declare function selectRange(sections: ChangelogSection[], from: string, to: string, firstInRange: string, order?: VersionOrder): {
|
|
77
81
|
sections: ChangelogSection[];
|
|
78
82
|
coversRange: boolean;
|
|
79
83
|
};
|
package/dist/live/changelog.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* conventional `## [7.0.0](https://…/compare/…) (2025-06-24)`
|
|
9
9
|
* plain / prefixed `## 1.2.3`, `# v1.2.3 (2024-01-01)`, `### Version 1.2.3`, `## fastembed 5.0.0`
|
|
10
10
|
* setext `1.2.3` underlined with `===` / `---`
|
|
11
|
+
* reStructuredText `Version 3.0.0` underlined (or over- and underlined)
|
|
12
|
+
* with any of `= - ~ ^ * + # " '`, then `Released 2023-09-30`
|
|
11
13
|
* History.md (express) `1.2.3 / 2020-01-01`
|
|
12
14
|
* A heading only opens a section when the rest of it looks like a trailer
|
|
13
15
|
* (date, link, "yanked") — `## Upgrading from 1.x` or `#### Rust 1.70 support`
|
|
@@ -25,7 +27,7 @@ import { classifyEntry, classifyHeading, sanitizeEntry } from "./changelog-class
|
|
|
25
27
|
* for a week unless this changes: bump it with every change to parseChangelog
|
|
26
28
|
* or changelog-classify.ts that can alter a section or an entry's kind.
|
|
27
29
|
*/
|
|
28
|
-
export const CHANGELOG_PARSER_VERSION =
|
|
30
|
+
export const CHANGELOG_PARSER_VERSION = 4;
|
|
29
31
|
const NAME_STEMS = ["changelog", "changes", "history", "releases", "release-notes", "release_notes", "releasenotes", "news"];
|
|
30
32
|
const NAME_EXTS = ["", ".md", ".markdown", ".txt", ".rst"];
|
|
31
33
|
/** True for a root-level basename that names a changelog (case-insensitive). */
|
|
@@ -106,7 +108,13 @@ export function parseVersionHeading(raw) {
|
|
|
106
108
|
return { version, date: extractDate(rest) };
|
|
107
109
|
}
|
|
108
110
|
const ATX = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
|
|
109
|
-
|
|
111
|
+
/**
|
|
112
|
+
* A setext / reStructuredText underline. `=` and `-` at any length (Markdown);
|
|
113
|
+
* the other RST adornments need three, so a stray `**` is not a heading rule.
|
|
114
|
+
*/
|
|
115
|
+
const SETEXT_RULE = /^\s*(=+|-+|~{3,}|\^{3,}|\*{3,}|\+{3,}|#{3,}|"{3,}|'{3,})\s*$/;
|
|
116
|
+
/** `Released 2023-09-30`, `Released on 2024-01-01`, `Release date: ...`, `Unreleased`: a date line under an RST heading. */
|
|
117
|
+
const RELEASED_LINE = /^\s*(?:\*{0,2}(?:released|release date|date)\*{0,2}(?:\s+on)?\s*:?\s*(.*)|unreleased|\(unreleased\))\s*$/i;
|
|
110
118
|
const BULLET = /^(\s*)(?:[-*+•]|\d+[.)])\s+(.*)$/;
|
|
111
119
|
const LINK_DEF = /^\s*\[[^\]]+\]:\s*\S+/;
|
|
112
120
|
const FENCE = /^\s*(```|~~~)/;
|
|
@@ -238,6 +246,37 @@ function addLine(state, line) {
|
|
|
238
246
|
state.parentEntry = null;
|
|
239
247
|
state.pending = { text: line, context: state.headingKind, indent: -1, under: state.headingText };
|
|
240
248
|
}
|
|
249
|
+
/** A heading that is not a release: a category inside the current release, or the end of it. */
|
|
250
|
+
function subHeading(state, raw, level, setext = false) {
|
|
251
|
+
flush(state);
|
|
252
|
+
// A setext title ends the release only from a strictly higher level: "text" over
|
|
253
|
+
// "---" is also how a paragraph followed by a rule reads.
|
|
254
|
+
if (setext && level >= state.sectionLevel)
|
|
255
|
+
level = state.sectionLevel + 1;
|
|
256
|
+
// "## Migration guide" after the last release must not be read as part of it.
|
|
257
|
+
// A change-category heading ("## Changed", "## Breaking Changes") at the
|
|
258
|
+
// release's own level is still inside it: date-fns 3.0.0 writes
|
|
259
|
+
// "## v3.0.0" then "## Changed", and its ten BREAKING entries were dropped.
|
|
260
|
+
if (state.current && level <= state.sectionLevel && !isCategoryHeading(raw))
|
|
261
|
+
state.current = null;
|
|
262
|
+
state.headingKind = classifyHeading(raw);
|
|
263
|
+
state.headingText = contextText(raw);
|
|
264
|
+
state.parentKind = null;
|
|
265
|
+
state.parentIndent = null;
|
|
266
|
+
state.parentText = null;
|
|
267
|
+
state.parentEntry = null;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Setext / RST title level by adornment. Markdown fixes `=` at 1 and `-` at 2;
|
|
271
|
+
* reStructuredText ranks adornments in the order a document first uses them,
|
|
272
|
+
* so the rest rank below both, in a fixed order: close enough to compare a
|
|
273
|
+
* sub-heading with the release title above it.
|
|
274
|
+
*/
|
|
275
|
+
function adornmentLevel(ch) {
|
|
276
|
+
const order = "=-~^*+#\"'";
|
|
277
|
+
const i = order.indexOf(ch);
|
|
278
|
+
return i === -1 ? 3 : i + 1;
|
|
279
|
+
}
|
|
241
280
|
/** Parse a changelog into version sections, in document order (usually newest first). */
|
|
242
281
|
export function parseChangelog(text) {
|
|
243
282
|
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
|
@@ -285,31 +324,30 @@ export function parseChangelog(text) {
|
|
|
285
324
|
const level = atx[1].length;
|
|
286
325
|
if (heading)
|
|
287
326
|
openSection(state, heading, level);
|
|
288
|
-
else
|
|
289
|
-
|
|
290
|
-
// "## Migration guide" after the last release must not be read as part of it.
|
|
291
|
-
// A change-category heading ("## Changed", "## Breaking Changes") at the
|
|
292
|
-
// release's own level is still inside it: date-fns 3.0.0 writes
|
|
293
|
-
// "## v3.0.0" then "## Changed", and its ten BREAKING entries were dropped.
|
|
294
|
-
if (state.current && level <= state.sectionLevel && !isCategoryHeading(atx[2]))
|
|
295
|
-
state.current = null;
|
|
296
|
-
state.headingKind = classifyHeading(atx[2]);
|
|
297
|
-
state.headingText = contextText(atx[2]);
|
|
298
|
-
state.parentKind = null;
|
|
299
|
-
state.parentIndent = null;
|
|
300
|
-
state.parentText = null;
|
|
301
|
-
state.parentEntry = null;
|
|
302
|
-
}
|
|
327
|
+
else
|
|
328
|
+
subHeading(state, atx[2], level);
|
|
303
329
|
continue;
|
|
304
330
|
}
|
|
305
331
|
const next = lines[i + 1];
|
|
306
|
-
if (next !== undefined && SETEXT_RULE.test(next) && line.trim() !== "" && !BULLET.test(line)) {
|
|
332
|
+
if (next !== undefined && SETEXT_RULE.test(next) && line.trim() !== "" && !BULLET.test(line) && !SETEXT_RULE.test(line)) {
|
|
333
|
+
const level = adornmentLevel(next.trim()[0]);
|
|
307
334
|
const heading = parseVersionHeading(line);
|
|
335
|
+
i++;
|
|
308
336
|
if (heading) {
|
|
309
|
-
openSection(state, heading,
|
|
310
|
-
i++;
|
|
337
|
+
openSection(state, heading, level);
|
|
311
338
|
continue;
|
|
312
339
|
}
|
|
340
|
+
// A non-release setext / RST title is a sub-heading ("Breaking Changes" over
|
|
341
|
+
// `~~~~`), as an ATX one is: it was an entry until parser version 3.
|
|
342
|
+
subHeading(state, line, level, true);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
// `Released 2023-09-30` right under an RST release title is its date, not a change (flask, werkzeug, click).
|
|
346
|
+
const released = state.current && state.current.entries.length === 0 && !state.pending ? RELEASED_LINE.exec(line) : null;
|
|
347
|
+
if (released && (released[1] === undefined || extractDate(released[1]) || /^(?:unreleased|tbd|-)?$/i.test(released[1].trim()))) {
|
|
348
|
+
if (released[1])
|
|
349
|
+
state.current.date ??= extractDate(released[1]);
|
|
350
|
+
continue;
|
|
313
351
|
}
|
|
314
352
|
// History.md style without an underline: `1.2.3 / 2020-01-01` alone on a line.
|
|
315
353
|
const history = new RegExp(String.raw `^v?(${VERSION})\s+/\s+(.+)$`).exec(line.trim());
|
|
@@ -352,10 +390,10 @@ export function parseChangelog(text) {
|
|
|
352
390
|
flush(state);
|
|
353
391
|
return state.sections;
|
|
354
392
|
}
|
|
355
|
-
/** True when `version` lies in (from, to] by
|
|
356
|
-
export function inRange(version, from, to) {
|
|
357
|
-
const lower =
|
|
358
|
-
const upper =
|
|
393
|
+
/** True when `version` lies in (from, to] by `order`; unreadable versions are out. */
|
|
394
|
+
export function inRange(version, from, to, order = compareVersionPrecedence) {
|
|
395
|
+
const lower = order(version, from);
|
|
396
|
+
const upper = order(version, to);
|
|
359
397
|
return lower !== null && upper !== null && lower > 0 && upper <= 0;
|
|
360
398
|
}
|
|
361
399
|
/**
|
|
@@ -365,10 +403,10 @@ export function inRange(version, from, to) {
|
|
|
365
403
|
* stopped being maintained two majors ago is reported as not covering the
|
|
366
404
|
* range instead of silently presenting a partial history as the whole one.
|
|
367
405
|
*/
|
|
368
|
-
export function selectRange(sections, from, to, firstInRange) {
|
|
369
|
-
const readable = sections.filter((s) =>
|
|
370
|
-
const selected = readable.filter((s) => inRange(s.version, from, to));
|
|
371
|
-
const reachesTop = readable.some((s) => (
|
|
372
|
-
const reachesBottom = readable.some((s) => (
|
|
406
|
+
export function selectRange(sections, from, to, firstInRange, order = compareVersionPrecedence) {
|
|
407
|
+
const readable = sections.filter((s) => order(s.version, s.version) !== null);
|
|
408
|
+
const selected = readable.filter((s) => inRange(s.version, from, to, order));
|
|
409
|
+
const reachesTop = readable.some((s) => (order(s.version, to) ?? -1) >= 0);
|
|
410
|
+
const reachesBottom = readable.some((s) => (order(s.version, firstInRange) ?? 1) <= 0);
|
|
373
411
|
return { sections: selected, coversRange: selected.length > 0 && reachesTop && reachesBottom };
|
|
374
412
|
}
|
package/dist/live/fix-paths.js
CHANGED
|
@@ -39,7 +39,7 @@ const VERSIONS_TTL = 6 * 3600;
|
|
|
39
39
|
const VERSION_DEPS_TTL = 30 * 86_400;
|
|
40
40
|
const MAX_OSV_PAGES = 10;
|
|
41
41
|
/** Bump when the shape of anything this module caches changes. */
|
|
42
|
-
const CACHE_VERSION =
|
|
42
|
+
const CACHE_VERSION = 3;
|
|
43
43
|
export class FixPathSources {
|
|
44
44
|
cache;
|
|
45
45
|
rateLimiter;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in changelog fallback for `upgrade_impact`: GitHub releases, and a
|
|
3
|
+
* changelog FILE a PyPI project links from `project_urls` when that file is
|
|
4
|
+
* on GitHub (Decision 1, option B, approved 2026-10-11).
|
|
5
|
+
*
|
|
6
|
+
* Off by default. The default contract stays option A: only the package's own
|
|
7
|
+
* registry and OSV.dev are contacted. A user who wants release notes for the
|
|
8
|
+
* many packages whose archive ships no changelog (vite, zod, cobra, most Go
|
|
9
|
+
* modules) sets FOURDA_GITHUB_CHANGELOG=true; GitHub then learns the
|
|
10
|
+
* repository and the version range, nothing else. GITHUB_TOKEN, when set, is
|
|
11
|
+
* sent as a bearer token (5,000 requests an hour instead of 60).
|
|
12
|
+
*
|
|
13
|
+
* What is parsed is what the project itself published as release notes: the
|
|
14
|
+
* release body (Markdown), read by the same parser and classifier as an
|
|
15
|
+
* archive changelog, one section per release whose tag names a version of
|
|
16
|
+
* this package. A docs page (HTML) is never parsed. Rate limits are reported,
|
|
17
|
+
* not retried: the answer says when the limit resets and how to raise it.
|
|
18
|
+
*/
|
|
19
|
+
import { type ChangelogSection, type VersionOrder } from "./changelog.js";
|
|
20
|
+
import type { ChangelogResult, UpgradeNet } from "./upgrade-sources.js";
|
|
21
|
+
export declare const GITHUB_CHANGELOG_ENV = "FOURDA_GITHUB_CHANGELOG";
|
|
22
|
+
/** True when the user opted in: FOURDA_GITHUB_CHANGELOG is 1 / true / yes / on. */
|
|
23
|
+
export declare function githubChangelogEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
24
|
+
/** `owner/repo` of a GitHub URL (https, git+https, git@, shorthand), or null. */
|
|
25
|
+
export declare function parseGithubRepo(url: string | null | undefined): {
|
|
26
|
+
owner: string;
|
|
27
|
+
repo: string;
|
|
28
|
+
} | null;
|
|
29
|
+
interface GithubRelease {
|
|
30
|
+
tag_name?: string;
|
|
31
|
+
name?: string | null;
|
|
32
|
+
body?: string | null;
|
|
33
|
+
draft?: boolean;
|
|
34
|
+
published_at?: string | null;
|
|
35
|
+
created_at?: string | null;
|
|
36
|
+
html_url?: string;
|
|
37
|
+
}
|
|
38
|
+
export declare class GithubRateLimitError extends Error {
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The version a release tag names for this package, or null when the tag is
|
|
42
|
+
* another package's (monorepos) or not a version. `tagPrefix` is a Go
|
|
43
|
+
* module's subdirectory (`service/s3`): its tags are `service/s3/v1.2.3`.
|
|
44
|
+
*/
|
|
45
|
+
export declare function versionFromTag(tag: string, pkg: string, tagPrefix: string | null): string | null;
|
|
46
|
+
/** One section per release that names a version of this package, entries parsed from its notes. */
|
|
47
|
+
export declare function releasesToSections(releases: GithubRelease[], pkg: string, tagPrefix: string | null): ChangelogSection[];
|
|
48
|
+
export interface GithubFallbackInput {
|
|
49
|
+
pkg: string;
|
|
50
|
+
ecosystem: string;
|
|
51
|
+
repository: string | null;
|
|
52
|
+
/** Changelog links the project published (PyPI `project_urls`). */
|
|
53
|
+
changelogLinks?: string[];
|
|
54
|
+
from: string;
|
|
55
|
+
to: string;
|
|
56
|
+
/** Go: the module's subdirectory in its repository, which prefixes its tags. */
|
|
57
|
+
tagPrefix?: string | null;
|
|
58
|
+
order: VersionOrder;
|
|
59
|
+
}
|
|
60
|
+
/** `https://github.com/o/r/blob/<ref>/<path>` (or raw.githubusercontent.com) to a raw file URL, when it names a changelog file. */
|
|
61
|
+
export declare function rawChangelogUrl(link: string): string | null;
|
|
62
|
+
export declare function githubCacheKey(kind: string, where: string, input: GithubFallbackInput): string;
|
|
63
|
+
/**
|
|
64
|
+
* The fallback: a GitHub changelog file the project links (PyPI), then the
|
|
65
|
+
* repository's releases. Caller checks the opt-in. Returns a not-found result
|
|
66
|
+
* with the reason (no GitHub repository, rate limit, no matching releases).
|
|
67
|
+
*/
|
|
68
|
+
export declare function getGithubChangelog(net: UpgradeNet, input: GithubFallbackInput): Promise<ChangelogResult>;
|
|
69
|
+
export {};
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Opt-in changelog fallback for `upgrade_impact`: GitHub releases, and a
|
|
4
|
+
* changelog FILE a PyPI project links from `project_urls` when that file is
|
|
5
|
+
* on GitHub (Decision 1, option B, approved 2026-10-11).
|
|
6
|
+
*
|
|
7
|
+
* Off by default. The default contract stays option A: only the package's own
|
|
8
|
+
* registry and OSV.dev are contacted. A user who wants release notes for the
|
|
9
|
+
* many packages whose archive ships no changelog (vite, zod, cobra, most Go
|
|
10
|
+
* modules) sets FOURDA_GITHUB_CHANGELOG=true; GitHub then learns the
|
|
11
|
+
* repository and the version range, nothing else. GITHUB_TOKEN, when set, is
|
|
12
|
+
* sent as a bearer token (5,000 requests an hour instead of 60).
|
|
13
|
+
*
|
|
14
|
+
* What is parsed is what the project itself published as release notes: the
|
|
15
|
+
* release body (Markdown), read by the same parser and classifier as an
|
|
16
|
+
* archive changelog, one section per release whose tag names a version of
|
|
17
|
+
* this package. A docs page (HTML) is never parsed. Rate limits are reported,
|
|
18
|
+
* not retried: the answer says when the limit resets and how to raise it.
|
|
19
|
+
*/
|
|
20
|
+
import { CHANGELOG_PARSER_VERSION, isChangelogName, parseChangelog } from "./changelog.js";
|
|
21
|
+
import { ArchiveError, MAX_FILE_BYTES } from "./package-archive.js";
|
|
22
|
+
export const GITHUB_CHANGELOG_ENV = "FOURDA_GITHUB_CHANGELOG";
|
|
23
|
+
const CACHE_SOURCE = "upgrade-impact";
|
|
24
|
+
/** A range whose target release is published: release notes rarely change after. */
|
|
25
|
+
const FOUND_TTL = 24 * 3600;
|
|
26
|
+
const MISS_TTL = 3600;
|
|
27
|
+
const PER_PAGE = 100;
|
|
28
|
+
const MAX_PAGES = 5;
|
|
29
|
+
const GITHUB_TIMEOUT_MS = 15_000;
|
|
30
|
+
const USER_AGENT = "4da-mcp-server (https://4da.ai; upgrade_impact)";
|
|
31
|
+
/** True when the user opted in: FOURDA_GITHUB_CHANGELOG is 1 / true / yes / on. */
|
|
32
|
+
export function githubChangelogEnabled(env = process.env) {
|
|
33
|
+
return /^(?:1|true|yes|on)$/i.test(String(env[GITHUB_CHANGELOG_ENV] ?? "").trim());
|
|
34
|
+
}
|
|
35
|
+
/** `owner/repo` of a GitHub URL (https, git+https, git@, shorthand), or null. */
|
|
36
|
+
export function parseGithubRepo(url) {
|
|
37
|
+
if (!url)
|
|
38
|
+
return null;
|
|
39
|
+
const raw = url.trim();
|
|
40
|
+
const m = /^(?:github:)?([\w.-]+)\/([\w.-]+)$/.exec(raw) ??
|
|
41
|
+
/github\.com[/:]([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:[/#?].*)?$/i.exec(raw);
|
|
42
|
+
if (!m)
|
|
43
|
+
return null;
|
|
44
|
+
const repo = m[2].replace(/\.git$/, "");
|
|
45
|
+
if (!repo || m[1] === "." || repo === "." || repo === "..")
|
|
46
|
+
return null;
|
|
47
|
+
return { owner: m[1], repo };
|
|
48
|
+
}
|
|
49
|
+
export class GithubRateLimitError extends Error {
|
|
50
|
+
}
|
|
51
|
+
function headers() {
|
|
52
|
+
const h = {
|
|
53
|
+
Accept: "application/vnd.github+json",
|
|
54
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
55
|
+
"User-Agent": USER_AGENT,
|
|
56
|
+
};
|
|
57
|
+
const token = process.env.GITHUB_TOKEN?.trim();
|
|
58
|
+
if (token)
|
|
59
|
+
h.Authorization = `Bearer ${token}`;
|
|
60
|
+
return h;
|
|
61
|
+
}
|
|
62
|
+
/** A 403/429 that is a rate limit, with when it lifts. */
|
|
63
|
+
function rateLimitMessage(res) {
|
|
64
|
+
if (res.status !== 403 && res.status !== 429)
|
|
65
|
+
return null;
|
|
66
|
+
const remaining = res.headers.get("x-ratelimit-remaining");
|
|
67
|
+
const retryAfter = res.headers.get("retry-after");
|
|
68
|
+
if (remaining !== "0" && !retryAfter && res.status !== 429)
|
|
69
|
+
return null;
|
|
70
|
+
const reset = Number(res.headers.get("x-ratelimit-reset"));
|
|
71
|
+
const when = Number.isFinite(reset) && reset > 0
|
|
72
|
+
? `resets at ${new Date(reset * 1000).toISOString()}`
|
|
73
|
+
: retryAfter
|
|
74
|
+
? `retry after ${retryAfter} s`
|
|
75
|
+
: "try again later";
|
|
76
|
+
const auth = process.env.GITHUB_TOKEN?.trim()
|
|
77
|
+
? "GITHUB_TOKEN is set; its hourly limit is spent"
|
|
78
|
+
: "set GITHUB_TOKEN to raise the limit from 60 to 5,000 requests an hour";
|
|
79
|
+
return `GitHub API rate limit reached (${when}); ${auth}`;
|
|
80
|
+
}
|
|
81
|
+
const KNOWN_TAG_PREFIX = /^(?:v|version[-_]?|release[-_]?|rel[-_]?)?$/i;
|
|
82
|
+
/**
|
|
83
|
+
* The version a release tag names for this package, or null when the tag is
|
|
84
|
+
* another package's (monorepos) or not a version. `tagPrefix` is a Go
|
|
85
|
+
* module's subdirectory (`service/s3`): its tags are `service/s3/v1.2.3`.
|
|
86
|
+
*/
|
|
87
|
+
export function versionFromTag(tag, pkg, tagPrefix) {
|
|
88
|
+
let rest = tag.trim();
|
|
89
|
+
if (tagPrefix) {
|
|
90
|
+
if (!rest.startsWith(`${tagPrefix}/`))
|
|
91
|
+
return null;
|
|
92
|
+
rest = rest.slice(tagPrefix.length + 1);
|
|
93
|
+
}
|
|
94
|
+
const m = /^(.*?)(\d+\.\d+(?:\.\d+)*(?:[-.+]?[0-9A-Za-z][0-9A-Za-z.+-]*)?)$/.exec(rest);
|
|
95
|
+
if (!m)
|
|
96
|
+
return null;
|
|
97
|
+
const prefix = m[1];
|
|
98
|
+
if (KNOWN_TAG_PREFIX.test(prefix))
|
|
99
|
+
return m[2];
|
|
100
|
+
// A monorepo tag: `pkg@1.2.3`, `pkg-v1.2.3`, `@scope/pkg@1.2.3`, `pkg/v1.2.3`.
|
|
101
|
+
const fold = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
102
|
+
const bare = prefix.replace(/(?:[@/_-]v?|v)$/i, "");
|
|
103
|
+
const name = pkg.split("/").pop() ?? pkg;
|
|
104
|
+
return fold(bare) === fold(pkg) || fold(bare) === fold(name) ? m[2] : null;
|
|
105
|
+
}
|
|
106
|
+
/** Release-body lines that are bookkeeping, not changes. */
|
|
107
|
+
const NOISE = /made their first contribution|^\W*full changelog\W|^\W*(?:new )?contributors?\W*$/i;
|
|
108
|
+
/** One section per release that names a version of this package, entries parsed from its notes. */
|
|
109
|
+
export function releasesToSections(releases, pkg, tagPrefix) {
|
|
110
|
+
const sections = [];
|
|
111
|
+
for (const r of releases) {
|
|
112
|
+
if (r.draft || typeof r.tag_name !== "string")
|
|
113
|
+
continue;
|
|
114
|
+
const version = versionFromTag(r.tag_name, pkg, tagPrefix);
|
|
115
|
+
if (!version)
|
|
116
|
+
continue;
|
|
117
|
+
const date = (r.published_at ?? r.created_at ?? "").slice(0, 10) || null;
|
|
118
|
+
// A synthetic heading makes the body one release; a body that repeats its own
|
|
119
|
+
// version heading opens the same version again, and those entries are kept too.
|
|
120
|
+
const parsed = parseChangelog(`# ${version}\n\n${(r.body ?? "").slice(0, MAX_FILE_BYTES)}`);
|
|
121
|
+
const entries = parsed
|
|
122
|
+
.filter((s, i) => i === 0 || s.version.replace(/^v/, "") === version.replace(/^v/, ""))
|
|
123
|
+
.flatMap((s) => s.entries)
|
|
124
|
+
.filter((e) => !NOISE.test(e.text) && !(e.under && /^\W*new contributors\W*$/i.test(e.under)));
|
|
125
|
+
sections.push({ version, date, entries });
|
|
126
|
+
}
|
|
127
|
+
return sections;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Release notes for `pkg` from `owner/repo`, newest first, paging until a
|
|
131
|
+
* release at or below `from` is seen (at most MAX_PAGES pages of 100).
|
|
132
|
+
*/
|
|
133
|
+
async function fetchReleases(net, repo, stop, pkg, tagPrefix) {
|
|
134
|
+
const out = [];
|
|
135
|
+
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
136
|
+
const url = `https://api.github.com/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}/releases?per_page=${PER_PAGE}&page=${page}`;
|
|
137
|
+
const res = await net.fetch(url, { headers: headers() }, GITHUB_TIMEOUT_MS);
|
|
138
|
+
const limited = rateLimitMessage(res);
|
|
139
|
+
if (limited)
|
|
140
|
+
throw new GithubRateLimitError(limited);
|
|
141
|
+
if (res.status === 404)
|
|
142
|
+
throw new Error(`github.com/${repo.owner}/${repo.repo} has no public releases API (not found or private)`);
|
|
143
|
+
if (!res.ok)
|
|
144
|
+
throw new Error(`GitHub API HTTP ${res.status}`);
|
|
145
|
+
const batch = (await res.json());
|
|
146
|
+
if (!Array.isArray(batch))
|
|
147
|
+
throw new Error("GitHub API returned an unexpected releases payload");
|
|
148
|
+
out.push(...batch);
|
|
149
|
+
const reachedFrom = batch.some((r) => {
|
|
150
|
+
const v = typeof r.tag_name === "string" ? versionFromTag(r.tag_name, pkg, tagPrefix) : null;
|
|
151
|
+
return v !== null && stop(v);
|
|
152
|
+
});
|
|
153
|
+
if (batch.length < PER_PAGE || reachedFrom)
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
/** `https://github.com/o/r/blob/<ref>/<path>` (or raw.githubusercontent.com) to a raw file URL, when it names a changelog file. */
|
|
159
|
+
export function rawChangelogUrl(link) {
|
|
160
|
+
let m = /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/(?:blob|raw)\/([^?#]+)$/i.exec(link.trim());
|
|
161
|
+
let rawUrl = m ? `https://raw.githubusercontent.com/${m[1]}/${m[2]}/${m[3]}` : null;
|
|
162
|
+
if (!rawUrl) {
|
|
163
|
+
m = /^https:\/\/raw\.githubusercontent\.com\/[\w.-]+\/[\w.-]+\/[^?#]+$/i.exec(link.trim());
|
|
164
|
+
rawUrl = m ? link.trim() : null;
|
|
165
|
+
}
|
|
166
|
+
if (!rawUrl)
|
|
167
|
+
return null;
|
|
168
|
+
const base = rawUrl.split("/").pop() ?? "";
|
|
169
|
+
return isChangelogName(base) ? rawUrl : null;
|
|
170
|
+
}
|
|
171
|
+
async function fetchChangelogFile(net, url) {
|
|
172
|
+
const res = await net.fetch(url, { headers: { "User-Agent": USER_AGENT } }, GITHUB_TIMEOUT_MS);
|
|
173
|
+
const limited = rateLimitMessage(res);
|
|
174
|
+
if (limited)
|
|
175
|
+
throw new GithubRateLimitError(limited);
|
|
176
|
+
if (!res.ok)
|
|
177
|
+
throw new Error(`HTTP ${res.status} for ${url}`);
|
|
178
|
+
const declared = Number(res.headers.get("content-length"));
|
|
179
|
+
if (Number.isFinite(declared) && declared > MAX_FILE_BYTES)
|
|
180
|
+
throw new ArchiveError(`${url} is over ${MAX_FILE_BYTES} bytes`);
|
|
181
|
+
const text = await res.text();
|
|
182
|
+
if (text.length > MAX_FILE_BYTES)
|
|
183
|
+
throw new ArchiveError(`${url} is over ${MAX_FILE_BYTES} bytes`);
|
|
184
|
+
return parseChangelog(text);
|
|
185
|
+
}
|
|
186
|
+
function inRangeCount(sections, from, to, order) {
|
|
187
|
+
return sections.filter((s) => (order(s.version, from) ?? 0) > 0 && (order(s.version, to) ?? 1) <= 0).length;
|
|
188
|
+
}
|
|
189
|
+
export function githubCacheKey(kind, where, input) {
|
|
190
|
+
return `upgrade-impact:github:p${CHANGELOG_PARSER_VERSION}:${kind}:${where}:${input.tagPrefix ?? ""}:${input.ecosystem}:${input.pkg}:${input.from}:${input.to}`;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The fallback: a GitHub changelog file the project links (PyPI), then the
|
|
194
|
+
* repository's releases. Caller checks the opt-in. Returns a not-found result
|
|
195
|
+
* with the reason (no GitHub repository, rate limit, no matching releases).
|
|
196
|
+
*/
|
|
197
|
+
export async function getGithubChangelog(net, input) {
|
|
198
|
+
const reasons = [];
|
|
199
|
+
for (const link of input.changelogLinks ?? []) {
|
|
200
|
+
const raw = rawChangelogUrl(link);
|
|
201
|
+
if (!raw)
|
|
202
|
+
continue;
|
|
203
|
+
const key = githubCacheKey("file", raw, input);
|
|
204
|
+
const hit = net.cache?.get(key);
|
|
205
|
+
if (hit)
|
|
206
|
+
return hit;
|
|
207
|
+
try {
|
|
208
|
+
const sections = await fetchChangelogFile(net, raw);
|
|
209
|
+
if (inRangeCount(sections, input.from, input.to, input.order) > 0) {
|
|
210
|
+
const result = { found: true, file: raw.split("/").pop(), sections, source: "github-file", url: link };
|
|
211
|
+
net.cache?.set(key, result, CACHE_SOURCE, FOUND_TTL);
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
reasons.push(`the changelog the project links (${link}) has no section for these releases`);
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
if (err instanceof GithubRateLimitError)
|
|
218
|
+
return { found: false, reason: err.message };
|
|
219
|
+
reasons.push(`the changelog the project links could not be read: ${err.message}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const repo = parseGithubRepo(input.repository);
|
|
223
|
+
if (!repo) {
|
|
224
|
+
reasons.push("the package names no GitHub repository, so there are no GitHub releases to read");
|
|
225
|
+
return { found: false, reason: reasons.join("; ") };
|
|
226
|
+
}
|
|
227
|
+
const where = `${repo.owner}/${repo.repo}`;
|
|
228
|
+
const key = githubCacheKey("releases", where, input);
|
|
229
|
+
const hit = net.cache?.get(key);
|
|
230
|
+
if (hit)
|
|
231
|
+
return hit;
|
|
232
|
+
const releasesUrl = `https://github.com/${where}/releases`;
|
|
233
|
+
try {
|
|
234
|
+
const releases = await fetchReleases(net, repo, (v) => (input.order(v, input.from) ?? 1) <= 0, input.pkg, input.tagPrefix ?? null);
|
|
235
|
+
const sections = releasesToSections(releases, input.pkg, input.tagPrefix ?? null);
|
|
236
|
+
const hits = inRangeCount(sections, input.from, input.to, input.order);
|
|
237
|
+
const result = hits > 0
|
|
238
|
+
? { found: true, file: "GitHub releases", sections, source: "github-releases", url: releasesUrl }
|
|
239
|
+
: {
|
|
240
|
+
found: false,
|
|
241
|
+
url: releasesUrl,
|
|
242
|
+
reason: [...reasons, `${where} has no GitHub release for ${input.pkg} between ${input.from} and ${input.to}`].join("; "),
|
|
243
|
+
};
|
|
244
|
+
const targetFound = sections.some((s) => input.order(s.version, input.to) === 0);
|
|
245
|
+
net.cache?.set(key, result, CACHE_SOURCE, targetFound ? FOUND_TTL : MISS_TTL);
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
// A rate limit or network failure is not remembered: the next call may succeed.
|
|
250
|
+
return { found: false, url: releasesUrl, reason: [...reasons, `GitHub releases could not be read: ${err.message}`].join("; ") };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Go module release index for dependency_check, from the module proxy
|
|
3
|
+
* (proxy.golang.org, the host `go get` downloads from):
|
|
4
|
+
*
|
|
5
|
+
* - `/@v/list`: the tagged versions, `+incompatible` ones included (a module
|
|
6
|
+
* that predates modules and tags v2+ without a `/v2` path).
|
|
7
|
+
* - `/@v/<version>.info`: one release's time. The list carries no dates, so
|
|
8
|
+
* this is read for the versions being judged only (cached; a published
|
|
9
|
+
* version never changes). A pseudo-version (`v0.0.0-20240102150405-abcdef123456`)
|
|
10
|
+
* is not in the list; its `.info` both confirms it exists and dates it.
|
|
11
|
+
* - The latest version's `go.mod`: its `retract` directives withdraw versions
|
|
12
|
+
* (`go get` skips them), reported like a yank.
|
|
13
|
+
* - An untagged module: `@latest` gives its newest pseudo-version.
|
|
14
|
+
*
|
|
15
|
+
* Major versions 2+ are different module paths (`.../v5`); the proxy lists
|
|
16
|
+
* each path's own versions only.
|
|
17
|
+
*
|
|
18
|
+
* Not applicable to Go and reported so by the signals: publisher identity
|
|
19
|
+
* (integrity is checked against sum.golang.org instead), install hooks (Go
|
|
20
|
+
* runs no package code at download), per-release dependency lists.
|
|
21
|
+
*/
|
|
22
|
+
import type { LiveCache } from "./cache.js";
|
|
23
|
+
import type { RateLimiter } from "./rate-limiter.js";
|
|
24
|
+
import type { PackageReleaseIndex, RegistryLookup, ReleaseMetadataSource } from "./release-metadata.js";
|
|
25
|
+
export declare const GO_NOT_APPLICABLE = "not applicable to Go modules: the proxy carries no publisher identity (downloads are checked against sum.golang.org)";
|
|
26
|
+
/** A Go version: release, prerelease, pseudo-version, optional `+incompatible`. */
|
|
27
|
+
export declare const GO_VERSION: RegExp;
|
|
28
|
+
/** `v`-prefixed spelling of a Go version ("1.2.3" -> "v1.2.3"). */
|
|
29
|
+
export declare function goVersion(raw: string): string;
|
|
30
|
+
/** The commit time a pseudo-version embeds, as ISO, or null when `version` is not one. */
|
|
31
|
+
export declare function pseudoVersionTime(version: string): string | null;
|
|
32
|
+
export interface Retraction {
|
|
33
|
+
low: string;
|
|
34
|
+
high: string;
|
|
35
|
+
rationale: string | null;
|
|
36
|
+
}
|
|
37
|
+
/** `retract` directives of a go.mod: single versions, `[low, high]` intervals, and blocks of either. */
|
|
38
|
+
export declare function parseRetractions(goMod: string): Retraction[];
|
|
39
|
+
export declare class GoReleasesReader implements ReleaseMetadataSource {
|
|
40
|
+
private cache;
|
|
41
|
+
private rateLimiter;
|
|
42
|
+
constructor(cache: LiveCache, rateLimiter: RateLimiter);
|
|
43
|
+
getReleases(module: string, wanted: string[]): Promise<RegistryLookup<PackageReleaseIndex>>;
|
|
44
|
+
getCreatedAt(): Promise<RegistryLookup<string | null>>;
|
|
45
|
+
private list;
|
|
46
|
+
private info;
|
|
47
|
+
private get;
|
|
48
|
+
}
|