@canyonjs/git-provider 0.0.1 → 0.0.2

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
@@ -1,49 +1,81 @@
1
- # git-provider
1
+ # @canyonjs/git-provider
2
2
 
3
- 统一封装 GitHub / GitLab / Gitea 的常见仓库 API。
3
+ 面向 **Git 托管平台(GitLab / GitHub)** 的轻量 TypeScript 客户端库。通过对外的 **`ScmAdapter`** 接口屏蔽平台差异:业务代码只依赖「仓库信息与两个引用之间的 Compare 结果」,由工厂方法按配置选择具体适配器。
4
4
 
5
- ## Development
5
+ ## 能力与现状
6
6
 
7
- - Install dependencies:
7
+ | 能力 | GitLab | GitHub |
8
+ | --- | --- | --- |
9
+ | `getRepoInfo` | 支持(Project API) | 支持(`owner/repo` 或数字仓库 id) |
10
+ | `getCompare` | **已实现**:比较 API + 逐文件 Raw 内容与行级差异 | **未实现**(调用会抛错) |
8
11
 
9
- ```bash
10
- npm install
11
- ```
12
+ `getCompare` 返回结构化结果 **`Compare`**:
13
+
14
+ - **`commitList`**:`base` … `head` 范围内涉及的提交 id(来源 GitLab `repository/compare`)。
15
+ - **`changedFiles`**:**仅保留扩展名为 `tsx` / `ts` / `jsx` / `js` 的文件**;每项含 **`path`** 以及 **`additions` / `deletions` 行号列表**(由 `diff` 对两侧全文做逐行比对得到)。不支持 Windows 路径风格,假定仓库路径均为 POSIX `/`。
16
+
17
+ GitLab 侧会处理 **重命名**:在 `base` 与 `head` 上分别按 **旧路径 / 新路径** 拉取 Raw,再在统一展示路径上与行号结果对齐。
12
18
 
