@imenam/mcp-documentor 1.0.76 → 1.0.78
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/README.md +58 -0
- package/dist/shared/schemas/doc.schema.d.ts +6 -0
- package/dist/shared/schemas/doc.schema.js +18 -2
- package/dist/src/cli/setup-mcp.js +3 -0
- package/dist/src/core/code-operation.d.ts +10 -0
- package/dist/src/core/code-operation.js +42 -0
- package/dist/src/core/code-ref-resolver.d.ts +11 -4
- package/dist/src/core/code-ref-resolver.js +76 -51
- package/dist/src/core/code-source.d.ts +79 -0
- package/dist/src/core/code-source.js +248 -0
- package/dist/src/core/github-code-api.d.ts +72 -0
- package/dist/src/core/github-code-api.js +182 -0
- package/dist/src/core/section-tracker.d.ts +38 -13
- package/dist/src/core/section-tracker.js +75 -72
- package/dist/src/gui-worker.js +12 -5
- package/dist/src/index.js +19 -11
- package/dist/src/tools/check-freshness.d.ts +1 -2
- package/dist/src/tools/check-freshness.js +82 -34
- package/dist/src/tools/create-doc.js +2 -2
- package/dist/src/tools/get-doc-metadata.js +14 -13
- package/dist/src/tools/get-doc.js +23 -20
- package/dist/src/tools/git-diff-doc.js +69 -13
- package/dist/src/tools/list-docs.js +2 -2
- package/dist/src/tools/patch-doc.js +2 -2
- package/dist/src/tools/refresh-file-snapshot.js +19 -17
- package/dist/src/tools/update-doc.js +4 -4
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { createLogger } from "@imenam/mcp-gui-interface";
|
|
4
|
+
import { bytesToFileContent, findAndHashSection } from "./section-tracker.js";
|
|
5
|
+
import { getCodeRoot, toAbsolutePath, toStoredPath as toStoredPathLocal } from "./path-resolver.js";
|
|
6
|
+
import { GitHubCodeApi, parseRepoSpec, formatRepoSpec } from "./github-code-api.js";
|
|
7
|
+
const logger = createLogger("CodeSource");
|
|
8
|
+
class LocalCodeSource {
|
|
9
|
+
project;
|
|
10
|
+
kind = "local";
|
|
11
|
+
constructor(project) {
|
|
12
|
+
this.project = project;
|
|
13
|
+
}
|
|
14
|
+
get label() {
|
|
15
|
+
const root = getCodeRoot(this.project);
|
|
16
|
+
return root ? `local working copy (${root})` : "local working copy";
|
|
17
|
+
}
|
|
18
|
+
async read(storedPath) {
|
|
19
|
+
const absolutePath = toAbsolutePath(storedPath, this.project);
|
|
20
|
+
if (!absolutePath || !fs.existsSync(absolutePath))
|
|
21
|
+
return undefined;
|
|
22
|
+
return {
|
|
23
|
+
content: bytesToFileContent(fs.readFileSync(absolutePath)),
|
|
24
|
+
lastModifiedAt: fs.statSync(absolutePath).mtime.toISOString(),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** A working copy has no identity cheaper than reading the file, so there is no shortcut. */
|
|
28
|
+
async contentId() {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
async toStoredPath(inputPath) {
|
|
32
|
+
// A relative input means "relative to the project's code root" — the same convention used
|
|
33
|
+
// when a stored path is read back. Never the server's cwd.
|
|
34
|
+
const absolute = toAbsolutePath(inputPath, this.project);
|
|
35
|
+
if (!absolute) {
|
|
36
|
+
throw new Error(`the path is relative but no code root is configured for project "${this.project}". ` +
|
|
37
|
+
`Provide an absolute path or set MCP_DOCUMENTOR_CODE_ROOT.`);
|
|
38
|
+
}
|
|
39
|
+
return toStoredPathLocal(absolute, this.project);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const toForwardSlashes = (p) => p.split("\\").join("/");
|
|
43
|
+
class GitHubCodeSource {
|
|
44
|
+
api;
|
|
45
|
+
project;
|
|
46
|
+
kind = "github";
|
|
47
|
+
/** Agent-supplied path → repo-relative path, per source. Repo layouts barely move. */
|
|
48
|
+
repoPathCache = new Map();
|
|
49
|
+
constructor(api, project) {
|
|
50
|
+
this.api = api;
|
|
51
|
+
this.project = project;
|
|
52
|
+
}
|
|
53
|
+
get label() {
|
|
54
|
+
return `GitHub ${this.api.label}`;
|
|
55
|
+
}
|
|
56
|
+
async read(storedPath) {
|
|
57
|
+
const { commitSha, commitDate } = await this.api.resolveRef();
|
|
58
|
+
const repoPath = await this.toRepoPath(storedPath, commitSha);
|
|
59
|
+
if (!repoPath)
|
|
60
|
+
return undefined;
|
|
61
|
+
const blobSha = await this.api.getBlobSha(commitSha, repoPath);
|
|
62
|
+
if (!blobSha)
|
|
63
|
+
return undefined;
|
|
64
|
+
return {
|
|
65
|
+
content: bytesToFileContent(await this.api.getBlob(blobSha)),
|
|
66
|
+
lastModifiedAt: commitDate,
|
|
67
|
+
revision: commitSha,
|
|
68
|
+
blobId: blobSha,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** The blob sha from the commit's tree — already fetched for the whole repository in one call. */
|
|
72
|
+
async contentId(storedPath) {
|
|
73
|
+
const { commitSha } = await this.api.resolveRef();
|
|
74
|
+
const repoPath = await this.toRepoPath(storedPath, commitSha);
|
|
75
|
+
return repoPath ? this.api.getBlobSha(commitSha, repoPath) : undefined;
|
|
76
|
+
}
|
|
77
|
+
async toStoredPath(inputPath) {
|
|
78
|
+
const { commitSha } = await this.api.resolveRef();
|
|
79
|
+
const repoPath = await this.toRepoPath(inputPath, commitSha);
|
|
80
|
+
if (!repoPath) {
|
|
81
|
+
throw new Error(`Cannot map "${inputPath}" to a path inside ${this.api.label}. ` +
|
|
82
|
+
`Provide the path relative to the repository root, or set a code root for project "${this.project}" ` +
|
|
83
|
+
`(MCP_DOCUMENTOR_CODE_ROOT / MCP_DOCUMENTOR_CODE_ROOTS) so absolute paths can be stripped down to it.`);
|
|
84
|
+
}
|
|
85
|
+
return repoPath;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Maps a path onto the repository layout.
|
|
89
|
+
*
|
|
90
|
+
* A relative path is already repo-relative. An absolute one — what an agent working on a local
|
|
91
|
+
* clone naturally sends — is reduced by the configured code root when there is one, and
|
|
92
|
+
* otherwise matched against the repository tree by its longest unambiguous path suffix, so the
|
|
93
|
+
* feature works without forcing every machine to declare a root.
|
|
94
|
+
*/
|
|
95
|
+
async toRepoPath(inputPath, commitSha) {
|
|
96
|
+
if (this.repoPathCache.has(inputPath))
|
|
97
|
+
return this.repoPathCache.get(inputPath);
|
|
98
|
+
const resolved = await this.computeRepoPath(inputPath, commitSha);
|
|
99
|
+
this.repoPathCache.set(inputPath, resolved);
|
|
100
|
+
return resolved;
|
|
101
|
+
}
|
|
102
|
+
async computeRepoPath(inputPath, commitSha) {
|
|
103
|
+
const forward = toForwardSlashes(inputPath);
|
|
104
|
+
if (!path.isAbsolute(inputPath) && !/^[A-Za-z]:\//.test(forward)) {
|
|
105
|
+
return forward.replace(/^\.\//, "").replace(/^\/+/, "");
|
|
106
|
+
}
|
|
107
|
+
// A configured code root is the authoritative answer when the file sits under it.
|
|
108
|
+
const viaRoot = toForwardSlashes(toStoredPathLocal(inputPath, this.project));
|
|
109
|
+
if (!path.isAbsolute(viaRoot) && !/^[A-Za-z]:\//.test(viaRoot))
|
|
110
|
+
return viaRoot;
|
|
111
|
+
const tree = await this.api.getTree(commitSha);
|
|
112
|
+
const candidates = [...tree.keys()].filter((repoPath) => forward.endsWith(`/${repoPath}`));
|
|
113
|
+
if (candidates.length === 0)
|
|
114
|
+
return undefined;
|
|
115
|
+
const best = candidates.reduce((longest, current) => (current.length > longest.length ? current : longest));
|
|
116
|
+
if (!best.includes("/")) {
|
|
117
|
+
// A bare filename is only trustworthy when the repository holds exactly one file by that name.
|
|
118
|
+
const sameName = [...tree.keys()].filter((repoPath) => repoPath.split("/").pop() === best);
|
|
119
|
+
if (sameName.length > 1) {
|
|
120
|
+
logger.warn(`"${inputPath}" matches ${sameName.length} files named "${best}" in ${this.api.label}; not mapping it`);
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return best;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
let settings;
|
|
128
|
+
const sourcesByProject = new Map();
|
|
129
|
+
const apisByRepo = new Map();
|
|
130
|
+
function loadSettings() {
|
|
131
|
+
if (settings)
|
|
132
|
+
return settings;
|
|
133
|
+
// One setting, two shapes. A repository is not a shared parent the way a code root is — it
|
|
134
|
+
// names exactly one codebase — so an instance serving several projects needs one spec per
|
|
135
|
+
// project, while a single-project or monorepo instance needs just the spec. A leading "{"
|
|
136
|
+
// tells the two apart unambiguously, since no repository spec starts with one.
|
|
137
|
+
const raw = process.env.MCP_DOCUMENTOR_CODE_REPO?.trim() || undefined;
|
|
138
|
+
const byProject = {};
|
|
139
|
+
let defaultSpec;
|
|
140
|
+
if (raw?.startsWith("{")) {
|
|
141
|
+
try {
|
|
142
|
+
const parsed = JSON.parse(raw);
|
|
143
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
144
|
+
for (const [proj, spec] of Object.entries(parsed)) {
|
|
145
|
+
if (typeof spec === "string" && spec.trim() !== "")
|
|
146
|
+
byProject[proj] = spec.trim();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
logger.warn("MCP_DOCUMENTOR_CODE_REPO is not a JSON object, ignoring it");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
logger.warn("MCP_DOCUMENTOR_CODE_REPO looks like a JSON map but could not be parsed, ignoring it");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
defaultSpec = raw;
|
|
159
|
+
}
|
|
160
|
+
settings = {
|
|
161
|
+
defaultSpec,
|
|
162
|
+
byProject,
|
|
163
|
+
defaultBranch: process.env.MCP_DOCUMENTOR_CODE_BRANCH?.trim() || undefined,
|
|
164
|
+
token: process.env.MCP_DOCUMENTOR_CODE_TOKEN?.trim() || process.env.MCP_DOCUMENTOR_SYNC_TOKEN?.trim() || undefined,
|
|
165
|
+
};
|
|
166
|
+
return settings;
|
|
167
|
+
}
|
|
168
|
+
/** Test-only / config-reload: drops the memoised environment snapshot and every cached source. */
|
|
169
|
+
export function resetCodeSourceCache() {
|
|
170
|
+
settings = undefined;
|
|
171
|
+
sourcesByProject.clear();
|
|
172
|
+
apisByRepo.clear();
|
|
173
|
+
}
|
|
174
|
+
function buildSource(project) {
|
|
175
|
+
const config = loadSettings();
|
|
176
|
+
const raw = config.byProject[project] ?? config.defaultSpec;
|
|
177
|
+
if (!raw || raw.toLowerCase() === "local")
|
|
178
|
+
return new LocalCodeSource(project);
|
|
179
|
+
let spec;
|
|
180
|
+
try {
|
|
181
|
+
spec = parseRepoSpec(raw);
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
185
|
+
logger.warn(`Code repository for project "${project}" is unusable, falling back to the local working copy: ${message}`);
|
|
186
|
+
return new LocalCodeSource(project);
|
|
187
|
+
}
|
|
188
|
+
if (!spec.ref && config.defaultBranch)
|
|
189
|
+
spec = { ...spec, ref: config.defaultBranch };
|
|
190
|
+
// One API client per (repo, ref): its tree and blob caches are then shared by every project
|
|
191
|
+
// pointing at the same branch.
|
|
192
|
+
const key = formatRepoSpec(spec);
|
|
193
|
+
let api = apisByRepo.get(key);
|
|
194
|
+
if (!api) {
|
|
195
|
+
api = new GitHubCodeApi(spec, config.token);
|
|
196
|
+
apisByRepo.set(key, api);
|
|
197
|
+
logger.info(`Project "${project}" reads its code from GitHub ${key}`);
|
|
198
|
+
}
|
|
199
|
+
return new GitHubCodeSource(api, project);
|
|
200
|
+
}
|
|
201
|
+
/** The code source a project reads from. Memoised — the configuration is environment-fixed. */
|
|
202
|
+
export function getCodeSource(project) {
|
|
203
|
+
let source = sourcesByProject.get(project);
|
|
204
|
+
if (!source) {
|
|
205
|
+
source = buildSource(project);
|
|
206
|
+
sourcesByProject.set(project, source);
|
|
207
|
+
}
|
|
208
|
+
return source;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Where each tracked section of a file currently sits, as 1-indexed line ranges — `undefined`
|
|
212
|
+
* for a section whose anchors no longer match, and `undefined` for every section when the file
|
|
213
|
+
* itself cannot be read. Used to show positions without storing them.
|
|
214
|
+
*/
|
|
215
|
+
export async function locateTrackedSections(project, storedPath, sections) {
|
|
216
|
+
let file;
|
|
217
|
+
try {
|
|
218
|
+
file = await getCodeSource(project).read(storedPath);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// Reporting positions is never worth failing a read of the doc itself.
|
|
222
|
+
file = undefined;
|
|
223
|
+
}
|
|
224
|
+
if (!file)
|
|
225
|
+
return sections.map(() => undefined);
|
|
226
|
+
return sections.map((section) => {
|
|
227
|
+
const result = findAndHashSection(file.content, section);
|
|
228
|
+
return result.found ? result.lines : undefined;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Stored form of an agent-supplied path, for callers that only use it to match against paths a
|
|
233
|
+
* doc already tracks. A path the source cannot place is returned normalised rather than raising:
|
|
234
|
+
* it simply matches nothing, which is the right outcome for a filter.
|
|
235
|
+
*/
|
|
236
|
+
export async function toStoredPathForMatching(project, inputPath) {
|
|
237
|
+
try {
|
|
238
|
+
return await getCodeSource(project).toStoredPath(inputPath);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return toForwardSlashes(inputPath);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/** The GitHub client behind a project's source, or undefined when it reads the local working copy. */
|
|
245
|
+
export function getGitHubApi(project) {
|
|
246
|
+
const source = getCodeSource(project);
|
|
247
|
+
return source instanceof GitHubCodeSource ? source.api : undefined;
|
|
248
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** A repository and the ref to read it at. `ref` undefined means the repo's default branch. */
|
|
2
|
+
export interface RepoSpec {
|
|
3
|
+
owner: string;
|
|
4
|
+
repo: string;
|
|
5
|
+
ref?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function formatRepoSpec(spec: RepoSpec): string;
|
|
8
|
+
/**
|
|
9
|
+
* Parses the repo half of a code-source setting.
|
|
10
|
+
*
|
|
11
|
+
* Accepted forms — with an optional `#branch` suffix on any of them:
|
|
12
|
+
* owner/repo
|
|
13
|
+
* https://github.com/owner/repo(.git)
|
|
14
|
+
* git@github.com:owner/repo(.git)
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseRepoSpec(raw: string): RepoSpec;
|
|
17
|
+
/** A ref resolved to the commit it pointed at. */
|
|
18
|
+
interface ResolvedRef {
|
|
19
|
+
commitSha: string;
|
|
20
|
+
/** Commit date of that commit — used as the snapshot timestamp for files read at this ref. */
|
|
21
|
+
commitDate: string;
|
|
22
|
+
}
|
|
23
|
+
export declare class GitHubApiError extends Error {
|
|
24
|
+
readonly status?: number | undefined;
|
|
25
|
+
constructor(message: string, status?: number | undefined);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Read-only access to a repository's file tree over the GitHub API.
|
|
29
|
+
*
|
|
30
|
+
* Reading a file takes three pieces of information, each cached at its own lifetime:
|
|
31
|
+
* - the commit a ref points at — the only time-varying one, so it is resolved once per
|
|
32
|
+
* operation and never carried across them (see `code-operation`);
|
|
33
|
+
* - that commit's tree (path → blob sha) — immutable, cached per commit;
|
|
34
|
+
* - a blob's bytes — immutable, cached per blob sha, so a file that survives a commit
|
|
35
|
+
* untouched is never downloaded twice.
|
|
36
|
+
*
|
|
37
|
+
* One instance per (repo, ref) pair; instances are shared across projects by `code-source`.
|
|
38
|
+
*/
|
|
39
|
+
export declare class GitHubCodeApi {
|
|
40
|
+
private readonly spec;
|
|
41
|
+
private readonly token;
|
|
42
|
+
private readonly treeByCommit;
|
|
43
|
+
private readonly truncatedCommits;
|
|
44
|
+
private readonly blobCache;
|
|
45
|
+
constructor(spec: RepoSpec, token: string | undefined);
|
|
46
|
+
get label(): string;
|
|
47
|
+
private request;
|
|
48
|
+
/**
|
|
49
|
+
* The commit the configured ref points at right now.
|
|
50
|
+
*
|
|
51
|
+
* Resolved afresh for each operation — so a push is visible to the very next call — but only
|
|
52
|
+
* once within it, however many files that operation ends up reading.
|
|
53
|
+
*/
|
|
54
|
+
resolveRef(): Promise<ResolvedRef>;
|
|
55
|
+
/** path → blob sha for every file in the commit's tree. */
|
|
56
|
+
getTree(commitSha: string): Promise<Map<string, string>>;
|
|
57
|
+
/** Whether the tree we hold for this commit is known to be incomplete. */
|
|
58
|
+
isTreeTruncated(commitSha: string): boolean;
|
|
59
|
+
/** Blob sha for one path at one commit, or undefined if the file does not exist there. */
|
|
60
|
+
getBlobSha(commitSha: string, repoPath: string): Promise<string | undefined>;
|
|
61
|
+
/** A blob's bytes. Blobs are immutable, so this is cached for the life of the process. */
|
|
62
|
+
getBlob(blobSha: string): Promise<Buffer>;
|
|
63
|
+
/**
|
|
64
|
+
* Unified diff of every file that changed between two commits, keyed by path.
|
|
65
|
+
*
|
|
66
|
+
* One call covers a whole doc, so `git_diff_doc` groups its tracked files by the commit they
|
|
67
|
+
* were snapshotted at rather than asking per file. GitHub caps the response at 300 files;
|
|
68
|
+
* paths beyond that are simply absent from the map.
|
|
69
|
+
*/
|
|
70
|
+
comparePatches(baseSha: string, headSha: string): Promise<Map<string, string>>;
|
|
71
|
+
}
|
|
72
|
+
export {};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { createLogger } from "@imenam/mcp-gui-interface";
|
|
2
|
+
import { oncePerOperation } from "./code-operation.js";
|
|
3
|
+
const logger = createLogger("GitHubCodeApi");
|
|
4
|
+
const API_BASE = "https://api.github.com";
|
|
5
|
+
export function formatRepoSpec(spec) {
|
|
6
|
+
return `${spec.owner}/${spec.repo}@${spec.ref ?? "HEAD"}`;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Parses the repo half of a code-source setting.
|
|
10
|
+
*
|
|
11
|
+
* Accepted forms — with an optional `#branch` suffix on any of them:
|
|
12
|
+
* owner/repo
|
|
13
|
+
* https://github.com/owner/repo(.git)
|
|
14
|
+
* git@github.com:owner/repo(.git)
|
|
15
|
+
*/
|
|
16
|
+
export function parseRepoSpec(raw) {
|
|
17
|
+
const trimmed = raw.trim();
|
|
18
|
+
const hashIdx = trimmed.indexOf("#");
|
|
19
|
+
const withoutRef = hashIdx >= 0 ? trimmed.slice(0, hashIdx) : trimmed;
|
|
20
|
+
const ref = hashIdx >= 0 ? trimmed.slice(hashIdx + 1).trim() || undefined : undefined;
|
|
21
|
+
// Strip any transport prefix and the trailing .git, leaving "owner/repo".
|
|
22
|
+
const slug = withoutRef
|
|
23
|
+
.replace(/^https?:\/\/[^/]+\//i, "")
|
|
24
|
+
.replace(/^git@[^:]+:/i, "")
|
|
25
|
+
.replace(/\.git$/i, "")
|
|
26
|
+
.replace(/^\/+|\/+$/g, "");
|
|
27
|
+
const parts = slug.split("/").filter(Boolean);
|
|
28
|
+
const NAME = /^[A-Za-z0-9._-]+$/;
|
|
29
|
+
if (parts.length !== 2 || !NAME.test(parts[0]) || !NAME.test(parts[1])) {
|
|
30
|
+
throw new Error(`Invalid repository "${raw}". Expected "owner/repo", a GitHub URL, optionally suffixed with "#branch".`);
|
|
31
|
+
}
|
|
32
|
+
return { owner: parts[0], repo: parts[1], ref };
|
|
33
|
+
}
|
|
34
|
+
export class GitHubApiError extends Error {
|
|
35
|
+
status;
|
|
36
|
+
constructor(message, status) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.status = status;
|
|
39
|
+
this.name = "GitHubApiError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Read-only access to a repository's file tree over the GitHub API.
|
|
44
|
+
*
|
|
45
|
+
* Reading a file takes three pieces of information, each cached at its own lifetime:
|
|
46
|
+
* - the commit a ref points at — the only time-varying one, so it is resolved once per
|
|
47
|
+
* operation and never carried across them (see `code-operation`);
|
|
48
|
+
* - that commit's tree (path → blob sha) — immutable, cached per commit;
|
|
49
|
+
* - a blob's bytes — immutable, cached per blob sha, so a file that survives a commit
|
|
50
|
+
* untouched is never downloaded twice.
|
|
51
|
+
*
|
|
52
|
+
* One instance per (repo, ref) pair; instances are shared across projects by `code-source`.
|
|
53
|
+
*/
|
|
54
|
+
export class GitHubCodeApi {
|
|
55
|
+
spec;
|
|
56
|
+
token;
|
|
57
|
+
treeByCommit = new Map();
|
|
58
|
+
truncatedCommits = new Set();
|
|
59
|
+
blobCache = new Map();
|
|
60
|
+
constructor(spec, token) {
|
|
61
|
+
this.spec = spec;
|
|
62
|
+
this.token = token;
|
|
63
|
+
}
|
|
64
|
+
get label() {
|
|
65
|
+
return formatRepoSpec(this.spec);
|
|
66
|
+
}
|
|
67
|
+
async request(path) {
|
|
68
|
+
const headers = {
|
|
69
|
+
Accept: "application/vnd.github+json",
|
|
70
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
71
|
+
"User-Agent": "mcp-documentor",
|
|
72
|
+
};
|
|
73
|
+
if (this.token)
|
|
74
|
+
headers.Authorization = `Bearer ${this.token}`;
|
|
75
|
+
let response;
|
|
76
|
+
try {
|
|
77
|
+
response = await fetch(`${API_BASE}${path}`, { headers });
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
81
|
+
throw new GitHubApiError(`network error contacting GitHub (${message})`);
|
|
82
|
+
}
|
|
83
|
+
// 422 is what a valid repository returns for a ref that does not exist, so a mistyped
|
|
84
|
+
// branch lands here rather than on 404 — both mean "nothing to read at that address".
|
|
85
|
+
if (response.status === 404 || response.status === 422) {
|
|
86
|
+
throw new GitHubApiError(`not found: ${this.label} (repository, branch or path missing — or the token cannot see this repository)`, response.status);
|
|
87
|
+
}
|
|
88
|
+
if (response.status === 401 || response.status === 403) {
|
|
89
|
+
const rateLimited = response.headers.get("x-ratelimit-remaining") === "0";
|
|
90
|
+
throw new GitHubApiError(rateLimited
|
|
91
|
+
? `GitHub API rate limit exhausted for ${this.label}`
|
|
92
|
+
: `access denied to ${this.label} — check MCP_DOCUMENTOR_CODE_TOKEN / MCP_DOCUMENTOR_SYNC_TOKEN`, response.status);
|
|
93
|
+
}
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
throw new GitHubApiError(`GitHub API returned ${response.status} for ${path}`, response.status);
|
|
96
|
+
}
|
|
97
|
+
return response.json();
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The commit the configured ref points at right now.
|
|
101
|
+
*
|
|
102
|
+
* Resolved afresh for each operation — so a push is visible to the very next call — but only
|
|
103
|
+
* once within it, however many files that operation ends up reading.
|
|
104
|
+
*/
|
|
105
|
+
resolveRef() {
|
|
106
|
+
return oncePerOperation(`ref:${this.label}`, async () => {
|
|
107
|
+
const ref = this.spec.ref ?? "HEAD";
|
|
108
|
+
const commit = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/commits/${encodeURIComponent(ref)}`);
|
|
109
|
+
logger.debug(`${this.label} is at ${String(commit.sha).slice(0, 7)}`);
|
|
110
|
+
return {
|
|
111
|
+
commitSha: commit.sha,
|
|
112
|
+
commitDate: commit.commit?.committer?.date ?? commit.commit?.author?.date ?? new Date().toISOString(),
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/** path → blob sha for every file in the commit's tree. */
|
|
117
|
+
async getTree(commitSha) {
|
|
118
|
+
const cached = this.treeByCommit.get(commitSha);
|
|
119
|
+
if (cached)
|
|
120
|
+
return cached;
|
|
121
|
+
const tree = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/git/trees/${commitSha}?recursive=1`);
|
|
122
|
+
const map = new Map();
|
|
123
|
+
for (const entry of tree.tree ?? []) {
|
|
124
|
+
if (entry.type === "blob")
|
|
125
|
+
map.set(entry.path, entry.sha);
|
|
126
|
+
}
|
|
127
|
+
if (tree.truncated) {
|
|
128
|
+
// Past ~100k entries GitHub stops listing. Existence checks would then be wrong, so we
|
|
129
|
+
// remember it and fall back to a per-path lookup for anything absent from the partial tree.
|
|
130
|
+
this.truncatedCommits.add(commitSha);
|
|
131
|
+
logger.warn(`${this.label}: tree listing truncated at ${map.size} entries; falling back to per-path lookups`);
|
|
132
|
+
}
|
|
133
|
+
this.treeByCommit.set(commitSha, map);
|
|
134
|
+
return map;
|
|
135
|
+
}
|
|
136
|
+
/** Whether the tree we hold for this commit is known to be incomplete. */
|
|
137
|
+
isTreeTruncated(commitSha) {
|
|
138
|
+
return this.truncatedCommits.has(commitSha);
|
|
139
|
+
}
|
|
140
|
+
/** Blob sha for one path at one commit, or undefined if the file does not exist there. */
|
|
141
|
+
async getBlobSha(commitSha, repoPath) {
|
|
142
|
+
const tree = await this.getTree(commitSha);
|
|
143
|
+
const known = tree.get(repoPath);
|
|
144
|
+
if (known || !this.isTreeTruncated(commitSha))
|
|
145
|
+
return known;
|
|
146
|
+
try {
|
|
147
|
+
const contents = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/contents/${encodeURI(repoPath)}?ref=${commitSha}`);
|
|
148
|
+
return typeof contents?.sha === "string" ? contents.sha : undefined;
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
if (err instanceof GitHubApiError && err.status === 404)
|
|
152
|
+
return undefined;
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** A blob's bytes. Blobs are immutable, so this is cached for the life of the process. */
|
|
157
|
+
async getBlob(blobSha) {
|
|
158
|
+
const cached = this.blobCache.get(blobSha);
|
|
159
|
+
if (cached)
|
|
160
|
+
return cached;
|
|
161
|
+
const blob = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/git/blobs/${blobSha}`);
|
|
162
|
+
const bytes = Buffer.from(blob.content ?? "", blob.encoding === "base64" ? "base64" : "utf-8");
|
|
163
|
+
this.blobCache.set(blobSha, bytes);
|
|
164
|
+
return bytes;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Unified diff of every file that changed between two commits, keyed by path.
|
|
168
|
+
*
|
|
169
|
+
* One call covers a whole doc, so `git_diff_doc` groups its tracked files by the commit they
|
|
170
|
+
* were snapshotted at rather than asking per file. GitHub caps the response at 300 files;
|
|
171
|
+
* paths beyond that are simply absent from the map.
|
|
172
|
+
*/
|
|
173
|
+
async comparePatches(baseSha, headSha) {
|
|
174
|
+
const comparison = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/compare/${baseSha}...${headSha}`);
|
|
175
|
+
const patches = new Map();
|
|
176
|
+
for (const file of comparison.files ?? []) {
|
|
177
|
+
if (typeof file.patch === "string")
|
|
178
|
+
patches.set(file.filename, file.patch);
|
|
179
|
+
}
|
|
180
|
+
return patches;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -1,32 +1,57 @@
|
|
|
1
1
|
import type { TrackedSection } from "../../shared/schemas/doc.schema.js";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* A file's content, split both ways.
|
|
4
|
+
*
|
|
5
|
+
* Matching and hashing always use `lines` — every line with its trailing CR stripped — so a
|
|
6
|
+
* snapshot taken from a CRLF working copy and one taken from the LF blob stored in the repo
|
|
7
|
+
* produce the same hash. Without that, pointing a project at a GitHub code source would mark
|
|
8
|
+
* every file as modified on the first check.
|
|
9
|
+
*
|
|
10
|
+
* `raw` is kept only to recognise hashes stored before that normalisation existed, so an
|
|
11
|
+
* untouched file never reads as modified just because the tracker changed.
|
|
12
|
+
*/
|
|
13
|
+
export interface FileContent {
|
|
14
|
+
raw: string[];
|
|
15
|
+
lines: string[];
|
|
16
|
+
}
|
|
17
|
+
export declare function toFileContent(text: string): FileContent;
|
|
18
|
+
export declare function bytesToFileContent(bytes: Buffer): FileContent;
|
|
19
|
+
/** The whole-file hash written into a snapshot: taken over the normalised content. */
|
|
20
|
+
export declare function hashFileContent(fc: FileContent): string;
|
|
21
|
+
/**
|
|
22
|
+
* Whether a stored whole-file hash still describes this content — true for the current
|
|
23
|
+
* (normalised) hash and for a legacy hash taken over the raw bytes.
|
|
24
|
+
*/
|
|
25
|
+
export declare function fileContentMatches(fc: FileContent, storedHash: string): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Reads a section defined by 1-indexed [start, end] line numbers and snapshots it.
|
|
4
28
|
* Extracts top/bottom anchor lines and computes a hash of the section content.
|
|
5
|
-
* The
|
|
6
|
-
* `lineCount` is used by
|
|
29
|
+
* The section text is intentionally not persisted — only anchors, sectionHash, and lineCount.
|
|
30
|
+
* `lineCount` is used by locateSection to constrain the bottom anchor search and prevent
|
|
7
31
|
* false matches when the file contains repeated lines.
|
|
8
32
|
*/
|
|
9
|
-
export declare function snapshotSection(
|
|
33
|
+
export declare function snapshotSection(fc: FileContent, start: number, end: number): TrackedSection;
|
|
10
34
|
type FindResult = {
|
|
11
35
|
found: false;
|
|
12
36
|
} | {
|
|
13
37
|
found: true;
|
|
38
|
+
/** Hash of the located content, in the form a snapshot would store. */
|
|
14
39
|
currentHash: string;
|
|
40
|
+
/** Whether the stored hash still describes the located content (legacy hashes included). */
|
|
41
|
+
matchesStored: boolean;
|
|
42
|
+
/** Current 1-indexed line range, so callers can display it without storing it. */
|
|
15
43
|
lines: {
|
|
16
44
|
start: number;
|
|
17
45
|
end: number;
|
|
18
46
|
};
|
|
19
47
|
};
|
|
20
48
|
/**
|
|
21
|
-
* Locates a tracked section in the current
|
|
22
|
-
* then hashes the found content for comparison with the stored sectionHash.
|
|
23
|
-
* Also returns the current 1-indexed line range where the section was found,
|
|
24
|
-
* so callers can display it without storing it.
|
|
49
|
+
* Locates a tracked section in the current content and compares it with its stored hash.
|
|
25
50
|
*
|
|
26
|
-
* Returns { found: false } if the anchors cannot be located —
|
|
51
|
+
* Returns { found: false } if the anchors cannot be located — treated as stale by callers,
|
|
27
52
|
* since the surrounding context has changed enough to invalidate the documentation.
|
|
28
53
|
*/
|
|
29
|
-
export declare function findAndHashSection(
|
|
54
|
+
export declare function findAndHashSection(fc: FileContent, section: TrackedSection): FindResult;
|
|
30
55
|
type RelocateResult = {
|
|
31
56
|
found: false;
|
|
32
57
|
} | {
|
|
@@ -35,8 +60,8 @@ type RelocateResult = {
|
|
|
35
60
|
};
|
|
36
61
|
/**
|
|
37
62
|
* Locates a section by its current anchors, then refreshes its sectionHash to match
|
|
38
|
-
* the current
|
|
39
|
-
*
|
|
63
|
+
* the current content. Used by refresh_file_snapshot to acknowledge changes.
|
|
64
|
+
* The anchors are re-stored normalised — they were found, so only their encoding changes.
|
|
40
65
|
*/
|
|
41
|
-
export declare function relocateAndRefreshSection(
|
|
66
|
+
export declare function relocateAndRefreshSection(fc: FileContent, section: TrackedSection): RelocateResult;
|
|
42
67
|
export {};
|