Forgejo-patched claude-code-gitea-action (View-job URL uses per-repo run index)
Some checks failed
Test Custom Executables / test-custom-executables (push) Has been cancelled
CI / test (push) Has been cancelled
CI / prettier (push) Has been cancelled
CI / typecheck (push) Has been cancelled
Sync Base Action to claude-code-base-action / Sync base-action to claude-code-base-action repository (push) Has been cancelled
Test Claude Code Action / test-inline-prompt (push) Has been cancelled
Test Claude Code Action / test-prompt-file (push) Has been cancelled
Test Claude Env Feature / test-claude-env-with-comments (push) Has been cancelled
Test MCP Servers / test-mcp-integration (push) Has been cancelled
Test MCP Servers / test-mcp-config-flag (push) Has been cancelled
Test Settings Feature / test-settings-inline-allow (push) Has been cancelled
Test Settings Feature / test-settings-inline-deny (push) Has been cancelled
Test Settings Feature / test-settings-file-allow (push) Has been cancelled
Test Settings Feature / test-settings-file-deny (push) Has been cancelled

Fork of markwylde/claude-code-gitea-action@b744372 (v1.0.21) with the
'View job' link fixed to use GITHUB_RUN_NUMBER (per-repo index) instead of
GITHUB_RUN_ID (global id), because Forgejo routes run pages by per-repo index.
See VENDOR.txt.
This commit is contained in:
claude (ops) 2026-07-01 06:42:55 +00:00
commit 32685a740f
151 changed files with 23555 additions and 0 deletions

17
src/github/api/client.ts Normal file
View file

@ -0,0 +1,17 @@
import { GiteaApiClient, createGiteaClient } from "./gitea-client";
export type GitHubClient = {
api: GiteaApiClient;
};
export function createClient(token: string): GitHubClient {
// Use the GITEA_API_URL environment variable if provided
const apiUrl = process.env.GITEA_API_URL;
console.log(
`Creating client with API URL: ${apiUrl || "default (https://api.github.com)"}`,
);
return {
api: apiUrl ? new GiteaApiClient(token, apiUrl) : createGiteaClient(token),
};
}

35
src/github/api/config.ts Normal file
View file

@ -0,0 +1,35 @@
// Derive API URL from server URL for Gitea instances
function deriveApiUrl(serverUrl: string): string {
if (serverUrl.includes("github.com")) {
return "https://api.github.com";
}
// For Gitea, add /api/v1 to the server URL to get the API URL
return `${serverUrl}/api/v1`;
}
// Get the appropriate server URL, prioritizing GITEA_SERVER_URL for custom Gitea instances
function getServerUrl(): string {
// First check for GITEA_SERVER_URL (can be set by user)
const giteaServerUrl = process.env.GITEA_SERVER_URL;
if (giteaServerUrl && giteaServerUrl !== "") {
return giteaServerUrl;
}
// Fall back to GITHUB_SERVER_URL (set by Gitea/GitHub Actions environment)
const githubServerUrl = process.env.GITHUB_SERVER_URL;
if (githubServerUrl && githubServerUrl !== "") {
return githubServerUrl;
}
// Default fallback
return "https://github.com";
}
export const GITEA_SERVER_URL = getServerUrl();
export const GITEA_API_URL =
process.env.GITEA_API_URL || deriveApiUrl(GITEA_SERVER_URL);
// Backwards-compatible aliases for legacy GitHub-specific naming
export const GITHUB_SERVER_URL = GITEA_SERVER_URL;
export const GITHUB_API_URL = GITEA_API_URL;

View file

@ -0,0 +1,318 @@
import fetch from "node-fetch";
import { GITEA_API_URL } from "./config";
export interface GiteaApiResponse<T = any> {
status: number;
data: T;
headers: Record<string, string>;
}
export interface GiteaApiError extends Error {
status: number;
response?: {
data: any;
status: number;
headers: Record<string, string>;
};
}
export class GiteaApiClient {
private baseUrl: string;
private token: string;
constructor(token: string, baseUrl: string = GITEA_API_URL) {
this.token = token;
this.baseUrl = baseUrl.replace(/\/+$/, ""); // Remove trailing slashes
}
getBaseUrl(): string {
return this.baseUrl;
}
private async request<T = any>(
method: string,
endpoint: string,
body?: any,
): Promise<GiteaApiResponse<T>> {
const url = `${this.baseUrl}${endpoint}`;
console.log(`Making ${method} request to: ${url}`);
const headers: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `token ${this.token}`,
};
const options: any = {
method,
headers,
};
if (body && (method === "POST" || method === "PUT" || method === "PATCH")) {
options.body = JSON.stringify(body);
}
try {
const response = await fetch(url, options);
let responseData: any = null;
const contentType = response.headers.get("content-type");
// Only try to parse JSON if the response has JSON content type
if (contentType && contentType.includes("application/json")) {
try {
responseData = await response.json();
} catch (parseError) {
console.warn(`Failed to parse JSON response: ${parseError}`);
responseData = await response.text();
}
} else {
responseData = await response.text();
}
if (!response.ok) {
const errorMessage =
typeof responseData === "object" && responseData.message
? responseData.message
: responseData || response.statusText;
const error = new Error(
`HTTP ${response.status}: ${errorMessage}`,
) as GiteaApiError;
error.status = response.status;
error.response = {
data: responseData,
status: response.status,
headers: Object.fromEntries(response.headers.entries()),
};
throw error;
}
return {
status: response.status,
data: responseData as T,
headers: Object.fromEntries(response.headers.entries()),
};
} catch (error) {
if (error instanceof Error && "status" in error) {
throw error;
}
throw new Error(`Request failed: ${error}`);
}
}
// Repository operations
async getRepo(owner: string, repo: string) {
return this.request("GET", `/api/v1/repos/${owner}/${repo}`);
}
// Simple test endpoint to verify API connectivity
async testConnection() {
return this.request("GET", "/api/v1/version");
}
async getBranch(owner: string, repo: string, branch: string) {
return this.request(
"GET",
`/api/v1/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}`,
);
}
async createBranch(
owner: string,
repo: string,
newBranch: string,
fromBranch: string,
) {
return this.request("POST", `/api/v1/repos/${owner}/${repo}/branches`, {
new_branch_name: newBranch,
old_branch_name: fromBranch,
});
}
async listBranches(owner: string, repo: string) {
return this.request("GET", `/api/v1/repos/${owner}/${repo}/branches`);
}
// Issue operations
async getIssue(owner: string, repo: string, issueNumber: number) {
return this.request(
"GET",
`/api/v1/repos/${owner}/${repo}/issues/${issueNumber}`,
);
}
async listIssueComments(owner: string, repo: string, issueNumber: number) {
return this.request(
"GET",
`/api/v1/repos/${owner}/${repo}/issues/${issueNumber}/comments`,
);
}
async createIssueComment(
owner: string,
repo: string,
issueNumber: number,
body: string,
) {
return this.request(
"POST",
`/api/v1/repos/${owner}/${repo}/issues/${issueNumber}/comments`,
{
body,
},
);
}
async updateIssueComment(
owner: string,
repo: string,
commentId: number,
body: string,
) {
return this.request(
"PATCH",
`/api/v1/repos/${owner}/${repo}/issues/comments/${commentId}`,
{
body,
},
);
}
// Pull request operations
async getPullRequest(owner: string, repo: string, prNumber: number) {
return this.request(
"GET",
`/api/v1/repos/${owner}/${repo}/pulls/${prNumber}`,
);
}
async listPullRequestFiles(owner: string, repo: string, prNumber: number) {
return this.request(
"GET",
`/api/v1/repos/${owner}/${repo}/pulls/${prNumber}/files`,
);
}
async listPullRequestComments(owner: string, repo: string, prNumber: number) {
return this.request(
"GET",
`/api/v1/repos/${owner}/${repo}/pulls/${prNumber}/comments`,
);
}
async createPullRequestComment(
owner: string,
repo: string,
prNumber: number,
body: string,
) {
return this.request(
"POST",
`/api/v1/repos/${owner}/${repo}/pulls/${prNumber}/comments`,
{
body,
},
);
}
// File operations
async getFileContents(
owner: string,
repo: string,
path: string,
ref?: string,
) {
let endpoint = `/api/v1/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`;
if (ref) {
endpoint += `?ref=${encodeURIComponent(ref)}`;
}
return this.request("GET", endpoint);
}
async createFile(
owner: string,
repo: string,
path: string,
content: string,
message: string,
branch?: string,
) {
const body: any = {
message,
content: Buffer.from(content).toString("base64"),
};
if (branch) {
body.branch = branch;
}
return this.request(
"POST",
`/api/v1/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`,
body,
);
}
async updateFile(
owner: string,
repo: string,
path: string,
content: string,
message: string,
sha: string,
branch?: string,
) {
const body: any = {
message,
content: Buffer.from(content).toString("base64"),
sha,
};
if (branch) {
body.branch = branch;
}
return this.request(
"PUT",
`/api/v1/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`,
body,
);
}
async deleteFile(
owner: string,
repo: string,
path: string,
message: string,
sha: string,
branch?: string,
) {
const body: any = {
message,
sha,
};
if (branch) {
body.branch = branch;
}
return this.request(
"DELETE",
`/api/v1/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`,
body,
);
}
// Generic request method for other operations
async customRequest<T = any>(
method: string,
endpoint: string,
body?: any,
): Promise<GiteaApiResponse<T>> {
return this.request<T>(method, endpoint, body);
}
}
export function createGiteaClient(token: string): GiteaApiClient {
return new GiteaApiClient(token);
}