13
- - Run the unit tests:
19
+ ## 依赖与请求
20
+
21
+ - 运行时依赖:**[`diff`](https://www.npmjs.com/package/diff)**(行级比对)。
22
+ - HTTP 使用全局 **`fetch`**(见 `src/request.ts`),按需配置超时等。
23
+
24
+ ## 安装
14
25
 
15
26
  ```bash
16
- npm run test
27
+ pnpm add @canyonjs/git-provider
17
28
  ```
18
29
 
19
- - Build the library:
30
+ 若从源码开发与联调:
20
31
 
21
32
  ```bash
22
- npm run build
33
+ pnpm install
34
+ pnpm run build
23
35
  ```
24
36
 
25
- ## Usage
37
+ ## 用法示例
26
38
 
27
39
  ```ts
28
- import { createGitProviderClient } from 'git-provider'
29
-
30
- const client = createGitProviderClient({
31
- provider: 'gitlab', // 'github' | 'gitlab' | 'gitea'
32
- token: process.env.GIT_TOKEN
33
- })
40
+ import {
41
+ createScmAdapter,
42
+ type Compare,
43
+ type ScmConfig,
44
+ } from "@canyonjs/git-provider";
34
45
 
35
- // 1) 根据 id 或 owner/repo 获取仓库摘要
36
- const repo = await client.getRepositorySummary('canyon-project/canyon')
46
+ const config: ScmConfig = {
47
+ type: "gitlab",
48
+ base: "https://gitlab.example.com",
49
+ token: process.env.GITLAB_TOKEN!,
50
+ };
37
51
 
38
- // 2) 获取 commit 摘要
39
- const commit = await client.getCommitSummary('canyon-project/canyon', 'abc123')
52
+ const adapter = createScmAdapter(config);
40
53
 
41
- // 3) 获取单个文件内容
42
- const file = await client.getFileContent('canyon-project/canyon', 'README.md', 'main')
54
+ const repo = await adapter.getRepoInfo("group/project"); // GitLab numeric id / path_with_namespace 等由平台解释
43
55
 
44
- // 4) 获取指定 commit zip 产物
45
- const archive = await client.downloadArchive('canyon-project/canyon', 'abc123')
56
+ const result: Compare = await adapter.getCompare(
57
+ repo.id,
58
+ "main~10", // base ref
59
+ "main", // head ref
60
+ );
46
61
 
47
- // 5) 比较两个 commit
48
- const compare = await client.compareCommits('canyon-project/canyon', 'fromSha', 'toSha')
62
+ console.log(result.commitList.length, result.changedFiles.length);
49
63
  ```
64
+
65
+ GitHub 仅 token、`base` 固定为 `https://api.github.com`。
66
+
67
+ ## 开发与脚本
68
+
69
+ | 脚本 | 说明 |
70
+ | --- | --- |
71
+ | `pnpm run build` | 使用 tsdown 产出 `dist` |
72
+ | `pnpm run dev` | 监听构建 |
73
+ | `pnpm run typecheck` | TypeScript 检查 |
74
+ | `pnpm run test` | Vitest |
75
+ | `pnpm run debug` | 本地脚本 `src/debug.ts`(可自行改为写 token、仓库与 ref) |
76
+
77
+ 目录概览:**`src/adapter.ts`** 定义 **`ScmAdapter`**;**`src/gitlab.ts`** / **`src/github.ts`** 为实现;**`src/diff-line.ts`** 为行号计算与扩展名判定;**`src/types.ts`** 为配置与各数据结构类型。
78
+
79
+ ## 许可证
80
+
81
+ MIT
package/dist/index.d.mts CHANGED
@@ -1,78 +1,47 @@
1
- //#region src/index.d.ts
2
- type GitProvider = 'github' | 'gitlab' | 'gitea';
3
- interface GitProviderClientOptions {
4
- provider: GitProvider;
5
- token?: string;
6
- baseUrl?: string;
7
- headers?: Record<string, string>;
8
- }
9
- interface RepositorySummary {
10
- provider: GitProvider;
1
+ //#region src/types.d.ts
2
+ type ScmType = "github" | "gitlab";
3
+ type GitlabScmConfig = {
4
+ type: "gitlab";
5
+ base: string;
6
+ token: string;
7
+ };
8
+ type GithubScmConfig = {
9
+ type: "github";
10
+ token: string;
11
+ };
12
+ /** GitLab 需 base + token,GitHub 仅需 token(base 固定为 api.github.com) */
13
+ type ScmConfig = GitlabScmConfig | GithubScmConfig;
14
+ interface RepoInfo {
15
+ /** 平台返回的仓库 ID(如 GitLab project id、GitHub repo id),String(data.id) */
11
16
  id: string;
12
- fullName: string;
13
- name: string;
14
- defaultBranch?: string;
15
- webUrl?: string;
16
- private?: boolean;
17
- description?: string | null;
18
- }
19
- interface CommitSummary {
20
- provider: GitProvider;
21
- sha: string;
22
- shortSha: string;
23
- title: string;
24
- message: string;
25
- authorName?: string;
26
- authorEmail?: string;
27
- authoredAt?: string;
28
- webUrl?: string;
29
- }
30
- interface CompareSummary {
31
- provider: GitProvider;
32
- fromSha: string;
33
- toSha: string;
34
- status?: string;
35
- aheadBy?: number;
36
- behindBy?: number;
37
- totalCommits: number;
17
+ pathWithNamespace: string;
18
+ description: string;
38
19
  }
39
- interface FileContentResult {
40
- provider: GitProvider;
20
+ interface CompareDiffItem {
41
21
  path: string;
42
- ref: string;
43
- sha?: string;
44
- size?: number;
45
- content: string;
22
+ additions: number[];
23
+ deletions: number[];
46
24
  }
47
- interface ArchiveResult {
48
- provider: GitProvider;
49
- ref: string;
50
- contentType?: string | null;
51
- fileName?: string;
52
- size: number;
53
- data: Uint8Array;
25
+ interface Compare {
26
+ commitList: string[];
27
+ changedFiles: CompareDiffItem[];
54
28
  }
55
- declare class GitProviderClient {
56
- private readonly provider;
57
- private readonly token?;
58
- private readonly baseUrl;
59
- private readonly extraHeaders;
60
- constructor(options: GitProviderClientOptions);
61
- getRepositorySummary(idOrPath: string | number): Promise<RepositorySummary>;
62
- getCommitSummary(repo: string | number, sha: string): Promise<CommitSummary>;
63
- compareCommits(repo: string | number, fromSha: string, toSha: string): Promise<CompareSummary>;
64
- getFileContent(repo: string | number, filePath: string, ref: string): Promise<FileContentResult>;
65
- downloadArchive(repo: string | number, ref: string): Promise<ArchiveResult>;
66
- private fetchGiteaProject;
67
- private resolveOwnerRepo;
68
- private parseOwnerRepo;
69
- private encodeGitLabProjectId;
70
- private encodePath;
71
- private parseFileNameFromHeaders;
72
- private requestJson;
73
- private fetchWithAuth;
74
- private getAuthHeaders;
29
+ //#endregion
30
+ //#region src/adapter.d.ts
31
+ /**
32
+ * SCM 适配器接口:业务层只依赖此接口,通过 createScmAdapter 获取具体实现。
33
+ */
34
+ interface ScmAdapter {
35
+ /** 获取仓库信息(id、pathWithNamespace、description) */
36
+ getRepoInfo(repoID: string): Promise<RepoInfo>;
37
+ /** 获取 base...head 之间变更 */
38
+ getCompare(repoID: string, base: string, head: string): Promise<Compare>;
75
39
  }
76
- declare function createGitProviderClient(options: GitProviderClientOptions): GitProviderClient;
77
40
  //#endregion
78
- export { ArchiveResult, CommitSummary, CompareSummary, FileContentResult, GitProvider, GitProviderClient, GitProviderClientOptions, RepositorySummary, createGitProviderClient };
41
+ //#region src/index.d.ts
42
+ /**
43
+ * 根据配置里的 `type`(github / gitlab …)创建对应适配器,调用方只依赖返回的 `ScmAdapter`。
44
+ */
45
+ declare function createScmAdapter(config: ScmConfig): ScmAdapter;
46
+ //#endregion
47
+ export { type Compare, type CompareDiffItem, type RepoInfo, type ScmAdapter, type ScmConfig, type ScmType, createScmAdapter };
package/dist/index.mjs CHANGED
@@ -1,289 +1,248 @@
1
- //#region src/index.ts
2
- function stripTrailingSlash(value) {
3
- return value.endsWith("/") ? value.slice(0, -1) : value;
4
- }
5
- function normalizeProviderBaseUrl(provider, baseUrl) {
6
- if (baseUrl) return stripTrailingSlash(baseUrl);
7
- if (provider === "github") return "https://api.github.com";
8
- if (provider === "gitlab") return "https://gitlab.com/api/v4";
9
- return "https://gitea.com/api/v1";
1
+ import { diffLines } from "diff";
2
+ var HttpError = class extends Error {
3
+ status;
4
+ statusText;
5
+ data;
6
+ constructor(status, statusText, data) {
7
+ const detail = typeof data === "string" ? data : data != null && typeof data === "object" ? JSON.stringify(data) : data === void 0 ? "" : String(data);
8
+ super(detail ? `Request failed with ${status}: ${detail}` : `Request failed with ${status}`);
9
+ this.name = "HttpError";
10
+ this.status = status;
11
+ this.statusText = statusText;
12
+ this.data = data;
13
+ }
14
+ };
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;
10
20
  }
11
- function shortSha(sha) {
12
- return sha.slice(0, 8);
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;
13
30
  }
14
- function decodeBase64ToUtf8(value) {
15
- return Buffer.from(value.replace(/\n/g, ""), "base64").toString("utf-8");
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);
36
+ 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);
45
+ return {
46
+ data,
47
+ status: res.status,
48
+ statusText: res.statusText
49
+ };
50
+ } finally {
51
+ clearTimeout(timeoutId);
52
+ }
16
53
  }
17
- function isLikelyNumericRepoId(repo) {
18
- return typeof repo === "number" || /^\d+$/.test(repo);
54
+ /** 类似 `axios.get`:返回 `{ data, status, statusText }`,非 2xx 抛 `HttpError` */
55
+ function get(url, config = {}) {
56
+ return dispatch(url, {
57
+ ...config,
58
+ method: "GET"
59
+ });
19
60
  }
20
- var GitProviderClient = class {
21
- provider;
61
+ //#endregion
62
+ //#region src/github.ts
63
+ const GITHUB_BASE = "https://api.github.com";
64
+ var GithubAdapter = class {
65
+ base = GITHUB_BASE;
22
66
  token;
23
- baseUrl;
24
- extraHeaders;
25
- constructor(options) {
26
- this.provider = options.provider;
27
- this.token = options.token;
28
- this.baseUrl = normalizeProviderBaseUrl(options.provider, options.baseUrl);
29
- this.extraHeaders = options.headers ?? {};
67
+ constructor(config) {
68
+ this.token = config.token;
30
69
  }
31
- async getRepositorySummary(idOrPath) {
32
- if (this.provider === "github") {
33
- const url = isLikelyNumericRepoId(idOrPath) ? `${this.baseUrl}/repositories/${idOrPath}` : `${this.baseUrl}/repos/${encodeURIComponent(String(idOrPath).split("/")[0])}/${encodeURIComponent(String(idOrPath).split("/")[1] ?? "")}`;
34
- const project = await this.requestJson(url);
35
- return {
36
- provider: this.provider,
37
- id: String(project.id),
38
- fullName: project.full_name,
39
- name: project.name,
40
- defaultBranch: project.default_branch,
41
- webUrl: project.html_url,
42
- private: project.private,
43
- description: project.description
44
- };
45
- }
46
- if (this.provider === "gitlab") {
47
- const projectId = this.encodeGitLabProjectId(idOrPath);
48
- const project = await this.requestJson(`${this.baseUrl}/projects/${projectId}`);
49
- return {
50
- provider: this.provider,
51
- id: String(project.id),
52
- fullName: project.path_with_namespace,
53
- name: project.name,
54
- defaultBranch: project.default_branch,
55
- webUrl: project.web_url,
56
- private: project.visibility ? project.visibility !== "public" : void 0,
57
- description: project.description
58
- };
59
- }
60
- const project = await this.fetchGiteaProject(idOrPath);
70
+ headers() {
61
71
  return {
62
- provider: this.provider,
63
- id: String(project.id),
64
- fullName: project.full_name,
65
- name: project.name,
66
- defaultBranch: project.default_branch,
67
- webUrl: project.html_url,
68
- private: project.private,
69
- description: project.description
72
+ Authorization: `Bearer ${this.token}`,
73
+ Accept: "application/vnd.github.v3+json"
70
74
  };
71
75
  }
72
- async getCommitSummary(repo, sha) {
73
- if (this.provider === "github") {
74
- const ownerRepo = await this.resolveOwnerRepo(repo);
75
- const commit = await this.requestJson(`${this.baseUrl}/repos/${encodeURIComponent(ownerRepo.owner)}/${encodeURIComponent(ownerRepo.repo)}/commits/${encodeURIComponent(sha)}`);
76
- return {
77
- provider: this.provider,
78
- sha: commit.sha,
79
- shortSha: shortSha(commit.sha),
80
- title: commit.commit?.message?.split("\n")[0] ?? "",
81
- message: commit.commit?.message ?? "",
82
- authorName: commit.commit?.author?.name,
83
- authorEmail: commit.commit?.author?.email,
84
- authoredAt: commit.commit?.author?.date,
85
- webUrl: commit.html_url
86
- };
87
- }
88
- if (this.provider === "gitlab") {
89
- const pid = this.encodeGitLabProjectId(repo);
90
- const commit = await this.requestJson(`${this.baseUrl}/projects/${pid}/repository/commits/${encodeURIComponent(sha)}`);
91
- return {
92
- provider: this.provider,
93
- sha: commit.id,
94
- shortSha: shortSha(commit.short_id ?? commit.id),
95
- title: commit.title ?? commit.message?.split("\n")[0] ?? "",
96
- message: commit.message ?? "",
97
- authorName: commit.author_name,
98
- authorEmail: commit.author_email,
99
- authoredAt: commit.authored_date,
100
- webUrl: commit.web_url
101
- };
102
- }
103
- const ownerRepo = await this.resolveOwnerRepo(repo);
104
- const commit = await this.requestJson(`${this.baseUrl}/repos/${encodeURIComponent(ownerRepo.owner)}/${encodeURIComponent(ownerRepo.repo)}/commits/${encodeURIComponent(sha)}`);
76
+ /**
77
+ * 支持 repoID:数字 ID,或 owner/repo 形式
78
+ */
79
+ async getRepoInfo(repoID) {
80
+ const raw = repoID.trim();
81
+ let url;
82
+ if (raw.includes("/")) {
83
+ const [owner, repo] = raw.split("/");
84
+ if (!owner || !repo) throw new Error("GitHub owner/repo 格式无效");
85
+ url = `${this.base}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
86
+ } else url = `${this.base}/repositories/${encodeURIComponent(raw)}`;
87
+ const { data } = await get(url, { headers: this.headers() });
88
+ if (!data?.full_name) throw new Error("GitHub 未返回 full_name");
105
89
  return {
106
- provider: this.provider,
107
- sha: commit.sha,
108
- shortSha: shortSha(commit.sha),
109
- title: commit.commit?.message?.split("\n")[0] ?? "",
110
- message: commit.commit?.message ?? "",
111
- authorName: commit.commit?.author?.name,
112
- authorEmail: commit.commit?.author?.email,
113
- authoredAt: commit.commit?.author?.date,
114
- webUrl: commit.html_url
90
+ id: String(data.id),
91
+ pathWithNamespace: data.full_name,
92
+ description: data.description ?? ""
115
93
  };
116
94
  }
117
- async compareCommits(repo, fromSha, toSha) {
118
- if (this.provider === "github") {
119
- const ownerRepo = await this.resolveOwnerRepo(repo);
120
- const compare = await this.requestJson(`${this.baseUrl}/repos/${encodeURIComponent(ownerRepo.owner)}/${encodeURIComponent(ownerRepo.repo)}/compare/${encodeURIComponent(fromSha)}...${encodeURIComponent(toSha)}`);
121
- return {
122
- provider: this.provider,
123
- fromSha,
124
- toSha,
125
- status: compare.status,
126
- aheadBy: compare.ahead_by,
127
- behindBy: compare.behind_by,
128
- totalCommits: compare.total_commits ?? compare.commits?.length ?? 0
129
- };
130
- }
131
- if (this.provider === "gitlab") {
132
- const pid = this.encodeGitLabProjectId(repo);
133
- const compare = await this.requestJson(`${this.baseUrl}/projects/${pid}/repository/compare?from=${encodeURIComponent(fromSha)}&to=${encodeURIComponent(toSha)}`);
134
- return {
135
- provider: this.provider,
136
- fromSha,
137
- toSha,
138
- status: compare.compare_same_ref ? "identical" : "different",
139
- totalCommits: compare.commits?.length ?? 0
140
- };
141
- }
142
- const ownerRepo = await this.resolveOwnerRepo(repo);
143
- const compare = await this.requestJson(`${this.baseUrl}/repos/${encodeURIComponent(ownerRepo.owner)}/${encodeURIComponent(ownerRepo.repo)}/compare/${encodeURIComponent(fromSha)}...${encodeURIComponent(toSha)}`);
144
- return {
145
- provider: this.provider,
146
- fromSha,
147
- toSha,
148
- status: compare.status,
149
- aheadBy: compare.ahead_by,
150
- behindBy: compare.behind_by,
151
- totalCommits: compare.total_commits ?? compare.commits?.length ?? 0
152
- };
95
+ async getCompare(_repoID, _base, _head) {
96
+ throw new Error("GitHubAdapter.getCompare 尚未实现");
153
97
  }
154
- async getFileContent(repo, filePath, ref) {
155
- if (this.provider === "github") {
156
- const ownerRepo = await this.resolveOwnerRepo(repo);
157
- const payload = await this.requestJson(`${this.baseUrl}/repos/${encodeURIComponent(ownerRepo.owner)}/${encodeURIComponent(ownerRepo.repo)}/contents/${this.encodePath(filePath)}?ref=${encodeURIComponent(ref)}`);
158
- return {
159
- provider: this.provider,
160
- path: payload.path ?? filePath,
161
- ref,
162
- sha: payload.sha,
163
- size: payload.size,
164
- content: decodeBase64ToUtf8(payload.content ?? "")
165
- };
98
+ };
99
+ //#endregion
100
+ //#region src/diff-line.ts
101
+ /** 参与逐行 diff 的源码扩展名(不含点);匹配时按后缀长度降序避免 `.tsx` 被当成 `.ts`。 */
102
+ const DEFAULT_LINE_DIFF_EXTENSIONS = [
103
+ "tsx",
104
+ "ts",
105
+ "jsx",
106
+ "js"
107
+ ];
108
+ function textExceedsLineCount(text, maxLines) {
109
+ if (maxLines < 1) return true;
110
+ if (text === "") return false;
111
+ let n = 1;
112
+ for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) {
113
+ n++;
114
+ if (n > maxLines) return true;
115
+ }
116
+ return false;
117
+ }
118
+ function textsExceedDiffLineBudget(oldText, newText, maxLines) {
119
+ return textExceedsLineCount(oldText, maxLines) || textExceedsLineCount(newText, maxLines);
120
+ }
121
+ function pathMatchesLineDiffExtensions(filePath, extensions = DEFAULT_LINE_DIFF_EXTENSIONS) {
122
+ const basename = filePath.split("/").pop() ?? "";
123
+ if (!basename.includes(".")) return false;
124
+ const sorted = [...extensions].sort((a, b) => b.length - a.length);
125
+ const lower = basename.toLowerCase();
126
+ return sorted.some((ext) => lower.endsWith(`.${ext.toLowerCase()}`));
127
+ }
128
+ /**
129
+ * 给定同一文件在「旧版 / 新版」两段全文下的行号桶(算法与原先的 diffLines 口径一致)。
130
+ */
131
+ function lineBucketsFromTexts(oldText, newText) {
132
+ const changes = diffLines(oldText ?? "", newText ?? "");
133
+ const additions = [];
134
+ const deletions = [];
135
+ const lineRangeDescending = (endLine, len) => Array.from({ length: len }, (_, i) => endLine - i).reverse();
136
+ const prefixSumInclusive = (lengths, index) => lengths.slice(0, index + 1).reduce((s, v) => s + v, 0);
137
+ const unchangedOnNewSide = changes.map((c) => c.added || c.removed ? 0 : c.count ?? 0);
138
+ const unchangedOnOldSide = changes.map((c) => c.added || c.removed ? 0 : c.count ?? 0);
139
+ changes.forEach((change, idx) => {
140
+ const count = change.count ?? 0;
141
+ if (change.added) {
142
+ const anchor = prefixSumInclusive(unchangedOnNewSide, idx) + 1;
143
+ additions.push(...lineRangeDescending(anchor + count - 1, count));
144
+ } else if (change.removed) {
145
+ const anchor = prefixSumInclusive(unchangedOnOldSide, idx) + 1;
146
+ deletions.push(...lineRangeDescending(anchor + count - 1, count));
166
147
  }
167
- if (this.provider === "gitlab") {
168
- const pid = this.encodeGitLabProjectId(repo);
169
- const payload = await this.requestJson(`${this.baseUrl}/projects/${pid}/repository/files/${this.encodePath(filePath)}?ref=${encodeURIComponent(ref)}`);
148
+ });
149
+ return {
150
+ additions,
151
+ deletions
152
+ };
153
+ }
154
+ function comparedRefsFromGitlabDiff(diff) {
155
+ const oldPath = diff.old_path ?? "";
156
+ const newPath = diff.new_path ?? "";
157
+ const keyPath = newPath || oldPath || "";
158
+ if (!keyPath) return null;
159
+ return {
160
+ keyPath,
161
+ pathAtBase: oldPath || newPath,
162
+ pathAtHead: newPath || oldPath
163
+ };
164
+ }
165
+ /**
166
+ * 调用方可自行按扩展名等规则筛掉不需要的路径;本函数对已传入的列表全部拉全文并分段并发。
167
+ * 任一版本全文行数超过 `LINE_DIFF_MAX_LINES_PER_FILE_SIDE` 时该行号结果退化为空数组(仍会请求 raw)。
168
+ */
169
+ async function computeLineBucketsByRef(files, baseRef, headRef, fetchText, concurrent) {
170
+ const chunk = concurrent != null ? Math.max(1, concurrent) : Math.min(files.length || 1, 64);
171
+ const out = /* @__PURE__ */ new Map();
172
+ for (let offset = 0; offset < files.length; offset += chunk) {
173
+ const slice = files.slice(offset, offset + chunk);
174
+ const parts = await Promise.all(slice.map(async ({ keyPath, pathAtBase, pathAtHead }) => {
175
+ const [oldText, newText] = await Promise.all([fetchText(pathAtBase, baseRef).catch(() => ""), fetchText(pathAtHead, headRef).catch(() => "")]);
170
176
  return {
171
- provider: this.provider,
172
- path: payload.file_path ?? filePath,
173
- ref,
174
- sha: payload.blob_id,
175
- size: payload.size,
176
- content: decodeBase64ToUtf8(payload.content ?? "")
177
+ keyPath,
178
+ buckets: textsExceedDiffLineBudget(oldText, newText, 1e4) ? {
179
+ additions: [],
180
+ deletions: []
181
+ } : lineBucketsFromTexts(oldText, newText)
177
182
  };
178
- }
179
- const ownerRepo = await this.resolveOwnerRepo(repo);
180
- const payload = await this.requestJson(`${this.baseUrl}/repos/${encodeURIComponent(ownerRepo.owner)}/${encodeURIComponent(ownerRepo.repo)}/contents/${this.encodePath(filePath)}?ref=${encodeURIComponent(ref)}`);
181
- return {
182
- provider: this.provider,
183
- path: payload.path ?? filePath,
184
- ref,
185
- sha: payload.sha,
186
- size: payload.size,
187
- content: decodeBase64ToUtf8(payload.content ?? "")
188
- };
183
+ }));
184
+ for (const { keyPath, buckets } of parts) out.set(keyPath, buckets);
189
185
  }
190
- async downloadArchive(repo, ref) {
191
- let url;
192
- if (this.provider === "gitlab") {
193
- const pid = this.encodeGitLabProjectId(repo);
194
- url = `${this.baseUrl}/projects/${pid}/repository/archive.zip?sha=${encodeURIComponent(ref)}`;
195
- } else {
196
- const ownerRepo = await this.resolveOwnerRepo(repo);
197
- if (this.provider === "github") url = `${this.baseUrl}/repos/${encodeURIComponent(ownerRepo.owner)}/${encodeURIComponent(ownerRepo.repo)}/zipball/${encodeURIComponent(ref)}`;
198
- else url = `${this.baseUrl}/repos/${encodeURIComponent(ownerRepo.owner)}/${encodeURIComponent(ownerRepo.repo)}/archive/${encodeURIComponent(ref)}.zip`;
199
- }
200
- const response = await this.fetchWithAuth(url, { headers: { Accept: "application/zip" } });
201
- if (!response.ok) {
202
- const body = await response.text();
203
- throw new Error(`[${this.provider}] request failed (${response.status}): ${body}`);
204
- }
205
- const data = new Uint8Array(await response.arrayBuffer());
206
- return {
207
- provider: this.provider,
208
- ref,
209
- contentType: response.headers.get("content-type"),
210
- fileName: this.parseFileNameFromHeaders(response.headers),
211
- size: data.byteLength,
212
- data
213
- };
186
+ return out;
187
+ }
188
+ //#endregion
189
+ //#region src/gitlab.ts
190
+ var GitlabAdapter = class {
191
+ base;
192
+ token;
193
+ constructor(config) {
194
+ this.base = config.base.replace(/\/$/, "");
195
+ this.token = config.token;
214
196
  }
215
- async fetchGiteaProject(idOrPath) {
216
- if (!isLikelyNumericRepoId(idOrPath)) {
217
- const ownerRepo = this.parseOwnerRepo(String(idOrPath));
218
- return this.requestJson(`${this.baseUrl}/repos/${encodeURIComponent(ownerRepo.owner)}/${encodeURIComponent(ownerRepo.repo)}`);
219
- }
220
- try {
221
- return await this.requestJson(`${this.baseUrl}/repositories/${idOrPath}`);
222
- } catch {
223
- throw new Error("[gitea] numeric repository id not found; try owner/repo form");
224
- }
197
+ headers() {
198
+ return { "PRIVATE-TOKEN": this.token };
225
199
  }
226
- async resolveOwnerRepo(repo) {
227
- if (!isLikelyNumericRepoId(repo)) return this.parseOwnerRepo(String(repo));
228
- if (this.provider === "gitlab") {
229
- const project = await this.getRepositorySummary(repo);
230
- return this.parseOwnerRepo(project.fullName);
231
- }
232
- const project = await this.getRepositorySummary(repo);
233
- return this.parseOwnerRepo(project.fullName);
200
+ fileRawUrl(repoID, filePath, ref) {
201
+ const encodedPath = encodeURIComponent(filePath);
202
+ return `${this.base}/api/v4/projects/${encodeURIComponent(repoID)}/repository/files/${encodedPath}/raw?ref=${encodeURIComponent(ref)}`;
203
+ }
204
+ async getRawFileText(repoID, filePath, ref) {
205
+ const { data } = await get(this.fileRawUrl(repoID, filePath, ref), { headers: this.headers() });
206
+ return typeof data === "string" ? data : "";
234
207
  }
235
- parseOwnerRepo(raw) {
236
- const parts = raw.split("/");
237
- if (parts.length < 2 || !parts[0] || !parts[1]) throw new Error(`invalid repository identifier "${raw}", expected "owner/repo" or numeric id`);
208
+ async getRepoInfo(repoID) {
209
+ const { data } = await get(`${this.base}/api/v4/projects/${encodeURIComponent(repoID)}`, { headers: this.headers() });
238
210
  return {
239
- owner: parts[0],
240
- repo: parts.slice(1).join("/")
211
+ id: String(data.id),
212
+ pathWithNamespace: data.path_with_namespace,
213
+ description: data.description ?? ""
241
214
  };
242
215
  }
243
- encodeGitLabProjectId(idOrPath) {
244
- if (isLikelyNumericRepoId(idOrPath)) return String(idOrPath);
245
- return this.encodePath(String(idOrPath));
246
- }
247
- encodePath(path) {
248
- return path.split("/").filter(Boolean).map((piece) => encodeURIComponent(piece)).join("%2F");
249
- }
250
- parseFileNameFromHeaders(headers) {
251
- const disposition = headers.get("content-disposition");
252
- if (!disposition) return;
253
- const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
254
- if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]);
255
- return disposition.match(/filename="([^"]+)"/i)?.[1];
256
- }
257
- async requestJson(url) {
258
- const response = await this.fetchWithAuth(url);
259
- if (!response.ok) {
260
- const body = await response.text();
261
- throw new Error(`[${this.provider}] request failed (${response.status}): ${body}`);
262
- }
263
- return response.json();
264
- }
265
- async fetchWithAuth(url, init) {
266
- const headers = new Headers(init?.headers);
267
- for (const [key, value] of Object.entries(this.getAuthHeaders())) headers.set(key, value);
268
- for (const [key, value] of Object.entries(this.extraHeaders)) headers.set(key, value);
269
- return fetch(url, {
270
- ...init,
271
- headers
272
- });
273
- }
274
- getAuthHeaders() {
275
- const headers = {};
276
- if (this.provider === "github") headers.Accept = "application/vnd.github+json";
277
- else headers.Accept = "application/json";
278
- if (!this.token) return headers;
279
- if (this.provider === "gitlab") headers["PRIVATE-TOKEN"] = this.token;
280
- else if (this.provider === "gitea") headers.Authorization = `token ${this.token}`;
281
- else headers.Authorization = `Bearer ${this.token}`;
282
- return headers;
216
+ 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() });
218
+ const diffRows = data.diffs ?? [];
219
+ 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));
230
+ return {
231
+ commitList: (data.commits ?? []).map((c) => c.id),
232
+ changedFiles
233
+ };
283
234
  }
284
235
  };
285
- function createGitProviderClient(options) {
286
- return new GitProviderClient(options);
236
+ //#endregion
237
+ //#region src/index.ts
238
+ /**
239
+ * 根据配置里的 `type`(github / gitlab …)创建对应适配器,调用方只依赖返回的 `ScmAdapter`。
240
+ */
241
+ function createScmAdapter(config) {
242
+ switch (config.type) {
243
+ case "github": return new GithubAdapter(config);
244
+ case "gitlab": return new GitlabAdapter(config);
245
+ }
287
246
  }
288
247
  //#endregion
289
- export { GitProviderClient, createGitProviderClient };
248
+ export { createScmAdapter };
package/package.json CHANGED
@@ -1,24 +1,22 @@
1
1
  {
2
2
  "name": "@canyonjs/git-provider",
3
3
  "type": "module",
4
- "version": "0.0.1",
5
- "description": "Unified Git provider client for GitHub, GitLab, and Gitea.",
4
+ "version": "0.0.2",
5
+ "description": "GitLab provider client with adapter architecture.",
6
6
  "keywords": [
7
7
  "git",
8
- "github",
9
8
  "gitlab",
10
- "gitea",
11
9
  "api"
12
10
  ],
13
11
  "author": "canyonjs",
14
12
  "license": "MIT",
15
- "homepage": "https://git.dev.sh.ctripcorp.com/canyon-project/git-provider#readme",
13
+ "homepage": "https://github.com/canyon-project/git-provider#readme",
16
14
  "repository": {
17
15
  "type": "git",
18
- "url": "git+https://git.dev.sh.ctripcorp.com/canyon-project/git-provider.git"
16
+ "url": "git+https://github.com/canyon-project/git-provider.git"
19
17
  },
20
18
  "bugs": {
21
- "url": "https://git.dev.sh.ctripcorp.com/canyon-project/git-provider/issues"
19
+ "url": "https://github.com/canyon-project/git-provider/issues"
22
20
  },
23
21
  "types": "./dist/index.d.ts",
24
22
  "exports": {
@@ -31,17 +29,19 @@
31
29
  "sideEffects": false,
32
30
  "devDependencies": {
33
31
  "@types/node": "^25.5.0",
34
- "@typescript/native-preview": "7.0.0-dev.20260328.1",
35
- "bumpp": "^11.0.1",
32
+ "@typescript/native-preview": "7.0.0-dev.20260505.1",
36
33
  "tsdown": "^0.21.7",
37
- "typescript": "^6.0.2",
34
+ "tsx": "^4.21.0",
38
35
  "vitest": "^4.1.2"
39
36
  },
37
+ "dependencies": {
38
+ "diff": "^9.0.0"
39
+ },
40
40
  "scripts": {
41
41
  "build": "tsdown",
42
42
  "dev": "tsdown --watch",
43
43
  "test": "vitest run",
44
- "typecheck": "tsc --noEmit",
45
- "release": "bumpp"
44
+ "typecheck": "tsgo --noEmit",
45
+ "debug": "tsx src/debug.ts"
46
46
  }
47
47
  }