mirror of
https://github.com/modelcontextprotocol/servers.git
synced 2026-04-18 16:13:22 +02:00
Merge main: Move to modular structure and add PR functionality
This commit is contained in:
89
src/github/common/errors.ts
Normal file
89
src/github/common/errors.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
export class GitHubError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
public readonly response: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = "GitHubError";
|
||||
}
|
||||
}
|
||||
|
||||
export class GitHubValidationError extends GitHubError {
|
||||
constructor(message: string, status: number, response: unknown) {
|
||||
super(message, status, response);
|
||||
this.name = "GitHubValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class GitHubResourceNotFoundError extends GitHubError {
|
||||
constructor(resource: string) {
|
||||
super(`Resource not found: ${resource}`, 404, { message: `${resource} not found` });
|
||||
this.name = "GitHubResourceNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class GitHubAuthenticationError extends GitHubError {
|
||||
constructor(message = "Authentication failed") {
|
||||
super(message, 401, { message });
|
||||
this.name = "GitHubAuthenticationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class GitHubPermissionError extends GitHubError {
|
||||
constructor(message = "Insufficient permissions") {
|
||||
super(message, 403, { message });
|
||||
this.name = "GitHubPermissionError";
|
||||
}
|
||||
}
|
||||
|
||||
export class GitHubRateLimitError extends GitHubError {
|
||||
constructor(
|
||||
message = "Rate limit exceeded",
|
||||
public readonly resetAt: Date
|
||||
) {
|
||||
super(message, 429, { message, reset_at: resetAt.toISOString() });
|
||||
this.name = "GitHubRateLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
export class GitHubConflictError extends GitHubError {
|
||||
constructor(message: string) {
|
||||
super(message, 409, { message });
|
||||
this.name = "GitHubConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
export function isGitHubError(error: unknown): error is GitHubError {
|
||||
return error instanceof GitHubError;
|
||||
}
|
||||
|
||||
export function createGitHubError(status: number, response: any): GitHubError {
|
||||
switch (status) {
|
||||
case 401:
|
||||
return new GitHubAuthenticationError(response?.message);
|
||||
case 403:
|
||||
return new GitHubPermissionError(response?.message);
|
||||
case 404:
|
||||
return new GitHubResourceNotFoundError(response?.message || "Resource");
|
||||
case 409:
|
||||
return new GitHubConflictError(response?.message || "Conflict occurred");
|
||||
case 422:
|
||||
return new GitHubValidationError(
|
||||
response?.message || "Validation failed",
|
||||
status,
|
||||
response
|
||||
);
|
||||
case 429:
|
||||
return new GitHubRateLimitError(
|
||||
response?.message,
|
||||
new Date(response?.reset_at || Date.now() + 60000)
|
||||
);
|
||||
default:
|
||||
return new GitHubError(
|
||||
response?.message || "GitHub API error",
|
||||
status,
|
||||
response
|
||||
);
|
||||
}
|
||||
}
|
||||
259
src/github/common/types.ts
Normal file
259
src/github/common/types.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Base schemas for common types
|
||||
export const GitHubAuthorSchema = z.object({
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
date: z.string(),
|
||||
});
|
||||
|
||||
export const GitHubOwnerSchema = z.object({
|
||||
login: z.string(),
|
||||
id: z.number(),
|
||||
node_id: z.string(),
|
||||
avatar_url: z.string(),
|
||||
url: z.string(),
|
||||
html_url: z.string(),
|
||||
type: z.string(),
|
||||
});
|
||||
|
||||
export const GitHubRepositorySchema = z.object({
|
||||
id: z.number(),
|
||||
node_id: z.string(),
|
||||
name: z.string(),
|
||||
full_name: z.string(),
|
||||
private: z.boolean(),
|
||||
owner: GitHubOwnerSchema,
|
||||
html_url: z.string(),
|
||||
description: z.string().nullable(),
|
||||
fork: z.boolean(),
|
||||
url: z.string(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
pushed_at: z.string(),
|
||||
git_url: z.string(),
|
||||
ssh_url: z.string(),
|
||||
clone_url: z.string(),
|
||||
default_branch: z.string(),
|
||||
});
|
||||
|
||||
export const GithubFileContentLinks = z.object({
|
||||
self: z.string(),
|
||||
git: z.string().nullable(),
|
||||
html: z.string().nullable()
|
||||
});
|
||||
|
||||
export const GitHubFileContentSchema = z.object({
|
||||
name: z.string(),
|
||||
path: z.string(),
|
||||
sha: z.string(),
|
||||
size: z.number(),
|
||||
url: z.string(),
|
||||
html_url: z.string(),
|
||||
git_url: z.string(),
|
||||
download_url: z.string(),
|
||||
type: z.string(),
|
||||
content: z.string().optional(),
|
||||
encoding: z.string().optional(),
|
||||
_links: GithubFileContentLinks
|
||||
});
|
||||
|
||||
export const GitHubDirectoryContentSchema = z.object({
|
||||
type: z.string(),
|
||||
size: z.number(),
|
||||
name: z.string(),
|
||||
path: z.string(),
|
||||
sha: z.string(),
|
||||
url: z.string(),
|
||||
git_url: z.string(),
|
||||
html_url: z.string(),
|
||||
download_url: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const GitHubContentSchema = z.union([
|
||||
GitHubFileContentSchema,
|
||||
z.array(GitHubDirectoryContentSchema),
|
||||
]);
|
||||
|
||||
export const GitHubTreeEntrySchema = z.object({
|
||||
path: z.string(),
|
||||
mode: z.enum(["100644", "100755", "040000", "160000", "120000"]),
|
||||
type: z.enum(["blob", "tree", "commit"]),
|
||||
size: z.number().optional(),
|
||||
sha: z.string(),
|
||||
url: z.string(),
|
||||
});
|
||||
|
||||
export const GitHubTreeSchema = z.object({
|
||||
sha: z.string(),
|
||||
url: z.string(),
|
||||
tree: z.array(GitHubTreeEntrySchema),
|
||||
truncated: z.boolean(),
|
||||
});
|
||||
|
||||
export const GitHubCommitSchema = z.object({
|
||||
sha: z.string(),
|
||||
node_id: z.string(),
|
||||
url: z.string(),
|
||||
author: GitHubAuthorSchema,
|
||||
committer: GitHubAuthorSchema,
|
||||
message: z.string(),
|
||||
tree: z.object({
|
||||
sha: z.string(),
|
||||
url: z.string(),
|
||||
}),
|
||||
parents: z.array(
|
||||
z.object({
|
||||
sha: z.string(),
|
||||
url: z.string(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export const GitHubListCommitsSchema = z.array(z.object({
|
||||
sha: z.string(),
|
||||
node_id: z.string(),
|
||||
commit: z.object({
|
||||
author: GitHubAuthorSchema,
|
||||
committer: GitHubAuthorSchema,
|
||||
message: z.string(),
|
||||
tree: z.object({
|
||||
sha: z.string(),
|
||||
url: z.string()
|
||||
}),
|
||||
url: z.string(),
|
||||
comment_count: z.number(),
|
||||
}),
|
||||
url: z.string(),
|
||||
html_url: z.string(),
|
||||
comments_url: z.string()
|
||||
}));
|
||||
|
||||
export const GitHubReferenceSchema = z.object({
|
||||
ref: z.string(),
|
||||
node_id: z.string(),
|
||||
url: z.string(),
|
||||
object: z.object({
|
||||
sha: z.string(),
|
||||
type: z.string(),
|
||||
url: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
// User and assignee schemas
|
||||
export const GitHubIssueAssigneeSchema = z.object({
|
||||
login: z.string(),
|
||||
id: z.number(),
|
||||
avatar_url: z.string(),
|
||||
url: z.string(),
|
||||
html_url: z.string(),
|
||||
});
|
||||
|
||||
// Issue-related schemas
|
||||
export const GitHubLabelSchema = z.object({
|
||||
id: z.number(),
|
||||
node_id: z.string(),
|
||||
url: z.string(),
|
||||
name: z.string(),
|
||||
color: z.string(),
|
||||
default: z.boolean(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const GitHubMilestoneSchema = z.object({
|
||||
url: z.string(),
|
||||
html_url: z.string(),
|
||||
labels_url: z.string(),
|
||||
id: z.number(),
|
||||
node_id: z.string(),
|
||||
number: z.number(),
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
state: z.string(),
|
||||
});
|
||||
|
||||
export const GitHubIssueSchema = z.object({
|
||||
url: z.string(),
|
||||
repository_url: z.string(),
|
||||
labels_url: z.string(),
|
||||
comments_url: z.string(),
|
||||
events_url: z.string(),
|
||||
html_url: z.string(),
|
||||
id: z.number(),
|
||||
node_id: z.string(),
|
||||
number: z.number(),
|
||||
title: z.string(),
|
||||
user: GitHubIssueAssigneeSchema,
|
||||
labels: z.array(GitHubLabelSchema),
|
||||
state: z.string(),
|
||||
locked: z.boolean(),
|
||||
assignee: GitHubIssueAssigneeSchema.nullable(),
|
||||
assignees: z.array(GitHubIssueAssigneeSchema),
|
||||
milestone: GitHubMilestoneSchema.nullable(),
|
||||
comments: z.number(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
closed_at: z.string().nullable(),
|
||||
body: z.string().nullable(),
|
||||
});
|
||||
|
||||
// Search-related schemas
|
||||
export const GitHubSearchResponseSchema = z.object({
|
||||
total_count: z.number(),
|
||||
incomplete_results: z.boolean(),
|
||||
items: z.array(GitHubRepositorySchema),
|
||||
});
|
||||
|
||||
// Pull request schemas
|
||||
export const GitHubPullRequestRefSchema = z.object({
|
||||
label: z.string(),
|
||||
ref: z.string(),
|
||||
sha: z.string(),
|
||||
user: GitHubIssueAssigneeSchema,
|
||||
repo: GitHubRepositorySchema,
|
||||
});
|
||||
|
||||
export const GitHubPullRequestSchema = z.object({
|
||||
url: z.string(),
|
||||
id: z.number(),
|
||||
node_id: z.string(),
|
||||
html_url: z.string(),
|
||||
diff_url: z.string(),
|
||||
patch_url: z.string(),
|
||||
issue_url: z.string(),
|
||||
number: z.number(),
|
||||
state: z.string(),
|
||||
locked: z.boolean(),
|
||||
title: z.string(),
|
||||
user: GitHubIssueAssigneeSchema,
|
||||
body: z.string().nullable(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
closed_at: z.string().nullable(),
|
||||
merged_at: z.string().nullable(),
|
||||
merge_commit_sha: z.string().nullable(),
|
||||
assignee: GitHubIssueAssigneeSchema.nullable(),
|
||||
assignees: z.array(GitHubIssueAssigneeSchema),
|
||||
requested_reviewers: z.array(GitHubIssueAssigneeSchema),
|
||||
labels: z.array(GitHubLabelSchema),
|
||||
head: GitHubPullRequestRefSchema,
|
||||
base: GitHubPullRequestRefSchema,
|
||||
});
|
||||
|
||||
// Export types
|
||||
export type GitHubAuthor = z.infer<typeof GitHubAuthorSchema>;
|
||||
export type GitHubRepository = z.infer<typeof GitHubRepositorySchema>;
|
||||
export type GitHubFileContent = z.infer<typeof GitHubFileContentSchema>;
|
||||
export type GitHubDirectoryContent = z.infer<typeof GitHubDirectoryContentSchema>;
|
||||
export type GitHubContent = z.infer<typeof GitHubContentSchema>;
|
||||
export type GitHubTree = z.infer<typeof GitHubTreeSchema>;
|
||||
export type GitHubCommit = z.infer<typeof GitHubCommitSchema>;
|
||||
export type GitHubListCommits = z.infer<typeof GitHubListCommitsSchema>;
|
||||
export type GitHubReference = z.infer<typeof GitHubReferenceSchema>;
|
||||
export type GitHubIssueAssignee = z.infer<typeof GitHubIssueAssigneeSchema>;
|
||||
export type GitHubLabel = z.infer<typeof GitHubLabelSchema>;
|
||||
export type GitHubMilestone = z.infer<typeof GitHubMilestoneSchema>;
|
||||
export type GitHubIssue = z.infer<typeof GitHubIssueSchema>;
|
||||
export type GitHubSearchResponse = z.infer<typeof GitHubSearchResponseSchema>;
|
||||
export type GitHubPullRequest = z.infer<typeof GitHubPullRequestSchema>;
|
||||
export type GitHubPullRequestRef = z.infer<typeof GitHubPullRequestRefSchema>;
|
||||
133
src/github/common/utils.ts
Normal file
133
src/github/common/utils.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { createGitHubError } from "./errors.js";
|
||||
|
||||
type RequestOptions = {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
async function parseResponseBody(response: Response): Promise<unknown> {
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (contentType?.includes("application/json")) {
|
||||
return response.json();
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
export function buildUrl(baseUrl: string, params: Record<string, string | number | undefined>): string {
|
||||
const url = new URL(baseUrl);
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
url.searchParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export async function githubRequest(
|
||||
url: string,
|
||||
options: RequestOptions = {}
|
||||
): Promise<unknown> {
|
||||
const headers: Record<string, string> = {
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
if (process.env.GITHUB_PERSONAL_ACCESS_TOKEN) {
|
||||
headers["Authorization"] = `Bearer ${process.env.GITHUB_PERSONAL_ACCESS_TOKEN}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: options.method || "GET",
|
||||
headers,
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
|
||||
const responseBody = await parseResponseBody(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw createGitHubError(response.status, responseBody);
|
||||
}
|
||||
|
||||
return responseBody;
|
||||
}
|
||||
|
||||
export function validateBranchName(branch: string): string {
|
||||
const sanitized = branch.trim();
|
||||
if (!sanitized) {
|
||||
throw new Error("Branch name cannot be empty");
|
||||
}
|
||||
if (sanitized.includes("..")) {
|
||||
throw new Error("Branch name cannot contain '..'");
|
||||
}
|
||||
if (/[\s~^:?*[\\\]]/.test(sanitized)) {
|
||||
throw new Error("Branch name contains invalid characters");
|
||||
}
|
||||
if (sanitized.startsWith("/") || sanitized.endsWith("/")) {
|
||||
throw new Error("Branch name cannot start or end with '/'");
|
||||
}
|
||||
if (sanitized.endsWith(".lock")) {
|
||||
throw new Error("Branch name cannot end with '.lock'");
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function validateRepositoryName(name: string): string {
|
||||
const sanitized = name.trim().toLowerCase();
|
||||
if (!sanitized) {
|
||||
throw new Error("Repository name cannot be empty");
|
||||
}
|
||||
if (!/^[a-z0-9_.-]+$/.test(sanitized)) {
|
||||
throw new Error(
|
||||
"Repository name can only contain lowercase letters, numbers, hyphens, periods, and underscores"
|
||||
);
|
||||
}
|
||||
if (sanitized.startsWith(".") || sanitized.endsWith(".")) {
|
||||
throw new Error("Repository name cannot start or end with a period");
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function validateOwnerName(owner: string): string {
|
||||
const sanitized = owner.trim().toLowerCase();
|
||||
if (!sanitized) {
|
||||
throw new Error("Owner name cannot be empty");
|
||||
}
|
||||
if (!/^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/.test(sanitized)) {
|
||||
throw new Error(
|
||||
"Owner name must start with a letter or number and can contain up to 39 characters"
|
||||
);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export async function checkBranchExists(
|
||||
owner: string,
|
||||
repo: string,
|
||||
branch: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await githubRequest(
|
||||
`https://api.github.com/repos/${owner}/${repo}/branches/${branch}`
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "status" in error && error.status === 404) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkUserExists(username: string): Promise<boolean> {
|
||||
try {
|
||||
await githubRequest(`https://api.github.com/users/${username}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "status" in error && error.status === 404) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user