View file

@ -0,0 +1,114 @@
// GraphQL queries for GitHub data
export const PR_QUERY = `
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
title
body
author {
login
}
baseRefName
headRefName
headRefOid
createdAt
additions
deletions
state
commits(first: 100) {
totalCount
nodes {
commit {
oid
message
author {
name
email
}
}
}
}
files(first: 100) {
nodes {
path
additions
deletions
changeType
}
}
comments(first: 100) {
nodes {
id
databaseId
body
author {
login
}
createdAt
}
}
reviews(first: 100) {
nodes {
id
databaseId
author {
login
}
body
state
submittedAt
comments(first: 100) {
nodes {
id
databaseId
body
path
line
author {
login
}
createdAt
}
}
}
}
}
}
}
`;
export const ISSUE_QUERY = `
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
issue(number: $number) {
title
body
author {
login
}
createdAt
state
comments(first: 100) {
nodes {
id
databaseId
body
author {
login
}
createdAt
}
}
}
}
}
`;
export const USER_QUERY = `
query($login: String!) {
user(login: $login) {
name
}
}
`;

214
src/github/context.ts Normal file
View file

@ -0,0 +1,214 @@
import * as github from "@actions/github";
import type {
IssuesEvent,
IssuesAssignedEvent,
IssueCommentEvent,
PullRequestEvent,
PullRequestReviewEvent,
PullRequestReviewCommentEvent,
} from "@octokit/webhooks-types";
import type { ModeName } from "../modes/types";
import { DEFAULT_MODE, isValidMode } from "../modes/registry";
// Gitea review payloads use `review.content` instead of `review.body`, and
// `sender` instead of nested user objects. These types extend the GitHub base
// types to make both fields available without `as any` casts.
export type GiteaPullRequestReviewEvent = PullRequestReviewEvent & {
review?: { content?: string };
sender?: { login: string };
};
export type GiteaPullRequestReviewCommentEvent =
PullRequestReviewCommentEvent & {
review?: { type: string; content: string };
sender?: { login: string };
};
export type ParsedGitHubContext = {
runId: string;
eventName: string;
eventAction?: string;
repository: {
owner: string;
repo: string;
full_name: string;
};
actor: string;
payload:
| IssuesEvent
| IssueCommentEvent
| PullRequestEvent
| GiteaPullRequestReviewEvent
| GiteaPullRequestReviewCommentEvent;
entityNumber: number;
isPR: boolean;
inputs: {
mode: ModeName;
triggerPhrase: string;
assigneeTrigger: string;
labelTrigger: string;
allowedTools: string[];
disallowedTools: string[];
customInstructions: string;
directPrompt: string;
overridePrompt: string;
baseBranch?: string;
branchPrefix: string;
useStickyComment: boolean;
additionalPermissions: Map<string, string>;
useCommitSigning: boolean;
};
};
export function parseGitHubContext(): ParsedGitHubContext {
const context = github.context;
const modeInput = process.env.MODE ?? DEFAULT_MODE;
if (!isValidMode(modeInput)) {
throw new Error(`Invalid mode: ${modeInput}.`);
}
const commonFields = {
runId: process.env.GITHUB_RUN_ID!,
eventName: context.eventName,
eventAction: context.payload.action,
repository: {
owner: context.repo.owner,
repo: context.repo.repo,
full_name: `${context.repo.owner}/${context.repo.repo}`,
},
actor: context.actor,
inputs: {
mode: modeInput as ModeName,
triggerPhrase: process.env.TRIGGER_PHRASE ?? "@claude",
assigneeTrigger: process.env.ASSIGNEE_TRIGGER ?? "",
labelTrigger: process.env.LABEL_TRIGGER ?? "",
allowedTools: parseMultilineInput(process.env.ALLOWED_TOOLS ?? ""),
disallowedTools: parseMultilineInput(process.env.DISALLOWED_TOOLS ?? ""),
customInstructions: process.env.CUSTOM_INSTRUCTIONS ?? "",
directPrompt: process.env.DIRECT_PROMPT ?? "",
overridePrompt: process.env.OVERRIDE_PROMPT ?? "",
baseBranch: process.env.BASE_BRANCH,
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
useStickyComment: process.env.USE_STICKY_COMMENT === "true",
additionalPermissions: parseAdditionalPermissions(
process.env.ADDITIONAL_PERMISSIONS ?? "",
),
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
},
};
switch (context.eventName) {
case "issues": {
return {
...commonFields,
payload: context.payload as IssuesEvent,
entityNumber: (context.payload as IssuesEvent).issue.number,
isPR: false,
};
}
case "issue_comment": {
return {
...commonFields,
payload: context.payload as IssueCommentEvent,
entityNumber: (context.payload as IssueCommentEvent).issue.number,
isPR: Boolean(
(context.payload as IssueCommentEvent).issue.pull_request,
),
};
}
case "pull_request": {
return {
...commonFields,
payload: context.payload as PullRequestEvent,
entityNumber: (context.payload as PullRequestEvent).pull_request.number,
isPR: true,
};
}
case "pull_request_review": {
return {
...commonFields,
payload: context.payload as PullRequestReviewEvent,
entityNumber: (context.payload as PullRequestReviewEvent).pull_request
.number,
isPR: true,
};
}
case "pull_request_review_comment": {
return {
...commonFields,
payload: context.payload as PullRequestReviewCommentEvent,
entityNumber: (context.payload as PullRequestReviewCommentEvent)
.pull_request.number,
isPR: true,
};
}
default:
throw new Error(`Unsupported event type: ${context.eventName}`);
}
}
export function parseMultilineInput(s: string): string[] {
return s
.split(/,|[\n\r]+/)
.map((tool) => tool.replace(/#.+$/, ""))
.map((tool) => tool.trim())
.filter((tool) => tool.length > 0);
}
export function parseAdditionalPermissions(s: string): Map<string, string> {
const permissions = new Map<string, string>();
if (!s || !s.trim()) {
return permissions;
}
const lines = s.trim().split("\n");
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine) {
const [key, value] = trimmedLine.split(":").map((part) => part.trim());
if (key && value) {
permissions.set(key, value);
}
}
}
return permissions;
}
export function isIssuesEvent(
context: ParsedGitHubContext,
): context is ParsedGitHubContext & { payload: IssuesEvent } {
return context.eventName === "issues";
}
export function isIssueCommentEvent(
context: ParsedGitHubContext,
): context is ParsedGitHubContext & { payload: IssueCommentEvent } {
return context.eventName === "issue_comment";
}
export function isPullRequestEvent(
context: ParsedGitHubContext,
): context is ParsedGitHubContext & { payload: PullRequestEvent } {
return context.eventName === "pull_request";
}
export function isPullRequestReviewEvent(
context: ParsedGitHubContext,
): context is ParsedGitHubContext & { payload: GiteaPullRequestReviewEvent } {
return context.eventName === "pull_request_review";
}
export function isPullRequestReviewCommentEvent(
context: ParsedGitHubContext,
): context is ParsedGitHubContext & {
payload: GiteaPullRequestReviewCommentEvent;
} {
return context.eventName === "pull_request_review_comment";
}
export function isIssuesAssignedEvent(
context: ParsedGitHubContext,
): context is ParsedGitHubContext & { payload: IssuesAssignedEvent } {
return isIssuesEvent(context) && context.eventAction === "assigned";
}

249
src/github/data/fetcher.ts Normal file
View file

