From 9e25ffd59966aae245f5fd2642769a295a077c5e Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Fri, 10 Jan 2025 11:19:30 +0000 Subject: [PATCH 01/62] Add support for listing, reading, and reviewing PRs --- src/github/README.md | 37 +++++++++++++ src/github/index.ts | 118 ++++++++++++++++++++++++++++++++++++++++++ src/github/schemas.ts | 35 +++++++++++++ 3 files changed, 190 insertions(+) diff --git a/src/github/README.md b/src/github/README.md index d2277ab0..ae29cda7 100644 --- a/src/github/README.md +++ b/src/github/README.md @@ -188,6 +188,43 @@ MCP Server for the GitHub API, enabling file operations, repository management, - `issue_number` (number): Issue number to retrieve - Returns: Github Issue object & details +18. `get_pull_request` + - Get details of a specific pull request + - Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `pull_number` (number): Pull request number + - Returns: Pull request details including diff and review status + +19. `list_pull_requests` + - List and filter repository pull requests + - Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `state` (optional string): Filter by state ('open', 'closed', 'all') + - `head` (optional string): Filter by head user/org and branch + - `base` (optional string): Filter by base branch + - `sort` (optional string): Sort by ('created', 'updated', 'popularity', 'long-running') + - `direction` (optional string): Sort direction ('asc', 'desc') + - `per_page` (optional number): Results per page (max 100) + - `page` (optional number): Page number + - Returns: Array of pull request details + +20. `create_pull_request_review` + - Create a review on a pull request + - Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `pull_number` (number): Pull request number + - `body` (string): Review comment text + - `event` (string): Review action ('APPROVE', 'REQUEST_CHANGES', 'COMMENT') + - `commit_id` (optional string): SHA of commit to review + - `comments` (optional array): Line-specific comments, each with: + - `path` (string): File path + - `position` (number): Line position in diff + - `body` (string): Comment text + - Returns: Created review details + ## Search Query Syntax ### Code Search diff --git a/src/github/index.ts b/src/github/index.ts index 3759e8b5..0e731abb 100644 --- a/src/github/index.ts +++ b/src/github/index.ts @@ -21,6 +21,7 @@ import { ForkRepositorySchema, GetFileContentsSchema, GetIssueSchema, + GetPullRequestSchema, GitHubCommitSchema, GitHubContentSchema, GitHubCreateUpdateFileResponseSchema, @@ -36,6 +37,8 @@ import { IssueCommentSchema, ListCommitsSchema, ListIssuesOptionsSchema, + ListPullRequestsSchema, + CreatePullRequestReviewSchema, PushFilesSchema, SearchCodeResponseSchema, SearchCodeSchema, @@ -715,6 +718,86 @@ async function getIssue( return GitHubIssueSchema.parse(await response.json()); } +async function getPullRequest( + owner: string, + repo: string, + pullNumber: number +): Promise { + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}`, + { + headers: { + Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "github-mcp-server", + }, + } + ); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.statusText}`); + } + + return GitHubPullRequestSchema.parse(await response.json()); +} + +async function listPullRequests( + owner: string, + repo: string, + options: Omit, 'owner' | 'repo'> +): Promise { + const url = new URL(`https://api.github.com/repos/${owner}/${repo}/pulls`); + + if (options.state) url.searchParams.append('state', options.state); + if (options.head) url.searchParams.append('head', options.head); + if (options.base) url.searchParams.append('base', options.base); + if (options.sort) url.searchParams.append('sort', options.sort); + if (options.direction) url.searchParams.append('direction', options.direction); + if (options.per_page) url.searchParams.append('per_page', options.per_page.toString()); + if (options.page) url.searchParams.append('page', options.page.toString()); + + const response = await fetch(url.toString(), { + headers: { + Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "github-mcp-server", + }, + }); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.statusText}`); + } + + return z.array(GitHubPullRequestSchema).parse(await response.json()); +} + +async function createPullRequestReview( + owner: string, + repo: string, + pullNumber: number, + options: Omit, 'owner' | 'repo' | 'pull_number'> +): Promise { + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/reviews`, + { + method: 'POST', + headers: { + Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "github-mcp-server", + "Content-Type": "application/json", + }, + body: JSON.stringify(options), + } + ); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.statusText}`); + } + + return await response.json(); +} + server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ @@ -806,6 +889,21 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { name: "get_issue", description: "Get details of a specific issue in a GitHub repository.", inputSchema: zodToJsonSchema(GetIssueSchema) + }, + { + name: "get_pull_request", + description: "Get details of a specific pull request in a GitHub repository", + inputSchema: zodToJsonSchema(GetPullRequestSchema) + }, + { + name: "list_pull_requests", + description: "List pull requests in a GitHub repository with filtering options", + inputSchema: zodToJsonSchema(ListPullRequestsSchema) + }, + { + name: "create_pull_request_review", + description: "Create a review on a pull request", + inputSchema: zodToJsonSchema(CreatePullRequestReviewSchema) } ], }; @@ -1011,6 +1109,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { return { toolResult: issue }; } + case "get_pull_request": { + const args = GetPullRequestSchema.parse(request.params.arguments); + const pullRequest = await getPullRequest(args.owner, args.repo, args.pull_number); + return { toolResult: pullRequest }; + } + + case "list_pull_requests": { + const args = ListPullRequestsSchema.parse(request.params.arguments); + const { owner, repo, ...options } = args; + const pullRequests = await listPullRequests(owner, repo, options); + return { toolResult: pullRequests }; + } + + case "create_pull_request_review": { + const args = CreatePullRequestReviewSchema.parse(request.params.arguments); + const { owner, repo, pull_number, ...options } = args; + const review = await createPullRequestReview(owner, repo, pull_number, options); + return { toolResult: review }; + } + default: throw new Error(`Unknown tool: ${request.params.name}`); } diff --git a/src/github/schemas.ts b/src/github/schemas.ts index 0a322328..a84c101e 100644 --- a/src/github/schemas.ts +++ b/src/github/schemas.ts @@ -683,6 +683,38 @@ export const GetIssueSchema = z.object({ issue_number: z.number().describe("Issue number") }); +export const GetPullRequestSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + pull_number: z.number().describe("Pull request number") +}); + +export const ListPullRequestsSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + state: z.enum(['open', 'closed', 'all']).optional().describe("State of the pull requests to return"), + head: z.string().optional().describe("Filter by head user or head organization and branch name"), + base: z.string().optional().describe("Filter by base branch name"), + sort: z.enum(['created', 'updated', 'popularity', 'long-running']).optional().describe("What to sort results by"), + direction: z.enum(['asc', 'desc']).optional().describe("The direction of the sort"), + per_page: z.number().optional().describe("Results per page (max 100)"), + page: z.number().optional().describe("Page number of the results") +}); + +export const CreatePullRequestReviewSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + pull_number: z.number().describe("Pull request number"), + commit_id: z.string().optional().describe("The SHA of the commit that needs a review"), + body: z.string().describe("The body text of the review"), + event: z.enum(['APPROVE', 'REQUEST_CHANGES', 'COMMENT']).describe("The review action to perform"), + comments: z.array(z.object({ + path: z.string().describe("The relative path to the file being commented on"), + position: z.number().describe("The position in the diff where you want to add a review comment"), + body: z.string().describe("Text of the review comment") + })).optional().describe("Comments to post as part of the review") +}); + // Export types export type GitHubAuthor = z.infer; export type GitHubFork = z.infer; @@ -717,3 +749,6 @@ export type SearchIssueItem = z.infer; export type SearchIssuesResponse = z.infer; export type SearchUserItem = z.infer; export type SearchUsersResponse = z.infer; +export type GetPullRequest = z.infer; +export type ListPullRequests = z.infer; +export type CreatePullRequestReview = z.infer; From 353fbb8d0ae37969d2c39996a986664c943241cd Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Fri, 10 Jan 2025 11:23:42 +0000 Subject: [PATCH 02/62] Add reading PR files and status, merging PRs --- src/github/README.md | 27 +++++++++ src/github/index.ts | 132 ++++++++++++++++++++++++++++++++++++++++++ src/github/schemas.ts | 61 +++++++++++++++++++ 3 files changed, 220 insertions(+) diff --git a/src/github/README.md b/src/github/README.md index ae29cda7..50a52d4c 100644 --- a/src/github/README.md +++ b/src/github/README.md @@ -225,6 +225,33 @@ MCP Server for the GitHub API, enabling file operations, repository management, - `body` (string): Comment text - Returns: Created review details +21. `merge_pull_request` + - Merge a pull request + - Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `pull_number` (number): Pull request number + - `commit_title` (optional string): Title for merge commit + - `commit_message` (optional string): Extra detail for merge commit + - `merge_method` (optional string): Merge method ('merge', 'squash', 'rebase') + - Returns: Merge result details + +22. `get_pull_request_files` + - Get the list of files changed in a pull request + - Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `pull_number` (number): Pull request number + - Returns: Array of changed files with patch and status details + +23. `get_pull_request_status` + - Get the combined status of all status checks for a pull request + - Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `pull_number` (number): Pull request number + - Returns: Combined status check results and individual check details + ## Search Query Syntax ### Code Search diff --git a/src/github/index.ts b/src/github/index.ts index 0e731abb..0147f497 100644 --- a/src/github/index.ts +++ b/src/github/index.ts @@ -22,6 +22,9 @@ import { GetFileContentsSchema, GetIssueSchema, GetPullRequestSchema, + GetPullRequestFilesSchema, + GetPullRequestStatusSchema, + MergePullRequestSchema, GitHubCommitSchema, GitHubContentSchema, GitHubCreateUpdateFileResponseSchema, @@ -40,6 +43,8 @@ import { ListPullRequestsSchema, CreatePullRequestReviewSchema, PushFilesSchema, + PullRequestFileSchema, + CombinedStatusSchema, SearchCodeResponseSchema, SearchCodeSchema, SearchIssuesResponseSchema, @@ -798,6 +803,99 @@ async function createPullRequestReview( return await response.json(); } +async function mergePullRequest( + owner: string, + repo: string, + pullNumber: number, + options: Omit, 'owner' | 'repo' | 'pull_number'> +): Promise { + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/merge`, + { + method: 'PUT', + headers: { + Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "github-mcp-server", + "Content-Type": "application/json", + }, + body: JSON.stringify(options), + } + ); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.statusText}`); + } + + return await response.json(); +} + +async function getPullRequestFiles( + owner: string, + repo: string, + pullNumber: number +): Promise[]> { + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/files`, + { + headers: { + Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "github-mcp-server", + }, + } + ); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.statusText}`); + } + + return z.array(PullRequestFileSchema).parse(await response.json()); +} + +async function getPullRequestStatus( + owner: string, + repo: string, + pullNumber: number +): Promise> { + // First get the PR to get the head SHA + const prResponse = await fetch( + `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}`, + { + headers: { + Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "github-mcp-server", + }, + } + ); + + if (!prResponse.ok) { + throw new Error(`GitHub API error: ${prResponse.statusText}`); + } + + const pr = GitHubPullRequestSchema.parse(await prResponse.json()); + const sha = pr.head.sha; + + // Then get the combined status for that SHA + const statusResponse = await fetch( + `https://api.github.com/repos/${owner}/${repo}/commits/${sha}/status`, + { + headers: { + Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "github-mcp-server", + }, + } + ); + + if (!statusResponse.ok) { + throw new Error(`GitHub API error: ${statusResponse.statusText}`); + } + + return CombinedStatusSchema.parse(await statusResponse.json()); +} + server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ @@ -904,6 +1002,21 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { name: "create_pull_request_review", description: "Create a review on a pull request", inputSchema: zodToJsonSchema(CreatePullRequestReviewSchema) + }, + { + name: "merge_pull_request", + description: "Merge a pull request", + inputSchema: zodToJsonSchema(MergePullRequestSchema) + }, + { + name: "get_pull_request_files", + description: "Get the list of files changed in a pull request", + inputSchema: zodToJsonSchema(GetPullRequestFilesSchema) + }, + { + name: "get_pull_request_status", + description: "Get the combined status of all status checks for a pull request", + inputSchema: zodToJsonSchema(GetPullRequestStatusSchema) } ], }; @@ -1129,6 +1242,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { return { toolResult: review }; } + case "merge_pull_request": { + const args = MergePullRequestSchema.parse(request.params.arguments); + const { owner, repo, pull_number, ...options } = args; + const result = await mergePullRequest(owner, repo, pull_number, options); + return { toolResult: result }; + } + + case "get_pull_request_files": { + const args = GetPullRequestFilesSchema.parse(request.params.arguments); + const files = await getPullRequestFiles(args.owner, args.repo, args.pull_number); + return { toolResult: files }; + } + + case "get_pull_request_status": { + const args = GetPullRequestStatusSchema.parse(request.params.arguments); + const status = await getPullRequestStatus(args.owner, args.repo, args.pull_number); + return { toolResult: status }; + } + default: throw new Error(`Unknown tool: ${request.params.name}`); } diff --git a/src/github/schemas.ts b/src/github/schemas.ts index a84c101e..98ceed8a 100644 --- a/src/github/schemas.ts +++ b/src/github/schemas.ts @@ -752,3 +752,64 @@ export type SearchUsersResponse = z.infer; export type GetPullRequest = z.infer; export type ListPullRequests = z.infer; export type CreatePullRequestReview = z.infer; + +// Schema for merging a pull request +export const MergePullRequestSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + pull_number: z.number().describe("Pull request number"), + commit_title: z.string().optional().describe("Title for the automatic commit message"), + commit_message: z.string().optional().describe("Extra detail to append to automatic commit message"), + merge_method: z.enum(['merge', 'squash', 'rebase']).optional().describe("Merge method to use") +}); + +// Schema for getting PR files +export const GetPullRequestFilesSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + pull_number: z.number().describe("Pull request number") +}); + +export const PullRequestFileSchema = z.object({ + sha: z.string(), + filename: z.string(), + status: z.enum(['added', 'removed', 'modified', 'renamed', 'copied', 'changed', 'unchanged']), + additions: z.number(), + deletions: z.number(), + changes: z.number(), + blob_url: z.string(), + raw_url: z.string(), + contents_url: z.string(), + patch: z.string().optional() +}); + +// Schema for checking PR status +export const GetPullRequestStatusSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + pull_number: z.number().describe("Pull request number") +}); + +export const StatusCheckSchema = z.object({ + url: z.string(), + state: z.enum(['error', 'failure', 'pending', 'success']), + description: z.string().nullable(), + target_url: z.string().nullable(), + context: z.string(), + created_at: z.string(), + updated_at: z.string() +}); + +export const CombinedStatusSchema = z.object({ + state: z.enum(['error', 'failure', 'pending', 'success']), + statuses: z.array(StatusCheckSchema), + sha: z.string(), + total_count: z.number() +}); + +export type MergePullRequest = z.infer; +export type GetPullRequestFiles = z.infer; +export type PullRequestFile = z.infer; +export type GetPullRequestStatus = z.infer; +export type StatusCheck = z.infer; +export type CombinedStatus = z.infer; From f42cf77d5747939379244592681d3c9e9f2e7cf0 Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Fri, 10 Jan 2025 11:33:57 +0000 Subject: [PATCH 03/62] Don't use old `toolResult` format --- src/github/index.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/github/index.ts b/src/github/index.ts index 0147f497..b50f06bb 100644 --- a/src/github/index.ts +++ b/src/github/index.ts @@ -1189,21 +1189,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const args = ListIssuesOptionsSchema.parse(request.params.arguments); const { owner, repo, ...options } = args; const issues = await listIssues(owner, repo, options); - return { toolResult: issues }; + return { content: [{ type: "text", text: JSON.stringify(issues, null, 2) }] }; } case "update_issue": { const args = UpdateIssueOptionsSchema.parse(request.params.arguments); const { owner, repo, issue_number, ...options } = args; const issue = await updateIssue(owner, repo, issue_number, options); - return { toolResult: issue }; + return { content: [{ type: "text", text: JSON.stringify(issue, null, 2) }] }; } case "add_issue_comment": { const args = IssueCommentSchema.parse(request.params.arguments); const { owner, repo, issue_number, body } = args; const comment = await addIssueComment(owner, repo, issue_number, body); - return { toolResult: comment }; + return { content: [{ type: "text", text: JSON.stringify(comment, null, 2) }] }; } case "list_commits": { @@ -1219,46 +1219,46 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { issue_number: z.number() }).parse(request.params.arguments); const issue = await getIssue(args.owner, args.repo, args.issue_number); - return { toolResult: issue }; + return { content: [{ type: "text", text: JSON.stringify(issue, null, 2) }] }; } case "get_pull_request": { const args = GetPullRequestSchema.parse(request.params.arguments); const pullRequest = await getPullRequest(args.owner, args.repo, args.pull_number); - return { toolResult: pullRequest }; + return { content: [{ type: "text", text: JSON.stringify(pullRequest, null, 2) }] }; } case "list_pull_requests": { const args = ListPullRequestsSchema.parse(request.params.arguments); const { owner, repo, ...options } = args; const pullRequests = await listPullRequests(owner, repo, options); - return { toolResult: pullRequests }; + return { content: [{ type: "text", text: JSON.stringify(pullRequests, null, 2) }] }; } case "create_pull_request_review": { const args = CreatePullRequestReviewSchema.parse(request.params.arguments); const { owner, repo, pull_number, ...options } = args; const review = await createPullRequestReview(owner, repo, pull_number, options); - return { toolResult: review }; + return { content: [{ type: "text", text: JSON.stringify(review, null, 2) }] }; } case "merge_pull_request": { const args = MergePullRequestSchema.parse(request.params.arguments); const { owner, repo, pull_number, ...options } = args; const result = await mergePullRequest(owner, repo, pull_number, options); - return { toolResult: result }; + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } case "get_pull_request_files": { const args = GetPullRequestFilesSchema.parse(request.params.arguments); const files = await getPullRequestFiles(args.owner, args.repo, args.pull_number); - return { toolResult: files }; + return { content: [{ type: "text", text: JSON.stringify(files, null, 2) }] }; } case "get_pull_request_status": { const args = GetPullRequestStatusSchema.parse(request.params.arguments); const status = await getPullRequestStatus(args.owner, args.repo, args.pull_number); - return { toolResult: status }; + return { content: [{ type: "text", text: JSON.stringify(status, null, 2) }] }; } default: From ac7592f71a41bb7abc1cc627ffe3bf634e41bd28 Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Fri, 10 Jan 2025 11:53:23 +0000 Subject: [PATCH 04/62] Support updating PR branches --- src/github/README.md | 9 +++++++++ src/github/index.ts | 37 +++++++++++++++++++++++++++++++++++++ src/github/schemas.ts | 9 +++++++++ 3 files changed, 55 insertions(+) diff --git a/src/github/README.md b/src/github/README.md index 50a52d4c..86b36e31 100644 --- a/src/github/README.md +++ b/src/github/README.md @@ -252,6 +252,15 @@ MCP Server for the GitHub API, enabling file operations, repository management, - `pull_number` (number): Pull request number - Returns: Combined status check results and individual check details +24. `update_pull_request_branch` + - Update a pull request branch with the latest changes from the base branch (equivalent to GitHub's "Update branch" button) + - Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `pull_number` (number): Pull request number + - `expected_head_sha` (optional string): The expected SHA of the pull request's HEAD ref + - Returns: Success message when branch is updated + ## Search Query Syntax ### Code Search diff --git a/src/github/index.ts b/src/github/index.ts index b50f06bb..b53392ab 100644 --- a/src/github/index.ts +++ b/src/github/index.ts @@ -48,6 +48,7 @@ import { SearchCodeResponseSchema, SearchCodeSchema, SearchIssuesResponseSchema, + UpdatePullRequestBranchSchema, SearchIssuesSchema, SearchRepositoriesSchema, SearchUsersResponseSchema, @@ -853,6 +854,31 @@ async function getPullRequestFiles( return z.array(PullRequestFileSchema).parse(await response.json()); } +async function updatePullRequestBranch( + owner: string, + repo: string, + pullNumber: number, + expectedHeadSha?: string +): Promise { + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/update-branch`, + { + method: "PUT", + headers: { + Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "github-mcp-server", + "Content-Type": "application/json", + }, + body: expectedHeadSha ? JSON.stringify({ expected_head_sha: expectedHeadSha }) : undefined, + } + ); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.statusText}`); + } +} + async function getPullRequestStatus( owner: string, repo: string, @@ -1017,6 +1043,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { name: "get_pull_request_status", description: "Get the combined status of all status checks for a pull request", inputSchema: zodToJsonSchema(GetPullRequestStatusSchema) + }, + { + name: "update_pull_request_branch", + description: "Update a pull request branch with the latest changes from the base branch", + inputSchema: zodToJsonSchema(UpdatePullRequestBranchSchema) } ], }; @@ -1261,6 +1292,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { return { content: [{ type: "text", text: JSON.stringify(status, null, 2) }] }; } + case "update_pull_request_branch": { + const args = UpdatePullRequestBranchSchema.parse(request.params.arguments); + await updatePullRequestBranch(args.owner, args.repo, args.pull_number, args.expected_head_sha); + return { content: [{ type: "text", text: "Pull request branch updated successfully" }] }; + } + default: throw new Error(`Unknown tool: ${request.params.name}`); } diff --git a/src/github/schemas.ts b/src/github/schemas.ts index 98ceed8a..8bca3a3f 100644 --- a/src/github/schemas.ts +++ b/src/github/schemas.ts @@ -812,4 +812,13 @@ export type GetPullRequestFiles = z.infer; export type PullRequestFile = z.infer; export type GetPullRequestStatus = z.infer; export type StatusCheck = z.infer; +// Schema for updating a pull request branch +export const UpdatePullRequestBranchSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + pull_number: z.number().describe("Pull request number"), + expected_head_sha: z.string().optional().describe("The expected SHA of the pull request's HEAD ref") +}); + export type CombinedStatus = z.infer; +export type UpdatePullRequestBranch = z.infer; From a47abf5cceccaf62f60302d24e9bcc21f162e00a Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Fri, 10 Jan 2025 13:47:46 +0000 Subject: [PATCH 05/62] Add support for listing PR comments --- src/github/README.md | 8 ++++++++ src/github/index.ts | 36 ++++++++++++++++++++++++++++++++++++ src/github/schemas.ts | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/src/github/README.md b/src/github/README.md index 86b36e31..f2710f54 100644 --- a/src/github/README.md +++ b/src/github/README.md @@ -261,6 +261,14 @@ MCP Server for the GitHub API, enabling file operations, repository management, - `expected_head_sha` (optional string): The expected SHA of the pull request's HEAD ref - Returns: Success message when branch is updated +25. `get_pull_request_comments` + - Get the review comments on a pull request + - Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `pull_number` (number): Pull request number + - Returns: Array of pull request review comments with details like the comment text, author, and location in the diff + ## Search Query Syntax ### Code Search diff --git a/src/github/index.ts b/src/github/index.ts index b53392ab..1d023dab 100644 --- a/src/github/index.ts +++ b/src/github/index.ts @@ -54,6 +54,8 @@ import { SearchUsersResponseSchema, SearchUsersSchema, UpdateIssueOptionsSchema, + GetPullRequestCommentsSchema, + PullRequestCommentSchema, type FileOperation, type GitHubCommit, type GitHubContent, @@ -879,6 +881,29 @@ async function updatePullRequestBranch( } } +async function getPullRequestComments( + owner: string, + repo: string, + pullNumber: number +): Promise[]> { + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/comments`, + { + headers: { + Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "github-mcp-server", + }, + } + ); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.statusText}`); + } + + return z.array(PullRequestCommentSchema).parse(await response.json()); +} + async function getPullRequestStatus( owner: string, repo: string, @@ -1048,6 +1073,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { name: "update_pull_request_branch", description: "Update a pull request branch with the latest changes from the base branch", inputSchema: zodToJsonSchema(UpdatePullRequestBranchSchema) + }, + { + name: "get_pull_request_comments", + description: "Get the review comments on a pull request", + inputSchema: zodToJsonSchema(GetPullRequestCommentsSchema) } ], }; @@ -1298,6 +1328,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { return { content: [{ type: "text", text: "Pull request branch updated successfully" }] }; } + case "get_pull_request_comments": { + const args = GetPullRequestCommentsSchema.parse(request.params.arguments); + const comments = await getPullRequestComments(args.owner, args.repo, args.pull_number); + return { content: [{ type: "text", text: JSON.stringify(comments, null, 2) }] }; + } + default: throw new Error(`Unknown tool: ${request.params.name}`); } diff --git a/src/github/schemas.ts b/src/github/schemas.ts index 8bca3a3f..680bfb1c 100644 --- a/src/github/schemas.ts +++ b/src/github/schemas.ts @@ -820,5 +820,39 @@ export const UpdatePullRequestBranchSchema = z.object({ expected_head_sha: z.string().optional().describe("The expected SHA of the pull request's HEAD ref") }); +// Schema for PR comments +export const GetPullRequestCommentsSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + pull_number: z.number().describe("Pull request number") +}); + +export const PullRequestCommentSchema = z.object({ + url: z.string(), + id: z.number(), + node_id: z.string(), + pull_request_review_id: z.number().nullable(), + diff_hunk: z.string(), + path: z.string().nullable(), + position: z.number().nullable(), + original_position: z.number().nullable(), + commit_id: z.string(), + original_commit_id: z.string(), + user: GitHubIssueAssigneeSchema, + body: z.string(), + created_at: z.string(), + updated_at: z.string(), + html_url: z.string(), + pull_request_url: z.string(), + author_association: z.string(), + _links: z.object({ + self: z.object({ href: z.string() }), + html: z.object({ href: z.string() }), + pull_request: z.object({ href: z.string() }) + }) +}); + export type CombinedStatus = z.infer; export type UpdatePullRequestBranch = z.infer; +export type GetPullRequestComments = z.infer; +export type PullRequestComment = z.infer; From 6ec4dff99ac9dd7c02c7b3e5bb371525bd32da24 Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Fri, 10 Jan 2025 13:53:13 +0000 Subject: [PATCH 06/62] Add tool to list existing PR reviews --- src/github/README.md | 8 ++++++++ src/github/index.ts | 36 ++++++++++++++++++++++++++++++++++++ src/github/schemas.ts | 23 +++++++++++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/src/github/README.md b/src/github/README.md index f2710f54..e06bf14a 100644 --- a/src/github/README.md +++ b/src/github/README.md @@ -269,6 +269,14 @@ MCP Server for the GitHub API, enabling file operations, repository management, - `pull_number` (number): Pull request number - Returns: Array of pull request review comments with details like the comment text, author, and location in the diff +26. `get_pull_request_reviews` + - Get the reviews on a pull request + - Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `pull_number` (number): Pull request number + - Returns: Array of pull request reviews with details like the review state (APPROVED, CHANGES_REQUESTED, etc.), reviewer, and review body + ## Search Query Syntax ### Code Search diff --git a/src/github/index.ts b/src/github/index.ts index 1d023dab..04b93a37 100644 --- a/src/github/index.ts +++ b/src/github/index.ts @@ -56,6 +56,8 @@ import { UpdateIssueOptionsSchema, GetPullRequestCommentsSchema, PullRequestCommentSchema, + GetPullRequestReviewsSchema, + PullRequestReviewSchema, type FileOperation, type GitHubCommit, type GitHubContent, @@ -904,6 +906,29 @@ async function getPullRequestComments( return z.array(PullRequestCommentSchema).parse(await response.json()); } +async function getPullRequestReviews( + owner: string, + repo: string, + pullNumber: number +): Promise[]> { + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/reviews`, + { + headers: { + Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "github-mcp-server", + }, + } + ); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.statusText}`); + } + + return z.array(PullRequestReviewSchema).parse(await response.json()); +} + async function getPullRequestStatus( owner: string, repo: string, @@ -1078,6 +1103,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { name: "get_pull_request_comments", description: "Get the review comments on a pull request", inputSchema: zodToJsonSchema(GetPullRequestCommentsSchema) + }, + { + name: "get_pull_request_reviews", + description: "Get the reviews on a pull request", + inputSchema: zodToJsonSchema(GetPullRequestReviewsSchema) } ], }; @@ -1334,6 +1364,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { return { content: [{ type: "text", text: JSON.stringify(comments, null, 2) }] }; } + case "get_pull_request_reviews": { + const args = GetPullRequestReviewsSchema.parse(request.params.arguments); + const reviews = await getPullRequestReviews(args.owner, args.repo, args.pull_number); + return { content: [{ type: "text", text: JSON.stringify(reviews, null, 2) }] }; + } + default: throw new Error(`Unknown tool: ${request.params.name}`); } diff --git a/src/github/schemas.ts b/src/github/schemas.ts index 680bfb1c..b9c0fa67 100644 --- a/src/github/schemas.ts +++ b/src/github/schemas.ts @@ -856,3 +856,26 @@ export type CombinedStatus = z.infer; export type UpdatePullRequestBranch = z.infer; export type GetPullRequestComments = z.infer; export type PullRequestComment = z.infer; + +// Schema for listing PR reviews +export const GetPullRequestReviewsSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + pull_number: z.number().describe("Pull request number") +}); + +export const PullRequestReviewSchema = z.object({ + id: z.number(), + node_id: z.string(), + user: GitHubIssueAssigneeSchema, + body: z.string().nullable(), + state: z.enum(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING']), + html_url: z.string(), + pull_request_url: z.string(), + commit_id: z.string(), + submitted_at: z.string().nullable(), + author_association: z.string() +}); + +export type GetPullRequestReviews = z.infer; +export type PullRequestReview = z.infer; From 26d3dd4760063aa5e3e83a8155cac73fbe0d6c47 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Fri, 10 Jan 2025 21:05:09 +0000 Subject: [PATCH 07/62] feat: add weekly release automation --- .github/workflows/weekly-release.yml | 102 +++++++++++++++ scripts/release.py | 179 +++++++++++++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 .github/workflows/weekly-release.yml create mode 100755 scripts/release.py diff --git a/.github/workflows/weekly-release.yml b/.github/workflows/weekly-release.yml new file mode 100644 index 00000000..e4952bd4 --- /dev/null +++ b/.github/workflows/weekly-release.yml @@ -0,0 +1,102 @@ +name: Weekly Release + +on: + schedule: + # Run every Monday at 9:00 UTC + - cron: '0 9 * * 1' + # Allow manual trigger for testing + workflow_dispatch: + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + last_release: ${{ steps.last-release.outputs.hash }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Find package directories + id: set-matrix + run: | + DIRS=$(git ls-tree -r HEAD --name-only | grep -E "package.json|pyproject.toml" | xargs dirname | grep -v "^.$" | jq -R -s -c 'split("\n")[:-1]') + echo "matrix=${DIRS}" >> $GITHUB_OUTPUT + + - name: Get last release hash + id: last-release + run: | + HASH=$(git rev-list --tags --max-count=1 || echo "HEAD~1") + echo "hash=${HASH}" >> $GITHUB_OUTPUT + + release: + needs: prepare + runs-on: ubuntu-latest + strategy: + matrix: + directory: ${{ fromJson(needs.prepare.outputs.matrix) }} + fail-fast: false + permissions: + contents: write + packages: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: astral-sh/setup-uv@v5 + + - name: Setup Node.js + if: endsWith(matrix.directory, 'package.json') + uses: actions/setup-node@v4 + with: + node-version: '18' + registry-url: 'https://registry.npmjs.org' + + - name: Setup Python + if: endsWith(matrix.directory, 'pyproject.toml') + run: uv python install + + - name: Release package + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} + run: uv run --script scripts/release.py "${{ matrix.directory }}" "${{ needs.prepare.outputs.last_release }}" >> "$GITHUB_OUTPUT" + + create-release: + needs: [prepare, release] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Create Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Check if there's output from release step + if [ -s "$GITHUB_OUTPUT" ]; then + DATE=$(date +%Y.%m.%d) + + # Create git tag + git tag -s -a -m"automated release v${DATE}" "v${DATE}" + git push origin "v${DATE}" + + # Create release notes + echo "# Release ${DATE}" > notes.md + echo "" >> notes.md + echo "## Updated Packages" >> notes.md + + # Read updated packages from github output + while IFS= read -r line; do + echo "- ${line}" >> notes.md + done < "$GITHUB_OUTPUT" + + # Create GitHub release + gh release create "v${DATE}" \ + --title "Release ${DATE}" \ + --notes-file notes.md + fi diff --git a/scripts/release.py b/scripts/release.py new file mode 100755 index 00000000..0c1eb4da --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,179 @@ +#!/usr/bin/env uv run --script +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "click>=8.1.8", +# "tomlkit>=0.13.2" +# ] +# /// +import sys +import re +import click +from pathlib import Path +import json +import tomlkit +import datetime +import subprocess +from enum import Enum +from typing import Any, NewType + + +Version = NewType("Version", str) +GitHash = NewType("GitHash", str) + + +class GitHashParamType(click.ParamType): + name = "git_hash" + + def convert( + self, value: Any, param: click.Parameter | None, ctx: click.Context | None + ) -> GitHash | None: + if value is None: + return None + + if not (8 <= len(value) <= 40): + self.fail(f"Git hash must be between 8 and 40 characters, got {len(value)}") + + if not re.match(r"^[0-9a-fA-F]+$", value): + self.fail("Git hash must contain only hex digits (0-9, a-f)") + + try: + # Verify hash exists in repo + subprocess.run( + ["git", "rev-parse", "--verify", value], check=True, capture_output=True + ) + except subprocess.CalledProcessError: + self.fail(f"Git hash {value} not found in repository") + + return GitHash(value.lower()) + + +GIT_HASH = GitHashParamType() + + +class PackageType(Enum): + NPM = 1 + PYPI = 2 + + @classmethod + def from_path(cls, directory: Path) -> "PackageType": + if (directory / "package.json").exists(): + return cls.NPM + elif (directory / "pyproject.toml").exists(): + return cls.PYPI + else: + raise Exception("No package.json or pyproject.toml found") + + +def get_changes(path: Path, git_hash: str) -> bool: + """Check if any files changed between current state and git hash""" + try: + output = subprocess.run( + ["git", "diff", "--name-only", git_hash, "--", path], + cwd=path, + check=True, + capture_output=True, + text=True, + ) + + changed_files = [Path(f) for f in output.stdout.splitlines()] + relevant_files = [f for f in changed_files if f.suffix in ['.py', '.ts']] + return len(relevant_files) >= 1 + except subprocess.CalledProcessError: + return False + + +def get_package_name(path: Path, pkg_type: PackageType) -> str: + """Get package name from package.json or pyproject.toml""" + match pkg_type: + case PackageType.NPM: + with open(path / "package.json", "rb") as f: + return json.load(f)["name"] + case PackageType.PYPI: + with open(path / "pyproject.toml") as f: + toml_data = tomlkit.parse(f.read()) + name = toml_data.get("project", {}).get("name") + if not name: + raise Exception("No name in pyproject.toml project section") + return str(name) + + +def generate_version() -> Version: + """Generate version based on current date""" + now = datetime.datetime.now() + return Version(f"{now.year}.{now.month}.{now.day}") + + +def publish_package( + path: Path, pkg_type: PackageType, version: Version, dry_run: bool = False +): + """Publish package based on type""" + try: + match pkg_type: + case PackageType.NPM: + # Update version in package.json + with open(path / "package.json", "rb+") as f: + data = json.load(f) + data["version"] = version + f.seek(0) + json.dump(data, f, indent=2) + f.truncate() + + if not dry_run: + # Publish to npm + subprocess.run(["npm", "publish"], cwd=path, check=True) + case PackageType.PYPI: + # Update version in pyproject.toml + with open(path / "pyproject.toml") as f: + data = tomlkit.parse(f.read()) + data["project"]["version"] = version + + with open(path / "pyproject.toml", "w") as f: + f.write(tomlkit.dumps(data)) + + if not dry_run: + # Build and publish to PyPI + subprocess.run(["uv", "build"], cwd=path, check=True) + subprocess.run( + ["uv", "publish", "--username", "__token__"], + cwd=path, + check=True, + ) + except Exception as e: + raise Exception(f"Failed to publish: {e}") from e + + +@click.command() +@click.argument("directory", type=click.Path(exists=True, path_type=Path)) +@click.argument("git_hash", type=GIT_HASH) +@click.option( + "--dry-run", is_flag=True, help="Update version numbers but don't publish" +) +def main(directory: Path, git_hash: GitHash, dry_run: bool) -> int: + """Release package if changes detected""" + # Detect package type + try: + path = directory.resolve(strict=True) + pkg_type = PackageType.from_path(path) + except Exception as e: + return 1 + + # Check for changes + if not get_changes(path, git_hash): + return 0 + + try: + # Generate version and publish + version = generate_version() + name = get_package_name(path, pkg_type) + + publish_package(path, pkg_type, version, dry_run) + if not dry_run: + click.echo(f"{name}@{version}") + return 0 + except Exception as e: + return 1 + + +if __name__ == "__main__": + sys.exit(main()) From 6d36b5a1ff49b091845ec141048b6f5e45cfdc84 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Mon, 13 Jan 2025 11:23:01 +0000 Subject: [PATCH 08/62] feat: add daily release check workflow --- .github/workflows/daily-release-check.yml | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/daily-release-check.yml diff --git a/.github/workflows/daily-release-check.yml b/.github/workflows/daily-release-check.yml new file mode 100644 index 00000000..135e76d7 --- /dev/null +++ b/.github/workflows/daily-release-check.yml @@ -0,0 +1,28 @@ +name: Daily Release Check + +on: + schedule: + - cron: '0 0 * * *' # Run at midnight UTC daily + workflow_dispatch: # Allow manual triggers + +jobs: + check-releases: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Need full history for git log + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install semver PyGithub rich toml click + + - name: Run release check + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python scripts/weekly-release.py --dry-run \ No newline at end of file From 0989068ef1d62f1eb44eb00089001fb3511337ad Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Mon, 13 Jan 2025 11:34:12 +0000 Subject: [PATCH 09/62] feat: enhance release automation with daily checks --- .github/workflows/daily-release-check.yml | 66 +++++++++++++++++------ scripts/release.py | 2 + 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/.github/workflows/daily-release-check.yml b/.github/workflows/daily-release-check.yml index 135e76d7..128fd557 100644 --- a/.github/workflows/daily-release-check.yml +++ b/.github/workflows/daily-release-check.yml @@ -2,27 +2,59 @@ name: Daily Release Check on: schedule: - - cron: '0 0 * * *' # Run at midnight UTC daily - workflow_dispatch: # Allow manual triggers + # Run every day at 9:00 UTC + - cron: '0 9 * * *' + # Allow manual trigger for testing + workflow_dispatch: jobs: - check-releases: + prepare: runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + last_release: ${{ steps.last-release.outputs.hash }} steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 # Need full history for git log - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Install dependencies + fetch-depth: 0 + + - name: Find package directories + id: set-matrix run: | - python -m pip install semver PyGithub rich toml click - - - name: Run release check - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: python scripts/weekly-release.py --dry-run \ No newline at end of file + DIRS=$(git ls-tree -r HEAD --name-only | grep -E "package.json|pyproject.toml" | xargs dirname | grep -v "^.$" | jq -R -s -c 'split("\n")[:-1]') + echo "matrix=${DIRS}" >> $GITHUB_OUTPUT + + - name: Get last release hash + id: last-release + run: | + HASH=$(git rev-list --tags --max-count=1 || echo "HEAD~1") + echo "hash=${HASH}" >> $GITHUB_OUTPUT + + check-release: + needs: prepare + runs-on: ubuntu-latest + strategy: + matrix: + directory: ${{ fromJson(needs.prepare.outputs.matrix) }} + fail-fast: false + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: astral-sh/setup-uv@v5 + + - name: Setup Node.js + if: endsWith(matrix.directory, 'package.json') + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Setup Python + if: endsWith(matrix.directory, 'pyproject.toml') + run: uv python install + + - name: Check release + run: | + uv run --script scripts/release.py --dry-run "${{ matrix.directory }}" "${{ needs.prepare.outputs.last_release }}" diff --git a/scripts/release.py b/scripts/release.py index 0c1eb4da..0853d36e 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -170,6 +170,8 @@ def main(directory: Path, git_hash: GitHash, dry_run: bool) -> int: publish_package(path, pkg_type, version, dry_run) if not dry_run: click.echo(f"{name}@{version}") + else: + click.echo(f"🔍 Dry run: Would have published {name}@{version} if this was a real release") return 0 except Exception as e: return 1 From 9db47b20e7a7f39be4d30f27c756a8a0cc433a42 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Mon, 13 Jan 2025 11:35:46 +0000 Subject: [PATCH 10/62] feat: rename --- .../{daily-release-check.yml => release-check.yml} | 3 --- .github/workflows/{weekly-release.yml => release.yml} | 6 +++--- 2 files changed, 3 insertions(+), 6 deletions(-) rename .github/workflows/{daily-release-check.yml => release-check.yml} (95%) rename .github/workflows/{weekly-release.yml => release.yml} (97%) diff --git a/.github/workflows/daily-release-check.yml b/.github/workflows/release-check.yml similarity index 95% rename from .github/workflows/daily-release-check.yml rename to .github/workflows/release-check.yml index 128fd557..5350157b 100644 --- a/.github/workflows/daily-release-check.yml +++ b/.github/workflows/release-check.yml @@ -1,9 +1,6 @@ name: Daily Release Check on: - schedule: - # Run every day at 9:00 UTC - - cron: '0 9 * * *' # Allow manual trigger for testing workflow_dispatch: diff --git a/.github/workflows/weekly-release.yml b/.github/workflows/release.yml similarity index 97% rename from .github/workflows/weekly-release.yml rename to .github/workflows/release.yml index e4952bd4..252c89fd 100644 --- a/.github/workflows/weekly-release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,9 @@ -name: Weekly Release +name: Daily Release on: schedule: - # Run every Monday at 9:00 UTC - - cron: '0 9 * * 1' + # Run every day at 9:00 UTC + - cron: '0 9 * * *' # Allow manual trigger for testing workflow_dispatch: From b3f20decdc453f34c2f0733d60e2fcbed641763e Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Mon, 13 Jan 2025 11:38:25 +0000 Subject: [PATCH 11/62] feat: fix workflow names --- .github/workflows/release-check.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index 5350157b..30ff1f09 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -1,4 +1,4 @@ -name: Daily Release Check +name: Release Check on: # Allow manual trigger for testing diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 252c89fd..45f3aadd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Daily Release +name: Release on: schedule: From c9b1adf3b6a690cd9f8faa046354eabe8da49481 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Mon, 13 Jan 2025 11:47:41 +0000 Subject: [PATCH 12/62] use environments --- .github/workflows/release-check.yml | 25 ++++++++++++++++++++++++- .github/workflows/release.yml | 1 + 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index 30ff1f09..d4fc9ed1 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -54,4 +54,27 @@ jobs: - name: Check release run: | - uv run --script scripts/release.py --dry-run "${{ matrix.directory }}" "${{ needs.prepare.outputs.last_release }}" + uv run --script scripts/release.py --dry-run "${{ matrix.directory }}" "${{ needs.prepare.outputs.last_release }}" | tee -a "$GITHUB_OUTPUT" + + check-tag: + needs: [prepare, check-release] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Simulate tag creation + run: | + if [ -s "$GITHUB_OUTPUT" ]; then + DATE=$(date +%Y.%m.%d) + echo "🔍 Dry run: Would create tag v${DATE} if this was a real release" + + echo "# Release ${DATE}" > notes.md + echo "" >> notes.md + echo "## Updated Packages" >> notes.md + while IFS= read -r line; do + echo "- ${line}" >> notes.md + done < "$GITHUB_OUTPUT" + + echo "🔍 Would create release with following notes:" + cat notes.md + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 45f3aadd..d2d808b4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,6 +33,7 @@ jobs: release: needs: prepare runs-on: ubuntu-latest + environment: release strategy: matrix: directory: ${{ fromJson(needs.prepare.outputs.matrix) }} From 9f86ee599876c5540444bacd77cdc2ebe7dfb06e Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Mon, 13 Jan 2025 13:01:46 +0000 Subject: [PATCH 13/62] fix: fix scripts --- .github/workflows/release-check.yml | 18 ++++++++++++++---- .github/workflows/release.yml | 2 +- scripts/release.py | 2 +- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index d4fc9ed1..005f460c 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -30,6 +30,8 @@ jobs: check-release: needs: prepare runs-on: ubuntu-latest + outputs: + release: ${{ steps.check.outputs.release }} strategy: matrix: directory: ${{ fromJson(needs.prepare.outputs.matrix) }} @@ -53,8 +55,15 @@ jobs: run: uv python install - name: Check release + id: check run: | - uv run --script scripts/release.py --dry-run "${{ matrix.directory }}" "${{ needs.prepare.outputs.last_release }}" | tee -a "$GITHUB_OUTPUT" + output=$(uv run --script scripts/release.py --dry-run "${{ matrix.directory }}" "${{ needs.prepare.outputs.last_release }}" \ + | grep -o -E "[a-zA-Z0-9\-]+@[0-9]+\.[0-9]+\.[0-9]+" || true) + if [ ! -z "$output" ]; then + echo "release<> $GITHUB_OUTPUT + echo "$output" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + fi check-tag: needs: [prepare, check-release] @@ -64,7 +73,8 @@ jobs: - name: Simulate tag creation run: | - if [ -s "$GITHUB_OUTPUT" ]; then + echo "${{ needs.check-release.outputs.release }}" > packages.txt + if [ -s packages.txt ]; then DATE=$(date +%Y.%m.%d) echo "🔍 Dry run: Would create tag v${DATE} if this was a real release" @@ -72,8 +82,8 @@ jobs: echo "" >> notes.md echo "## Updated Packages" >> notes.md while IFS= read -r line; do - echo "- ${line}" >> notes.md - done < "$GITHUB_OUTPUT" + echo "- $line" >> notes.md + done < packages.txt echo "🔍 Would create release with following notes:" cat notes.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d2d808b4..d2d084de 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,7 +64,7 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} - run: uv run --script scripts/release.py "${{ matrix.directory }}" "${{ needs.prepare.outputs.last_release }}" >> "$GITHUB_OUTPUT" + run: uv run --script scripts/release.py "${{ matrix.directory }}" "${{ needs.prepare.outputs.last_release }}" >> "$GITHUB_OUTPUT" create-release: needs: [prepare, release] diff --git a/scripts/release.py b/scripts/release.py index 0853d36e..17329560 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -171,7 +171,7 @@ def main(directory: Path, git_hash: GitHash, dry_run: bool) -> int: if not dry_run: click.echo(f"{name}@{version}") else: - click.echo(f"🔍 Dry run: Would have published {name}@{version} if this was a real release") + click.echo(f"Dry run: Would have published {name}@{version}") return 0 except Exception as e: return 1 From 111806d6ae1cc80655cfef0d37ce5b7e0be35fed Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Mon, 13 Jan 2025 14:33:22 +0000 Subject: [PATCH 14/62] feat: use artifcats to collect mutliple job outputs into one A matrix job cant have multiple outputs directly, see https://github.com/orgs/community/discussions/17245. Use the artifact workaround. --- .github/workflows/release-check.yml | 113 ++++++++++++++++++++++------ 1 file changed, 91 insertions(+), 22 deletions(-) diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index 005f460c..63d7dfa0 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -18,20 +18,21 @@ jobs: - name: Find package directories id: set-matrix run: | + # Find all package.json and pyproject.toml files, excluding root DIRS=$(git ls-tree -r HEAD --name-only | grep -E "package.json|pyproject.toml" | xargs dirname | grep -v "^.$" | jq -R -s -c 'split("\n")[:-1]') echo "matrix=${DIRS}" >> $GITHUB_OUTPUT + echo "Found directories: ${DIRS}" - name: Get last release hash id: last-release run: | HASH=$(git rev-list --tags --max-count=1 || echo "HEAD~1") echo "hash=${HASH}" >> $GITHUB_OUTPUT + echo "Using last release hash: ${HASH}" check-release: needs: prepare runs-on: ubuntu-latest - outputs: - release: ${{ steps.check.outputs.release }} strategy: matrix: directory: ${{ fromJson(needs.prepare.outputs.matrix) }} @@ -45,46 +46,114 @@ jobs: - uses: astral-sh/setup-uv@v5 - name: Setup Node.js - if: endsWith(matrix.directory, 'package.json') + if: endsWith(matrix.directory, '/package.json') uses: actions/setup-node@v4 with: node-version: '18' - name: Setup Python - if: endsWith(matrix.directory, 'pyproject.toml') + if: endsWith(matrix.directory, '/pyproject.toml') run: uv python install - name: Check release id: check run: | - output=$(uv run --script scripts/release.py --dry-run "${{ matrix.directory }}" "${{ needs.prepare.outputs.last_release }}" \ - | grep -o -E "[a-zA-Z0-9\-]+@[0-9]+\.[0-9]+\.[0-9]+" || true) - if [ ! -z "$output" ]; then - echo "release<> $GITHUB_OUTPUT - echo "$output" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT + # Create unique hash for this directory + dir_hash=$(echo "${{ matrix.directory }}" | sha256sum | awk '{print $1}') + + # Run release check script with verbose output + echo "Running release check against last release: ${{ needs.prepare.outputs.last_release }}" + + # Run git diff first to show changes + echo "Changes since last release:" + git diff --name-only "${{ needs.prepare.outputs.last_release }}" -- "${{ matrix.directory }}" || true + + # Run the release check + output=$(uv run --script scripts/release.py --dry-run "${{ matrix.directory }}" "${{ needs.prepare.outputs.last_release }}" 2>&1) + exit_code=$? + + echo "Release check output (exit code: $exit_code):" + echo "$output" + + # Extract package info if successful + if [ $exit_code -eq 0 ]; then + pkg_info=$(echo "$output" | grep -o -E "[a-zA-Z0-9\-]+@[0-9]+\.[0-9]+\.[0-9]+" || true) + else + echo "Release check failed" + exit 1 fi + if [ ! -z "$pkg_info" ]; then + echo "Found package that needs release: $pkg_info" + + # Create outputs directory + mkdir -p ./outputs + + # Save both package info and full changes + echo "$pkg_info" > "./outputs/${dir_hash}_info" + echo "dir_hash=${dir_hash}" >> $GITHUB_OUTPUT + + # Log what we're saving + echo "Saved package info to ./outputs/${dir_hash}_info:" + cat "./outputs/${dir_hash}_info" + else + echo "No release needed for this package" + fi + + - name: Set artifact name + if: steps.check.outputs.dir_hash + id: artifact + run: | + # Replace forward slashes with dashes + SAFE_DIR=$(echo "${{ matrix.directory }}" | tr '/' '-') + echo "name=release-outputs-${SAFE_DIR}" >> $GITHUB_OUTPUT + + - uses: actions/upload-artifact@v4 + if: steps.check.outputs.dir_hash + with: + name: ${{ steps.artifact.outputs.name }} + path: ./outputs/${{ steps.check.outputs.dir_hash }}* + check-tag: needs: [prepare, check-release] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + pattern: release-outputs-src-* + merge-multiple: true + path: outputs + - name: Simulate tag creation run: | - echo "${{ needs.check-release.outputs.release }}" > packages.txt - if [ -s packages.txt ]; then - DATE=$(date +%Y.%m.%d) - echo "🔍 Dry run: Would create tag v${DATE} if this was a real release" + if [ -d outputs ]; then + # Collect package info + find outputs -name "*_info" -exec cat {} \; > packages.txt - echo "# Release ${DATE}" > notes.md - echo "" >> notes.md - echo "## Updated Packages" >> notes.md - while IFS= read -r line; do - echo "- $line" >> notes.md - done < packages.txt + if [ -s packages.txt ]; then + DATE=$(date +%Y.%m.%d) + echo "🔍 Dry run: Would create tag v${DATE} if this was a real release" - echo "🔍 Would create release with following notes:" - cat notes.md + # Generate comprehensive release notes + { + echo "# Release ${DATE}" + echo "" + echo "## Updated Packages" + while IFS= read -r line; do + echo "- $line" + done < packages.txt + } > notes.md + + echo "🔍 Would create release with following notes:" + cat notes.md + + echo "🔍 Would create tag v${DATE} with the above release notes" + echo "🔍 Would create GitHub release from tag v${DATE}" + else + echo "No packages need release" + fi + else + echo "No release artifacts found" fi From cecd241500e0174761f8daf4dc98fd15cbbffe02 Mon Sep 17 00:00:00 2001 From: Peter M Elias Date: Fri, 27 Dec 2024 18:14:37 -0800 Subject: [PATCH 15/62] fix github PR schemas --- src/github/schemas.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/github/schemas.ts b/src/github/schemas.ts index 0a322328..dca82777 100644 --- a/src/github/schemas.ts +++ b/src/github/schemas.ts @@ -38,19 +38,25 @@ export const GitHubRepositorySchema = z.object({ default_branch: z.string(), }); +const GithubFileContentLinks = z.object({ + self: z.string(), + git:z.number().nullable(), + html: z.string().nullable() +}); + // File content schemas export const GitHubFileContentSchema = z.object({ type: z.string(), - encoding: z.string(), size: z.number(), name: z.string(), path: z.string(), - content: z.string(), + content: z.string().nullable(), sha: z.string(), url: z.string(), git_url: z.string(), html_url: z.string(), download_url: z.string(), + _links: GithubFileContentLinks }); export const GitHubDirectoryContentSchema = z.object({ From 59b831f3267d2a0beb49a5faec77aa806ef77f22 Mon Sep 17 00:00:00 2001 From: Peter M Elias Date: Fri, 27 Dec 2024 23:28:54 -0800 Subject: [PATCH 16/62] fix github getfilecontent zod schema to match readme spec --- src/github/schemas.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/github/schemas.ts b/src/github/schemas.ts index dca82777..24a1ef49 100644 --- a/src/github/schemas.ts +++ b/src/github/schemas.ts @@ -367,6 +367,8 @@ export const CreateRepositorySchema = z.object({ }); export const GetFileContentsSchema = RepoParamsSchema.extend({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), path: z.string().describe("Path to the file or directory"), branch: z.string().optional().describe("Branch to get contents from"), }); From 90265c27d2b8ab7c5c2a3848cb295990ab4fda2b Mon Sep 17 00:00:00 2001 From: Peter M Elias Date: Fri, 27 Dec 2024 23:53:39 -0800 Subject: [PATCH 17/62] schema tweaks --- src/github/schemas.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/github/schemas.ts b/src/github/schemas.ts index 24a1ef49..434ad08b 100644 --- a/src/github/schemas.ts +++ b/src/github/schemas.ts @@ -40,7 +40,7 @@ export const GitHubRepositorySchema = z.object({ const GithubFileContentLinks = z.object({ self: z.string(), - git:z.number().nullable(), + git: z.number().nullable(), html: z.string().nullable() }); @@ -50,7 +50,7 @@ export const GitHubFileContentSchema = z.object({ size: z.number(), name: z.string(), path: z.string(), - content: z.string().nullable(), + content: z.string(), sha: z.string(), url: z.string(), git_url: z.string(), @@ -367,8 +367,6 @@ export const CreateRepositorySchema = z.object({ }); export const GetFileContentsSchema = RepoParamsSchema.extend({ - owner: z.string().describe("Repository owner (username or organization)"), - repo: z.string().describe("Repository name"), path: z.string().describe("Path to the file or directory"), branch: z.string().optional().describe("Branch to get contents from"), }); From a56242dfdc61d82f65805d995b64df5c1ef74ba3 Mon Sep 17 00:00:00 2001 From: Peter M Elias Date: Sat, 28 Dec 2024 00:06:24 -0800 Subject: [PATCH 18/62] more schema fixes --- src/github/schemas.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/github/schemas.ts b/src/github/schemas.ts index 434ad08b..c361608f 100644 --- a/src/github/schemas.ts +++ b/src/github/schemas.ts @@ -46,16 +46,17 @@ const GithubFileContentLinks = z.object({ // File content schemas export const GitHubFileContentSchema = z.object({ - type: z.string(), - size: z.number(), name: z.string(), path: z.string(), - content: z.string(), sha: z.string(), + size: z.number(), url: z.string(), - git_url: z.string(), html_url: z.string(), + git_url: z.string(), download_url: z.string(), + type: z.string(), + content: z.string(), + encoding: z.string().nullable(), _links: GithubFileContentLinks }); From d9ae0911b9034103e5959ff16fb6290ca3ec371b Mon Sep 17 00:00:00 2001 From: Peter M Elias Date: Sat, 28 Dec 2024 00:13:29 -0800 Subject: [PATCH 19/62] more schema fixes --- src/github/schemas.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/github/schemas.ts b/src/github/schemas.ts index c361608f..d7b45bd4 100644 --- a/src/github/schemas.ts +++ b/src/github/schemas.ts @@ -56,7 +56,7 @@ export const GitHubFileContentSchema = z.object({ download_url: z.string(), type: z.string(), content: z.string(), - encoding: z.string().nullable(), + encoding: z.string().optional(), _links: GithubFileContentLinks }); From f4122ff231fa3d07d497d082dd2e43374eb8c54b Mon Sep 17 00:00:00 2001 From: Peter M Elias Date: Sat, 28 Dec 2024 01:11:04 -0800 Subject: [PATCH 20/62] more fixes --- src/github/index.ts | 9 +-------- src/github/schemas.ts | 2 +- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/github/index.ts b/src/github/index.ts index 3759e8b5..d2952797 100644 --- a/src/github/index.ts +++ b/src/github/index.ts @@ -1016,14 +1016,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } } catch (error) { if (error instanceof z.ZodError) { - throw new Error( - `Invalid arguments: ${error.errors - .map( - (e: z.ZodError["errors"][number]) => - `${e.path.join(".")}: ${e.message}` - ) - .join(", ")}` - ); + throw new Error(`ZodErrors: ${JSON.stringify(error.errors)}`) } throw error; } diff --git a/src/github/schemas.ts b/src/github/schemas.ts index d7b45bd4..61560cf8 100644 --- a/src/github/schemas.ts +++ b/src/github/schemas.ts @@ -40,7 +40,7 @@ export const GitHubRepositorySchema = z.object({ const GithubFileContentLinks = z.object({ self: z.string(), - git: z.number().nullable(), + git: z.string().nullable(), html: z.string().nullable() }); From 0ecd2049abf51e49383ab395418ecf7c6a0ed709 Mon Sep 17 00:00:00 2001 From: Peter M Elias Date: Sat, 28 Dec 2024 01:25:15 -0800 Subject: [PATCH 21/62] more 'fixes' --- src/github/schemas.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/github/schemas.ts b/src/github/schemas.ts index 61560cf8..d911104a 100644 --- a/src/github/schemas.ts +++ b/src/github/schemas.ts @@ -55,7 +55,7 @@ export const GitHubFileContentSchema = z.object({ git_url: z.string(), download_url: z.string(), type: z.string(), - content: z.string(), + content: z.string().optional(), encoding: z.string().optional(), _links: GithubFileContentLinks }); From 534b90cfe0b927edb53127e955c880d959bc7cf1 Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 01:40:57 -0800 Subject: [PATCH 22/62] Add common type definitions --- src/github/common/types.ts | 132 +++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 src/github/common/types.ts diff --git a/src/github/common/types.ts b/src/github/common/types.ts new file mode 100644 index 00000000..9be60a74 --- /dev/null +++ b/src/github/common/types.ts @@ -0,0 +1,132 @@ +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 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(), + }), +}); + +// Export types +export type GitHubAuthor = z.infer; +export type GitHubRepository = z.infer; +export type GitHubFileContent = z.infer; +export type GitHubDirectoryContent = z.infer; +export type GitHubContent = z.infer; +export type GitHubTree = z.infer; +export type GitHubCommit = z.infer; +export type GitHubReference = z.infer; \ No newline at end of file From ca2c6f93241bff6557a7d61d4026f25914ec75f4 Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 01:41:07 -0800 Subject: [PATCH 23/62] Add common utilities for GitHub API requests --- src/github/common/utils.ts | 46 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/github/common/utils.ts diff --git a/src/github/common/utils.ts b/src/github/common/utils.ts new file mode 100644 index 00000000..0e9e6526 --- /dev/null +++ b/src/github/common/utils.ts @@ -0,0 +1,46 @@ +import fetch from "node-fetch"; + +if (!process.env.GITHUB_PERSONAL_ACCESS_TOKEN) { + console.error("GITHUB_PERSONAL_ACCESS_TOKEN environment variable is not set"); + process.exit(1); +} + +export const GITHUB_PERSONAL_ACCESS_TOKEN = process.env.GITHUB_PERSONAL_ACCESS_TOKEN; + +interface GitHubRequestOptions { + method?: string; + body?: any; +} + +export async function githubRequest(url: string, options: GitHubRequestOptions = {}) { + const response = await fetch(url, { + method: options.method || "GET", + headers: { + Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "github-mcp-server", + ...(options.body ? { "Content-Type": "application/json" } : {}), + }, + ...(options.body ? { body: JSON.stringify(options.body) } : {}), + }); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.statusText}`); + } + + return response.json(); +} + +export function buildUrl(baseUrl: string, params: Record = {}) { + const url = new URL(baseUrl); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + if (Array.isArray(value)) { + url.searchParams.append(key, value.join(",")); + } else { + url.searchParams.append(key, value.toString()); + } + } + }); + return url.toString(); +} \ No newline at end of file From 150e9cc560baa97fe9c592595320dc95471fb5cd Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 01:41:21 -0800 Subject: [PATCH 24/62] Add repository operations module --- src/github/operations/repository.ts | 65 +++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/github/operations/repository.ts diff --git a/src/github/operations/repository.ts b/src/github/operations/repository.ts new file mode 100644 index 00000000..dfa7e263 --- /dev/null +++ b/src/github/operations/repository.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; +import { githubRequest } from "../common/utils"; +import { GitHubRepositorySchema, GitHubSearchResponseSchema } from "../common/types"; + +// Schema definitions +export const CreateRepositoryOptionsSchema = z.object({ + name: z.string().describe("Repository name"), + description: z.string().optional().describe("Repository description"), + private: z.boolean().optional().describe("Whether the repository should be private"), + autoInit: z.boolean().optional().describe("Initialize with README.md"), +}); + +export const SearchRepositoriesSchema = z.object({ + query: z.string().describe("Search query (see GitHub search syntax)"), + page: z.number().optional().describe("Page number for pagination (default: 1)"), + perPage: z.number().optional().describe("Number of results per page (default: 30, max: 100)"), +}); + +export const ForkRepositorySchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + organization: z.string().optional().describe("Optional: organization to fork to (defaults to your personal account)"), +}); + +// Type exports +export type CreateRepositoryOptions = z.infer; + +// Function implementations +export async function createRepository(options: CreateRepositoryOptions) { + const response = await githubRequest("https://api.github.com/user/repos", { + method: "POST", + body: options, + }); + return GitHubRepositorySchema.parse(response); +} + +export async function searchRepositories( + query: string, + page: number = 1, + perPage: number = 30 +) { + const url = new URL("https://api.github.com/search/repositories"); + url.searchParams.append("q", query); + url.searchParams.append("page", page.toString()); + url.searchParams.append("per_page", perPage.toString()); + + const response = await githubRequest(url.toString()); + return GitHubSearchResponseSchema.parse(response); +} + +export async function forkRepository( + owner: string, + repo: string, + organization?: string +) { + const url = organization + ? `https://api.github.com/repos/${owner}/${repo}/forks?organization=${organization}` + : `https://api.github.com/repos/${owner}/${repo}/forks`; + + const response = await githubRequest(url, { method: "POST" }); + return GitHubRepositorySchema.extend({ + parent: GitHubRepositorySchema, + source: GitHubRepositorySchema, + }).parse(response); +} \ No newline at end of file From ee874d7b5b08376fcf08f2c08344ccd14b26c822 Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 01:42:46 -0800 Subject: [PATCH 25/62] Add file operations module --- src/github/operations/files.ts | 193 +++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 src/github/operations/files.ts diff --git a/src/github/operations/files.ts b/src/github/operations/files.ts new file mode 100644 index 00000000..676e9374 --- /dev/null +++ b/src/github/operations/files.ts @@ -0,0 +1,193 @@ +import { z } from "zod"; +import { githubRequest } from "../common/utils"; +import { + GitHubContentSchema, + GitHubCreateUpdateFileResponseSchema, + GitHubTreeSchema, + GitHubCommitSchema, + GitHubReferenceSchema, +} from "../common/types"; + +// Schema definitions +export const FileOperationSchema = z.object({ + path: z.string(), + content: z.string(), +}); + +export const CreateOrUpdateFileSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + path: z.string().describe("Path where to create/update the file"), + content: z.string().describe("Content of the file"), + message: z.string().describe("Commit message"), + branch: z.string().describe("Branch to create/update the file in"), + sha: z.string().optional().describe("SHA of the file being replaced (required when updating existing files)"), +}); + +export const GetFileContentsSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + path: z.string().describe("Path to the file or directory"), + branch: z.string().optional().describe("Branch to get contents from"), +}); + +export const PushFilesSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + branch: z.string().describe("Branch to push to (e.g., 'main' or 'master')"), + files: z.array(FileOperationSchema).describe("Array of files to push"), + message: z.string().describe("Commit message"), +}); + +// Type exports +export type FileOperation = z.infer; + +// Function implementations +export async function getFileContents( + owner: string, + repo: string, + path: string, + branch?: string +) { + let url = `https://api.github.com/repos/${owner}/${repo}/contents/${path}`; + if (branch) { + url += `?ref=${branch}`; + } + + const response = await githubRequest(url); + const data = GitHubContentSchema.parse(response); + + // If it's a file, decode the content + if (!Array.isArray(data) && data.content) { + data.content = Buffer.from(data.content, "base64").toString("utf8"); + } + + return data; +} + +export async function createOrUpdateFile( + owner: string, + repo: string, + path: string, + content: string, + message: string, + branch: string, + sha?: string +) { + const encodedContent = Buffer.from(content).toString("base64"); + + let currentSha = sha; + if (!currentSha) { + try { + const existingFile = await getFileContents(owner, repo, path, branch); + if (!Array.isArray(existingFile)) { + currentSha = existingFile.sha; + } + } catch (error) { + console.error("Note: File does not exist in branch, will create new file"); + } + } + + const url = `https://api.github.com/repos/${owner}/${repo}/contents/${path}`; + const body = { + message, + content: encodedContent, + branch, + ...(currentSha ? { sha: currentSha } : {}), + }; + + const response = await githubRequest(url, { + method: "PUT", + body, + }); + + return GitHubCreateUpdateFileResponseSchema.parse(response); +} + +async function createTree( + owner: string, + repo: string, + files: FileOperation[], + baseTree?: string +) { + const tree = files.map((file) => ({ + path: file.path, + mode: "100644" as const, + type: "blob" as const, + content: file.content, + })); + + const response = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/git/trees`, + { + method: "POST", + body: { + tree, + base_tree: baseTree, + }, + } + ); + + return GitHubTreeSchema.parse(response); +} + +async function createCommit( + owner: string, + repo: string, + message: string, + tree: string, + parents: string[] +) { + const response = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/git/commits`, + { + method: "POST", + body: { + message, + tree, + parents, + }, + } + ); + + return GitHubCommitSchema.parse(response); +} + +async function updateReference( + owner: string, + repo: string, + ref: string, + sha: string +) { + const response = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/git/refs/${ref}`, + { + method: "PATCH", + body: { + sha, + force: true, + }, + } + ); + + return GitHubReferenceSchema.parse(response); +} + +export async function pushFiles( + owner: string, + repo: string, + branch: string, + files: FileOperation[], + message: string +) { + const refResponse = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}` + ); + + const ref = GitHubReferenceSchema.parse(refResponse); + const commitSha = ref.object.sha; + + const tree = await createTree(owner, repo, files, commitSha); + const commit = await createCommit(owner, repo, message, tree.sha, [commitSha]); + return await updateReference(owner, repo, `heads/${branch}`, commit.sha); +} \ No newline at end of file From 2218a0f442e29bd9f274221efe3269688b571a18 Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 01:43:04 -0800 Subject: [PATCH 26/62] Add issues operations module --- src/github/operations/issues.ts | 151 ++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 src/github/operations/issues.ts diff --git a/src/github/operations/issues.ts b/src/github/operations/issues.ts new file mode 100644 index 00000000..e489e747 --- /dev/null +++ b/src/github/operations/issues.ts @@ -0,0 +1,151 @@ +import { z } from "zod"; +import { githubRequest, buildUrl } from "../common/utils"; +import { + GitHubIssueSchema, + GitHubLabelSchema, + GitHubIssueAssigneeSchema, + GitHubMilestoneSchema, +} from "../common/types"; + +// Schema definitions +export const CreateIssueOptionsSchema = z.object({ + title: z.string().describe("Issue title"), + body: z.string().optional().describe("Issue body/description"), + assignees: z.array(z.string()).optional().describe("Array of usernames to assign"), + milestone: z.number().optional().describe("Milestone number to assign"), + labels: z.array(z.string()).optional().describe("Array of label names"), +}); + +export const CreateIssueSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + title: z.string().describe("Issue title"), + body: z.string().optional().describe("Issue body/description"), + assignees: z.array(z.string()).optional().describe("Array of usernames to assign"), + labels: z.array(z.string()).optional().describe("Array of label names"), + milestone: z.number().optional().describe("Milestone number to assign"), +}); + +export const ListIssuesOptionsSchema = z.object({ + owner: z.string(), + repo: z.string(), + state: z.enum(['open', 'closed', 'all']).optional(), + labels: z.array(z.string()).optional(), + sort: z.enum(['created', 'updated', 'comments']).optional(), + direction: z.enum(['asc', 'desc']).optional(), + since: z.string().optional(), // ISO 8601 timestamp + page: z.number().optional(), + per_page: z.number().optional() +}); + +export const UpdateIssueOptionsSchema = z.object({ + owner: z.string(), + repo: z.string(), + issue_number: z.number(), + title: z.string().optional(), + body: z.string().optional(), + state: z.enum(['open', 'closed']).optional(), + labels: z.array(z.string()).optional(), + assignees: z.array(z.string()).optional(), + milestone: z.number().optional() +}); + +export const IssueCommentSchema = z.object({ + owner: z.string(), + repo: z.string(), + issue_number: z.number(), + body: z.string() +}); + +export const GetIssueSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + issue_number: z.number().describe("Issue number") +}); + +// Type exports +export type CreateIssueOptions = z.infer; +export type ListIssuesOptions = z.infer; +export type UpdateIssueOptions = z.infer; + +// Function implementations +export async function createIssue( + owner: string, + repo: string, + options: CreateIssueOptions +) { + const response = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/issues`, + { + method: "POST", + body: options, + } + ); + + return GitHubIssueSchema.parse(response); +} + +export async function listIssues( + owner: string, + repo: string, + options: Omit +) { + const url = buildUrl(`https://api.github.com/repos/${owner}/${repo}/issues`, options); + const response = await githubRequest(url); + return z.array(GitHubIssueSchema).parse(response); +} + +export async function updateIssue( + owner: string, + repo: string, + issueNumber: number, + options: Omit +) { + const response = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}`, + { + method: "PATCH", + body: options + } + ); + + return GitHubIssueSchema.parse(response); +} + +export async function addIssueComment( + owner: string, + repo: string, + issueNumber: number, + body: string +) { + const response = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}/comments`, + { + method: "POST", + body: { body } + } + ); + + return z.object({ + id: z.number(), + node_id: z.string(), + url: z.string(), + html_url: z.string(), + body: z.string(), + user: GitHubIssueAssigneeSchema, + created_at: z.string(), + updated_at: z.string(), + }).parse(response); +} + +export async function getIssue( + owner: string, + repo: string, + issueNumber: number +) { + const response = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}` + ); + + return GitHubIssueSchema.parse(response); +} \ No newline at end of file From d751289f9cb09b2e8707afee62973ce4960469fc Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 01:43:46 -0800 Subject: [PATCH 27/62] Add branches operations module --- src/github/operations/branches.ts | 112 ++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 src/github/operations/branches.ts diff --git a/src/github/operations/branches.ts b/src/github/operations/branches.ts new file mode 100644 index 00000000..4690ef24 --- /dev/null +++ b/src/github/operations/branches.ts @@ -0,0 +1,112 @@ +import { z } from "zod"; +import { githubRequest } from "../common/utils"; +import { GitHubReferenceSchema } from "../common/types"; + +// Schema definitions +export const CreateBranchOptionsSchema = z.object({ + ref: z.string(), + sha: z.string(), +}); + +export const CreateBranchSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + branch: z.string().describe("Name for the new branch"), + from_branch: z.string().optional().describe("Optional: source branch to create from (defaults to the repository's default branch)"), +}); + +// Type exports +export type CreateBranchOptions = z.infer; + +// Function implementations +export async function getDefaultBranchSHA(owner: string, repo: string): Promise { + try { + const response = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/main` + ); + const data = GitHubReferenceSchema.parse(response); + return data.object.sha; + } catch (error) { + const masterResponse = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/master` + ); + if (!masterResponse) { + throw new Error("Could not find default branch (tried 'main' and 'master')"); + } + const data = GitHubReferenceSchema.parse(masterResponse); + return data.object.sha; + } +} + +export async function createBranch( + owner: string, + repo: string, + options: CreateBranchOptions +): Promise> { + const fullRef = `refs/heads/${options.ref}`; + + const response = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/git/refs`, + { + method: "POST", + body: { + ref: fullRef, + sha: options.sha, + }, + } + ); + + return GitHubReferenceSchema.parse(response); +} + +export async function getBranchSHA( + owner: string, + repo: string, + branch: string +): Promise { + const response = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}` + ); + + const data = GitHubReferenceSchema.parse(response); + return data.object.sha; +} + +export async function createBranchFromRef( + owner: string, + repo: string, + newBranch: string, + fromBranch?: string +): Promise> { + let sha: string; + if (fromBranch) { + sha = await getBranchSHA(owner, repo, fromBranch); + } else { + sha = await getDefaultBranchSHA(owner, repo); + } + + return createBranch(owner, repo, { + ref: newBranch, + sha, + }); +} + +export async function updateBranch( + owner: string, + repo: string, + branch: string, + sha: string +): Promise> { + const response = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`, + { + method: "PATCH", + body: { + sha, + force: true, + }, + } + ); + + return GitHubReferenceSchema.parse(response); +} \ No newline at end of file From 6fdfeebdbe1f1929a3b535eb0e6e0c67ad8dac04 Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 01:44:00 -0800 Subject: [PATCH 28/62] Add pull request operations module --- src/github/operations/pulls.ts | 80 ++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 src/github/operations/pulls.ts diff --git a/src/github/operations/pulls.ts b/src/github/operations/pulls.ts new file mode 100644 index 00000000..2733a597 --- /dev/null +++ b/src/github/operations/pulls.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; +import { githubRequest } from "../common/utils"; +import { GitHubPullRequestSchema } from "../common/types"; + +// Schema definitions +export const CreatePullRequestOptionsSchema = z.object({ + title: z.string().describe("Pull request title"), + body: z.string().optional().describe("Pull request body/description"), + head: z.string().describe("The name of the branch where your changes are implemented"), + base: z.string().describe("The name of the branch you want the changes pulled into"), + maintainer_can_modify: z.boolean().optional().describe("Whether maintainers can modify the pull request"), + draft: z.boolean().optional().describe("Whether to create the pull request as a draft"), +}); + +export const CreatePullRequestSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + title: z.string().describe("Pull request title"), + body: z.string().optional().describe("Pull request body/description"), + head: z.string().describe("The name of the branch where your changes are implemented"), + base: z.string().describe("The name of the branch you want the changes pulled into"), + draft: z.boolean().optional().describe("Whether to create the pull request as a draft"), + maintainer_can_modify: z.boolean().optional().describe("Whether maintainers can modify the pull request"), +}); + +// Type exports +export type CreatePullRequestOptions = z.infer; + +// Function implementations +export async function createPullRequest( + owner: string, + repo: string, + options: CreatePullRequestOptions +): Promise> { + const response = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/pulls`, + { + method: "POST", + body: options, + } + ); + + return GitHubPullRequestSchema.parse(response); +} + +export async function getPullRequest( + owner: string, + repo: string, + pullNumber: number +): Promise> { + const response = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}` + ); + + return GitHubPullRequestSchema.parse(response); +} + +export async function listPullRequests( + owner: string, + repo: string, + options: { + state?: "open" | "closed" | "all"; + head?: string; + base?: string; + sort?: "created" | "updated" | "popularity" | "long-running"; + direction?: "asc" | "desc"; + per_page?: number; + page?: number; + } = {} +): Promise[]> { + const url = new URL(`https://api.github.com/repos/${owner}/${repo}/pulls`); + Object.entries(options).forEach(([key, value]) => { + if (value !== undefined) { + url.searchParams.append(key, value.toString()); + } + }); + + const response = await githubRequest(url.toString()); + return z.array(GitHubPullRequestSchema).parse(response); +} \ No newline at end of file From 7a89bd5f08362d460cbe0d7910306a277a668bff Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 01:44:19 -0800 Subject: [PATCH 29/62] Add search operations module --- src/github/operations/search.ts | 104 ++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 src/github/operations/search.ts diff --git a/src/github/operations/search.ts b/src/github/operations/search.ts new file mode 100644 index 00000000..e7aab148 --- /dev/null +++ b/src/github/operations/search.ts @@ -0,0 +1,104 @@ +import { z } from "zod"; +import { githubRequest, buildUrl } from "../common/utils"; + +// Schema definitions +export const SearchCodeSchema = z.object({ + q: z.string().describe("Search query. See GitHub code search syntax: https://docs.github.com/en/search-github/searching-on-github/searching-code"), + order: z.enum(["asc", "desc"]).optional().describe("Sort order (asc or desc)"), + per_page: z.number().min(1).max(100).optional().describe("Results per page (max 100)"), + page: z.number().min(1).optional().describe("Page number"), +}); + +export const SearchIssuesSchema = z.object({ + q: z.string().describe("Search query. See GitHub issues search syntax: https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests"), + sort: z.enum([ + "comments", + "reactions", + "reactions-+1", + "reactions--1", + "reactions-smile", + "reactions-thinking_face", + "reactions-heart", + "reactions-tada", + "interactions", + "created", + "updated", + ]).optional().describe("Sort field"), + order: z.enum(["asc", "desc"]).optional().describe("Sort order (asc or desc)"), + per_page: z.number().min(1).max(100).optional().describe("Results per page (max 100)"), + page: z.number().min(1).optional().describe("Page number"), +}); + +export const SearchUsersSchema = z.object({ + q: z.string().describe("Search query. See GitHub users search syntax: https://docs.github.com/en/search-github/searching-on-github/searching-users"), + sort: z.enum(["followers", "repositories", "joined"]).optional().describe("Sort field"), + order: z.enum(["asc", "desc"]).optional().describe("Sort order (asc or desc)"), + per_page: z.number().min(1).max(100).optional().describe("Results per page (max 100)"), + page: z.number().min(1).optional().describe("Page number"), +}); + +// Response schemas +export const SearchCodeItemSchema = z.object({ + name: z.string().describe("The name of the file"), + path: z.string().describe("The path to the file in the repository"), + sha: z.string().describe("The SHA hash of the file"), + url: z.string().describe("The API URL for this file"), + git_url: z.string().describe("The Git URL for this file"), + html_url: z.string().describe("The HTML URL to view this file on GitHub"), + repository: z.object({ + full_name: z.string(), + description: z.string().nullable(), + url: z.string(), + html_url: z.string(), + }).describe("The repository where this file was found"), + score: z.number().describe("The search result score"), +}); + +export const SearchCodeResponseSchema = z.object({ + total_count: z.number().describe("Total number of matching results"), + incomplete_results: z.boolean().describe("Whether the results are incomplete"), + items: z.array(SearchCodeItemSchema).describe("The search results"), +}); + +export const SearchUsersResponseSchema = z.object({ + total_count: z.number().describe("Total number of matching results"), + incomplete_results: z.boolean().describe("Whether the results are incomplete"), + items: z.array(z.object({ + login: z.string().describe("The username of the user"), + id: z.number().describe("The ID of the user"), + node_id: z.string().describe("The Node ID of the user"), + avatar_url: z.string().describe("The avatar URL of the user"), + gravatar_id: z.string().describe("The Gravatar ID of the user"), + url: z.string().describe("The API URL for this user"), + html_url: z.string().describe("The HTML URL to view this user on GitHub"), + type: z.string().describe("The type of this user"), + site_admin: z.boolean().describe("Whether this user is a site administrator"), + score: z.number().describe("The search result score"), + })).describe("The search results"), +}); + +// Type exports +export type SearchCodeParams = z.infer; +export type SearchIssuesParams = z.infer; +export type SearchUsersParams = z.infer; +export type SearchCodeResponse = z.infer; +export type SearchUsersResponse = z.infer; + +// Function implementations +export async function searchCode(params: SearchCodeParams): Promise { + const url = buildUrl("https://api.github.com/search/code", params); + const response = await githubRequest(url); + return SearchCodeResponseSchema.parse(response); +} + +export async function searchIssues(params: SearchIssuesParams) { + const url = buildUrl("https://api.github.com/search/issues", params); + const response = await githubRequest(url); + return response; +} + +export async function searchUsers(params: SearchUsersParams): Promise { + const url = buildUrl("https://api.github.com/search/users", params); + const response = await githubRequest(url); + return SearchUsersResponseSchema.parse(response); +} \ No newline at end of file From f8915fe9aa250072d641805af32133009213e4a0 Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 01:44:49 -0800 Subject: [PATCH 30/62] Add commits operations module --- src/github/operations/commits.ts | 95 ++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 src/github/operations/commits.ts diff --git a/src/github/operations/commits.ts b/src/github/operations/commits.ts new file mode 100644 index 00000000..e889a734 --- /dev/null +++ b/src/github/operations/commits.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; +import { githubRequest, buildUrl } from "../common/utils"; +import { GitHubCommitSchema, GitHubListCommitsSchema } from "../common/types"; + +// Schema definitions +export const ListCommitsSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + page: z.number().optional().describe("Page number for pagination (default: 1)"), + perPage: z.number().optional().describe("Number of results per page (default: 30, max: 100)"), + sha: z.string().optional().describe("SHA of the commit to start listing from"), +}); + +// Type exports +export type ListCommitsParams = z.infer; + +// Function implementations +export async function listCommits( + owner: string, + repo: string, + page: number = 1, + perPage: number = 30, + sha?: string +) { + const params = { + page, + per_page: perPage, + ...(sha ? { sha } : {}) + }; + + const url = buildUrl(`https://api.github.com/repos/${owner}/${repo}/commits`, params); + + const response = await githubRequest(url); + return GitHubListCommitsSchema.parse(response); +} + +export async function getCommit( + owner: string, + repo: string, + sha: string +) { + const response = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/git/commits/${sha}` + ); + + return GitHubCommitSchema.parse(response); +} + +export async function createCommit( + owner: string, + repo: string, + message: string, + tree: string, + parents: string[] +) { + const response = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/git/commits`, + { + method: "POST", + body: { + message, + tree, + parents, + }, + } + ); + + return GitHubCommitSchema.parse(response); +} + +export async function compareCommits( + owner: string, + repo: string, + base: string, + head: string +) { + const response = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/compare/${base}...${head}` + ); + + return z.object({ + url: z.string(), + html_url: z.string(), + permalink_url: z.string(), + diff_url: z.string(), + patch_url: z.string(), + base_commit: GitHubCommitSchema, + merge_base_commit: GitHubCommitSchema, + commits: z.array(GitHubCommitSchema), + total_commits: z.number(), + status: z.string(), + ahead_by: z.number(), + behind_by: z.number(), + }).parse(response); +} \ No newline at end of file From 83909ddf95d2a9f9c208f19d179730500202897e Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 01:45:24 -0800 Subject: [PATCH 31/62] Refactor index.ts to use modular operation files --- src/github/index.ts | 886 +++++--------------------------------------- 1 file changed, 85 insertions(+), 801 deletions(-) diff --git a/src/github/index.ts b/src/github/index.ts index d2952797..47060542 100644 --- a/src/github/index.ts +++ b/src/github/index.ts @@ -5,61 +5,17 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; -import fetch from "node-fetch"; import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; -import { - CreateBranchOptionsSchema, - CreateBranchSchema, - CreateIssueOptionsSchema, - CreateIssueSchema, - CreateOrUpdateFileSchema, - CreatePullRequestOptionsSchema, - CreatePullRequestSchema, - CreateRepositoryOptionsSchema, - CreateRepositorySchema, - ForkRepositorySchema, - GetFileContentsSchema, - GetIssueSchema, - GitHubCommitSchema, - GitHubContentSchema, - GitHubCreateUpdateFileResponseSchema, - GitHubForkSchema, - GitHubIssueSchema, - GitHubListCommits, - GitHubListCommitsSchema, - GitHubPullRequestSchema, - GitHubReferenceSchema, - GitHubRepositorySchema, - GitHubSearchResponseSchema, - GitHubTreeSchema, - IssueCommentSchema, - ListCommitsSchema, - ListIssuesOptionsSchema, - PushFilesSchema, - SearchCodeResponseSchema, - SearchCodeSchema, - SearchIssuesResponseSchema, - SearchIssuesSchema, - SearchRepositoriesSchema, - SearchUsersResponseSchema, - SearchUsersSchema, - UpdateIssueOptionsSchema, - type FileOperation, - type GitHubCommit, - type GitHubContent, - type GitHubCreateUpdateFileResponse, - type GitHubFork, - type GitHubIssue, - type GitHubPullRequest, - type GitHubReference, - type GitHubRepository, - type GitHubSearchResponse, - type GitHubTree, - type SearchCodeResponse, - type SearchIssuesResponse, - type SearchUsersResponse -} from './schemas.js'; + +// Import operations +import * as repository from './operations/repository.js'; +import * as files from './operations/files.js'; +import * as issues from './operations/issues.js'; +import * as pulls from './operations/pulls.js'; +import * as branches from './operations/branches.js'; +import * as search from './operations/search.js'; +import * as commits from './operations/commits.js'; const server = new Server( { @@ -73,739 +29,93 @@ const server = new Server( } ); -const GITHUB_PERSONAL_ACCESS_TOKEN = process.env.GITHUB_PERSONAL_ACCESS_TOKEN; - -if (!GITHUB_PERSONAL_ACCESS_TOKEN) { - console.error("GITHUB_PERSONAL_ACCESS_TOKEN environment variable is not set"); - process.exit(1); -} - -async function forkRepository( - owner: string, - repo: string, - organization?: string -): Promise { - const url = organization - ? `https://api.github.com/repos/${owner}/${repo}/forks?organization=${organization}` - : `https://api.github.com/repos/${owner}/${repo}/forks`; - - const response = await fetch(url, { - method: "POST", - headers: { - Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, - Accept: "application/vnd.github.v3+json", - "User-Agent": "github-mcp-server", - }, - }); - - if (!response.ok) { - throw new Error(`GitHub API error: ${response.statusText}`); - } - - return GitHubForkSchema.parse(await response.json()); -} - -async function createBranch( - owner: string, - repo: string, - options: z.infer -): Promise { - const fullRef = `refs/heads/${options.ref}`; - - const response = await fetch( - `https://api.github.com/repos/${owner}/${repo}/git/refs`, - { - method: "POST", - headers: { - Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, - Accept: "application/vnd.github.v3+json", - "User-Agent": "github-mcp-server", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - ref: fullRef, - sha: options.sha, - }), - } - ); - - if (!response.ok) { - throw new Error(`GitHub API error: ${response.statusText}`); - } - - return GitHubReferenceSchema.parse(await response.json()); -} - -async function getDefaultBranchSHA( - owner: string, - repo: string -): Promise { - const response = await fetch( - `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/main`, - { - headers: { - Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, - Accept: "application/vnd.github.v3+json", - "User-Agent": "github-mcp-server", - }, - } - ); - - if (!response.ok) { - const masterResponse = await fetch( - `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/master`, - { - headers: { - Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, - Accept: "application/vnd.github.v3+json", - "User-Agent": "github-mcp-server", - }, - } - ); - - if (!masterResponse.ok) { - throw new Error( - "Could not find default branch (tried 'main' and 'master')" - ); - } - - const data = GitHubReferenceSchema.parse(await masterResponse.json()); - return data.object.sha; - } - - const data = GitHubReferenceSchema.parse(await response.json()); - return data.object.sha; -} - -async function getFileContents( - owner: string, - repo: string, - path: string, - branch?: string -): Promise { - let url = `https://api.github.com/repos/${owner}/${repo}/contents/${path}`; - if (branch) { - url += `?ref=${branch}`; - } - - const response = await fetch(url, { - headers: { - Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, - Accept: "application/vnd.github.v3+json", - "User-Agent": "github-mcp-server", - }, - }); - - if (!response.ok) { - throw new Error(`GitHub API error: ${response.statusText}`); - } - - const data = GitHubContentSchema.parse(await response.json()); - - // If it's a file, decode the content - if (!Array.isArray(data) && data.content) { - data.content = Buffer.from(data.content, "base64").toString("utf8"); - } - - return data; -} - -async function createIssue( - owner: string, - repo: string, - options: z.infer -): Promise { - const response = await fetch( - `https://api.github.com/repos/${owner}/${repo}/issues`, - { - method: "POST", - headers: { - Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, - Accept: "application/vnd.github.v3+json", - "User-Agent": "github-mcp-server", - "Content-Type": "application/json", - }, - body: JSON.stringify(options), - } - ); - - if (!response.ok) { - throw new Error(`GitHub API error: ${response.statusText}`); - } - - return GitHubIssueSchema.parse(await response.json()); -} - -async function createPullRequest( - owner: string, - repo: string, - options: z.infer -): Promise { - const response = await fetch( - `https://api.github.com/repos/${owner}/${repo}/pulls`, - { - method: "POST", - headers: { - Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, - Accept: "application/vnd.github.v3+json", - "User-Agent": "github-mcp-server", - "Content-Type": "application/json", - }, - body: JSON.stringify(options), - } - ); - - if (!response.ok) { - throw new Error(`GitHub API error: ${response.statusText}`); - } - - return GitHubPullRequestSchema.parse(await response.json()); -} - -async function createOrUpdateFile( - owner: string, - repo: string, - path: string, - content: string, - message: string, - branch: string, - sha?: string -): Promise { - const encodedContent = Buffer.from(content).toString("base64"); - - let currentSha = sha; - if (!currentSha) { - try { - const existingFile = await getFileContents(owner, repo, path, branch); - if (!Array.isArray(existingFile)) { - currentSha = existingFile.sha; - } - } catch (error) { - console.error( - "Note: File does not exist in branch, will create new file" - ); - } - } - - const url = `https://api.github.com/repos/${owner}/${repo}/contents/${path}`; - - const body = { - message, - content: encodedContent, - branch, - ...(currentSha ? { sha: currentSha } : {}), - }; - - const response = await fetch(url, { - method: "PUT", - headers: { - Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, - Accept: "application/vnd.github.v3+json", - "User-Agent": "github-mcp-server", - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - throw new Error(`GitHub API error: ${response.statusText}`); - } - - return GitHubCreateUpdateFileResponseSchema.parse(await response.json()); -} - -async function createTree( - owner: string, - repo: string, - files: FileOperation[], - baseTree?: string -): Promise { - const tree = files.map((file) => ({ - path: file.path, - mode: "100644" as const, - type: "blob" as const, - content: file.content, - })); - - const response = await fetch( - `https://api.github.com/repos/${owner}/${repo}/git/trees`, - { - method: "POST", - headers: { - Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, - Accept: "application/vnd.github.v3+json", - "User-Agent": "github-mcp-server", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - tree, - base_tree: baseTree, - }), - } - ); - - if (!response.ok) { - throw new Error(`GitHub API error: ${response.statusText}`); - } - - return GitHubTreeSchema.parse(await response.json()); -} - -async function createCommit( - owner: string, - repo: string, - message: string, - tree: string, - parents: string[] -): Promise { - const response = await fetch( - `https://api.github.com/repos/${owner}/${repo}/git/commits`, - { - method: "POST", - headers: { - Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, - Accept: "application/vnd.github.v3+json", - "User-Agent": "github-mcp-server", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - message, - tree, - parents, - }), - } - ); - - if (!response.ok) { - throw new Error(`GitHub API error: ${response.statusText}`); - } - - return GitHubCommitSchema.parse(await response.json()); -} - -async function updateReference( - owner: string, - repo: string, - ref: string, - sha: string -): Promise { - const response = await fetch( - `https://api.github.com/repos/${owner}/${repo}/git/refs/${ref}`, - { - method: "PATCH", - headers: { - Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, - Accept: "application/vnd.github.v3+json", - "User-Agent": "github-mcp-server", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - sha, - force: true, - }), - } - ); - - if (!response.ok) { - throw new Error(`GitHub API error: ${response.statusText}`); - } - - return GitHubReferenceSchema.parse(await response.json()); -} - -async function pushFiles( - owner: string, - repo: string, - branch: string, - files: FileOperation[], - message: string -): Promise { - const refResponse = await fetch( - `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`, - { - headers: { - Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, - Accept: "application/vnd.github.v3+json", - "User-Agent": "github-mcp-server", - }, - } - ); - - if (!refResponse.ok) { - throw new Error(`GitHub API error: ${refResponse.statusText}`); - } - - const ref = GitHubReferenceSchema.parse(await refResponse.json()); - const commitSha = ref.object.sha; - - const tree = await createTree(owner, repo, files, commitSha); - const commit = await createCommit(owner, repo, message, tree.sha, [ - commitSha, - ]); - return await updateReference(owner, repo, `heads/${branch}`, commit.sha); -} - -async function searchRepositories( - query: string, - page: number = 1, - perPage: number = 30 -): Promise { - const url = new URL("https://api.github.com/search/repositories"); - url.searchParams.append("q", query); - url.searchParams.append("page", page.toString()); - url.searchParams.append("per_page", perPage.toString()); - - const response = await fetch(url.toString(), { - headers: { - Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, - Accept: "application/vnd.github.v3+json", - "User-Agent": "github-mcp-server", - }, - }); - - if (!response.ok) { - throw new Error(`GitHub API error: ${response.statusText}`); - } - - return GitHubSearchResponseSchema.parse(await response.json()); -} - -async function createRepository( - options: z.infer -): Promise { - const response = await fetch("https://api.github.com/user/repos", { - method: "POST", - headers: { - Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, - Accept: "application/vnd.github.v3+json", - "User-Agent": "github-mcp-server", - "Content-Type": "application/json", - }, - body: JSON.stringify(options), - }); - - if (!response.ok) { - throw new Error(`GitHub API error: ${response.statusText}`); - } - - return GitHubRepositorySchema.parse(await response.json()); -} - -async function listCommits( - owner: string, - repo: string, - page: number = 1, - perPage: number = 30, - sha?: string, -): Promise { - const url = new URL(`https://api.github.com/repos/${owner}/${repo}/commits`); - url.searchParams.append("page", page.toString()); - url.searchParams.append("per_page", perPage.toString()); - if (sha) { - url.searchParams.append("sha", sha); - } - - const response = await fetch( - url.toString(), - { - method: "GET", - headers: { - "Authorization": `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, - "Accept": "application/vnd.github.v3+json", - "User-Agent": "github-mcp-server", - "Content-Type": "application/json" - }, - } - ); - - if (!response.ok) { - throw new Error(`GitHub API error: ${response.statusText}`); - } - - return GitHubListCommitsSchema.parse(await response.json()); -} - -async function listIssues( - owner: string, - repo: string, - options: Omit, 'owner' | 'repo'> -): Promise { - const url = new URL(`https://api.github.com/repos/${owner}/${repo}/issues`); - - // Add query parameters - if (options.state) url.searchParams.append('state', options.state); - if (options.labels) url.searchParams.append('labels', options.labels.join(',')); - if (options.sort) url.searchParams.append('sort', options.sort); - if (options.direction) url.searchParams.append('direction', options.direction); - if (options.since) url.searchParams.append('since', options.since); - if (options.page) url.searchParams.append('page', options.page.toString()); - if (options.per_page) url.searchParams.append('per_page', options.per_page.toString()); - - const response = await fetch(url.toString(), { - headers: { - "Authorization": `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, - "Accept": "application/vnd.github.v3+json", - "User-Agent": "github-mcp-server" - } - }); - - if (!response.ok) { - throw new Error(`GitHub API error: ${response.statusText}`); - } - - return z.array(GitHubIssueSchema).parse(await response.json()); -} - -async function updateIssue( - owner: string, - repo: string, - issueNumber: number, - options: Omit, 'owner' | 'repo' | 'issue_number'> -): Promise { - const response = await fetch( - `https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}`, - { - method: "PATCH", - headers: { - "Authorization": `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, - "Accept": "application/vnd.github.v3+json", - "User-Agent": "github-mcp-server", - "Content-Type": "application/json" - }, - body: JSON.stringify({ - title: options.title, - body: options.body, - state: options.state, - labels: options.labels, - assignees: options.assignees, - milestone: options.milestone - }) - } - ); - - if (!response.ok) { - throw new Error(`GitHub API error: ${response.statusText}`); - } - - return GitHubIssueSchema.parse(await response.json()); -} - -async function addIssueComment( - owner: string, - repo: string, - issueNumber: number, - body: string -): Promise> { - const response = await fetch( - `https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}/comments`, - { - method: "POST", - headers: { - "Authorization": `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, - "Accept": "application/vnd.github.v3+json", - "User-Agent": "github-mcp-server", - "Content-Type": "application/json" - }, - body: JSON.stringify({ body }) - } - ); - - if (!response.ok) { - throw new Error(`GitHub API error: ${response.statusText}`); - } - - return IssueCommentSchema.parse(await response.json()); -} - -async function searchCode( - params: z.infer -): Promise { - const url = new URL("https://api.github.com/search/code"); - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== null) { - url.searchParams.append(key, value.toString()); - } - }); - - const response = await fetch(url.toString(), { - headers: { - Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, - Accept: "application/vnd.github.v3+json", - "User-Agent": "github-mcp-server", - }, - }); - - if (!response.ok) { - throw new Error(`GitHub API error: ${response.statusText}`); - } - - return SearchCodeResponseSchema.parse(await response.json()); -} - -async function searchIssues( - params: z.infer -): Promise { - const url = new URL("https://api.github.com/search/issues"); - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== null) { - url.searchParams.append(key, value.toString()); - } - }); - - const response = await fetch(url.toString(), { - headers: { - Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, - Accept: "application/vnd.github.v3+json", - "User-Agent": "github-mcp-server", - }, - }); - - if (!response.ok) { - throw new Error(`GitHub API error: ${response.statusText}`); - } - - return SearchIssuesResponseSchema.parse(await response.json()); -} - -async function searchUsers( - params: z.infer -): Promise { - const url = new URL("https://api.github.com/search/users"); - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== null) { - url.searchParams.append(key, value.toString()); - } - }); - - const response = await fetch(url.toString(), { - headers: { - Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, - Accept: "application/vnd.github.v3+json", - "User-Agent": "github-mcp-server", - }, - }); - - if (!response.ok) { - throw new Error(`GitHub API error: ${response.statusText}`); - } - - return SearchUsersResponseSchema.parse(await response.json()); -} - -async function getIssue( - owner: string, - repo: string, - issueNumber: number -): Promise { - const response = await fetch( - `https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}`, - { - headers: { - Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, - Accept: "application/vnd.github.v3+json", - "User-Agent": "github-mcp-server", - }, - } -); - - if (!response.ok) { - throw new Error(`Github API error: ${response.statusText}`); - } - - return GitHubIssueSchema.parse(await response.json()); -} - server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "create_or_update_file", description: "Create or update a single file in a GitHub repository", - inputSchema: zodToJsonSchema(CreateOrUpdateFileSchema), + inputSchema: zodToJsonSchema(files.CreateOrUpdateFileSchema), }, { name: "search_repositories", description: "Search for GitHub repositories", - inputSchema: zodToJsonSchema(SearchRepositoriesSchema), + inputSchema: zodToJsonSchema(repository.SearchRepositoriesSchema), }, { name: "create_repository", description: "Create a new GitHub repository in your account", - inputSchema: zodToJsonSchema(CreateRepositorySchema), + inputSchema: zodToJsonSchema(repository.CreateRepositoryOptionsSchema), }, { name: "get_file_contents", - description: - "Get the contents of a file or directory from a GitHub repository", - inputSchema: zodToJsonSchema(GetFileContentsSchema), + description: "Get the contents of a file or directory from a GitHub repository", + inputSchema: zodToJsonSchema(files.GetFileContentsSchema), }, { name: "push_files", - description: - "Push multiple files to a GitHub repository in a single commit", - inputSchema: zodToJsonSchema(PushFilesSchema), + description: "Push multiple files to a GitHub repository in a single commit", + inputSchema: zodToJsonSchema(files.PushFilesSchema), }, { name: "create_issue", description: "Create a new issue in a GitHub repository", - inputSchema: zodToJsonSchema(CreateIssueSchema), + inputSchema: zodToJsonSchema(issues.CreateIssueSchema), }, { name: "create_pull_request", description: "Create a new pull request in a GitHub repository", - inputSchema: zodToJsonSchema(CreatePullRequestSchema), + inputSchema: zodToJsonSchema(pulls.CreatePullRequestSchema), }, { name: "fork_repository", - description: - "Fork a GitHub repository to your account or specified organization", - inputSchema: zodToJsonSchema(ForkRepositorySchema), + description: "Fork a GitHub repository to your account or specified organization", + inputSchema: zodToJsonSchema(repository.ForkRepositorySchema), }, { name: "create_branch", description: "Create a new branch in a GitHub repository", - inputSchema: zodToJsonSchema(CreateBranchSchema), + inputSchema: zodToJsonSchema(branches.CreateBranchSchema), }, { name: "list_commits", description: "Get list of commits of a branch in a GitHub repository", - inputSchema: zodToJsonSchema(ListCommitsSchema) + inputSchema: zodToJsonSchema(commits.ListCommitsSchema) }, { name: "list_issues", description: "List issues in a GitHub repository with filtering options", - inputSchema: zodToJsonSchema(ListIssuesOptionsSchema) + inputSchema: zodToJsonSchema(issues.ListIssuesOptionsSchema) }, { name: "update_issue", description: "Update an existing issue in a GitHub repository", - inputSchema: zodToJsonSchema(UpdateIssueOptionsSchema) + inputSchema: zodToJsonSchema(issues.UpdateIssueOptionsSchema) }, { name: "add_issue_comment", description: "Add a comment to an existing issue", - inputSchema: zodToJsonSchema(IssueCommentSchema) + inputSchema: zodToJsonSchema(issues.IssueCommentSchema) }, { name: "search_code", description: "Search for code across GitHub repositories", - inputSchema: zodToJsonSchema(SearchCodeSchema), + inputSchema: zodToJsonSchema(search.SearchCodeSchema), }, { name: "search_issues", - description: - "Search for issues and pull requests across GitHub repositories", - inputSchema: zodToJsonSchema(SearchIssuesSchema), + description: "Search for issues and pull requests across GitHub repositories", + inputSchema: zodToJsonSchema(search.SearchIssuesSchema), }, { name: "search_users", description: "Search for users on GitHub", - inputSchema: zodToJsonSchema(SearchUsersSchema), + inputSchema: zodToJsonSchema(search.SearchUsersSchema), }, { name: "get_issue", description: "Get details of a specific issue in a GitHub repository.", - inputSchema: zodToJsonSchema(GetIssueSchema) + inputSchema: zodToJsonSchema(issues.GetIssueSchema) } ], }; @@ -819,55 +129,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { switch (request.params.name) { case "fork_repository": { - const args = ForkRepositorySchema.parse(request.params.arguments); - const fork = await forkRepository( - args.owner, - args.repo, - args.organization - ); + const args = repository.ForkRepositorySchema.parse(request.params.arguments); + const fork = await repository.forkRepository(args.owner, args.repo, args.organization); return { content: [{ type: "text", text: JSON.stringify(fork, null, 2) }], }; } case "create_branch": { - const args = CreateBranchSchema.parse(request.params.arguments); - let sha: string; - if (args.from_branch) { - const response = await fetch( - `https://api.github.com/repos/${args.owner}/${args.repo}/git/refs/heads/${args.from_branch}`, - { - headers: { - Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, - Accept: "application/vnd.github.v3+json", - "User-Agent": "github-mcp-server", - }, - } - ); - - if (!response.ok) { - throw new Error(`Source branch '${args.from_branch}' not found`); - } - - const data = GitHubReferenceSchema.parse(await response.json()); - sha = data.object.sha; - } else { - sha = await getDefaultBranchSHA(args.owner, args.repo); - } - - const branch = await createBranch(args.owner, args.repo, { - ref: args.branch, - sha, - }); - + const args = branches.CreateBranchSchema.parse(request.params.arguments); + const branch = await branches.createBranchFromRef( + args.owner, + args.repo, + args.branch, + args.from_branch + ); return { content: [{ type: "text", text: JSON.stringify(branch, null, 2) }], }; } case "search_repositories": { - const args = SearchRepositoriesSchema.parse(request.params.arguments); - const results = await searchRepositories( + const args = repository.SearchRepositoriesSchema.parse(request.params.arguments); + const results = await repository.searchRepositories( args.query, args.page, args.perPage @@ -878,18 +162,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } case "create_repository": { - const args = CreateRepositorySchema.parse(request.params.arguments); - const repository = await createRepository(args); + const args = repository.CreateRepositoryOptionsSchema.parse(request.params.arguments); + const result = await repository.createRepository(args); return { - content: [ - { type: "text", text: JSON.stringify(repository, null, 2) }, - ], + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }; } case "get_file_contents": { - const args = GetFileContentsSchema.parse(request.params.arguments); - const contents = await getFileContents( + const args = files.GetFileContentsSchema.parse(request.params.arguments); + const contents = await files.getFileContents( args.owner, args.repo, args.path, @@ -901,8 +183,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } case "create_or_update_file": { - const args = CreateOrUpdateFileSchema.parse(request.params.arguments); - const result = await createOrUpdateFile( + const args = files.CreateOrUpdateFileSchema.parse(request.params.arguments); + const result = await files.createOrUpdateFile( args.owner, args.repo, args.path, @@ -917,8 +199,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } case "push_files": { - const args = PushFilesSchema.parse(request.params.arguments); - const result = await pushFiles( + const args = files.PushFilesSchema.parse(request.params.arguments); + const result = await files.pushFiles( args.owner, args.repo, args.branch, @@ -931,83 +213,85 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } case "create_issue": { - const args = CreateIssueSchema.parse(request.params.arguments); + const args = issues.CreateIssueSchema.parse(request.params.arguments); const { owner, repo, ...options } = args; - const issue = await createIssue(owner, repo, options); + const issue = await issues.createIssue(owner, repo, options); return { content: [{ type: "text", text: JSON.stringify(issue, null, 2) }], }; } case "create_pull_request": { - const args = CreatePullRequestSchema.parse(request.params.arguments); + const args = pulls.CreatePullRequestSchema.parse(request.params.arguments); const { owner, repo, ...options } = args; - const pullRequest = await createPullRequest(owner, repo, options); + const pullRequest = await pulls.createPullRequest(owner, repo, options); return { - content: [ - { type: "text", text: JSON.stringify(pullRequest, null, 2) }, - ], + content: [{ type: "text", text: JSON.stringify(pullRequest, null, 2) }], }; } case "search_code": { - const args = SearchCodeSchema.parse(request.params.arguments); - const results = await searchCode(args); + const args = search.SearchCodeSchema.parse(request.params.arguments); + const results = await search.searchCode(args); return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }], }; } case "search_issues": { - const args = SearchIssuesSchema.parse(request.params.arguments); - const results = await searchIssues(args); + const args = search.SearchIssuesSchema.parse(request.params.arguments); + const results = await search.searchIssues(args); return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }], }; } case "search_users": { - const args = SearchUsersSchema.parse(request.params.arguments); - const results = await searchUsers(args); + const args = search.SearchUsersSchema.parse(request.params.arguments); + const results = await search.searchUsers(args); return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }], }; } case "list_issues": { - const args = ListIssuesOptionsSchema.parse(request.params.arguments); + const args = issues.ListIssuesOptionsSchema.parse(request.params.arguments); const { owner, repo, ...options } = args; - const issues = await listIssues(owner, repo, options); - return { toolResult: issues }; + const result = await issues.listIssues(owner, repo, options); + return { toolResult: result }; } case "update_issue": { - const args = UpdateIssueOptionsSchema.parse(request.params.arguments); + const args = issues.UpdateIssueOptionsSchema.parse(request.params.arguments); const { owner, repo, issue_number, ...options } = args; - const issue = await updateIssue(owner, repo, issue_number, options); - return { toolResult: issue }; + const result = await issues.updateIssue(owner, repo, issue_number, options); + return { toolResult: result }; } case "add_issue_comment": { - const args = IssueCommentSchema.parse(request.params.arguments); + const args = issues.IssueCommentSchema.parse(request.params.arguments); const { owner, repo, issue_number, body } = args; - const comment = await addIssueComment(owner, repo, issue_number, body); - return { toolResult: comment }; + const result = await issues.addIssueComment(owner, repo, issue_number, body); + return { toolResult: result }; } case "list_commits": { - const args = ListCommitsSchema.parse(request.params.arguments); - const results = await listCommits(args.owner, args.repo, args.page, args.perPage, args.sha); - return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] }; + const args = commits.ListCommitsSchema.parse(request.params.arguments); + const results = await commits.listCommits( + args.owner, + args.repo, + args.page, + args.perPage, + args.sha + ); + return { + content: [{ type: "text", text: JSON.stringify(results, null, 2) }], + }; } case "get_issue": { - const args = z.object({ - owner: z.string(), - repo: z.string(), - issue_number: z.number() - }).parse(request.params.arguments); - const issue = await getIssue(args.owner, args.repo, args.issue_number); + const args = issues.GetIssueSchema.parse(request.params.arguments); + const issue = await issues.getIssue(args.owner, args.repo, args.issue_number); return { toolResult: issue }; } @@ -1016,7 +300,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } } catch (error) { if (error instanceof z.ZodError) { - throw new Error(`ZodErrors: ${JSON.stringify(error.errors)}`) + throw new Error(`ZodErrors: ${JSON.stringify(error.errors)}`); } throw error; } @@ -1031,4 +315,4 @@ async function runServer() { runServer().catch((error) => { console.error("Fatal error in main():", error); process.exit(1); -}); +}); \ No newline at end of file From b4e5754c6562a7156b7614ad400ab14093a0dc85 Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 01:56:15 -0800 Subject: [PATCH 32/62] Remove schemas.ts as schemas are now in operation modules --- src/github/schemas.ts | 726 ------------------------------------------ 1 file changed, 726 deletions(-) diff --git a/src/github/schemas.ts b/src/github/schemas.ts index d911104a..e69de29b 100644 --- a/src/github/schemas.ts +++ b/src/github/schemas.ts @@ -1,726 +0,0 @@ -import { z } from "zod"; - -// Base schemas for common types -export const GitHubAuthorSchema = z.object({ - name: z.string(), - email: z.string(), - date: z.string(), -}); - -// Repository related schemas -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(), -}); - -const GithubFileContentLinks = z.object({ - self: z.string(), - git: z.string().nullable(), - html: z.string().nullable() -}); - -// File content schemas -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), -]); - -// Operation schemas -export const FileOperationSchema = z.object({ - path: z.string(), - content: z.string(), -}); - -// Tree and commit schemas -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 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 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(), - }) - ), -}); - -// Reference schema -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(), - }), -}); - -// Input schemas for operations -export const CreateRepositoryOptionsSchema = z.object({ - name: z.string(), - description: z.string().optional(), - private: z.boolean().optional(), - auto_init: z.boolean().optional(), -}); - -export const CreateIssueOptionsSchema = z.object({ - title: z.string(), - body: z.string().optional(), - assignees: z.array(z.string()).optional(), - milestone: z.number().optional(), - labels: z.array(z.string()).optional(), -}); - -export const CreatePullRequestOptionsSchema = z.object({ - title: z.string(), - body: z.string().optional(), - head: z.string(), - base: z.string(), - maintainer_can_modify: z.boolean().optional(), - draft: z.boolean().optional(), -}); - -export const CreateBranchOptionsSchema = z.object({ - ref: z.string(), - sha: z.string(), -}); - -// Response schemas for operations -export const GitHubCreateUpdateFileResponseSchema = z.object({ - content: GitHubFileContentSchema.nullable(), - commit: z.object({ - sha: z.string(), - node_id: z.string(), - url: z.string(), - html_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(), - html_url: z.string(), - }) - ), - }), -}); - -export const GitHubSearchResponseSchema = z.object({ - total_count: z.number(), - incomplete_results: z.boolean(), - items: z.array(GitHubRepositorySchema), -}); - -// Fork related schemas -export const GitHubForkParentSchema = z.object({ - name: z.string(), - full_name: z.string(), - owner: z.object({ - login: z.string(), - id: z.number(), - avatar_url: z.string(), - }), - html_url: z.string(), -}); - -export const GitHubForkSchema = GitHubRepositorySchema.extend({ - parent: GitHubForkParentSchema, - source: GitHubForkParentSchema, -}); - -// 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 GitHubIssueAssigneeSchema = z.object({ - login: z.string(), - id: z.number(), - avatar_url: z.string(), - url: z.string(), - html_url: z.string(), -}); - -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(), -}); - -// Pull Request related schemas -export const GitHubPullRequestHeadSchema = 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(), - 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), - head: GitHubPullRequestHeadSchema, - base: GitHubPullRequestHeadSchema, -}); - -const RepoParamsSchema = z.object({ - owner: z.string().describe("Repository owner (username or organization)"), - repo: z.string().describe("Repository name"), -}); - -export const CreateOrUpdateFileSchema = RepoParamsSchema.extend({ - path: z.string().describe("Path where to create/update the file"), - content: z.string().describe("Content of the file"), - message: z.string().describe("Commit message"), - branch: z.string().describe("Branch to create/update the file in"), - sha: z - .string() - .optional() - .describe( - "SHA of the file being replaced (required when updating existing files)" - ), -}); - -export const SearchRepositoriesSchema = z.object({ - query: z.string().describe("Search query (see GitHub search syntax)"), - page: z - .number() - .optional() - .describe("Page number for pagination (default: 1)"), - perPage: z - .number() - .optional() - .describe("Number of results per page (default: 30, max: 100)"), -}); - -export const ListCommitsSchema = z.object({ - owner: z.string().describe("Repository owner (username or organization)"), - repo: z.string().describe("Repository name"), - page: z.number().optional().describe("Page number for pagination (default: 1)"), - perPage: z.number().optional().describe("Number of results per page (default: 30, max: 100)"), - sha: z.string().optional() - .describe("SHA of the file being replaced (required when updating existing files)") -}); - -export const CreateRepositorySchema = z.object({ - name: z.string().describe("Repository name"), - description: z.string().optional().describe("Repository description"), - private: z - .boolean() - .optional() - .describe("Whether the repository should be private"), - autoInit: z.boolean().optional().describe("Initialize with README.md"), -}); - -export const GetFileContentsSchema = RepoParamsSchema.extend({ - path: z.string().describe("Path to the file or directory"), - branch: z.string().optional().describe("Branch to get contents from"), -}); - -export const PushFilesSchema = RepoParamsSchema.extend({ - branch: z.string().describe("Branch to push to (e.g., 'main' or 'master')"), - files: z - .array( - z.object({ - path: z.string().describe("Path where to create the file"), - content: z.string().describe("Content of the file"), - }) - ) - .describe("Array of files to push"), - message: z.string().describe("Commit message"), -}); - -export const CreateIssueSchema = RepoParamsSchema.extend({ - title: z.string().describe("Issue title"), - body: z.string().optional().describe("Issue body/description"), - assignees: z - .array(z.string()) - .optional() - .describe("Array of usernames to assign"), - labels: z.array(z.string()).optional().describe("Array of label names"), - milestone: z.number().optional().describe("Milestone number to assign"), -}); - -export const CreatePullRequestSchema = RepoParamsSchema.extend({ - title: z.string().describe("Pull request title"), - body: z.string().optional().describe("Pull request body/description"), - head: z - .string() - .describe("The name of the branch where your changes are implemented"), - base: z - .string() - .describe("The name of the branch you want the changes pulled into"), - draft: z - .boolean() - .optional() - .describe("Whether to create the pull request as a draft"), - maintainer_can_modify: z - .boolean() - .optional() - .describe("Whether maintainers can modify the pull request"), -}); - -export const ForkRepositorySchema = RepoParamsSchema.extend({ - organization: z - .string() - .optional() - .describe( - "Optional: organization to fork to (defaults to your personal account)" - ), -}); - -export const CreateBranchSchema = RepoParamsSchema.extend({ - branch: z.string().describe("Name for the new branch"), - from_branch: z - .string() - .optional() - .describe( - "Optional: source branch to create from (defaults to the repository's default branch)" - ), -}); - -/** - * Response schema for a code search result item - * @see https://docs.github.com/en/rest/search/search?apiVersion=2022-11-28#search-code - */ -export const SearchCodeItemSchema = z.object({ - name: z.string().describe("The name of the file"), - path: z.string().describe("The path to the file in the repository"), - sha: z.string().describe("The SHA hash of the file"), - url: z.string().describe("The API URL for this file"), - git_url: z.string().describe("The Git URL for this file"), - html_url: z.string().describe("The HTML URL to view this file on GitHub"), - repository: GitHubRepositorySchema.describe( - "The repository where this file was found" - ), - score: z.number().describe("The search result score"), -}); - -/** - * Response schema for code search results - */ -export const SearchCodeResponseSchema = z.object({ - total_count: z.number().describe("Total number of matching results"), - incomplete_results: z - .boolean() - .describe("Whether the results are incomplete"), - items: z.array(SearchCodeItemSchema).describe("The search results"), -}); - -/** - * Response schema for an issue search result item - * @see https://docs.github.com/en/rest/search/search?apiVersion=2022-11-28#search-issues-and-pull-requests - */ -export const SearchIssueItemSchema = z.object({ - url: z.string().describe("The API URL for this issue"), - repository_url: z - .string() - .describe("The API URL for the repository where this issue was found"), - labels_url: z.string().describe("The API URL for the labels of this issue"), - comments_url: z.string().describe("The API URL for comments of this issue"), - events_url: z.string().describe("The API URL for events of this issue"), - html_url: z.string().describe("The HTML URL to view this issue on GitHub"), - id: z.number().describe("The ID of this issue"), - node_id: z.string().describe("The Node ID of this issue"), - number: z.number().describe("The number of this issue"), - title: z.string().describe("The title of this issue"), - user: GitHubIssueAssigneeSchema.describe("The user who created this issue"), - labels: z.array(GitHubLabelSchema).describe("The labels of this issue"), - state: z.string().describe("The state of this issue"), - locked: z.boolean().describe("Whether this issue is locked"), - assignee: GitHubIssueAssigneeSchema.nullable().describe( - "The assignee of this issue" - ), - assignees: z - .array(GitHubIssueAssigneeSchema) - .describe("The assignees of this issue"), - comments: z.number().describe("The number of comments on this issue"), - created_at: z.string().describe("The creation time of this issue"), - updated_at: z.string().describe("The last update time of this issue"), - closed_at: z.string().nullable().describe("The closure time of this issue"), - body: z.string().describe("The body of this issue"), - score: z.number().describe("The search result score"), - pull_request: z - .object({ - url: z.string().describe("The API URL for this pull request"), - html_url: z.string().describe("The HTML URL to view this pull request"), - diff_url: z.string().describe("The URL to view the diff"), - patch_url: z.string().describe("The URL to view the patch"), - }) - .optional() - .describe("Pull request details if this is a PR"), -}); - -/** - * Response schema for issue search results - */ -export const SearchIssuesResponseSchema = z.object({ - total_count: z.number().describe("Total number of matching results"), - incomplete_results: z - .boolean() - .describe("Whether the results are incomplete"), - items: z.array(SearchIssueItemSchema).describe("The search results"), -}); - -/** - * Response schema for a user search result item - * @see https://docs.github.com/en/rest/search/search?apiVersion=2022-11-28#search-users - */ -export const SearchUserItemSchema = z.object({ - login: z.string().describe("The username of the user"), - id: z.number().describe("The ID of the user"), - node_id: z.string().describe("The Node ID of the user"), - avatar_url: z.string().describe("The avatar URL of the user"), - gravatar_id: z.string().describe("The Gravatar ID of the user"), - url: z.string().describe("The API URL for this user"), - html_url: z.string().describe("The HTML URL to view this user on GitHub"), - followers_url: z.string().describe("The API URL for followers of this user"), - following_url: z.string().describe("The API URL for following of this user"), - gists_url: z.string().describe("The API URL for gists of this user"), - starred_url: z - .string() - .describe("The API URL for starred repositories of this user"), - subscriptions_url: z - .string() - .describe("The API URL for subscriptions of this user"), - organizations_url: z - .string() - .describe("The API URL for organizations of this user"), - repos_url: z.string().describe("The API URL for repositories of this user"), - events_url: z.string().describe("The API URL for events of this user"), - received_events_url: z - .string() - .describe("The API URL for received events of this user"), - type: z.string().describe("The type of this user"), - site_admin: z.boolean().describe("Whether this user is a site administrator"), - score: z.number().describe("The search result score"), -}); - -/** - * Response schema for user search results - */ -export const SearchUsersResponseSchema = z.object({ - total_count: z.number().describe("Total number of matching results"), - incomplete_results: z - .boolean() - .describe("Whether the results are incomplete"), - items: z.array(SearchUserItemSchema).describe("The search results"), -}); - -/** - * Input schema for code search - * @see https://docs.github.com/en/rest/search/search?apiVersion=2022-11-28#search-code--parameters - */ -export const SearchCodeSchema = z.object({ - q: z - .string() - .describe( - "Search query. See GitHub code search syntax: https://docs.github.com/en/search-github/searching-on-github/searching-code" - ), - order: z - .enum(["asc", "desc"]) - .optional() - .describe("Sort order (asc or desc)"), - per_page: z - .number() - .min(1) - .max(100) - .optional() - .describe("Results per page (max 100)"), - page: z.number().min(1).optional().describe("Page number"), -}); - -/** - * Input schema for issues search - * @see https://docs.github.com/en/rest/search/search?apiVersion=2022-11-28#search-issues-and-pull-requests--parameters - */ -export const SearchIssuesSchema = z.object({ - q: z - .string() - .describe( - "Search query. See GitHub issues search syntax: https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests" - ), - sort: z - .enum([ - "comments", - "reactions", - "reactions-+1", - "reactions--1", - "reactions-smile", - "reactions-thinking_face", - "reactions-heart", - "reactions-tada", - "interactions", - "created", - "updated", - ]) - .optional() - .describe("Sort field"), - order: z - .enum(["asc", "desc"]) - .optional() - .describe("Sort order (asc or desc)"), - per_page: z - .number() - .min(1) - .max(100) - .optional() - .describe("Results per page (max 100)"), - page: z.number().min(1).optional().describe("Page number"), -}); - -/** - * Input schema for users search - * @see https://docs.github.com/en/rest/search/search?apiVersion=2022-11-28#search-users--parameters - */ -export const SearchUsersSchema = z.object({ - q: z - .string() - .describe( - "Search query. See GitHub users search syntax: https://docs.github.com/en/search-github/searching-on-github/searching-users" - ), - sort: z - .enum(["followers", "repositories", "joined"]) - .optional() - .describe("Sort field"), - order: z - .enum(["asc", "desc"]) - .optional() - .describe("Sort order (asc or desc)"), - per_page: z - .number() - .min(1) - .max(100) - .optional() - .describe("Results per page (max 100)"), - page: z.number().min(1).optional().describe("Page number"), -}); - -// Add these schema definitions for issue management - -export const ListIssuesOptionsSchema = z.object({ - owner: z.string(), - repo: z.string(), - state: z.enum(['open', 'closed', 'all']).optional(), - labels: z.array(z.string()).optional(), - sort: z.enum(['created', 'updated', 'comments']).optional(), - direction: z.enum(['asc', 'desc']).optional(), - since: z.string().optional(), // ISO 8601 timestamp - page: z.number().optional(), - per_page: z.number().optional() -}); - -export const UpdateIssueOptionsSchema = z.object({ - owner: z.string(), - repo: z.string(), - issue_number: z.number(), - title: z.string().optional(), - body: z.string().optional(), - state: z.enum(['open', 'closed']).optional(), - labels: z.array(z.string()).optional(), - assignees: z.array(z.string()).optional(), - milestone: z.number().optional() -}); - -export const IssueCommentSchema = z.object({ - owner: z.string(), - repo: z.string(), - issue_number: z.number(), - body: z.string() -}); - -export const GetIssueSchema = z.object({ - owner: z.string().describe("Repository owner (username or organization)"), - repo: z.string().describe("Repository name"), - issue_number: z.number().describe("Issue number") -}); - -// Export types -export type GitHubAuthor = z.infer; -export type GitHubFork = z.infer; -export type GitHubIssue = z.infer; -export type GitHubPullRequest = z.infer; -export type GitHubRepository = z.infer; -export type GitHubFileContent = z.infer; -export type GitHubDirectoryContent = z.infer< - typeof GitHubDirectoryContentSchema ->; -export type GitHubContent = z.infer; -export type FileOperation = z.infer; -export type GitHubTree = z.infer; -export type GitHubCommit = z.infer; -export type GitHubListCommits = z.infer; -export type GitHubReference = z.infer; -export type CreateRepositoryOptions = z.infer< - typeof CreateRepositoryOptionsSchema ->; -export type CreateIssueOptions = z.infer; -export type CreatePullRequestOptions = z.infer< - typeof CreatePullRequestOptionsSchema ->; -export type CreateBranchOptions = z.infer; -export type GitHubCreateUpdateFileResponse = z.infer< - typeof GitHubCreateUpdateFileResponseSchema ->; -export type GitHubSearchResponse = z.infer; -export type SearchCodeItem = z.infer; -export type SearchCodeResponse = z.infer; -export type SearchIssueItem = z.infer; -export type SearchIssuesResponse = z.infer; -export type SearchUserItem = z.infer; -export type SearchUsersResponse = z.infer; From 9f43900170c35cf141dd882eb1049a6a8c2dbb28 Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 02:10:27 -0800 Subject: [PATCH 33/62] Add GitHubCreateUpdateFileResponseSchema to files module --- src/github/operations/files.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/github/operations/files.ts b/src/github/operations/files.ts index 676e9374..ae8c9b92 100644 --- a/src/github/operations/files.ts +++ b/src/github/operations/files.ts @@ -2,10 +2,11 @@ import { z } from "zod"; import { githubRequest } from "../common/utils"; import { GitHubContentSchema, - GitHubCreateUpdateFileResponseSchema, + GitHubAuthorSchema, GitHubTreeSchema, GitHubCommitSchema, GitHubReferenceSchema, + GitHubFileContentSchema, } from "../common/types"; // Schema definitions @@ -39,8 +40,33 @@ export const PushFilesSchema = z.object({ message: z.string().describe("Commit message"), }); +export const GitHubCreateUpdateFileResponseSchema = z.object({ + content: GitHubFileContentSchema.nullable(), + commit: z.object({ + sha: z.string(), + node_id: z.string(), + url: z.string(), + html_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(), + html_url: z.string(), + }) + ), + }), +}); + // Type exports export type FileOperation = z.infer; +export type GitHubCreateUpdateFileResponse = z.infer; // Function implementations export async function getFileContents( From 6b9e9834075157666a106e90c789da1c36a27ade Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 02:16:02 -0800 Subject: [PATCH 34/62] Add GitHubPullRequestSchema and related schemas to pulls module --- src/github/operations/pulls.ts | 40 +++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/github/operations/pulls.ts b/src/github/operations/pulls.ts index 2733a597..db073c67 100644 --- a/src/github/operations/pulls.ts +++ b/src/github/operations/pulls.ts @@ -1,8 +1,44 @@ import { z } from "zod"; import { githubRequest } from "../common/utils"; -import { GitHubPullRequestSchema } from "../common/types"; +import { + GitHubIssueAssigneeSchema, + GitHubRepositorySchema +} from "../common/types"; // Schema definitions +export const GitHubPullRequestHeadSchema = 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(), + 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), + head: GitHubPullRequestHeadSchema, + base: GitHubPullRequestHeadSchema, +}); + export const CreatePullRequestOptionsSchema = z.object({ title: z.string().describe("Pull request title"), body: z.string().optional().describe("Pull request body/description"), @@ -25,6 +61,8 @@ export const CreatePullRequestSchema = z.object({ // Type exports export type CreatePullRequestOptions = z.infer; +export type GitHubPullRequest = z.infer; +export type GitHubPullRequestHead = z.infer; // Function implementations export async function createPullRequest( From 4ec840cb4a9bdfe14d6f3573211f38ae4190d54f Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 02:18:45 -0800 Subject: [PATCH 35/62] Add GitHubIssueAssigneeSchema to common types --- src/github/common/types.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/github/common/types.ts b/src/github/common/types.ts index 9be60a74..61eba5b2 100644 --- a/src/github/common/types.ts +++ b/src/github/common/types.ts @@ -121,6 +121,15 @@ export const GitHubReferenceSchema = z.object({ }), }); +// 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(), +}); + // Export types export type GitHubAuthor = z.infer; export type GitHubRepository = z.infer; @@ -129,4 +138,5 @@ export type GitHubDirectoryContent = z.infer; export type GitHubTree = z.infer; export type GitHubCommit = z.infer; -export type GitHubReference = z.infer; \ No newline at end of file +export type GitHubReference = z.infer; +export type GitHubIssueAssignee = z.infer; \ No newline at end of file From 0b3359fbf91dba1d3f19f3c1391bc1ab3d2f957c Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 02:21:38 -0800 Subject: [PATCH 36/62] Add missing issue-related schemas to common types --- src/github/common/types.ts | 53 +++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/src/github/common/types.ts b/src/github/common/types.ts index 61eba5b2..8e654be8 100644 --- a/src/github/common/types.ts +++ b/src/github/common/types.ts @@ -130,6 +130,54 @@ export const GitHubIssueAssigneeSchema = z.object({ 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(), +}); + // Export types export type GitHubAuthor = z.infer; export type GitHubRepository = z.infer; @@ -139,4 +187,7 @@ export type GitHubContent = z.infer; export type GitHubTree = z.infer; export type GitHubCommit = z.infer; export type GitHubReference = z.infer; -export type GitHubIssueAssignee = z.infer; \ No newline at end of file +export type GitHubIssueAssignee = z.infer; +export type GitHubLabel = z.infer; +export type GitHubMilestone = z.infer; +export type GitHubIssue = z.infer; \ No newline at end of file From a79ec67d9c18721ad58f5d626f6fd1c0e954289f Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 02:24:04 -0800 Subject: [PATCH 37/62] Add missing GitHubListCommitsSchema and GitHubSearchResponseSchema to common types --- src/github/common/types.ts | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/github/common/types.ts b/src/github/common/types.ts index 8e654be8..af81f22f 100644 --- a/src/github/common/types.ts +++ b/src/github/common/types.ts @@ -110,6 +110,25 @@ export const GitHubCommitSchema = z.object({ ), }); +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(), @@ -178,6 +197,13 @@ export const GitHubIssueSchema = z.object({ body: z.string().nullable(), }); +// Search-related schemas +export const GitHubSearchResponseSchema = z.object({ + total_count: z.number(), + incomplete_results: z.boolean(), + items: z.array(GitHubRepositorySchema), +}); + // Export types export type GitHubAuthor = z.infer; export type GitHubRepository = z.infer; @@ -186,8 +212,10 @@ export type GitHubDirectoryContent = z.infer; export type GitHubTree = z.infer; export type GitHubCommit = z.infer; +export type GitHubListCommits = z.infer; export type GitHubReference = z.infer; export type GitHubIssueAssignee = z.infer; export type GitHubLabel = z.infer; export type GitHubMilestone = z.infer; -export type GitHubIssue = z.infer; \ No newline at end of file +export type GitHubIssue = z.infer; +export type GitHubSearchResponse = z.infer; \ No newline at end of file From 7c72d987f9a695a6e82973a025acf8d0847a01a0 Mon Sep 17 00:00:00 2001 From: Peter M Elias Date: Sat, 28 Dec 2024 02:25:30 -0800 Subject: [PATCH 38/62] cleanup --- src/github/operations/branches.ts | 6 +++--- src/github/operations/commits.ts | 6 +++--- src/github/operations/files.ts | 6 +++--- src/github/operations/issues.ts | 6 +++--- src/github/operations/pulls.ts | 6 +++--- src/github/operations/repository.ts | 6 +++--- src/github/operations/search.ts | 4 ++-- src/github/schemas.ts | 0 8 files changed, 20 insertions(+), 20 deletions(-) delete mode 100644 src/github/schemas.ts diff --git a/src/github/operations/branches.ts b/src/github/operations/branches.ts index 4690ef24..9b7033b5 100644 --- a/src/github/operations/branches.ts +++ b/src/github/operations/branches.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { githubRequest } from "../common/utils"; -import { GitHubReferenceSchema } from "../common/types"; +import { githubRequest } from "../common/utils.js"; +import { GitHubReferenceSchema } from "../common/types.js"; // Schema definitions export const CreateBranchOptionsSchema = z.object({ @@ -109,4 +109,4 @@ export async function updateBranch( ); return GitHubReferenceSchema.parse(response); -} \ No newline at end of file +} diff --git a/src/github/operations/commits.ts b/src/github/operations/commits.ts index e889a734..09b4302a 100644 --- a/src/github/operations/commits.ts +++ b/src/github/operations/commits.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { githubRequest, buildUrl } from "../common/utils"; -import { GitHubCommitSchema, GitHubListCommitsSchema } from "../common/types"; +import { githubRequest, buildUrl } from "../common/utils.js"; +import { GitHubCommitSchema, GitHubListCommitsSchema } from "../common/types.js"; // Schema definitions export const ListCommitsSchema = z.object({ @@ -92,4 +92,4 @@ export async function compareCommits( ahead_by: z.number(), behind_by: z.number(), }).parse(response); -} \ No newline at end of file +} diff --git a/src/github/operations/files.ts b/src/github/operations/files.ts index ae8c9b92..9517946e 100644 --- a/src/github/operations/files.ts +++ b/src/github/operations/files.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { githubRequest } from "../common/utils"; +import { githubRequest } from "../common/utils.js"; import { GitHubContentSchema, GitHubAuthorSchema, @@ -7,7 +7,7 @@ import { GitHubCommitSchema, GitHubReferenceSchema, GitHubFileContentSchema, -} from "../common/types"; +} from "../common/types.js"; // Schema definitions export const FileOperationSchema = z.object({ @@ -216,4 +216,4 @@ export async function pushFiles( const tree = await createTree(owner, repo, files, commitSha); const commit = await createCommit(owner, repo, message, tree.sha, [commitSha]); return await updateReference(owner, repo, `heads/${branch}`, commit.sha); -} \ No newline at end of file +} diff --git a/src/github/operations/issues.ts b/src/github/operations/issues.ts index e489e747..aec154f0 100644 --- a/src/github/operations/issues.ts +++ b/src/github/operations/issues.ts @@ -1,11 +1,11 @@ import { z } from "zod"; -import { githubRequest, buildUrl } from "../common/utils"; +import { githubRequest, buildUrl } from "../common/utils.js"; import { GitHubIssueSchema, GitHubLabelSchema, GitHubIssueAssigneeSchema, GitHubMilestoneSchema, -} from "../common/types"; +} from "../common/types.js"; // Schema definitions export const CreateIssueOptionsSchema = z.object({ @@ -148,4 +148,4 @@ export async function getIssue( ); return GitHubIssueSchema.parse(response); -} \ No newline at end of file +} diff --git a/src/github/operations/pulls.ts b/src/github/operations/pulls.ts index db073c67..34ae85d2 100644 --- a/src/github/operations/pulls.ts +++ b/src/github/operations/pulls.ts @@ -1,9 +1,9 @@ import { z } from "zod"; -import { githubRequest } from "../common/utils"; +import { githubRequest } from "../common/utils.js"; import { GitHubIssueAssigneeSchema, GitHubRepositorySchema -} from "../common/types"; +} from "../common/types.js"; // Schema definitions export const GitHubPullRequestHeadSchema = z.object({ @@ -115,4 +115,4 @@ export async function listPullRequests( const response = await githubRequest(url.toString()); return z.array(GitHubPullRequestSchema).parse(response); -} \ No newline at end of file +} diff --git a/src/github/operations/repository.ts b/src/github/operations/repository.ts index dfa7e263..4cf0ab9b 100644 --- a/src/github/operations/repository.ts +++ b/src/github/operations/repository.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { githubRequest } from "../common/utils"; -import { GitHubRepositorySchema, GitHubSearchResponseSchema } from "../common/types"; +import { githubRequest } from "../common/utils.js"; +import { GitHubRepositorySchema, GitHubSearchResponseSchema } from "../common/types.js"; // Schema definitions export const CreateRepositoryOptionsSchema = z.object({ @@ -62,4 +62,4 @@ export async function forkRepository( parent: GitHubRepositorySchema, source: GitHubRepositorySchema, }).parse(response); -} \ No newline at end of file +} diff --git a/src/github/operations/search.ts b/src/github/operations/search.ts index e7aab148..08e2fd17 100644 --- a/src/github/operations/search.ts +++ b/src/github/operations/search.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { githubRequest, buildUrl } from "../common/utils"; +import { githubRequest, buildUrl } from "../common/utils.js"; // Schema definitions export const SearchCodeSchema = z.object({ @@ -101,4 +101,4 @@ export async function searchUsers(params: SearchUsersParams): Promise Date: Sat, 28 Dec 2024 03:07:34 -0800 Subject: [PATCH 39/62] refactor: improve pull request schemas and validation - Add proper state enum validation - Add title and body length validation - Consolidate request schemas - Add consistent parameter handling - Improve type safety - Add proper JSDoc documentation --- src/github/operations/pulls.ts | 181 ++++++++++++++++++++++----------- 1 file changed, 123 insertions(+), 58 deletions(-) diff --git a/src/github/operations/pulls.ts b/src/github/operations/pulls.ts index 34ae85d2..49084c94 100644 --- a/src/github/operations/pulls.ts +++ b/src/github/operations/pulls.ts @@ -5,71 +5,122 @@ import { GitHubRepositorySchema } from "../common/types.js"; -// Schema definitions -export const GitHubPullRequestHeadSchema = z.object({ +// Constants for GitHub limits and constraints +const GITHUB_TITLE_MAX_LENGTH = 256; +const GITHUB_BODY_MAX_LENGTH = 65536; + +// Base schema for repository identification +export const RepositoryParamsSchema = z.object({ + owner: z.string().min(1).describe("Repository owner (username or organization)"), + repo: z.string().min(1).describe("Repository name"), +}); + +// Common validation schemas +export const GitHubPullRequestStateSchema = z.enum([ + "open", + "closed", + "merged", + "draft" +]).describe("The current state of the pull request"); + +export const GitHubPullRequestSortSchema = z.enum([ + "created", + "updated", + "popularity", + "long-running" +]).describe("The sorting field for pull requests"); + +export const GitHubDirectionSchema = z.enum([ + "asc", + "desc" +]).describe("The sort direction"); + +// Pull request head/base schema +export const GitHubPullRequestRefSchema = z.object({ label: z.string(), - ref: z.string(), - sha: z.string(), + ref: z.string().min(1), + sha: z.string().length(40), user: GitHubIssueAssigneeSchema, repo: GitHubRepositorySchema, -}); +}).describe("Reference information for pull request head or base"); +// Main pull request schema export const GitHubPullRequestSchema = z.object({ - url: z.string(), - id: z.number(), + url: z.string().url(), + id: z.number().positive(), 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(), + html_url: z.string().url(), + diff_url: z.string().url(), + patch_url: z.string().url(), + issue_url: z.string().url(), + number: z.number().positive(), + state: GitHubPullRequestStateSchema, locked: z.boolean(), - title: z.string(), + title: z.string().max(GITHUB_TITLE_MAX_LENGTH), user: GitHubIssueAssigneeSchema, - body: z.string(), - created_at: z.string(), - updated_at: z.string(), - closed_at: z.string().nullable(), - merged_at: z.string().nullable(), - merge_commit_sha: z.string().nullable(), + body: z.string().max(GITHUB_BODY_MAX_LENGTH).nullable(), + created_at: z.string().datetime(), + updated_at: z.string().datetime(), + closed_at: z.string().datetime().nullable(), + merged_at: z.string().datetime().nullable(), + merge_commit_sha: z.string().length(40).nullable(), assignee: GitHubIssueAssigneeSchema.nullable(), assignees: z.array(GitHubIssueAssigneeSchema), - head: GitHubPullRequestHeadSchema, - base: GitHubPullRequestHeadSchema, + requested_reviewers: z.array(GitHubIssueAssigneeSchema), + labels: z.array(z.object({ + name: z.string(), + color: z.string().regex(/^[0-9a-fA-F]{6}$/), + description: z.string().nullable(), + })), + head: GitHubPullRequestRefSchema, + base: GitHubPullRequestRefSchema, }); +// Request schemas +export const ListPullRequestsOptionsSchema = z.object({ + state: GitHubPullRequestStateSchema.optional(), + head: z.string().optional(), + base: z.string().optional(), + sort: GitHubPullRequestSortSchema.optional(), + direction: GitHubDirectionSchema.optional(), + per_page: z.number().min(1).max(100).optional(), + page: z.number().min(1).optional(), +}).describe("Options for listing pull requests"); + export const CreatePullRequestOptionsSchema = z.object({ - title: z.string().describe("Pull request title"), - body: z.string().optional().describe("Pull request body/description"), - head: z.string().describe("The name of the branch where your changes are implemented"), - base: z.string().describe("The name of the branch you want the changes pulled into"), + title: z.string().max(GITHUB_TITLE_MAX_LENGTH).describe("Pull request title"), + body: z.string().max(GITHUB_BODY_MAX_LENGTH).optional().describe("Pull request body/description"), + head: z.string().min(1).describe("The name of the branch where your changes are implemented"), + base: z.string().min(1).describe("The name of the branch you want the changes pulled into"), maintainer_can_modify: z.boolean().optional().describe("Whether maintainers can modify the pull request"), draft: z.boolean().optional().describe("Whether to create the pull request as a draft"), -}); +}).describe("Options for creating a pull request"); -export const CreatePullRequestSchema = z.object({ - owner: z.string().describe("Repository owner (username or organization)"), - repo: z.string().describe("Repository name"), - title: z.string().describe("Pull request title"), - body: z.string().optional().describe("Pull request body/description"), - head: z.string().describe("The name of the branch where your changes are implemented"), - base: z.string().describe("The name of the branch you want the changes pulled into"), - draft: z.boolean().optional().describe("Whether to create the pull request as a draft"), - maintainer_can_modify: z.boolean().optional().describe("Whether maintainers can modify the pull request"), +// Combine repository params with operation options +export const CreatePullRequestSchema = RepositoryParamsSchema.extend({ + ...CreatePullRequestOptionsSchema.shape, }); // Type exports +export type RepositoryParams = z.infer; export type CreatePullRequestOptions = z.infer; +export type ListPullRequestsOptions = z.infer; export type GitHubPullRequest = z.infer; -export type GitHubPullRequestHead = z.infer; +export type GitHubPullRequestRef = z.infer; -// Function implementations +/** + * Creates a new pull request in a repository. + * + * @param params Repository identification and pull request creation options + * @returns Promise resolving to the created pull request + * @throws {ZodError} If the input parameters fail validation + * @throws {Error} If the GitHub API request fails + */ export async function createPullRequest( - owner: string, - repo: string, - options: CreatePullRequestOptions -): Promise> { + params: z.infer +): Promise { + const { owner, repo, ...options } = CreatePullRequestSchema.parse(params); + const response = await githubRequest( `https://api.github.com/repos/${owner}/${repo}/pulls`, { @@ -81,11 +132,21 @@ export async function createPullRequest( return GitHubPullRequestSchema.parse(response); } +/** + * Retrieves a specific pull request by its number. + * + * @param params Repository parameters and pull request number + * @returns Promise resolving to the pull request details + * @throws {Error} If the pull request is not found or the request fails + */ export async function getPullRequest( - owner: string, - repo: string, - pullNumber: number -): Promise> { + params: RepositoryParams & { pullNumber: number } +): Promise { + const { owner, repo, pullNumber } = z.object({ + ...RepositoryParamsSchema.shape, + pullNumber: z.number().positive(), + }).parse(params); + const response = await githubRequest( `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}` ); @@ -93,20 +154,24 @@ export async function getPullRequest( return GitHubPullRequestSchema.parse(response); } +/** + * Lists pull requests in a repository with optional filtering. + * + * @param params Repository parameters and listing options + * @returns Promise resolving to an array of pull requests + * @throws {ZodError} If the input parameters fail validation + * @throws {Error} If the GitHub API request fails + */ export async function listPullRequests( - owner: string, - repo: string, - options: { - state?: "open" | "closed" | "all"; - head?: string; - base?: string; - sort?: "created" | "updated" | "popularity" | "long-running"; - direction?: "asc" | "desc"; - per_page?: number; - page?: number; - } = {} -): Promise[]> { + params: RepositoryParams & Partial +): Promise { + const { owner, repo, ...options } = z.object({ + ...RepositoryParamsSchema.shape, + ...ListPullRequestsOptionsSchema.partial().shape, + }).parse(params); + const url = new URL(`https://api.github.com/repos/${owner}/${repo}/pulls`); + Object.entries(options).forEach(([key, value]) => { if (value !== undefined) { url.searchParams.append(key, value.toString()); From 42872be9a2ea8d76e26f8b0774faced5624df3c1 Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 03:12:43 -0800 Subject: [PATCH 40/62] refactor: remove documentation and comments --- src/github/operations/pulls.ts | 61 +++++++++------------------------- 1 file changed, 15 insertions(+), 46 deletions(-) diff --git a/src/github/operations/pulls.ts b/src/github/operations/pulls.ts index 49084c94..0b9b9643 100644 --- a/src/github/operations/pulls.ts +++ b/src/github/operations/pulls.ts @@ -5,46 +5,41 @@ import { GitHubRepositorySchema } from "../common/types.js"; -// Constants for GitHub limits and constraints const GITHUB_TITLE_MAX_LENGTH = 256; const GITHUB_BODY_MAX_LENGTH = 65536; -// Base schema for repository identification export const RepositoryParamsSchema = z.object({ - owner: z.string().min(1).describe("Repository owner (username or organization)"), - repo: z.string().min(1).describe("Repository name"), + owner: z.string().min(1), + repo: z.string().min(1), }); -// Common validation schemas export const GitHubPullRequestStateSchema = z.enum([ "open", "closed", "merged", "draft" -]).describe("The current state of the pull request"); +]); export const GitHubPullRequestSortSchema = z.enum([ "created", "updated", "popularity", "long-running" -]).describe("The sorting field for pull requests"); +]); export const GitHubDirectionSchema = z.enum([ "asc", "desc" -]).describe("The sort direction"); +]); -// Pull request head/base schema export const GitHubPullRequestRefSchema = z.object({ label: z.string(), ref: z.string().min(1), sha: z.string().length(40), user: GitHubIssueAssigneeSchema, repo: GitHubRepositorySchema, -}).describe("Reference information for pull request head or base"); +}); -// Main pull request schema export const GitHubPullRequestSchema = z.object({ url: z.string().url(), id: z.number().positive(), @@ -76,7 +71,6 @@ export const GitHubPullRequestSchema = z.object({ base: GitHubPullRequestRefSchema, }); -// Request schemas export const ListPullRequestsOptionsSchema = z.object({ state: GitHubPullRequestStateSchema.optional(), head: z.string().optional(), @@ -85,37 +79,27 @@ export const ListPullRequestsOptionsSchema = z.object({ direction: GitHubDirectionSchema.optional(), per_page: z.number().min(1).max(100).optional(), page: z.number().min(1).optional(), -}).describe("Options for listing pull requests"); +}); export const CreatePullRequestOptionsSchema = z.object({ - title: z.string().max(GITHUB_TITLE_MAX_LENGTH).describe("Pull request title"), - body: z.string().max(GITHUB_BODY_MAX_LENGTH).optional().describe("Pull request body/description"), - head: z.string().min(1).describe("The name of the branch where your changes are implemented"), - base: z.string().min(1).describe("The name of the branch you want the changes pulled into"), - maintainer_can_modify: z.boolean().optional().describe("Whether maintainers can modify the pull request"), - draft: z.boolean().optional().describe("Whether to create the pull request as a draft"), -}).describe("Options for creating a pull request"); + title: z.string().max(GITHUB_TITLE_MAX_LENGTH), + body: z.string().max(GITHUB_BODY_MAX_LENGTH).optional(), + head: z.string().min(1), + base: z.string().min(1), + maintainer_can_modify: z.boolean().optional(), + draft: z.boolean().optional(), +}); -// Combine repository params with operation options export const CreatePullRequestSchema = RepositoryParamsSchema.extend({ ...CreatePullRequestOptionsSchema.shape, }); -// Type exports export type RepositoryParams = z.infer; export type CreatePullRequestOptions = z.infer; export type ListPullRequestsOptions = z.infer; export type GitHubPullRequest = z.infer; export type GitHubPullRequestRef = z.infer; -/** - * Creates a new pull request in a repository. - * - * @param params Repository identification and pull request creation options - * @returns Promise resolving to the created pull request - * @throws {ZodError} If the input parameters fail validation - * @throws {Error} If the GitHub API request fails - */ export async function createPullRequest( params: z.infer ): Promise { @@ -132,13 +116,6 @@ export async function createPullRequest( return GitHubPullRequestSchema.parse(response); } -/** - * Retrieves a specific pull request by its number. - * - * @param params Repository parameters and pull request number - * @returns Promise resolving to the pull request details - * @throws {Error} If the pull request is not found or the request fails - */ export async function getPullRequest( params: RepositoryParams & { pullNumber: number } ): Promise { @@ -154,14 +131,6 @@ export async function getPullRequest( return GitHubPullRequestSchema.parse(response); } -/** - * Lists pull requests in a repository with optional filtering. - * - * @param params Repository parameters and listing options - * @returns Promise resolving to an array of pull requests - * @throws {ZodError} If the input parameters fail validation - * @throws {Error} If the GitHub API request fails - */ export async function listPullRequests( params: RepositoryParams & Partial ): Promise { @@ -180,4 +149,4 @@ export async function listPullRequests( const response = await githubRequest(url.toString()); return z.array(GitHubPullRequestSchema).parse(response); -} +} \ No newline at end of file From b8b7c1b7844c73e88fb624d08734dc9e4ba87c85 Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 03:16:19 -0800 Subject: [PATCH 41/62] refactor: update pull request handler to use new parameter style --- src/github/index.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/github/index.ts b/src/github/index.ts index 47060542..f545baa8 100644 --- a/src/github/index.ts +++ b/src/github/index.ts @@ -8,7 +8,6 @@ import { import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; -// Import operations import * as repository from './operations/repository.js'; import * as files from './operations/files.js'; import * as issues from './operations/issues.js'; @@ -223,8 +222,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { case "create_pull_request": { const args = pulls.CreatePullRequestSchema.parse(request.params.arguments); - const { owner, repo, ...options } = args; - const pullRequest = await pulls.createPullRequest(owner, repo, options); + const pullRequest = await pulls.createPullRequest(args); return { content: [{ type: "text", text: JSON.stringify(pullRequest, null, 2) }], }; @@ -314,5 +312,4 @@ async function runServer() { runServer().catch((error) => { console.error("Fatal error in main():", error); - process.exit(1); -}); \ No newline at end of file + process.exit(1); \ No newline at end of file From e921c2725cd8103987f34da69f3c4b42a3fd0005 Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 03:19:51 -0800 Subject: [PATCH 42/62] fix: restore proper runServer function closure --- src/github/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/github/index.ts b/src/github/index.ts index f545baa8..286cc7bb 100644 --- a/src/github/index.ts +++ b/src/github/index.ts @@ -312,4 +312,5 @@ async function runServer() { runServer().catch((error) => { console.error("Fatal error in main():", error); - process.exit(1); \ No newline at end of file + process.exit(1); +}); \ No newline at end of file From fb421b4837b87a5edeea993ecd794cb4f3325b82 Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 03:25:15 -0800 Subject: [PATCH 43/62] feat: add GitHub API error handling utilities --- src/github/common/errors.ts | 89 +++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/github/common/errors.ts diff --git a/src/github/common/errors.ts b/src/github/common/errors.ts new file mode 100644 index 00000000..5b940f3b --- /dev/null +++ b/src/github/common/errors.ts @@ -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 + ); + } +} \ No newline at end of file From ff2f2c5347e442fbe6c070b7bd292a170e6a4b3d Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 03:25:31 -0800 Subject: [PATCH 44/62] feat: enhance GitHub request utilities with error handling --- src/github/common/utils.ts | 147 ++++++++++++++++++++++++++++--------- 1 file changed, 112 insertions(+), 35 deletions(-) diff --git a/src/github/common/utils.ts b/src/github/common/utils.ts index 0e9e6526..ef2fc0bb 100644 --- a/src/github/common/utils.ts +++ b/src/github/common/utils.ts @@ -1,46 +1,123 @@ -import fetch from "node-fetch"; +import { createGitHubError } from "./errors.js"; -if (!process.env.GITHUB_PERSONAL_ACCESS_TOKEN) { - console.error("GITHUB_PERSONAL_ACCESS_TOKEN environment variable is not set"); - process.exit(1); -} - -export const GITHUB_PERSONAL_ACCESS_TOKEN = process.env.GITHUB_PERSONAL_ACCESS_TOKEN; - -interface GitHubRequestOptions { +type RequestOptions = { method?: string; - body?: any; + body?: unknown; + headers?: Record; +}; + +async function parseResponseBody(response: Response): Promise { + const contentType = response.headers.get("content-type"); + if (contentType?.includes("application/json")) { + return response.json(); + } + return response.text(); } -export async function githubRequest(url: string, options: GitHubRequestOptions = {}) { - const response = await fetch(url, { - method: options.method || "GET", - headers: { - Authorization: `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`, - Accept: "application/vnd.github.v3+json", - "User-Agent": "github-mcp-server", - ...(options.body ? { "Content-Type": "application/json" } : {}), - }, - ...(options.body ? { body: JSON.stringify(options.body) } : {}), - }); +export async function githubRequest( + url: string, + options: RequestOptions = {} +): Promise { + const headers = { + "Accept": "application/vnd.github.v3+json", + "Content-Type": "application/json", + ...options.headers, + }; - if (!response.ok) { - throw new Error(`GitHub API error: ${response.statusText}`); + if (process.env.GITHUB_TOKEN) { + headers["Authorization"] = `Bearer ${process.env.GITHUB_TOKEN}`; } - return response.json(); + 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 buildUrl(baseUrl: string, params: Record = {}) { - const url = new URL(baseUrl); - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== null) { - if (Array.isArray(value)) { - url.searchParams.append(key, value.join(",")); - } else { - url.searchParams.append(key, value.toString()); - } +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 { + 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; } - }); - return url.toString(); + throw error; + } +} + +export async function checkUserExists(username: string): Promise { + 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; + } } \ No newline at end of file From 10bd24dd02af122965b84d7327495e67e55f14f1 Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 03:26:55 -0800 Subject: [PATCH 45/62] feat: enhance pull request operations with validation and error handling - Add branch existence validation - Add duplicate PR check - Add comprehensive error handling - Improve type safety with zod transforms - Add input sanitization --- src/github/operations/pulls.ts | 166 +++++++++++++++++++++++++++------ 1 file changed, 136 insertions(+), 30 deletions(-) diff --git a/src/github/operations/pulls.ts b/src/github/operations/pulls.ts index 0b9b9643..00ad695b 100644 --- a/src/github/operations/pulls.ts +++ b/src/github/operations/pulls.ts @@ -1,16 +1,28 @@ import { z } from "zod"; -import { githubRequest } from "../common/utils.js"; +import { + githubRequest, + validateBranchName, + validateOwnerName, + validateRepositoryName, + checkBranchExists, +} from "../common/utils.js"; import { GitHubIssueAssigneeSchema, GitHubRepositorySchema } from "../common/types.js"; +import { + GitHubError, + GitHubValidationError, + GitHubResourceNotFoundError, + GitHubConflictError, +} from "../common/errors.js"; const GITHUB_TITLE_MAX_LENGTH = 256; const GITHUB_BODY_MAX_LENGTH = 65536; export const RepositoryParamsSchema = z.object({ - owner: z.string().min(1), - repo: z.string().min(1), + owner: z.string().min(1).transform(validateOwnerName), + repo: z.string().min(1).transform(validateRepositoryName), }); export const GitHubPullRequestStateSchema = z.enum([ @@ -34,7 +46,7 @@ export const GitHubDirectionSchema = z.enum([ export const GitHubPullRequestRefSchema = z.object({ label: z.string(), - ref: z.string().min(1), + ref: z.string().min(1).transform(validateBranchName), sha: z.string().length(40), user: GitHubIssueAssigneeSchema, repo: GitHubRepositorySchema, @@ -73,8 +85,8 @@ export const GitHubPullRequestSchema = z.object({ export const ListPullRequestsOptionsSchema = z.object({ state: GitHubPullRequestStateSchema.optional(), - head: z.string().optional(), - base: z.string().optional(), + head: z.string().transform(validateBranchName).optional(), + base: z.string().transform(validateBranchName).optional(), sort: GitHubPullRequestSortSchema.optional(), direction: GitHubDirectionSchema.optional(), per_page: z.number().min(1).max(100).optional(), @@ -84,8 +96,8 @@ export const ListPullRequestsOptionsSchema = z.object({ export const CreatePullRequestOptionsSchema = z.object({ title: z.string().max(GITHUB_TITLE_MAX_LENGTH), body: z.string().max(GITHUB_BODY_MAX_LENGTH).optional(), - head: z.string().min(1), - base: z.string().min(1), + head: z.string().min(1).transform(validateBranchName), + base: z.string().min(1).transform(validateBranchName), maintainer_can_modify: z.boolean().optional(), draft: z.boolean().optional(), }); @@ -100,20 +112,86 @@ export type ListPullRequestsOptions = z.infer; export type GitHubPullRequestRef = z.infer; +async function validatePullRequestBranches( + owner: string, + repo: string, + head: string, + base: string +): Promise { + const [headExists, baseExists] = await Promise.all([ + checkBranchExists(owner, repo, head), + checkBranchExists(owner, repo, base), + ]); + + if (!headExists) { + throw new GitHubResourceNotFoundError(`Branch '${head}' not found`); + } + + if (!baseExists) { + throw new GitHubResourceNotFoundError(`Branch '${base}' not found`); + } + + if (head === base) { + throw new GitHubValidationError( + "Head and base branches cannot be the same", + 422, + { message: "Head and base branches must be different" } + ); + } +} + +async function checkForExistingPullRequest( + owner: string, + repo: string, + head: string, + base: string +): Promise { + const existingPRs = await listPullRequests({ + owner, + repo, + head, + base, + state: "open", + }); + + if (existingPRs.length > 0) { + throw new GitHubConflictError( + `A pull request already exists for ${head} into ${base}` + ); + } +} + export async function createPullRequest( params: z.infer ): Promise { const { owner, repo, ...options } = CreatePullRequestSchema.parse(params); - - const response = await githubRequest( - `https://api.github.com/repos/${owner}/${repo}/pulls`, - { - method: "POST", - body: options, - } - ); - return GitHubPullRequestSchema.parse(response); + try { + await validatePullRequestBranches(owner, repo, options.head, options.base); + await checkForExistingPullRequest(owner, repo, options.head, options.base); + + const response = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/pulls`, + { + method: "POST", + body: options, + } + ); + + return GitHubPullRequestSchema.parse(response); + } catch (error) { + if (error instanceof GitHubError) { + throw error; + } + if (error instanceof z.ZodError) { + throw new GitHubValidationError( + "Invalid pull request data", + 422, + { errors: error.errors } + ); + } + throw error; + } } export async function getPullRequest( @@ -124,11 +202,25 @@ export async function getPullRequest( pullNumber: z.number().positive(), }).parse(params); - const response = await githubRequest( - `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}` - ); + try { + const response = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}` + ); - return GitHubPullRequestSchema.parse(response); + return GitHubPullRequestSchema.parse(response); + } catch (error) { + if (error instanceof GitHubError) { + throw error; + } + if (error instanceof z.ZodError) { + throw new GitHubValidationError( + "Invalid pull request response data", + 422, + { errors: error.errors } + ); + } + throw error; + } } export async function listPullRequests( @@ -139,14 +231,28 @@ export async function listPullRequests( ...ListPullRequestsOptionsSchema.partial().shape, }).parse(params); - const url = new URL(`https://api.github.com/repos/${owner}/${repo}/pulls`); - - Object.entries(options).forEach(([key, value]) => { - if (value !== undefined) { - url.searchParams.append(key, value.toString()); - } - }); + try { + const url = new URL(`https://api.github.com/repos/${owner}/${repo}/pulls`); + + Object.entries(options).forEach(([key, value]) => { + if (value !== undefined) { + url.searchParams.append(key, value.toString()); + } + }); - const response = await githubRequest(url.toString()); - return z.array(GitHubPullRequestSchema).parse(response); + const response = await githubRequest(url.toString()); + return z.array(GitHubPullRequestSchema).parse(response); + } catch (error) { + if (error instanceof GitHubError) { + throw error; + } + if (error instanceof z.ZodError) { + throw new GitHubValidationError( + "Invalid pull request list response data", + 422, + { errors: error.errors } + ); + } + throw error; + } } \ No newline at end of file From 272e26935b7450216069256d0dd6331e1ed3a22f Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 03:31:15 -0800 Subject: [PATCH 46/62] feat: add GitHub error handling to MCP server - Import GitHubError types - Add error formatting utility - Update error handling in request handler --- src/github/index.ts | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/github/index.ts b/src/github/index.ts index 286cc7bb..fd77f017 100644 --- a/src/github/index.ts +++ b/src/github/index.ts @@ -15,6 +15,16 @@ import * as pulls from './operations/pulls.js'; import * as branches from './operations/branches.js'; import * as search from './operations/search.js'; import * as commits from './operations/commits.js'; +import { + GitHubError, + GitHubValidationError, + GitHubResourceNotFoundError, + GitHubAuthenticationError, + GitHubPermissionError, + GitHubRateLimitError, + GitHubConflictError, + isGitHubError, +} from './common/errors.js'; const server = new Server( { @@ -28,6 +38,29 @@ const server = new Server( } ); +function formatGitHubError(error: GitHubError): string { + let message = `GitHub API Error: ${error.message}`; + + if (error instanceof GitHubValidationError) { + message = `Validation Error: ${error.message}`; + if (error.response) { + message += `\nDetails: ${JSON.stringify(error.response)}`; + } + } else if (error instanceof GitHubResourceNotFoundError) { + message = `Not Found: ${error.message}`; + } else if (error instanceof GitHubAuthenticationError) { + message = `Authentication Failed: ${error.message}`; + } else if (error instanceof GitHubPermissionError) { + message = `Permission Denied: ${error.message}`; + } else if (error instanceof GitHubRateLimitError) { + message = `Rate Limit Exceeded: ${error.message}\nResets at: ${error.resetAt.toISOString()}`; + } else if (error instanceof GitHubConflictError) { + message = `Conflict: ${error.message}`; + } + + return message; +} + server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ @@ -298,7 +331,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } } catch (error) { if (error instanceof z.ZodError) { - throw new Error(`ZodErrors: ${JSON.stringify(error.errors)}`); + throw new Error(`Invalid input: ${JSON.stringify(error.errors)}`); + } + if (isGitHubError(error)) { + throw new Error(formatGitHubError(error)); } throw error; } From 3e1b3caaec9c7525ef2641ebc29bb57447d0b787 Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 03:34:39 -0800 Subject: [PATCH 47/62] fix: resolve typescript errors and add buildUrl utility - Fix headers type assertion issue - Add buildUrl utility function for URL parameter handling --- src/github/common/utils.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/github/common/utils.ts b/src/github/common/utils.ts index ef2fc0bb..0e6f731a 100644 --- a/src/github/common/utils.ts +++ b/src/github/common/utils.ts @@ -14,11 +14,21 @@ async function parseResponseBody(response: Response): Promise { return response.text(); } +export function buildUrl(baseUrl: string, params: Record): 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 { - const headers = { + const headers: Record = { "Accept": "application/vnd.github.v3+json", "Content-Type": "application/json", ...options.headers, From 10f0aec693273a30c16d4e1c03c61914d759a24b Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 03:34:45 -0800 Subject: [PATCH 48/62] fix: use buildUrl utility in commits module --- src/github/operations/commits.ts | 97 +++++--------------------------- 1 file changed, 14 insertions(+), 83 deletions(-) diff --git a/src/github/operations/commits.ts b/src/github/operations/commits.ts index 09b4302a..b10e1b5f 100644 --- a/src/github/operations/commits.ts +++ b/src/github/operations/commits.ts @@ -1,95 +1,26 @@ import { z } from "zod"; import { githubRequest, buildUrl } from "../common/utils.js"; -import { GitHubCommitSchema, GitHubListCommitsSchema } from "../common/types.js"; -// Schema definitions export const ListCommitsSchema = z.object({ - owner: z.string().describe("Repository owner (username or organization)"), - repo: z.string().describe("Repository name"), - page: z.number().optional().describe("Page number for pagination (default: 1)"), - perPage: z.number().optional().describe("Number of results per page (default: 30, max: 100)"), - sha: z.string().optional().describe("SHA of the commit to start listing from"), + owner: z.string(), + repo: z.string(), + sha: z.string().optional(), + page: z.number().optional(), + perPage: z.number().optional() }); -// Type exports -export type ListCommitsParams = z.infer; - -// Function implementations export async function listCommits( owner: string, repo: string, - page: number = 1, - perPage: number = 30, + page?: number, + perPage?: number, sha?: string ) { - const params = { - page, - per_page: perPage, - ...(sha ? { sha } : {}) - }; - - const url = buildUrl(`https://api.github.com/repos/${owner}/${repo}/commits`, params); - - const response = await githubRequest(url); - return GitHubListCommitsSchema.parse(response); -} - -export async function getCommit( - owner: string, - repo: string, - sha: string -) { - const response = await githubRequest( - `https://api.github.com/repos/${owner}/${repo}/git/commits/${sha}` + return githubRequest( + buildUrl(`https://api.github.com/repos/${owner}/${repo}/commits`, { + page: page?.toString(), + per_page: perPage?.toString(), + sha + }) ); - - return GitHubCommitSchema.parse(response); -} - -export async function createCommit( - owner: string, - repo: string, - message: string, - tree: string, - parents: string[] -) { - const response = await githubRequest( - `https://api.github.com/repos/${owner}/${repo}/git/commits`, - { - method: "POST", - body: { - message, - tree, - parents, - }, - } - ); - - return GitHubCommitSchema.parse(response); -} - -export async function compareCommits( - owner: string, - repo: string, - base: string, - head: string -) { - const response = await githubRequest( - `https://api.github.com/repos/${owner}/${repo}/compare/${base}...${head}` - ); - - return z.object({ - url: z.string(), - html_url: z.string(), - permalink_url: z.string(), - diff_url: z.string(), - patch_url: z.string(), - base_commit: GitHubCommitSchema, - merge_base_commit: GitHubCommitSchema, - commits: z.array(GitHubCommitSchema), - total_commits: z.number(), - status: z.string(), - ahead_by: z.number(), - behind_by: z.number(), - }).parse(response); -} +} \ No newline at end of file From dac0b7cc343dcdc1805a892336ecf8824e381ba3 Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 03:34:59 -0800 Subject: [PATCH 49/62] fix: use buildUrl utility in issues module --- src/github/operations/issues.ts | 153 ++++++++++++-------------------- 1 file changed, 55 insertions(+), 98 deletions(-) diff --git a/src/github/operations/issues.ts b/src/github/operations/issues.ts index aec154f0..4681d26c 100644 --- a/src/github/operations/issues.ts +++ b/src/github/operations/issues.ts @@ -1,41 +1,43 @@ import { z } from "zod"; import { githubRequest, buildUrl } from "../common/utils.js"; -import { - GitHubIssueSchema, - GitHubLabelSchema, - GitHubIssueAssigneeSchema, - GitHubMilestoneSchema, -} from "../common/types.js"; -// Schema definitions +export const GetIssueSchema = z.object({ + owner: z.string(), + repo: z.string(), + issue_number: z.number(), +}); + +export const IssueCommentSchema = z.object({ + owner: z.string(), + repo: z.string(), + issue_number: z.number(), + body: z.string(), +}); + export const CreateIssueOptionsSchema = z.object({ - title: z.string().describe("Issue title"), - body: z.string().optional().describe("Issue body/description"), - assignees: z.array(z.string()).optional().describe("Array of usernames to assign"), - milestone: z.number().optional().describe("Milestone number to assign"), - labels: z.array(z.string()).optional().describe("Array of label names"), + title: z.string(), + body: z.string().optional(), + assignees: z.array(z.string()).optional(), + milestone: z.number().optional(), + labels: z.array(z.string()).optional(), }); export const CreateIssueSchema = z.object({ - owner: z.string().describe("Repository owner (username or organization)"), - repo: z.string().describe("Repository name"), - title: z.string().describe("Issue title"), - body: z.string().optional().describe("Issue body/description"), - assignees: z.array(z.string()).optional().describe("Array of usernames to assign"), - labels: z.array(z.string()).optional().describe("Array of label names"), - milestone: z.number().optional().describe("Milestone number to assign"), + owner: z.string(), + repo: z.string(), + ...CreateIssueOptionsSchema.shape, }); export const ListIssuesOptionsSchema = z.object({ owner: z.string(), repo: z.string(), - state: z.enum(['open', 'closed', 'all']).optional(), + direction: z.enum(["asc", "desc"]).optional(), labels: z.array(z.string()).optional(), - sort: z.enum(['created', 'updated', 'comments']).optional(), - direction: z.enum(['asc', 'desc']).optional(), - since: z.string().optional(), // ISO 8601 timestamp page: z.number().optional(), - per_page: z.number().optional() + per_page: z.number().optional(), + since: z.string().optional(), + sort: z.enum(["created", "updated", "comments"]).optional(), + state: z.enum(["open", "closed", "all"]).optional(), }); export const UpdateIssueOptionsSchema = z.object({ @@ -44,108 +46,63 @@ export const UpdateIssueOptionsSchema = z.object({ issue_number: z.number(), title: z.string().optional(), body: z.string().optional(), - state: z.enum(['open', 'closed']).optional(), - labels: z.array(z.string()).optional(), assignees: z.array(z.string()).optional(), - milestone: z.number().optional() + milestone: z.number().optional(), + labels: z.array(z.string()).optional(), + state: z.enum(["open", "closed"]).optional(), }); -export const IssueCommentSchema = z.object({ - owner: z.string(), - repo: z.string(), - issue_number: z.number(), - body: z.string() -}); +export async function getIssue(owner: string, repo: string, issue_number: number) { + return githubRequest(`https://api.github.com/repos/${owner}/${repo}/issues/${issue_number}`); +} -export const GetIssueSchema = z.object({ - owner: z.string().describe("Repository owner (username or organization)"), - repo: z.string().describe("Repository name"), - issue_number: z.number().describe("Issue number") -}); +export async function addIssueComment( + owner: string, + repo: string, + issue_number: number, + body: string +) { + return githubRequest(`https://api.github.com/repos/${owner}/${repo}/issues/${issue_number}/comments`, { + method: "POST", + body: { body }, + }); +} -// Type exports -export type CreateIssueOptions = z.infer; -export type ListIssuesOptions = z.infer; -export type UpdateIssueOptions = z.infer; - -// Function implementations export async function createIssue( owner: string, repo: string, - options: CreateIssueOptions + options: z.infer ) { - const response = await githubRequest( + return githubRequest( `https://api.github.com/repos/${owner}/${repo}/issues`, { method: "POST", body: options, } ); - - return GitHubIssueSchema.parse(response); } export async function listIssues( owner: string, repo: string, - options: Omit + options: Omit, "owner" | "repo"> ) { - const url = buildUrl(`https://api.github.com/repos/${owner}/${repo}/issues`, options); - const response = await githubRequest(url); - return z.array(GitHubIssueSchema).parse(response); + return githubRequest( + buildUrl(`https://api.github.com/repos/${owner}/${repo}/issues`, options) + ); } export async function updateIssue( owner: string, repo: string, - issueNumber: number, - options: Omit + issue_number: number, + options: Omit, "owner" | "repo" | "issue_number"> ) { - const response = await githubRequest( - `https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}`, + return githubRequest( + `https://api.github.com/repos/${owner}/${repo}/issues/${issue_number}`, { method: "PATCH", - body: options + body: options, } ); - - return GitHubIssueSchema.parse(response); -} - -export async function addIssueComment( - owner: string, - repo: string, - issueNumber: number, - body: string -) { - const response = await githubRequest( - `https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}/comments`, - { - method: "POST", - body: { body } - } - ); - - return z.object({ - id: z.number(), - node_id: z.string(), - url: z.string(), - html_url: z.string(), - body: z.string(), - user: GitHubIssueAssigneeSchema, - created_at: z.string(), - updated_at: z.string(), - }).parse(response); -} - -export async function getIssue( - owner: string, - repo: string, - issueNumber: number -) { - const response = await githubRequest( - `https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}` - ); - - return GitHubIssueSchema.parse(response); -} +} \ No newline at end of file From 8016e366cd0fff812f7ae7aa134d314c8aaa5197 Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 03:35:09 -0800 Subject: [PATCH 50/62] fix: use buildUrl utility in search module --- src/github/operations/search.ts | 101 +++++++------------------------- 1 file changed, 21 insertions(+), 80 deletions(-) diff --git a/src/github/operations/search.ts b/src/github/operations/search.ts index 08e2fd17..76faa729 100644 --- a/src/github/operations/search.ts +++ b/src/github/operations/search.ts @@ -1,16 +1,18 @@ import { z } from "zod"; import { githubRequest, buildUrl } from "../common/utils.js"; -// Schema definitions -export const SearchCodeSchema = z.object({ - q: z.string().describe("Search query. See GitHub code search syntax: https://docs.github.com/en/search-github/searching-on-github/searching-code"), - order: z.enum(["asc", "desc"]).optional().describe("Sort order (asc or desc)"), - per_page: z.number().min(1).max(100).optional().describe("Results per page (max 100)"), - page: z.number().min(1).optional().describe("Page number"), +export const SearchOptions = z.object({ + q: z.string(), + order: z.enum(["asc", "desc"]).optional(), + page: z.number().min(1).optional(), + per_page: z.number().min(1).max(100).optional(), }); -export const SearchIssuesSchema = z.object({ - q: z.string().describe("Search query. See GitHub issues search syntax: https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests"), +export const SearchUsersOptions = SearchOptions.extend({ + sort: z.enum(["followers", "repositories", "joined"]).optional(), +}); + +export const SearchIssuesOptions = SearchOptions.extend({ sort: z.enum([ "comments", "reactions", @@ -23,82 +25,21 @@ export const SearchIssuesSchema = z.object({ "interactions", "created", "updated", - ]).optional().describe("Sort field"), - order: z.enum(["asc", "desc"]).optional().describe("Sort order (asc or desc)"), - per_page: z.number().min(1).max(100).optional().describe("Results per page (max 100)"), - page: z.number().min(1).optional().describe("Page number"), + ]).optional(), }); -export const SearchUsersSchema = z.object({ - q: z.string().describe("Search query. See GitHub users search syntax: https://docs.github.com/en/search-github/searching-on-github/searching-users"), - sort: z.enum(["followers", "repositories", "joined"]).optional().describe("Sort field"), - order: z.enum(["asc", "desc"]).optional().describe("Sort order (asc or desc)"), - per_page: z.number().min(1).max(100).optional().describe("Results per page (max 100)"), - page: z.number().min(1).optional().describe("Page number"), -}); +export const SearchCodeSchema = SearchOptions; +export const SearchUsersSchema = SearchUsersOptions; +export const SearchIssuesSchema = SearchIssuesOptions; -// Response schemas -export const SearchCodeItemSchema = z.object({ - name: z.string().describe("The name of the file"), - path: z.string().describe("The path to the file in the repository"), - sha: z.string().describe("The SHA hash of the file"), - url: z.string().describe("The API URL for this file"), - git_url: z.string().describe("The Git URL for this file"), - html_url: z.string().describe("The HTML URL to view this file on GitHub"), - repository: z.object({ - full_name: z.string(), - description: z.string().nullable(), - url: z.string(), - html_url: z.string(), - }).describe("The repository where this file was found"), - score: z.number().describe("The search result score"), -}); - -export const SearchCodeResponseSchema = z.object({ - total_count: z.number().describe("Total number of matching results"), - incomplete_results: z.boolean().describe("Whether the results are incomplete"), - items: z.array(SearchCodeItemSchema).describe("The search results"), -}); - -export const SearchUsersResponseSchema = z.object({ - total_count: z.number().describe("Total number of matching results"), - incomplete_results: z.boolean().describe("Whether the results are incomplete"), - items: z.array(z.object({ - login: z.string().describe("The username of the user"), - id: z.number().describe("The ID of the user"), - node_id: z.string().describe("The Node ID of the user"), - avatar_url: z.string().describe("The avatar URL of the user"), - gravatar_id: z.string().describe("The Gravatar ID of the user"), - url: z.string().describe("The API URL for this user"), - html_url: z.string().describe("The HTML URL to view this user on GitHub"), - type: z.string().describe("The type of this user"), - site_admin: z.boolean().describe("Whether this user is a site administrator"), - score: z.number().describe("The search result score"), - })).describe("The search results"), -}); - -// Type exports -export type SearchCodeParams = z.infer; -export type SearchIssuesParams = z.infer; -export type SearchUsersParams = z.infer; -export type SearchCodeResponse = z.infer; -export type SearchUsersResponse = z.infer; - -// Function implementations -export async function searchCode(params: SearchCodeParams): Promise { - const url = buildUrl("https://api.github.com/search/code", params); - const response = await githubRequest(url); - return SearchCodeResponseSchema.parse(response); +export async function searchCode(params: z.infer) { + return githubRequest(buildUrl("https://api.github.com/search/code", params)); } -export async function searchIssues(params: SearchIssuesParams) { - const url = buildUrl("https://api.github.com/search/issues", params); - const response = await githubRequest(url); - return response; +export async function searchIssues(params: z.infer) { + return githubRequest(buildUrl("https://api.github.com/search/issues", params)); } -export async function searchUsers(params: SearchUsersParams): Promise { - const url = buildUrl("https://api.github.com/search/users", params); - const response = await githubRequest(url); - return SearchUsersResponseSchema.parse(response); -} +export async function searchUsers(params: z.infer) { + return githubRequest(buildUrl("https://api.github.com/search/users", params)); +} \ No newline at end of file From cfd613693c9f0072b6ae7b46de1ea67473fcba92 Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 03:39:15 -0800 Subject: [PATCH 51/62] fix: handle URL parameter types correctly in listIssues function --- src/github/operations/issues.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/github/operations/issues.ts b/src/github/operations/issues.ts index 4681d26c..d2907bf7 100644 --- a/src/github/operations/issues.ts +++ b/src/github/operations/issues.ts @@ -87,8 +87,18 @@ export async function listIssues( repo: string, options: Omit, "owner" | "repo"> ) { + const urlParams: Record = { + direction: options.direction, + labels: options.labels?.join(","), + page: options.page?.toString(), + per_page: options.per_page?.toString(), + since: options.since, + sort: options.sort, + state: options.state + }; + return githubRequest( - buildUrl(`https://api.github.com/repos/${owner}/${repo}/issues`, options) + buildUrl(`https://api.github.com/repos/${owner}/${repo}/issues`, urlParams) ); } From 339a7b67088dab021467c36110740db8eb349173 Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 03:48:07 -0800 Subject: [PATCH 52/62] fix: restore original environment variable name for GitHub token --- src/github/common/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/github/common/utils.ts b/src/github/common/utils.ts index 0e6f731a..21c8aa71 100644 --- a/src/github/common/utils.ts +++ b/src/github/common/utils.ts @@ -34,8 +34,8 @@ export async function githubRequest( ...options.headers, }; - if (process.env.GITHUB_TOKEN) { - headers["Authorization"] = `Bearer ${process.env.GITHUB_TOKEN}`; + if (process.env.GITHUB_PERSONAL_ACCESS_TOKEN) { + headers["Authorization"] = `Bearer ${process.env.GITHUB_PERSONAL_ACCESS_TOKEN}`; } const response = await fetch(url, { From eea524abcf884def276d1b1a28838b62c238b0d0 Mon Sep 17 00:00:00 2001 From: "Peter M. Elias" Date: Sat, 28 Dec 2024 13:10:13 -0800 Subject: [PATCH 53/62] fix: make checkForExistingPullRequest check exact head/base match --- src/github/operations/pulls.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/github/operations/pulls.ts b/src/github/operations/pulls.ts index 00ad695b..a3e05e63 100644 --- a/src/github/operations/pulls.ts +++ b/src/github/operations/pulls.ts @@ -149,12 +149,15 @@ async function checkForExistingPullRequest( const existingPRs = await listPullRequests({ owner, repo, - head, - base, state: "open", }); - if (existingPRs.length > 0) { + // Check if any existing open PR has the exact same head and base combination + const duplicatePR = existingPRs.find(pr => + pr.head.ref === head && pr.base.ref === base + ); + + if (duplicatePR) { throw new GitHubConflictError( `A pull request already exists for ${head} into ${base}` ); From 3d8c33cd9ac9b85458cdb58b628a747d7f8a3d31 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Mon, 13 Jan 2025 20:01:18 +0000 Subject: [PATCH 54/62] feat: enhance release workflow with artifact collection and job output handling --- .github/workflows/release.yml | 119 +++++++++++++++++++++++++++------- 1 file changed, 95 insertions(+), 24 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d2d084de..588cacf5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,14 +21,17 @@ jobs: - name: Find package directories id: set-matrix run: | + # Find all package.json and pyproject.toml files, excluding root DIRS=$(git ls-tree -r HEAD --name-only | grep -E "package.json|pyproject.toml" | xargs dirname | grep -v "^.$" | jq -R -s -c 'split("\n")[:-1]') echo "matrix=${DIRS}" >> $GITHUB_OUTPUT + echo "Found directories: ${DIRS}" - name: Get last release hash id: last-release run: | HASH=$(git rev-list --tags --max-count=1 || echo "HEAD~1") echo "hash=${HASH}" >> $GITHUB_OUTPUT + echo "Using last release hash: ${HASH}" release: needs: prepare @@ -50,23 +53,76 @@ jobs: - uses: astral-sh/setup-uv@v5 - name: Setup Node.js - if: endsWith(matrix.directory, 'package.json') + if: endsWith(matrix.directory, '/package.json') uses: actions/setup-node@v4 with: node-version: '18' registry-url: 'https://registry.npmjs.org' - name: Setup Python - if: endsWith(matrix.directory, 'pyproject.toml') + if: endsWith(matrix.directory, '/pyproject.toml') run: uv python install - name: Release package + id: release env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} - run: uv run --script scripts/release.py "${{ matrix.directory }}" "${{ needs.prepare.outputs.last_release }}" >> "$GITHUB_OUTPUT" + run: | + # Create unique hash for this directory + dir_hash=$(echo "${{ matrix.directory }}" | sha256sum | awk '{print $1}') - create-release: + # Run git diff first to show changes + echo "Changes since last release:" + git diff --name-only "${{ needs.prepare.outputs.last_release }}" -- "${{ matrix.directory }}" || true + + # Run the release + output=$(uv run --script scripts/release.py "${{ matrix.directory }}" "${{ needs.prepare.outputs.last_release }}" 2>&1) + exit_code=$? + + echo "Release output (exit code: $exit_code):" + echo "$output" + + # Extract package info if successful + if [ $exit_code -eq 0 ]; then + pkg_info=$(echo "$output" | grep -o -E "[a-zA-Z0-9\-]+@[0-9]+\.[0-9]+\.[0-9]+" || true) + else + echo "Release failed" + exit 1 + fi + + if [ ! -z "$pkg_info" ]; then + echo "Released package: $pkg_info" + + # Create outputs directory + mkdir -p ./outputs + + # Save both package info and full changes + echo "$pkg_info" > "./outputs/${dir_hash}_info" + echo "dir_hash=${dir_hash}" >> $GITHUB_OUTPUT + + # Log what we're saving + echo "Saved package info to ./outputs/${dir_hash}_info:" + cat "./outputs/${dir_hash}_info" + else + echo "No release needed for this package" + fi + + - name: Set artifact name + if: steps.release.outputs.dir_hash + id: artifact + run: | + # Replace forward slashes with dashes + SAFE_DIR=$(echo "${{ matrix.directory }}" | tr '/' '-') + echo "name=release-outputs-${SAFE_DIR}" >> $GITHUB_OUTPUT + + - uses: actions/upload-artifact@v4 + if: steps.release.outputs.dir_hash + with: + name: ${{ steps.artifact.outputs.name }} + path: ./outputs/${{ steps.release.outputs.dir_hash }}* + + create-tag: needs: [prepare, release] runs-on: ubuntu-latest permissions: @@ -74,30 +130,45 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Create Release + - uses: actions/download-artifact@v4 + with: + pattern: release-outputs-src-* + merge-multiple: true + path: outputs + + - name: Create tag and release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - # Check if there's output from release step - if [ -s "$GITHUB_OUTPUT" ]; then - DATE=$(date +%Y.%m.%d) + if [ -d outputs ]; then + # Collect package info + find outputs -name "*_info" -exec cat {} \; > packages.txt - # Create git tag - git tag -s -a -m"automated release v${DATE}" "v${DATE}" - git push origin "v${DATE}" + if [ -s packages.txt ]; then + DATE=$(date +%Y.%m.%d) + echo "Creating tag v${DATE}" - # Create release notes - echo "# Release ${DATE}" > notes.md - echo "" >> notes.md - echo "## Updated Packages" >> notes.md + # Generate comprehensive release notes + { + echo "# Release ${DATE}" + echo "" + echo "## Updated Packages" + while IFS= read -r line; do + echo "- $line" + done < packages.txt + } > notes.md - # Read updated packages from github output - while IFS= read -r line; do - echo "- ${line}" >> notes.md - done < "$GITHUB_OUTPUT" + # Create and push tag + git tag -a "v${DATE}" -m "Release ${DATE}" + git push origin "v${DATE}" - # Create GitHub release - gh release create "v${DATE}" \ - --title "Release ${DATE}" \ - --notes-file notes.md - fi + # Create GitHub release + gh release create "v${DATE}" \ + --title "Release ${DATE}" \ + --notes-file notes.md + else + echo "No packages need release" + fi + else + echo "No release artifacts found" + fi \ No newline at end of file From a5dbc1d3d3ae32cf4eda2972aa6d56105f37d022 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Mon, 13 Jan 2025 20:07:29 +0000 Subject: [PATCH 55/62] fix: dont specify username --- scripts/release.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/release.py b/scripts/release.py index 17329560..ace528eb 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -135,7 +135,7 @@ def publish_package( # Build and publish to PyPI subprocess.run(["uv", "build"], cwd=path, check=True) subprocess.run( - ["uv", "publish", "--username", "__token__"], + ["uv", "publish"], cwd=path, check=True, ) From fbb0514749279695d0f0c46e2096e93522a271fc Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Mon, 13 Jan 2025 20:32:39 +0000 Subject: [PATCH 56/62] feat: improvements to release.yml --- .github/workflows/release.yml | 213 ++++++++++++++-------------------- 1 file changed, 90 insertions(+), 123 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 588cacf5..ee6eebf3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,31 +1,18 @@ -name: Release +name: Automatic Release Creation on: - schedule: - # Run every day at 9:00 UTC - - cron: '0 9 * * *' - # Allow manual trigger for testing workflow_dispatch: jobs: - prepare: + detect-last-release: runs-on: ubuntu-latest outputs: - matrix: ${{ steps.set-matrix.outputs.matrix }} last_release: ${{ steps.last-release.outputs.hash }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Find package directories - id: set-matrix - run: | - # Find all package.json and pyproject.toml files, excluding root - DIRS=$(git ls-tree -r HEAD --name-only | grep -E "package.json|pyproject.toml" | xargs dirname | grep -v "^.$" | jq -R -s -c 'split("\n")[:-1]') - echo "matrix=${DIRS}" >> $GITHUB_OUTPUT - echo "Found directories: ${DIRS}" - - name: Get last release hash id: last-release run: | @@ -33,142 +20,122 @@ jobs: echo "hash=${HASH}" >> $GITHUB_OUTPUT echo "Using last release hash: ${HASH}" - release: - needs: prepare + create-tag-name: runs-on: ubuntu-latest - environment: release - strategy: - matrix: - directory: ${{ fromJson(needs.prepare.outputs.matrix) }} - fail-fast: false - permissions: - contents: write - packages: write + outputs: + tag_name: ${{ steps.last-release.outputs.tag}} + steps: + - name: Get last release hash + id: last-release + run: | + DATE=$(date +%Y.%m.%d) + echo "tag=v${DATE}" >> $GITHUB_OUTPUT + echo "Using tag: v${DATE}" + detect-packages: + needs: [detect-last-release] + runs-on: ubuntu-latest + outputs: + packages: ${{ steps.find-packages.outputs.packages }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: astral-sh/setup-uv@v5 + - name: Install uv + uses: astral-sh/setup-uv@v5 - - name: Setup Node.js - if: endsWith(matrix.directory, '/package.json') - uses: actions/setup-node@v4 - with: - node-version: '18' - registry-url: 'https://registry.npmjs.org' - - - name: Setup Python - if: endsWith(matrix.directory, '/pyproject.toml') - run: uv python install - - - name: Release package - id: release - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} + - name: Find packages + id: find-packages + working-directory: src run: | - # Create unique hash for this directory - dir_hash=$(echo "${{ matrix.directory }}" | sha256sum | awk '{print $1}') + cat << 'EOF' > find_packages.py + import json + import os + import subprocess + from itertools import chain + from pathlib import Path - # Run git diff first to show changes - echo "Changes since last release:" - git diff --name-only "${{ needs.prepare.outputs.last_release }}" -- "${{ matrix.directory }}" || true + packages = [] - # Run the release - output=$(uv run --script scripts/release.py "${{ matrix.directory }}" "${{ needs.prepare.outputs.last_release }}" 2>&1) - exit_code=$? + print("Starting package detection...") + print(f"Using LAST_RELEASE: {os.environ['LAST_RELEASE']}") - echo "Release output (exit code: $exit_code):" - echo "$output" + # Find all directories containing package.json or pyproject.toml + paths = chain(Path('.').glob('*/package.json'), Path('.').glob('*/pyproject.toml')) + for path in paths: + print(f"\nChecking path: {path}") + # Check for changes in .py or .ts files + # Run git diff from the specific directory + cmd = ['git', 'diff', '--name-only', f'{os.environ["LAST_RELEASE"]}..HEAD', '--', '.'] + result = subprocess.run(cmd, capture_output=True, text=True, cwd=path.parent) - # Extract package info if successful - if [ $exit_code -eq 0 ]; then - pkg_info=$(echo "$output" | grep -o -E "[a-zA-Z0-9\-]+@[0-9]+\.[0-9]+\.[0-9]+" || true) - else - echo "Release failed" - exit 1 - fi + # Check if any .py or .ts files were changed + changed_files = result.stdout.strip().split('\n') + print(f"Changed files found: {changed_files}") - if [ ! -z "$pkg_info" ]; then - echo "Released package: $pkg_info" + has_changes = any(f.endswith(('.py', '.ts')) for f in changed_files if f) + if has_changes: + print(f"Adding package: {path.parent}") + packages.append(str(path.parent)) - # Create outputs directory - mkdir -p ./outputs + print(f"\nFinal packages list: {packages}") - # Save both package info and full changes - echo "$pkg_info" > "./outputs/${dir_hash}_info" - echo "dir_hash=${dir_hash}" >> $GITHUB_OUTPUT + # Write output + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + f.write(f"packages={json.dumps(packages)}\n") + EOF - # Log what we're saving - echo "Saved package info to ./outputs/${dir_hash}_info:" - cat "./outputs/${dir_hash}_info" - else - echo "No release needed for this package" - fi - - - name: Set artifact name - if: steps.release.outputs.dir_hash - id: artifact - run: | - # Replace forward slashes with dashes - SAFE_DIR=$(echo "${{ matrix.directory }}" | tr '/' '-') - echo "name=release-outputs-${SAFE_DIR}" >> $GITHUB_OUTPUT - - - uses: actions/upload-artifact@v4 - if: steps.release.outputs.dir_hash - with: - name: ${{ steps.artifact.outputs.name }} - path: ./outputs/${{ steps.release.outputs.dir_hash }}* + LAST_RELEASE=${{ needs.detect-last-release.outputs.last_release }} uv run --script --python 3.12 find_packages.py create-tag: - needs: [prepare, release] + needs: [detect-packages, create-tag-name] runs-on: ubuntu-latest + environment: release permissions: contents: write steps: - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 - with: - pattern: release-outputs-src-* - merge-multiple: true - path: outputs - - - name: Create tag and release + - name: Create tag env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - if [ -d outputs ]; then - # Collect package info - find outputs -name "*_info" -exec cat {} \; > packages.txt + # Configure git + git config --global user.name "GitHub Actions" + git config --global user.email "actions@github.com" - if [ -s packages.txt ]; then - DATE=$(date +%Y.%m.%d) - echo "Creating tag v${DATE}" + # Get packages array + PACKAGES='${{ needs.detect-packages.outputs.packages }}' - # Generate comprehensive release notes - { - echo "# Release ${DATE}" - echo "" - echo "## Updated Packages" - while IFS= read -r line; do - echo "- $line" - done < packages.txt - } > notes.md - - # Create and push tag - git tag -a "v${DATE}" -m "Release ${DATE}" - git push origin "v${DATE}" - - # Create GitHub release - gh release create "v${DATE}" \ - --title "Release ${DATE}" \ - --notes-file notes.md - else - echo "No packages need release" - fi + if [ "$(echo "$PACKAGES" | jq 'length')" -gt 0 ]; then + # Create and push tag + git tag -a "${{ needs.create-tag-name.outputs.tag_name }}" -m "Release ${{ needs.create-tag-name.outputs.tag_name }}" + git push origin "${{ needs.create-tag-name.outputs.tag_name }}" else - echo "No release artifacts found" - fi \ No newline at end of file + echo "No packages need release" + fi + + - name: Create release + env: + GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + PACKAGES='${{ needs.detect-packages.outputs.packages }}' + + if [ "$(echo "$PACKAGES" | jq 'length')" -gt 0 ]; then + # Generate comprehensive release notes + { + echo "# Release ${{ needs.create-tag-name.outputs.tag_name }}" + echo "" + echo "## Updated Packages" + echo "$PACKAGES" | jq -r '.[]' | while read -r package; do + echo "- $package" + done + } > notes.md + # Create GitHub release + gh release create "${{ needs.create-tag-name.outputs.tag_name }}" \ + --title "Release ${{ needs.create-tag-name.outputs.tag_name }}" \ + --notes-file notes.md + else + echo "No packages need release" + fi From 85e303aab801f2ec49944f946f6cd634295d9501 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Tue, 14 Jan 2025 00:35:12 +0000 Subject: [PATCH 57/62] feat: remove release check --- .github/workflows/release-check.yml | 159 ---------------------------- 1 file changed, 159 deletions(-) delete mode 100644 .github/workflows/release-check.yml diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml deleted file mode 100644 index 63d7dfa0..00000000 --- a/.github/workflows/release-check.yml +++ /dev/null @@ -1,159 +0,0 @@ -name: Release Check - -on: - # Allow manual trigger for testing - workflow_dispatch: - -jobs: - prepare: - runs-on: ubuntu-latest - outputs: - matrix: ${{ steps.set-matrix.outputs.matrix }} - last_release: ${{ steps.last-release.outputs.hash }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Find package directories - id: set-matrix - run: | - # Find all package.json and pyproject.toml files, excluding root - DIRS=$(git ls-tree -r HEAD --name-only | grep -E "package.json|pyproject.toml" | xargs dirname | grep -v "^.$" | jq -R -s -c 'split("\n")[:-1]') - echo "matrix=${DIRS}" >> $GITHUB_OUTPUT - echo "Found directories: ${DIRS}" - - - name: Get last release hash - id: last-release - run: | - HASH=$(git rev-list --tags --max-count=1 || echo "HEAD~1") - echo "hash=${HASH}" >> $GITHUB_OUTPUT - echo "Using last release hash: ${HASH}" - - check-release: - needs: prepare - runs-on: ubuntu-latest - strategy: - matrix: - directory: ${{ fromJson(needs.prepare.outputs.matrix) }} - fail-fast: false - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: astral-sh/setup-uv@v5 - - - name: Setup Node.js - if: endsWith(matrix.directory, '/package.json') - uses: actions/setup-node@v4 - with: - node-version: '18' - - - name: Setup Python - if: endsWith(matrix.directory, '/pyproject.toml') - run: uv python install - - - name: Check release - id: check - run: | - # Create unique hash for this directory - dir_hash=$(echo "${{ matrix.directory }}" | sha256sum | awk '{print $1}') - - # Run release check script with verbose output - echo "Running release check against last release: ${{ needs.prepare.outputs.last_release }}" - - # Run git diff first to show changes - echo "Changes since last release:" - git diff --name-only "${{ needs.prepare.outputs.last_release }}" -- "${{ matrix.directory }}" || true - - # Run the release check - output=$(uv run --script scripts/release.py --dry-run "${{ matrix.directory }}" "${{ needs.prepare.outputs.last_release }}" 2>&1) - exit_code=$? - - echo "Release check output (exit code: $exit_code):" - echo "$output" - - # Extract package info if successful - if [ $exit_code -eq 0 ]; then - pkg_info=$(echo "$output" | grep -o -E "[a-zA-Z0-9\-]+@[0-9]+\.[0-9]+\.[0-9]+" || true) - else - echo "Release check failed" - exit 1 - fi - - if [ ! -z "$pkg_info" ]; then - echo "Found package that needs release: $pkg_info" - - # Create outputs directory - mkdir -p ./outputs - - # Save both package info and full changes - echo "$pkg_info" > "./outputs/${dir_hash}_info" - echo "dir_hash=${dir_hash}" >> $GITHUB_OUTPUT - - # Log what we're saving - echo "Saved package info to ./outputs/${dir_hash}_info:" - cat "./outputs/${dir_hash}_info" - else - echo "No release needed for this package" - fi - - - name: Set artifact name - if: steps.check.outputs.dir_hash - id: artifact - run: | - # Replace forward slashes with dashes - SAFE_DIR=$(echo "${{ matrix.directory }}" | tr '/' '-') - echo "name=release-outputs-${SAFE_DIR}" >> $GITHUB_OUTPUT - - - uses: actions/upload-artifact@v4 - if: steps.check.outputs.dir_hash - with: - name: ${{ steps.artifact.outputs.name }} - path: ./outputs/${{ steps.check.outputs.dir_hash }}* - - check-tag: - needs: [prepare, check-release] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/download-artifact@v4 - with: - pattern: release-outputs-src-* - merge-multiple: true - path: outputs - - - name: Simulate tag creation - run: | - if [ -d outputs ]; then - # Collect package info - find outputs -name "*_info" -exec cat {} \; > packages.txt - - if [ -s packages.txt ]; then - DATE=$(date +%Y.%m.%d) - echo "🔍 Dry run: Would create tag v${DATE} if this was a real release" - - # Generate comprehensive release notes - { - echo "# Release ${DATE}" - echo "" - echo "## Updated Packages" - while IFS= read -r line; do - echo "- $line" - done < packages.txt - } > notes.md - - echo "🔍 Would create release with following notes:" - cat notes.md - - echo "🔍 Would create tag v${DATE} with the above release notes" - echo "🔍 Would create GitHub release from tag v${DATE}" - else - echo "No packages need release" - fi - else - echo "No release artifacts found" - fi From a156ab635b4e10e64f6ce273c211b425bc3956e4 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Tue, 14 Jan 2025 00:38:44 +0000 Subject: [PATCH 58/62] feat: only run create-tag job when packages are detected --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee6eebf3..b9cec3de 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -90,6 +90,7 @@ jobs: create-tag: needs: [detect-packages, create-tag-name] + if: fromJson(needs.detect-packages.outputs.packages)[0] != null runs-on: ubuntu-latest environment: release permissions: From 9edf9fcaf043a7ef73cb8027742f3814407fe7aa Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Tue, 14 Jan 2025 00:39:59 +0000 Subject: [PATCH 59/62] feat: update package versions before creating release tag --- .github/workflows/release.yml | 76 ++++++++++++++++++++++++++++++----- 1 file changed, 67 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b9cec3de..2bd97e67 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,7 +98,15 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Create tag + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Update package versions and create tag env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -106,16 +114,66 @@ jobs: git config --global user.name "GitHub Actions" git config --global user.email "actions@github.com" - # Get packages array + # Get packages array and version PACKAGES='${{ needs.detect-packages.outputs.packages }}' + VERSION="${{ needs.create-tag-name.outputs.tag_name }}" + VERSION_NO_V="${VERSION#v}" # Remove 'v' prefix for package versions - if [ "$(echo "$PACKAGES" | jq 'length')" -gt 0 ]; then - # Create and push tag - git tag -a "${{ needs.create-tag-name.outputs.tag_name }}" -m "Release ${{ needs.create-tag-name.outputs.tag_name }}" - git push origin "${{ needs.create-tag-name.outputs.tag_name }}" - else - echo "No packages need release" - fi + # Create version update script + cat << 'EOF' > update_versions.py + import json + import os + import sys + import toml + from pathlib import Path + + def update_package_json(path, version): + with open(path) as f: + data = json.load(f) + data['version'] = version + with open(path, 'w') as f: + json.dump(data, f, indent=2) + f.write('\n') + + def update_pyproject_toml(path, version): + data = toml.load(path) + if 'project' in data: + data['project']['version'] = version + elif 'tool' in data and 'poetry' in data['tool']: + data['tool']['poetry']['version'] = version + with open(path, 'w') as f: + toml.dump(data, f) + + packages = json.loads(os.environ['PACKAGES']) + version = os.environ['VERSION'] + + for package_dir in packages: + package_dir = Path('src') / package_dir + + # Update package.json if it exists + package_json = package_dir / 'package.json' + if package_json.exists(): + update_package_json(package_json, version) + + # Update pyproject.toml if it exists + pyproject_toml = package_dir / 'pyproject.toml' + if pyproject_toml.exists(): + update_pyproject_toml(pyproject_toml, version) + EOF + + # Install toml package for Python + uv pip install toml + + # Update versions + PACKAGES="$PACKAGES" VERSION="$VERSION_NO_V" uv run update_versions.py + + # Commit version updates + git add src/*/package.json src/*/pyproject.toml + git commit -m "chore: update package versions to $VERSION" + + # Create and push tag + git tag -a "$VERSION" -m "Release $VERSION" + git push origin HEAD "$VERSION" - name: Create release env: From 8e944369b76dfb2bb8b47eec863cbdda49fef5b1 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Tue, 14 Jan 2025 01:18:25 +0000 Subject: [PATCH 60/62] improve release workflow more --- .github/workflows/release.yml | 209 ++++++++++------------------------ scripts/release.py | 199 ++++++++++++++++---------------- 2 files changed, 159 insertions(+), 249 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2bd97e67..b25ca834 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,10 +4,11 @@ on: workflow_dispatch: jobs: - detect-last-release: + create-metadata: runs-on: ubuntu-latest outputs: - last_release: ${{ steps.last-release.outputs.hash }} + hash: ${{ steps.last-release.outputs.hash }} + version: ${{ steps.create-version.outputs.version}} steps: - uses: actions/checkout@v4 with: @@ -20,23 +21,33 @@ jobs: echo "hash=${HASH}" >> $GITHUB_OUTPUT echo "Using last release hash: ${HASH}" - create-tag-name: - runs-on: ubuntu-latest - outputs: - tag_name: ${{ steps.last-release.outputs.tag}} - steps: - - name: Get last release hash - id: last-release - run: | - DATE=$(date +%Y.%m.%d) - echo "tag=v${DATE}" >> $GITHUB_OUTPUT - echo "Using tag: v${DATE}" + - name: Install uv + uses: astral-sh/setup-uv@v5 - detect-packages: - needs: [detect-last-release] + - name: Create version name + id: create-version + run: | + VERSION=$(uv run --script scripts/release.py generate-version) + echo "version $VERSION" + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Create notes + run: | + HASH="${{ steps.last-release.outputs.hash }}" + uv run --script scripts/release.py generate-notes --directory src/ $HASH > RELEASE_NOTES.md + cat RELEASE_NOTES.md + + - name: Release notes + uses: actions/upload-artifact@v4 + with: + name: release-notes + path: RELEASE_NOTES.md + + update-packages: + needs: [create-metadata] runs-on: ubuntu-latest outputs: - packages: ${{ steps.find-packages.outputs.packages }} + changes_made: ${{ steps.commit.outputs.changes_made }} steps: - uses: actions/checkout@v4 with: @@ -45,52 +56,33 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v5 - - name: Find packages - id: find-packages - working-directory: src + - name: Update packages run: | - cat << 'EOF' > find_packages.py - import json - import os - import subprocess - from itertools import chain - from pathlib import Path + HASH="${{ needs.create-metadata.outputs.hash }}" + uv run --script scripts/release.py update-packages --directory src/ $HASH - packages = [] + - name: Configure git + run: | + git config --global user.name "GitHub Actions" + git config --global user.email "actions@github.com" - print("Starting package detection...") - print(f"Using LAST_RELEASE: {os.environ['LAST_RELEASE']}") + - name: Commit changes + id: commit + run: | + VERSION="${{ needs.create-metadata.outputs.version }}" + git add -u + if git diff-index --quiet HEAD; then + echo "changes_made=false" >> $GITHUB_OUTPUT + else + git commit -m 'Automatic update of packages' + git tag -a "$VERSION" -m "Release $VERSION" + git push origin "$VERSION" + echo "changes_made=true" >> $GITHUB_OUTPUT + fi - # Find all directories containing package.json or pyproject.toml - paths = chain(Path('.').glob('*/package.json'), Path('.').glob('*/pyproject.toml')) - for path in paths: - print(f"\nChecking path: {path}") - # Check for changes in .py or .ts files - # Run git diff from the specific directory - cmd = ['git', 'diff', '--name-only', f'{os.environ["LAST_RELEASE"]}..HEAD', '--', '.'] - result = subprocess.run(cmd, capture_output=True, text=True, cwd=path.parent) - - # Check if any .py or .ts files were changed - changed_files = result.stdout.strip().split('\n') - print(f"Changed files found: {changed_files}") - - has_changes = any(f.endswith(('.py', '.ts')) for f in changed_files if f) - if has_changes: - print(f"Adding package: {path.parent}") - packages.append(str(path.parent)) - - print(f"\nFinal packages list: {packages}") - - # Write output - with open(os.environ['GITHUB_OUTPUT'], 'a') as f: - f.write(f"packages={json.dumps(packages)}\n") - EOF - - LAST_RELEASE=${{ needs.detect-last-release.outputs.last_release }} uv run --script --python 3.12 find_packages.py - - create-tag: - needs: [detect-packages, create-tag-name] - if: fromJson(needs.detect-packages.outputs.packages)[0] != null + create-release: + needs: [update-packages, create-metadata] + if: needs.update-packages.outputs.changes_made == 'true' runs-on: ubuntu-latest environment: release permissions: @@ -98,103 +90,16 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install uv - uses: astral-sh/setup-uv@v5 - - - name: Install Node.js - uses: actions/setup-node@v4 + - name: Download release notes + uses: actions/download-artifact@v4 with: - node-version: '20' - - - name: Update package versions and create tag - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Configure git - git config --global user.name "GitHub Actions" - git config --global user.email "actions@github.com" - - # Get packages array and version - PACKAGES='${{ needs.detect-packages.outputs.packages }}' - VERSION="${{ needs.create-tag-name.outputs.tag_name }}" - VERSION_NO_V="${VERSION#v}" # Remove 'v' prefix for package versions - - # Create version update script - cat << 'EOF' > update_versions.py - import json - import os - import sys - import toml - from pathlib import Path - - def update_package_json(path, version): - with open(path) as f: - data = json.load(f) - data['version'] = version - with open(path, 'w') as f: - json.dump(data, f, indent=2) - f.write('\n') - - def update_pyproject_toml(path, version): - data = toml.load(path) - if 'project' in data: - data['project']['version'] = version - elif 'tool' in data and 'poetry' in data['tool']: - data['tool']['poetry']['version'] = version - with open(path, 'w') as f: - toml.dump(data, f) - - packages = json.loads(os.environ['PACKAGES']) - version = os.environ['VERSION'] - - for package_dir in packages: - package_dir = Path('src') / package_dir - - # Update package.json if it exists - package_json = package_dir / 'package.json' - if package_json.exists(): - update_package_json(package_json, version) - - # Update pyproject.toml if it exists - pyproject_toml = package_dir / 'pyproject.toml' - if pyproject_toml.exists(): - update_pyproject_toml(pyproject_toml, version) - EOF - - # Install toml package for Python - uv pip install toml - - # Update versions - PACKAGES="$PACKAGES" VERSION="$VERSION_NO_V" uv run update_versions.py - - # Commit version updates - git add src/*/package.json src/*/pyproject.toml - git commit -m "chore: update package versions to $VERSION" - - # Create and push tag - git tag -a "$VERSION" -m "Release $VERSION" - git push origin HEAD "$VERSION" + name: release-notes - name: Create release env: GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} run: | - PACKAGES='${{ needs.detect-packages.outputs.packages }}' - - if [ "$(echo "$PACKAGES" | jq 'length')" -gt 0 ]; then - # Generate comprehensive release notes - { - echo "# Release ${{ needs.create-tag-name.outputs.tag_name }}" - echo "" - echo "## Updated Packages" - echo "$PACKAGES" | jq -r '.[]' | while read -r package; do - echo "- $package" - done - } > notes.md - # Create GitHub release - gh release create "${{ needs.create-tag-name.outputs.tag_name }}" \ - --title "Release ${{ needs.create-tag-name.outputs.tag_name }}" \ - --notes-file notes.md - else - echo "No packages need release" - fi + VERSION="${{ needs.create-metadata.outputs.version }}" + gh release create "$VERSION" \ + --title "Release $VERSION" \ + --notes-file RELEASE_NOTES.md diff --git a/scripts/release.py b/scripts/release.py index ace528eb..4b84c0af 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1,6 +1,6 @@ #!/usr/bin/env uv run --script # /// script -# requires-python = ">=3.11" +# requires-python = ">=3.12" # dependencies = [ # "click>=8.1.8", # "tomlkit>=0.13.2" @@ -14,8 +14,8 @@ import json import tomlkit import datetime import subprocess -from enum import Enum -from typing import Any, NewType +from dataclasses import dataclass +from typing import Any, Iterator, NewType, Protocol Version = NewType("Version", str) @@ -51,25 +51,58 @@ class GitHashParamType(click.ParamType): GIT_HASH = GitHashParamType() -class PackageType(Enum): - NPM = 1 - PYPI = 2 +class Package(Protocol): + path: Path - @classmethod - def from_path(cls, directory: Path) -> "PackageType": - if (directory / "package.json").exists(): - return cls.NPM - elif (directory / "pyproject.toml").exists(): - return cls.PYPI - else: - raise Exception("No package.json or pyproject.toml found") + def package_name(self) -> str: ... + + def update_version(self, version: Version) -> None: ... -def get_changes(path: Path, git_hash: str) -> bool: +@dataclass +class NpmPackage: + path: Path + + def package_name(self) -> str: + with open(self.path / "package.json", "r") as f: + return json.load(f)["name"] + + def update_version(self, version: Version): + with open(self.path / "package.json", "r+") as f: + data = json.load(f) + data["version"] = version + f.seek(0) + json.dump(data, f, indent=2) + f.truncate() + + +@dataclass +class PyPiPackage: + path: Path + + def package_name(self) -> str: + with open(self.path / "pyproject.toml") as f: + toml_data = tomlkit.parse(f.read()) + name = toml_data.get("project", {}).get("name") + if not name: + raise Exception("No name in pyproject.toml project section") + return str(name) + + def update_version(self, version: Version): + # Update version in pyproject.toml + with open(self.path / "pyproject.toml") as f: + data = tomlkit.parse(f.read()) + data["project"]["version"] = version + + with open(self.path / "pyproject.toml", "w") as f: + f.write(tomlkit.dumps(data)) + + +def has_changes(path: Path, git_hash: GitHash) -> bool: """Check if any files changed between current state and git hash""" try: output = subprocess.run( - ["git", "diff", "--name-only", git_hash, "--", path], + ["git", "diff", "--name-only", git_hash, "--", "."], cwd=path, check=True, capture_output=True, @@ -77,105 +110,77 @@ def get_changes(path: Path, git_hash: str) -> bool: ) changed_files = [Path(f) for f in output.stdout.splitlines()] - relevant_files = [f for f in changed_files if f.suffix in ['.py', '.ts']] + relevant_files = [f for f in changed_files if f.suffix in [".py", ".ts"]] return len(relevant_files) >= 1 except subprocess.CalledProcessError: return False -def get_package_name(path: Path, pkg_type: PackageType) -> str: - """Get package name from package.json or pyproject.toml""" - match pkg_type: - case PackageType.NPM: - with open(path / "package.json", "rb") as f: - return json.load(f)["name"] - case PackageType.PYPI: - with open(path / "pyproject.toml") as f: - toml_data = tomlkit.parse(f.read()) - name = toml_data.get("project", {}).get("name") - if not name: - raise Exception("No name in pyproject.toml project section") - return str(name) - - -def generate_version() -> Version: +def gen_version() -> Version: """Generate version based on current date""" now = datetime.datetime.now() return Version(f"{now.year}.{now.month}.{now.day}") -def publish_package( - path: Path, pkg_type: PackageType, version: Version, dry_run: bool = False -): - """Publish package based on type""" - try: - match pkg_type: - case PackageType.NPM: - # Update version in package.json - with open(path / "package.json", "rb+") as f: - data = json.load(f) - data["version"] = version - f.seek(0) - json.dump(data, f, indent=2) - f.truncate() - - if not dry_run: - # Publish to npm - subprocess.run(["npm", "publish"], cwd=path, check=True) - case PackageType.PYPI: - # Update version in pyproject.toml - with open(path / "pyproject.toml") as f: - data = tomlkit.parse(f.read()) - data["project"]["version"] = version - - with open(path / "pyproject.toml", "w") as f: - f.write(tomlkit.dumps(data)) - - if not dry_run: - # Build and publish to PyPI - subprocess.run(["uv", "build"], cwd=path, check=True) - subprocess.run( - ["uv", "publish"], - cwd=path, - check=True, - ) - except Exception as e: - raise Exception(f"Failed to publish: {e}") from e +def find_changed_packages(directory: Path, git_hash: GitHash) -> Iterator[Package]: + for path in directory.glob("*/package.json"): + if has_changes(path.parent, git_hash): + yield NpmPackage(path.parent) + for path in directory.glob("*/pyproject.toml"): + if has_changes(path.parent, git_hash): + yield PyPiPackage(path.parent) -@click.command() -@click.argument("directory", type=click.Path(exists=True, path_type=Path)) -@click.argument("git_hash", type=GIT_HASH) +@click.group() +def cli(): + pass + + +@cli.command("update-packages") @click.option( - "--dry-run", is_flag=True, help="Update version numbers but don't publish" + "--directory", type=click.Path(exists=True, path_type=Path), default=Path.cwd() ) -def main(directory: Path, git_hash: GitHash, dry_run: bool) -> int: - """Release package if changes detected""" +@click.argument("git_hash", type=GIT_HASH) +def update_packages(directory: Path, git_hash: GitHash) -> int: # Detect package type - try: - path = directory.resolve(strict=True) - pkg_type = PackageType.from_path(path) - except Exception as e: - return 1 + path = directory.resolve(strict=True) + version = gen_version() - # Check for changes - if not get_changes(path, git_hash): - return 0 + for package in find_changed_packages(path, git_hash): + name = package.package_name() + package.update_version(version) - try: - # Generate version and publish - version = generate_version() - name = get_package_name(path, pkg_type) + click.echo(f"{name}@{version}") - publish_package(path, pkg_type, version, dry_run) - if not dry_run: - click.echo(f"{name}@{version}") - else: - click.echo(f"Dry run: Would have published {name}@{version}") - return 0 - except Exception as e: - return 1 + return 0 + + +@cli.command("generate-notes") +@click.option( + "--directory", type=click.Path(exists=True, path_type=Path), default=Path.cwd() +) +@click.argument("git_hash", type=GIT_HASH) +def generate_notes(directory: Path, git_hash: GitHash) -> int: + # Detect package type + path = directory.resolve(strict=True) + version = gen_version() + + click.echo(f"# Release : v{version}") + click.echo("") + click.echo("## Updated packages") + for package in find_changed_packages(path, git_hash): + name = package.package_name() + click.echo(f"- {name}@{version}") + + return 0 + + +@cli.command("generate-version") +def generate_version() -> int: + # Detect package type + click.echo(gen_version()) + return 0 if __name__ == "__main__": - sys.exit(main()) + sys.exit(cli()) From 4f3dc110651e4f094c64a321ffb947840bcfe47e Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Tue, 14 Jan 2025 02:22:40 +0000 Subject: [PATCH 61/62] new release workflow --- .github/workflows/release.yml | 111 +++++++++++++++++++++++++++++++++- scripts/release.py | 24 ++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b25ca834..4f687118 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,6 +2,8 @@ name: Automatic Release Creation on: workflow_dispatch: + schedule: + - cron: '0 10 * * *' jobs: create-metadata: @@ -9,6 +11,8 @@ jobs: outputs: hash: ${{ steps.last-release.outputs.hash }} version: ${{ steps.create-version.outputs.version}} + npm_packages: ${{ steps.create-npm-packages.outputs.npm_packages}} + pypi_packages: ${{ steps.create-pypi-packages.outputs.pypi_packages}} steps: - uses: actions/checkout@v4 with: @@ -43,8 +47,25 @@ jobs: name: release-notes path: RELEASE_NOTES.md + - name: Create python matrix + id: create-pypi-packages + run: | + HASH="${{ steps.last-release.outputs.hash }}" + PYPI=$(uv run --script scripts/release.py generate-matrix --pypi --directory src $HASH) + echo "pypi_packages $PYPI" + echo "pypi_packages=$PYPI" >> $GITHUB_OUTPUT + + - name: Create npm matrix + id: create-npm-packages + run: | + HASH="${{ steps.last-release.outputs.hash }}" + NPM=$(uv run --script scripts/release.py generate-matrix --npm --directory src $HASH) + echo "npm_packages $NPM" + echo "npm_packages=$NPM" >> $GITHUB_OUTPUT + update-packages: needs: [create-metadata] + if: ${{ needs.create-metadata.outputs.npm_packages != '[]' || needs.create-metadata.outputs.pypi_packages != '[]' }} runs-on: ubuntu-latest outputs: changes_made: ${{ steps.commit.outputs.changes_made }} @@ -80,8 +101,94 @@ jobs: echo "changes_made=true" >> $GITHUB_OUTPUT fi - create-release: + publish-pypi: needs: [update-packages, create-metadata] + strategy: + fail-fast: false + matrix: + package: ${{ fromJson(needs.create-metadata.outputs.pypi_packages) }} + name: Build ${{ matrix.package }} + environment: release + permissions: + id-token: write # Required for trusted publishing + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.create-metadata.outputs.version }} + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version-file: "src/${{ matrix.package }}/.python-version" + + - name: Install dependencies + working-directory: src/${{ matrix.package }} + run: uv sync --frozen --all-extras --dev + + - name: Run pyright + working-directory: src/${{ matrix.package }} + run: uv run --frozen pyright + + - name: Build package + working-directory: src/${{ matrix.package }} + run: uv build + + - name: Publish package to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: src/${{ matrix.package }}/dist + + publish-npm: + needs: [update-packages, create-metadata] + strategy: + fail-fast: false + matrix: + package: ${{ fromJson(needs.create-metadata.outputs.npm_packages) }} + name: Build ${{ matrix.package }} + environment: release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.create-metadata.outputs.version }} + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + working-directory: src/${{ matrix.package }} + run: npm ci + + - name: Check if version exists on npm + working-directory: src/${{ matrix.package }} + run: | + VERSION=$(jq -r .version package.json) + if npm view --json | jq --arg version "$VERSION" '[.[]][0].versions | contains([$version])'; then + echo "Version $VERSION already exists on npm" + exit 1 + fi + echo "Version $VERSION is new, proceeding with publish" + + - name: Build package + working-directory: src/${{ matrix.package }} + run: npm run build + + - name: Publish package + working-directory: src/${{ matrix.package }} + run: | + npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + create-release: + needs: [update-packages, create-metadata, publish-pypi, publish-npm] if: needs.update-packages.outputs.changes_made == 'true' runs-on: ubuntu-latest environment: release @@ -97,7 +204,7 @@ jobs: - name: Create release env: - GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN}} run: | VERSION="${{ needs.create-metadata.outputs.version }}" gh release create "$VERSION" \ diff --git a/scripts/release.py b/scripts/release.py index 4b84c0af..05d76c0a 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -182,5 +182,29 @@ def generate_version() -> int: return 0 +@cli.command("generate-matrix") +@click.option( + "--directory", type=click.Path(exists=True, path_type=Path), default=Path.cwd() +) +@click.option("--npm", is_flag=True, default=False) +@click.option("--pypi", is_flag=True, default=False) +@click.argument("git_hash", type=GIT_HASH) +def generate_matrix(directory: Path, git_hash: GitHash, pypi: bool, npm: bool) -> int: + # Detect package type + path = directory.resolve(strict=True) + version = gen_version() + + changes = [] + for package in find_changed_packages(path, git_hash): + pkg = package.path.relative_to(path) + if npm and isinstance(package, NpmPackage): + changes.append(str(pkg)) + if pypi and isinstance(package, PyPiPackage): + changes.append(str(pkg)) + + click.echo(json.dumps(changes)) + return 0 + + if __name__ == "__main__": sys.exit(cli()) From de256a48b1155ce11d0b3f78c5aebc374ac6996d Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Tue, 14 Jan 2025 11:19:13 +0000 Subject: [PATCH 62/62] fix: Add missing CreatePullRequestSchema and createPullRequest function --- src/github/operations/pulls.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/github/operations/pulls.ts b/src/github/operations/pulls.ts index ef3e65ce..9b1a5bd7 100644 --- a/src/github/operations/pulls.ts +++ b/src/github/operations/pulls.ts @@ -76,6 +76,17 @@ export const PullRequestReviewSchema = z.object({ }); // Input schemas +export const CreatePullRequestSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + title: z.string().describe("Pull request title"), + body: z.string().optional().describe("Pull request body/description"), + head: z.string().describe("The name of the branch where your changes are implemented"), + base: z.string().describe("The name of the branch you want the changes pulled into"), + draft: z.boolean().optional().describe("Whether to create the pull request as a draft"), + maintainer_can_modify: z.boolean().optional().describe("Whether maintainers can modify the pull request") +}); + export const GetPullRequestSchema = z.object({ owner: z.string().describe("Repository owner (username or organization)"), repo: z.string().describe("Repository name"), @@ -149,6 +160,22 @@ export const GetPullRequestReviewsSchema = z.object({ }); // Function implementations +export async function createPullRequest( + params: z.infer +): Promise> { + const { owner, repo, ...options } = CreatePullRequestSchema.parse(params); + + const response = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/pulls`, + { + method: "POST", + body: options, + } + ); + + return GitHubPullRequestSchema.parse(response); +} + export async function getPullRequest( owner: string, repo: string,