@imenam/mcp-documentor 1.0.87 → 1.0.88
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 +7 -5
- 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/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 +29 -2
- package/dist/src/core/github-code-api.d.ts +3 -8
- package/dist/src/core/github-code-api.js +67 -36
- 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 +75 -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-s4GL8QZ_.js} +1 -1
- package/gui/dist/assets/{architectureDiagram-3BPJPVTR-BIPBAzZr.js → architectureDiagram-3BPJPVTR-CfDERg0r.js} +1 -1
- package/gui/dist/assets/{blockDiagram-GPEHLZMM-BE9WwuyF.js → blockDiagram-GPEHLZMM-CjDoEG3-.js} +1 -1
- package/gui/dist/assets/{c4Diagram-AAUBKEIU-V1_4ktIt.js → c4Diagram-AAUBKEIU-9o7f2UhK.js} +1 -1
- package/gui/dist/assets/channel-BsKHRH5P.js +1 -0
- package/gui/dist/assets/{chunk-2J33WTMH-DskG-Kmf.js → chunk-2J33WTMH-B37ouRYO.js} +1 -1
- package/gui/dist/assets/{chunk-4BX2VUAB-DaPhidEI.js → chunk-4BX2VUAB-Dcq44Skv.js} +1 -1
- package/gui/dist/assets/{chunk-55IACEB6-C9r6WDFM.js → chunk-55IACEB6-CLyM-RQf.js} +1 -1
- package/gui/dist/assets/{chunk-727SXJPM-BA-aC8US.js → chunk-727SXJPM-BjzEo2iz.js} +1 -1
- package/gui/dist/assets/{chunk-AQP2D5EJ-ByBp3whV.js → chunk-AQP2D5EJ-Co03OHPi.js} +1 -1
- package/gui/dist/assets/{chunk-FMBD7UC4-CzqUByx7.js → chunk-FMBD7UC4-ClbF2rAp.js} +1 -1
- package/gui/dist/assets/{chunk-ND2GUHAM-C0_B2ryQ.js → chunk-ND2GUHAM-CN5cTCWu.js} +1 -1
- package/gui/dist/assets/{chunk-QZHKN3VN-B3eEwjDh.js → chunk-QZHKN3VN-Bg4-kEPK.js} +1 -1
- package/gui/dist/assets/classDiagram-4FO5ZUOK-DphzXyc8.js +1 -0
- package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-DphzXyc8.js +1 -0
- package/gui/dist/assets/{cose-bilkent-S5V4N54A-D_i0I2pU.js → cose-bilkent-S5V4N54A-6Dkp59bE.js} +1 -1
- package/gui/dist/assets/{dagre-BM42HDAG-DtEVPK_z.js → dagre-BM42HDAG-2dD-ID2T.js} +1 -1
- package/gui/dist/assets/{diagram-2AECGRRQ-BftnXa5w.js → diagram-2AECGRRQ-DBE2f2Lb.js} +1 -1
- package/gui/dist/assets/{diagram-5GNKFQAL-Dh9-kl_u.js → diagram-5GNKFQAL-CkKPHiH9.js} +1 -1
- package/gui/dist/assets/{diagram-KO2AKTUF-CvB0BWCa.js → diagram-KO2AKTUF-uWtGAF2N.js} +1 -1
- package/gui/dist/assets/{diagram-LMA3HP47-CDy-J1uy.js → diagram-LMA3HP47-DyQaWg_0.js} +1 -1
- package/gui/dist/assets/{diagram-OG6HWLK6-iY9FwkIF.js → diagram-OG6HWLK6-H_DfuB5t.js} +1 -1
- package/gui/dist/assets/{erDiagram-TEJ5UH35-BT2_IX8D.js → erDiagram-TEJ5UH35-Cj9K5EXA.js} +1 -1
- package/gui/dist/assets/{flowDiagram-I6XJVG4X-DaixWyMx.js → flowDiagram-I6XJVG4X-BYF24Jvt.js} +1 -1
- package/gui/dist/assets/{ganttDiagram-6RSMTGT7-DSo3cQPH.js → ganttDiagram-6RSMTGT7-D93LjmhI.js} +1 -1
- package/gui/dist/assets/{gitGraphDiagram-PVQCEYII-BfFT7Lds.js → gitGraphDiagram-PVQCEYII-EVTcusDb.js} +1 -1
- package/gui/dist/assets/index-CtPuh-0k.css +1 -0
- package/gui/dist/assets/{index-DPGmAc6z.js → index-DvVxuv5N.js} +154 -152
- package/gui/dist/assets/{infoDiagram-5YYISTIA-BxbxNxe2.js → infoDiagram-5YYISTIA-DeGbaexj.js} +1 -1
- package/gui/dist/assets/{ishikawaDiagram-YF4QCWOH-DpopzqZr.js → ishikawaDiagram-YF4QCWOH-DNdk7sPJ.js} +1 -1
- package/gui/dist/assets/{journeyDiagram-JHISSGLW-DrOlFzIk.js → journeyDiagram-JHISSGLW-yLLfyn3W.js} +1 -1
- package/gui/dist/assets/{kanban-definition-UN3LZRKU-vH81lYXy.js → kanban-definition-UN3LZRKU-D8yvVgr8.js} +1 -1
- package/gui/dist/assets/{linear-BipFpFah.js → linear-j3lplktA.js} +1 -1
- package/gui/dist/assets/{mindmap-definition-RKZ34NQL-DsTxCLgj.js → mindmap-definition-RKZ34NQL-t9adVeMH.js} +1 -1
- package/gui/dist/assets/{pieDiagram-4H26LBE5-Bbq2ZWQU.js → pieDiagram-4H26LBE5-6Yrvo8h8.js} +1 -1
- package/gui/dist/assets/{quadrantDiagram-W4KKPZXB-BdTSaEo4.js → quadrantDiagram-W4KKPZXB-C_3Su6rX.js} +1 -1
- package/gui/dist/assets/{requirementDiagram-4Y6WPE33-BndI8YZy.js → requirementDiagram-4Y6WPE33-B1qYSC11.js} +1 -1
- package/gui/dist/assets/{sankeyDiagram-5OEKKPKP-ofBkVITf.js → sankeyDiagram-5OEKKPKP-D3Ervn-u.js} +1 -1
- package/gui/dist/assets/{sequenceDiagram-3UESZ5HK-Cjj2UrN9.js → sequenceDiagram-3UESZ5HK-CKgTeWBS.js} +1 -1
- package/gui/dist/assets/{stateDiagram-AJRCARHV-CzPrXUuz.js → stateDiagram-AJRCARHV-D_PjEdFH.js} +1 -1
- package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-KPbsissC.js +1 -0
- package/gui/dist/assets/{timeline-definition-PNZ67QCA-Cz4X-IOO.js → timeline-definition-PNZ67QCA-HeWRiNHu.js} +1 -1
- package/gui/dist/assets/{vennDiagram-CIIHVFJN-BwIgXJcZ.js → vennDiagram-CIIHVFJN-CREk7vzm.js} +1 -1
- package/gui/dist/assets/{wardley-L42UT6IY-keQS0aNZ.js → wardley-L42UT6IY-BWtRQ7ZH.js} +1 -1
- package/gui/dist/assets/{wardleyDiagram-YWT4CUSO-DhykSgbD.js → wardleyDiagram-YWT4CUSO-BvlqWWwp.js} +1 -1
- package/gui/dist/assets/{xychartDiagram-2RQKCTM6-DFTqc4cj.js → xychartDiagram-2RQKCTM6-BCjv6pwc.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
|
|
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, {
|
|
@@ -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",
|
|
@@ -72,6 +73,31 @@ export class SyncEngine {
|
|
|
72
73
|
return cfg.remoteUrl;
|
|
73
74
|
}
|
|
74
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Runs one git command that reaches the remote and records it in github.log. Error messages
|
|
78
|
+
* are recorded with the token masked: git quotes the authenticated URL in some of them.
|
|
79
|
+
*/
|
|
80
|
+
async remoteGit(cfg, action, command, run) {
|
|
81
|
+
const startedAt = Date.now();
|
|
82
|
+
const call = { kind: "git", action, command, repo: describeRemote(cfg.remoteUrl) };
|
|
83
|
+
try {
|
|
84
|
+
const result = await run();
|
|
85
|
+
recordGitHubCall({ ...call, ok: true, status: "ok", durationMs: Date.now() - startedAt });
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
90
|
+
const rejected = /fetch first|non-fast-forward|\[rejected\]/i.test(message);
|
|
91
|
+
recordGitHubCall({
|
|
92
|
+
...call,
|
|
93
|
+
ok: false,
|
|
94
|
+
status: rejected ? "rejected" : "failed",
|
|
95
|
+
durationMs: Date.now() - startedAt,
|
|
96
|
+
error: redactSecrets(message, [cfg.token]).trim(),
|
|
97
|
+
});
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
75
101
|
requireConfig() {
|
|
76
102
|
const cfg = this.storage.getSyncConfig();
|
|
77
103
|
if (!cfg?.remoteUrl) {
|
|
@@ -185,7 +211,7 @@ export class SyncEngine {
|
|
|
185
211
|
const pending = await this.git.status();
|
|
186
212
|
return { status: "conflict", conflicted: pending.conflicted };
|
|
187
213
|
}
|
|
188
|
-
await this.git.fetch("origin", cfg.branch);
|
|
214
|
+
await this.remoteGit(cfg, "Fetch the docs repository", `git fetch origin ${cfg.branch}`, () => this.git.fetch("origin", cfg.branch));
|
|
189
215
|
const remoteRef = `refs/remotes/origin/${cfg.branch}`;
|
|
190
216
|
// No remote branch yet (fresh repo) — nothing to pull.
|
|
191
217
|
const hasRemote = await this.git
|
|
@@ -274,8 +300,9 @@ export class SyncEngine {
|
|
|
274
300
|
"Resolve it with resolve_sync_conflict before pushing, otherwise the conflict markers would be committed as documentation content.");
|
|
275
301
|
}
|
|
276
302
|
const committed = await this.commitLocalChanges(message);
|
|
303
|
+
const unpushed = (await this.git.status()).ahead;
|
|
277
304
|
try {
|
|
278
|
-
await this.git.push(["-u", "origin", `HEAD:${cfg.branch}`]);
|
|
305
|
+
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
306
|
}
|
|
280
307
|
catch (e) {
|
|
281
308
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -101,15 +101,10 @@ export declare class GitHubCodeApi {
|
|
|
101
101
|
isTreeTruncated(commitSha: string): boolean;
|
|
102
102
|
/** Blob sha for one path at one commit, or undefined if the file does not exist there. */
|
|
103
103
|
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
104
|
/**
|
|
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.
|
|
105
|
+
* A blob's bytes. Blobs are immutable, so this is cached for the life of the process.
|
|
106
|
+
* `filePath` names the file in github.log.
|
|
112
107
|
*/
|
|
113
|
-
|
|
108
|
+
getBlob(blobSha: string, filePath?: string): Promise<Buffer>;
|
|
114
109
|
}
|
|
115
110
|
export {};
|
|
@@ -1,5 +1,6 @@
|
|
|
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";
|
|
3
4
|
const logger = createLogger("GitHubCodeApi");
|
|
4
5
|
const API_BASE = "https://api.github.com";
|
|
5
6
|
export function formatRepoSpec(spec) {
|
|
@@ -39,12 +40,20 @@ export class GitHubApiError extends Error {
|
|
|
39
40
|
this.name = "GitHubApiError";
|
|
40
41
|
}
|
|
41
42
|
}
|
|
43
|
+
function readRateLimit(response) {
|
|
44
|
+
const remaining = Number(response.headers.get("x-ratelimit-remaining"));
|
|
45
|
+
const limit = Number(response.headers.get("x-ratelimit-limit"));
|
|
46
|
+
const reset = Number(response.headers.get("x-ratelimit-reset"));
|
|
47
|
+
if (!Number.isFinite(remaining) || !Number.isFinite(limit) || !limit)
|
|
48
|
+
return undefined;
|
|
49
|
+
return { remaining, limit, reset: Number.isFinite(reset) ? new Date(reset * 1000).toISOString() : "" };
|
|
50
|
+
}
|
|
42
51
|
/**
|
|
43
|
-
* One GET against the GitHub REST API
|
|
52
|
+
* One GET against the GitHub REST API, recorded in github.log whatever its outcome.
|
|
44
53
|
* 404 and 422 both mean "nothing at that address": 422 is what a valid repository returns for a
|
|
45
54
|
* ref that does not exist.
|
|
46
55
|
*/
|
|
47
|
-
async function githubRequest(apiPath, token,
|
|
56
|
+
async function githubRequest(apiPath, token, purpose) {
|
|
48
57
|
const headers = {
|
|
49
58
|
Accept: "application/vnd.github+json",
|
|
50
59
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
@@ -52,31 +61,58 @@ async function githubRequest(apiPath, token, label) {
|
|
|
52
61
|
};
|
|
53
62
|
if (token)
|
|
54
63
|
headers.Authorization = `Bearer ${token}`;
|
|
64
|
+
const { label } = purpose;
|
|
65
|
+
const startedAt = Date.now();
|
|
66
|
+
const record = (status, ok, error, rateLimit) => recordGitHubCall({
|
|
67
|
+
kind: "api",
|
|
68
|
+
action: purpose.action,
|
|
69
|
+
command: `GET ${apiPath}`,
|
|
70
|
+
repo: purpose.repo,
|
|
71
|
+
ok,
|
|
72
|
+
status,
|
|
73
|
+
durationMs: Date.now() - startedAt,
|
|
74
|
+
error: error && redactSecrets(error, [token]),
|
|
75
|
+
rateLimit,
|
|
76
|
+
});
|
|
55
77
|
let response;
|
|
56
78
|
try {
|
|
57
79
|
response = await fetch(`${API_BASE}${apiPath}`, { headers });
|
|
58
80
|
}
|
|
59
81
|
catch (err) {
|
|
60
82
|
const message = err instanceof Error ? err.message : String(err);
|
|
83
|
+
record("network error", false, message);
|
|
61
84
|
throw new GitHubApiError(`network error contacting GitHub (${message})`);
|
|
62
85
|
}
|
|
86
|
+
const rateLimit = readRateLimit(response);
|
|
87
|
+
const status = String(response.status);
|
|
63
88
|
if (response.status === 404 || response.status === 422) {
|
|
64
|
-
|
|
89
|
+
const message = `not found: ${label} (repository, branch or path missing — or the token cannot see this repository)`;
|
|
90
|
+
record(status, false, message, rateLimit);
|
|
91
|
+
throw new GitHubApiError(message, response.status);
|
|
65
92
|
}
|
|
66
93
|
if (response.status === 401 || response.status === 403) {
|
|
67
94
|
const rateLimited = response.headers.get("x-ratelimit-remaining") === "0";
|
|
68
|
-
|
|
95
|
+
const message = rateLimited
|
|
69
96
|
? `GitHub API rate limit exhausted for ${label}`
|
|
70
|
-
: `access denied to ${label} — check the GitHub token in the project settings
|
|
97
|
+
: `access denied to ${label} — check the GitHub token in the project settings`;
|
|
98
|
+
record(status, false, message, rateLimit);
|
|
99
|
+
throw new GitHubApiError(message, response.status);
|
|
71
100
|
}
|
|
72
101
|
if (!response.ok) {
|
|
73
|
-
|
|
102
|
+
const message = `GitHub API returned ${response.status} for ${apiPath}`;
|
|
103
|
+
record(status, false, message, rateLimit);
|
|
104
|
+
throw new GitHubApiError(message, response.status);
|
|
74
105
|
}
|
|
106
|
+
record(status, true, undefined, rateLimit);
|
|
75
107
|
return response.json();
|
|
76
108
|
}
|
|
77
109
|
/** Visibility and default branch of a repository. */
|
|
78
110
|
export async function getRepositoryInfo(owner, repo, token) {
|
|
79
|
-
const data = await githubRequest(`/repos/${owner}/${repo}`, token,
|
|
111
|
+
const data = await githubRequest(`/repos/${owner}/${repo}`, token, {
|
|
112
|
+
label: `${owner}/${repo}`,
|
|
113
|
+
action: "Read the repository details",
|
|
114
|
+
repo: `${owner}/${repo}`,
|
|
115
|
+
});
|
|
80
116
|
return {
|
|
81
117
|
fullName: data.full_name,
|
|
82
118
|
private: !!data.private,
|
|
@@ -89,7 +125,7 @@ const MAX_BRANCH_PAGES = 10;
|
|
|
89
125
|
export async function listBranches(owner, repo, token) {
|
|
90
126
|
const names = [];
|
|
91
127
|
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}`);
|
|
128
|
+
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
129
|
if (!Array.isArray(batch))
|
|
94
130
|
break;
|
|
95
131
|
for (const branch of batch)
|
|
@@ -101,7 +137,7 @@ export async function listBranches(owner, repo, token) {
|
|
|
101
137
|
}
|
|
102
138
|
/** The commit a branch points at, with its first message line. */
|
|
103
139
|
export async function getBranchHead(owner, repo, branch, token) {
|
|
104
|
-
const commit = await githubRequest(`/repos/${owner}/${repo}/commits/${encodeURIComponent(branch)}`, token, `${owner}/${repo}@${branch}`);
|
|
140
|
+
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}` });
|
|
105
141
|
return {
|
|
106
142
|
sha: commit.sha,
|
|
107
143
|
message: String(commit.commit?.message ?? "").split("\n")[0],
|
|
@@ -110,7 +146,11 @@ export async function getBranchHead(owner, repo, branch, token) {
|
|
|
110
146
|
}
|
|
111
147
|
/** Login of the account a token belongs to. */
|
|
112
148
|
export async function getTokenLogin(token) {
|
|
113
|
-
const user = await githubRequest("/user", token,
|
|
149
|
+
const user = await githubRequest("/user", token, {
|
|
150
|
+
label: "the authenticated user",
|
|
151
|
+
action: "Identify the account the token belongs to",
|
|
152
|
+
repo: null,
|
|
153
|
+
});
|
|
114
154
|
return user.login;
|
|
115
155
|
}
|
|
116
156
|
/**
|
|
@@ -122,7 +162,7 @@ export async function searchRepositories(term, token, limit) {
|
|
|
122
162
|
const owner = slash >= 0 ? term.slice(0, slash).trim() : "";
|
|
123
163
|
const name = slash >= 0 ? term.slice(slash + 1).trim() : term.trim();
|
|
124
164
|
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}"`);
|
|
165
|
+
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
166
|
return (data.items ?? []).map((item) => ({
|
|
127
167
|
fullName: item.full_name,
|
|
128
168
|
private: !!item.private,
|
|
@@ -158,8 +198,12 @@ export class GitHubCodeApi {
|
|
|
158
198
|
get label() {
|
|
159
199
|
return formatRepoSpec(this.spec);
|
|
160
200
|
}
|
|
161
|
-
request(path) {
|
|
162
|
-
return githubRequest(path, this.token,
|
|
201
|
+
request(path, action) {
|
|
202
|
+
return githubRequest(path, this.token, {
|
|
203
|
+
label: this.label,
|
|
204
|
+
action,
|
|
205
|
+
repo: `${this.spec.owner}/${this.spec.repo}`,
|
|
206
|
+
});
|
|
163
207
|
}
|
|
164
208
|
/**
|
|
165
209
|
* The commit the configured ref points at right now.
|
|
@@ -170,7 +214,7 @@ export class GitHubCodeApi {
|
|
|
170
214
|
resolveRef() {
|
|
171
215
|
return oncePerOperation(`ref:${this.label}`, async () => {
|
|
172
216
|
const ref = this.spec.ref ?? "HEAD";
|
|
173
|
-
const commit = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/commits/${encodeURIComponent(ref)}`);
|
|
217
|
+
const commit = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/commits/${encodeURIComponent(ref)}`, `Resolve ${ref} to its current commit`);
|
|
174
218
|
logger.debug(`${this.label} is at ${String(commit.sha).slice(0, 7)}`);
|
|
175
219
|
return {
|
|
176
220
|
commitSha: commit.sha,
|
|
@@ -190,7 +234,7 @@ export class GitHubCodeApi {
|
|
|
190
234
|
return this.cached(this.commitByDate, isoDate, async () => {
|
|
191
235
|
const ref = this.spec.ref ?? "HEAD";
|
|
192
236
|
const commits = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/commits` +
|
|
193
|
-
`?sha=${encodeURIComponent(ref)}&until=${encodeURIComponent(isoDate)}&per_page=1`);
|
|
237
|
+
`?sha=${encodeURIComponent(ref)}&until=${encodeURIComponent(isoDate)}&per_page=1`, `Find the last commit on ${ref} at or before ${isoDate}`);
|
|
194
238
|
const sha = Array.isArray(commits) && typeof commits[0]?.sha === "string" ? commits[0].sha : undefined;
|
|
195
239
|
logger.debug(`${this.label}: commit at or before ${isoDate} is ${sha ? sha.slice(0, 7) : "none"}`);
|
|
196
240
|
return sha;
|
|
@@ -199,7 +243,7 @@ export class GitHubCodeApi {
|
|
|
199
243
|
/** path → blob sha for every file in the commit's tree. A commit's tree never changes. */
|
|
200
244
|
getTree(commitSha) {
|
|
201
245
|
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`);
|
|
246
|
+
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
247
|
const map = new Map();
|
|
204
248
|
for (const entry of tree.tree ?? []) {
|
|
205
249
|
if (entry.type === "blob")
|
|
@@ -240,7 +284,7 @@ export class GitHubCodeApi {
|
|
|
240
284
|
if (known || !this.isTreeTruncated(commitSha))
|
|
241
285
|
return known;
|
|
242
286
|
try {
|
|
243
|
-
const contents = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/contents/${encodeURI(repoPath)}?ref=${commitSha}`);
|
|
287
|
+
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
288
|
return typeof contents?.sha === "string" ? contents.sha : undefined;
|
|
245
289
|
}
|
|
246
290
|
catch (err) {
|
|
@@ -249,27 +293,14 @@ export class GitHubCodeApi {
|
|
|
249
293
|
throw err;
|
|
250
294
|
}
|
|
251
295
|
}
|
|
252
|
-
/**
|
|
253
|
-
|
|
296
|
+
/**
|
|
297
|
+
* A blob's bytes. Blobs are immutable, so this is cached for the life of the process.
|
|
298
|
+
* `filePath` names the file in github.log.
|
|
299
|
+
*/
|
|
300
|
+
getBlob(blobSha, filePath) {
|
|
254
301
|
return this.cached(this.blobCache, blobSha, async () => {
|
|
255
|
-
const blob = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/git/blobs/${blobSha}`);
|
|
302
|
+
const blob = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/git/blobs/${blobSha}`, filePath ? `Download ${filePath}` : `Download blob ${blobSha.slice(0, 7)}`);
|
|
256
303
|
return Buffer.from(blob.content ?? "", blob.encoding === "base64" ? "base64" : "utf-8");
|
|
257
304
|
});
|
|
258
305
|
}
|
|
259
|
-
/**
|
|
260
|
-
* Unified diff of every file that changed between two commits, keyed by path.
|
|
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.
|
|
265
|
-
*/
|
|
266
|
-
async comparePatches(baseSha, headSha) {
|
|
267
|
-
const comparison = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/compare/${baseSha}...${headSha}`);
|
|
268
|
-
const patches = new Map();
|
|
269
|
-
for (const file of comparison.files ?? []) {
|
|
270
|
-
if (typeof file.patch === "string")
|
|
271
|
-
patches.set(file.filename, file.patch);
|
|
272
|
-
}
|
|
273
|
-
return patches;
|
|
274
|
-
}
|
|
275
306
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export declare const GITHUB_LOG_FILE = "github.log";
|
|
2
|
+
/**
|
|
3
|
+
* One call to GitHub, as written to github.log — one JSON object per line.
|
|
4
|
+
*
|
|
5
|
+
* kind "git" for a git command reaching the remote, "api" for a REST request
|
|
6
|
+
* action what was done, in plain words
|
|
7
|
+
* command the exact git command or HTTP request
|
|
8
|
+
* repo "owner/repo" the call targets, when it targets one
|
|
9
|
+
* trigger the tool call, GUI request or server step that caused it
|
|
10
|
+
* status HTTP status, or "ok" / "rejected" / "failed" / "network error"
|
|
11
|
+
*/
|
|
12
|
+
export interface GitHubLogEntry {
|
|
13
|
+
ts: string;
|
|
14
|
+
kind: "git" | "api";
|
|
15
|
+
action: string;
|
|
16
|
+
command: string;
|
|
17
|
+
repo: string | null;
|
|
18
|
+
trigger: string;
|
|
19
|
+
ok: boolean;
|
|
20
|
+
status: string;
|
|
21
|
+
durationMs: number;
|
|
22
|
+
error?: string;
|
|
23
|
+
rateLimit?: {
|
|
24
|
+
remaining: number;
|
|
25
|
+
limit: number;
|
|
26
|
+
reset: string;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Runs `fn` with `trigger` recorded as the cause of every GitHub call it makes. */
|
|
30
|
+
export declare function runTriggeredBy<T>(trigger: string, fn: () => T): T;
|
|
31
|
+
/** Opens github.log in `logDir` for appending. */
|
|
32
|
+
export declare function initGitHubLog(logDir: string): void;
|
|
33
|
+
/** Masks credentials embedded in URLs or tokens quoted verbatim. */
|
|
34
|
+
export declare function redactSecrets(text: string, secrets?: Array<string | undefined>): string;
|
|
35
|
+
/** "owner/repo" for a GitHub URL, the host and path for any other remote. */
|
|
36
|
+
export declare function describeRemote(remoteUrl: string): string;
|
|
37
|
+
/** Appends one call to github.log and mirrors it in server.log. */
|
|
38
|
+
export declare function recordGitHubCall(call: Omit<GitHubLogEntry, "ts" | "trigger">): void;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { createLogger } from "@imenam/mcp-gui-interface";
|
|
5
|
+
const logger = createLogger("GitHub");
|
|
6
|
+
export const GITHUB_LOG_FILE = "github.log";
|
|
7
|
+
const triggerStorage = new AsyncLocalStorage();
|
|
8
|
+
/** Runs `fn` with `trigger` recorded as the cause of every GitHub call it makes. */
|
|
9
|
+
export function runTriggeredBy(trigger, fn) {
|
|
10
|
+
return triggerStorage.run(trigger, fn);
|
|
11
|
+
}
|
|
12
|
+
function currentTrigger() {
|
|
13
|
+
return triggerStorage.getStore() ?? "server";
|
|
14
|
+
}
|
|
15
|
+
let stream = null;
|
|
16
|
+
/** Opens github.log in `logDir` for appending. */
|
|
17
|
+
export function initGitHubLog(logDir) {
|
|
18
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
19
|
+
const file = path.join(logDir, GITHUB_LOG_FILE);
|
|
20
|
+
stream = fs.createWriteStream(file, { flags: "a", encoding: "utf8" });
|
|
21
|
+
stream.on("error", (err) => {
|
|
22
|
+
stream = null;
|
|
23
|
+
logger.error(`Cannot write ${file}, GitHub calls are no longer recorded there: ${err.message}`);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/** Masks credentials embedded in URLs or tokens quoted verbatim. */
|
|
27
|
+
export function redactSecrets(text, secrets = []) {
|
|
28
|
+
let out = text.replace(/(https?:\/\/)[^@\s/]+@/gi, "$1***@");
|
|
29
|
+
for (const secret of secrets) {
|
|
30
|
+
if (secret)
|
|
31
|
+
out = out.split(secret).join("***");
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
/** "owner/repo" for a GitHub URL, the host and path for any other remote. */
|
|
36
|
+
export function describeRemote(remoteUrl) {
|
|
37
|
+
const cleaned = redactSecrets(remoteUrl).replace(/^https?:\/\/(\*\*\*@)?/i, "").replace(/^git@/i, "").replace(/\.git$/i, "");
|
|
38
|
+
const github = cleaned.match(/^github\.com[/:](.+)$/i);
|
|
39
|
+
return github ? github[1] : cleaned.replace(":", "/");
|
|
40
|
+
}
|
|
41
|
+
/** Appends one call to github.log and mirrors it in server.log. */
|
|
42
|
+
export function recordGitHubCall(call) {
|
|
43
|
+
const entry = {
|
|
44
|
+
ts: new Date().toISOString(),
|
|
45
|
+
kind: call.kind,
|
|
46
|
+
action: call.action,
|
|
47
|
+
command: call.command,
|
|
48
|
+
repo: call.repo,
|
|
49
|
+
trigger: currentTrigger(),
|
|
50
|
+
ok: call.ok,
|
|
51
|
+
status: call.status,
|
|
52
|
+
durationMs: call.durationMs,
|
|
53
|
+
...(call.error ? { error: call.error } : {}),
|
|
54
|
+
...(call.rateLimit ? { rateLimit: call.rateLimit } : {}),
|
|
55
|
+
};
|
|
56
|
+
stream?.write(`${JSON.stringify(entry)}\n`);
|
|
57
|
+
const summary = `${entry.command} · ${entry.status} · ${entry.durationMs} ms · ${entry.trigger}`;
|
|
58
|
+
if (entry.ok)
|
|
59
|
+
logger.info(summary);
|
|
60
|
+
else if (entry.status === "404" || entry.status === "422")
|
|
61
|
+
logger.warn(`${summary} · ${entry.error ?? ""}`);
|
|
62
|
+
else
|
|
63
|
+
logger.error(`${summary} · ${entry.error ?? ""}`);
|
|
64
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type GitHubLogEntry } from "./github-log.js";
|
|
2
|
+
export declare const SERVER_LOG_FILE = "server.log";
|
|
3
|
+
export declare const LOG_SOURCES: {
|
|
4
|
+
readonly server: "server.log";
|
|
5
|
+
readonly github: "github.log";
|
|
6
|
+
};
|
|
7
|
+
export type LogSourceId = keyof typeof LOG_SOURCES;
|
|
8
|
+
export declare function isLogSource(id: string): id is LogSourceId;
|
|
9
|
+
export interface LogFileInfo {
|
|
10
|
+
id: LogSourceId;
|
|
11
|
+
file: string;
|
|
12
|
+
path: string;
|
|
13
|
+
exists: boolean;
|
|
14
|
+
size: number;
|
|
15
|
+
mtime: string | null;
|
|
16
|
+
}
|
|
17
|
+
/** One line of server.log, with the lines that followed it without a timestamp of their own. */
|
|
18
|
+
export interface ServerLogEntry {
|
|
19
|
+
ts: string;
|
|
20
|
+
pid: number;
|
|
21
|
+
process: string;
|
|
22
|
+
level: "DEBUG" | "INFO" | "WARN" | "ERROR";
|
|
23
|
+
scope: string;
|
|
24
|
+
message: string;
|
|
25
|
+
continuation: string[];
|
|
26
|
+
}
|
|
27
|
+
export interface GitHubLogLine extends GitHubLogEntry {
|
|
28
|
+
raw: string;
|
|
29
|
+
}
|
|
30
|
+
/** A window on the end of a log file. `partial` is true when older lines lie before it. */
|
|
31
|
+
export interface LogWindow<T> {
|
|
32
|
+
file: LogFileInfo;
|
|
33
|
+
partial: boolean;
|
|
34
|
+
entries: T[];
|
|
35
|
+
/** Lines that could not be parsed, left out of `entries`. */
|
|
36
|
+
unreadable: number;
|
|
37
|
+
}
|
|
38
|
+
export declare function logFileInfo(logDir: string, id: LogSourceId): LogFileInfo;
|
|
39
|
+
/**
|
|
40
|
+
* Parses server.log lines as written by the shared logger:
|
|
41
|
+
* [ISO date] [PID n] [process] [LEVEL] [scope] message
|
|
42
|
+
* Text printed straight to the console carries no level nor scope: it is read as INFO from
|
|
43
|
+
* "console". A line without the date prefix continues the entry above it (stack traces).
|
|
44
|
+
*/
|
|
45
|
+
export declare function readServerLog(logDir: string): LogWindow<ServerLogEntry>;
|
|
46
|
+
/** Parses github.log: one JSON object per line. */
|
|
47
|
+
export declare function readGitHubLog(logDir: string): LogWindow<GitHubLogLine>;
|