@ -0,0 +1,249 @@
import { execSync } from "child_process";
import type {
GitHubPullRequest,
GitHubIssue,
GitHubComment,
GitHubFile,
GitHubReview,
} from "../types";
import type { GitHubClient } from "../api/client";
import { downloadCommentImages } from "../utils/image-downloader";
import type { CommentWithImages } from "../utils/image-downloader";
type FetchDataParams = {
client: GitHubClient;
repository: string;
prNumber: string;
isPR: boolean;
};
export type GitHubFileWithSHA = GitHubFile & {
sha: string;
};
export type FetchDataResult = {
contextData: GitHubPullRequest | GitHubIssue;
comments: GitHubComment[];
changedFiles: GitHubFile[];
changedFilesWithSHA: GitHubFileWithSHA[];
reviewData: { nodes: GitHubReview[] } | null;
imageUrlMap: Map<string, string>;
};
export async function fetchGitHubData({
client,
repository,
prNumber,
isPR,
}: FetchDataParams): Promise<FetchDataResult> {
const [owner, repo] = repository.split("/");
if (!owner || !repo) {
throw new Error("Invalid repository format. Expected 'owner/repo'.");
}
let contextData: GitHubPullRequest | GitHubIssue | null = null;
let comments: GitHubComment[] = [];
let changedFiles: GitHubFile[] = [];
let reviewData: { nodes: GitHubReview[] } | null = null;
try {
// Use REST API for all requests (works with both GitHub and Gitea)
if (isPR) {
console.log(`Fetching PR #${prNumber} data using REST API`);
const prResponse = await client.api.getPullRequest(
owner,
repo,
parseInt(prNumber),
);
contextData = {
title: prResponse.data.title,
body: prResponse.data.body || "",
author: { login: prResponse.data.user?.login || "" },
baseRefName: prResponse.data.base.ref,
headRefName: prResponse.data.head.ref,
headRefOid: prResponse.data.head.sha,
createdAt: prResponse.data.created_at,
additions: prResponse.data.additions || 0,
deletions: prResponse.data.deletions || 0,
state: prResponse.data.state.toUpperCase(),
commits: { totalCount: 0, nodes: [] },
files: { nodes: [] },
comments: { nodes: [] },
reviews: { nodes: [] },
};
// Fetch comments separately
try {
const commentsResponse = await client.api.listIssueComments(
owner,
repo,
parseInt(prNumber),
);
comments = commentsResponse.data.map((comment: any) => ({
id: comment.id.toString(),
databaseId: comment.id.toString(),
body: comment.body || "",
author: { login: comment.user?.login || "" },
createdAt: comment.created_at,
}));
} catch (error) {
console.warn("Failed to fetch PR comments:", error);
comments = []; // Ensure we have an empty array
}
// Try to fetch files
try {
const filesResponse = await client.api.listPullRequestFiles(
owner,
repo,
parseInt(prNumber),
);
changedFiles = filesResponse.data.map((file: any) => ({
path: file.filename,
additions: file.additions || 0,
deletions: file.deletions || 0,
changeType: file.status || "modified",
}));
} catch (error) {
console.warn("Failed to fetch PR files:", error);
changedFiles = []; // Ensure we have an empty array
}
reviewData = { nodes: [] }; // Simplified for Gitea
} else {
console.log(`Fetching issue #${prNumber} data using REST API`);
const issueResponse = await client.api.getIssue(
owner,
repo,
parseInt(prNumber),
);
contextData = {
title: issueResponse.data.title,
body: issueResponse.data.body || "",
author: { login: issueResponse.data.user?.login || "" },
createdAt: issueResponse.data.created_at,
state: issueResponse.data.state.toUpperCase(),
comments: { nodes: [] },
};
// Fetch comments
try {
const commentsResponse = await client.api.listIssueComments(
owner,
repo,
parseInt(prNumber),
);
comments = commentsResponse.data.map((comment: any) => ({
id: comment.id.toString(),
databaseId: comment.id.toString(),
body: comment.body || "",
author: { login: comment.user?.login || "" },
createdAt: comment.created_at,
}));
} catch (error) {
console.warn("Failed to fetch issue comments:", error);
comments = []; // Ensure we have an empty array
}
}
} catch (error) {
console.error(`Failed to fetch ${isPR ? "PR" : "issue"} data:`, error);
throw new Error(`Failed to fetch ${isPR ? "PR" : "issue"} data`);
}
// Compute SHAs for changed files
let changedFilesWithSHA: GitHubFileWithSHA[] = [];
if (isPR && changedFiles.length > 0) {
changedFilesWithSHA = changedFiles.map((file) => {
try {
// Use git hash-object to compute the SHA for the current file content
const sha = execSync(`git hash-object "${file.path}"`, {
encoding: "utf-8",
}).trim();
return {
...file,
sha,
};
} catch (error) {
console.warn(`Failed to compute SHA for ${file.path}:`, error);
// Return original file without SHA if computation fails
return {
...file,
sha: "unknown",
};
}
});
}
// Prepare all comments for image processing
const issueComments: CommentWithImages[] = comments
.filter((c) => c.body)
.map((c) => ({
type: "issue_comment" as const,
id: c.databaseId,
body: c.body,
}));
const reviewBodies: CommentWithImages[] =
reviewData?.nodes
?.filter((r) => r.body)
.map((r) => ({
type: "review_body" as const,
id: r.databaseId,
pullNumber: prNumber,
body: r.body,
})) ?? [];
const reviewComments: CommentWithImages[] =
reviewData?.nodes
?.flatMap((r) => r.comments?.nodes ?? [])
.filter((c) => c.body)
.map((c) => ({
type: "review_comment" as const,
id: c.databaseId,
body: c.body,
})) ?? [];
// Add the main issue/PR body if it has content
const mainBody: CommentWithImages[] = contextData.body
? [
{
...(isPR
? {
type: "pr_body" as const,
pullNumber: prNumber,
body: contextData.body,
}
: {
type: "issue_body" as const,
issueNumber: prNumber,
body: contextData.body,
}),
},
]
: [];
const allComments = [
...mainBody,
...issueComments,
...reviewBodies,
...reviewComments,
];
const imageUrlMap = await downloadCommentImages(
client,
owner,
repo,
allComments,
);
return {
contextData,
comments,
changedFiles,
changedFilesWithSHA,
reviewData,
imageUrlMap,
};
}

View file

@ -0,0 +1,140 @@
import type {
GitHubPullRequest,
GitHubIssue,
GitHubComment,
GitHubFile,
GitHubReview,
} from "../types";
import type { GitHubFileWithSHA } from "./fetcher";
import { sanitizeContent } from "../utils/sanitizer";
export function formatContext(
contextData: GitHubPullRequest | GitHubIssue,
isPR: boolean,
): string {
if (isPR) {
const prData = contextData as GitHubPullRequest;
return `PR Title: ${prData.title}
PR Author: ${prData.author.login}
PR Branch: ${prData.headRefName} -> ${prData.baseRefName}
PR State: ${prData.state}
PR Additions: ${prData.additions}
PR Deletions: ${prData.deletions}
Total Commits: ${prData.commits.totalCount}
Changed Files: ${prData.files.nodes.length} files`;
} else {
const issueData = contextData as GitHubIssue;
return `Issue Title: ${issueData.title}
Issue Author: ${issueData.author.login}
Issue State: ${issueData.state}`;
}
}
export function formatBody(
body: string,
imageUrlMap: Map<string, string>,
): string {
let processedBody = body;
for (const [originalUrl, localPath] of imageUrlMap) {
processedBody = processedBody.replaceAll(originalUrl, localPath);
}
processedBody = sanitizeContent(processedBody);
return processedBody;
}
export function formatComments(
comments: GitHubComment[],
imageUrlMap?: Map<string, string>,
): string {
return comments
.map((comment) => {
let body = comment.body;
if (imageUrlMap && body) {
for (const [originalUrl, localPath] of imageUrlMap) {
body = body.replaceAll(originalUrl, localPath);
}
}
body = sanitizeContent(body);
return `[${comment.author.login} at ${comment.createdAt}]: ${body}`;
})
.join("\n\n");
}
export function formatReviewComments(
reviewData: { nodes: GitHubReview[] } | null,
imageUrlMap?: Map<string, string>,
): string {
if (!reviewData || !reviewData.nodes) {
return "";
}
const formattedReviews = reviewData.nodes.map((review) => {
let reviewOutput = `[Review by ${review.author.login} at ${review.submittedAt}]: ${review.state}`;
if (review.body && review.body.trim()) {
let body = review.body;
if (imageUrlMap) {
for (const [originalUrl, localPath] of imageUrlMap) {
body = body.replaceAll(originalUrl, localPath);
}
}
const sanitizedBody = sanitizeContent(body);
reviewOutput += `\n${sanitizedBody}`;
}
if (
review.comments &&
review.comments.nodes &&
review.comments.nodes.length > 0
) {
const comments = review.comments.nodes
.map((comment) => {
let body = comment.body;
if (imageUrlMap) {
for (const [originalUrl, localPath] of imageUrlMap) {
body = body.replaceAll(originalUrl, localPath);
}
}
body = sanitizeContent(body);
return ` [Comment on ${comment.path}:${comment.line || "?"}]: ${body}`;
})
.join("\n");
reviewOutput += `\n${comments}`;
}
return reviewOutput;
});
return formattedReviews.join("\n\n");
}
export function formatChangedFiles(changedFiles: GitHubFile[]): string {
return changedFiles
.map(
(file) =>
`- ${file.path} (${file.changeType}) +${file.additions}/-${file.deletions}`,
)
.join("\n");
}
export function formatChangedFilesWithSHA(
changedFiles: GitHubFileWithSHA[],
): string {
return changedFiles
.map(
(file) =>
`- ${file.path} (${file.changeType}) +${file.additions}/-${file.deletions} SHA: ${file.sha}`,
)
.join("\n");
}

