@canyonjs/git-provider 0.0.3 → 0.0.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/README.md CHANGED
@@ -62,15 +62,15 @@ GitHub 仅 token、`base` 固定为 `https://api.github.com`。
62
62
 
63
63
  ## 开发与脚本
64
64
 
65
- | 脚本 | 说明 |
66
- | -------------------- | --------------------------------------------------------- |
67
- | `pnpm run build` | 使用 tsdown 产出 `dist` |
68
- | `pnpm run dev` | 监听构建 |
69
- | `pnpm run typecheck` | TypeScript 检查 |
70
- | `pnpm run test` | Vitest |
71
- | `pnpm run debug` | 本地脚本 `src/debug.ts`(可自行改为写 token、仓库与 ref) |
72
-
73
- 目录概览:**`src/adapter.ts`** 定义 **`ScmAdapter`**;**`src/gitlab.ts`** / **`src/github.ts`** 为实现;**`src/diff-line.ts`** 为行号计算与扩展名判定;**`src/types.ts`** 为配置与各数据结构类型。
65
+ | 脚本 | 说明 |
66
+ | -------------------- | ---------------------------------------------------------- |
67
+ | `pnpm run build` | 使用 tsdown 产出 `dist` |
68
+ | `pnpm run dev` | 监听构建 |
69
+ | `pnpm run typecheck` | TypeScript 检查 |
70
+ | `pnpm run test` | Vitest |
71
+ | `pnpm run debug` | 本地脚本 `scripts/debug/`(`.env` 配置 token、仓库与 ref) |
72
+
73
+ 目录概览:**`src/adapter.ts`** 定义 **`ScmAdapter`**;**`src/gitlab.ts`** / **`src/github.ts`** 为实现;**`src/diff-line.ts`** 为行号计算与扩展名判定;**`src/types.ts`** 为配置与各数据结构类型。**`tests/`** 为 Vitest 测试;**`tests/test-utils/`** 为本机 HTTP mock、fixture JSON 等(不进入 `dist`)。
74
74
 
75
75
  ## 许可证
76
76
 
