@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 +60 -28
- package/dist/index.d.mts +40 -71
- package/dist/index.mjs +221 -262
- package/package.json +12 -12
package/README.md
CHANGED
|
@@ -1,49 +1,81 @@
|
|
|
1
|
-
# git-provider
|
|
1
|
+
# @canyonjs/git-provider
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
面向 **Git 托管平台(GitLab / GitHub)** 的轻量 TypeScript 客户端库。通过对外的 **`ScmAdapter`** 接口屏蔽平台差异:业务代码只依赖「仓库信息与两个引用之间的 Compare 结果」,由工厂方法按配置选择具体适配器。
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 能力与现状
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
| 能力 | GitLab | GitHub |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| `getRepoInfo` | 支持(Project API) | 支持(`owner/repo` 或数字仓库 id) |
|
|
10
|
+
| `getCompare` | **已实现**:比较 API + 逐文件 Raw 内容与行级差异 | **未实现**(调用会抛错) |
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
19
|
+
## 依赖与请求
|
|
20
|
+
|
|
21
|
+
- 运行时依赖:**[`diff`](https://www.npmjs.com/package/diff)**(行级比对)。
|
|
22
|
+
- HTTP 使用全局 **`fetch`**(见 `src/request.ts`),按需配置超时等。
|
|
23
|
+
|
|
24
|
+
## 安装
|
|
14
25
|
|
|
15
26
|
```bash
|
|
16
|
-
|
|
27
|
+
pnpm add @canyonjs/git-provider
|
|
17
28
|
```
|
|
18
29
|
|
|
19
|
-
|
|
30
|
+
若从源码开发与联调:
|
|
20
31
|
|
|
21
32
|
```bash
|
|
22
|
-
|
|
33
|
+
pnpm install
|
|
34
|
+
pnpm run build
|
|
23
35
|
```
|
|
24
36
|
|
|
25
|
-
##
|
|
37
|
+
## 用法示例
|
|
26
38
|
|
|
27
39
|
```ts
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
})
|
|
40
|
+
import {
|
|
41
|
+
createScmAdapter,
|
|
42
|
+
type Compare,
|
|
43
|
+
type ScmConfig,
|
|
44
|
+
} from "@canyonjs/git-provider";
|
|
34
45
|
|
|
35
|
-
|
|
36
|
-
|
|
46
|
+
const config: ScmConfig = {
|
|
47
|
+
type: "gitlab",
|
|
48
|
+
base: "https://gitlab.example.com",
|
|
49
|
+
token: process.env.GITLAB_TOKEN!,
|
|
50
|
+
};
|
|
37
51
|
|
|
38
|
-
|
|
39
|
-
const commit = await client.getCommitSummary('canyon-project/canyon', 'abc123')
|
|
52
|
+
const adapter = createScmAdapter(config);
|
|
40
53
|
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
56
|
+
const result: Compare = await adapter.getCompare(
|
|
57
|
+
repo.id,
|
|
58
|
+
"main~10", // base ref
|
|
59
|
+
"main", // head ref
|
|
60
|
+
);
|
|
46
61
|
|
|
47
|
-
|
|
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/
|
|
2
|
-
type
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
|
40
|
-
provider: GitProvider;
|
|
20
|
+
interface CompareDiffItem {
|
|
41
21
|
path: string;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
size?: number;
|
|
45
|
-
content: string;
|
|
22
|
+
additions: number[];
|
|
23
|
+
deletions: number[];
|
|
46
24
|
}
|
|
47
|
-
interface
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
12
|
-
|
|
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
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
118
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
}
|
|
232
|
-
|
|
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
|
-
|
|
236
|
-
const
|
|
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
|
-
|
|
240
|
-
|
|
211
|
+
id: String(data.id),
|
|
212
|
+
pathWithNamespace: data.path_with_namespace,
|
|
213
|
+
description: data.description ?? ""
|
|
241
214
|
};
|
|
242
215
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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 {
|
|
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.
|
|
5
|
-
"description": "
|
|
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://
|
|
13
|
+
"homepage": "https://github.com/canyon-project/git-provider#readme",
|
|
16
14
|
"repository": {
|
|
17
15
|
"type": "git",
|
|
18
|
-
"url": "git+https://
|
|
16
|
+
"url": "git+https://github.com/canyon-project/git-provider.git"
|
|
19
17
|
},
|
|
20
18
|
"bugs": {
|
|
21
|
-
"url": "https://
|
|
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.
|
|
35
|
-
"bumpp": "^11.0.1",
|
|
32
|
+
"@typescript/native-preview": "7.0.0-dev.20260505.1",
|
|
36
33
|
"tsdown": "^0.21.7",
|
|
37
|
-
"
|
|
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": "
|
|
45
|
-
"
|
|
44
|
+
"typecheck": "tsgo --noEmit",
|
|
45
|
+
"debug": "tsx src/debug.ts"
|
|
46
46
|
}
|
|
47
47
|
}
|