View file

@ -0,0 +1,146 @@
import type { GitHubClient } from "../api/client";
import { GITEA_SERVER_URL } from "../api/config";
import {
branchHasChanges,
fetchBranch,
branchExists,
remoteBranchExists,
} from "../utils/local-git";
export async function checkAndDeleteEmptyBranch(
client: GitHubClient,
owner: string,
repo: string,
claudeBranch: string | undefined,
baseBranch: string,
): Promise<{ shouldDeleteBranch: boolean; branchLink: string }> {
let branchLink = "";
let shouldDeleteBranch = false;
if (claudeBranch) {
// Check if we're using Gitea or GitHub
const giteaApiUrl = process.env.GITEA_API_URL?.trim();
const isGitea =
giteaApiUrl &&
giteaApiUrl !== "" &&
!giteaApiUrl.includes("api.github.com") &&
!giteaApiUrl.includes("github.com");
if (isGitea) {
// Use local git operations for Gitea
console.log("Using local git commands for branch check (Gitea mode)");
try {
// Fetch latest changes from remote
await fetchBranch(claudeBranch);
await fetchBranch(baseBranch);
// Check if branch exists and has changes
const { hasChanges, branchSha, baseSha } = await branchHasChanges(
claudeBranch,
baseBranch,
);
if (branchSha && baseSha) {
if (hasChanges) {
console.log(
`Branch ${claudeBranch} appears to have commits (different SHA from base)`,
);
const branchUrl = `${GITEA_SERVER_URL}/${owner}/${repo}/src/branch/${claudeBranch}`;
branchLink = `\n[View branch](${branchUrl})`;
} else {
console.log(
`Branch ${claudeBranch} has same SHA as base, marking for deletion`,
);
shouldDeleteBranch = true;
}
} else {
// If we can't get SHAs, check if branch exists at all
const localExists = await branchExists(claudeBranch);
const remoteExists = await remoteBranchExists(claudeBranch);
if (localExists || remoteExists) {
console.log(
`Branch ${claudeBranch} exists but SHA comparison failed, assuming it has commits`,
);
const branchUrl = `${GITEA_SERVER_URL}/${owner}/${repo}/src/branch/${claudeBranch}`;
branchLink = `\n[View branch](${branchUrl})`;
} else {
console.log(
`Branch ${claudeBranch} does not exist yet - this is normal during workflow`,
);
branchLink = "";
}
}
} catch (error: any) {
console.error("Error checking branch with git commands:", error);
// For errors, assume the branch has commits to be safe
console.log("Assuming branch exists due to git command error");
const branchUrl = `${GITEA_SERVER_URL}/${owner}/${repo}/src/branch/${claudeBranch}`;
branchLink = `\n[View branch](${branchUrl})`;
}
} else {
// Use API calls for GitHub
console.log("Using API calls for branch check (GitHub mode)");
try {
// Get the branch info to see if it exists and has commits
const branchResponse = await client.api.getBranch(
owner,
repo,
claudeBranch,
);
// Get base branch info for comparison
const baseResponse = await client.api.getBranch(
owner,
repo,
baseBranch,
);
const branchSha = branchResponse.data.commit.sha;
const baseSha = baseResponse.data.commit.sha;
// If SHAs are different, assume there are commits
if (branchSha !== baseSha) {
console.log(
`Branch ${claudeBranch} appears to have commits (different SHA from base)`,
);
const branchUrl = `${GITEA_SERVER_URL}/${owner}/${repo}/src/branch/${claudeBranch}`;
branchLink = `\n[View branch](${branchUrl})`;
} else {
console.log(
`Branch ${claudeBranch} has same SHA as base, marking for deletion`,
);
shouldDeleteBranch = true;
}
} catch (error: any) {
console.error("Error checking branch:", error);
// Handle 404 specifically - branch doesn't exist
if (error.status === 404) {
console.log(
`Branch ${claudeBranch} does not exist yet - this is normal during workflow`,
);
// Don't add branch link since branch doesn't exist
branchLink = "";
} else {
// For other errors, assume the branch has commits to be safe
console.log("Assuming branch exists due to non-404 error");
const branchUrl = `${GITEA_SERVER_URL}/${owner}/${repo}/src/branch/${claudeBranch}`;
branchLink = `\n[View branch](${branchUrl})`;
}
}
}
}
// Delete the branch if it has no commits
if (shouldDeleteBranch && claudeBranch) {
console.log(
`Skipping branch deletion - not reliably supported across all Git platforms: ${claudeBranch}`,
);
// Skip deletion to avoid compatibility issues
}
return { shouldDeleteBranch, branchLink };
}

View file

@ -0,0 +1,139 @@
#!/usr/bin/env bun
/**
* Setup the appropriate branch based on the event type:
* - For PRs: Checkout the PR branch
* - For Issues: Create a new branch
*/
import { $ } from "bun";
import * as core from "@actions/core";
import type { ParsedGitHubContext } from "../context";
import type { GitHubPullRequest } from "../types";
import type { GitHubClient } from "../api/client";
import type { FetchDataResult } from "../data/fetcher";
export type BranchInfo = {
baseBranch: string;
claudeBranch?: string;
currentBranch: string;
};
export async function setupBranch(
client: GitHubClient,
githubData: FetchDataResult,
context: ParsedGitHubContext,
): Promise<BranchInfo> {
const { owner, repo } = context.repository;
const entityNumber = context.entityNumber;
const { baseBranch } = context.inputs;
const isPR = context.isPR;
// Determine base branch - use baseBranch if provided, otherwise fetch default
let sourceBranch: string;
if (baseBranch) {
// Use provided base branch for source
sourceBranch = baseBranch;
} else {
// No base branch provided, fetch the default branch to use as source
const repoResponse = await client.api.getRepo(owner, repo);
sourceBranch = repoResponse.data.default_branch;
}
if (isPR) {
const prData = githubData.contextData as GitHubPullRequest;
const prState = prData.state;
// Check if PR is closed or merged
if (prState === "CLOSED" || prState === "MERGED") {
console.log(
`PR #${entityNumber} is ${prState}, will let Claude create a new branch when needed`,
);
// Check out the base branch and let Claude create branches as needed
await $`git fetch origin --depth=1 ${sourceBranch}`;
await $`git checkout ${sourceBranch}`;
await $`git pull origin ${sourceBranch}`;
return {
baseBranch: sourceBranch,
currentBranch: sourceBranch,
};
} else {
// Handle open PR: Checkout the PR branch
console.log("This is an open PR, checking out PR branch...");
const branchName = prData.headRefName;
// Execute git commands to checkout PR branch (shallow fetch for performance)
// Fetch the branch with a depth of 20 to avoid fetching too much history, while still allowing for some context
await $`git fetch origin --depth=20 ${branchName}`;
await $`git checkout ${branchName}`;
console.log(`Successfully checked out PR branch for PR #${entityNumber}`);
// For open PRs, we need to get the base branch of the PR
const baseBranch = prData.baseRefName;
return {
baseBranch,
currentBranch: branchName,
};
}
}
// For issues, check out the base branch and let Claude create branches as needed
console.log(
`Setting up base branch ${sourceBranch} for issue #${entityNumber}, Claude will create branch when needed...`,
);
try {
// Ensure we're in the repository directory
const repoDir = process.env.GITHUB_WORKSPACE || process.cwd();
console.log(`Working in directory: ${repoDir}`);
// Check if we're in a git repository
console.log(`Checking if we're in a git repository...`);
await $`git status`;
// Ensure we have the latest version of the source branch
console.log(`Fetching latest ${sourceBranch}...`);
await $`git fetch origin --depth=1 ${sourceBranch}`;
// Checkout the source branch
console.log(`Checking out ${sourceBranch}...`);
await $`git checkout ${sourceBranch}`;
// Pull latest changes
console.log(`Pulling latest changes for ${sourceBranch}...`);
await $`git pull origin ${sourceBranch}`;
// Verify the branch was checked out
const currentBranch = await $`git branch --show-current`;
const branchName = currentBranch.text().trim();
console.log(`Current branch: ${branchName}`);
if (branchName === sourceBranch) {
console.log(`✅ Successfully checked out base branch: ${sourceBranch}`);
} else {
throw new Error(
`Branch checkout failed. Expected ${sourceBranch}, got ${branchName}`,
);
}
console.log(
`Branch setup completed, ready for Claude to create branches as needed`,
);
// Set outputs for GitHub Actions
core.setOutput("BASE_BRANCH", sourceBranch);
return {
baseBranch: sourceBranch,
currentBranch: sourceBranch,
};
} catch (error) {
console.error("Error setting up branch:", error);
process.exit(1);
}
}