package/dist/index.d.mts CHANGED
@@ -34,8 +34,15 @@ interface Compare {
34
34
  interface ScmAdapter {
35
35
  /** 获取仓库信息(id、pathWithNamespace、description) */
36
36
  getRepoInfo(repoID: string): Promise<RepoInfo>;
37
+ /** 获取 base..head 之间 commit sha 列表(顺序与 GitLab `repository/compare` 返回的 `commits` 一致;无 `commits` 时可能仅有 `commit.id`) */
38
+ getCommitsBetween(repoID: string, base: string, head: string): Promise<string[]>;
37
39
  /** 获取 base...head 之间变更 */
38
40
  getCompare(repoID: string, base: string, head: string): Promise<Compare>;
41
+ /**
42
+ * 批量获取指定 ref 下多个文件的源码(通过 archive 下载后解压提取,避免逐文件请求)
43
+ * @returns Map<相对路径, 文件内容 UTF-8 字符串>
44
+ */
45
+ getSourceFiles(repoID: string, sha: string, filePaths: string[]): Promise<Map<string, string>>;
39
46
  }
40
47
  //#endregion
41
48
  //#region src/index.d.ts
package/dist/index.mjs CHANGED
@@ -1,4 +1,7 @@
1
+ import axios from "axios";
1
2
  import { diffLines } from "diff";
3
+ /** 统一超时;需要改签名单独在调用里传 `AxiosRequestConfig` */
4
+ const http = axios.create({ timeout: 1e4 });
2
5
  var HttpError = class extends Error {
3
6
  status;
4
7
  statusText;
@@ -12,52 +15,24 @@ var HttpError = class extends Error {
12
15
  this.data = data;
13
16
  }
14
17
  };
15
- function mergeSignal(timeoutSignal, userSignal) {
16
- if (!userSignal) return timeoutSignal;
17
- const any = AbortSignal.any;
18
- if (typeof any === "function") return any([userSignal, timeoutSignal]);
19
- return timeoutSignal;
18
+ function rethrowAsHttpError(e) {
19
+ if (axios.isAxiosError(e) && e.response) throw new HttpError(e.response.status, e.response.statusText ?? "", e.response.data);
20
+ if (e instanceof Error) throw e;
21
+ throw new Error(String(e));
20
22
  }
21
- async function parseBody(res) {
22
- const text = await res.text();
23
- if (!text) return void 0;
24
- if ((res.headers.get("content-type") ?? "").includes("application/json")) try {
25
- return JSON.parse(text);
26
- } catch {
27
- return text;
28
- }
29
- return text;
30
- }
31
- async function dispatch(url, config) {
32
- const timeoutMs = config.timeout ?? 1e4;
33
- const controller = new AbortController();
34
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
35
- const signal = mergeSignal(controller.signal, config.signal);
23
+ async function get(url, config) {
36
24
  try {
37
- const res = await fetch(url, {
38
- method: config.method ?? "GET",
39
- headers: config.headers,
40
- body: config.body,
41
- signal
42
- });
43
- const data = await parseBody(res);
44
- if (!res.ok) throw new HttpError(res.status, res.statusText, data);
25
+ const r = await http.get(url, config);
45
26
  return {
46
- data,
47
- status: res.status,
48
- statusText: res.statusText
27
+ data: r.data,
28
+ status: r.status,
29
+ statusText: r.statusText
49
30
  };
50
- } finally {
51
- clearTimeout(timeoutId);
31
+ } catch (e) {
32
+ rethrowAsHttpError(e);
52
33
  }
53
34
  }
54
- /** 类似 `axios.get`:返回 `{ data, status, statusText }`,非 2xx 抛 `HttpError` */
55
- function get(url, config = {}) {
56
- return dispatch(url, {
57
- ...config,
58
- method: "GET"
59
- });
60
- }
35
+ http.defaults;
61
36
  //#endregion
62
37
  //#region src/github.ts
63
38
  const GITHUB_BASE = "https://api.github.com";
@@ -92,9 +67,15 @@ var GithubAdapter = class {
92
67
  description: data.description ?? ""
93
68
  };
94
69
  }
70
+ async getCommitsBetween(_repoID, _base, _head) {
71
+ throw new Error("GitHubAdapter.getCommitsBetween 尚未实现");
72
+ }
95
73
  async getCompare(_repoID, _base, _head) {
96
74
  throw new Error("GitHubAdapter.getCompare 尚未实现");
97
75
  }
76
+ async getSourceFiles(repoID, sha, filePaths) {
77
+ throw new Error("GitHubAdapter.getSourceFiles 尚未实现");
78
+ }
98
79
  };
99
80
  //#endregion
100
81
  //#region src/diff-line.ts
@@ -187,6 +168,15 @@ async function computeLineBucketsByRef(files, baseRef, headRef, fetchText, concu
187
168
  }
188
169
  //#endregion
189
170
  //#region src/gitlab.ts
171
+ /** 唯一路径数超过该值时用 archive.zip;否则并发请求单文件 raw,避免为少量路径拉整仓 */
172
+ const SOURCE_FILES_ARCHIVE_THRESHOLD = 8;
173
+ /** 提交 id 列表:完全以 compare 响应为准——有 `commits` 则用其顺序;否则用 `commit.id`(同 ref / 无区间时) */
174
+ function commitIdsFromCompare(data) {
175
+ const ids = (data.commits ?? []).map((c) => c.id).filter((id) => id !== "");
176
+ if (ids.length > 0) return ids;
177
+ const tip = data.commit?.id;
178
+ return tip != null && tip !== "" ? [tip] : [];
179
+ }
190
180
  var GitlabAdapter = class {
191
181
  base;
192
182
  token;
@@ -199,39 +189,101 @@ var GitlabAdapter = class {
199
189
  }
200
190
  fileRawUrl(repoID, filePath, ref) {
201
191
  const encodedPath = encodeURIComponent(filePath);
202
- return `${this.base}/api/v4/projects/${encodeURIComponent(repoID)}/repository/files/${encodedPath}/raw?ref=${encodeURIComponent(ref)}`;
192
+ return `${this.base}/projects/${encodeURIComponent(repoID)}/repository/files/${encodedPath}/raw?ref=${encodeURIComponent(ref)}`;
203
193
  }
204
194
  async getRawFileText(repoID, filePath, ref) {
205
195
  const { data } = await get(this.fileRawUrl(repoID, filePath, ref), { headers: this.headers() });
206
196
  return typeof data === "string" ? data : "";
207
197
  }
208
198
  async getRepoInfo(repoID) {
209
- const { data } = await get(`${this.base}/api/v4/projects/${encodeURIComponent(repoID)}`, { headers: this.headers() });
199
+ const { data } = await get(`${this.base}/projects/${encodeURIComponent(repoID)}`, { headers: this.headers() });
210
200
  return {
211
201
  id: String(data.id),
212
202
  pathWithNamespace: data.path_with_namespace,
213
203
  description: data.description ?? ""
214
204
  };
215
205
  }
206
+ async fetchComparePayload(repoID, base, head) {
207
+ const { data } = await get(`${this.base}/projects/${encodeURIComponent(repoID)}/repository/compare?from=${encodeURIComponent(base)}&to=${encodeURIComponent(head)}`, { headers: this.headers() });
208
+ return data;
209
+ }
210
+ async getCommitsBetween(repoID, base, head) {
211
+ return commitIdsFromCompare(await this.fetchComparePayload(repoID, base, head));
212
+ }
216
213
  async getCompare(repoID, base, head) {
217
- const { data } = await get(`${this.base}/api/v4/projects/${encodeURIComponent(repoID)}/repository/compare?from=${encodeURIComponent(base)}&to=${encodeURIComponent(head)}`, { headers: this.headers() });
214
+ const data = await this.fetchComparePayload(repoID, base, head);
215
+ const commitList = commitIdsFromCompare(data);
218
216
  const diffRows = data.diffs ?? [];
219
217
  const comparedPaths = diffRows.map(comparedRefsFromGitlabDiff).filter((entry) => entry != null).filter((entry) => pathMatchesLineDiffExtensions(entry.keyPath));
220
- const lineBuckets = comparedPaths.length > 1500 ? /* @__PURE__ */ new Map() : await computeLineBucketsByRef(comparedPaths, base, head, (path, ref) => this.getRawFileText(repoID, path, ref));
221
- const changedFiles = diffRows.map((d) => {
222
- const path = comparedRefsFromGitlabDiff(d)?.keyPath ?? (d.new_path || d.old_path || "");
223
- const buckets = lineBuckets.get(path);
224
- return {
225
- path,
226
- additions: buckets?.additions ?? [],
227
- deletions: buckets?.deletions ?? []
228
- };
229
- }).filter((item) => pathMatchesLineDiffExtensions(item.path));
218
+ let lineBuckets;
219
+ if (comparedPaths.length === 0 || comparedPaths.length > 1500) lineBuckets = /* @__PURE__ */ new Map();
220
+ else {
221
+ const basePaths = [...new Set(comparedPaths.map((p) => p.pathAtBase))];
222
+ const headPaths = [...new Set(comparedPaths.map((p) => p.pathAtHead))];
223
+ const [baseTexts, headTexts] = await Promise.all([this.getSourceFiles(repoID, base, basePaths), this.getSourceFiles(repoID, head, headPaths)]);
224
+ lineBuckets = await computeLineBucketsByRef(comparedPaths, base, head, (path, ref) => Promise.resolve(ref === base ? baseTexts.get(path) ?? "" : ref === head ? headTexts.get(path) ?? "" : ""));
225
+ }
230
226
  return {
231
- commitList: (data.commits ?? []).map((c) => c.id),
232
- changedFiles
227
+ commitList,
228
+ changedFiles: diffRows.map((d) => {
229
+ const path = comparedRefsFromGitlabDiff(d)?.keyPath ?? (d.new_path || d.old_path || "");
230
+ const buckets = lineBuckets.get(path);
231
+ return {
232
+ path,
233
+ additions: buckets?.additions ?? [],
234
+ deletions: buckets?.deletions ?? []
235
+ };
236
+ }).filter((item) => pathMatchesLineDiffExtensions(item.path))
233
237
  };
234
238
  }
239
+ async getSourceFiles(repoID, sha, filePaths) {
240
+ const uniquePaths = [...new Set(filePaths)];
241
+ const wantAllFiles = uniquePaths.length === 0;
242
+ if (!wantAllFiles && uniquePaths.length <= SOURCE_FILES_ARCHIVE_THRESHOLD) {
243
+ const settled = await Promise.allSettled(uniquePaths.map((path) => this.getRawFileText(repoID, path, sha).then((text) => ({
244
+ path,
245
+ text
246
+ }))));
247
+ const result = /* @__PURE__ */ new Map();
248
+ for (const s of settled) if (s.status === "fulfilled") result.set(s.value.path, s.value.text);
249
+ return result;
250
+ }
251
+ const pid = encodeURIComponent(repoID);
252
+ const { data } = await get(`${this.base}/projects/${pid}/repository/archive.zip`, {
253
+ headers: this.headers(),
254
+ params: { sha },
255
+ responseType: "arraybuffer",
256
+ timeout: 6e4
257
+ });
258
+ const { default: AdmZip } = await import("adm-zip");
259
+ const { tmpNameSync } = await import("tmp");
260
+ const fs = await import("node:fs");
261
+ const tempZip = tmpNameSync({ postfix: ".zip" });
262
+ try {
263
+ const bin = data;
264
+ const buf = Buffer.isBuffer(bin) ? bin : bin instanceof ArrayBuffer ? Buffer.from(bin) : Buffer.from(bin);
265
+ fs.writeFileSync(tempZip, buf);
266
+ const entries = new AdmZip(tempZip).getEntries();
267
+ const targetSet = wantAllFiles ? null : new Set(uniquePaths);
268
+ const result = /* @__PURE__ */ new Map();
269
+ for (const entry of entries) {
270
+ if (entry.isDirectory) continue;
271
+ const parts = entry.entryName.split("/");
272
+ if (parts.length < 2) continue;
273
+ const relativePath = parts.slice(1).join("/");
274
+ if (targetSet && !targetSet.has(relativePath)) continue;
275
+ try {
276
+ const content = entry.getData().toString("utf8");
277
+ result.set(relativePath, content);
278
+ } catch {}
279
+ }
280
+ return result;
281
+ } finally {
282
+ try {
283
+ fs.unlinkSync(tempZip);
284
+ } catch {}
285
+ }
286
+ }
235
287
  };
236
288
  //#endregion
237
289
  //#region src/index.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canyonjs/git-provider",
3
- "version": "0.0.3",
3
+ "version": "0.0.6",
4
4
  "description": "GitLab provider client with adapter architecture.",
5
5
  "keywords": [
6
6
  "api",
@@ -28,11 +28,20 @@
28
28
  "./package.json": "./package.json"
29
29
  },
30
30
  "dependencies": {
31
- "diff": "^9.0.0"
31
+ "adm-zip": "^0.5.17",
32
+ "axios": "^1.16.0",
33
+ "diff": "^9.0.0",
34
+ "tmp": "^0.2.5"
32
35
  },
33
36
  "devDependencies": {
37
+ "@canyonjs/report": "^1.0.28",
38
+ "@types/adm-zip": "^0.5.8",
34
39
  "@types/node": "^25.5.0",
40
+ "@types/tmp": "^0.2.6",
35
41
  "@typescript/native-preview": "7.0.0-dev.20260505.1",
42
+ "@vitest/coverage-istanbul": "4.1.5",
43
+ "@vitest/ui": "4.1.5",
44
+ "dotenv": "^17.4.2",
36
45
  "oxfmt": "^0.48.0",
37
46
  "oxlint": "^1.63.0",
38
47
  "tsdown": "^0.21.7",
@@ -43,8 +52,9 @@
43
52
  "build": "tsdown",
44
53
  "dev": "tsdown --watch",
45
54
  "test": "vitest run",
55
+ "test:ui": "vitest --ui",
46
56
  "typecheck": "tsgo --noEmit",
47
- "debug": "tsx src/debug.ts",
57
+ "debug": "tsx scripts/debug/index.ts",
48
58
  "lint": "oxlint",
49
59
  "lint:fix": "oxlint --fix",
50
60
  "fmt": "oxfmt",