From 3fcf15fd153d65c3781846a8a5f750c426cd8aa9 Mon Sep 17 00:00:00 2001 From: Anonymous <> Date: Thu, 29 Jan 2026 22:56:05 +0800 Subject: [PATCH] gitea support --- README.md | 14 ++ src/github/api/config.ts | 2 + src/github/api/queries/gitea.ts | 292 +++++++++++++++++++++++ src/github/data/fetcher.ts | 50 ++-- src/github/operations/branch-cleanup.ts | 19 +- src/github/operations/comment-logic.ts | 13 +- src/github/operations/comments/common.ts | 25 +- src/github/validation/actor.ts | 13 + src/github/validation/permissions.ts | 2 +- src/modes/detector.ts | 2 + 10 files changed, 400 insertions(+), 32 deletions(-) create mode 100644 src/github/api/queries/gitea.ts diff --git a/README.md b/README.md index b8301f7..387bd51 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,19 @@ A general-purpose [Claude Code](https://claude.ai/code) action for GitHub PRs and issues that can answer questions and implement code changes. This action intelligently detects when to activate based on your workflow context—whether responding to @claude mentions, issue assignments, or executing automation tasks with explicit prompts. It supports multiple authentication methods including Anthropic direct API, Amazon Bedrock, Google Vertex AI, and Microsoft Foundry. +## 🦊 Gitea Support + +This action now supports Gitea through REST API(GITEA API) fallback . Key features: + +**Configuration Example for Gitea:** +```yaml +env: + GITHUB_TOKEN: "" # setup your gitea PAT + GITHUB_API_URL: "https://gitea.example.com/api/v1" + GITHUB_SERVER_URL: "https://gitea.example.com" + GITEA_BOT_USERNAMES: "gitea-actions" # comma-separated +``` + ## Features - 🎯 **Intelligent Mode Detection**: Automatically selects the appropriate execution mode based on your workflow context—no configuration needed @@ -16,6 +29,7 @@ A general-purpose [Claude Code](https://claude.ai/code) action for GitHub PRs an - 📊 **Structured Outputs**: Get validated JSON results that automatically become GitHub Action outputs for complex automations - 🏃 **Runs on Your Infrastructure**: The action executes entirely on your own GitHub runner (Anthropic API calls go to your chosen provider) - ⚙️ **Simplified Configuration**: Unified `prompt` and `claude_args` inputs provide clean, powerful configuration aligned with Claude Code SDK +- 🦊 **Gitea Support**: Support with Gitea through REST API(GITEA API) fallback when GraphQL is unavailable ## 📦 Upgrading from v0.x? diff --git a/src/github/api/config.ts b/src/github/api/config.ts index 9e533e5..ca21127 100644 --- a/src/github/api/config.ts +++ b/src/github/api/config.ts @@ -2,3 +2,5 @@ export const GITHUB_API_URL = process.env.GITHUB_API_URL || "https://api.github.com"; export const GITHUB_SERVER_URL = process.env.GITHUB_SERVER_URL || "https://github.com"; +export const USE_GITEA_API = process.env.USE_GITEA_API === "true" || process.env.USE_GITEA_API === "1"; +export const GITEA_BOT_USERNAMES = (process.env.USE_GITEA_API || "").split(",") \ 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..ef5345a --- /dev/null +++ b/src/github/api/queries/gitea.ts @@ -0,0 +1,292 @@ +import type { Octokit } from "@octokit/rest"; + +// Type definitions for REST API responses +type RestFile = Awaited< + ReturnType +>["data"][number]; +type RestComment = Awaited< + ReturnType +>["data"][number]; +type RestCommit = Awaited< + ReturnType +>["data"][number]; +type RestReview = Awaited< + ReturnType +>["data"][number]; +type RestReviewComment = Awaited< + ReturnType +>["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 fb344ac..4e974ee 100644 --- a/src/github/data/fetcher.ts +++ b/src/github/data/fetcher.ts @@ -1,6 +1,8 @@ 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, isIssuesEvent, @@ -249,12 +251,14 @@ 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), }, ); @@ -264,13 +268,15 @@ export async function fetchGitHubData({ changedFiles = pullRequest.files.nodes || []; comments = filterCommentsByActor( filterCommentsToTriggerTime( - pullRequest.comments?.nodes || [], - triggerTime, + pullRequest.comments?.nodes || [], + triggerTime, ), includeCommentsByActor, excludeCommentsByActor, ); - reviewData = pullRequest.reviews || []; + reviewData = USE_GITEA_API + ? pullRequest.reviews || null + : pullRequest.reviews || []; console.log(`Successfully fetched PR #${prNumber} data`); } else { @@ -278,12 +284,14 @@ 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), }, ); @@ -291,8 +299,8 @@ export async function fetchGitHubData({ contextData = issueResult.repository.issue; comments = filterCommentsByActor( filterCommentsToTriggerTime( - contextData?.comments?.nodes || [], - triggerTime, + contextData?.comments?.nodes || [], + triggerTime, ), includeCommentsByActor, excludeCommentsByActor, @@ -475,9 +483,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..2d06335 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..79240b1 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..a31f818 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,31 @@ 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 { + return USE_GITEA_API ? "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 36be2ff..23ee999 100644 --- a/src/github/validation/actor.ts +++ b/src/github/validation/actor.ts @@ -7,11 +7,24 @@ import type { Octokit } from "@octokit/rest"; import type { GitHubContext } from "../context"; +import { GITEA_BOT_USERNAMES, USE_GITEA_API } from "../api/config.ts"; export async function checkHumanActor( octokit: Octokit, githubContext: GitHubContext, ) { + + + if (USE_GITEA_API) { + if (!GITEA_BOT_USERNAMES.includes(githubContext.actor)) { + throw new Error( + `Workflow initiated by non-human actor: ${githubContext.actor}. Please add bot to GITEA_BOT_USERNAMES`, + ); + } + // For Gitea environments, skip the user type check if USE_GITEA_API is set + 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..a9a58e1 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 === "admin" || permissionLevel === "write" || permissionLevel === "owner") { core.info(`Actor has write access: ${permissionLevel}`); return true; } else { 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", ];