View file

@ -0,0 +1,214 @@
export type ExecutionDetails = {
cost_usd?: number;
duration_ms?: number;
duration_api_ms?: number;
};
export type CommentUpdateInput = {
currentBody: string;
actionFailed: boolean;
executionDetails: ExecutionDetails | null;
jobUrl: string;
branchLink?: string;
prLink?: string;
branchName?: string;
triggerUsername?: string;
errorDetails?: string;
};
export function ensureProperlyEncodedUrl(url: string): string | null {
try {
// First, try to parse the URL to see if it's already properly encoded
new URL(url);
if (url.includes(" ")) {
const [baseUrl, queryString] = url.split("?");
if (queryString) {
// Parse query parameters and re-encode them properly
const params = new URLSearchParams();
const pairs = queryString.split("&");
for (const pair of pairs) {
const [key, value = ""] = pair.split("=");
if (key) {
// Decode first in case it's partially encoded, then encode properly
params.set(key, decodeURIComponent(value));
}
}
return `${baseUrl}?${params.toString()}`;
}
// If no query string, just encode spaces
return url.replace(/ /g, "%20");
}
return url;
} catch (e) {
// If URL parsing fails, try basic fixes
try {
// Replace spaces with %20
let fixedUrl = url.replace(/ /g, "%20");
// Ensure colons in parameter values are encoded (but not in http:// or after domain)
const urlParts = fixedUrl.split("?");
if (urlParts.length > 1 && urlParts[1]) {
const [baseUrl, queryString] = urlParts;
// Encode colons in the query string that aren't already encoded
const fixedQuery = queryString.replace(/([^%]|^):(?!%2F%2F)/g, "$1%3A");
fixedUrl = `${baseUrl}?${fixedQuery}`;
}
// Try to validate the fixed URL
new URL(fixedUrl);
return fixedUrl;
} catch {
// If we still can't create a valid URL, return null
return null;
}
}
}
export function updateCommentBody(input: CommentUpdateInput): string {
const originalBody = input.currentBody;
const {
executionDetails,
jobUrl,
branchLink,
prLink,
actionFailed,
branchName,
triggerUsername,
errorDetails,
} = input;
// Extract content from the original comment body
// First, remove the "Claude Code is working…" or "Claude Code is working..." message
const workingPattern = /Claude Code is working[…\.]{1,3}(?:\s*<img[^>]*>)?/i;
let bodyContent = originalBody.replace(workingPattern, "").trim();
// Check if there's a PR link in the content
let prLinkFromContent = "";
// Match the entire markdown link structure
const prLinkPattern = /\[Create .* PR\]\((.*)\)$/m;
const prLinkMatch = bodyContent.match(prLinkPattern);
if (prLinkMatch && prLinkMatch[1]) {
const encodedUrl = ensureProperlyEncodedUrl(prLinkMatch[1]);
if (encodedUrl) {
prLinkFromContent = encodedUrl;
// Remove the PR link from the content
bodyContent = bodyContent.replace(prLinkMatch[0], "").trim();
}
}
// Calculate duration string if available
let durationStr = "";
if (executionDetails?.duration_ms !== undefined) {
const totalSeconds = Math.round(executionDetails.duration_ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
durationStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
}
// Build the header
let header = "";
if (actionFailed) {
header = "**Claude encountered an error";
if (durationStr) {
header += ` after ${durationStr}`;
}
header += "**";
} else {
// Get the username from triggerUsername or extract from content
const usernameMatch = bodyContent.match(/@([a-zA-Z0-9-]+)/);
const username =
triggerUsername || (usernameMatch ? usernameMatch[1] : "user");
header = `**Claude finished @${username}'s task`;
if (durationStr) {
header += ` in ${durationStr}`;
}
header += "**";
}
// Add links section
let links = ` —— [View job](${jobUrl})`;
// Add branch name with link
if (branchName || branchLink) {
let finalBranchName = branchName;
let branchUrl = "";
if (branchLink) {
// Extract the branch URL from the link
const urlMatch = branchLink.match(/\((https?:\/\/[^\)]+)\)/);
if (urlMatch && urlMatch[1]) {
branchUrl = urlMatch[1];
}
// Extract branch name from link if not provided
if (!finalBranchName) {
const branchNameMatch = branchLink.match(
/(?:tree|src\/branch)\/([^"'\)\s]+)/,
);
if (branchNameMatch) {
finalBranchName = branchNameMatch[1];
}
}
}
// If we don't have a URL yet but have a branch name, construct it
if (!branchUrl && finalBranchName) {
try {
const parsedJobUrl = new URL(jobUrl);
const segments = parsedJobUrl.pathname
.split("/")
.filter((segment) => segment);
const [owner, repo] = segments;
if (owner && repo) {
const serverUrl =
process.env.GITEA_SERVER_URL ||
process.env.GITHUB_SERVER_URL ||
"https://github.com";
branchUrl = `${serverUrl}/${owner}/${repo}/src/branch/${finalBranchName}`;
}
} catch (error) {
console.warn(`Failed to derive branch URL from job URL: ${error}`);
}
}
if (finalBranchName && branchUrl) {
links += ` • [\`${finalBranchName}\`](${branchUrl})`;
} else if (finalBranchName) {
links += `\`${finalBranchName}\``;
}
}
// Add PR link (either from content or provided)
const prUrl =
prLinkFromContent || (prLink ? prLink.match(/\(([^)]+)\)/)?.[1] : "");
if (prUrl) {
links += ` • [Create PR ➔](${prUrl})`;
}
// Build the new body with blank line between header and separator
let newBody = `${header}${links}`;
// Add error details if available
if (actionFailed && errorDetails) {
newBody += `\n\n\`\`\`\n${errorDetails}\n\`\`\``;
}
newBody += `\n\n---\n`;
// Clean up the body content
// Remove any existing View job run, branch links from the bottom
bodyContent = bodyContent.replace(/\n?\[View job run\]\([^\)]+\)/g, "");
bodyContent = bodyContent.replace(/\n?\[View branch\]\([^\)]+\)/g, "");
// Remove any existing duration info at the bottom
bodyContent = bodyContent.replace(/\n*---\n*Duration: [0-9]+m? [0-9]+s/g, "");
// Add the cleaned body content
newBody += bodyContent;
return newBody.trim();
}

View file

@ -0,0 +1,40 @@
import { GITEA_SERVER_URL } from "../../api/config";
function getSpinnerHtml(): string {
return `<img src="https://raw.githubusercontent.com/markwylde/claude-code-gitea-action/refs/heads/gitea/assets/spinner.gif" width="14px" height="14px" style="vertical-align: middle; margin-left: 4px;" />`;
}
export const SPINNER_HTML = getSpinnerHtml();
export function createJobRunLink(
owner: string,
repo: string,
runId: string,
): string {
// Forgejo routes run pages by the per-repo index (exposed as GITHUB_RUN_NUMBER),
// NOT the global run id (GITHUB_RUN_ID / context.runId, which GitHub URLs use).
// Fall back to runId when run number is absent (e.g. on GitHub).
const runRef = process.env.GITHUB_RUN_NUMBER || runId;
const jobRunUrl = `${GITEA_SERVER_URL}/${owner}/${repo}/actions/runs/${runRef}`;
return `[View job run](${jobRunUrl})`;
}
export function createBranchLink(
owner: string,
repo: string,
branchName: string,
): string {
const branchUrl = `${GITEA_SERVER_URL}/${owner}/${repo}/src/branch/${branchName}/`;
return `\n[View branch](${branchUrl})`;
}
export function createCommentBody(
jobRunLink: string,
branchLink: string = "",
): string {
return `Claude Code is working… ${SPINNER_HTML}
I'll analyze this and get back to you.
${jobRunLink}${branchLink}`;
}

