@dpesch/mantisbt-mcp-server 1.10.2 → 1.10.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/README.de.md +2 -1
- package/README.md +2 -1
- package/dist/client.js +0 -15
- package/dist/tools/files.js +14 -15
- package/docs/cookbook.de.md +1 -1
- package/docs/cookbook.md +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/.gitea/PULL_REQUEST_TEMPLATE.md +0 -17
- package/.gitea/workflows/ci.yml +0 -95
- package/.github/workflows/ci.yml +0 -32
- package/scripts/hooks/pre-push.mjs +0 -220
- package/scripts/init.mjs +0 -95
- package/scripts/record-fixtures.ts +0 -160
- package/tests/cache.test.ts +0 -265
- package/tests/client.test.ts +0 -372
- package/tests/config.test.ts +0 -263
- package/tests/fixtures/get_current_user.json +0 -40
- package/tests/fixtures/get_issue.json +0 -133
- package/tests/fixtures/get_issue_enums.json +0 -67
- package/tests/fixtures/get_issue_fields_sample.json +0 -76
- package/tests/fixtures/get_project_categories.json +0 -157
- package/tests/fixtures/get_project_versions.json +0 -3
- package/tests/fixtures/get_project_versions_with_data.json +0 -52
- package/tests/fixtures/list_issues.json +0 -95
- package/tests/fixtures/list_projects.json +0 -65
- package/tests/helpers/mock-server.ts +0 -166
- package/tests/helpers/search-mocks.ts +0 -84
- package/tests/prompts/prompts.test.ts +0 -242
- package/tests/resources/resources.test.ts +0 -309
- package/tests/search/embedder.test.ts +0 -81
- package/tests/search/highlight.test.ts +0 -129
- package/tests/search/store.test.ts +0 -193
- package/tests/search/sync.test.ts +0 -249
- package/tests/search/tools.test.ts +0 -661
- package/tests/tools/config.test.ts +0 -212
- package/tests/tools/files.test.ts +0 -344
- package/tests/tools/issues.test.ts +0 -1180
- package/tests/tools/metadata.test.ts +0 -509
- package/tests/tools/monitors.test.ts +0 -101
- package/tests/tools/projects.test.ts +0 -338
- package/tests/tools/relationships.test.ts +0 -177
- package/tests/tools/string-coercion.test.ts +0 -317
- package/tests/tools/users.test.ts +0 -62
- package/tests/utils/date-filter.test.ts +0 -169
- package/tests/version-hint.test.ts +0 -230
- package/tsconfig.build.json +0 -8
- package/vitest.config.ts +0 -8
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,40 @@ This project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
14
|
+
## [1.10.6] – 2026-06-29
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Restored automatic npm publishing via Gitea CI pipeline (triggered on version tags). The manual `--provenance` approach was reverted as Sigstore local signing is not supported outside CI environments with OIDC providers.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## [1.10.5] – 2026-06-29
|
|
23
|
+
|
|
24
|
+
### Security
|
|
25
|
+
|
|
26
|
+
- Fixed `.npmignore` to explicitly exclude sensitive files (`.env*`, `.mcpregistry_*`) and dev artifacts (`src/`, `tests/`, `scripts/`, `.claude/`, `.gitea/`, `.github/`, `.idea/`). Previously, when `.npmignore` exists, npm ignores `.gitignore` entirely — requiring all exclusions to be duplicated. v1.10.4 was unpublished due to accidental inclusion of local credential files; this patch corrects the root cause.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## [1.10.4] – 2026-06-29
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- npm releases are now published with `--provenance`, creating a Sigstore attestation that cryptographically links each package to its source commit. Verifiable with `npm audit signatures @dpesch/mantisbt-mcp-server`.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## [1.10.3] – 2026-05-15
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
- `upload_file`: file uploads previously used `multipart/form-data` via a now-removed `postFormData` helper. The MantisBT REST API does not accept `multipart/form-data` for file uploads — it expects a JSON body with Base64-encoded content. The tool now sends `POST issues/{id}/files` with a JSON body containing `{ files: [{ name, type, content }] }`. The caller-facing API is unchanged: `file_path` and `content` continue to work as before; the server handles Base64 encoding internally when `file_path` is used.
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
- `upload_file` tool and parameter descriptions updated: `file_path` is now explicitly marked as the preferred input mode (use whenever the file exists on disk — the server reads and encodes it automatically); `content` is marked as a fallback to be used only when `file_path` is not available (e.g. in-memory data or files not accessible via the server filesystem).
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
14
48
|
## [1.10.2] – 2026-05-03
|
|
15
49
|
|
|
16
50
|
### Changed
|
package/README.de.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@dpesch/mantisbt-mcp-server)
|
|
4
4
|
[](LICENSE)
|
|
5
|
+
[](https://modelcontextprotocol.io/)
|
|
5
6
|
[](https://lobehub.com/mcp/dpesch-mantisbt-mcp-server)
|
|
6
7
|
[](https://glama.ai/mcp/servers/dpesch/mantisbt-mcp-server)
|
|
7
8
|
|
|
@@ -106,7 +107,7 @@ npm run build
|
|
|
106
107
|
| Tool | Beschreibung |
|
|
107
108
|
|---|---|
|
|
108
109
|
| `list_issue_files` | Anhänge eines Issues auflisten |
|
|
109
|
-
| `upload_file` | Datei an ein Issue anhängen –
|
|
110
|
+
| `upload_file` | Datei an ein Issue anhängen – bevorzugt: lokaler `file_path` (der Server liest und kodiert die Datei automatisch); Fallback: Base64-kodiertes `content` + `filename` (nur verwenden, wenn `file_path` nicht verfügbar ist) |
|
|
110
111
|
|
|
111
112
|
### Beziehungen
|
|
112
113
|
|
package/README.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@dpesch/mantisbt-mcp-server)
|
|
4
4
|
[](LICENSE)
|
|
5
|
+
[](https://modelcontextprotocol.io/)
|
|
5
6
|
[](https://lobehub.com/mcp/dpesch-mantisbt-mcp-server)
|
|
6
7
|
[](https://glama.ai/mcp/servers/dpesch/mantisbt-mcp-server)
|
|
7
8
|
|
|
@@ -106,7 +107,7 @@ npm run build
|
|
|
106
107
|
| Tool | Description |
|
|
107
108
|
|---|---|
|
|
108
109
|
| `list_issue_files` | List attachments of an issue |
|
|
109
|
-
| `upload_file` | Upload a file to an issue —
|
|
110
|
+
| `upload_file` | Upload a file to an issue — preferred: local `file_path` (server reads and encodes automatically); fallback: Base64-encoded `content` + `filename` (use only when `file_path` is not available) |
|
|
110
111
|
|
|
111
112
|
### Relationships
|
|
112
113
|
|
package/dist/client.js
CHANGED
|
@@ -140,21 +140,6 @@ export class MantisClient {
|
|
|
140
140
|
});
|
|
141
141
|
return this.handleResponse(response);
|
|
142
142
|
}
|
|
143
|
-
async postFormData(path, formData) {
|
|
144
|
-
// Note: Content-Type must NOT be set here — fetch sets it automatically
|
|
145
|
-
// with the correct multipart/form-data boundary.
|
|
146
|
-
const { apiKey } = await this.getCredentials();
|
|
147
|
-
const response = await fetch(await this.buildUrl(path), {
|
|
148
|
-
method: 'POST',
|
|
149
|
-
headers: {
|
|
150
|
-
'Authorization': apiKey,
|
|
151
|
-
'Accept': 'application/json',
|
|
152
|
-
},
|
|
153
|
-
body: formData,
|
|
154
|
-
signal: AbortSignal.timeout(MantisClient.TIMEOUT_MS),
|
|
155
|
-
});
|
|
156
|
-
return this.handleResponse(response);
|
|
157
|
-
}
|
|
158
143
|
async getVersion() {
|
|
159
144
|
const response = await fetch(await this.buildUrl('users/me'), {
|
|
160
145
|
method: 'GET',
|
package/dist/tools/files.js
CHANGED
|
@@ -46,17 +46,17 @@ Use this tool when you need to inspect or enumerate files attached to an issue.
|
|
|
46
46
|
title: 'Upload File Attachment',
|
|
47
47
|
description: `Upload a file as an attachment to a MantisBT issue. Adds the file to the issue without modifying any issue fields or status. Returns the created attachment metadata on success.
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
- file_path: absolute path to a local file; filename is derived from the path
|
|
51
|
-
- content: Base64-encoded file content; filename must be supplied explicitly via the filename parameter
|
|
49
|
+
Provide exactly one of the two input modes:
|
|
50
|
+
- file_path (preferred): absolute path to a local file — use this whenever the file exists on disk; the server reads and encodes it automatically; filename is derived from the path
|
|
51
|
+
- content: Base64-encoded file content — only use this when the file is not accessible via a path (e.g. in-memory data); filename must be supplied explicitly via the filename parameter
|
|
52
52
|
|
|
53
53
|
The optional content_type sets the MIME type (e.g. "image/png"); defaults to "application/octet-stream". Use the optional description to annotate the attachment.
|
|
54
54
|
|
|
55
55
|
Use this tool to attach files such as logs, screenshots, or patches to an existing issue. To list existing attachments, use list_issue_files. To retrieve issue details, use get_issue.`,
|
|
56
56
|
inputSchema: z.object({
|
|
57
57
|
issue_id: z.coerce.number().int().positive().describe('Numeric issue ID'),
|
|
58
|
-
file_path: z.string().min(1).optional().describe('
|
|
59
|
-
content: z.string().min(1).optional().describe('Base64-encoded file content (mutually exclusive with file_path)'),
|
|
58
|
+
file_path: z.string().min(1).optional().describe('Preferred: absolute path to the local file to upload — use this whenever the file exists on disk (mutually exclusive with content)'),
|
|
59
|
+
content: z.string().min(1).optional().describe('Fallback: Base64-encoded file content — only use when file_path is not available (mutually exclusive with file_path)'),
|
|
60
60
|
filename: z.string().min(1).optional().describe('File name for the attachment (required when using content; overrides the derived name when using file_path)'),
|
|
61
61
|
content_type: z.string().optional().describe('MIME type of the file, e.g. "image/png" (default: "application/octet-stream")'),
|
|
62
62
|
description: z.string().optional().describe('Optional description for the attachment'),
|
|
@@ -74,7 +74,7 @@ Use this tool to attach files such as logs, screenshots, or patches to an existi
|
|
|
74
74
|
if (file_path && content) {
|
|
75
75
|
return { content: [{ type: 'text', text: 'Error: Only one of file_path or content may be provided' }], isError: true };
|
|
76
76
|
}
|
|
77
|
-
let
|
|
77
|
+
let base64Content;
|
|
78
78
|
let fileName;
|
|
79
79
|
if (file_path) {
|
|
80
80
|
if (normalizedUploadDir) {
|
|
@@ -83,23 +83,22 @@ Use this tool to attach files such as logs, screenshots, or patches to an existi
|
|
|
83
83
|
return { content: [{ type: 'text', text: errorText('file_path is not allowed — access restricted to the designated upload directory') }], isError: true };
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
|
-
fileBuffer = await readFile(file_path);
|
|
86
|
+
const fileBuffer = await readFile(file_path);
|
|
87
|
+
base64Content = fileBuffer.toString('base64');
|
|
87
88
|
fileName = filename ?? basename(file_path);
|
|
88
89
|
}
|
|
89
90
|
else {
|
|
90
91
|
if (!filename) {
|
|
91
92
|
return { content: [{ type: 'text', text: 'Error: filename is required when using content' }], isError: true };
|
|
92
93
|
}
|
|
93
|
-
|
|
94
|
+
base64Content = content;
|
|
94
95
|
fileName = filename;
|
|
95
96
|
}
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
const result = await client.postFormData(`issues/${issue_id}/files`, formData);
|
|
97
|
+
const body = {
|
|
98
|
+
files: [{ name: fileName, type: content_type ?? 'application/octet-stream', content: base64Content }],
|
|
99
|
+
...(description && { description }),
|
|
100
|
+
};
|
|
101
|
+
const result = await client.post(`issues/${issue_id}/files`, body);
|
|
103
102
|
return {
|
|
104
103
|
content: [{ type: 'text', text: JSON.stringify(result ?? { success: true }, null, 2) }],
|
|
105
104
|
};
|
package/docs/cookbook.de.md
CHANGED
|
@@ -821,7 +821,7 @@ Hängt eine Datei aus dem lokalen Dateisystem an ein Issue an.
|
|
|
821
821
|
|
|
822
822
|
### Dateiinhalt hochladen (Base64)
|
|
823
823
|
|
|
824
|
-
Hängt eine Datei an, indem ihr base64-kodierter Inhalt direkt übergeben wird.
|
|
824
|
+
Hängt eine Datei an, indem ihr base64-kodierter Inhalt direkt übergeben wird. Nur als **Fallback** verwenden, wenn die Datei nicht über einen Pfad im Server-Dateisystem erreichbar ist (z.B. im Speicher erzeugte Daten oder Dateien, die nur auf der Client-Seite vorliegen). `file_path` bevorzugen, wenn die Datei auf der Festplatte existiert – der Server liest und kodiert sie automatisch.
|
|
825
825
|
|
|
826
826
|
**Tool:** `upload_file`
|
|
827
827
|
|
package/docs/cookbook.md
CHANGED
|
@@ -821,7 +821,7 @@ Attaches a file from the local filesystem to an issue.
|
|
|
821
821
|
|
|
822
822
|
### Upload file content (base64)
|
|
823
823
|
|
|
824
|
-
Attaches a file by passing its base64-encoded content directly. Use this when the file is not
|
|
824
|
+
Attaches a file by passing its base64-encoded content directly. Use this **only** as a fallback when the file is not accessible via a path on the server filesystem (e.g. in-memory data or files generated on the client side). Prefer `file_path` whenever the file exists on disk — the server reads and encodes it automatically.
|
|
825
825
|
|
|
826
826
|
**Tool:** `upload_file`
|
|
827
827
|
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
"name": "io.github.dpesch/mantisbt-mcp-server",
|
|
4
4
|
"title": "MantisBT MCP Server",
|
|
5
5
|
"description": "MantisBT MCP server – manage issues, notes, files, tags, and relationships. With semantic search.",
|
|
6
|
-
"version": "1.10.
|
|
6
|
+
"version": "1.10.6",
|
|
7
7
|
"packages": [
|
|
8
8
|
{
|
|
9
9
|
"registryType": "npm",
|
|
10
10
|
"identifier": "@dpesch/mantisbt-mcp-server",
|
|
11
|
-
"version": "1.10.
|
|
11
|
+
"version": "1.10.6",
|
|
12
12
|
"runtimeHint": "npx",
|
|
13
13
|
"transport": {
|
|
14
14
|
"type": "stdio"
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
## Description
|
|
2
|
-
|
|
3
|
-
<!-- Why is this change needed? What problem does it solve? -->
|
|
4
|
-
|
|
5
|
-
## Changes
|
|
6
|
-
|
|
7
|
-
<!-- Brief summary of what was changed -->
|
|
8
|
-
|
|
9
|
-
## Testing
|
|
10
|
-
|
|
11
|
-
<!-- How did you test this? -->
|
|
12
|
-
|
|
13
|
-
---
|
|
14
|
-
|
|
15
|
-
> **Note:** This repository is a public mirror. Your PR will be reviewed and
|
|
16
|
-
> integrated via cherry-pick — it will not be merged directly through Codeberg.
|
|
17
|
-
> You will be credited in the commit message and CHANGELOG.
|
package/.gitea/workflows/ci.yml
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
name: CI / Publish
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches: [main]
|
|
6
|
-
tags:
|
|
7
|
-
- 'v*'
|
|
8
|
-
pull_request:
|
|
9
|
-
branches: [main]
|
|
10
|
-
|
|
11
|
-
jobs:
|
|
12
|
-
ci:
|
|
13
|
-
runs-on: codeberg-small
|
|
14
|
-
if: |
|
|
15
|
-
github.event_name == 'pull_request' ||
|
|
16
|
-
startsWith(github.ref, 'refs/tags/') ||
|
|
17
|
-
!startsWith(github.event.head_commit.message, 'chore: release')
|
|
18
|
-
|
|
19
|
-
steps:
|
|
20
|
-
- uses: actions/checkout@v4
|
|
21
|
-
|
|
22
|
-
- name: Set up Node.js
|
|
23
|
-
uses: actions/setup-node@v4
|
|
24
|
-
with:
|
|
25
|
-
node-version: '20'
|
|
26
|
-
cache: 'npm'
|
|
27
|
-
|
|
28
|
-
- name: Install dependencies
|
|
29
|
-
run: npm ci
|
|
30
|
-
|
|
31
|
-
- name: Typecheck
|
|
32
|
-
run: npm run typecheck
|
|
33
|
-
|
|
34
|
-
- name: Test
|
|
35
|
-
run: npm test
|
|
36
|
-
|
|
37
|
-
- name: Build
|
|
38
|
-
run: npm run build
|
|
39
|
-
|
|
40
|
-
publish:
|
|
41
|
-
runs-on: codeberg-small
|
|
42
|
-
needs: ci
|
|
43
|
-
if: startsWith(github.ref, 'refs/tags/v')
|
|
44
|
-
|
|
45
|
-
steps:
|
|
46
|
-
- uses: actions/checkout@v4
|
|
47
|
-
|
|
48
|
-
- name: Set up Node.js
|
|
49
|
-
uses: actions/setup-node@v4
|
|
50
|
-
with:
|
|
51
|
-
node-version: '20'
|
|
52
|
-
cache: 'npm'
|
|
53
|
-
registry-url: 'https://registry.npmjs.org'
|
|
54
|
-
|
|
55
|
-
- name: Install dependencies
|
|
56
|
-
run: npm ci
|
|
57
|
-
|
|
58
|
-
- name: Build
|
|
59
|
-
run: npm run build
|
|
60
|
-
|
|
61
|
-
- name: Publish to npm
|
|
62
|
-
run: npm publish --access public
|
|
63
|
-
env:
|
|
64
|
-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
65
|
-
|
|
66
|
-
- name: Extract release notes from CHANGELOG
|
|
67
|
-
run: |
|
|
68
|
-
VERSION="${{ github.ref_name }}"
|
|
69
|
-
VERSION_NUM="${VERSION#v}"
|
|
70
|
-
awk "/^## \[${VERSION_NUM}\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md \
|
|
71
|
-
| sed '/^[[:space:]]*$/d' > /tmp/release_notes.md
|
|
72
|
-
echo "Release notes:"
|
|
73
|
-
cat /tmp/release_notes.md
|
|
74
|
-
|
|
75
|
-
- name: Create Codeberg release
|
|
76
|
-
run: |
|
|
77
|
-
curl -s -X POST "https://codeberg.org/api/v1/repos/dpesch/mantisbt-mcp-server/releases" \
|
|
78
|
-
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
|
79
|
-
-H "Content-Type: application/json" \
|
|
80
|
-
-d "$(jq -n \
|
|
81
|
-
--arg tag "${{ github.ref_name }}" \
|
|
82
|
-
--arg name "${{ github.ref_name }}" \
|
|
83
|
-
--rawfile body /tmp/release_notes.md \
|
|
84
|
-
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')"
|
|
85
|
-
|
|
86
|
-
- name: Create GitHub release
|
|
87
|
-
run: |
|
|
88
|
-
curl -s -X POST "https://api.github.com/repos/dpesch/mantisbt-mcp-server/releases" \
|
|
89
|
-
-H "Authorization: Bearer ${{ secrets.GH_RELEASE_TOKEN }}" \
|
|
90
|
-
-H "Content-Type: application/json" \
|
|
91
|
-
-d "$(jq -n \
|
|
92
|
-
--arg tag "${{ github.ref_name }}" \
|
|
93
|
-
--arg name "${{ github.ref_name }}" \
|
|
94
|
-
--rawfile body /tmp/release_notes.md \
|
|
95
|
-
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')"
|
package/.github/workflows/ci.yml
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
name: CI
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches: [main]
|
|
6
|
-
pull_request:
|
|
7
|
-
branches: [main]
|
|
8
|
-
|
|
9
|
-
jobs:
|
|
10
|
-
test:
|
|
11
|
-
runs-on: ubuntu-latest
|
|
12
|
-
|
|
13
|
-
steps:
|
|
14
|
-
- uses: actions/checkout@v4
|
|
15
|
-
|
|
16
|
-
- name: Set up Node.js
|
|
17
|
-
uses: actions/setup-node@v4
|
|
18
|
-
with:
|
|
19
|
-
node-version: '20'
|
|
20
|
-
cache: 'npm'
|
|
21
|
-
|
|
22
|
-
- name: Install dependencies
|
|
23
|
-
run: npm ci
|
|
24
|
-
|
|
25
|
-
- name: Type check
|
|
26
|
-
run: npm run typecheck
|
|
27
|
-
|
|
28
|
-
- name: Build
|
|
29
|
-
run: npm run build
|
|
30
|
-
|
|
31
|
-
- name: Test
|
|
32
|
-
run: npm test
|
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// pre-push hook — typecheck gate + Codeberg .claude/ filter
|
|
3
|
-
//
|
|
4
|
-
// Typecheck runs for every push (origin and upstream) to catch type errors
|
|
5
|
-
// before they reach CI.
|
|
6
|
-
//
|
|
7
|
-
// Codeberg filter handles multi-commit pushes correctly:
|
|
8
|
-
// 1. Fetches the actual Codeberg tip via ls-remote
|
|
9
|
-
// 2. Finds the local commit whose filtered tree matches that tip (anchor)
|
|
10
|
-
// 3. Filters every commit between anchor and tip in order (oldest first)
|
|
11
|
-
// 4. Builds a proper filtered chain anchored to the actual remote tip
|
|
12
|
-
// 5. Pushes the filtered tip with --force
|
|
13
|
-
// 6. Exits 2 to block git's unfiltered push
|
|
14
|
-
// Exit codes: 0 = no Codeberg refs, 1 = error, 2 = success (filtered push done)
|
|
15
|
-
//
|
|
16
|
-
// Branches are processed before tags so the shaMap is available for tags
|
|
17
|
-
// that point to commits already processed as part of the branch.
|
|
18
|
-
//
|
|
19
|
-
// ⚠ IMPORTANT: upstream (Codeberg) is a filtered mirror — push-only.
|
|
20
|
-
// Never run git pull, git fetch + merge, or git rebase against upstream.
|
|
21
|
-
// Filtered commits have different SHAs; pulling them back creates duplicate
|
|
22
|
-
// history. Use origin (Gitolite) as the authoritative source.
|
|
23
|
-
//
|
|
24
|
-
// --force is always required for Codeberg: filtered SHAs structurally diverge
|
|
25
|
-
// from local SHAs, so git rejects main as non-fast-forward before the hook
|
|
26
|
-
// runs unless --force bypasses that check.
|
|
27
|
-
|
|
28
|
-
import { execSync } from 'node:child_process';
|
|
29
|
-
import { createInterface } from 'node:readline';
|
|
30
|
-
|
|
31
|
-
// Recursion guard — must be first to avoid running typecheck during recursive pushes
|
|
32
|
-
if (process.env._PREPUSH_FILTER_ACTIVE) process.exit(0);
|
|
33
|
-
|
|
34
|
-
// Run typecheck before every push (catches type errors before they hit CI)
|
|
35
|
-
try {
|
|
36
|
-
execSync('npm run typecheck', { stdio: 'inherit' });
|
|
37
|
-
} catch {
|
|
38
|
-
console.error('✗ Typecheck failed — push aborted. Fix type errors first.');
|
|
39
|
-
process.exit(1);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const [,, , remoteUrl] = process.argv;
|
|
43
|
-
|
|
44
|
-
// Only intercept Codeberg pushes for the filter step
|
|
45
|
-
if (!remoteUrl?.includes('codeberg.org')) process.exit(0);
|
|
46
|
-
|
|
47
|
-
console.log('→ Codeberg push detected — filtering .claude/ directory...');
|
|
48
|
-
|
|
49
|
-
const ZERO_SHA = '0'.repeat(40);
|
|
50
|
-
|
|
51
|
-
const git = (cmd, opts = {}) => {
|
|
52
|
-
try {
|
|
53
|
-
return execSync(`git ${cmd}`, { encoding: 'utf8', ...opts }).trim();
|
|
54
|
-
} catch (err) {
|
|
55
|
-
console.error(`✗ git ${cmd}\n${err.stderr || err.message}`);
|
|
56
|
-
process.exit(1);
|
|
57
|
-
}
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const gitOptional = (cmd, opts = {}) => {
|
|
61
|
-
try { return execSync(`git ${cmd}`, { encoding: 'utf8', ...opts }).trim(); }
|
|
62
|
-
catch { return ''; }
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
const isSha = s => /^[0-9a-f]{40}$/.test(s);
|
|
66
|
-
|
|
67
|
-
// Remove .claude/ from the root tree of a commit and return the new tree SHA.
|
|
68
|
-
const filterTree = commitSha => {
|
|
69
|
-
const entries = git(`ls-tree "${commitSha}^{tree}"`);
|
|
70
|
-
const filtered = entries.split('\n').filter(e => !e.match(/\t\.claude$/)).join('\n');
|
|
71
|
-
const tree = git('mktree', { input: filtered });
|
|
72
|
-
if (!isSha(tree)) { console.error(`✗ mktree failed for ${commitSha}`); process.exit(1); }
|
|
73
|
-
return tree;
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
// Create a filtered commit object preserving all original metadata.
|
|
77
|
-
const makeFilteredCommit = (commitSha, filteredTree, mappedParents) => {
|
|
78
|
-
const log = git(`log -1 --format=%an%n%ae%n%aI%n%cn%n%ce%n%cI%n%n%B "${commitSha}"`);
|
|
79
|
-
const [an, ae, aI, cn, ce, cI, , ...msgLines] = log.split('\n');
|
|
80
|
-
const commitMsg = msgLines.join('\n').trimEnd();
|
|
81
|
-
const parentFlags = mappedParents.map(p => `-p ${p}`).join(' ');
|
|
82
|
-
const env = {
|
|
83
|
-
...process.env,
|
|
84
|
-
GIT_AUTHOR_NAME: an, GIT_AUTHOR_EMAIL: ae, GIT_AUTHOR_DATE: aI,
|
|
85
|
-
GIT_COMMITTER_NAME: cn, GIT_COMMITTER_EMAIL: ce, GIT_COMMITTER_DATE: cI,
|
|
86
|
-
};
|
|
87
|
-
const result = git(`commit-tree "${filteredTree}" ${parentFlags}`, { env, input: commitMsg });
|
|
88
|
-
if (!isSha(result)) { console.error(`✗ commit-tree failed for ${commitSha}`); process.exit(1); }
|
|
89
|
-
return result;
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
// Scan ancestors of tipSha (newest first) and return the most recent one whose
|
|
93
|
-
// filtered root tree equals the tree of remoteSha. Returns null if not found.
|
|
94
|
-
const findLocalAnchor = (tipSha, remoteSha, maxDepth = 100) => {
|
|
95
|
-
const remoteTree = gitOptional(`rev-parse "${remoteSha}^{tree}"`);
|
|
96
|
-
if (!remoteTree) return null;
|
|
97
|
-
const candidates = gitOptional(`rev-list --max-count=${maxDepth} "${tipSha}"`);
|
|
98
|
-
if (!candidates) return null;
|
|
99
|
-
for (const sha of candidates.split('\n').filter(Boolean)) {
|
|
100
|
-
if (filterTree(sha) === remoteTree) return sha;
|
|
101
|
-
}
|
|
102
|
-
return null;
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
const rl = createInterface({ input: process.stdin });
|
|
106
|
-
const lines = [];
|
|
107
|
-
rl.on('line', line => { if (line.trim()) lines.push(line.trim()); });
|
|
108
|
-
|
|
109
|
-
rl.on('close', () => {
|
|
110
|
-
// Process branches before tags so shaMap is populated when tags are handled.
|
|
111
|
-
const branches = lines.filter(l => l.split(' ')[2]?.startsWith('refs/heads/'));
|
|
112
|
-
const others = lines.filter(l => !l.split(' ')[2]?.startsWith('refs/heads/'));
|
|
113
|
-
|
|
114
|
-
// Batch-fetch all tags from Codeberg (single ls-remote call, ref → sha).
|
|
115
|
-
const remoteTagsRaw = gitOptional(`ls-remote --tags "${remoteUrl}"`);
|
|
116
|
-
const remoteTagMap = new Map(
|
|
117
|
-
remoteTagsRaw
|
|
118
|
-
? remoteTagsRaw.split('\n').filter(Boolean)
|
|
119
|
-
.map(l => { const [sha, ref] = l.split(/\s+/); return [ref, sha]; })
|
|
120
|
-
.filter(([ref]) => ref)
|
|
121
|
-
: []
|
|
122
|
-
);
|
|
123
|
-
|
|
124
|
-
// localSha → filteredSha, accumulated across all refs in this push.
|
|
125
|
-
const shaMap = {};
|
|
126
|
-
let pushed = 0;
|
|
127
|
-
|
|
128
|
-
for (const line of [...branches, ...others]) {
|
|
129
|
-
const parts = line.split(' ');
|
|
130
|
-
if (parts.length < 3) { console.error(`✗ Malformed push input: ${line}`); process.exit(1); }
|
|
131
|
-
const [, localSha, remoteRef] = parts;
|
|
132
|
-
|
|
133
|
-
if (localSha === ZERO_SHA) continue; // deletion — skip
|
|
134
|
-
|
|
135
|
-
const label = remoteRef.replace('refs/heads/', '').replace('refs/tags/', '');
|
|
136
|
-
|
|
137
|
-
if (remoteRef.startsWith('refs/tags/')) {
|
|
138
|
-
if (remoteTagMap.has(remoteRef)) {
|
|
139
|
-
console.log(` ↷ ${label} already on Codeberg — skipping`);
|
|
140
|
-
continue;
|
|
141
|
-
}
|
|
142
|
-
if (!gitOptional(`rev-parse --verify "${localSha}"`)) {
|
|
143
|
-
console.warn(` ⚠ ${label} — commit ${localSha.slice(0, 8)} not found locally, skipping`);
|
|
144
|
-
continue;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// If this commit was already filtered as part of a branch push, reuse it.
|
|
149
|
-
if (localSha in shaMap) {
|
|
150
|
-
try {
|
|
151
|
-
execSync(`git push "${remoteUrl}" "${shaMap[localSha]}:${remoteRef}" --force`,
|
|
152
|
-
{ env: { ...process.env, _PREPUSH_FILTER_ACTIVE: '1' }, stdio: 'inherit' });
|
|
153
|
-
} catch {
|
|
154
|
-
console.error(`✗ Push failed for ${label}`); process.exit(1);
|
|
155
|
-
}
|
|
156
|
-
console.log(`✓ ${label} pushed to Codeberg (without .claude/)`);
|
|
157
|
-
pushed++;
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Tags: existing ones were skipped above, so only new tags reach here — no remote anchor.
|
|
162
|
-
// Branches: query individually to find the incremental anchor for filtered chain building.
|
|
163
|
-
const actualRemoteSha = remoteRef.startsWith('refs/tags/')
|
|
164
|
-
? ''
|
|
165
|
-
: (gitOptional(`ls-remote "${remoteUrl}" "${remoteRef}"`)?.split(/\s+/)[0] ?? '');
|
|
166
|
-
|
|
167
|
-
if (!actualRemoteSha) {
|
|
168
|
-
// New ref on Codeberg — filter the tip with no parent.
|
|
169
|
-
shaMap[localSha] = makeFilteredCommit(localSha, filterTree(localSha), []);
|
|
170
|
-
} else {
|
|
171
|
-
// Find the local commit that corresponds to the current Codeberg tip.
|
|
172
|
-
const localAnchor = findLocalAnchor(localSha, actualRemoteSha);
|
|
173
|
-
|
|
174
|
-
if (localAnchor) {
|
|
175
|
-
shaMap[localAnchor] = actualRemoteSha;
|
|
176
|
-
|
|
177
|
-
// Filter all commits between anchor and tip, oldest first.
|
|
178
|
-
const revList = gitOptional(`rev-list --reverse "${localAnchor}..${localSha}"`);
|
|
179
|
-
const commits = revList ? revList.split('\n').filter(Boolean) : [];
|
|
180
|
-
|
|
181
|
-
for (const sha of commits) {
|
|
182
|
-
const filteredTree = filterTree(sha);
|
|
183
|
-
const parents = gitOptional(`log -1 --format=%P "${sha}"`);
|
|
184
|
-
const parentShas = parents ? parents.split(' ').filter(Boolean) : [];
|
|
185
|
-
// Map each parent through shaMap; fall back to actualRemoteSha for
|
|
186
|
-
// parents outside the current range (already on the remote).
|
|
187
|
-
const mappedParents = parentShas
|
|
188
|
-
.map(p => shaMap[p] ?? actualRemoteSha)
|
|
189
|
-
.filter(Boolean);
|
|
190
|
-
shaMap[sha] = makeFilteredCommit(sha, filteredTree, mappedParents);
|
|
191
|
-
}
|
|
192
|
-
} else {
|
|
193
|
-
// Fallback: anchor not found — branch only (tags exit early above).
|
|
194
|
-
const remoteExists = !!gitOptional(`rev-parse --verify "${actualRemoteSha}"`);
|
|
195
|
-
console.warn(` ⚠ Could not find local base for ${label}, filtering tip only`);
|
|
196
|
-
shaMap[localSha] = makeFilteredCommit(localSha, filterTree(localSha), remoteExists ? [actualRemoteSha] : []);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const filteredTip = shaMap[localSha];
|
|
201
|
-
if (!filteredTip) {
|
|
202
|
-
console.error(`✗ Could not compute filtered SHA for ${localSha}`);
|
|
203
|
-
process.exit(1);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
try {
|
|
207
|
-
execSync(`git push "${remoteUrl}" "${filteredTip}:${remoteRef}" --force`,
|
|
208
|
-
{ env: { ...process.env, _PREPUSH_FILTER_ACTIVE: '1' }, stdio: 'inherit' });
|
|
209
|
-
} catch {
|
|
210
|
-
console.error(`✗ Push to Codeberg failed for ${label}`); process.exit(1);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
console.log(`✓ ${label} pushed to Codeberg (without .claude/)`);
|
|
214
|
-
pushed++;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
if (pushed === 0) process.exit(0);
|
|
218
|
-
console.log('→ Filtered push complete. Blocking unfiltered push.');
|
|
219
|
-
process.exit(2); // 2 = success (filtered push done, unfiltered blocked); 1 = error
|
|
220
|
-
});
|