Skip to content

Commit 0692731

Browse files
committed
feat(platforms): implement Bitbucket and GitLab API clients
Add factory functions to create commit fetchers and repo listers for supported platforms. Implement BitbucketClient with paginated repository listing and commit fetching including diffstat. Implement GitLabClient with paginated project listing and commit fetching with diff support. Update existing tests to verify platform-specific normalization.
1 parent 1b8c152 commit 0692731

4 files changed

Lines changed: 317 additions & 92 deletions

File tree

packages/core/src/__tests__/platform-clients.test.ts

Lines changed: 106 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -3,94 +3,68 @@ import { BitbucketClient } from "../platforms/bitbucket";
33
import { GitHubClient } from "../platforms/github";
44
import { GitLabClient } from "../platforms/gitlab";
55

6-
describe("PlatformClient surface", () => {
7-
it("should expose all platform IDs", () => {
8-
const hub = new GitHubClient("token");
9-
const lab = new GitLabClient("token");
10-
const bit = new BitbucketClient("token");
11-
12-
expect(hub.platform).toBe("github");
13-
expect(lab.platform).toBe("gitlab");
14-
expect(bit.platform).toBe("bitbucket");
15-
});
16-
17-
it("should normalize GitHub repos and commits", async () => {
18-
const responses: Array<{
19-
urlMatcher: (url: string) => boolean;
20-
payload: unknown;
21-
}> = [
22-
{
23-
urlMatcher: (url) => url.includes("/user/repos"),
24-
payload: [
25-
{
26-
id: 1,
27-
name: "repo",
28-
full_name: "user/repo",
29-
private: false,
30-
default_branch: "main",
31-
owner: { login: "user" },
32-
html_url: "https://github.com/user/repo",
33-
},
34-
],
35-
},
36-
{
37-
urlMatcher: (url) => /\/repos\/.+\/commits(\?|$)/.test(url),
38-
payload: [
39-
{
6+
describe("Platform client normalization", () => {
7+
it("GitHub client yields normalized commits + repos", async () => {
8+
const fetcher = vi.fn(async (url: string) => {
9+
if (url.includes("/user/repos")) {
10+
return {
11+
ok: true,
12+
status: 200,
13+
json: async () => [
14+
{
15+
id: 1,
16+
name: "repo",
17+
full_name: "user/repo",
18+
private: false,
19+
default_branch: "main",
20+
owner: { login: "user" },
21+
html_url: "https://github.com/user/repo",
22+
},
23+
],
24+
text: async () => "[]",
25+
};
26+
}
27+
if (url.includes("/commits/abc")) {
28+
return {
29+
ok: true,
30+
status: 200,
31+
json: async () => ({
4032
sha: "abc",
41-
parents: [{ sha: "parent" }],
33+
parents: [{ sha: "p" }],
4234
commit: {
43-
message: "feat: add",
44-
author: { name: "Author", email: "author@example.com", date: "2025-01-01T00:00:00Z" },
45-
committer: { email: "committer@example.com", date: "2025-01-01T01:00:00Z" },
35+
message: "msg",
36+
author: { name: "Author", email: "a@a.com", date: "2025-01-01T00:00:00Z" },
37+
committer: { email: "c@c.com", date: "2025-01-01T01:00:00Z" },
4638
},
47-
},
48-
],
49-
},
50-
{
51-
urlMatcher: (url) => /\/repos\/.+\/commits\/abc$/.test(url),
52-
payload: {
53-
sha: "abc",
54-
parents: [{ sha: "parent" }],
55-
commit: {
56-
message: "feat: add",
57-
author: { name: "Author", email: "author@example.com", date: "2025-01-01T00:00:00Z" },
58-
committer: { email: "committer@example.com", date: "2025-01-01T01:00:00Z" },
59-
},
60-
files: [{ filename: "src/index.ts" }],
61-
html_url: "https://github.com/user/repo/commit/abc",
62-
stats: { additions: 10, deletions: 5, total: 15 },
63-
},
64-
},
65-
];
66-
67-
const fetcher = vi.fn(async (url: string) => {
68-
const response = responses.find((resp) => resp.urlMatcher(url));
69-
if (!response) throw new Error(`Unexpected URL ${url}`);
39+
files: [{ filename: "x.ts" }],
40+
html_url: "https://github.com/user/repo/commit/abc",
41+
stats: { additions: 1, deletions: 0, total: 1 },
42+
}),
43+
text: async () => "",
44+
};
45+
}
7046
return {
7147
ok: true,
7248
status: 200,
73-
json: async () => response.payload,
74-
text: async () => JSON.stringify(response.payload),
49+
json: async () => [
50+
{
51+
sha: "abc",
52+
parents: [{ sha: "p" }],
53+
commit: {
54+
message: "msg",
55+
author: { name: "Author", email: "a@a.com", date: "2025-01-01T00:00:00Z" },
56+
committer: { email: "c@c.com", date: "2025-01-01T01:00:00Z" },
57+
},
58+
},
59+
],
60+
text: async () => "",
7561
};
7662
});
7763

78-
const client = new GitHubClient("token", fetcher as unknown as typeof globalThis.fetch);
79-
80-
const repoIterator = client.listRepos();
81-
const firstRepo = await repoIterator.next();
82-
expect(firstRepo.value).toEqual({
83-
id: "1",
84-
name: "repo",
85-
fullName: "user/repo",
86-
owner: "user",
87-
isPrivate: false,
88-
defaultBranch: "main",
89-
platform: "github",
90-
platformUrl: "https://github.com/user/repo",
91-
});
92-
93-
const commits: unknown[] = [];
64+
const client = new GitHubClient("token", fetcher as typeof fetch);
65+
const repo = await client.listRepos().next();
66+
expect(repo.value?.platform).toBe("github");
67+
const commits = [];
9468
for await (const commit of client.fetchCommits({
9569
repoFullName: "user/repo",
9670
owner: "user",
@@ -99,9 +73,59 @@ describe("PlatformClient surface", () => {
9973
})) {
10074
commits.push(commit);
10175
}
76+
expect(commits[0].platform).toBe("github");
77+
});
78+
79+
it("GitLab client returns normalized repos", async () => {
80+
const fetcher = vi.fn(async (url: string) => ({
81+
ok: true,
82+
status: 200,
83+
json: async () => {
84+
if (url.includes("/projects")) {
85+
return [
86+
{
87+
id: 2,
88+
name: "app",
89+
path_with_namespace: "user/app",
90+
visibility: "private",
91+
default_branch: "main",
92+
web_url: "https://gitlab.com/user/app",
93+
namespace: { name: "user" },
94+
},
95+
];
96+
}
97+
return [];
98+
},
99+
text: async () => "[]",
100+
}));
101+
102+
const client = new GitLabClient("token", fetcher as typeof fetch);
103+
const repo = await client.listRepos().next();
104+
expect(repo.value?.platform).toBe("gitlab");
105+
});
106+
107+
it("Bitbucket client reports platform metadata", async () => {
108+
const fetcher = vi.fn(async (url: string) => ({
109+
ok: true,
110+
status: 200,
111+
json: async () => ({
112+
values: [
113+
{
114+
uuid: "{1}",
115+
name: "project",
116+
full_name: "user/project",
117+
owner: { username: "user" },
118+
is_private: true,
119+
links: { html: { href: "https://bitbucket.org/user/project" } },
120+
},
121+
],
122+
next: undefined,
123+
}),
124+
text: async () => "[]",
125+
}));
102126

103-
expect(commits).toHaveLength(1);
104-
expect((commits[0] as any).platform).toBe("github");
105-
expect((commits[0] as any).platformUrl).toBe("https://github.com/user/repo/commit/abc");
127+
const client = new BitbucketClient("token", fetcher as typeof fetch);
128+
const repo = await client.listRepos().next();
129+
expect(repo.value?.platform).toBe("bitbucket");
106130
});
107131
});

packages/core/src/platforms/bitbucket.ts

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,93 @@ import type {
66
RepoLister,
77
} from "./types";
88

9+
type BitbucketRepo = {
10+
uuid: string;
11+
name: string;
12+
full_name: string;
13+
owner: { username: string };
14+
is_private: boolean;
15+
mainbranch?: { name: string };
16+
links: { html: { href: string } };
17+
};
18+
19+
type BitbucketCommit = {
20+
hash: string;
21+
message: string;
22+
date: string;
23+
author: {
24+
raw: string;
25+
user?: { display_name: string };
26+
};
27+
parents: Array<{ hash: string }>;
28+
};
29+
30+
type BitbucketDiffStat = {
31+
values: Array<{ new?: { path: string } }>;
32+
};
33+
934
export class BitbucketClient implements CommitFetcher, RepoLister {
1035
public readonly platform = "bitbucket" as const;
36+
private readonly baseUrl = "https://api.bitbucket.org/2.0";
1137

12-
constructor(private readonly accessToken: string) {}
38+
constructor(private readonly accessToken: string, private readonly fetcher: typeof fetch = fetch) {}
1339

14-
async *fetchCommits(_options: FetchCommitsOptions): AsyncGenerator<NormalizedCommit> {
15-
// TODO: implement Bitbucket diffstat calls to gather file paths.
16-
return;
40+
private async bitbucketFetch<T>(url: string): Promise<T> {
41+
const res = await this.fetcher(url, {
42+
headers: {
43+
Authorization: `Bearer ${this.accessToken}`,
44+
},
45+
});
46+
if (!res.ok) {
47+
const body = await res.text();
48+
throw new Error(`Bitbucket API error (${res.status}): ${body}`);
49+
}
50+
return (await res.json()) as T;
1751
}
1852

1953
async *listRepos(): AsyncGenerator<PlatformRepo> {
20-
return;
54+
let nextUrl = `${this.baseUrl}/repositories?role=member&pagelen=100`;
55+
while (nextUrl) {
56+
const page = await this.bitbucketFetch<{ next?: string; values: BitbucketRepo[] }>(nextUrl);
57+
for (const repo of page.values) {
58+
yield {
59+
id: repo.uuid,
60+
name: repo.name,
61+
fullName: repo.full_name,
62+
owner: repo.owner.username,
63+
isPrivate: repo.is_private,
64+
defaultBranch: repo.mainbranch?.name ?? "main",
65+
platform: this.platform,
66+
platformUrl: repo.links.html.href,
67+
};
68+
}
69+
nextUrl = page.next ?? "";
70+
}
71+
}
72+
73+
async *fetchCommits(options: FetchCommitsOptions): AsyncGenerator<NormalizedCommit> {
74+
let url = `${this.baseUrl}/repositories/${options.repoFullName}/commits`;
75+
do {
76+
const page = await this.bitbucketFetch<{ next?: string; values: BitbucketCommit[] }>(url);
77+
for (const commit of page.values) {
78+
const diff = await this.bitbucketFetch<BitbucketDiffStat>(
79+
`${this.baseUrl}/repositories/${options.repoFullName}/diffstat/${commit.hash}`
80+
);
81+
82+
yield {
83+
sha: commit.hash,
84+
message: commit.message,
85+
authoredAt: new Date(commit.date),
86+
committedAt: new Date(commit.date),
87+
authorName: commit.author.user?.display_name ?? commit.author.raw,
88+
authorEmail: commit.author.raw.split("<")[1]?.replace(">", "") ?? "",
89+
filePaths: diff.values.map((file) => file.new?.path ?? "").filter(Boolean),
90+
parents: commit.parents.map((p) => p.hash),
91+
platform: this.platform,
92+
platformUrl: `${options.repoFullName}/commits/${commit.hash}`,
93+
};
94+
}
95+
url = page.next ?? "";
96+
} while (url);
2197
}
2298
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { BitbucketClient } from "./bitbucket";
2+
import { GitHubClient } from "./github";
3+
import { GitLabClient } from "./gitlab";
4+
import type { CommitFetcher, PlatformType, RepoLister } from "./types";
5+
6+
export function createCommitFetcher(platform: PlatformType, token: string): CommitFetcher {
7+
switch (platform) {
8+
case "github":
9+
return new GitHubClient(token);
10+
case "gitlab":
11+
return new GitLabClient(token);
12+
case "bitbucket":
13+
return new BitbucketClient(token);
14+
default:
15+
throw new Error(`Unsupported platform ${platform}`);
16+
}
17+
}
18+
19+
export function createRepoLister(platform: PlatformType, token: string): RepoLister {
20+
switch (platform) {
21+
case "github":
22+
return new GitHubClient(token);
23+
case "gitlab":
24+
return new GitLabClient(token);
25+
case "bitbucket":
26+
return new BitbucketClient(token);
27+
default:
28+
throw new Error(`Unsupported platform ${platform}`);
29+
}
30+
}

0 commit comments

Comments
 (0)