View file

@ -0,0 +1,83 @@
#!/usr/bin/env bun
/**
* Create the initial tracking comment when Claude Code starts working
* This comment shows the working status and includes a link to the job run
*/
import { appendFileSync } from "fs";
import { createJobRunLink, createCommentBody } from "./common";
import {
isPullRequestReviewCommentEvent,
type ParsedGitHubContext,
} from "../../context";
import type { GiteaApiClient } from "../../api/gitea-client";
export async function createInitialComment(
api: GiteaApiClient,
context: ParsedGitHubContext,
) {
const { owner, repo } = context.repository;
const jobRunLink = createJobRunLink(owner, repo, context.runId);
const initialBody = createCommentBody(jobRunLink);
try {
let response;
console.log(
`Creating comment for ${context.isPR ? "PR" : "issue"} #${context.entityNumber}`,
);
console.log(`Repository: ${owner}/${repo}`);
// Only use createReplyForReviewComment if it's a PR review comment AND we have a comment_id
if (
isPullRequestReviewCommentEvent(context) &&
context.payload.comment?.id
) {
console.log(`Creating PR review comment reply`);
response = await api.customRequest(
"POST",
`/api/v1/repos/${owner}/${repo}/pulls/${context.entityNumber}/comments/${context.payload.comment.id}/replies`,
{
body: initialBody,
},
);
} else {
// For all other cases (issues, issue comments, or missing comment_id)
console.log(`Creating issue comment via API`);
response = await api.createIssueComment(
owner,
repo,
context.entityNumber,
initialBody,
);
}
// Output the comment ID for downstream steps using GITHUB_OUTPUT
const githubOutput = process.env.GITHUB_OUTPUT!;
appendFileSync(githubOutput, `claude_comment_id=${response.data.id}\n`);
console.log(`✅ Created initial comment with ID: ${response.data.id}`);
return response.data.id;
} catch (error) {
console.error("Error in initial comment:", error);
// Always fall back to regular issue comment if anything fails
try {
const response = await api.createIssueComment(
owner,
repo,
context.entityNumber,
initialBody,
);
const githubOutput = process.env.GITHUB_OUTPUT!;
appendFileSync(githubOutput, `claude_comment_id=${response.data.id}\n`);
console.log(`✅ Created fallback comment with ID: ${response.data.id}`);
return response.data.id;
} catch (fallbackError) {
console.error("Error creating fallback comment:", fallbackError);
throw fallbackError;
}
}
}

View file

@ -0,0 +1,70 @@
import { Octokit } from "@octokit/rest";
export type UpdateClaudeCommentParams = {
owner: string;
repo: string;
commentId: number;
body: string;
isPullRequestReviewComment: boolean;
};
export type UpdateClaudeCommentResult = {
id: number;
html_url: string;
updated_at: string;
};
/**
* Updates a Claude comment on GitHub (either an issue/PR comment or a PR review comment)
*
* @param octokit - Authenticated Octokit instance
* @param params - Parameters for updating the comment
* @returns The updated comment details
* @throws Error if the update fails
*/
export async function updateClaudeComment(
octokit: Octokit,
params: UpdateClaudeCommentParams,
): Promise<UpdateClaudeCommentResult> {
const { owner, repo, commentId, body, isPullRequestReviewComment } = params;
let response;
try {
if (isPullRequestReviewComment) {
// Try PR review comment API first
response = await octokit.rest.pulls.updateReviewComment({
owner,
repo,
comment_id: commentId,
body,
});
} else {
// Use issue comment API (works for both issues and PR general comments)
response = await octokit.rest.issues.updateComment({
owner,
repo,
comment_id: commentId,
body,
});
}
} catch (error: any) {
// If PR review comment update fails with 404, fall back to issue comment API
if (isPullRequestReviewComment && error.status === 404) {
response = await octokit.rest.issues.updateComment({
owner,
repo,
comment_id: commentId,
body,
});
} else {
throw error;
}
}
return {
id: response.data.id,
html_url: response.data.html_url,
updated_at: response.data.updated_at,
};
}

View file

@ -0,0 +1,58 @@
#!/usr/bin/env bun
/**
* Update the initial tracking comment with branch link
* This happens after the branch is created for issues
*/
import {
createJobRunLink,
createBranchLink,
createCommentBody,
} from "./common";
import { type GitHubClient } from "../../api/client";
import {
isPullRequestReviewCommentEvent,
type ParsedGitHubContext,
} from "../../context";
export async function updateTrackingComment(
client: GitHubClient,
context: ParsedGitHubContext,
commentId: number,
branch?: string,
) {
const { owner, repo } = context.repository;
const jobRunLink = createJobRunLink(owner, repo, context.runId);
// Add branch link for issues (not PRs)
let branchLink = "";
if (branch && !context.isPR) {
branchLink = createBranchLink(owner, repo, branch);
}
const updatedBody = createCommentBody(jobRunLink, branchLink);
// Update the existing comment with the branch link
try {
if (isPullRequestReviewCommentEvent(context)) {
// For PR review comments (inline comments), use the pulls API
await client.api.customRequest(
"PATCH",
`/api/v1/repos/${owner}/${repo}/pulls/comments/${commentId}`,
{
body: updatedBody,
},
);
console.log(`✅ Updated PR review comment ${commentId} with branch link`);
} else {
// For all other comments, use the issues API
await client.api.updateIssueComment(owner, repo, commentId, updatedBody);
console.log(`✅ Updated issue comment ${commentId} with branch link`);
}
} catch (error) {
console.error("Error updating comment with branch link:", error);
throw error;
}
}

View file

@ -0,0 +1,62 @@
#!/usr/bin/env bun
/**
* Configure git authentication for non-signing mode
* Sets up git user and authentication to work with GitHub App tokens
*/
import { $ } from "bun";
import type { ParsedGitHubContext } from "../context";
import { GITEA_SERVER_URL } from "../api/config";
type GitUser = {
login: string;
id: number;
};
export async function configureGitAuth(
githubToken: string,
context: ParsedGitHubContext,
user: GitUser | null,
) {
console.log("Configuring git authentication for non-signing mode");
// Determine the noreply email domain based on GITHUB_SERVER_URL
const serverUrl = new URL(GITEA_SERVER_URL);
const noreplyDomain =
serverUrl.hostname === "github.com"
? "users.noreply.github.com"
: `users.noreply.${serverUrl.hostname}`;
// Configure git user based on the comment creator
console.log("Configuring git user...");
if (user) {
const botName = user.login;
const botId = user.id;
console.log(`Setting git user as ${botName}...`);
await $`git config user.name "${botName}"`;
await $`git config user.email "${botId}+${botName}@${noreplyDomain}"`;
console.log(`✓ Set git user as ${botName}`);
} else {
console.log("No user data in comment, using default bot user");
await $`git config user.name "github-actions[bot]"`;
await $`git config user.email "41898282+github-actions[bot]@${noreplyDomain}"`;
}
// Remove the authorization header that actions/checkout sets
console.log("Removing existing git authentication headers...");
try {
await $`git config --unset-all http.${GITEA_SERVER_URL}/.extraheader`;
console.log("✓ Removed existing authentication headers");
} catch (e) {
console.log("No existing authentication headers to remove");
}
// Update the remote URL to include the token for authentication
console.log("Updating remote URL with authentication...");
const remoteUrl = `https://x-access-token:${githubToken}@${serverUrl.host}/${context.repository.owner}/${context.repository.repo}.git`;
await $`git remote set-url origin ${remoteUrl}`;
console.log("✓ Updated remote URL with authentication token");
console.log("Git authentication configured successfully");
}

34
src/github/token.ts Normal file
View file

@ -0,0 +1,34 @@
#!/usr/bin/env bun
import * as core from "@actions/core";
export async function setupGitHubToken(): Promise<string> {
try {
// Check if GitHub token was provided as override
const providedToken = process.env.OVERRIDE_GITHUB_TOKEN;
if (providedToken) {
console.log("Using provided GITHUB_TOKEN for authentication");
core.setOutput("GITHUB_TOKEN", providedToken);
return providedToken;
}
// Use the standard GITHUB_TOKEN from the workflow environment
const workflowToken = process.env.GITHUB_TOKEN;
if (workflowToken) {
console.log("Using workflow GITHUB_TOKEN for authentication");
core.setOutput("GITHUB_TOKEN", workflowToken);
return workflowToken;
}
throw new Error(
"No GitHub token available. Please provide a gitea_token input or ensure GITHUB_TOKEN is available in the workflow environment.",
);
} catch (error) {
core.setFailed(
`Failed to setup GitHub token: ${error}.\n\nPlease provide a \`gitea_token\` in the \`with\` section of the action in your workflow yml file, or ensure the workflow has access to the default GITHUB_TOKEN.`,
);
process.exit(1);
}
}

