@imenam/mcp-documentor 1.0.87 → 1.0.89
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 +10 -6
- package/dist/shared/read-only.js +1 -1
- package/dist/shared/schemas/doc.schema.d.ts +1 -0
- package/dist/shared/schemas/doc.schema.js +1 -0
- package/dist/src/core/blob-store.d.ts +5 -0
- package/dist/src/core/blob-store.js +49 -0
- package/dist/src/core/code-source.d.ts +32 -0
- package/dist/src/core/code-source.js +23 -4
- package/dist/src/core/git-sync.d.ts +5 -0
- package/dist/src/core/git-sync.js +41 -2
- package/dist/src/core/github-code-api.d.ts +7 -11
- package/dist/src/core/github-code-api.js +94 -41
- package/dist/src/core/github-log.d.ts +38 -0
- package/dist/src/core/github-log.js +64 -0
- package/dist/src/core/log-reader.d.ts +47 -0
- package/dist/src/core/log-reader.js +106 -0
- package/dist/src/core/storage-engine.d.ts +21 -6
- package/dist/src/core/storage-engine.js +80 -24
- package/dist/src/gui-worker.js +58 -1
- package/dist/src/index.js +77 -10
- package/dist/src/tools/code-sources.d.ts +9 -0
- package/dist/src/tools/code-sources.js +18 -16
- package/dist/src/tools/create-project.d.ts +19 -0
- package/dist/src/tools/create-project.js +42 -0
- package/dist/src/tools/delete-project.d.ts +16 -0
- package/dist/src/tools/delete-project.js +25 -0
- package/dist/src/tools/git-diff-doc.js +123 -98
- package/dist/src/tools/patch-doc.js +14 -3
- package/gui/dist/assets/{arc-DBgCQU2O.js → arc-BSPFCwLF.js} +1 -1
- package/gui/dist/assets/{architectureDiagram-3BPJPVTR-BIPBAzZr.js → architectureDiagram-3BPJPVTR-DHZcCdLS.js} +1 -1
- package/gui/dist/assets/{blockDiagram-GPEHLZMM-BE9WwuyF.js → blockDiagram-GPEHLZMM-D9dTrlKM.js} +1 -1
- package/gui/dist/assets/{c4Diagram-AAUBKEIU-V1_4ktIt.js → c4Diagram-AAUBKEIU-BF3-rTSW.js} +1 -1
- package/gui/dist/assets/channel-BgXnhna2.js +1 -0
- package/gui/dist/assets/{chunk-2J33WTMH-DskG-Kmf.js → chunk-2J33WTMH-B_rKbUyS.js} +1 -1
- package/gui/dist/assets/{chunk-4BX2VUAB-DaPhidEI.js → chunk-4BX2VUAB-C9w424bT.js} +1 -1
- package/gui/dist/assets/{chunk-55IACEB6-C9r6WDFM.js → chunk-55IACEB6-B2gLfiw3.js} +1 -1
- package/gui/dist/assets/{chunk-727SXJPM-BA-aC8US.js → chunk-727SXJPM-CXLwbQzD.js} +1 -1
- package/gui/dist/assets/{chunk-AQP2D5EJ-ByBp3whV.js → chunk-AQP2D5EJ-DIEelNex.js} +1 -1
- package/gui/dist/assets/{chunk-FMBD7UC4-CzqUByx7.js → chunk-FMBD7UC4-CKDjYi8Q.js} +1 -1
- package/gui/dist/assets/{chunk-ND2GUHAM-C0_B2ryQ.js → chunk-ND2GUHAM-D5NTnoWH.js} +1 -1
- package/gui/dist/assets/{chunk-QZHKN3VN-B3eEwjDh.js → chunk-QZHKN3VN-Cc9DzsoT.js} +1 -1
- package/gui/dist/assets/classDiagram-4FO5ZUOK-DmPDrQWl.js +1 -0
- package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-DmPDrQWl.js +1 -0
- package/gui/dist/assets/{cose-bilkent-S5V4N54A-D_i0I2pU.js → cose-bilkent-S5V4N54A-zK_y1Jct.js} +1 -1
- package/gui/dist/assets/{dagre-BM42HDAG-DtEVPK_z.js → dagre-BM42HDAG-D87rXs2_.js} +1 -1
- package/gui/dist/assets/{diagram-2AECGRRQ-BftnXa5w.js → diagram-2AECGRRQ-0qYWhZfZ.js} +1 -1
- package/gui/dist/assets/{diagram-5GNKFQAL-Dh9-kl_u.js → diagram-5GNKFQAL-BvmrfVQ0.js} +1 -1
- package/gui/dist/assets/{diagram-KO2AKTUF-CvB0BWCa.js → diagram-KO2AKTUF-0DIH1FgX.js} +1 -1
- package/gui/dist/assets/{diagram-LMA3HP47-CDy-J1uy.js → diagram-LMA3HP47-T8W429Io.js} +1 -1
- package/gui/dist/assets/{diagram-OG6HWLK6-iY9FwkIF.js → diagram-OG6HWLK6-DJMnhtDn.js} +1 -1
- package/gui/dist/assets/{erDiagram-TEJ5UH35-BT2_IX8D.js → erDiagram-TEJ5UH35-CyuwcTwK.js} +1 -1
- package/gui/dist/assets/{flowDiagram-I6XJVG4X-DaixWyMx.js → flowDiagram-I6XJVG4X-xhGOn_4T.js} +1 -1
- package/gui/dist/assets/{ganttDiagram-6RSMTGT7-DSo3cQPH.js → ganttDiagram-6RSMTGT7-CmkhPjbj.js} +1 -1
- package/gui/dist/assets/{gitGraphDiagram-PVQCEYII-BfFT7Lds.js → gitGraphDiagram-PVQCEYII-CyMVk2nl.js} +1 -1
- package/gui/dist/assets/{index-DPGmAc6z.js → index-CCqNIUBp.js} +154 -152
- package/gui/dist/assets/index-CtPuh-0k.css +1 -0
- package/gui/dist/assets/{infoDiagram-5YYISTIA-BxbxNxe2.js → infoDiagram-5YYISTIA-q2gOQizR.js} +1 -1
- package/gui/dist/assets/{ishikawaDiagram-YF4QCWOH-DpopzqZr.js → ishikawaDiagram-YF4QCWOH-C_a8ouWt.js} +1 -1
- package/gui/dist/assets/{journeyDiagram-JHISSGLW-DrOlFzIk.js → journeyDiagram-JHISSGLW-B0XgZgmz.js} +1 -1
- package/gui/dist/assets/{kanban-definition-UN3LZRKU-vH81lYXy.js → kanban-definition-UN3LZRKU-BE1EyBT0.js} +1 -1
- package/gui/dist/assets/{linear-BipFpFah.js → linear-CJpiIHfp.js} +1 -1
- package/gui/dist/assets/{mindmap-definition-RKZ34NQL-DsTxCLgj.js → mindmap-definition-RKZ34NQL-BqrhFP-W.js} +1 -1
- package/gui/dist/assets/{pieDiagram-4H26LBE5-Bbq2ZWQU.js → pieDiagram-4H26LBE5-TchMhFLa.js} +1 -1
- package/gui/dist/assets/{quadrantDiagram-W4KKPZXB-BdTSaEo4.js → quadrantDiagram-W4KKPZXB-KjwnPHRE.js} +1 -1
- package/gui/dist/assets/{requirementDiagram-4Y6WPE33-BndI8YZy.js → requirementDiagram-4Y6WPE33-9wlFYy5z.js} +1 -1
- package/gui/dist/assets/{sankeyDiagram-5OEKKPKP-ofBkVITf.js → sankeyDiagram-5OEKKPKP-3DSwfggy.js} +1 -1
- package/gui/dist/assets/{sequenceDiagram-3UESZ5HK-Cjj2UrN9.js → sequenceDiagram-3UESZ5HK-CeGhZql1.js} +1 -1
- package/gui/dist/assets/{stateDiagram-AJRCARHV-CzPrXUuz.js → stateDiagram-AJRCARHV-DgPL6ffw.js} +1 -1
- package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-y8DOR9eQ.js +1 -0
- package/gui/dist/assets/{timeline-definition-PNZ67QCA-Cz4X-IOO.js → timeline-definition-PNZ67QCA-BUs8-FJU.js} +1 -1
- package/gui/dist/assets/{vennDiagram-CIIHVFJN-BwIgXJcZ.js → vennDiagram-CIIHVFJN-Csa_wIyD.js} +1 -1
- package/gui/dist/assets/{wardley-L42UT6IY-keQS0aNZ.js → wardley-L42UT6IY-Cw3ksKPO.js} +1 -1
- package/gui/dist/assets/{wardleyDiagram-YWT4CUSO-DhykSgbD.js → wardleyDiagram-YWT4CUSO-C0tdxIha.js} +1 -1
- package/gui/dist/assets/{xychartDiagram-2RQKCTM6-DFTqc4cj.js → xychartDiagram-2RQKCTM6-C6UgKrOd.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +2 -1
- package/gui/dist/assets/channel-B_cci0fU.js +0 -1
- package/gui/dist/assets/classDiagram-4FO5ZUOK-BqlcJ5BP.js +0 -1
- package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-BqlcJ5BP.js +0 -1
- package/gui/dist/assets/index-CxCC1gxu.css +0 -1
- package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-DAq4ObN9.js +0 -1
package/README.md
CHANGED
|
@@ -119,13 +119,15 @@ that work on a whole project (`list_docs`, `get_doc_tree`, `check_freshness` wit
|
|
|
119
119
|
| `list_docs` | List docs with staleness indicators (optionally filter by project) |
|
|
120
120
|
| `get_doc_tree` | Return folder/doc structure for a project (or all projects) |
|
|
121
121
|
| `update_doc` | Fully replace content and/or metadata (`project` required; prefer `patch_doc` for small edits) |
|
|
122
|
-
| `patch_doc` | Search-and-replace a fragment of a doc’s content (`project` required) |
|
|
123
|
-
| `move_doc` | Change a doc’s path (`new_path`) and/or project (`new_project`); links are rewritten across all projects (`project` required) |
|
|
122
|
+
| `patch_doc` | Search-and-replace a fragment of a doc’s content; the text must occur once unless `replace_all` is set (`project` required) |
|
|
123
|
+
| `move_doc` | Change a doc’s path (`new_path`) and/or project (`new_project`); links are rewritten across all projects, bare within their own project (`project` required) |
|
|
124
124
|
| `delete_doc` | Permanently delete a documentation entry (`project` required) |
|
|
125
|
+
| `create_project` | Create an empty project, optionally with its code source (`code_source`, as `set_code_source`) |
|
|
126
|
+
| `delete_project` | Delete a project; refused while it holds any doc, image or other file besides its settings |
|
|
125
127
|
| `search_docs` | Semantic search over docs (natural language; requires OpenAI) |
|
|
126
128
|
| `check_freshness` | Staleness for one doc (`project` required) or a full project scan |
|
|
127
129
|
| `refresh_file_snapshot` | Mark tracked files as up-to-date without editing the doc (`project` required) |
|
|
128
|
-
| `git_diff_doc` |
|
|
130
|
+
| `git_diff_doc` | Diff of changed tracked files between their snapshot content and the current one (`project` required) |
|
|
129
131
|
| `check_links` | Validate `related_docs`, `doc://` links, and `#heading` anchors (`project` required) |
|
|
130
132
|
| `list_projects` | List all documentation projects |
|
|
131
133
|
| `upload_image` | Upload an image from a local file path to a project's assets (`project` required); returns the markdown link to insert |
|
|
@@ -291,7 +293,7 @@ The GUI then shows a report of what each project became and which entries were n
|
|
|
291
293
|
| Sees uncommitted edits | yes | no — only what is pushed |
|
|
292
294
|
| Affected by `git checkout` | yes | no |
|
|
293
295
|
| Needs the codebase on the server | yes | no |
|
|
294
|
-
| `git_diff_doc` baseline |
|
|
296
|
+
| `git_diff_doc` baseline | the commit the snapshot was taken at, else the last one before its date | the blob the snapshot was taken from, else its commit, else the last one before its date |
|
|
295
297
|
|
|
296
298
|
Every report names the source it used (`Code source: GitHub you/my-repo@main`), so a verdict is never ambiguous. If the source cannot be reached — network, auth, rate limit — the status is `unverifiable` with the reason attached, **never** `stale`.
|
|
297
299
|
|
|
@@ -302,7 +304,7 @@ Stored tracked-file paths are already relative to the code root, and a code root
|
|
|
302
304
|
- Hashes are computed on line-ending-normalised content, so a snapshot taken from a CRLF checkout still matches the LF blob in the repository. Hashes stored by earlier versions are still recognised.
|
|
303
305
|
- Absolute paths sent by an agent are reduced by the code root when one is configured, and otherwise matched against the repository tree by their longest unambiguous suffix (a bare filename is only accepted when the repository holds exactly one file by that name).
|
|
304
306
|
|
|
305
|
-
Docs snapshotted before the switch carry no source commit: `check_freshness` works immediately,
|
|
307
|
+
Docs snapshotted before the switch carry no source commit: `check_freshness` works immediately, and `git_diff_doc` falls back on the last commit before the snapshot date. Whatever its origin, a baseline is checked against the hash stored at the snapshot; when it does not match — typically after a rebase that rewrote commit dates — the diff is reported impossible, and one `refresh_file_snapshot` anchors the file on its blob, which survives rebases.
|
|
306
308
|
|
|
307
309
|
### API usage
|
|
308
310
|
|
|
@@ -310,7 +312,9 @@ Freshness is **never computed in the background** — only when something asks f
|
|
|
310
312
|
|
|
311
313
|
Within one such request, the branch head is resolved **once** and every tracked file is compared against that same commit — no mixed verdict if someone pushes mid-scan. The next request resolves it again, so a push is visible immediately: there is no staleness window to configure.
|
|
312
314
|
|
|
313
|
-
|
|
315
|
+
Resolving the branch head is a conditional request: while the branch has not moved, GitHub answers `304 Not Modified`, which does not count against the rate limit of an authenticated token.
|
|
316
|
+
|
|
317
|
+
Everything else is content-addressed: a commit's tree is cached by its sha for the life of the process, and a file's bytes by their blob sha on disk, in `<data dir>/blob-cache/` (machine-local, excluded from sync) — each version of a file is downloaded once per machine, across restarts. Each tracked file also records the blob sha it was snapshotted at, so an unchanged file is proven untouched without being downloaded at all. A full scan of an unchanged repository costs about two API calls — not one per file.
|
|
314
318
|
|
|
315
319
|
## Data Storage
|
|
316
320
|
|
package/dist/shared/read-only.js
CHANGED
|
@@ -5,7 +5,7 @@ export function isReadOnly() {
|
|
|
5
5
|
}
|
|
6
6
|
/** Outils MCP qui créent / modifient / suppriment de la donnée. */
|
|
7
7
|
export const WRITE_TOOLS = new Set([
|
|
8
|
-
"create_doc", "update_doc", "patch_doc", "move_doc", "delete_doc",
|
|
8
|
+
"create_doc", "update_doc", "patch_doc", "move_doc", "delete_doc", "create_project", "delete_project",
|
|
9
9
|
"restore_doc_version", "refresh_file_snapshot", "reindex_embeddings",
|
|
10
10
|
"prepare_export_bundle", "delete_export_bundle", "upload_image",
|
|
11
11
|
// Sync: pull_docs and sync_status stay allowed (read-only consumer machines
|
|
@@ -178,6 +178,7 @@ export declare const GetDocMetadataArgsSchema: z.ZodObject<{
|
|
|
178
178
|
project: z.ZodString;
|
|
179
179
|
}, z.core.$strip>;
|
|
180
180
|
export declare const PatchDocArgsSchema: z.ZodObject<{
|
|
181
|
+
replace_all: z.ZodOptional<z.ZodBoolean>;
|
|
181
182
|
replacement_file_path: z.ZodOptional<z.ZodString>;
|
|
182
183
|
replacement_file_content: z.ZodOptional<z.ZodString>;
|
|
183
184
|
replacement_draft: z.ZodOptional<z.ZodString>;
|
|
@@ -166,6 +166,7 @@ export const PatchDocArgsSchema = z.object({
|
|
|
166
166
|
...textSourceFields("search", "the search text"),
|
|
167
167
|
replacement: z.string().optional().describe("Text to replace it with"),
|
|
168
168
|
...textSourceFields("replacement", "the replacement"),
|
|
169
|
+
replace_all: z.boolean().optional().describe("Replace every occurrence. Without it, a search text that occurs more than once is refused."),
|
|
169
170
|
}).refine(d => d.search !== undefined || d.search_file_path !== undefined || d.search_file_content !== undefined || d.search_draft !== undefined, {
|
|
170
171
|
message: "One of search, search_file_path or search_draft is required",
|
|
171
172
|
}).refine(d => d.replacement !== undefined || d.replacement_file_path !== undefined || d.replacement_file_content !== undefined || d.replacement_draft !== undefined, {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function setBlobStoreDir(dir: string): void;
|
|
2
|
+
/** A stored blob, or undefined when it is not on disk. An entry that does not hash to its sha is dropped. */
|
|
3
|
+
export declare function readStoredBlob(sha: string): Buffer | undefined;
|
|
4
|
+
/** Stores a blob. Written to a temporary file then renamed, so a reader never sees a partial one. */
|
|
5
|
+
export declare function storeBlob(sha: string, bytes: Buffer): void;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { createLogger } from "@imenam/mcp-gui-interface";
|
|
5
|
+
const logger = createLogger("BlobStore");
|
|
6
|
+
/**
|
|
7
|
+
* Git blobs downloaded from GitHub, kept on disk by sha. A blob's content is fixed by its sha, so
|
|
8
|
+
* an entry never goes stale: each version of a file is downloaded once per machine, whatever the
|
|
9
|
+
* number of restarts. Entries are checked against their sha when read.
|
|
10
|
+
*/
|
|
11
|
+
let storeDir;
|
|
12
|
+
export function setBlobStoreDir(dir) {
|
|
13
|
+
storeDir = dir;
|
|
14
|
+
}
|
|
15
|
+
function blobPath(sha) {
|
|
16
|
+
if (!storeDir || !/^[0-9a-f]{40}$/.test(sha))
|
|
17
|
+
return undefined;
|
|
18
|
+
return path.join(storeDir, sha.slice(0, 2), sha);
|
|
19
|
+
}
|
|
20
|
+
/** The git blob sha of some bytes. */
|
|
21
|
+
function gitBlobSha(bytes) {
|
|
22
|
+
return crypto.createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex");
|
|
23
|
+
}
|
|
24
|
+
/** A stored blob, or undefined when it is not on disk. An entry that does not hash to its sha is dropped. */
|
|
25
|
+
export function readStoredBlob(sha) {
|
|
26
|
+
const file = blobPath(sha);
|
|
27
|
+
if (!file || !fs.existsSync(file))
|
|
28
|
+
return undefined;
|
|
29
|
+
const bytes = fs.readFileSync(file);
|
|
30
|
+
if (gitBlobSha(bytes) === sha)
|
|
31
|
+
return bytes;
|
|
32
|
+
logger.warn(`Stored blob ${sha.slice(0, 7)} does not match its sha; removing it`);
|
|
33
|
+
fs.rmSync(file, { force: true });
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
/** Stores a blob. Written to a temporary file then renamed, so a reader never sees a partial one. */
|
|
37
|
+
export function storeBlob(sha, bytes) {
|
|
38
|
+
const file = blobPath(sha);
|
|
39
|
+
if (!file)
|
|
40
|
+
return;
|
|
41
|
+
if (gitBlobSha(bytes) !== sha) {
|
|
42
|
+
logger.warn(`Downloaded content of blob ${sha.slice(0, 7)} does not match its sha; not storing it`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
46
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
47
|
+
fs.writeFileSync(temp, bytes);
|
|
48
|
+
fs.renameSync(temp, file);
|
|
49
|
+
}
|
|
@@ -48,6 +48,36 @@ export interface CodeSource {
|
|
|
48
48
|
/** Converts a path supplied by an agent into the portable form persisted in a doc. */
|
|
49
49
|
toStoredPath(inputPath: string): Promise<string>;
|
|
50
50
|
}
|
|
51
|
+
export declare class GitHubCodeSource implements CodeSource {
|
|
52
|
+
readonly api: GitHubCodeApi;
|
|
53
|
+
private readonly project;
|
|
54
|
+
readonly kind: "github";
|
|
55
|
+
/** Agent-supplied path → repo-relative path, per source. Repo layouts barely move. */
|
|
56
|
+
private readonly repoPathCache;
|
|
57
|
+
constructor(api: GitHubCodeApi, project: string);
|
|
58
|
+
get label(): string;
|
|
59
|
+
read(storedPath: string): Promise<SourceFile | undefined>;
|
|
60
|
+
/** The blob sha from the commit's tree — already fetched for the whole repository in one call. */
|
|
61
|
+
contentId(storedPath: string): Promise<string | undefined>;
|
|
62
|
+
/**
|
|
63
|
+
* A blob's content by its sha. A blob outlives the commits that referenced it, rebases included.
|
|
64
|
+
* `repoPath` names the file in github.log.
|
|
65
|
+
*/
|
|
66
|
+
readBlob(blobSha: string, repoPath?: string): Promise<FileContent>;
|
|
67
|
+
/** A stored path's content at a given commit, or undefined when the file does not exist there. */
|
|
68
|
+
readAt(storedPath: string, commitSha: string): Promise<FileContent | undefined>;
|
|
69
|
+
toStoredPath(inputPath: string): Promise<string>;
|
|
70
|
+
/**
|
|
71
|
+
* Maps a path onto the repository layout.
|
|
72
|
+
*
|
|
73
|
+
* A relative path is already repo-relative. An absolute one — what an agent working on a local
|
|
74
|
+
* clone naturally sends — is reduced by the configured code root when there is one, and
|
|
75
|
+
* otherwise matched against the repository tree by its longest unambiguous path suffix, so the
|
|
76
|
+
* feature works without forcing every machine to declare a root.
|
|
77
|
+
*/
|
|
78
|
+
private toRepoPath;
|
|
79
|
+
private computeRepoPath;
|
|
80
|
+
}
|
|
51
81
|
/** Where the code sources read their configuration. */
|
|
52
82
|
export interface CodeSourceProvider {
|
|
53
83
|
/** The project's code source setting. Throws when the settings cannot be read. */
|
|
@@ -86,5 +116,7 @@ export declare function locateTrackedSections(project: string, storedPath: strin
|
|
|
86
116
|
* it simply matches nothing, which is the right outcome for a filter.
|
|
87
117
|
*/
|
|
88
118
|
export declare function toStoredPathForMatching(project: string, inputPath: string): Promise<string>;
|
|
119
|
+
/** A project's source when it reads a GitHub branch, undefined when it reads the local working copy. */
|
|
120
|
+
export declare function getGitHubSource(project: string): GitHubCodeSource | undefined;
|
|
89
121
|
/** The GitHub client behind a project's source, or undefined when it reads the local working copy. */
|
|
90
122
|
export declare function getGitHubApi(project: string): GitHubCodeApi | undefined;
|
|
@@ -49,7 +49,7 @@ class LocalCodeSource {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
const toForwardSlashes = (p) => p.split("\\").join("/");
|
|
52
|
-
class GitHubCodeSource {
|
|
52
|
+
export class GitHubCodeSource {
|
|
53
53
|
api;
|
|
54
54
|
project;
|
|
55
55
|
kind = "github";
|
|
@@ -71,7 +71,7 @@ class GitHubCodeSource {
|
|
|
71
71
|
if (!blobSha)
|
|
72
72
|
return undefined;
|
|
73
73
|
return {
|
|
74
|
-
content: bytesToFileContent(await this.api.getBlob(blobSha)),
|
|
74
|
+
content: bytesToFileContent(await this.api.getBlob(blobSha, repoPath)),
|
|
75
75
|
lastModifiedAt: commitDate,
|
|
76
76
|
revision: commitSha,
|
|
77
77
|
blobId: blobSha,
|
|
@@ -83,6 +83,21 @@ class GitHubCodeSource {
|
|
|
83
83
|
const repoPath = await this.toRepoPath(storedPath, commitSha);
|
|
84
84
|
return repoPath ? this.api.getBlobSha(commitSha, repoPath) : undefined;
|
|
85
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* A blob's content by its sha. A blob outlives the commits that referenced it, rebases included.
|
|
88
|
+
* `repoPath` names the file in github.log.
|
|
89
|
+
*/
|
|
90
|
+
async readBlob(blobSha, repoPath) {
|
|
91
|
+
return bytesToFileContent(await this.api.getBlob(blobSha, repoPath));
|
|
92
|
+
}
|
|
93
|
+
/** A stored path's content at a given commit, or undefined when the file does not exist there. */
|
|
94
|
+
async readAt(storedPath, commitSha) {
|
|
95
|
+
const repoPath = await this.toRepoPath(storedPath, commitSha);
|
|
96
|
+
if (!repoPath)
|
|
97
|
+
return undefined;
|
|
98
|
+
const blobSha = await this.api.getBlobSha(commitSha, repoPath);
|
|
99
|
+
return blobSha ? this.readBlob(blobSha, repoPath) : undefined;
|
|
100
|
+
}
|
|
86
101
|
async toStoredPath(inputPath) {
|
|
87
102
|
const { commitSha } = await this.api.resolveRef();
|
|
88
103
|
const repoPath = await this.toRepoPath(inputPath, commitSha);
|
|
@@ -264,8 +279,12 @@ export async function toStoredPathForMatching(project, inputPath) {
|
|
|
264
279
|
return toForwardSlashes(inputPath);
|
|
265
280
|
}
|
|
266
281
|
}
|
|
282
|
+
/** A project's source when it reads a GitHub branch, undefined when it reads the local working copy. */
|
|
283
|
+
export function getGitHubSource(project) {
|
|
284
|
+
const source = getCodeSource(project);
|
|
285
|
+
return source instanceof GitHubCodeSource ? source : undefined;
|
|
286
|
+
}
|
|
267
287
|
/** The GitHub client behind a project's source, or undefined when it reads the local working copy. */
|
|
268
288
|
export function getGitHubApi(project) {
|
|
269
|
-
|
|
270
|
-
return source instanceof GitHubCodeSource ? source.api : undefined;
|
|
289
|
+
return getGitHubSource(project)?.api;
|
|
271
290
|
}
|
|
@@ -61,6 +61,11 @@ export declare class SyncEngine {
|
|
|
61
61
|
* ever stored in .git/config (which git never commits), never versioned.
|
|
62
62
|
*/
|
|
63
63
|
private authedUrl;
|
|
64
|
+
/**
|
|
65
|
+
* Runs one git command that reaches the remote and records it in github.log. Error messages
|
|
66
|
+
* are recorded with the token masked: git quotes the authenticated URL in some of them.
|
|
67
|
+
*/
|
|
68
|
+
private remoteGit;
|
|
64
69
|
private requireConfig;
|
|
65
70
|
/**
|
|
66
71
|
* Registers the structured merge drivers in .git/config, which git never
|
|
@@ -4,6 +4,7 @@ import path from "path";
|
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
5
|
import { simpleGit } from "simple-git";
|
|
6
6
|
import { createLogger } from "@imenam/mcp-gui-interface";
|
|
7
|
+
import { describeRemote, recordGitHubCall, redactSecrets } from "./github-log.js";
|
|
7
8
|
const logger = createLogger("GitSync");
|
|
8
9
|
const GITIGNORE_CONTENT = [
|
|
9
10
|
"# Derived / machine-local state — never synced",
|
|
@@ -20,6 +21,7 @@ const GITIGNORE_CONTENT = [
|
|
|
20
21
|
"errors-tmp-files/",
|
|
21
22
|
"export-bundles.json",
|
|
22
23
|
"local-state.json",
|
|
24
|
+
"blob-cache/",
|
|
23
25
|
"",
|
|
24
26
|
].join("\n");
|
|
25
27
|
/**
|
|
@@ -72,6 +74,31 @@ export class SyncEngine {
|
|
|
72
74
|
return cfg.remoteUrl;
|
|
73
75
|
}
|
|
74
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Runs one git command that reaches the remote and records it in github.log. Error messages
|
|
79
|
+
* are recorded with the token masked: git quotes the authenticated URL in some of them.
|
|
80
|
+
*/
|
|
81
|
+
async remoteGit(cfg, action, command, run) {
|
|
82
|
+
const startedAt = Date.now();
|
|
83
|
+
const call = { kind: "git", action, command, repo: describeRemote(cfg.remoteUrl) };
|
|
84
|
+
try {
|
|
85
|
+
const result = await run();
|
|
86
|
+
recordGitHubCall({ ...call, ok: true, status: "ok", durationMs: Date.now() - startedAt });
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
91
|
+
const rejected = /fetch first|non-fast-forward|\[rejected\]/i.test(message);
|
|
92
|
+
recordGitHubCall({
|
|
93
|
+
...call,
|
|
94
|
+
ok: false,
|
|
95
|
+
status: rejected ? "rejected" : "failed",
|
|
96
|
+
durationMs: Date.now() - startedAt,
|
|
97
|
+
error: redactSecrets(message, [cfg.token]).trim(),
|
|
98
|
+
});
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
75
102
|
requireConfig() {
|
|
76
103
|
const cfg = this.storage.getSyncConfig();
|
|
77
104
|
if (!cfg?.remoteUrl) {
|
|
@@ -117,10 +144,21 @@ export class SyncEngine {
|
|
|
117
144
|
async ensureRepo() {
|
|
118
145
|
const cfg = this.requireConfig();
|
|
119
146
|
const authed = this.authedUrl(cfg);
|
|
147
|
+
// Entries a newer server added are appended to an existing file, so machine-local state it
|
|
148
|
+
// introduces is never staged in a repo initialized before it.
|
|
120
149
|
const gitignorePath = path.join(this.dataDir, ".gitignore");
|
|
121
150
|
if (!fs.existsSync(gitignorePath)) {
|
|
122
151
|
fs.writeFileSync(gitignorePath, GITIGNORE_CONTENT, "utf-8");
|
|
123
152
|
}
|
|
153
|
+
else {
|
|
154
|
+
const current = fs.readFileSync(gitignorePath, "utf-8");
|
|
155
|
+
const present = new Set(current.split(/\r?\n/).map((line) => line.trim()));
|
|
156
|
+
const missing = GITIGNORE_CONTENT.split("\n").filter((line) => line && !line.startsWith("#") && !present.has(line));
|
|
157
|
+
if (missing.length > 0) {
|
|
158
|
+
const separator = current === "" || current.endsWith("\n") ? "" : "\n";
|
|
159
|
+
fs.writeFileSync(gitignorePath, `${current}${separator}${missing.join("\n")}\n`, "utf-8");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
124
162
|
// Rewritten when it drifts, so an upgraded server updates the routing of
|
|
125
163
|
// repos that were initialized by an older version.
|
|
126
164
|
const attributesPath = path.join(this.dataDir, ".gitattributes");
|
|
@@ -185,7 +223,7 @@ export class SyncEngine {
|
|
|
185
223
|
const pending = await this.git.status();
|
|
186
224
|
return { status: "conflict", conflicted: pending.conflicted };
|
|
187
225
|
}
|
|
188
|
-
await this.git.fetch("origin", cfg.branch);
|
|
226
|
+
await this.remoteGit(cfg, "Fetch the docs repository", `git fetch origin ${cfg.branch}`, () => this.git.fetch("origin", cfg.branch));
|
|
189
227
|
const remoteRef = `refs/remotes/origin/${cfg.branch}`;
|
|
190
228
|
// No remote branch yet (fresh repo) — nothing to pull.
|
|
191
229
|
const hasRemote = await this.git
|
|
@@ -274,8 +312,9 @@ export class SyncEngine {
|
|
|
274
312
|
"Resolve it with resolve_sync_conflict before pushing, otherwise the conflict markers would be committed as documentation content.");
|
|
275
313
|
}
|
|
276
314
|
const committed = await this.commitLocalChanges(message);
|
|
315
|
+
const unpushed = (await this.git.status()).ahead;
|
|
277
316
|
try {
|
|
278
|
-
await this.git.push(["-u", "origin", `HEAD:${cfg.branch}`]);
|
|
317
|
+
await this.remoteGit(cfg, unpushed > 0 ? `Push ${unpushed} local doc commit${unpushed > 1 ? "s" : ""}` : "Push the docs branch", `git push -u origin HEAD:${cfg.branch}`, () => this.git.push(["-u", "origin", `HEAD:${cfg.branch}`]));
|
|
279
318
|
}
|
|
280
319
|
catch (e) {
|
|
281
320
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -59,8 +59,8 @@ export declare function searchRepositories(term: string, token: string | undefin
|
|
|
59
59
|
* - the commit a ref points at — the only time-varying one, so it is resolved once per
|
|
60
60
|
* operation and never carried across them (see `code-operation`);
|
|
61
61
|
* - that commit's tree (path → blob sha) — immutable, cached per commit;
|
|
62
|
-
* - a blob's bytes — immutable, cached per blob sha
|
|
63
|
-
*
|
|
62
|
+
* - a blob's bytes — immutable, cached per blob sha in memory and on disk (see `blob-store`),
|
|
63
|
+
* so a given version of a file is downloaded once per machine.
|
|
64
64
|
*
|
|
65
65
|
* One instance per (repo, ref) pair; instances are shared across projects by `code-source`.
|
|
66
66
|
*/
|
|
@@ -78,7 +78,8 @@ export declare class GitHubCodeApi {
|
|
|
78
78
|
* The commit the configured ref points at right now.
|
|
79
79
|
*
|
|
80
80
|
* Resolved afresh for each operation — so a push is visible to the very next call — but only
|
|
81
|
-
* once within it, however many files that operation ends up reading.
|
|
81
|
+
* once within it, however many files that operation ends up reading. The request is conditional:
|
|
82
|
+
* while the branch has not moved, GitHub answers 304 and the call costs no rate limit.
|
|
82
83
|
*/
|
|
83
84
|
resolveRef(): Promise<ResolvedRef>;
|
|
84
85
|
/**
|
|
@@ -101,15 +102,10 @@ export declare class GitHubCodeApi {
|
|
|
101
102
|
isTreeTruncated(commitSha: string): boolean;
|
|
102
103
|
/** Blob sha for one path at one commit, or undefined if the file does not exist there. */
|
|
103
104
|
getBlobSha(commitSha: string, repoPath: string): Promise<string | undefined>;
|
|
104
|
-
/** A blob's bytes. Blobs are immutable, so this is cached for the life of the process. */
|
|
105
|
-
getBlob(blobSha: string): Promise<Buffer>;
|
|
106
105
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* One call covers a whole doc, so `git_diff_doc` groups its tracked files by the commit they
|
|
110
|
-
* were snapshotted at rather than asking per file. GitHub caps the response at 300 files;
|
|
111
|
-
* paths beyond that are simply absent from the map.
|
|
106
|
+
* A blob's bytes, read from the blob store when this machine already downloaded them.
|
|
107
|
+
* `filePath` names the file in github.log.
|
|
112
108
|
*/
|
|
113
|
-
|
|
109
|
+
getBlob(blobSha: string, filePath?: string): Promise<Buffer>;
|
|
114
110
|
}
|
|
115
111
|
export {};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createLogger } from "@imenam/mcp-gui-interface";
|
|
2
2
|
import { oncePerOperation } from "./code-operation.js";
|
|
3
|
+
import { recordGitHubCall, redactSecrets } from "./github-log.js";
|
|
4
|
+
import { readStoredBlob, storeBlob } from "./blob-store.js";
|
|
3
5
|
const logger = createLogger("GitHubCodeApi");
|
|
4
6
|
const API_BASE = "https://api.github.com";
|
|
5
7
|
export function formatRepoSpec(spec) {
|
|
@@ -39,12 +41,22 @@ export class GitHubApiError extends Error {
|
|
|
39
41
|
this.name = "GitHubApiError";
|
|
40
42
|
}
|
|
41
43
|
}
|
|
44
|
+
/** Last answer to each revalidated path, under the token it was read with. */
|
|
45
|
+
const revalidated = new Map();
|
|
46
|
+
function readRateLimit(response) {
|
|
47
|
+
const remaining = Number(response.headers.get("x-ratelimit-remaining"));
|
|
48
|
+
const limit = Number(response.headers.get("x-ratelimit-limit"));
|
|
49
|
+
const reset = Number(response.headers.get("x-ratelimit-reset"));
|
|
50
|
+
if (!Number.isFinite(remaining) || !Number.isFinite(limit) || !limit)
|
|
51
|
+
return undefined;
|
|
52
|
+
return { remaining, limit, reset: Number.isFinite(reset) ? new Date(reset * 1000).toISOString() : "" };
|
|
53
|
+
}
|
|
42
54
|
/**
|
|
43
|
-
* One GET against the GitHub REST API
|
|
55
|
+
* One GET against the GitHub REST API, recorded in github.log whatever its outcome.
|
|
44
56
|
* 404 and 422 both mean "nothing at that address": 422 is what a valid repository returns for a
|
|
45
57
|
* ref that does not exist.
|
|
46
58
|
*/
|
|
47
|
-
async function githubRequest(apiPath, token,
|
|
59
|
+
async function githubRequest(apiPath, token, purpose) {
|
|
48
60
|
const headers = {
|
|
49
61
|
Accept: "application/vnd.github+json",
|
|
50
62
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
@@ -52,31 +64,70 @@ async function githubRequest(apiPath, token, label) {
|
|
|
52
64
|
};
|
|
53
65
|
if (token)
|
|
54
66
|
headers.Authorization = `Bearer ${token}`;
|
|
67
|
+
const cacheKey = `${token ?? ""} ${apiPath}`;
|
|
68
|
+
const previous = purpose.revalidate ? revalidated.get(cacheKey) : undefined;
|
|
69
|
+
if (previous)
|
|
70
|
+
headers["If-None-Match"] = previous.etag;
|
|
71
|
+
const { label } = purpose;
|
|
72
|
+
const startedAt = Date.now();
|
|
73
|
+
const record = (status, ok, error, rateLimit) => recordGitHubCall({
|
|
74
|
+
kind: "api",
|
|
75
|
+
action: purpose.action,
|
|
76
|
+
command: `GET ${apiPath}`,
|
|
77
|
+
repo: purpose.repo,
|
|
78
|
+
ok,
|
|
79
|
+
status,
|
|
80
|
+
durationMs: Date.now() - startedAt,
|
|
81
|
+
error: error && redactSecrets(error, [token]),
|
|
82
|
+
rateLimit,
|
|
83
|
+
});
|
|
55
84
|
let response;
|
|
56
85
|
try {
|
|
57
86
|
response = await fetch(`${API_BASE}${apiPath}`, { headers });
|
|
58
87
|
}
|
|
59
88
|
catch (err) {
|
|
60
89
|
const message = err instanceof Error ? err.message : String(err);
|
|
90
|
+
record("network error", false, message);
|
|
61
91
|
throw new GitHubApiError(`network error contacting GitHub (${message})`);
|
|
62
92
|
}
|
|
93
|
+
const rateLimit = readRateLimit(response);
|
|
94
|
+
const status = String(response.status);
|
|
95
|
+
if (response.status === 304 && previous) {
|
|
96
|
+
record(status, true, undefined, rateLimit);
|
|
97
|
+
return previous.body;
|
|
98
|
+
}
|
|
63
99
|
if (response.status === 404 || response.status === 422) {
|
|
64
|
-
|
|
100
|
+
const message = `not found: ${label} (repository, branch or path missing — or the token cannot see this repository)`;
|
|
101
|
+
record(status, false, message, rateLimit);
|
|
102
|
+
throw new GitHubApiError(message, response.status);
|
|
65
103
|
}
|
|
66
104
|
if (response.status === 401 || response.status === 403) {
|
|
67
105
|
const rateLimited = response.headers.get("x-ratelimit-remaining") === "0";
|
|
68
|
-
|
|
106
|
+
const message = rateLimited
|
|
69
107
|
? `GitHub API rate limit exhausted for ${label}`
|
|
70
|
-
: `access denied to ${label} — check the GitHub token in the project settings
|
|
108
|
+
: `access denied to ${label} — check the GitHub token in the project settings`;
|
|
109
|
+
record(status, false, message, rateLimit);
|
|
110
|
+
throw new GitHubApiError(message, response.status);
|
|
71
111
|
}
|
|
72
112
|
if (!response.ok) {
|
|
73
|
-
|
|
113
|
+
const message = `GitHub API returned ${response.status} for ${apiPath}`;
|
|
114
|
+
record(status, false, message, rateLimit);
|
|
115
|
+
throw new GitHubApiError(message, response.status);
|
|
74
116
|
}
|
|
75
|
-
|
|
117
|
+
record(status, true, undefined, rateLimit);
|
|
118
|
+
const body = await response.json();
|
|
119
|
+
const etag = response.headers.get("etag");
|
|
120
|
+
if (purpose.revalidate && etag)
|
|
121
|
+
revalidated.set(cacheKey, { etag, body });
|
|
122
|
+
return body;
|
|
76
123
|
}
|
|
77
124
|
/** Visibility and default branch of a repository. */
|
|
78
125
|
export async function getRepositoryInfo(owner, repo, token) {
|
|
79
|
-
const data = await githubRequest(`/repos/${owner}/${repo}`, token,
|
|
126
|
+
const data = await githubRequest(`/repos/${owner}/${repo}`, token, {
|
|
127
|
+
label: `${owner}/${repo}`,
|
|
128
|
+
action: "Read the repository details",
|
|
129
|
+
repo: `${owner}/${repo}`,
|
|
130
|
+
});
|
|
80
131
|
return {
|
|
81
132
|
fullName: data.full_name,
|
|
82
133
|
private: !!data.private,
|
|
@@ -89,7 +140,7 @@ const MAX_BRANCH_PAGES = 10;
|
|
|
89
140
|
export async function listBranches(owner, repo, token) {
|
|
90
141
|
const names = [];
|
|
91
142
|
for (let page = 1; page <= MAX_BRANCH_PAGES; page++) {
|
|
92
|
-
const batch = await githubRequest(`/repos/${owner}/${repo}/branches?per_page=100&page=${page}`, token, `${owner}/${repo}`);
|
|
143
|
+
const batch = await githubRequest(`/repos/${owner}/${repo}/branches?per_page=100&page=${page}`, token, { label: `${owner}/${repo}`, action: `List the repository branches (page ${page})`, repo: `${owner}/${repo}` });
|
|
93
144
|
if (!Array.isArray(batch))
|
|
94
145
|
break;
|
|
95
146
|
for (const branch of batch)
|
|
@@ -101,7 +152,7 @@ export async function listBranches(owner, repo, token) {
|
|
|
101
152
|
}
|
|
102
153
|
/** The commit a branch points at, with its first message line. */
|
|
103
154
|
export async function getBranchHead(owner, repo, branch, token) {
|
|
104
|
-
const commit = await githubRequest(`/repos/${owner}/${repo}/commits/${encodeURIComponent(branch)}`, token, `${owner}/${repo}@${branch}`);
|
|
155
|
+
const commit = await githubRequest(`/repos/${owner}/${repo}/commits/${encodeURIComponent(branch)}`, token, { label: `${owner}/${repo}@${branch}`, action: `Read the head commit of ${branch}`, repo: `${owner}/${repo}`, revalidate: true });
|
|
105
156
|
return {
|
|
106
157
|
sha: commit.sha,
|
|
107
158
|
message: String(commit.commit?.message ?? "").split("\n")[0],
|
|
@@ -110,7 +161,11 @@ export async function getBranchHead(owner, repo, branch, token) {
|
|
|
110
161
|
}
|
|
111
162
|
/** Login of the account a token belongs to. */
|
|
112
163
|
export async function getTokenLogin(token) {
|
|
113
|
-
const user = await githubRequest("/user", token,
|
|
164
|
+
const user = await githubRequest("/user", token, {
|
|
165
|
+
label: "the authenticated user",
|
|
166
|
+
action: "Identify the account the token belongs to",
|
|
167
|
+
repo: null,
|
|
168
|
+
});
|
|
114
169
|
return user.login;
|
|
115
170
|
}
|
|
116
171
|
/**
|
|
@@ -122,7 +177,7 @@ export async function searchRepositories(term, token, limit) {
|
|
|
122
177
|
const owner = slash >= 0 ? term.slice(0, slash).trim() : "";
|
|
123
178
|
const name = slash >= 0 ? term.slice(slash + 1).trim() : term.trim();
|
|
124
179
|
const qualifiers = [name, "in:name", ...(owner ? [`user:${owner}`] : [])].filter(Boolean).join(" ");
|
|
125
|
-
const data = await githubRequest(`/search/repositories?q=${encodeURIComponent(qualifiers)}&per_page=${limit}`, token, `repositories matching "${term}"`);
|
|
180
|
+
const data = await githubRequest(`/search/repositories?q=${encodeURIComponent(qualifiers)}&per_page=${limit}`, token, { label: `repositories matching "${term}"`, action: `Search repositories matching "${term}"`, repo: null });
|
|
126
181
|
return (data.items ?? []).map((item) => ({
|
|
127
182
|
fullName: item.full_name,
|
|
128
183
|
private: !!item.private,
|
|
@@ -136,8 +191,8 @@ export async function searchRepositories(term, token, limit) {
|
|
|
136
191
|
* - the commit a ref points at — the only time-varying one, so it is resolved once per
|
|
137
192
|
* operation and never carried across them (see `code-operation`);
|
|
138
193
|
* - that commit's tree (path → blob sha) — immutable, cached per commit;
|
|
139
|
-
* - a blob's bytes — immutable, cached per blob sha
|
|
140
|
-
*
|
|
194
|
+
* - a blob's bytes — immutable, cached per blob sha in memory and on disk (see `blob-store`),
|
|
195
|
+
* so a given version of a file is downloaded once per machine.
|
|
141
196
|
*
|
|
142
197
|
* One instance per (repo, ref) pair; instances are shared across projects by `code-source`.
|
|
143
198
|
*/
|
|
@@ -158,19 +213,25 @@ export class GitHubCodeApi {
|
|
|
158
213
|
get label() {
|
|
159
214
|
return formatRepoSpec(this.spec);
|
|
160
215
|
}
|
|
161
|
-
request(path) {
|
|
162
|
-
return githubRequest(path, this.token,
|
|
216
|
+
request(path, action, revalidate = false) {
|
|
217
|
+
return githubRequest(path, this.token, {
|
|
218
|
+
label: this.label,
|
|
219
|
+
action,
|
|
220
|
+
repo: `${this.spec.owner}/${this.spec.repo}`,
|
|
221
|
+
revalidate,
|
|
222
|
+
});
|
|
163
223
|
}
|
|
164
224
|
/**
|
|
165
225
|
* The commit the configured ref points at right now.
|
|
166
226
|
*
|
|
167
227
|
* Resolved afresh for each operation — so a push is visible to the very next call — but only
|
|
168
|
-
* once within it, however many files that operation ends up reading.
|
|
228
|
+
* once within it, however many files that operation ends up reading. The request is conditional:
|
|
229
|
+
* while the branch has not moved, GitHub answers 304 and the call costs no rate limit.
|
|
169
230
|
*/
|
|
170
231
|
resolveRef() {
|
|
171
232
|
return oncePerOperation(`ref:${this.label}`, async () => {
|
|
172
233
|
const ref = this.spec.ref ?? "HEAD";
|
|
173
|
-
const commit = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/commits/${encodeURIComponent(ref)}`);
|
|
234
|
+
const commit = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/commits/${encodeURIComponent(ref)}`, `Resolve ${ref} to its current commit`, true);
|
|
174
235
|
logger.debug(`${this.label} is at ${String(commit.sha).slice(0, 7)}`);
|
|
175
236
|
return {
|
|
176
237
|
commitSha: commit.sha,
|
|
@@ -190,7 +251,7 @@ export class GitHubCodeApi {
|
|
|
190
251
|
return this.cached(this.commitByDate, isoDate, async () => {
|
|
191
252
|
const ref = this.spec.ref ?? "HEAD";
|
|
192
253
|
const commits = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/commits` +
|
|
193
|
-
`?sha=${encodeURIComponent(ref)}&until=${encodeURIComponent(isoDate)}&per_page=1`);
|
|
254
|
+
`?sha=${encodeURIComponent(ref)}&until=${encodeURIComponent(isoDate)}&per_page=1`, `Find the last commit on ${ref} at or before ${isoDate}`);
|
|
194
255
|
const sha = Array.isArray(commits) && typeof commits[0]?.sha === "string" ? commits[0].sha : undefined;
|
|
195
256
|
logger.debug(`${this.label}: commit at or before ${isoDate} is ${sha ? sha.slice(0, 7) : "none"}`);
|
|
196
257
|
return sha;
|
|
@@ -199,7 +260,7 @@ export class GitHubCodeApi {
|
|
|
199
260
|
/** path → blob sha for every file in the commit's tree. A commit's tree never changes. */
|
|
200
261
|
getTree(commitSha) {
|
|
201
262
|
return this.cached(this.treeByCommit, commitSha, async () => {
|
|
202
|
-
const tree = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/git/trees/${commitSha}?recursive=1`);
|
|
263
|
+
const tree = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/git/trees/${commitSha}?recursive=1`, `List the files of commit ${commitSha.slice(0, 7)}`);
|
|
203
264
|
const map = new Map();
|
|
204
265
|
for (const entry of tree.tree ?? []) {
|
|
205
266
|
if (entry.type === "blob")
|
|
@@ -240,7 +301,7 @@ export class GitHubCodeApi {
|
|
|
240
301
|
if (known || !this.isTreeTruncated(commitSha))
|
|
241
302
|
return known;
|
|
242
303
|
try {
|
|
243
|
-
const contents = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/contents/${encodeURI(repoPath)}?ref=${commitSha}`);
|
|
304
|
+
const contents = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/contents/${encodeURI(repoPath)}?ref=${commitSha}`, `Look up ${repoPath} at ${commitSha.slice(0, 7)}`);
|
|
244
305
|
return typeof contents?.sha === "string" ? contents.sha : undefined;
|
|
245
306
|
}
|
|
246
307
|
catch (err) {
|
|
@@ -249,27 +310,19 @@ export class GitHubCodeApi {
|
|
|
249
310
|
throw err;
|
|
250
311
|
}
|
|
251
312
|
}
|
|
252
|
-
/** A blob's bytes. Blobs are immutable, so this is cached for the life of the process. */
|
|
253
|
-
getBlob(blobSha) {
|
|
254
|
-
return this.cached(this.blobCache, blobSha, async () => {
|
|
255
|
-
const blob = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/git/blobs/${blobSha}`);
|
|
256
|
-
return Buffer.from(blob.content ?? "", blob.encoding === "base64" ? "base64" : "utf-8");
|
|
257
|
-
});
|
|
258
|
-
}
|
|
259
313
|
/**
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
* One call covers a whole doc, so `git_diff_doc` groups its tracked files by the commit they
|
|
263
|
-
* were snapshotted at rather than asking per file. GitHub caps the response at 300 files;
|
|
264
|
-
* paths beyond that are simply absent from the map.
|
|
314
|
+
* A blob's bytes, read from the blob store when this machine already downloaded them.
|
|
315
|
+
* `filePath` names the file in github.log.
|
|
265
316
|
*/
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
317
|
+
getBlob(blobSha, filePath) {
|
|
318
|
+
return this.cached(this.blobCache, blobSha, async () => {
|
|
319
|
+
const stored = readStoredBlob(blobSha);
|
|
320
|
+
if (stored)
|
|
321
|
+
return stored;
|
|
322
|
+
const blob = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/git/blobs/${blobSha}`, filePath ? `Download ${filePath}` : `Download blob ${blobSha.slice(0, 7)}`);
|
|
323
|
+
const bytes = Buffer.from(blob.content ?? "", blob.encoding === "base64" ? "base64" : "utf-8");
|
|
324
|
+
storeBlob(blobSha, bytes);
|
|
325
|
+
return bytes;
|
|
326
|
+
});
|
|
274
327
|
}
|
|
275
328
|
}
|