Skip to content

Commit 62796b0

Browse files
mshen6666Cheerego7
andauthored
feat(provider): add Gitee (#76)
## Summary Add a Gitee provider backed by the official Gitee API V5, with OAuth 2.0 and personal access token authentication. ## Changes - Add 3 locally executable Gitee actions for the current user, authenticated-user repositories, and repository lookup. - Add OAuth and personal access token credential validation with provider-native scopes, stable profiles, request error mapping, pagination, and encoded repository paths. - Send the shared outbound User-Agent on provider and OAuth token requests for Gitee compatibility. - Add focused runtime tests and a runnable local HTTP example for both authentication modes. ## Validation - [x] `npm run generate:catalog` (1046 providers, 10107 actions) - [x] `npm run fix-check` - [x] `npm test` (52 files, 563 tests) - [x] `npm run build` - [x] Generated Gitee catalog and lazy executor registry match all 3 executable actions --------- Co-authored-by: CheerChen <meetcheerego@gmail.com>
1 parent 0fbdfde commit 62796b0

6 files changed

Lines changed: 534 additions & 0 deletions

File tree

src/providers/gitee/actions.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import type { ActionDefinition, JsonSchema } from "../../core/types.ts";
2+
3+
import { s } from "../../core/json-schema.ts";
4+
import { defineProviderAction } from "../../core/provider-definition.ts";
5+
import { giteeProjectScopes, giteeUserInfoScopes } from "./scopes.ts";
6+
7+
const service = "gitee";
8+
9+
export type GiteeActionName = (typeof giteeActions)[number]["name"];
10+
11+
interface GiteeActionSource {
12+
name: string;
13+
description: string;
14+
requiredScopes: string[];
15+
inputSchema: JsonSchema;
16+
outputSchema: JsonSchema;
17+
}
18+
19+
const giteeUser = s.looseObject(
20+
{
21+
id: s.integer({ description: "The Gitee user ID." }),
22+
login: s.string({ description: "The Gitee username." }),
23+
name: s.nullableString("The user's display name when set."),
24+
email: s.nullableString("The user's email address when visible."),
25+
avatar_url: s.string({ description: "The user's avatar URL." }),
26+
html_url: s.string({ description: "The user's Gitee profile URL." }),
27+
bio: s.nullableString("The user's biography when set."),
28+
public_repos: s.integer({ description: "The number of public repositories." }),
29+
followers: s.integer({ description: "The user's follower count." }),
30+
following: s.integer({ description: "The number of users followed by this user." }),
31+
created_at: s.string({ description: "The account creation timestamp." }),
32+
updated_at: s.string({ description: "The account update timestamp." }),
33+
},
34+
{ description: "A Gitee user record." },
35+
);
36+
37+
const giteeRepository = s.looseObject(
38+
{
39+
id: s.integer({ description: "The Gitee repository ID." }),
40+
full_name: s.string({ description: "The repository name including its namespace." }),
41+
human_name: s.string({ description: "The human-readable repository name." }),
42+
path: s.string({ description: "The repository path." }),
43+
name: s.string({ description: "The repository name." }),
44+
description: s.nullableString("The repository description when set."),
45+
private: s.boolean({ description: "Whether the repository is private." }),
46+
public: s.boolean({ description: "Whether the repository is public." }),
47+
internal: s.boolean({ description: "Whether the repository is internally visible." }),
48+
fork: s.boolean({ description: "Whether the repository is a fork." }),
49+
html_url: s.string({ description: "The repository web URL." }),
50+
ssh_url: s.string({ description: "The repository SSH clone URL." }),
51+
default_branch: s.string({ description: "The default branch name." }),
52+
language: s.nullableString("The primary repository language when detected."),
53+
forks_count: s.integer({ description: "The number of forks." }),
54+
stargazers_count: s.integer({ description: "The number of stars." }),
55+
watchers_count: s.integer({ description: "The number of watchers." }),
56+
open_issues_count: s.integer({ description: "The number of open issues." }),
57+
created_at: s.string({ description: "The repository creation timestamp." }),
58+
updated_at: s.string({ description: "The repository update timestamp." }),
59+
pushed_at: s.nullableString("The most recent push timestamp when the repository has commits."),
60+
owner: giteeUser,
61+
},
62+
{ description: "A Gitee repository record." },
63+
);
64+
65+
function input(properties: Record<string, JsonSchema>, required: string[] = []): JsonSchema {
66+
return s.actionInput(properties, required, "Gitee action input.");
67+
}
68+
69+
const actions: GiteeActionSource[] = [
70+
{
71+
name: "get_current_user",
72+
description: "Get the current authenticated Gitee user profile.",
73+
requiredScopes: giteeUserInfoScopes,
74+
inputSchema: input({}),
75+
outputSchema: giteeUser,
76+
},
77+
{
78+
name: "list_my_repositories",
79+
description: "List repositories visible to the authenticated Gitee user.",
80+
requiredScopes: giteeProjectScopes,
81+
inputSchema: input({
82+
visibility: s.stringEnum(["private", "public", "all"], {
83+
description: "Filter repositories by visibility.",
84+
}),
85+
q: s.string({ minLength: 1, description: "Search repositories by keyword." }),
86+
sort: s.stringEnum(["created", "updated", "pushed", "full_name"], {
87+
description: "Sort repositories by a Gitee-supported field.",
88+
}),
89+
direction: s.stringEnum(["asc", "desc"], { description: "Sort direction." }),
90+
page: s.integer({ minimum: 1, description: "The page number to fetch." }),
91+
perPage: s.integer({ minimum: 1, maximum: 100, description: "The number of repositories per page." }),
92+
}),
93+
outputSchema: s.object(
94+
{
95+
repositories: s.array(giteeRepository, { description: "Repositories returned by Gitee." }),
96+
},
97+
{ required: ["repositories"], description: "A Gitee repository list response." },
98+
),
99+
},
100+
{
101+
name: "get_repository",
102+
description: "Get a Gitee repository by namespace owner and repository path.",
103+
requiredScopes: giteeProjectScopes,
104+
inputSchema: input(
105+
{
106+
owner: s.string({ minLength: 1, description: "The repository namespace path." }),
107+
repo: s.string({ minLength: 1, description: "The repository path." }),
108+
},
109+
["owner", "repo"],
110+
),
111+
outputSchema: giteeRepository,
112+
},
113+
];
114+
115+
export const giteeActions: ActionDefinition[] = actions.map((action) =>
116+
defineProviderAction(service, {
117+
...action,
118+
providerPermissions: [],
119+
}),
120+
);

src/providers/gitee/definition.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { ProviderDefinition } from "../../core/types.ts";
2+
3+
import { giteeActions } from "./actions.ts";
4+
import { giteeOAuthScopes } from "./scopes.ts";
5+
6+
const service = "gitee";
7+
8+
/** Gitee provider backed by the public Gitee API V5. */
9+
export const provider: ProviderDefinition = {
10+
service,
11+
displayName: "Gitee",
12+
categories: ["Developer Tools"],
13+
authTypes: ["oauth2", "api_key"],
14+
auth: [
15+
{
16+
type: "oauth2",
17+
authorizationUrl: "https://gitee.com/oauth/authorize",
18+
tokenUrl: "https://gitee.com/oauth/token",
19+
refreshTokenUrl: "https://gitee.com/oauth/token",
20+
scopes: giteeOAuthScopes,
21+
tokenEndpointAuthMethod: "client_secret_post",
22+
},
23+
{
24+
type: "api_key",
25+
label: "Personal access token",
26+
placeholder: "Gitee personal access token",
27+
description: "Gitee personal access token used with the public API V5.",
28+
},
29+
],
30+
homepageUrl: "https://gitee.com",
31+
actions: giteeActions,
32+
};

src/providers/gitee/executors.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { CredentialValidators, ProviderExecutors } from "../../core/types.ts";
2+
3+
import { defineBearerProviderExecutors } from "../provider-runtime.ts";
4+
import { giteeActionHandlers, parseGiteeScopes, validateGiteeCredential } from "./runtime.ts";
5+
6+
const service = "gitee";
7+
8+
export const executors: ProviderExecutors = defineBearerProviderExecutors(service, giteeActionHandlers);
9+
10+
export const credentialValidators: CredentialValidators = {
11+
apiKey(input, { fetcher, signal }) {
12+
return validateGiteeCredential(input.apiKey, fetcher, signal);
13+
},
14+
oauth2(input, { fetcher, signal }) {
15+
return validateGiteeCredential(input.accessToken, fetcher, signal, parseGiteeScopes(input.metadata.scope));
16+
},
17+
};
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import type { BearerProviderContext } from "../provider-runtime.ts";
2+
3+
import { describe, expect, it, vi } from "vitest";
4+
import { ProviderRequestError } from "../provider-runtime.ts";
5+
import { credentialValidators } from "./executors.ts";
6+
import { giteeActionHandlers, validateGiteeCredential } from "./runtime.ts";
7+
8+
function context(fetcher: typeof fetch): BearerProviderContext {
9+
return {
10+
accessToken: "gitee-token",
11+
tokenType: "Bearer",
12+
fetcher,
13+
};
14+
}
15+
16+
describe("Gitee runtime", () => {
17+
it("validates a token with the current user and preserves OAuth scopes", async () => {
18+
const fetcher = vi.fn(
19+
async (_input: RequestInfo | URL, _init?: RequestInit): Promise<Response> =>
20+
Response.json({
21+
id: 42,
22+
login: "octocat-cn",
23+
name: "Octocat CN",
24+
html_url: "https://gitee.com/octocat-cn",
25+
}),
26+
);
27+
28+
await expect(
29+
validateGiteeCredential("gitee-token", fetcher, undefined, ["user_info", "projects"]),
30+
).resolves.toMatchObject({
31+
profile: {
32+
accountId: "gitee:42",
33+
displayName: "Octocat CN",
34+
},
35+
grantedScopes: ["user_info", "projects"],
36+
metadata: {
37+
validationEndpoint: "/user",
38+
currentUser: {
39+
id: 42,
40+
login: "octocat-cn",
41+
},
42+
},
43+
});
44+
45+
const [url, init] = fetcher.mock.calls[0] as unknown as [URL, RequestInit];
46+
expect(url.toString()).toBe("https://gitee.com/api/v5/user");
47+
expect(init.headers).toMatchObject({
48+
authorization: "Bearer gitee-token",
49+
"user-agent": "oomol-connect/0.1",
50+
});
51+
});
52+
53+
it("uses the same validation for API keys and OAuth credentials", async () => {
54+
const fetcher = vi.fn(
55+
async (_input: RequestInfo | URL, _init?: RequestInit): Promise<Response> =>
56+
Response.json({ id: 7, login: "gitee-user" }),
57+
);
58+
59+
const apiKeyResult = await credentialValidators.apiKey?.(
60+
{ apiKey: "personal-token", values: { apiKey: "personal-token" } },
61+
{ fetcher },
62+
);
63+
const oauthResult = await credentialValidators.oauth2?.(
64+
{
65+
authType: "oauth2",
66+
accessToken: "oauth-token",
67+
tokenType: "Bearer",
68+
profile: { accountId: "oauth2", displayName: "OAuth Credential", grantedScopes: [] },
69+
metadata: { scope: "user_info projects" },
70+
},
71+
{ fetcher },
72+
);
73+
74+
expect(apiKeyResult?.grantedScopes).toEqual([]);
75+
expect(oauthResult?.grantedScopes).toEqual(["user_info", "projects"]);
76+
expect(fetcher.mock.calls[0]?.[1]?.headers).toMatchObject({ authorization: "Bearer personal-token" });
77+
expect(fetcher.mock.calls[1]?.[1]?.headers).toMatchObject({ authorization: "Bearer oauth-token" });
78+
expect(new URL(String(fetcher.mock.calls[0]?.[0])).searchParams.has("access_token")).toBe(false);
79+
expect(new URL(String(fetcher.mock.calls[1]?.[0])).searchParams.has("access_token")).toBe(false);
80+
});
81+
82+
it("lists repositories with Gitee pagination parameters", async () => {
83+
const fetcher = vi.fn(
84+
async (_input: RequestInfo | URL, _init?: RequestInit): Promise<Response> =>
85+
Response.json([{ id: 1, full_name: "acme/demo" }]),
86+
);
87+
88+
await expect(
89+
giteeActionHandlers.list_my_repositories(
90+
{
91+
visibility: "private",
92+
q: "demo",
93+
sort: "updated",
94+
direction: "desc",
95+
page: 2,
96+
perPage: 50,
97+
},
98+
context(fetcher),
99+
),
100+
).resolves.toEqual({ repositories: [{ id: 1, full_name: "acme/demo" }] });
101+
102+
const url = new URL(String(fetcher.mock.calls[0]?.[0]));
103+
expect(url.pathname).toBe("/api/v5/user/repos");
104+
expect(Object.fromEntries(url.searchParams)).toEqual({
105+
visibility: "private",
106+
q: "demo",
107+
sort: "updated",
108+
direction: "desc",
109+
page: "2",
110+
per_page: "50",
111+
});
112+
});
113+
114+
it("encodes repository owner and path segments", async () => {
115+
const fetcher = vi.fn(
116+
async (_input: RequestInfo | URL, _init?: RequestInit): Promise<Response> =>
117+
Response.json({ id: 1, full_name: "team name/repo/name" }),
118+
);
119+
120+
await giteeActionHandlers.get_repository({ owner: "team name", repo: "repo/name" }, context(fetcher));
121+
122+
const url = new URL(String(fetcher.mock.calls[0]?.[0]));
123+
expect(url.pathname).toBe("/api/v5/repos/team%20name/repo%2Fname");
124+
expect(url.searchParams.has("access_token")).toBe(false);
125+
expect(fetcher.mock.calls[0]?.[1]?.headers).toMatchObject({ authorization: "Bearer gitee-token" });
126+
});
127+
128+
it("maps authentication, rate limit, and malformed response failures", async () => {
129+
const unauthorized = vi.fn(
130+
async (_input: RequestInfo | URL, _init?: RequestInit): Promise<Response> =>
131+
Response.json({ message: "invalid access token" }, { status: 401 }),
132+
);
133+
const limited = vi.fn(
134+
async (_input: RequestInfo | URL, _init?: RequestInit): Promise<Response> =>
135+
Response.json({ message: "rate limit exceeded" }, { status: 429 }),
136+
);
137+
const malformed = vi.fn(
138+
async (_input: RequestInfo | URL, _init?: RequestInit): Promise<Response> =>
139+
new Response("not-json", { status: 200 }),
140+
);
141+
const malformedShape = vi.fn(
142+
async (_input: RequestInfo | URL, _init?: RequestInit): Promise<Response> => Response.json([]),
143+
);
144+
const notFound = vi.fn(
145+
async (_input: RequestInfo | URL, _init?: RequestInit): Promise<Response> =>
146+
Response.json({ message: "repository not found" }, { status: 404 }),
147+
);
148+
const networkFailure = vi.fn(
149+
async (_input: RequestInfo | URL, _init?: RequestInit): Promise<Response> =>
150+
Promise.reject(new Error("connection reset")),
151+
);
152+
153+
await expect(validateGiteeCredential("bad-token", unauthorized)).rejects.toMatchObject({
154+
status: 400,
155+
message: "Gitee authentication failed: invalid access token",
156+
});
157+
await expect(giteeActionHandlers.get_current_user({}, context(limited))).rejects.toMatchObject({
158+
status: 429,
159+
});
160+
await expect(giteeActionHandlers.get_current_user({}, context(malformed))).rejects.toEqual(
161+
expect.objectContaining<Partial<ProviderRequestError>>({
162+
status: 502,
163+
message: "Gitee returned invalid JSON",
164+
}),
165+
);
166+
await expect(validateGiteeCredential("bad-shape", malformedShape)).rejects.toMatchObject({
167+
status: 502,
168+
message: "Gitee current user response is not an object",
169+
});
170+
await expect(
171+
giteeActionHandlers.get_repository({ owner: "acme", repo: "missing" }, context(notFound)),
172+
).rejects.toMatchObject({ status: 404 });
173+
await expect(giteeActionHandlers.get_current_user({}, context(networkFailure))).rejects.toMatchObject({
174+
status: 502,
175+
message: "Gitee request failed: connection reset",
176+
});
177+
});
178+
});

0 commit comments

Comments
 (0)