97
src/github/types.ts Normal file
View file

@ -0,0 +1,97 @@
// Types for GitHub GraphQL query responses
export type GitHubAuthor = {
login: string;
name?: string;
};
export type GitHubComment = {
id: string;
databaseId: string;
body: string;
author: GitHubAuthor;
createdAt: string;
};
export type GitHubReviewComment = GitHubComment & {
path: string;
line: number | null;
};
export type GitHubCommit = {
oid: string;
message: string;
author: {
name: string;
email: string;
};
};
export type GitHubFile = {
path: string;
additions: number;
deletions: number;
changeType: string;
};
export type GitHubReview = {
id: string;
databaseId: string;
author: GitHubAuthor;
body: string;
state: string;
submittedAt: string;
comments: {
nodes: GitHubReviewComment[];
};
};
export type GitHubPullRequest = {
title: string;
body: string;
author: GitHubAuthor;
baseRefName: string;
headRefName: string;
headRefOid: string;
createdAt: string;
additions: number;
deletions: number;
state: string;
commits: {
totalCount: number;
nodes: Array<{
commit: GitHubCommit;
}>;
};
files: {
nodes: GitHubFile[];
};
comments: {
nodes: GitHubComment[];
};
reviews: {
nodes: GitHubReview[];
};
};
export type GitHubIssue = {
title: string;
body: string;
author: GitHubAuthor;
createdAt: string;
state: string;
comments: {
nodes: GitHubComment[];
};
};
export type PullRequestQueryResponse = {
repository: {
pullRequest: GitHubPullRequest;
};
};
export type IssueQueryResponse = {
repository: {
issue: GitHubIssue;
};
};

View file

@ -0,0 +1,53 @@
import type { GitHubClient } from "../api/client";
type IssueComment = {
type: "issue_comment";
id: string;
body: string;
};
type ReviewComment = {
type: "review_comment";
id: string;
body: string;
};
type ReviewBody = {
type: "review_body";
id: string;
pullNumber: string;
body: string;
};
type IssueBody = {
type: "issue_body";
issueNumber: string;
body: string;
};
type PullRequestBody = {
type: "pr_body";
pullNumber: string;
body: string;
};
export type CommentWithImages =
| IssueComment
| ReviewComment
| ReviewBody
| IssueBody
| PullRequestBody;
export async function downloadCommentImages(
_client: GitHubClient,
_owner: string,
_repo: string,
_comments: CommentWithImages[],
): Promise<Map<string, string>> {
// Temporarily simplified - return empty map to avoid Octokit dependencies
// TODO: Implement image downloading with direct Gitea API calls if needed
console.log(
"Image downloading temporarily disabled during Octokit migration",
);
return new Map<string, string>();
}

View file

@ -0,0 +1,96 @@
#!/usr/bin/env bun
import { $ } from "bun";
/**
* Check if a branch exists locally using git commands
*/
export async function branchExists(branchName: string): Promise<boolean> {
try {
await $`git show-ref --verify --quiet refs/heads/${branchName}`;
return true;
} catch {
return false;
}
}
/**
* Check if a remote branch exists using git commands
*/
export async function remoteBranchExists(branchName: string): Promise<boolean> {
try {
await $`git show-ref --verify --quiet refs/remotes/origin/${branchName}`;
return true;
} catch {
return false;
}
}
/**
* Get the SHA of a branch using git commands
*/
export async function getBranchSha(branchName: string): Promise<string | null> {
try {
// Try local branch first
if (await branchExists(branchName)) {
const result = await $`git rev-parse refs/heads/${branchName}`;
return result.text().trim();
}
// Try remote branch if local doesn't exist
if (await remoteBranchExists(branchName)) {
const result = await $`git rev-parse refs/remotes/origin/${branchName}`;
return result.text().trim();
}
return null;
} catch (error) {
console.error(`Error getting SHA for branch ${branchName}:`, error);
return null;
}
}
/**
* Check if a branch has commits different from base branch
*/
export async function branchHasChanges(
branchName: string,
baseBranch: string,
): Promise<{
hasChanges: boolean;
branchSha: string | null;
baseSha: string | null;
}> {
try {
const branchSha = await getBranchSha(branchName);
const baseSha = await getBranchSha(baseBranch);
if (!branchSha || !baseSha) {
return { hasChanges: false, branchSha, baseSha };
}
const hasChanges = branchSha !== baseSha;
return { hasChanges, branchSha, baseSha };
} catch (error) {
console.error(
`Error comparing branches ${branchName} and ${baseBranch}:`,
error,
);
return { hasChanges: false, branchSha: null, baseSha: null };
}
}
/**
* Fetch latest changes from remote to ensure we have up-to-date branch info
*/
export async function fetchBranch(branchName: string): Promise<boolean> {
try {
await $`git fetch origin --depth=1 ${branchName}`;
return true;
} catch (error) {
console.log(
`Could not fetch branch ${branchName} from remote (may not exist yet)`,
);
return false;
}
}

View file

@ -0,0 +1,65 @@
export function stripInvisibleCharacters(content: string): string {
content = content.replace(/[\u200B\u200C\u200D\uFEFF]/g, "");
content = content.replace(
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g,
"",
);
content = content.replace(/\u00AD/g, "");
content = content.replace(/[\u202A-\u202E\u2066-\u2069]/g, "");
return content;
}
export function stripMarkdownImageAltText(content: string): string {
return content.replace(/!\[[^\]]*\]\(/g, "![](");
}
export function stripMarkdownLinkTitles(content: string): string {
content = content.replace(/(\[[^\]]*\]\([^)]+)\s+"[^"]*"/g, "$1");
content = content.replace(/(\[[^\]]*\]\([^)]+)\s+'[^']*'/g, "$1");
return content;
}
export function stripHiddenAttributes(content: string): string {
content = content.replace(/\salt\s*=\s*["'][^"']*["']/gi, "");
content = content.replace(/\salt\s*=\s*[^\s>]+/gi, "");
content = content.replace(/\stitle\s*=\s*["'][^"']*["']/gi, "");
content = content.replace(/\stitle\s*=\s*[^\s>]+/gi, "");
content = content.replace(/\saria-label\s*=\s*["'][^"']*["']/gi, "");
content = content.replace(/\saria-label\s*=\s*[^\s>]+/gi, "");
content = content.replace(/\sdata-[a-zA-Z0-9-]+\s*=\s*["'][^"']*["']/gi, "");
content = content.replace(/\sdata-[a-zA-Z0-9-]+\s*=\s*[^\s>]+/gi, "");
content = content.replace(/\splaceholder\s*=\s*["'][^"']*["']/gi, "");
content = content.replace(/\splaceholder\s*=\s*[^\s>]+/gi, "");
return content;
}
export function normalizeHtmlEntities(content: string): string {
content = content.replace(/&#(\d+);/g, (_, dec) => {
const num = parseInt(dec, 10);
if (num >= 32 && num <= 126) {
return String.fromCharCode(num);
}
return "";
});
content = content.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => {
const num = parseInt(hex, 16);
if (num >= 32 && num <= 126) {
return String.fromCharCode(num);
}
return "";
});
return content;
}
export function sanitizeContent(content: string): string {
content = stripHtmlComments(content);
content = stripInvisibleCharacters(content);
content = stripMarkdownImageAltText(content);
content = stripMarkdownLinkTitles(content);
content = stripHiddenAttributes(content);
content = normalizeHtmlEntities(content);
return content;
}
export const stripHtmlComments = (content: string) =>
content.replace(/<!--[\s\S]*?-->/g, "");

View file

@ -0,0 +1,57 @@
#!/usr/bin/env bun
/**
* Check if the action trigger is from a human actor
* Prevents automated tools or bots from triggering Claude
*/
import type { GiteaApiClient } from "../api/gitea-client";
import type { ParsedGitHubContext } from "../context";
export async function checkHumanActor(
api: GiteaApiClient,
githubContext: ParsedGitHubContext,
) {
// Check if we're in a Gitea environment
const isGitea =
process.env.GITEA_API_URL &&
!process.env.GITEA_API_URL.includes("api.github.com");
if (isGitea) {
console.log(
`Detected Gitea environment, skipping actor type validation for: ${githubContext.actor}`,
);
return;
}
try {
// Fetch user information from GitHub API
const response = await api.customRequest(
"GET",
`/api/v1/users/${githubContext.actor}`,
);
const userData = response.data;
const actorType = userData.type;
console.log(`Actor type: ${actorType}`);
if (actorType !== "User") {
throw new Error(
`Workflow initiated by non-human actor: ${githubContext.actor} (type: ${actorType}).`,
);
}
console.log(`Verified human actor: ${githubContext.actor}`);
} catch (error) {
console.warn(
`Failed to check actor type for ${githubContext.actor}:`,
error,
);
// For compatibility, assume human actor if API call fails
console.log(
`Assuming human actor due to API failure: ${githubContext.actor}`,
);
}
}

