From 86c6ed301f342756efa48d2519bfeb5c10dd05e2 Mon Sep 17 00:00:00 2001 From: yiqingxiong Date: Sun, 11 Jan 2026 22:02:14 +0800 Subject: [PATCH] simplified code --- src/github/api/config.ts | 8 +- src/github/api/queries/gitea.ts | 279 +++++++++++++++++++++++ src/github/data/fetcher.ts | 66 ++++-- src/github/operations/branch-cleanup.ts | 19 +- src/github/operations/comment-logic.ts | 13 +- src/github/operations/comments/common.ts | 26 ++- src/github/validation/actor.ts | 10 + src/github/validation/permissions.ts | 2 +- src/mcp/github-actions-server.ts | 15 ++ src/modes/detector.ts | 2 + 10 files changed, 405 insertions(+), 35 deletions(-) create mode 100644 src/github/api/queries/gitea.ts diff --git a/src/github/api/config.ts b/src/github/api/config.ts index 9e533e5..68a39f7 100644 --- a/src/github/api/config.ts +++ b/src/github/api/config.ts @@ -1,4 +1,8 @@ export const GITHUB_API_URL = - process.env.GITHUB_API_URL || "https://api.github.com"; + process.env.GITHUB_API_URL || "https://api.github.com" || + process.env.GITEA_API_URL; export const GITHUB_SERVER_URL = - process.env.GITHUB_SERVER_URL || "https://github.com"; + process.env.GITHUB_SERVER_URL || "https://github.com" || + process.env.GITEA_SERVER_URL; + + export const USE_REST_API = process.env.USE_REST_API === "true"; \ No newline at end of file diff --git a/src/github/api/queries/gitea.ts b/src/github/api/queries/gitea.ts new file mode 100644 index 0000000..1ee5345 --- /dev/null +++ b/src/github/api/queries/gitea.ts @@ -0,0 +1,279 @@ +// Gitea REST API query functions +// These functions replace GraphQL queries with REST API calls + +import type { Octokit } from "@octokit/rest"; + +// Type definitions for REST API responses +type RestFile = Awaited>["data"][number]; +type RestComment = Awaited>["data"][number]; +type RestCommit = Awaited>["data"][number]; +type RestReview = Awaited>["data"][number]; +type RestReviewComment = Awaited>["data"][number]; + +/** + * Fetch complete Pull Request data including commits, files, comments, and reviews + */ +export async function fetchPullRequest( + octokit: Octokit, + owner: string, + repo: string, + number: number, +) { + // Fetch all PR data in parallel for better performance + const [prData, prFiles, prComments, prCommits, prReviews] = await Promise.all([ + // Basic PR information + octokit.rest.pulls.get({ + owner, + repo, + pull_number: number, + }), + // Changed files + octokit.rest.pulls.listFiles({ + owner, + repo, + pull_number: number, + per_page: 100, + }), + // Issue comments (PR general comments) + octokit.rest.issues.listComments({ + owner, + repo, + issue_number: number, + per_page: 100, + }), + // PR commits + octokit.rest.pulls.listCommits({ + owner, + repo, + pull_number: number, + per_page: 100, + }), + // PR reviews + octokit.rest.pulls.listReviews({ + owner, + repo, + pull_number: number, + per_page: 100, + }), + ]); + + // Fetch review comments for each review using Gitea API + // Gitea endpoint: GET /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments + const reviewsWithComments = await Promise.all( + prReviews.data.map(async (review: RestReview) => { + try { + // Use Gitea-specific endpoint to get comments for each review + const response = await octokit.request( + "GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/comments", + { + owner, + repo, + pull_number: number, + review_id: review.id, + per_page: 100, + } + ); + + return { + ...review, + comments: response.data, + }; + } catch (error) { + // If fetching comments fails, return review with empty comments + // @ts-expect-error - console is available at runtime + console.warn(`Failed to fetch comments for review ${review.id}:`, error); + return { + ...review, + comments: [], + }; + } + }) + ); + + // Transform REST API response to match GraphQL-like structure + return { + repository: { + pullRequest: { + title: prData.data.title, + body: prData.data.body || "", + author: { + login: prData.data.user?.login || "", + name: prData.data.user?.name || undefined, + }, + baseRefName: prData.data.base.ref, + headRefName: prData.data.head.ref, + headRefOid: prData.data.head.sha, + createdAt: prData.data.created_at, + updatedAt: prData.data.updated_at, + lastEditedAt: prData.data.updated_at, // Gitea may not have separate lastEditedAt + additions: prData.data.additions || 0, + deletions: prData.data.deletions || 0, + state: prData.data.state.toUpperCase(), // "open" -> "OPEN" + commits: { + totalCount: prCommits.data.length, + nodes: prCommits.data.map((commit: RestCommit) => ({ + commit: { + oid: commit.sha, + message: commit.commit.message, + author: { + name: commit.commit.author?.name || "", + email: commit.commit.author?.email || "", + }, + }, + })), + }, + files: { + nodes: prFiles.data.map((file: RestFile) => ({ + path: file.filename, + additions: file.additions, + deletions: file.deletions, + changeType: mapFileStatus(file.status), + })), + }, + comments: { + nodes: prComments.data.map((comment: RestComment) => ({ + id: `comment_${comment.id}`, + databaseId: comment.id, + body: comment.body || "", + author: { + login: comment.user?.login || "", + }, + createdAt: comment.created_at, + updatedAt: comment.updated_at, + lastEditedAt: comment.updated_at, + isMinimized: false, // Gitea may not support this + })), + }, + reviews: { + nodes: reviewsWithComments.map((review: RestReview & { comments: RestReviewComment[] }) => ({ + id: `review_${review.id}`, + databaseId: review.id, + author: { + login: review.user?.login || "", + }, + body: review.body || "", + state: review.state.toUpperCase(), // "APPROVED", "CHANGES_REQUESTED", etc. + submittedAt: review.submitted_at || review.created_at || "", + updatedAt: review.submitted_at || review.created_at || "", + lastEditedAt: review.submitted_at || review.created_at || "", + comments: { + nodes: review.comments.map((comment: RestReviewComment) => ({ + id: `review_comment_${comment.id}`, + databaseId: comment.id, + body: comment.body || "", + path: comment.path, + line: comment.line || comment.original_line || null, + author: { + login: comment.user?.login || "", + }, + createdAt: comment.created_at, + updatedAt: comment.updated_at, + lastEditedAt: comment.updated_at, + isMinimized: false, + })), + }, + })), + }, + }, + }, + }; +} + +/** + * Fetch complete Issue data including comments + */ +export async function fetchIssue( + octokit: Octokit, + owner: string, + repo: string, + number: number, +) { + // Fetch issue data and comments in parallel + const [issueData, issueComments] = await Promise.all([ + octokit.rest.issues.get({ + owner, + repo, + issue_number: number, + }), + octokit.rest.issues.listComments({ + owner, + repo, + issue_number: number, + per_page: 100, + }), + ]); + + // Transform REST API response to match GraphQL-like structure + return { + repository: { + issue: { + title: issueData.data.title, + body: issueData.data.body || "", + author: { + login: issueData.data.user?.login || "", + }, + createdAt: issueData.data.created_at, + updatedAt: issueData.data.updated_at, + lastEditedAt: issueData.data.updated_at, + state: issueData.data.state.toUpperCase(), + comments: { + nodes: issueComments.data.map((comment: RestComment) => ({ + id: `comment_${comment.id}`, + databaseId: comment.id, + body: comment.body || "", + author: { + login: comment.user?.login || "", + }, + createdAt: comment.created_at, + updatedAt: comment.updated_at, + lastEditedAt: comment.updated_at, + isMinimized: false, + })), + }, + }, + }, + }; +} + +/** + * Fetch user display name + */ +export async function fetchUser(octokit: Octokit, login: string) { + try { + const userData = await octokit.rest.users.getByUsername({ + username: login, + }); + + return { + user: { + name: userData.data.name || userData.data.full_name || null, + }, + }; + } catch (error) { + // Note: console is available at runtime in Node.js environment + // @ts-expect-error - console is not in lib but available at runtime + console.warn(`Failed to fetch user ${login}:`, error); + return { + user: { + name: null, + }, + }; + } +} + +/** + * Map Gitea file status to GraphQL changeType format + */ +function mapFileStatus(status: string): string { + const statusMap: Record = { + added: "ADDED", + modified: "MODIFIED", + removed: "DELETED", + renamed: "RENAMED", + copied: "COPIED", + changed: "MODIFIED", + }; + + return statusMap[status] || status.toUpperCase(); +} + diff --git a/src/github/data/fetcher.ts b/src/github/data/fetcher.ts index c756e00..5ee2f1a 100644 --- a/src/github/data/fetcher.ts +++ b/src/github/data/fetcher.ts @@ -1,6 +1,12 @@ import { execFileSync } from "child_process"; import type { Octokits } from "../api/client"; +import { USE_GITEA_API } from "../api/config"; import { ISSUE_QUERY, PR_QUERY, USER_QUERY } from "../api/queries/github"; +import { + fetchPullRequest, + fetchIssue, + fetchUser, +} from "../api/queries/gitea"; import { isIssueCommentEvent, isPullRequestReviewEvent, @@ -183,14 +189,21 @@ export async function fetchGitHubData({ try { if (isPR) { // Fetch PR data with all comments and file information - const prResult = await octokits.graphql( - PR_QUERY, - { - owner, - repo, - number: parseInt(prNumber), - }, - ); + const prResult = USE_GITEA_API + ? await fetchPullRequest( + octokits.rest, + owner, + repo, + parseInt(prNumber), + ) + : await octokits.graphql( + PR_QUERY, + { + owner, + repo, + number: parseInt(prNumber), + }, + ); if (prResult.repository.pullRequest) { const pullRequest = prResult.repository.pullRequest; @@ -200,7 +213,9 @@ export async function fetchGitHubData({ pullRequest.comments?.nodes || [], triggerTime, ); - reviewData = pullRequest.reviews || []; + reviewData = USE_GITEA_API + ? (pullRequest.reviews || null) + : (pullRequest.reviews || { nodes: [] }); console.log(`Successfully fetched PR #${prNumber} data`); } else { @@ -208,14 +223,21 @@ export async function fetchGitHubData({ } } else { // Fetch issue data - const issueResult = await octokits.graphql( - ISSUE_QUERY, - { - owner, - repo, - number: parseInt(prNumber), - }, - ); + const issueResult = USE_GITEA_API + ? await fetchIssue( + octokits.rest, + owner, + repo, + parseInt(prNumber), + ) + : await octokits.graphql( + ISSUE_QUERY, + { + owner, + repo, + number: parseInt(prNumber), + }, + ); if (issueResult.repository.issue) { contextData = issueResult.repository.issue; @@ -223,7 +245,7 @@ export async function fetchGitHubData({ contextData?.comments?.nodes || [], triggerTime, ); - + console.log(`Successfully fetched issue #${prNumber} data`); } else { throw new Error(`Issue #${prNumber} not found`); @@ -376,9 +398,11 @@ export async function fetchUserDisplayName( login: string, ): Promise { try { - const result = await octokits.graphql(USER_QUERY, { - login, - }); + const result = USE_GITEA_API + ? await fetchUser(octokits.rest, login) + : await octokits.graphql(USER_QUERY, { + login, + }); return result.user.name; } catch (error) { console.warn(`Failed to fetch user display name for ${login}:`, error); diff --git a/src/github/operations/branch-cleanup.ts b/src/github/operations/branch-cleanup.ts index 88de6de..4336cb7 100644 --- a/src/github/operations/branch-cleanup.ts +++ b/src/github/operations/branch-cleanup.ts @@ -1,5 +1,6 @@ import type { Octokits } from "../api/client"; -import { GITHUB_SERVER_URL } from "../api/config"; +import { GITHUB_SERVER_URL, USE_GITEA_API} from "../api/config"; +import { createBranchUrl } from "./comments/common"; import { $ } from "bun"; export async function checkAndCommitOrDeleteBranch( @@ -80,7 +81,9 @@ export async function checkAndCommitOrDeleteBranch( ); // Set branch link since we now have commits - const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`; + const branchUrl = USE_GITEA_API + ? createBranchUrl(owner, repo, claudeBranch) + : `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`; branchLink = `\n[View branch](${branchUrl})`; } else { console.log( @@ -91,7 +94,9 @@ export async function checkAndCommitOrDeleteBranch( } catch (gitError) { console.error("Error checking/committing changes:", gitError); // If we can't check git status, assume the branch might have changes - const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`; + const branchUrl = USE_GITEA_API + ? createBranchUrl(owner, repo, claudeBranch) + : `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`; branchLink = `\n[View branch](${branchUrl})`; } } else { @@ -102,13 +107,17 @@ export async function checkAndCommitOrDeleteBranch( } } else { // Only add branch link if there are commits - const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`; + const branchUrl = USE_GITEA_API + ? createBranchUrl(owner, repo, claudeBranch) + : `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`; branchLink = `\n[View branch](${branchUrl})`; } } catch (error) { console.error("Error comparing commits on Claude branch:", error); // If we can't compare but the branch exists remotely, include the branch link - const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`; + const branchUrl = USE_GITEA_API + ? createBranchUrl(owner, repo, claudeBranch) + : `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`; branchLink = `\n[View branch](${branchUrl})`; } } diff --git a/src/github/operations/comment-logic.ts b/src/github/operations/comment-logic.ts index 03b5d86..0198244 100644 --- a/src/github/operations/comment-logic.ts +++ b/src/github/operations/comment-logic.ts @@ -1,4 +1,5 @@ -import { GITHUB_SERVER_URL } from "../api/config"; +import { GITHUB_SERVER_URL, USE_GITEA_API} from "../api/config"; +import { createBranchUrl } from "./comments/common"; export type ExecutionDetails = { total_cost_usd?: number; @@ -157,10 +158,14 @@ export function updateCommentBody(input: CommentUpdateInput): string { // If we don't have a URL yet but have a branch name, construct it if (!branchUrl && finalBranchName) { - // Extract owner/repo from jobUrl - const repoMatch = jobUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\//); + // Extract owner/repo from jobUrl (works for both GitHub and Gitea URLs) + const repoMatch = USE_GITEA_API + ? jobUrl.match(/\/([^\/]+)\/([^\/]+)\/(?:actions|tree|src)/) + : jobUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\//); if (repoMatch) { - branchUrl = `${GITHUB_SERVER_URL}/${repoMatch[1]}/${repoMatch[2]}/tree/${finalBranchName}`; + branchUrl = USE_GITEA_API + ? createBranchUrl(repoMatch[1], repoMatch[2], finalBranchName) + : `${GITHUB_SERVER_URL}/${repoMatch[1]}/${repoMatch[2]}/tree/${finalBranchName}`; } } diff --git a/src/github/operations/comments/common.ts b/src/github/operations/comments/common.ts index df24c03..fe85478 100644 --- a/src/github/operations/comments/common.ts +++ b/src/github/operations/comments/common.ts @@ -1,4 +1,4 @@ -import { GITHUB_SERVER_URL } from "../../api/config"; +import { GITHUB_SERVER_URL, USE_GITEA_API} from "../../api/config"; export const SPINNER_HTML = ''; @@ -17,10 +17,32 @@ export function createBranchLink( repo: string, branchName: string, ): string { - const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${branchName}`; + const branchUrl = USE_GITEA_API + ? createBranchUrl(owner, repo, branchName) + : `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${branchName}`; return `\n[View branch](${branchUrl})`; } +/** + * Get the branch URL path segment for the current platform + * Gitea uses /src/branch/ while GitHub uses /tree/ + */ +export function getBranchPath(): string { + const isGitea = !GITHUB_SERVER_URL.includes("github.com"); + return isGitea ? "src/branch" : "tree"; +} + +/** + * Create a branch URL for the current platform + */ +export function createBranchUrl( + owner: string, + repo: string, + branchName: string, +): string { + return `${GITHUB_SERVER_URL}/${owner}/${repo}/${getBranchPath()}/${branchName}`; +} + export function createCommentBody( jobRunLink: string, branchLink: string = "", diff --git a/src/github/validation/actor.ts b/src/github/validation/actor.ts index 2599254..6b9da03 100644 --- a/src/github/validation/actor.ts +++ b/src/github/validation/actor.ts @@ -12,6 +12,16 @@ export async function checkHumanActor( octokit: Octokit, githubContext: ParsedGitHubContext, ) { + + if (process.env.OVERRIDE_BOT_USERNAMES){ + if (process.env.OVERRIDE_BOT_USERNAMES.split(",").includes(githubContext.actor)){ + throw new Error( + `Workflow initiated by non-human actor: ${githubContext.actor}. Please add bot to OVERRIDE_BOT_USERNAMES`, + ); + } + return; + } + // Fetch user information from GitHub API const { data: userData } = await octokit.users.getByUsername({ username: githubContext.actor, diff --git a/src/github/validation/permissions.ts b/src/github/validation/permissions.ts index 731fcd4..a4e6fb2 100644 --- a/src/github/validation/permissions.ts +++ b/src/github/validation/permissions.ts @@ -59,7 +59,7 @@ export async function checkWritePermissions( const permissionLevel = response.data.permission; core.info(`Permission level retrieved: ${permissionLevel}`); - if (permissionLevel === "admin" || permissionLevel === "write") { + if (permissionLevel === "owner" || permissionLevel === "admin" || permissionLevel === "write") { core.info(`Actor has write access: ${permissionLevel}`); return true; } else { diff --git a/src/mcp/github-actions-server.ts b/src/mcp/github-actions-server.ts index e600624..956941d 100644 --- a/src/mcp/github-actions-server.ts +++ b/src/mcp/github-actions-server.ts @@ -71,6 +71,11 @@ server.tool( repo: REPO_NAME!, head_sha: headSha, ...(status && { status }), + }).catch((error: Error) => { + // Gitea Actions API might not be fully compatible + throw new Error( + `Failed to fetch workflow runs. This may indicate Gitea Actions API incompatibility: ${error.message}`, + ); }); // Process runs to create summary @@ -152,6 +157,11 @@ server.tool( owner: REPO_OWNER!, repo: REPO_NAME!, run_id, + }) + .catch((error: Error) => { + throw new Error( + `Failed to fetch workflow run details. Gitea Actions API may not be fully compatible: ${error.message}`, + ); }); const processedJobs = jobsData.jobs.map((job: any) => { @@ -219,6 +229,11 @@ server.tool( owner: REPO_OWNER!, repo: REPO_NAME!, job_id, + }) + .catch((error: Error) => { + throw new Error( + `Failed to download job logs. Gitea Actions API may not be fully compatible: ${error.message}`, + ); }); const logsText = response.data as unknown as string; diff --git a/src/modes/detector.ts b/src/modes/detector.ts index 8e30aff..98955b7 100644 --- a/src/modes/detector.ts +++ b/src/modes/detector.ts @@ -65,6 +65,7 @@ export function detectMode(context: GitHubContext): AutoDetectedMode { const supportedActions = [ "opened", "synchronize", + "synchronized", "ready_for_review", "reopened", ]; @@ -112,6 +113,7 @@ function validateTrackProgressEvent(context: GitHubContext): void { const validActions = [ "opened", "synchronize", + "synchronized", "ready_for_review", "reopened", ];