View file

@ -0,0 +1,84 @@
import * as core from "@actions/core";
import type { ParsedGitHubContext } from "../context";
import type { GiteaApiClient } from "../api/gitea-client";
/**
* Check if the actor has write permissions to the repository
* @param api - The Gitea API client
* @param context - The GitHub context
* @returns true if the actor has write permissions, false otherwise
*/
export async function checkWritePermissions(
api: GiteaApiClient,
context: ParsedGitHubContext,
): Promise<boolean> {
const { repository, actor } = context;
core.info(
`Environment check - GITEA_API_URL: ${process.env.GITEA_API_URL || "undefined"}`,
);
core.info(`API client base URL: ${api.getBaseUrl?.() || "undefined"}`);
// For Gitea compatibility, check if we're in a non-GitHub environment
const giteaApiUrl = process.env.GITEA_API_URL?.trim();
const isGitea =
giteaApiUrl &&
giteaApiUrl !== "" &&
!giteaApiUrl.includes("api.github.com") &&
!giteaApiUrl.includes("github.com");
if (isGitea) {
core.info(
`Detected Gitea environment (${giteaApiUrl}), assuming actor has permissions`,
);
return true;
}
// Also check if the API client base URL suggests we're using Gitea
const apiUrl = api.getBaseUrl?.() || "";
if (
apiUrl &&
!apiUrl.includes("api.github.com") &&
!apiUrl.includes("github.com")
) {
core.info(
`Detected non-GitHub API URL (${apiUrl}), assuming actor has permissions`,
);
return true;
}
// If we're still here, we might be using GitHub's API, so attempt the permissions check
core.info(
`Proceeding with GitHub-style permission check for actor: ${actor}`,
);
// However, if the API client is clearly pointing to a non-GitHub URL, skip the check
if (apiUrl && apiUrl !== "https://api.github.com") {
core.info(
`API URL ${apiUrl} doesn't look like GitHub, assuming permissions and skipping check`,
);
return true;
}
try {
// Check permissions directly using the permission endpoint
const response = await api.customRequest(
"GET",
`/api/v1/repos/${repository.owner}/${repository.repo}/collaborators/${actor}/permission`,
);
const permissionLevel = response.data.permission;
core.info(`Permission level retrieved: ${permissionLevel}`);
if (permissionLevel === "admin" || permissionLevel === "write") {
core.info(`Actor has write access: ${permissionLevel}`);
return true;
} else {
core.warning(`Actor has insufficient permissions: ${permissionLevel}`);
return false;
}
} catch (error) {
core.error(`Failed to check permissions: ${error}`);
throw new Error(`Failed to check permissions for ${actor}: ${error}`);
}
}

View file

@ -0,0 +1,187 @@
#!/usr/bin/env bun
import * as core from "@actions/core";
import {
isIssuesEvent,
isIssueCommentEvent,
isPullRequestEvent,
isPullRequestReviewEvent,
isPullRequestReviewCommentEvent,
} from "../context";
import type { IssuesLabeledEvent } from "@octokit/webhooks-types";
import type { ParsedGitHubContext } from "../context";
export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
const {
inputs: { assigneeTrigger, triggerPhrase, directPrompt },
} = context;
console.log(
`Checking trigger: event=${context.eventName}, action=${context.eventAction}, phrase='${triggerPhrase}', assignee='${assigneeTrigger}', direct='${directPrompt}'`,
);
// If direct prompt is provided, always trigger
if (directPrompt) {
console.log(`Direct prompt provided, triggering action`);
return true;
}
// Check for assignee trigger
if (isIssuesEvent(context) && context.eventAction === "assigned") {
// Remove @ symbol from assignee_trigger if present
let triggerUser = assigneeTrigger?.replace(/^@/, "") || "";
const assigneeUsername = context.payload.issue.assignee?.login || "";
console.log(
`Checking assignee trigger: user='${triggerUser}', assignee='${assigneeUsername}'`,
);
if (triggerUser && assigneeUsername === triggerUser) {
console.log(`Issue assigned to trigger user '${triggerUser}'`);
return true;
}
}
// Check for issue label trigger
if (isIssuesEvent(context) && context.eventAction === "labeled") {
const triggerLabel = context.inputs.labelTrigger?.trim();
const appliedLabel = (
context.payload as IssuesLabeledEvent
).label?.name?.trim();
console.log(
`Checking label trigger: expected='${triggerLabel}', applied='${appliedLabel}'`,
);
if (
triggerLabel &&
appliedLabel &&
triggerLabel.localeCompare(appliedLabel, undefined, {
sensitivity: "accent",
}) === 0
) {
console.log(`Issue labeled with trigger label '${triggerLabel}'`);
return true;
}
}
// Check for issue body and title trigger on issue creation
if (isIssuesEvent(context) && context.eventAction === "opened") {
const issueBody = context.payload.issue.body || "";
const issueTitle = context.payload.issue.title || "";
// Check for exact match with word boundaries or punctuation
const regex = new RegExp(
`(^|\\s)${escapeRegExp(triggerPhrase)}([\\s.,!?;:]|$)`,
);
// Check in body
if (regex.test(issueBody)) {
console.log(
`Issue body contains exact trigger phrase '${triggerPhrase}'`,
);
return true;
}
// Check in title
if (regex.test(issueTitle)) {
console.log(
`Issue title contains exact trigger phrase '${triggerPhrase}'`,
);
return true;
}
}
// Check for pull request body and title trigger
if (isPullRequestEvent(context)) {
const prBody = context.payload.pull_request.body || "";
const prTitle = context.payload.pull_request.title || "";
// Check for exact match with word boundaries or punctuation
const regex = new RegExp(
`(^|\\s)${escapeRegExp(triggerPhrase)}([\\s.,!?;:]|$)`,
);
// Check in body
if (regex.test(prBody)) {
console.log(
`Pull request body contains exact trigger phrase '${triggerPhrase}'`,
);
return true;
}
// Check in title
if (regex.test(prTitle)) {
console.log(
`Pull request title contains exact trigger phrase '${triggerPhrase}'`,
);
return true;
}
// Check if trigger user is in requested reviewers (treat same as mention in text)
const triggerUser = triggerPhrase.replace(/^@/, "");
const requestedReviewers =
context.payload.pull_request.requested_reviewers || [];
const isReviewerRequested = requestedReviewers.some(
(reviewer) => "login" in reviewer && reviewer.login === triggerUser,
);
if (isReviewerRequested) {
console.log(
`Pull request has '${triggerUser}' as requested reviewer (treating as trigger)`,
);
return true;
}
}
// Check for pull request review body trigger
if (
isPullRequestReviewEvent(context) &&
(context.eventAction === "submitted" ||
context.eventAction === "edited" ||
context.eventAction === "reviewed")
) {
const reviewBody =
context.payload.review?.body ?? context.payload.review?.content ?? "";
// Check for exact match with word boundaries or punctuation
const regex = new RegExp(
`(^|\\s)${escapeRegExp(triggerPhrase)}([\\s.,!?;:]|$)`,
);
if (regex.test(reviewBody)) {
console.log(
`Pull request review contains exact trigger phrase '${triggerPhrase}'`,
);
return true;
}
}
// Check for comment trigger
if (
isIssueCommentEvent(context) ||
isPullRequestReviewCommentEvent(context)
) {
const commentBody = isIssueCommentEvent(context)
? context.payload.comment.body
: (context.payload.comment?.body ?? context.payload.review?.content);
// Check for exact match with word boundaries or punctuation
const regex = new RegExp(
`(^|\\s)${escapeRegExp(triggerPhrase)}([\\s.,!?;:]|$)`,
);
if (regex.test(commentBody)) {
console.log(`Comment contains exact trigger phrase '${triggerPhrase}'`);
return true;
}
}
console.log(`No trigger was met for ${triggerPhrase}`);
return false;
}
export function escapeRegExp(string: string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export async function checkTriggerAction(context: ParsedGitHubContext) {
const containsTrigger = checkContainsTrigger(context);
core.setOutput("contains_trigger", containsTrigger.toString());
return containsTrigger;
}