Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
4a7768d6d0 chore(deps): bump actions/setup-node from 4 to 6
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-20 13:23:09 +00:00
40 changed files with 3752 additions and 2417 deletions

View File

@@ -11,27 +11,26 @@ TRUST_PROXY=false
# SECRETS CONFIGURATION
# ============================
# The application supports two methods for providing secrets:
#
#
# 1. Environment Variables (shown below) - Convenient for development
# 2. Secret Files - More secure for production
#
# If both are provided, SECRET FILES TAKE PRIORITY over environment variables.
#
# For file-based secrets, the app looks for files at:
# - /run/secrets/gitea_token (or path in GITEA_TOKEN_FILE)
# - /run/secrets/github_token (or path in GITHUB_TOKEN_FILE)
# - /run/secrets/anthropic_api_key (or path in ANTHROPIC_API_KEY_FILE)
# - /run/secrets/gitea_webhook_secret (or path in GITEA_WEBHOOK_SECRET_FILE)
# - /run/secrets/webhook_secret (or path in GITHUB_WEBHOOK_SECRET_FILE)
#
# To use file-based secrets in development:
# 1. Create a secrets directory: mkdir secrets
# 2. Add secret files: echo "your-secret" > secrets/gitea_token.txt
# 3. Mount in docker-compose or use GITEA_TOKEN_FILE=/path/to/secret
# 2. Add secret files: echo "your-secret" > secrets/github_token.txt
# 3. Mount in docker-compose or use GITHUB_TOKEN_FILE=/path/to/secret
# ============================
# Gitea Webhook Settings
GITEA_API_URL=https://your-gitea-instance.com/api/v1
GITEA_WEBHOOK_SECRET=your_webhook_secret_here
GITEA_TOKEN=your_gitea_token_here
# GitHub Webhook Settings
GITHUB_WEBHOOK_SECRET=your_webhook_secret_here
GITHUB_TOKEN=ghp_your_github_token_here
# Bot Configuration (REQUIRED)
BOT_USERNAME=@ClaudeBot
@@ -41,9 +40,9 @@ BOT_EMAIL=claude@example.com
AUTHORIZED_USERS=admin,username2,username3
DEFAULT_AUTHORIZED_USER=admin
# Default Gitea Configuration for CLI
DEFAULT_GITEA_OWNER=your-org
DEFAULT_GITEA_USER=your-username
# Default GitHub Configuration for CLI
DEFAULT_GITHUB_OWNER=your-org
DEFAULT_GITHUB_USER=your-username
DEFAULT_BRANCH=main
# Claude API Settings
@@ -103,13 +102,13 @@ TEST_REPO_FULL_NAME=owner/repo
# DISABLE_LOG_REDACTION=false # WARNING: Only enable for debugging, exposes sensitive data in logs
# File-based Secrets (optional, takes priority over environment variables)
# GITEA_TOKEN_FILE=/run/secrets/gitea_token
# GITHUB_TOKEN_FILE=/run/secrets/github_token
# ANTHROPIC_API_KEY_FILE=/run/secrets/anthropic_api_key
# GITEA_WEBHOOK_SECRET_FILE=/run/secrets/gitea_webhook_secret
# GITHUB_WEBHOOK_SECRET_FILE=/run/secrets/webhook_secret
# Authentication Methods (optional)
# CLAUDE_AUTH_HOST_DIR=/path/to/claude/auth # For setup container authentication
# CLI Configuration (optional)
# API_URL=http://localhost:3003 # Default API URL for CLI tool
# WEBHOOK_URL=http://localhost:3002/api/webhooks/gitea # Webhook endpoint URL
# WEBHOOK_URL=http://localhost:3002/api/webhooks/github # Webhook endpoint URL

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm

View File

@@ -27,7 +27,7 @@ jobs:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm

View File

@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm

View File

@@ -22,7 +22,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'npm'

119
CLAUDE.md
View File

@@ -2,14 +2,14 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Claude Gitea Webhook
## Claude GitHub Webhook
This repository contains a webhook service that integrates Claude with Gitea, allowing Claude to respond to mentions in Gitea comments, help with repository tasks, and automatically respond to CI failures. When someone mentions the configured bot username (configured via environment variables) in a Gitea issue or PR comment, the system processes the command with Claude Code and returns a helpful response.
This repository contains a webhook service that integrates Claude with GitHub, allowing Claude to respond to mentions in GitHub comments and help with repository tasks. When someone mentions the configured bot username (configured via environment variables) in a GitHub issue or PR comment, the system processes the command with Claude Code and returns a helpful response.
## Documentation Structure
- `/docs/complete-workflow.md` - Comprehensive workflow documentation
- `/docs/gitea-workflow.md` - Gitea-specific integration details
- `/docs/github-workflow.md` - GitHub-specific integration details
- `/docs/container-setup.md` - Docker container configuration
- `/docs/container-limitations.md` - Container execution constraints
- `/docs/aws-authentication-best-practices.md` - AWS credential management
@@ -80,13 +80,14 @@ The project uses Husky for Git pre-commit hooks to ensure code quality:
- **Manual run**: Execute `.husky/pre-commit` to test locally
### End-to-End Testing
Use a test repository for testing auto-tagging and webhook functionality:
- Test auto-tagging: `./cli/webhook-cli.js --repo "owner/test-repo" --command "Auto-tag this issue" --issue 1 --url "http://localhost:8082"`
- Test with specific issue content: Create a new issue in a test repository to trigger auto-tagging webhook
Use the demo repository for testing auto-tagging and webhook functionality:
- Demo repository: `https://github.com/claude-did-this/demo-repository`
- Test auto-tagging: `./cli/webhook-cli.js --repo "claude-did-this/demo-repository" --command "Auto-tag this issue" --issue 1 --url "http://localhost:8082"`
- Test with specific issue content: Create a new issue in the demo repository to trigger auto-tagging webhook
- Verify labels are applied based on issue content analysis
### Label Management
- Setup repository labels: `GITEA_TOKEN=your_token node scripts/utils/setup-repository-labels.js owner/repo`
- Setup repository labels: `GITHUB_TOKEN=your_token node scripts/utils/setup-repository-labels.js owner/repo`
### CLI Commands
- Basic usage: `./cli/claude-webhook myrepo "Your command for Claude"`
@@ -130,53 +131,45 @@ cp -r ${CLAUDE_HUB_DIR:-~/.claude-hub}/* ~/.claude/
The system automatically analyzes new issues and applies appropriate labels using a secure, minimal-permission approach:
**Security Features:**
- **Minimal Tool Access**: Uses only `Read` and `Gitea` API tools (no file editing or bash execution)
- **Minimal Tool Access**: Uses only `Read` and `GitHub` tools (no file editing or bash execution)
- **Dedicated Container**: Runs in specialized container with restricted entrypoint script
- **API-Based**: Uses Gitea REST API directly for reliable label management
- **CLI-Based**: Uses `gh` CLI commands directly instead of JSON parsing for better reliability
**Label Categories:**
- **Priority**: critical, high, medium, low
- **Type**: bug, feature, enhancement, documentation, question, security
- **Type**: bug, feature, enhancement, documentation, question, security
- **Complexity**: trivial, simple, moderate, complex
- **Component**: api, frontend, backend, database, auth, webhook, docker
**Process Flow:**
1. New issue triggers `issues.opened` webhook from Gitea
1. New issue triggers `issues.opened` webhook
2. Dedicated Claude container starts with `claudecode-tagging-entrypoint.sh`
3. Claude analyzes issue content using minimal tools
4. Labels applied via Gitea REST API
4. Labels applied directly via `gh issue edit --add-label` commands
5. No comments posted (silent operation)
6. Fallback to keyword-based labeling if API approach fails
6. Fallback to keyword-based labeling if CLI approach fails
### Automated PR Review
The system automatically triggers comprehensive PR reviews when all checks pass:
- **Trigger**: `pull_request` webhook events
- **Scope**: Reviews all PRs as requested
- **Trigger**: `check_suite` webhook event with `conclusion: 'success'`
- **Scope**: Reviews all PRs associated with the successful check suite
- **Process**: Claude performs security, logic, performance, and code quality analysis
- **Output**: Detailed review comments, line-specific feedback, and approval/change requests
- **Integration**: Uses Gitea REST API for seamless review workflow
### CI Failure Response
The system can automatically respond to CI/CD workflow failures:
- **Trigger**: `workflow_run` or `workflow_job` webhook events with `conclusion: 'failure'`
- **Process**: Claude analyzes the failure logs and attempts to diagnose the issue
- **Output**: Creates a fix PR with proposed changes or comments with analysis
- **Integration**: Uses Gitea Actions API to fetch workflow logs
- **Integration**: Uses GitHub CLI (`gh`) commands for seamless review workflow
## Architecture Overview
### Core Components
1. **Express Server** (`src/index.ts`): Main application entry point that sets up middleware, routes, and error handling
2. **Routes**:
- Webhook Router: `/api/webhooks/:provider` - Processes webhook events from configured providers
- GitHub Webhook: `/api/webhooks/github` - Processes GitHub webhook events
- Claude API: `/api/claude` - Direct API access to Claude
- Health Check: `/health` - Service status monitoring
3. **Providers**:
- `providers/gitea/` - Gitea webhook handling and API client
- `providers/claude/` - Claude orchestration and session management
3. **Controllers**:
- `githubController.ts` - Handles webhook verification and processing
4. **Services**:
- `claudeService.ts` - Interfaces with Claude Code CLI
- `providers/gitea/GiteaApiClient.ts` - Handles Gitea REST API interactions
- `githubService.ts` - Handles GitHub API interactions
5. **Utilities**:
- `logger.ts` - Logging functionality with redaction capability
- `awsCredentialProvider.ts` - Secure AWS credential management
@@ -186,9 +179,8 @@ The system can automatically respond to CI/CD workflow failures:
The system uses different execution modes based on operation type:
**Operation Types:**
- **Auto-tagging**: Minimal permissions (`Read` tool only, uses Gitea API)
- **Auto-tagging**: Minimal permissions (`Read`, `GitHub` tools only)
- **PR Review**: Standard permissions (full tool set)
- **CI Failure Fix**: Standard permissions (full tool set for code fixes)
- **Default**: Standard permissions (full tool set)
**Security Features:**
@@ -198,8 +190,8 @@ The system uses different execution modes based on operation type:
- **Container Isolation**: Docker containers with minimal required capabilities
**Container Entrypoints:**
- `claudecode-tagging-entrypoint.sh`: Minimal tools for auto-tagging (`--allowedTools Read`)
- `claudecode-entrypoint.sh`: Full tools for general operations (`--allowedTools Bash,Create,Edit,Read,Write`)
- `claudecode-tagging-entrypoint.sh`: Minimal tools for auto-tagging (`--allowedTools Read,GitHub`)
- `claudecode-entrypoint.sh`: Full tools for general operations (`--allowedTools Bash,Create,Edit,Read,Write,GitHub`)
**DevContainer Configuration:**
The repository includes a `.devcontainer` configuration for development:
@@ -210,13 +202,11 @@ The repository includes a `.devcontainer` configuration for development:
- Automatic firewall initialization via post-create command
### Workflow
1. Gitea comment with bot mention (configured via BOT_USERNAME) triggers a webhook event
2. Express server receives the webhook at `/api/webhooks/gitea`
3. GiteaWebhookProvider verifies the signature and parses the event
4. Appropriate handler processes the event (issue, PR, workflow failure, etc.)
5. Service extracts the command and processes it with Claude in a Docker container
6. Claude analyzes the repository and responds to the command
7. Response is posted back to Gitea via the REST API
1. GitHub comment with bot mention (configured via BOT_USERNAME) triggers a webhook event
2. Express server receives the webhook at `/api/webhooks/github`
3. Service extracts the command and processes it with Claude in a Docker container
4. Claude analyzes the repository and responds to the command
5. Response is returned via the webhook HTTP response
## AWS Authentication
The service supports multiple AWS authentication methods, with a focus on security:
@@ -228,7 +218,7 @@ The service supports multiple AWS authentication methods, with a focus on securi
The `awsCredentialProvider.ts` utility handles credential retrieval and rotation.
## Security Features
- Webhook signature verification using HMAC-SHA256 (`x-gitea-signature` header)
- Webhook signature verification using HMAC
- Credential scanning in pre-commit hooks
- Container isolation for Claude execution
- AWS profile-based authentication
@@ -239,55 +229,26 @@ The `awsCredentialProvider.ts` utility handles credential retrieval and rotation
## Configuration
- Environment variables are loaded from `.env` file
- AWS Bedrock credentials for Claude access
- Gitea tokens and webhook secrets
- GitHub tokens and webhook secrets
- Container execution settings
- Webhook URL and port configuration
### Required Environment Variables
- `BOT_USERNAME`: Username that the bot responds to (e.g., `@ClaudeBot`)
- `DEFAULT_AUTHORIZED_USER`: Default username authorized to use the bot (if AUTHORIZED_USERS is not set)
- `AUTHORIZED_USERS`: Comma-separated list of usernames authorized to use the bot
- `BOT_USERNAME`: GitHub username that the bot responds to (e.g., `@ClaudeBot`)
- `DEFAULT_AUTHORIZED_USER`: Default GitHub username authorized to use the bot (if AUTHORIZED_USERS is not set)
- `AUTHORIZED_USERS`: Comma-separated list of GitHub usernames authorized to use the bot
- `BOT_EMAIL`: Email address used for git commits made by the bot
- `GITEA_API_URL`: Gitea API base URL (e.g., `https://git.example.com/api/v1`)
- `GITEA_WEBHOOK_SECRET`: Secret for validating Gitea webhook payloads
- `GITEA_TOKEN`: Gitea personal access token for API access
- `GITHUB_WEBHOOK_SECRET`: Secret for validating GitHub webhook payloads
- `GITHUB_TOKEN`: GitHub token for API access
- `ANTHROPIC_API_KEY`: Anthropic API key for Claude access
### Optional Environment Variables
- `PR_REVIEW_WAIT_FOR_ALL_CHECKS`: Set to `"true"` to wait for all workflow runs to complete successfully before triggering PR review (default: `"true"`).
- `PR_REVIEW_TRIGGER_WORKFLOW`: Name of a specific Gitea Actions workflow that should trigger PR reviews (e.g., `"Pull Request CI"`). Only used if `PR_REVIEW_WAIT_FOR_ALL_CHECKS` is `"false"`.
- `PR_REVIEW_DEBOUNCE_MS`: Delay in milliseconds before checking workflow status (default: `"5000"`).
- `PR_REVIEW_MAX_WAIT_MS`: Maximum time to wait for in-progress workflows before considering them failed (default: `"1800000"` = 30 minutes).
- `PR_REVIEW_WAIT_FOR_ALL_CHECKS`: Set to `"true"` to wait for all meaningful check suites to complete successfully before triggering PR review (default: `"true"`). Uses smart logic to handle conditional jobs and skipped checks, preventing duplicate reviews from different check suites.
- `PR_REVIEW_TRIGGER_WORKFLOW`: Name of a specific GitHub Actions workflow that should trigger PR reviews (e.g., `"Pull Request CI"`). Only used if `PR_REVIEW_WAIT_FOR_ALL_CHECKS` is `"false"`.
- `PR_REVIEW_DEBOUNCE_MS`: Delay in milliseconds before checking all check suites status (default: `"5000"`). This accounts for GitHub's eventual consistency.
- `PR_REVIEW_MAX_WAIT_MS`: Maximum time to wait for stale in-progress check suites before considering them failed (default: `"1800000"` = 30 minutes).
- `PR_REVIEW_CONDITIONAL_TIMEOUT_MS`: Time to wait for conditional jobs that never start before skipping them (default: `"300000"` = 5 minutes).
### Gitea Webhook Setup
To configure Gitea to send webhooks to this service:
1. Go to your repository's **Settings****Webhooks****Add Webhook****Gitea**
2. Configure the webhook:
- **Target URL**: `https://your-claude-hub-domain/api/webhooks/gitea`
- **HTTP Method**: POST
- **Content Type**: application/json
- **Secret**: Match the `GITEA_WEBHOOK_SECRET` environment variable
3. Select events to trigger the webhook:
- **Issues**: For issue auto-tagging
- **Issue Comment**: For bot mentions in issue comments
- **Pull Request**: For PR events
- **Pull Request Comment**: For bot mentions in PR comments
- **Workflow Run**: For CI failure detection (Gitea Actions)
- **Workflow Job**: For job-level CI failure detection
4. Save the webhook and test with the "Test Delivery" button
### Gitea Personal Access Token
Create a personal access token with these scopes:
- `read:repository` - Read repository content
- `write:issue` - Create/edit issues and comments
- `write:repository` - Push commits, create branches/PRs
Generate at: `https://your-gitea-instance/user/settings/applications`
## TypeScript Infrastructure
The project is configured with TypeScript for enhanced type safety and developer experience:

View File

@@ -63,29 +63,28 @@ FROM node:24-slim AS production
# Set shell with pipefail option for better error handling
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# Install runtime dependencies
# Install runtime dependencies with pinned versions
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
curl \
python3 \
python3-pip \
python3-venv \
expect \
ca-certificates \
gnupg \
lsb-release \
git=1:2.39.5-0+deb12u2 \
curl=7.88.1-10+deb12u12 \
python3=3.11.2-1+b1 \
python3-pip=23.0.1+dfsg-1 \
python3-venv=3.11.2-1+b1 \
expect=5.45.4-2+b1 \
ca-certificates=20230311 \
gnupg=2.2.40-1.1 \
lsb-release=12.0-1 \
&& rm -rf /var/lib/apt/lists/*
# Install Docker CLI (not the daemon, just the client)
# Install Docker CLI (not the daemon, just the client) with consolidated RUN and pinned versions
RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null \
&& apt-get update \
&& apt-get install -y --no-install-recommends docker-ce-cli \
&& apt-get install -y --no-install-recommends docker-ce-cli=5:27.* \
&& rm -rf /var/lib/apt/lists/*
# Create docker group first, then create a non-root user for running the application
# Note: GID 281 matches Unraid's docker group for socket access
RUN groupadd -g 281 docker 2>/dev/null || true \
RUN groupadd -g 999 docker 2>/dev/null || true \
&& useradd -m -u 1001 -s /bin/bash claudeuser \
&& usermod -aG docker claudeuser 2>/dev/null || true

View File

@@ -53,22 +53,22 @@ else
echo "WARNING: No Claude authentication source found at /home/node/.claude." >&2
fi
# Configure Gitea authentication
if [ -n "${GITEA_TOKEN}" ] && [ -n "${GITEA_API_URL}" ]; then
GIT_HOST=$(echo "${GITEA_API_URL}" | sed -E 's|https?://([^/]+).*|\1|')
echo "Using Gitea token for ${GIT_HOST}" >&2
# Configure GitHub authentication
if [ -n "${GITHUB_TOKEN}" ]; then
export GH_TOKEN="${GITHUB_TOKEN}"
echo "${GITHUB_TOKEN}" | sudo -u node gh auth login --with-token
sudo -u node gh auth setup-git
else
echo "No Gitea token provided, skipping Git authentication" >&2
GIT_HOST=""
echo "No GitHub token provided, skipping GitHub authentication"
fi
# Clone the repository as node user
if [ -n "${GITEA_TOKEN}" ] && [ -n "${REPO_FULL_NAME}" ] && [ -n "${GIT_HOST}" ]; then
echo "Cloning repository ${REPO_FULL_NAME} from ${GIT_HOST}..." >&2
sudo -u node git clone "https://ClaudeBot:${GITEA_TOKEN}@${GIT_HOST}/${REPO_FULL_NAME}.git" /workspace/repo >&2
if [ -n "${GITHUB_TOKEN}" ] && [ -n "${REPO_FULL_NAME}" ]; then
echo "Cloning repository ${REPO_FULL_NAME}..." >&2
sudo -u node git clone "https://x-access-token:${GITHUB_TOKEN}@github.com/${REPO_FULL_NAME}.git" /workspace/repo >&2
cd /workspace/repo
else
echo "Skipping repository clone - missing token or repository name" >&2
echo "Skipping repository clone - missing GitHub token or repository name" >&2
cd /workspace
fi

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
/**
* Allowed webhook providers
*/
export const ALLOWED_WEBHOOK_PROVIDERS = ['claude', 'gitea'] as const;
export const ALLOWED_WEBHOOK_PROVIDERS = ['github', 'claude'] as const;
export type AllowedWebhookProvider = (typeof ALLOWED_WEBHOOK_PROVIDERS)[number];

View File

@@ -4,6 +4,7 @@ import bodyParser from 'body-parser';
import rateLimit from 'express-rate-limit';
import { createLogger } from './utils/logger';
import { StartupMetrics } from './utils/startup-metrics';
import githubRoutes from './routes/github';
import webhookRoutes from './routes/webhooks';
import type { WebhookRequest, HealthCheckResponse, ErrorResponse } from './types/express';
import { execSync } from 'child_process';
@@ -98,7 +99,8 @@ app.use(
startupMetrics.recordMilestone('middleware_configured', 'Express middleware configured');
// Routes
app.use('/api/webhooks', webhookRoutes); // Modular webhook endpoint
app.use('/api/webhooks/github', githubRoutes); // Legacy endpoint
app.use('/api/webhooks', webhookRoutes); // New modular webhook endpoint
startupMetrics.recordMilestone('routes_configured', 'API routes configured');

View File

@@ -81,7 +81,7 @@ export class SessionManager {
'-e',
`SESSION_TYPE=${session.type}`,
'-e',
`GITEA_TOKEN=${process.env.GITEA_TOKEN ?? ''}`,
`GITHUB_TOKEN=${process.env.GITHUB_TOKEN ?? ''}`,
'-e',
`REPO_FULL_NAME=${session.project.repository}`,
'-e',

View File

@@ -1,704 +0,0 @@
import axios, { AxiosInstance, AxiosError } from 'axios';
import { createLogger } from '../../utils/logger';
import secureCredentials from '../../utils/secureCredentials';
import type {
GiteaUser,
GiteaRepository,
GiteaIssue,
GiteaPullRequest,
GiteaComment,
GiteaLabel,
GiteaWorkflowRun,
GiteaWorkflowJob,
GiteaCombinedStatus,
GiteaCreateCommentRequest,
GiteaCreateCommentResponse,
GiteaCreatePullRequestRequest,
GiteaCreateBranchRequest,
GiteaGetWorkflowLogsRequest,
GiteaCreateCommitStatusRequest,
GiteaAddLabelsRequest,
GiteaCreateLabelRequest
} from './types';
const logger = createLogger('GiteaApiClient');
/**
* Gitea API Client
* Provides methods for interacting with Gitea REST API
*/
export class GiteaApiClient {
private client: AxiosInstance | null = null;
private baseUrl: string;
constructor() {
this.baseUrl = process.env.GITEA_API_URL ?? 'https://git.wylab.me/api/v1';
}
/**
* Get or create the axios client instance
*/
private getClient(): AxiosInstance | null {
if (!this.client) {
const token = secureCredentials.get('GITEA_TOKEN') ?? process.env.GITEA_TOKEN;
if (!token) {
logger.warn('No GITEA_TOKEN found, API calls will fail');
return null;
}
this.client = axios.create({
baseURL: this.baseUrl,
headers: {
'Authorization': `token ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
timeout: 30000
});
// Add response interceptor for logging
this.client.interceptors.response.use(
response => response,
(error: AxiosError) => {
logger.error({
status: error.response?.status,
url: error.config?.url,
data: error.response?.data
}, 'Gitea API request failed');
return Promise.reject(error);
}
);
}
return this.client;
}
/**
* Validate repository parameters to prevent SSRF
*/
private validateRepoParams(owner: string, repo: string): void {
const repoPattern = /^[a-zA-Z0-9._-]+$/;
if (!repoPattern.test(owner) || !repoPattern.test(repo)) {
throw new Error('Invalid repository owner or name - contains unsafe characters');
}
}
/**
* Post a comment to an issue or pull request
*/
async postComment({
owner,
repo,
issueNumber,
body
}: GiteaCreateCommentRequest): Promise<GiteaCreateCommentResponse> {
this.validateRepoParams(owner, repo);
logger.info({
repo: `${owner}/${repo}`,
issue: issueNumber,
bodyLength: body.length
}, 'Posting comment to Gitea');
const client = this.getClient();
if (process.env.NODE_ENV === 'test' || !client) {
logger.info({
repo: `${owner}/${repo}`,
issue: issueNumber,
bodyPreview: body.substring(0, 100) + (body.length > 100 ? '...' : '')
}, 'TEST MODE: Would post comment to Gitea');
return {
id: 12345,
body: body,
created_at: new Date().toISOString()
};
}
const { data } = await client.post<GiteaComment>(
`/repos/${owner}/${repo}/issues/${issueNumber}/comments`,
{ body }
);
logger.info({
repo: `${owner}/${repo}`,
issue: issueNumber,
commentId: data.id
}, 'Comment posted successfully');
return {
id: data.id,
body: data.body,
created_at: data.created_at
};
}
/**
* Get issue details
*/
async getIssue(owner: string, repo: string, issueNumber: number): Promise<GiteaIssue | null> {
this.validateRepoParams(owner, repo);
const client = this.getClient();
if (process.env.NODE_ENV === 'test' || !client) {
return null;
}
try {
const { data } = await client.get<GiteaIssue>(
`/repos/${owner}/${repo}/issues/${issueNumber}`
);
return data;
} catch (error) {
logger.error({ err: error, owner, repo, issueNumber }, 'Failed to get issue');
return null;
}
}
/**
* Get pull request details
*/
async getPullRequest(owner: string, repo: string, prNumber: number): Promise<GiteaPullRequest | null> {
this.validateRepoParams(owner, repo);
logger.info({
repo: `${owner}/${repo}`,
pr: prNumber
}, 'Fetching pull request details from Gitea');
const client = this.getClient();
if (process.env.NODE_ENV === 'test' || !client) {
logger.info('TEST MODE: Would fetch PR details from Gitea');
return {
id: 1,
number: prNumber,
title: 'Test PR',
body: 'Test body',
state: 'open',
user: { id: 1, login: 'test', full_name: 'Test', email: '', avatar_url: '', username: 'test' },
labels: [],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
merged_at: null,
merged: false,
draft: false,
html_url: '',
head: { ref: 'feature-branch', sha: 'abc123', repo: {} as GiteaRepository },
base: { ref: 'main', sha: 'def456', repo: {} as GiteaRepository }
};
}
try {
const { data } = await client.get<GiteaPullRequest>(
`/repos/${owner}/${repo}/pulls/${prNumber}`
);
logger.info({
repo: `${owner}/${repo}`,
pr: prNumber,
headRef: data.head.ref,
baseRef: data.base.ref
}, 'Pull request details fetched successfully');
return data;
} catch (error) {
logger.error({ err: error, owner, repo, prNumber }, 'Failed to get pull request');
return null;
}
}
/**
* Create a pull request
*/
async createPullRequest({
owner,
repo,
title,
body,
head,
base
}: GiteaCreatePullRequestRequest): Promise<GiteaPullRequest | null> {
this.validateRepoParams(owner, repo);
logger.info({
repo: `${owner}/${repo}`,
title,
head,
base
}, 'Creating pull request in Gitea');
const client = this.getClient();
if (process.env.NODE_ENV === 'test' || !client) {
logger.info('TEST MODE: Would create PR in Gitea');
return null;
}
try {
const { data } = await client.post<GiteaPullRequest>(
`/repos/${owner}/${repo}/pulls`,
{ title, body, head, base }
);
logger.info({
repo: `${owner}/${repo}`,
prNumber: data.number
}, 'Pull request created successfully');
return data;
} catch (error) {
logger.error({ err: error, owner, repo, title }, 'Failed to create pull request');
throw error;
}
}
/**
* Create a branch
*/
async createBranch({
owner,
repo,
branchName,
oldBranchName
}: GiteaCreateBranchRequest): Promise<boolean> {
this.validateRepoParams(owner, repo);
logger.info({
repo: `${owner}/${repo}`,
branchName,
oldBranchName
}, 'Creating branch in Gitea');
const client = this.getClient();
if (process.env.NODE_ENV === 'test' || !client) {
logger.info('TEST MODE: Would create branch in Gitea');
return true;
}
try {
await client.post(
`/repos/${owner}/${repo}/branches`,
{
new_branch_name: branchName,
old_branch_name: oldBranchName
}
);
logger.info({
repo: `${owner}/${repo}`,
branchName
}, 'Branch created successfully');
return true;
} catch (error) {
logger.error({ err: error, owner, repo, branchName }, 'Failed to create branch');
return false;
}
}
/**
* Get workflow runs for a repository
*/
async getWorkflowRuns(
owner: string,
repo: string,
options?: { status?: string; branch?: string; limit?: number }
): Promise<GiteaWorkflowRun[]> {
this.validateRepoParams(owner, repo);
const client = this.getClient();
if (process.env.NODE_ENV === 'test' || !client) {
return [];
}
try {
const params = new URLSearchParams();
if (options?.status) params.append('status', options.status);
if (options?.branch) params.append('branch', options.branch);
if (options?.limit) params.append('limit', options.limit.toString());
const { data } = await client.get<{ workflow_runs: GiteaWorkflowRun[] }>(
`/repos/${owner}/${repo}/actions/runs?${params.toString()}`
);
return data.workflow_runs ?? [];
} catch (error) {
logger.error({ err: error, owner, repo }, 'Failed to get workflow runs');
return [];
}
}
/**
* Get workflow run details
*/
async getWorkflowRun(owner: string, repo: string, runId: number): Promise<GiteaWorkflowRun | null> {
this.validateRepoParams(owner, repo);
const client = this.getClient();
if (process.env.NODE_ENV === 'test' || !client) {
return null;
}
try {
const { data } = await client.get<GiteaWorkflowRun>(
`/repos/${owner}/${repo}/actions/runs/${runId}`
);
return data;
} catch (error) {
logger.error({ err: error, owner, repo, runId }, 'Failed to get workflow run');
return null;
}
}
/**
* Get workflow run jobs
*/
async getWorkflowJobs(owner: string, repo: string, runId: number): Promise<GiteaWorkflowJob[]> {
this.validateRepoParams(owner, repo);
const client = this.getClient();
if (process.env.NODE_ENV === 'test' || !client) {
return [];
}
try {
const { data } = await client.get<{ jobs: GiteaWorkflowJob[] }>(
`/repos/${owner}/${repo}/actions/runs/${runId}/jobs`
);
return data.jobs ?? [];
} catch (error) {
logger.error({ err: error, owner, repo, runId }, 'Failed to get workflow jobs');
return [];
}
}
/**
* Get workflow run logs
*/
async getWorkflowLogs({
owner,
repo,
runId
}: GiteaGetWorkflowLogsRequest): Promise<string | null> {
this.validateRepoParams(owner, repo);
logger.info({
repo: `${owner}/${repo}`,
runId
}, 'Fetching workflow logs from Gitea');
const client = this.getClient();
if (process.env.NODE_ENV === 'test' || !client) {
return 'TEST MODE: Mock workflow logs';
}
try {
// Get jobs first to get individual job logs
const jobs = await this.getWorkflowJobs(owner, repo, runId);
const logs: string[] = [];
for (const job of jobs) {
try {
const { data } = await client.get<string>(
`/repos/${owner}/${repo}/actions/jobs/${job.id}/logs`,
{ responseType: 'text' }
);
logs.push(`=== Job: ${job.name} ===\n${data}`);
} catch (jobError) {
logs.push(`=== Job: ${job.name} ===\n[Failed to fetch logs]`);
}
}
return logs.join('\n\n');
} catch (error) {
logger.error({ err: error, owner, repo, runId }, 'Failed to get workflow logs');
return null;
}
}
/**
* Get combined status for a commit
*/
async getCombinedStatus(owner: string, repo: string, ref: string): Promise<GiteaCombinedStatus | null> {
this.validateRepoParams(owner, repo);
// Validate ref
const refPattern = /^[a-zA-Z0-9._/-]+$/;
if (!refPattern.test(ref)) {
throw new Error('Invalid ref - contains unsafe characters');
}
const client = this.getClient();
if (process.env.NODE_ENV === 'test' || !client) {
return {
state: 'success',
statuses: [],
total_count: 0,
sha: ref,
repository: {} as GiteaRepository
};
}
try {
const { data } = await client.get<GiteaCombinedStatus>(
`/repos/${owner}/${repo}/commits/${ref}/status`
);
return data;
} catch (error) {
logger.error({ err: error, owner, repo, ref }, 'Failed to get combined status');
return null;
}
}
/**
* Create a commit status
*/
async createCommitStatus({
owner,
repo,
sha,
state,
context,
description,
targetUrl
}: GiteaCreateCommitStatusRequest): Promise<boolean> {
this.validateRepoParams(owner, repo);
const client = this.getClient();
if (process.env.NODE_ENV === 'test' || !client) {
logger.info({ owner, repo, sha, state, context }, 'TEST MODE: Would create commit status');
return true;
}
try {
await client.post(
`/repos/${owner}/${repo}/statuses/${sha}`,
{
state,
context,
description,
target_url: targetUrl
}
);
logger.info({ owner, repo, sha, state, context }, 'Commit status created successfully');
return true;
} catch (error) {
logger.error({ err: error, owner, repo, sha }, 'Failed to create commit status');
return false;
}
}
/**
* Add labels to an issue
*/
async addLabelsToIssue({
owner,
repo,
issueNumber,
labels
}: GiteaAddLabelsRequest): Promise<GiteaLabel[]> {
this.validateRepoParams(owner, repo);
logger.info({
repo: `${owner}/${repo}`,
issue: issueNumber,
labelCount: labels.length
}, 'Adding labels to Gitea issue');
const client = this.getClient();
if (process.env.NODE_ENV === 'test' || !client) {
logger.info('TEST MODE: Would add labels to Gitea issue');
return [];
}
try {
const { data } = await client.post<GiteaLabel[]>(
`/repos/${owner}/${repo}/issues/${issueNumber}/labels`,
{ labels }
);
logger.info({
repo: `${owner}/${repo}`,
issue: issueNumber,
appliedLabels: data.map(l => l.name)
}, 'Labels added successfully');
return data;
} catch (error) {
logger.error({ err: error, owner, repo, issueNumber }, 'Failed to add labels');
throw error;
}
}
/**
* Create a repository label
*/
async createLabel({
owner,
repo,
name,
color,
description
}: GiteaCreateLabelRequest): Promise<GiteaLabel | null> {
this.validateRepoParams(owner, repo);
const client = this.getClient();
if (process.env.NODE_ENV === 'test' || !client) {
return { id: 1, name, color, description: description ?? '' };
}
try {
const { data } = await client.post<GiteaLabel>(
`/repos/${owner}/${repo}/labels`,
{ name, color, description }
);
logger.info({ owner, repo, labelName: name }, 'Label created successfully');
return data;
} catch (error) {
const axiosError = error as AxiosError;
// Label might already exist (422)
if (axiosError.response?.status === 422) {
logger.debug({ labelName: name }, 'Label already exists, skipping');
return null;
}
logger.error({ err: error, owner, repo, labelName: name }, 'Failed to create label');
throw error;
}
}
/**
* Get repository labels
*/
async getLabels(owner: string, repo: string): Promise<GiteaLabel[]> {
this.validateRepoParams(owner, repo);
const client = this.getClient();
if (process.env.NODE_ENV === 'test' || !client) {
return [];
}
try {
const { data } = await client.get<GiteaLabel[]>(
`/repos/${owner}/${repo}/labels`
);
return data;
} catch (error) {
logger.error({ err: error, owner, repo }, 'Failed to get labels');
return [];
}
}
/**
* List pull request reviews
*/
async listPullRequestReviews(owner: string, repo: string, prNumber: number): Promise<unknown[]> {
this.validateRepoParams(owner, repo);
const client = this.getClient();
if (process.env.NODE_ENV === 'test' || !client) {
return [];
}
try {
const { data } = await client.get(
`/repos/${owner}/${repo}/pulls/${prNumber}/reviews`
);
return data as unknown[];
} catch (error) {
logger.error({ err: error, owner, repo, prNumber }, 'Failed to list PR reviews');
return [];
}
}
/**
* Get current authenticated user
*/
async getCurrentUser(): Promise<GiteaUser | null> {
const client = this.getClient();
if (process.env.NODE_ENV === 'test' || !client) {
return { id: 1, login: 'test', full_name: 'Test', email: '', avatar_url: '', username: 'test' };
}
try {
const { data } = await client.get<GiteaUser>('/user');
return data;
} catch (error) {
logger.error({ err: error }, 'Failed to get current user');
return null;
}
}
/**
* Get file contents from repository
*/
async getFileContent(owner: string, repo: string, path: string, ref?: string): Promise<string | null> {
this.validateRepoParams(owner, repo);
const client = this.getClient();
if (process.env.NODE_ENV === 'test' || !client) {
return null;
}
try {
const params = ref ? `?ref=${ref}` : '';
const { data } = await client.get<{ content: string; encoding: string }>(
`/repos/${owner}/${repo}/contents/${path}${params}`
);
if (data.encoding === 'base64') {
return Buffer.from(data.content, 'base64').toString('utf-8');
}
return data.content;
} catch (error) {
logger.error({ err: error, owner, repo, path }, 'Failed to get file content');
return null;
}
}
/**
* Create or update file in repository
*/
async createOrUpdateFile(
owner: string,
repo: string,
path: string,
content: string,
message: string,
branch: string,
sha?: string
): Promise<boolean> {
this.validateRepoParams(owner, repo);
const client = this.getClient();
if (process.env.NODE_ENV === 'test' || !client) {
logger.info({ owner, repo, path }, 'TEST MODE: Would create/update file');
return true;
}
try {
const body: Record<string, string> = {
content: Buffer.from(content).toString('base64'),
message,
branch
};
if (sha) {
body.sha = sha;
}
await client.put(
`/repos/${owner}/${repo}/contents/${path}`,
body
);
logger.info({ owner, repo, path }, 'File created/updated successfully');
return true;
} catch (error) {
logger.error({ err: error, owner, repo, path }, 'Failed to create/update file');
return false;
}
}
}
// Export singleton instance
export const giteaApiClient = new GiteaApiClient();

View File

@@ -1,209 +0,0 @@
import crypto from 'crypto';
import { createLogger } from '../../utils/logger';
import type { WebhookRequest } from '../../types/express';
import type {
WebhookProvider,
BaseWebhookPayload,
RepositoryInfo,
UserInfo,
IssueInfo,
PullRequestInfo
} from '../../types/webhook';
import type {
GiteaRepository,
GiteaUser,
GiteaIssue,
GiteaPullRequest
} from './types';
const logger = createLogger('GiteaWebhookProvider');
/**
* Gitea-specific webhook payload
*/
export interface GiteaWebhookEvent extends BaseWebhookPayload {
giteaEvent: string;
giteaDelivery: string;
action?: string;
repository?: GiteaRepository;
sender?: GiteaUser;
}
/**
* Gitea webhook provider implementation
*/
export class GiteaWebhookProvider implements WebhookProvider<GiteaWebhookEvent> {
readonly name = 'gitea';
/**
* Verify Gitea webhook signature
* Gitea uses HMAC-SHA256 with the x-gitea-signature header
*/
verifySignature(req: WebhookRequest, secret: string): Promise<boolean> {
return Promise.resolve(this.verifySignatureSync(req, secret));
}
private verifySignatureSync(req: WebhookRequest, secret: string): boolean {
// Gitea uses x-gitea-signature header
const signature = req.headers['x-gitea-signature'] as string;
if (!signature) {
logger.warn('No signature found in Gitea webhook request');
return false;
}
try {
const payload = req.rawBody ?? JSON.stringify(req.body);
const hmac = crypto.createHmac('sha256', secret);
const calculatedSignature = hmac.update(payload).digest('hex');
// Gitea sends just the hex digest without prefix
const signatureToCompare = signature.startsWith('sha256=')
? signature.slice(7)
: signature;
// Use timing-safe comparison
if (
signatureToCompare.length === calculatedSignature.length &&
crypto.timingSafeEqual(
Buffer.from(signatureToCompare),
Buffer.from(calculatedSignature)
)
) {
logger.debug('Gitea webhook signature verified successfully');
return true;
}
logger.warn('Gitea webhook signature verification failed');
return false;
} catch (error) {
logger.error({ err: error }, 'Error verifying Gitea webhook signature');
return false;
}
}
/**
* Parse Gitea webhook payload
*/
parsePayload(req: WebhookRequest): Promise<GiteaWebhookEvent> {
return Promise.resolve(this.parsePayloadSync(req));
}
private parsePayloadSync(req: WebhookRequest): GiteaWebhookEvent {
const giteaEvent = req.headers['x-gitea-event'] as string;
const giteaDelivery = req.headers['x-gitea-delivery'] as string;
const payload = req.body;
return {
id: giteaDelivery || crypto.randomUUID(),
timestamp: new Date().toISOString(),
event: this.normalizeEventType(giteaEvent, payload.action),
source: 'gitea',
giteaEvent,
giteaDelivery,
action: payload.action,
repository: payload.repository as unknown as GiteaRepository | undefined,
sender: payload.sender as unknown as GiteaUser | undefined,
data: payload
};
}
/**
* Get normalized event type
*/
getEventType(payload: GiteaWebhookEvent): string {
return payload.event;
}
/**
* Get human-readable event description
*/
getEventDescription(payload: GiteaWebhookEvent): string {
const parts = [payload.giteaEvent];
if (payload.action) {
parts.push(payload.action);
}
if (payload.repository) {
parts.push(`in ${payload.repository.full_name}`);
}
if (payload.sender) {
parts.push(`by ${payload.sender.login}`);
}
return parts.join(' ');
}
/**
* Normalize Gitea event type to a consistent format
*/
private normalizeEventType(event: string, action?: string): string {
if (!action) {
return event;
}
return `${event}.${action}`;
}
/**
* Transform Gitea repository to generic format
*/
static transformRepository(repo: GiteaRepository): RepositoryInfo {
return {
id: repo.id.toString(),
name: repo.name,
fullName: repo.full_name,
owner: repo.owner.login,
isPrivate: repo.private,
defaultBranch: repo.default_branch
};
}
/**
* Transform Gitea user to generic format
*/
static transformUser(user: GiteaUser): UserInfo {
return {
id: user.id.toString(),
username: user.login,
email: user.email,
displayName: user.full_name || user.login
};
}
/**
* Transform Gitea issue to generic format
*/
static transformIssue(issue: GiteaIssue): IssueInfo {
return {
id: issue.id,
number: issue.number,
title: issue.title,
body: issue.body ?? '',
state: issue.state,
author: GiteaWebhookProvider.transformUser(issue.user),
labels: issue.labels ? issue.labels.map(label => label.name) : [],
createdAt: new Date(issue.created_at),
updatedAt: new Date(issue.updated_at)
};
}
/**
* Transform Gitea pull request to generic format
*/
static transformPullRequest(pr: GiteaPullRequest): PullRequestInfo {
return {
id: pr.id,
number: pr.number,
title: pr.title,
body: pr.body ?? '',
state: pr.state as 'open' | 'closed',
author: GiteaWebhookProvider.transformUser(pr.user),
labels: pr.labels ? pr.labels.map(label => label.name) : [],
createdAt: new Date(pr.created_at),
updatedAt: new Date(pr.updated_at),
sourceBranch: pr.head.ref,
targetBranch: pr.base.ref,
isDraft: pr.draft || false,
isMerged: pr.merged || false,
mergedAt: pr.merged_at ? new Date(pr.merged_at) : undefined
};
}
}

View File

@@ -1,11 +0,0 @@
/**
* Gitea webhook event handlers
*/
export { issueOpenedHandler, issueCommentCreatedHandler } from './issueHandler';
export {
pullRequestOpenedHandler,
pullRequestCommentCreatedHandler,
pullRequestSynchronizedHandler
} from './pullRequestHandler';
export { workflowRunCompletedHandler, workflowJobCompletedHandler } from './workflowHandler';

View File

@@ -1,291 +0,0 @@
import { createLogger } from '../../../utils/logger';
import { processCommand } from '../../../services/claudeService';
import type {
WebhookEventHandler,
WebhookContext,
WebhookHandlerResponse
} from '../../../types/webhook';
import type { GiteaWebhookEvent } from '../GiteaWebhookProvider';
import type { GiteaIssuePayload, GiteaIssueCommentPayload } from '../types';
import { giteaApiClient } from '../GiteaApiClient';
const logger = createLogger('GiteaIssueHandler');
// Get bot username from environment
const BOT_USERNAME = process.env.BOT_USERNAME ?? 'ClaudeBot';
/**
* Provides fallback labels based on simple keyword matching
*/
function getFallbackLabels(title: string, body: string | null): string[] {
const content = `${title} ${body ?? ''}`.toLowerCase();
const labels: string[] = [];
// Type detection
if (content.includes('bug') || content.includes('error') || content.includes('issue') || content.includes('problem')) {
labels.push('type:bug');
} else if (content.includes('feature') || content.includes('add') || content.includes('new')) {
labels.push('type:feature');
} else if (content.includes('improve') || content.includes('enhance') || content.includes('better')) {
labels.push('type:enhancement');
} else if (content.includes('question') || content.includes('help') || content.includes('how')) {
labels.push('type:question');
} else if (content.includes('docs') || content.includes('readme') || content.includes('documentation')) {
labels.push('type:documentation');
}
// Priority detection
if (content.includes('critical') || content.includes('urgent') || content.includes('security') || content.includes('down')) {
labels.push('priority:critical');
} else if (content.includes('important') || content.includes('high')) {
labels.push('priority:high');
}
return labels;
}
/**
* Handler for issue.opened events (auto-tagging)
*/
export const issueOpenedHandler: WebhookEventHandler<GiteaWebhookEvent> = {
event: 'issues.opened',
priority: 100,
async handle(
payload: GiteaWebhookEvent,
context: WebhookContext
): Promise<WebhookHandlerResponse> {
try {
const data = payload.data as GiteaIssuePayload;
const issue = data.issue;
const repo = data.repository;
logger.info({
repo: repo.full_name,
issue: issue.number,
title: issue.title,
user: issue.user.login
}, 'Processing new issue for auto-tagging');
// Check if auto-tagging is enabled
if (process.env.ENABLE_AUTO_TAGGING === 'false') {
logger.info('Auto-tagging is disabled');
return {
success: true,
message: 'Auto-tagging is disabled'
};
}
// Create the tagging command for Claude
const tagCommand = `Analyze this issue and apply appropriate labels.
Issue Details:
- Title: ${issue.title}
- Description: ${issue.body ?? 'No description provided'}
- Issue Number: ${issue.number}
- Repository: ${repo.full_name}
Instructions:
1. Analyze the issue content to determine appropriate labels from these categories:
- Priority: critical, high, medium, low
- Type: bug, feature, enhancement, documentation, question, security
- Complexity: trivial, simple, moderate, complex
- Component: api, frontend, backend, database, auth, webhook, docker
2. Apply the labels using the Gitea API
3. Do NOT comment on the issue - only apply labels silently
Complete the auto-tagging task.`;
try {
// Process with Claude
await processCommand({
repoFullName: repo.full_name,
issueNumber: issue.number,
command: tagCommand,
isPullRequest: false,
branchName: null,
operationType: 'auto-tagging'
});
logger.info({
repo: repo.full_name,
issue: issue.number
}, 'Claude processed auto-tagging');
} catch (claudeError) {
logger.warn({
err: claudeError,
repo: repo.full_name,
issue: issue.number
}, 'Claude tagging failed, attempting fallback');
// Fall back to basic keyword-based tagging
const fallbackLabelNames = getFallbackLabels(issue.title, issue.body);
if (fallbackLabelNames.length > 0) {
// Get existing labels to find IDs
const repoLabels = await giteaApiClient.getLabels(repo.owner.login, repo.name);
const labelIds = repoLabels
.filter(l => fallbackLabelNames.includes(l.name))
.map(l => l.id);
if (labelIds.length > 0) {
await giteaApiClient.addLabelsToIssue({
owner: repo.owner.login,
repo: repo.name,
issueNumber: issue.number,
labels: labelIds
});
logger.info({ labels: fallbackLabelNames }, 'Applied fallback labels successfully');
}
}
}
return {
success: true,
message: 'Issue auto-tagged successfully',
data: {
repo: repo.full_name,
issue: issue.number
}
};
} catch (error) {
logger.error({
err: error,
context
}, 'Error processing issue for auto-tagging');
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to auto-tag issue'
};
}
}
};
/**
* Handler for issue_comment.created events (bot mentions)
*/
export const issueCommentCreatedHandler: WebhookEventHandler<GiteaWebhookEvent> = {
event: 'issue_comment.created',
priority: 100,
canHandle(payload: GiteaWebhookEvent, _context: WebhookContext): boolean {
const data = payload.data as GiteaIssueCommentPayload;
// Check if comment mentions the bot
const botMention = `@${BOT_USERNAME}`;
const hasMention = data.comment.body.includes(botMention);
// Don't respond to our own comments
const isOwnComment = data.comment.user.login === BOT_USERNAME;
return hasMention && !isOwnComment;
},
async handle(
payload: GiteaWebhookEvent,
context: WebhookContext
): Promise<WebhookHandlerResponse> {
try {
const data = payload.data as GiteaIssueCommentPayload;
const comment = data.comment;
const issue = data.issue;
const repo = data.repository;
const isPullRequest = data.is_pull;
logger.info({
repo: repo.full_name,
issue: issue.number,
isPullRequest,
commenter: comment.user.login,
commentPreview: comment.body.substring(0, 100)
}, 'Processing bot mention in comment');
// Extract the command (everything after the @mention)
const botMention = `@${BOT_USERNAME}`;
const mentionIndex = comment.body.indexOf(botMention);
const command = comment.body.substring(mentionIndex + botMention.length).trim();
if (!command) {
// No command provided, just acknowledged
await giteaApiClient.postComment({
owner: repo.owner.login,
repo: repo.name,
issueNumber: issue.number,
body: `Hi @${comment.user.login}! How can I help you? Please provide a command after mentioning me.`
});
return {
success: true,
message: 'Acknowledged mention without command'
};
}
// Get branch info if this is a PR
let branchName: string | null = null;
if (isPullRequest) {
const pr = await giteaApiClient.getPullRequest(repo.owner.login, repo.name, issue.number);
if (pr) {
branchName = pr.head.ref;
}
}
// Process the command with Claude
logger.info({
repo: repo.full_name,
issue: issue.number,
command: command.substring(0, 100)
}, 'Sending command to Claude');
const response = await processCommand({
repoFullName: repo.full_name,
issueNumber: issue.number,
command: command,
isPullRequest: isPullRequest,
branchName: branchName,
operationType: 'default'
});
// Post the response as a comment
await giteaApiClient.postComment({
owner: repo.owner.login,
repo: repo.name,
issueNumber: issue.number,
body: response
});
return {
success: true,
message: 'Processed bot mention successfully',
data: {
repo: repo.full_name,
issue: issue.number,
responseLength: response.length
}
};
} catch (error) {
logger.error({
err: error,
context
}, 'Error processing bot mention');
// Try to post error message
try {
const data = payload.data as GiteaIssueCommentPayload;
await giteaApiClient.postComment({
owner: data.repository.owner.login,
repo: data.repository.name,
issueNumber: data.issue.number,
body: `Sorry, I encountered an error while processing your request. Please try again later.`
});
} catch {
// Ignore error posting failure
}
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to process bot mention'
};
}
}
};

View File

@@ -1,308 +0,0 @@
import { createLogger } from '../../../utils/logger';
import { processCommand } from '../../../services/claudeService';
import type {
WebhookEventHandler,
WebhookContext,
WebhookHandlerResponse
} from '../../../types/webhook';
import type { GiteaWebhookEvent } from '../GiteaWebhookProvider';
import type { GiteaPullRequestPayload, GiteaPullRequestCommentPayload } from '../types';
import { giteaApiClient } from '../GiteaApiClient';
const logger = createLogger('GiteaPullRequestHandler');
// Get bot username from environment
const BOT_USERNAME = process.env.BOT_USERNAME ?? 'ClaudeBot';
/**
* Handler for pull_request.opened events (PR review)
*/
export const pullRequestOpenedHandler: WebhookEventHandler<GiteaWebhookEvent> = {
event: 'pull_request.opened',
priority: 80,
canHandle(payload: GiteaWebhookEvent, _context: WebhookContext): boolean {
// Check if auto-review is enabled
const enableAutoReview = process.env.ENABLE_AUTO_PR_REVIEW === 'true';
if (!enableAutoReview) {
logger.debug('Auto PR review is disabled');
return false;
}
const data = payload.data as GiteaPullRequestPayload;
// Don't review draft PRs
if (data.pull_request.draft) {
logger.debug('Skipping draft PR');
return false;
}
// Don't review our own PRs
if (data.pull_request.user.login === BOT_USERNAME) {
logger.debug('Skipping own PR');
return false;
}
return true;
},
async handle(
payload: GiteaWebhookEvent,
context: WebhookContext
): Promise<WebhookHandlerResponse> {
try {
const data = payload.data as GiteaPullRequestPayload;
const pr = data.pull_request;
const repo = data.repository;
logger.info({
repo: repo.full_name,
pr: pr.number,
title: pr.title,
author: pr.user.login,
headBranch: pr.head.ref,
baseBranch: pr.base.ref
}, 'Processing new PR for auto-review');
// Create the review command for Claude
const reviewCommand = `Review this pull request and provide feedback.
Pull Request Details:
- Title: ${pr.title}
- Description: ${pr.body ?? 'No description provided'}
- PR Number: #${pr.number}
- Author: ${pr.user.login}
- Source Branch: ${pr.head.ref}
- Target Branch: ${pr.base.ref}
- Repository: ${repo.full_name}
Instructions:
1. Clone the repository and checkout the PR branch
2. Review the changes made in this PR
3. Look for:
- Code quality issues
- Potential bugs
- Security concerns
- Performance issues
- Missing tests
- Documentation needs
4. Provide a constructive review comment with:
- Summary of changes
- Positive aspects
- Areas for improvement
- Specific suggestions with line references if applicable
Be helpful and constructive. Focus on significant issues, not nitpicks.`;
// Process with Claude
const response = await processCommand({
repoFullName: repo.full_name,
issueNumber: pr.number,
command: reviewCommand,
isPullRequest: true,
branchName: pr.head.ref,
operationType: 'default'
});
// Post the review as a comment
await giteaApiClient.postComment({
owner: repo.owner.login,
repo: repo.name,
issueNumber: pr.number,
body: response
});
logger.info({
repo: repo.full_name,
pr: pr.number
}, 'PR auto-review completed');
return {
success: true,
message: 'PR auto-reviewed successfully',
data: {
repo: repo.full_name,
pr: pr.number,
responseLength: response.length
}
};
} catch (error) {
logger.error({
err: error,
context
}, 'Error processing PR for auto-review');
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to auto-review PR'
};
}
}
};
/**
* Handler for pull_request_comment.created events (bot mentions in PR comments)
*/
export const pullRequestCommentCreatedHandler: WebhookEventHandler<GiteaWebhookEvent> = {
event: /^pull_request.*\.created$/,
priority: 100,
canHandle(payload: GiteaWebhookEvent, _context: WebhookContext): boolean {
// This handler catches PR review comments
// The issue_comment handler catches PR thread comments
// Only handle if it's a PR comment event
if (!payload.giteaEvent.includes('pull_request')) {
return false;
}
const data = payload.data as GiteaPullRequestCommentPayload;
if (!data.comment) {
return false;
}
// Check if comment mentions the bot
const botMention = `@${BOT_USERNAME}`;
const hasMention = data.comment.body.includes(botMention);
// Don't respond to our own comments
const isOwnComment = data.comment.user.login === BOT_USERNAME;
return hasMention && !isOwnComment;
},
async handle(
payload: GiteaWebhookEvent,
context: WebhookContext
): Promise<WebhookHandlerResponse> {
try {
const data = payload.data as GiteaPullRequestCommentPayload;
const comment = data.comment;
const pr = data.pull_request;
const repo = data.repository;
logger.info({
repo: repo.full_name,
pr: pr.number,
commenter: comment.user.login,
commentPreview: comment.body.substring(0, 100)
}, 'Processing bot mention in PR comment');
// Extract the command (everything after the @mention)
const botMention = `@${BOT_USERNAME}`;
const mentionIndex = comment.body.indexOf(botMention);
const command = comment.body.substring(mentionIndex + botMention.length).trim();
if (!command) {
// No command provided
await giteaApiClient.postComment({
owner: repo.owner.login,
repo: repo.name,
issueNumber: pr.number,
body: `Hi @${comment.user.login}! How can I help you with this PR? Please provide a command after mentioning me.`
});
return {
success: true,
message: 'Acknowledged mention without command'
};
}
// Process the command with Claude
logger.info({
repo: repo.full_name,
pr: pr.number,
command: command.substring(0, 100)
}, 'Sending PR command to Claude');
const response = await processCommand({
repoFullName: repo.full_name,
issueNumber: pr.number,
command: command,
isPullRequest: true,
branchName: pr.head.ref,
operationType: 'default'
});
// Post the response as a comment
await giteaApiClient.postComment({
owner: repo.owner.login,
repo: repo.name,
issueNumber: pr.number,
body: response
});
return {
success: true,
message: 'Processed PR bot mention successfully',
data: {
repo: repo.full_name,
pr: pr.number,
responseLength: response.length
}
};
} catch (error) {
logger.error({
err: error,
context
}, 'Error processing PR bot mention');
// Try to post error message
try {
const data = payload.data as GiteaPullRequestCommentPayload;
await giteaApiClient.postComment({
owner: data.repository.owner.login,
repo: data.repository.name,
issueNumber: data.pull_request.number,
body: `Sorry, I encountered an error while processing your request. Please try again later.`
});
} catch {
// Ignore error posting failure
}
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to process PR bot mention'
};
}
}
};
/**
* Handler for pull_request.synchronized events (new commits pushed)
*/
export const pullRequestSynchronizedHandler: WebhookEventHandler<GiteaWebhookEvent> = {
event: 'pull_request.synchronized',
priority: 50,
canHandle(_payload: GiteaWebhookEvent, _context: WebhookContext): boolean {
// Check if re-review on push is enabled
return process.env.ENABLE_PR_PUSH_REVIEW === 'true';
},
async handle(
payload: GiteaWebhookEvent,
_context: WebhookContext
): Promise<WebhookHandlerResponse> {
const data = payload.data as GiteaPullRequestPayload;
const pr = data.pull_request;
const repo = data.repository;
logger.info({
repo: repo.full_name,
pr: pr.number,
headSha: pr.head.sha
}, 'New commits pushed to PR');
// For now, just log the event. Full re-review can be expensive.
// A more sophisticated implementation could:
// - Check if significant files changed
// - Only re-review if CI passes
// - Provide incremental review of new commits
return {
success: true,
message: `Noted new commits on PR #${pr.number}. Re-review can be triggered manually.`
};
}
};

View File

@@ -1,303 +0,0 @@
import { createLogger } from '../../../utils/logger';
import type {
WebhookEventHandler,
WebhookContext,
WebhookHandlerResponse
} from '../../../types/webhook';
import type { GiteaWebhookEvent } from '../GiteaWebhookProvider';
import type { GiteaWorkflowRunPayload, GiteaWorkflowJobPayload } from '../types';
import { giteaApiClient } from '../GiteaApiClient';
import { processCommand } from '../../../services/claudeService';
const logger = createLogger('GiteaWorkflowHandler');
/**
* Extract workflow run info from webhook payload
*/
function extractWorkflowRunInfo(payload: GiteaWorkflowRunPayload) {
return {
runId: payload.workflow_run.id,
runNumber: payload.workflow_run.run_number,
name: payload.workflow_run.name,
status: payload.workflow_run.status,
conclusion: payload.workflow_run.conclusion,
headBranch: payload.workflow_run.head_branch,
headSha: payload.workflow_run.head_sha,
htmlUrl: payload.workflow_run.html_url,
owner: payload.repository.owner.login,
repo: payload.repository.name,
fullName: payload.repository.full_name
};
}
/**
* Handle workflow run completed event
* This is the main CI failure detection handler
*/
async function handleWorkflowRunCompleted(
payload: GiteaWebhookEvent,
_context: WebhookContext
): Promise<WebhookHandlerResponse> {
const data = payload.data as GiteaWorkflowRunPayload;
const workflowInfo = extractWorkflowRunInfo(data);
logger.info({
...workflowInfo,
action: data.action
}, 'Processing workflow run completed event');
// Only process failed workflows
if (data.workflow_run.conclusion !== 'failure') {
logger.info({
runId: workflowInfo.runId,
conclusion: data.workflow_run.conclusion
}, 'Workflow did not fail, skipping');
return {
success: true,
message: `Workflow ${workflowInfo.name} completed with ${data.workflow_run.conclusion}, no action needed`
};
}
logger.info({
runId: workflowInfo.runId,
name: workflowInfo.name,
branch: workflowInfo.headBranch
}, 'Workflow failed, analyzing failure');
try {
// Get workflow logs
const logs = await giteaApiClient.getWorkflowLogs({
owner: workflowInfo.owner,
repo: workflowInfo.repo,
runId: workflowInfo.runId
});
if (!logs) {
logger.warn({ runId: workflowInfo.runId }, 'Could not fetch workflow logs');
return {
success: false,
error: 'Could not fetch workflow logs'
};
}
// Get failed jobs details
const jobs = await giteaApiClient.getWorkflowJobs(
workflowInfo.owner,
workflowInfo.repo,
workflowInfo.runId
);
const failedJobs = jobs.filter(job => job.conclusion === 'failure');
const failedJobNames = failedJobs.map(job => job.name).join(', ');
// Build the prompt for Claude
const prompt = buildCIFailurePrompt({
workflowName: workflowInfo.name,
branch: workflowInfo.headBranch,
commit: workflowInfo.headSha,
failedJobs: failedJobNames,
logs: logs,
repoFullName: workflowInfo.fullName,
runUrl: workflowInfo.htmlUrl
});
// Create status to indicate we're analyzing
await giteaApiClient.createCommitStatus({
owner: workflowInfo.owner,
repo: workflowInfo.repo,
sha: workflowInfo.headSha,
state: 'pending',
context: 'claude-hub/ci-fix',
description: 'Analyzing CI failure...'
});
// Process with Claude using the existing processCommand function
logger.info({ runId: workflowInfo.runId }, 'Sending CI failure to Claude for analysis');
try {
const response = await processCommand({
repoFullName: workflowInfo.fullName,
issueNumber: null, // No issue associated with CI failure
command: prompt,
isPullRequest: false,
branchName: workflowInfo.headBranch,
operationType: 'default' // Full capabilities for fixing CI
});
// Update status to success
await giteaApiClient.createCommitStatus({
owner: workflowInfo.owner,
repo: workflowInfo.repo,
sha: workflowInfo.headSha,
state: 'success',
context: 'claude-hub/ci-fix',
description: 'CI fix attempted'
});
logger.info({
runId: workflowInfo.runId,
responseLength: response.length
}, 'Claude processed CI failure');
return {
success: true,
message: `Processed CI failure for workflow ${workflowInfo.name}`,
data: {
workflowRun: workflowInfo.runId,
response: response.substring(0, 500) // Truncate for logging
}
};
} catch (claudeError) {
// Update status to error
await giteaApiClient.createCommitStatus({
owner: workflowInfo.owner,
repo: workflowInfo.repo,
sha: workflowInfo.headSha,
state: 'error',
context: 'claude-hub/ci-fix',
description: 'Failed to analyze/fix CI failure'
});
throw claudeError;
}
} catch (error) {
logger.error({
err: error,
runId: workflowInfo.runId
}, 'Error handling workflow failure');
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
/**
* Build the prompt for Claude to analyze and fix CI failure
*/
function buildCIFailurePrompt(info: {
workflowName: string;
branch: string;
commit: string;
failedJobs: string;
logs: string;
repoFullName: string;
runUrl: string;
}): string {
// Truncate logs if too long (keep last 10000 chars which usually contains the error)
const truncatedLogs = info.logs.length > 10000
? `[...truncated...]\n${info.logs.slice(-10000)}`
: info.logs;
return `# CI Failure Analysis and Fix Request
## Workflow Information
- **Repository**: ${info.repoFullName}
- **Workflow**: ${info.workflowName}
- **Branch**: ${info.branch}
- **Commit**: ${info.commit}
- **Failed Jobs**: ${info.failedJobs}
- **Run URL**: ${info.runUrl}
## Task
Analyze the CI failure logs below and create a fix. Your goal is to:
1. Identify the root cause of the failure
2. Implement the necessary fix
3. Create a pull request with the fix
## CI Logs
\`\`\`
${truncatedLogs}
\`\`\`
## Instructions
1. First, analyze the logs to understand what failed and why
2. Clone the repository and checkout the branch
3. Make the minimal necessary changes to fix the issue
4. Create a new branch named \`fix/ci-${info.commit.slice(0, 7)}\`
5. Commit your changes with a descriptive message
6. Create a pull request targeting the original branch (${info.branch})
7. Include in the PR description:
- What failed
- Root cause analysis
- What you fixed
- Link to the failed workflow run
Be concise and focused. Only fix what's broken, don't refactor or improve unrelated code.`;
}
/**
* Handle workflow job completed event (more granular than run)
*/
async function handleWorkflowJobCompleted(
payload: GiteaWebhookEvent,
_context: WebhookContext
): Promise<WebhookHandlerResponse> {
const data = payload.data as GiteaWorkflowJobPayload;
logger.info({
jobId: data.workflow_job.id,
jobName: data.workflow_job.name,
conclusion: data.workflow_job.conclusion,
repo: payload.repository?.full_name
}, 'Processing workflow job completed event');
// We primarily handle this at the run level, but log job completions
// for debugging purposes
if (data.workflow_job.conclusion === 'failure') {
logger.warn({
jobId: data.workflow_job.id,
jobName: data.workflow_job.name
}, 'Workflow job failed (will be handled at run level)');
}
return {
success: true,
message: `Workflow job ${data.workflow_job.name} completed with ${data.workflow_job.conclusion}`
};
}
/**
* Workflow run completed handler
*/
export const workflowRunCompletedHandler: WebhookEventHandler<GiteaWebhookEvent> = {
event: 'workflow_run.completed',
priority: 100, // High priority for CI failures
canHandle(payload: GiteaWebhookEvent, _context: WebhookContext): boolean {
const data = payload.data as GiteaWorkflowRunPayload;
// Only handle if it's a completed workflow run
if (payload.giteaEvent !== 'workflow_run' || data.action !== 'completed') {
return false;
}
// Check if CI failure handling is enabled
const enableCIFix = process.env.ENABLE_CI_FIX !== 'false';
if (!enableCIFix) {
logger.debug('CI fix handling is disabled');
return false;
}
return true;
},
handle: handleWorkflowRunCompleted
};
/**
* Workflow job completed handler (for logging/monitoring)
*/
export const workflowJobCompletedHandler: WebhookEventHandler<GiteaWebhookEvent> = {
event: 'workflow_job.completed',
priority: 50,
canHandle(payload: GiteaWebhookEvent, _context: WebhookContext): boolean {
const data = payload.data as GiteaWorkflowJobPayload;
return payload.giteaEvent === 'workflow_job' && data.action === 'completed';
},
handle: handleWorkflowJobCompleted
};

View File

@@ -1,53 +0,0 @@
/**
* Gitea webhook provider
*/
import { webhookRegistry } from '../../core/webhook/WebhookRegistry';
import { GiteaWebhookProvider } from './GiteaWebhookProvider';
import {
issueOpenedHandler,
issueCommentCreatedHandler,
pullRequestOpenedHandler,
pullRequestCommentCreatedHandler,
pullRequestSynchronizedHandler,
workflowRunCompletedHandler,
workflowJobCompletedHandler
} from './handlers';
import { createLogger } from '../../utils/logger';
const logger = createLogger('GiteaProvider');
/**
* Initialize Gitea webhook provider and handlers
*/
export function initializeGiteaProvider(): void {
logger.info('Initializing Gitea webhook provider');
// Register the provider
const provider = new GiteaWebhookProvider();
webhookRegistry.registerProvider(provider);
// Register issue handlers
webhookRegistry.registerHandler('gitea', issueOpenedHandler);
webhookRegistry.registerHandler('gitea', issueCommentCreatedHandler);
// Register PR handlers
webhookRegistry.registerHandler('gitea', pullRequestOpenedHandler);
webhookRegistry.registerHandler('gitea', pullRequestCommentCreatedHandler);
webhookRegistry.registerHandler('gitea', pullRequestSynchronizedHandler);
// Register workflow handlers (CI failure detection)
webhookRegistry.registerHandler('gitea', workflowRunCompletedHandler);
webhookRegistry.registerHandler('gitea', workflowJobCompletedHandler);
logger.info('Gitea webhook provider initialized with handlers');
}
// Auto-initialize when imported
initializeGiteaProvider();
// Export types and classes
export { GiteaWebhookProvider, type GiteaWebhookEvent } from './GiteaWebhookProvider';
export { GiteaApiClient, giteaApiClient } from './GiteaApiClient';
export * from './handlers';
export * from './types';

View File

@@ -1,320 +0,0 @@
/**
* Gitea-specific types for webhook and API integration
*/
/**
* Gitea user representation
*/
export interface GiteaUser {
id: number;
login: string;
full_name: string;
email: string;
avatar_url: string;
username: string;
}
/**
* Gitea repository representation
*/
export interface GiteaRepository {
id: number;
name: string;
full_name: string;
owner: GiteaUser;
private: boolean;
html_url: string;
clone_url: string;
ssh_url: string;
default_branch: string;
}
/**
* Gitea label representation
*/
export interface GiteaLabel {
id: number;
name: string;
color: string;
description: string;
}
/**
* Gitea issue representation
*/
export interface GiteaIssue {
id: number;
number: number;
title: string;
body: string;
state: 'open' | 'closed';
user: GiteaUser;
labels: GiteaLabel[];
created_at: string;
updated_at: string;
html_url: string;
}
/**
* Gitea pull request representation
*/
export interface GiteaPullRequest {
id: number;
number: number;
title: string;
body: string;
state: 'open' | 'closed';
user: GiteaUser;
labels: GiteaLabel[];
created_at: string;
updated_at: string;
merged_at: string | null;
merged: boolean;
draft: boolean;
html_url: string;
head: {
ref: string;
sha: string;
repo: GiteaRepository;
};
base: {
ref: string;
sha: string;
repo: GiteaRepository;
};
}
/**
* Gitea comment representation
*/
export interface GiteaComment {
id: number;
body: string;
user: GiteaUser;
created_at: string;
updated_at: string;
html_url: string;
}
/**
* Gitea workflow run representation (Gitea Actions)
*/
export interface GiteaWorkflowRun {
id: number;
name: string;
head_branch: string;
head_sha: string;
run_number: number;
event: string;
status: 'queued' | 'in_progress' | 'completed' | 'waiting';
conclusion: 'success' | 'failure' | 'cancelled' | 'skipped' | null;
workflow_id: string;
html_url: string;
created_at: string;
updated_at: string;
run_started_at: string;
jobs_url: string;
logs_url: string;
}
/**
* Gitea workflow job representation
*/
export interface GiteaWorkflowJob {
id: number;
run_id: number;
name: string;
status: 'queued' | 'in_progress' | 'completed' | 'waiting';
conclusion: 'success' | 'failure' | 'cancelled' | 'skipped' | null;
started_at: string;
completed_at: string | null;
steps: GiteaWorkflowStep[];
}
/**
* Gitea workflow step representation
*/
export interface GiteaWorkflowStep {
name: string;
status: 'queued' | 'in_progress' | 'completed';
conclusion: 'success' | 'failure' | 'cancelled' | 'skipped' | null;
number: number;
started_at: string;
completed_at: string | null;
}
/**
* Gitea commit status representation
*/
export interface GiteaCommitStatus {
id: number;
state: 'pending' | 'success' | 'error' | 'failure' | 'warning';
context: string;
description: string;
target_url: string;
created_at: string;
updated_at: string;
}
/**
* Gitea combined status representation
*/
export interface GiteaCombinedStatus {
state: 'pending' | 'success' | 'error' | 'failure';
statuses: GiteaCommitStatus[];
total_count: number;
sha: string;
repository: GiteaRepository;
}
// ============================================
// Webhook Payload Types
// ============================================
/**
* Base Gitea webhook payload
*/
export interface GiteaWebhookPayloadBase {
action?: string;
repository: GiteaRepository;
sender: GiteaUser;
}
/**
* Issue webhook payload
*/
export interface GiteaIssuePayload extends GiteaWebhookPayloadBase {
action: 'opened' | 'edited' | 'closed' | 'reopened' | 'assigned' | 'unassigned' | 'label_updated' | 'label_cleared' | 'milestoned' | 'demilestoned';
issue: GiteaIssue;
}
/**
* Issue comment webhook payload
*/
export interface GiteaIssueCommentPayload extends GiteaWebhookPayloadBase {
action: 'created' | 'edited' | 'deleted';
issue: GiteaIssue;
comment: GiteaComment;
is_pull: boolean;
}
/**
* Pull request webhook payload
*/
export interface GiteaPullRequestPayload extends GiteaWebhookPayloadBase {
action: 'opened' | 'edited' | 'closed' | 'reopened' | 'synchronized' | 'assigned' | 'unassigned' | 'review_requested' | 'review_request_removed' | 'label_updated' | 'label_cleared';
pull_request: GiteaPullRequest;
}
/**
* Pull request comment webhook payload
*/
export interface GiteaPullRequestCommentPayload extends GiteaWebhookPayloadBase {
action: 'created' | 'edited' | 'deleted';
pull_request: GiteaPullRequest;
comment: GiteaComment;
}
/**
* Workflow run webhook payload (Gitea Actions)
*/
export interface GiteaWorkflowRunPayload extends GiteaWebhookPayloadBase {
action: 'requested' | 'in_progress' | 'completed';
workflow_run: GiteaWorkflowRun;
}
/**
* Workflow job webhook payload (Gitea Actions)
*/
export interface GiteaWorkflowJobPayload extends GiteaWebhookPayloadBase {
action: 'queued' | 'in_progress' | 'completed' | 'waiting';
workflow_job: GiteaWorkflowJob;
}
// ============================================
// API Request/Response Types
// ============================================
/**
* Create comment request
*/
export interface GiteaCreateCommentRequest {
owner: string;
repo: string;
issueNumber: number;
body: string;
}
/**
* Create comment response
*/
export interface GiteaCreateCommentResponse {
id: number;
body: string;
created_at: string;
}
/**
* Create pull request request
*/
export interface GiteaCreatePullRequestRequest {
owner: string;
repo: string;
title: string;
body: string;
head: string;
base: string;
}
/**
* Create branch request
*/
export interface GiteaCreateBranchRequest {
owner: string;
repo: string;
branchName: string;
oldBranchName: string;
}
/**
* Get workflow run logs request
*/
export interface GiteaGetWorkflowLogsRequest {
owner: string;
repo: string;
runId: number;
}
/**
* Create commit status request
*/
export interface GiteaCreateCommitStatusRequest {
owner: string;
repo: string;
sha: string;
state: 'pending' | 'success' | 'error' | 'failure' | 'warning';
context: string;
description?: string;
targetUrl?: string;
}
/**
* Add labels request
*/
export interface GiteaAddLabelsRequest {
owner: string;
repo: string;
issueNumber: number;
labels: number[];
}
/**
* Create label request
*/
export interface GiteaCreateLabelRequest {
owner: string;
repo: string;
name: string;
color: string;
description?: string;
}

View File

@@ -0,0 +1,209 @@
import crypto from 'crypto';
import { createLogger } from '../../utils/logger';
import type { WebhookRequest } from '../../types/express';
import type {
WebhookProvider,
BaseWebhookPayload,
RepositoryInfo,
UserInfo,
IssueInfo,
PullRequestInfo
} from '../../types/webhook';
import type {
GitHubRepository,
GitHubUser,
GitHubIssue,
GitHubPullRequest
} from '../../types/github';
const logger = createLogger('GitHubWebhookProvider');
/**
* GitHub-specific webhook payload
*/
export interface GitHubWebhookEvent extends BaseWebhookPayload {
githubEvent: string;
githubDelivery: string;
action?: string;
repository?: GitHubRepository;
sender?: GitHubUser;
installation?: {
id: number;
account: GitHubUser;
};
}
/**
* GitHub webhook provider implementation
*/
export class GitHubWebhookProvider implements WebhookProvider<GitHubWebhookEvent> {
readonly name = 'github';
/**
* Verify GitHub webhook signature
*/
verifySignature(req: WebhookRequest, secret: string): Promise<boolean> {
// eslint-disable-next-line no-sync
return Promise.resolve(this.verifySignatureSync(req, secret));
}
private verifySignatureSync(req: WebhookRequest, secret: string): boolean {
const signature = req.headers['x-hub-signature-256'] as string;
if (!signature) {
logger.warn('No signature found in GitHub webhook request');
return false;
}
try {
const payload = req.rawBody ?? JSON.stringify(req.body);
const hmac = crypto.createHmac('sha256', secret);
const calculatedSignature = 'sha256=' + hmac.update(payload).digest('hex');
// Use timing-safe comparison
if (
signature.length === calculatedSignature.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(calculatedSignature))
) {
logger.debug('GitHub webhook signature verified successfully');
return true;
}
logger.warn('GitHub webhook signature verification failed');
return false;
} catch (error) {
logger.error({ err: error }, 'Error verifying GitHub webhook signature');
return false;
}
}
/**
* Parse GitHub webhook payload
*/
parsePayload(req: WebhookRequest): Promise<GitHubWebhookEvent> {
// eslint-disable-next-line no-sync
return Promise.resolve(this.parsePayloadSync(req));
}
private parsePayloadSync(req: WebhookRequest): GitHubWebhookEvent {
const githubEvent = req.headers['x-github-event'] as string;
const githubDelivery = req.headers['x-github-delivery'] as string;
const payload = req.body;
return {
id: githubDelivery || crypto.randomUUID(),
timestamp: new Date().toISOString(),
event: this.normalizeEventType(githubEvent, payload.action),
source: 'github',
githubEvent,
githubDelivery,
action: payload.action,
repository: payload.repository,
sender: payload.sender,
installation: payload.installation,
data: payload
};
}
/**
* Get normalized event type
*/
getEventType(payload: GitHubWebhookEvent): string {
return payload.event;
}
/**
* Get human-readable event description
*/
getEventDescription(payload: GitHubWebhookEvent): string {
const parts = [payload.githubEvent];
if (payload.action) {
parts.push(payload.action);
}
if (payload.repository) {
parts.push(`in ${payload.repository.full_name}`);
}
if (payload.sender) {
parts.push(`by ${payload.sender.login}`);
}
return parts.join(' ');
}
/**
* Normalize GitHub event type to a consistent format
*/
private normalizeEventType(event: string, action?: string): string {
if (!action) {
return event;
}
return `${event}.${action}`;
}
/**
* Transform GitHub repository to generic format
*/
static transformRepository(repo: GitHubRepository): RepositoryInfo {
return {
id: repo.id.toString(),
name: repo.name,
fullName: repo.full_name,
owner: repo.owner.login,
isPrivate: repo.private,
defaultBranch: repo.default_branch
};
}
/**
* Transform GitHub user to generic format
*/
static transformUser(user: GitHubUser): UserInfo {
return {
id: user.id.toString(),
username: user.login,
email: user.email,
displayName: user.name ?? user.login
};
}
/**
* Transform GitHub issue to generic format
*/
static transformIssue(issue: GitHubIssue): IssueInfo {
return {
id: issue.id,
number: issue.number,
title: issue.title,
body: issue.body ?? '',
state: issue.state,
author: GitHubWebhookProvider.transformUser(issue.user),
labels: issue.labels
? issue.labels.map(label => (typeof label === 'string' ? label : label.name))
: [],
createdAt: new Date(issue.created_at),
updatedAt: new Date(issue.updated_at)
};
}
/**
* Transform GitHub pull request to generic format
*/
static transformPullRequest(pr: GitHubPullRequest): PullRequestInfo {
return {
id: pr.id,
number: pr.number,
title: pr.title,
body: pr.body ?? '',
state: pr.state as 'open' | 'closed',
author: GitHubWebhookProvider.transformUser(pr.user),
labels: pr.labels
? pr.labels.map(label => (typeof label === 'string' ? label : label.name))
: [],
createdAt: new Date(pr.created_at),
updatedAt: new Date(pr.updated_at),
sourceBranch: pr.head.ref,
targetBranch: pr.base.ref,
isDraft: pr.draft || false,
isMerged: pr.merged || false,
mergedAt: pr.merged_at ? new Date(pr.merged_at) : undefined
};
}
}

View File

@@ -0,0 +1,122 @@
import { createLogger } from '../../../utils/logger';
import { processCommand } from '../../../services/claudeService';
import { addLabelsToIssue, getFallbackLabels } from '../../../services/githubService';
import type {
WebhookEventHandler,
WebhookContext,
WebhookHandlerResponse
} from '../../../types/webhook';
import type { GitHubWebhookEvent } from '../GitHubWebhookProvider';
import type { GitHubIssue } from '../../../types/github';
const logger = createLogger('IssueHandler');
/**
* Handler for GitHub issue.opened events (auto-tagging)
*/
export class IssueOpenedHandler implements WebhookEventHandler<GitHubWebhookEvent> {
event = 'issues.opened';
priority = 100;
async handle(
payload: GitHubWebhookEvent,
context: WebhookContext
): Promise<WebhookHandlerResponse> {
try {
const githubPayload = payload.data as {
issue: GitHubIssue;
repository: { full_name: string; owner: { login: string }; name: string };
};
const issue = githubPayload.issue;
const repo = githubPayload.repository;
// Repository data is always present in GitHub webhook payloads
logger.info(
{
repo: repo.full_name,
issue: issue.number,
title: issue.title,
user: issue.user.login
},
'Processing new issue for auto-tagging'
);
// Create the tagging command for Claude
const tagCommand = `Analyze this GitHub issue and apply appropriate labels using GitHub CLI commands.
Issue Details:
- Title: ${issue.title}
- Description: ${issue.body ?? 'No description provided'}
- Issue Number: ${issue.number}
Instructions:
1. First run 'gh label list' to see what labels are available in this repository
2. Analyze the issue content to determine appropriate labels from these categories:
- Priority: critical, high, medium, low
- Type: bug, feature, enhancement, documentation, question, security
- Complexity: trivial, simple, moderate, complex
- Component: api, frontend, backend, database, auth, webhook, docker
3. Apply the labels using: gh issue edit ${issue.number} --add-label "label1,label2,label3"
4. Do NOT comment on the issue - only apply labels silently
Complete the auto-tagging task using only GitHub CLI commands.`;
// Process with Claude
const claudeResponse = await processCommand({
repoFullName: repo.full_name,
issueNumber: issue.number,
command: tagCommand,
isPullRequest: false,
branchName: null,
operationType: 'auto-tagging'
});
// Check if Claude succeeded
if (claudeResponse.includes('error') || claudeResponse.includes('failed')) {
logger.warn(
{
repo: repo.full_name,
issue: issue.number,
responsePreview: claudeResponse.substring(0, 200)
},
'Claude CLI tagging may have failed, attempting fallback'
);
// Fall back to basic tagging
const fallbackLabels = getFallbackLabels(issue.title, issue.body);
if (fallbackLabels.length > 0) {
await addLabelsToIssue({
repoOwner: repo.owner.login,
repoName: repo.name,
issueNumber: issue.number,
labels: fallbackLabels
});
logger.info('Applied fallback labels successfully');
}
}
return {
success: true,
message: 'Issue auto-tagged successfully',
data: {
repo: repo.full_name,
issue: issue.number
}
};
} catch (error) {
logger.error(
{
err: error,
context
},
'Error processing issue for auto-tagging'
);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to auto-tag issue'
};
}
}
}

View File

@@ -0,0 +1,25 @@
import { webhookRegistry } from '../../core/webhook/WebhookRegistry';
import { GitHubWebhookProvider } from './GitHubWebhookProvider';
import { IssueOpenedHandler } from './handlers/IssueHandler';
import { createLogger } from '../../utils/logger';
const logger = createLogger('GitHubProvider');
/**
* Initialize GitHub webhook provider and handlers
*/
export function initializeGitHubProvider(): void {
logger.info('Initializing GitHub webhook provider');
// Register the provider
const provider = new GitHubWebhookProvider();
webhookRegistry.registerProvider(provider);
// Register handlers
webhookRegistry.registerHandler('github', new IssueOpenedHandler());
logger.info('GitHub webhook provider initialized with handlers');
}
// Auto-initialize when imported
initializeGitHubProvider();

10
src/routes/github.ts Normal file
View File

@@ -0,0 +1,10 @@
import express from 'express';
import { handleWebhook } from '../controllers/githubController';
const router = express.Router();
// Legacy GitHub webhook endpoint - maintained for backward compatibility
// New webhooks should use /api/webhooks/github
router.post('/', handleWebhook as express.RequestHandler);
export default router;

View File

@@ -12,12 +12,12 @@ const processor = new WebhookProcessor();
// Initialize providers if not in test environment
if (process.env.NODE_ENV !== 'test') {
// Dynamically import to avoid side effects during testing
import('../providers/claude').catch(err => {
logger.error({ err }, 'Failed to initialize Claude provider');
import('../providers/github').catch(err => {
logger.error({ err }, 'Failed to initialize GitHub provider');
});
import('../providers/gitea').catch(err => {
logger.error({ err }, 'Failed to initialize Gitea provider');
import('../providers/claude').catch(err => {
logger.error({ err }, 'Failed to initialize Claude provider');
});
}
@@ -90,10 +90,11 @@ router.get('/health', (_req, res) => {
});
/**
* Gitea webhook endpoint
* POST /api/webhooks/gitea
* Legacy GitHub webhook endpoint (for backward compatibility)
* POST /api/webhooks/github
*
* This is handled by the generic endpoint above
* This is handled by the generic endpoint above, but we'll keep
* this documentation for clarity
*/
export default router;

View File

@@ -52,12 +52,13 @@ export async function processCommand({
'Processing command with Claude'
);
const giteaToken = secureCredentials.get('GITEA_TOKEN');
const githubToken = secureCredentials.get('GITHUB_TOKEN');
// In test mode, skip execution and return a mock response
// Gitea tokens are typically alphanumeric strings
const isValidGiteaToken = giteaToken && giteaToken.length > 0;
if (process.env['NODE_ENV'] === 'test' || !isValidGiteaToken) {
// Support both classic (ghp_) and fine-grained (github_pat_) GitHub tokens
const isValidGitHubToken =
githubToken && (githubToken.includes('ghp_') || githubToken.includes('github_pat_'));
if (process.env['NODE_ENV'] === 'test' || !isValidGitHubToken) {
logger.info(
{
repo: repoFullName,
@@ -73,9 +74,9 @@ Since this is a test environment, I'm providing a simulated response. In product
1. Clone the repository ${repoFullName}
2. ${isPullRequest ? `Checkout PR branch: ${branchName}` : 'Use the main branch'}
3. Analyze the codebase and execute: "${command}"
4. Use Gitea API to interact with issues, PRs, and comments
4. Use GitHub CLI to interact with issues, PRs, and comments
For real functionality, please configure valid Gitea and Claude API tokens.`;
For real functionality, please configure valid GitHub and Claude API tokens.`;
// Always sanitize responses, even in test mode
return sanitizeBotMentions(testResponse);
@@ -123,7 +124,7 @@ For real functionality, please configure valid Gitea and Claude API tokens.`;
branchName,
operationType,
fullPrompt,
giteaToken
githubToken
});
// Run the container
@@ -214,7 +215,7 @@ For real functionality, please configure valid Gitea and Claude API tokens.`;
containerName,
dockerArgs: sanitizedArgs,
dockerImageName,
giteaToken,
githubToken,
repoFullName,
issueNumber
});
@@ -251,7 +252,7 @@ function createPrompt({
command: string;
}): string {
if (operationType === 'auto-tagging') {
return `You are Claude, an AI assistant analyzing a Gitea issue for automatic label assignment.
return `You are Claude, an AI assistant analyzing a GitHub issue for automatic label assignment.
**Context:**
- Repository: ${repoFullName}
@@ -260,19 +261,19 @@ function createPrompt({
**Available Tools:**
- Read: Access repository files and issue content
- Gitea API: Use curl with GITEA_TOKEN to manage labels
- GitHub: Use 'gh' CLI for label operations only
**Task:**
Analyze the issue and apply appropriate labels using Gitea API. Use these categories:
Analyze the issue and apply appropriate labels using GitHub CLI commands. Use these categories:
- Priority: critical, high, medium, low
- Type: bug, feature, enhancement, documentation, question, security
- Complexity: trivial, simple, moderate, complex
- Component: api, frontend, backend, database, auth, webhook, docker
**Process:**
1. First list available labels via Gitea API
1. First run 'gh label list' to see available labels
2. Analyze the issue content
3. Use Gitea API to apply labels to the issue
3. Use 'gh issue edit #{issueNumber} --add-label "label1,label2,label3"' to apply labels
4. Do NOT comment on the issue - only apply labels
**User Request:**
@@ -280,7 +281,7 @@ ${command}
Complete the auto-tagging task using only the minimal required tools.`;
} else {
return `You are ${process.env.BOT_USERNAME}, an AI assistant responding to a Gitea ${isPullRequest ? 'pull request' : 'issue'}.
return `You are ${process.env.BOT_USERNAME}, an AI assistant responding to a GitHub ${isPullRequest ? 'pull request' : 'issue'}.
**Context:**
- Repository: ${repoFullName}
@@ -289,7 +290,7 @@ Complete the auto-tagging task using only the minimal required tools.`;
- Running in: Unattended mode
**Important Instructions:**
1. You have access to Gitea via the GITEA_TOKEN environment variable
1. You have full GitHub CLI access via the 'gh' command
2. When writing code:
- Always create a feature branch for new work
- Make commits with descriptive messages
@@ -299,17 +300,17 @@ Complete the auto-tagging task using only the minimal required tools.`;
- Create a pull request if appropriate
3. Iterate until the task is complete - don't stop at partial solutions
4. Always check in your work by pushing to the remote before finishing
5. Use Gitea API to post comments and provide updates on your progress
5. Use 'gh issue comment' or 'gh pr comment' to provide updates on your progress
6. If you encounter errors, debug and fix them before completing
7. **IMPORTANT - Markdown Formatting:**
- When your response contains markdown (like headers, lists, code blocks), return it as properly formatted markdown
- Do NOT escape or encode special characters like newlines (\\n) or quotes
- Return clean, human-readable markdown that Gitea will render correctly
- Return clean, human-readable markdown that GitHub will render correctly
- Your response should look like normal markdown text, not escaped strings
8. **Request Acknowledgment:**
- For larger or complex tasks that will take significant time, first acknowledge the request
- Post a brief comment like "I understand. Working on [task description]..." before starting
- Use Gitea API to post this acknowledgment immediately
- Use 'gh issue comment' or 'gh pr comment' to post this acknowledgment immediately
- This lets the user know their request was received and is being processed
**User Request:**
@@ -329,7 +330,7 @@ function createEnvironmentVars({
branchName,
operationType,
fullPrompt,
giteaToken
githubToken
}: {
repoFullName: string;
issueNumber: number | null;
@@ -337,7 +338,7 @@ function createEnvironmentVars({
branchName: string | null;
operationType: OperationType;
fullPrompt: string;
giteaToken: string;
githubToken: string;
}): ClaudeEnvironmentVars {
return {
REPO_FULL_NAME: repoFullName,
@@ -346,8 +347,7 @@ function createEnvironmentVars({
BRANCH_NAME: branchName ?? '',
OPERATION_TYPE: operationType,
COMMAND: fullPrompt,
GITEA_TOKEN: giteaToken,
GITEA_API_URL: process.env.GITEA_API_URL ?? '',
GITHUB_TOKEN: githubToken,
ANTHROPIC_API_KEY: secureCredentials.get('ANTHROPIC_API_KEY') ?? '',
BOT_USERNAME: process.env.BOT_USERNAME,
BOT_EMAIL: process.env.BOT_EMAIL
@@ -477,7 +477,7 @@ function sanitizeDockerArgs(dockerArgs: string[]): string[] {
if (envMatch) {
const envKey = envMatch[1];
const sensitiveKeys = [
'GITEA_TOKEN',
'GITHUB_TOKEN',
'ANTHROPIC_API_KEY',
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
@@ -504,7 +504,7 @@ function handleDockerExecutionError(
containerName: string;
dockerArgs: string[];
dockerImageName: string;
giteaToken: string;
githubToken: string;
repoFullName: string;
issueNumber: number | null;
}
@@ -518,7 +518,7 @@ function handleDockerExecutionError(
// Sensitive values to redact
const sensitiveValues = [
context.giteaToken,
context.githubToken,
secureCredentials.get('ANTHROPIC_API_KEY')
].filter(val => val && val.length > 0);
@@ -535,7 +535,9 @@ function handleDockerExecutionError(
const sensitivePatterns = [
/AKIA[0-9A-Z]{16}/g, // AWS Access Key pattern
/[a-zA-Z0-9/+=]{40}/g, // AWS Secret Key pattern
/sk-[a-zA-Z0-9]{32,}/g // API key pattern
/sk-[a-zA-Z0-9]{32,}/g, // API key pattern
/github_pat_[a-zA-Z0-9_]{82}/g, // GitHub fine-grained token pattern
/ghp_[a-zA-Z0-9]{36}/g // GitHub personal access token pattern
];
sensitivePatterns.forEach(pattern => {
@@ -650,11 +652,13 @@ function handleGeneralError(
/AWS_ACCESS_KEY_ID="[^"]+"/g,
/AWS_SECRET_ACCESS_KEY="[^"]+"/g,
/AWS_SESSION_TOKEN="[^"]+"/g,
/GITEA_TOKEN="[^"]+"/g,
/GITHUB_TOKEN="[^"]+"/g,
/ANTHROPIC_API_KEY="[^"]+"/g,
/AKIA[0-9A-Z]{16}/g, // AWS Access Key pattern
/[a-zA-Z0-9/+=]{40}/g, // AWS Secret Key pattern
/sk-[a-zA-Z0-9]{32,}/g // API key pattern
/sk-[a-zA-Z0-9]{32,}/g, // API key pattern
/github_pat_[a-zA-Z0-9_]{82}/g, // GitHub fine-grained token pattern
/ghp_[a-zA-Z0-9]{36}/g // GitHub personal access token pattern
];
sensitivePatterns.forEach(pattern => {

View File

@@ -0,0 +1,797 @@
import { Octokit } from '@octokit/rest';
import { createLogger } from '../utils/logger';
import secureCredentials from '../utils/secureCredentials';
import type {
CreateCommentRequest,
CreateCommentResponse,
AddLabelsRequest,
ManagePRLabelsRequest,
CreateRepositoryLabelsRequest,
GetCombinedStatusRequest,
HasReviewedPRRequest,
GetCheckSuitesRequest,
ValidatedGitHubParams,
GitHubCombinedStatus,
GitHubLabel,
GitHubCheckSuitesResponse
} from '../types/github';
const logger = createLogger('githubService');
// Create Octokit instance (lazy initialization)
let octokit: Octokit | null = null;
function getOctokit(): Octokit | null {
if (!octokit) {
const githubToken = secureCredentials.get('GITHUB_TOKEN');
// Support both classic (ghp_) and fine-grained (github_pat_) GitHub tokens
if (githubToken && (githubToken.includes('ghp_') || githubToken.includes('github_pat_'))) {
octokit = new Octokit({
auth: githubToken,
userAgent: 'Claude-GitHub-Webhook'
});
}
}
return octokit;
}
/**
* Posts a comment to a GitHub issue or pull request
*/
export async function postComment({
repoOwner,
repoName,
issueNumber,
body
}: CreateCommentRequest): Promise<CreateCommentResponse> {
try {
// Validate parameters to prevent SSRF
const validated = validateGitHubParams(repoOwner, repoName, issueNumber);
logger.info(
{
repo: `${repoOwner}/${repoName}`,
issue: issueNumber,
bodyLength: body.length
},
'Posting comment to GitHub'
);
// In test mode, just log the comment instead of posting to GitHub
const client = getOctokit();
if (process.env.NODE_ENV === 'test' || !client) {
logger.info(
{
repo: `${repoOwner}/${repoName}`,
issue: issueNumber,
bodyPreview: body.substring(0, 100) + (body.length > 100 ? '...' : '')
},
'TEST MODE: Would post comment to GitHub'
);
return {
id: 'test-comment-id',
body: body,
created_at: new Date().toISOString()
};
}
// Use Octokit to create comment
const { data } = await client.issues.createComment({
owner: validated.repoOwner,
repo: validated.repoName,
issue_number: validated.issueNumber,
body: body
});
logger.info(
{
repo: `${repoOwner}/${repoName}`,
issue: issueNumber,
commentId: data.id
},
'Comment posted successfully'
);
return {
id: data.id,
body: data.body ?? '',
created_at: data.created_at
};
} catch (error) {
const err = error as Error & { response?: { data?: unknown } };
logger.error(
{
err: {
message: err.message,
responseData: err.response?.data
},
repo: `${repoOwner}/${repoName}`,
issue: issueNumber
},
'Error posting comment to GitHub'
);
throw new Error(`Failed to post comment: ${err.message}`);
}
}
/**
* Validates GitHub repository and issue parameters to prevent SSRF
*/
function validateGitHubParams(
repoOwner: string,
repoName: string,
issueNumber: number
): ValidatedGitHubParams {
// Validate repoOwner and repoName contain only safe characters
const repoPattern = /^[a-zA-Z0-9._-]+$/;
if (!repoPattern.test(repoOwner) || !repoPattern.test(repoName)) {
throw new Error('Invalid repository owner or name - contains unsafe characters');
}
// Validate issueNumber is a positive integer
const issueNum = parseInt(String(issueNumber), 10);
if (!Number.isInteger(issueNum) || issueNum <= 0) {
throw new Error('Invalid issue number - must be a positive integer');
}
return { repoOwner, repoName, issueNumber: issueNum };
}
/**
* Adds labels to a GitHub issue
*/
export async function addLabelsToIssue({
repoOwner,
repoName,
issueNumber,
labels
}: AddLabelsRequest): Promise<GitHubLabel[]> {
try {
// Validate parameters to prevent SSRF
const validated = validateGitHubParams(repoOwner, repoName, issueNumber);
logger.info(
{
repo: `${repoOwner}/${repoName}`,
issue: issueNumber,
labelCount: labels.length
},
'Adding labels to GitHub issue'
);
// In test mode, just log the labels instead of applying to GitHub
const client = getOctokit();
if (process.env.NODE_ENV === 'test' || !client) {
logger.info(
{
repo: `${repoOwner}/${repoName}`,
issue: issueNumber,
labelCount: labels.length
},
'TEST MODE: Would add labels to GitHub issue'
);
return labels.map((label, index) => ({
id: index,
name: label,
color: '000000',
description: null
}));
}
// Use Octokit to add labels
const { data } = await client.issues.addLabels({
owner: validated.repoOwner,
repo: validated.repoName,
issue_number: validated.issueNumber,
labels: labels
});
logger.info(
{
repo: `${repoOwner}/${repoName}`,
issue: issueNumber,
appliedLabels: data.map(label => label.name)
},
'Labels added successfully'
);
return data;
} catch (error) {
const err = error as Error & { response?: { data?: unknown } };
logger.error(
{
err: {
message: err.message,
responseData: err.response?.data
},
repo: `${repoOwner}/${repoName}`,
issue: issueNumber,
labelCount: labels.length
},
'Error adding labels to GitHub issue'
);
throw new Error(`Failed to add labels: ${err.message}`);
}
}
/**
* Creates repository labels if they don't exist
*/
export async function createRepositoryLabels({
repoOwner,
repoName,
labels
}: CreateRepositoryLabelsRequest): Promise<GitHubLabel[]> {
try {
// Validate repository parameters to prevent SSRF
const repoPattern = /^[a-zA-Z0-9._-]+$/;
if (!repoPattern.test(repoOwner) || !repoPattern.test(repoName)) {
throw new Error('Invalid repository owner or name - contains unsafe characters');
}
logger.info(
{
repo: `${repoOwner}/${repoName}`,
labelCount: labels.length
},
'Creating repository labels'
);
// In test mode, just log the operation
const client = getOctokit();
if (process.env.NODE_ENV === 'test' || !client) {
logger.info(
{
repo: `${repoOwner}/${repoName}`,
labels: labels
},
'TEST MODE: Would create repository labels'
);
return labels.map((label, index) => ({
id: index,
name: label.name,
color: label.color,
description: label.description ?? null
}));
}
const createdLabels: GitHubLabel[] = [];
for (const label of labels) {
try {
// Use Octokit to create label
const { data } = await client.issues.createLabel({
owner: repoOwner,
repo: repoName,
name: label.name,
color: label.color,
description: label.description
});
createdLabels.push(data);
logger.debug({ labelName: label.name }, 'Label created successfully');
} catch (error) {
const err = error as Error & { status?: number };
// Label might already exist - check if it's a 422 (Unprocessable Entity)
if (err.status === 422) {
logger.debug({ labelName: label.name }, 'Label already exists, skipping');
} else {
logger.warn(
{
err: err.message,
labelName: label.name
},
'Failed to create label'
);
}
}
}
return createdLabels;
} catch (error) {
const err = error as Error;
logger.error(
{
err: err.message,
repo: `${repoOwner}/${repoName}`
},
'Error creating repository labels'
);
throw new Error(`Failed to create labels: ${err.message}`);
}
}
/**
* Gets pull request details from GitHub
*/
export async function getPullRequestDetails({
repoOwner,
repoName,
prNumber
}: {
repoOwner: string;
repoName: string;
prNumber: number;
}): Promise<{ head: { ref: string; sha: string }; base: { ref: string } } | null> {
try {
// Validate parameters
const repoPattern = /^[a-zA-Z0-9._-]+$/;
if (!repoPattern.test(repoOwner) || !repoPattern.test(repoName)) {
throw new Error('Invalid repository owner or name');
}
logger.info(
{
repo: `${repoOwner}/${repoName}`,
pr: prNumber
},
'Fetching pull request details from GitHub'
);
const client = getOctokit();
if (process.env.NODE_ENV === 'test' || !client) {
logger.info('TEST MODE: Would fetch PR details from GitHub');
return {
head: { ref: 'feature-branch', sha: 'abc123' },
base: { ref: 'main' }
};
}
const { data } = await client.pulls.get({
owner: repoOwner,
repo: repoName,
pull_number: prNumber
});
logger.info(
{
repo: `${repoOwner}/${repoName}`,
pr: prNumber,
headRef: data.head.ref,
baseRef: data.base.ref
},
'Pull request details fetched successfully'
);
return {
head: {
ref: data.head.ref,
sha: data.head.sha
},
base: {
ref: data.base.ref
}
};
} catch (error) {
const err = error as Error;
logger.error(
{
err: err,
repo: `${repoOwner}/${repoName}`,
pr: prNumber
},
'Error fetching pull request details'
);
return null;
}
}
/**
* Provides fallback labels based on simple keyword matching
*/
export function getFallbackLabels(title: string, body: string | null): string[] {
const content = `${title} ${body ?? ''}`.toLowerCase();
const labels: string[] = [];
// Type detection - check documentation first for specificity
if (
content.includes(' doc ') ||
content.includes('docs') ||
content.includes('readme') ||
content.includes('documentation')
) {
labels.push('type:documentation');
} else if (
content.includes('bug') ||
content.includes('error') ||
content.includes('issue') ||
content.includes('problem')
) {
labels.push('type:bug');
} else if (content.includes('feature') || content.includes('add') || content.includes('new')) {
labels.push('type:feature');
} else if (
content.includes('improve') ||
content.includes('enhance') ||
content.includes('better')
) {
labels.push('type:enhancement');
} else if (content.includes('question') || content.includes('help') || content.includes('how')) {
labels.push('type:question');
}
// Priority detection
if (
content.includes('critical') ||
content.includes('urgent') ||
content.includes('security') ||
content.includes('down')
) {
labels.push('priority:critical');
} else if (content.includes('important') || content.includes('high')) {
labels.push('priority:high');
} else {
labels.push('priority:medium');
}
// Component detection
if (content.includes('api') || content.includes('endpoint')) {
labels.push('component:api');
} else if (
content.includes('ui') ||
content.includes('frontend') ||
content.includes('interface')
) {
labels.push('component:frontend');
} else if (content.includes('backend') || content.includes('server')) {
labels.push('component:backend');
} else if (content.includes('database') || content.includes('db')) {
labels.push('component:database');
} else if (
content.includes('auth') ||
content.includes('login') ||
content.includes('permission')
) {
labels.push('component:auth');
} else if (content.includes('webhook') || content.includes('github')) {
labels.push('component:webhook');
} else if (content.includes('docker') || content.includes('container')) {
labels.push('component:docker');
}
return labels;
}
/**
* Gets the combined status for a specific commit/ref
* Used to verify all required status checks have passed
*/
export async function getCombinedStatus({
repoOwner,
repoName,
ref
}: GetCombinedStatusRequest): Promise<GitHubCombinedStatus> {
try {
// Validate parameters to prevent SSRF
const repoPattern = /^[a-zA-Z0-9._-]+$/;
if (!repoPattern.test(repoOwner) || !repoPattern.test(repoName)) {
throw new Error('Invalid repository owner or name - contains unsafe characters');
}
// Validate ref (commit SHA, branch, or tag)
const refPattern = /^[a-zA-Z0-9._/-]+$/;
if (!refPattern.test(ref)) {
throw new Error('Invalid ref - contains unsafe characters');
}
logger.info(
{
repo: `${repoOwner}/${repoName}`,
ref: ref
},
'Getting combined status from GitHub'
);
// In test mode, return a mock successful status
const client = getOctokit();
if (process.env.NODE_ENV === 'test' || !client) {
logger.info(
{
repo: `${repoOwner}/${repoName}`,
ref: ref
},
'TEST MODE: Returning mock successful combined status'
);
return {
state: 'success',
total_count: 2,
statuses: [
{ state: 'success', context: 'ci/test', description: null, target_url: null },
{ state: 'success', context: 'ci/build', description: null, target_url: null }
]
};
}
// Use Octokit to get combined status
const { data } = await client.repos.getCombinedStatusForRef({
owner: repoOwner,
repo: repoName,
ref: ref
});
logger.info(
{
repo: `${repoOwner}/${repoName}`,
ref: ref,
state: data.state,
totalCount: data.total_count
},
'Combined status retrieved successfully'
);
return data;
} catch (error) {
const err = error as Error & { response?: { status?: number; data?: unknown } };
logger.error(
{
err: {
message: err.message,
status: err.response?.status,
responseData: err.response?.data
},
repo: `${repoOwner}/${repoName}`,
ref: ref
},
'Error getting combined status from GitHub'
);
throw new Error(`Failed to get combined status: ${err.message}`);
}
}
/**
* Check if we've already reviewed this PR at the given commit SHA
*/
export async function hasReviewedPRAtCommit({
repoOwner,
repoName,
prNumber,
commitSha
}: HasReviewedPRRequest): Promise<boolean> {
try {
// Validate parameters
const repoPattern = /^[a-zA-Z0-9._-]+$/;
if (!repoPattern.test(repoOwner) || !repoPattern.test(repoName)) {
throw new Error('Invalid repository owner or name - contains unsafe characters');
}
logger.info(
{
repo: `${repoOwner}/${repoName}`,
pr: prNumber,
commitSha: commitSha
},
'Checking if PR has been reviewed at commit'
);
// In test mode, return false to allow review
const client = getOctokit();
if (process.env.NODE_ENV === 'test' || !client) {
return false;
}
// Get review comments for this PR using Octokit
const { data: reviews } = await client.pulls.listReviews({
owner: repoOwner,
repo: repoName,
pull_number: prNumber
});
// Check if any review mentions this specific commit SHA
const botUsername = process.env.BOT_USERNAME ?? 'ClaudeBot';
const existingReview = reviews.find(review => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return review.user?.login === botUsername && review.body?.includes(`commit: ${commitSha}`);
});
return !!existingReview;
} catch (error) {
const err = error as Error;
logger.error(
{
err: err.message,
repo: `${repoOwner}/${repoName}`,
pr: prNumber
},
'Failed to check for existing reviews'
);
// On error, assume not reviewed to avoid blocking reviews
return false;
}
}
/**
* Gets check suites for a specific commit
*/
export async function getCheckSuitesForRef({
repoOwner,
repoName,
ref
}: GetCheckSuitesRequest): Promise<GitHubCheckSuitesResponse> {
try {
// Validate parameters to prevent SSRF
const repoPattern = /^[a-zA-Z0-9._-]+$/;
if (!repoPattern.test(repoOwner) || !repoPattern.test(repoName)) {
throw new Error('Invalid repository owner or name - contains unsafe characters');
}
// Validate ref (commit SHA, branch, or tag)
const refPattern = /^[a-zA-Z0-9._/-]+$/;
if (!refPattern.test(ref)) {
throw new Error('Invalid ref - contains unsafe characters');
}
logger.info(
{
repo: `${repoOwner}/${repoName}`,
ref
},
'Getting check suites for ref'
);
// In test mode, return mock data
const client = getOctokit();
if (process.env.NODE_ENV === 'test' || !client) {
return {
total_count: 1,
check_suites: [
{
id: 12345,
head_branch: 'main',
head_sha: ref,
status: 'completed',
conclusion: 'success',
app: { id: 1, slug: 'github-actions', name: 'GitHub Actions' },
pull_requests: [],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
latest_check_runs_count: 1
}
]
};
}
// Use Octokit's built-in method
const { data } = await client.checks.listSuitesForRef({
owner: repoOwner,
repo: repoName,
ref: ref
});
// Transform the response to match our interface
const transformedResponse: GitHubCheckSuitesResponse = {
total_count: data.total_count,
check_suites: data.check_suites.map(suite => ({
id: suite.id,
head_branch: suite.head_branch,
head_sha: suite.head_sha,
status: suite.status,
conclusion: suite.conclusion,
app: suite.app
? {
id: suite.app.id,
slug: suite.app.slug,
name: suite.app.name
}
: null,
pull_requests: null, // Simplified for our use case
created_at: suite.created_at,
updated_at: suite.updated_at,
latest_check_runs_count: suite.latest_check_runs_count
}))
};
return transformedResponse;
} catch (error) {
const err = error as Error;
logger.error(
{
err: err.message,
repo: `${repoOwner}/${repoName}`,
ref
},
'Failed to get check suites'
);
throw error;
}
}
/**
* Add or remove labels on a pull request
*/
export async function managePRLabels({
repoOwner,
repoName,
prNumber,
labelsToAdd = [],
labelsToRemove = []
}: ManagePRLabelsRequest): Promise<void> {
try {
// Validate parameters
const repoPattern = /^[a-zA-Z0-9._-]+$/;
if (!repoPattern.test(repoOwner) || !repoPattern.test(repoName)) {
throw new Error('Invalid repository owner or name - contains unsafe characters');
}
// In test mode, just log
const client = getOctokit();
if (process.env.NODE_ENV === 'test' || !client) {
logger.info(
{
repo: `${repoOwner}/${repoName}`,
pr: prNumber,
labelsToAdd,
labelsToRemove
},
'TEST MODE: Would manage PR labels'
);
return;
}
// Remove labels first using Octokit
for (const label of labelsToRemove) {
try {
await client.issues.removeLabel({
owner: repoOwner,
repo: repoName,
issue_number: prNumber,
name: label
});
logger.info(
{
repo: `${repoOwner}/${repoName}`,
pr: prNumber,
label
},
'Removed label from PR'
);
} catch (error) {
const err = error as Error & { status?: number };
// Ignore 404 errors (label not present)
if (err.status !== 404) {
logger.error(
{
err: err.message,
label
},
'Failed to remove label'
);
}
}
}
// Add new labels using Octokit
if (labelsToAdd.length > 0) {
await client.issues.addLabels({
owner: repoOwner,
repo: repoName,
issue_number: prNumber,
labels: labelsToAdd
});
logger.info(
{
repo: `${repoOwner}/${repoName}`,
pr: prNumber,
labels: labelsToAdd
},
'Added labels to PR'
);
}
} catch (error) {
const err = error as Error;
logger.error(
{
err: err.message,
repo: `${repoOwner}/${repoName}`,
pr: prNumber
},
'Failed to manage PR labels'
);
throw error;
}
}

View File

@@ -1,7 +1,7 @@
// TypeScript type definitions for the claude-hub webhook project
// TypeScript type definitions for the claude-github-webhook project
// This file establishes the TypeScript infrastructure
export interface WebhookPayload {
export interface GitHubWebhookPayload {
action?: string;
issue?: {
number: number;

View File

@@ -39,8 +39,7 @@ export interface ClaudeEnvironmentVars {
BRANCH_NAME: string;
OPERATION_TYPE: string;
COMMAND: string;
GITEA_TOKEN: string;
GITEA_API_URL: string;
GITHUB_TOKEN: string;
ANTHROPIC_API_KEY: string;
BOT_USERNAME?: string;
BOT_EMAIL?: string;

View File

@@ -3,8 +3,8 @@ export interface EnvironmentConfig {
// Required environment variables
BOT_USERNAME: string;
BOT_EMAIL: string;
GITEA_WEBHOOK_SECRET: string;
GITEA_TOKEN: string;
GITHUB_WEBHOOK_SECRET: string;
GITHUB_TOKEN: string;
ANTHROPIC_API_KEY: string;
// Optional environment variables with defaults
@@ -48,9 +48,9 @@ export interface ApplicationConfig {
botEmail: string;
authorizedUsers: string[];
// Gitea configuration
giteaWebhookSecret: string;
giteaToken: string;
// GitHub configuration
githubWebhookSecret: string;
githubToken: string;
skipWebhookVerification: boolean;
// Claude configuration

View File

@@ -1,27 +1,12 @@
import type { Request, Response, NextFunction } from 'express';
import type { GitHubWebhookPayload } from './github';
import type { StartupMetrics } from './metrics';
// Generic webhook payload (provider-agnostic)
export interface WebhookPayload {
action?: string;
repository?: {
full_name: string;
name: string;
owner: {
login: string;
};
};
sender?: {
login: string;
};
[key: string]: unknown;
}
// Extended Express Request with custom properties
export interface WebhookRequest extends Request {
rawBody?: Buffer;
startupMetrics?: StartupMetrics;
body: WebhookPayload;
body: GitHubWebhookPayload;
}
export interface ClaudeAPIRequest extends Request {
@@ -116,11 +101,9 @@ export interface RequestLogData {
}
export interface WebhookHeaders {
// Gitea headers
'x-gitea-event'?: string;
'x-gitea-delivery'?: string;
'x-gitea-signature'?: string;
// Generic headers
'x-github-event'?: string;
'x-github-delivery'?: string;
'x-hub-signature-256'?: string;
'user-agent'?: string;
'content-type'?: string;
}

229
src/types/github.ts Normal file
View File

@@ -0,0 +1,229 @@
export interface GitHubWebhookPayload {
action?: string;
issue?: GitHubIssue;
pull_request?: GitHubPullRequest;
comment?: GitHubComment;
check_suite?: GitHubCheckSuite;
repository: GitHubRepository;
sender: GitHubUser;
installation?: {
id: number;
account: GitHubUser;
};
}
export interface GitHubIssue {
id: number;
number: number;
title: string;
body: string | null;
state: 'open' | 'closed';
user: GitHubUser;
labels: GitHubLabel[];
created_at: string;
updated_at: string;
html_url: string;
pull_request?: {
head?: {
ref: string;
sha: string;
};
base?: {
ref: string;
};
};
}
export interface GitHubPullRequest {
id: number;
number: number;
title: string;
body: string | null;
state: 'open' | 'closed' | 'merged';
user: GitHubUser;
head: GitHubPullRequestHead;
base: GitHubPullRequestBase;
labels: GitHubLabel[];
created_at: string;
updated_at: string;
html_url: string;
merged: boolean;
mergeable: boolean | null;
draft: boolean;
merged_at: string | null;
}
export interface GitHubPullRequestHead {
ref: string;
sha: string;
repo: GitHubRepository | null;
}
export interface GitHubPullRequestBase {
ref: string;
sha: string;
repo: GitHubRepository;
}
export interface GitHubComment {
id: number;
body: string;
user: GitHubUser;
created_at: string;
updated_at: string;
html_url: string;
}
export interface GitHubCheckSuite {
id: number;
head_branch: string | null;
head_sha: string;
status: 'queued' | 'in_progress' | 'completed' | 'pending' | 'waiting' | 'requested' | null;
conclusion:
| 'success'
| 'failure'
| 'neutral'
| 'cancelled'
| 'skipped'
| 'timed_out'
| 'action_required'
| 'startup_failure'
| 'stale'
| null;
app: GitHubApp | null;
pull_requests: GitHubPullRequest[] | null;
created_at: string | null;
updated_at: string | null;
latest_check_runs_count: number;
[key: string]: unknown;
}
export interface GitHubApp {
id: number;
slug?: string;
name: string;
[key: string]: unknown;
}
export interface GitHubRepository {
id: number;
name: string;
full_name: string;
owner: GitHubUser;
private: boolean;
html_url: string;
default_branch: string;
}
export interface GitHubUser {
id: number;
login: string;
type: 'User' | 'Bot' | 'Organization';
html_url: string;
email?: string;
name?: string;
}
export interface GitHubLabel {
id: number;
name: string;
color: string;
description: string | null;
}
export interface GitHubCombinedStatus {
state: string;
total_count: number;
statuses: GitHubStatus[];
[key: string]: unknown;
}
export interface GitHubStatus {
state: string;
context: string;
description: string | null;
target_url: string | null;
[key: string]: unknown;
}
export interface GitHubCheckSuitesResponse {
total_count: number;
check_suites: GitHubCheckSuite[];
}
export interface GitHubReview {
id: number;
user: GitHubUser;
body: string | null;
state: 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' | 'DISMISSED' | 'PENDING';
html_url: string;
commit_id: string;
submitted_at: string | null;
}
// API Request/Response Types
export interface CreateCommentRequest {
repoOwner: string;
repoName: string;
issueNumber: number;
body: string;
}
export interface CreateCommentResponse {
id: number | string;
body: string;
created_at: string;
}
export interface AddLabelsRequest {
repoOwner: string;
repoName: string;
issueNumber: number;
labels: string[];
}
export interface ManagePRLabelsRequest {
repoOwner: string;
repoName: string;
prNumber: number;
labelsToAdd?: string[];
labelsToRemove?: string[];
}
export interface CreateLabelRequest {
name: string;
color: string;
description?: string;
}
export interface CreateRepositoryLabelsRequest {
repoOwner: string;
repoName: string;
labels: CreateLabelRequest[];
}
export interface GetCombinedStatusRequest {
repoOwner: string;
repoName: string;
ref: string;
}
export interface HasReviewedPRRequest {
repoOwner: string;
repoName: string;
prNumber: number;
commitSha: string;
}
export interface GetCheckSuitesRequest {
repoOwner: string;
repoName: string;
ref: string;
}
// Validation Types
export interface ValidatedGitHubParams {
repoOwner: string;
repoName: string;
issueNumber: number;
}

View File

@@ -1,4 +1,5 @@
// Central export file for all types
export * from './github';
export * from './claude';
export * from './aws';
export * from './express';
@@ -32,13 +33,14 @@ export interface ApiError {
}
// Import types for type guards and aliases
import type { GitHubWebhookPayload } from './github';
import type { ClaudeCommandOptions } from './claude';
import type { AWSCredentials } from './aws';
import type { ApplicationConfig } from './config';
import type { PerformanceMetrics } from './metrics';
// Type guards for runtime type checking
export function isWebhookPayload(obj: unknown): obj is { repository: unknown; sender: unknown } {
export function isWebhookPayload(obj: unknown): obj is GitHubWebhookPayload {
return typeof obj === 'object' && obj !== null && 'repository' in obj && 'sender' in obj;
}
@@ -53,6 +55,7 @@ export function isAWSCredentials(obj: unknown): obj is AWSCredentials {
}
// Common type aliases for convenience
export type WebhookPayload = GitHubWebhookPayload;
export type ClaudeOptions = ClaudeCommandOptions;
export type AWSCreds = AWSCredentials;
export type AppConfig = ApplicationConfig;

View File

@@ -68,7 +68,7 @@ export interface ClaudeExecutionMetrics {
operationTypes: Record<string, number>;
}
export interface GiteaAPIMetrics {
export interface GitHubAPIMetrics {
totalRequests: number;
rateLimitRemaining: number;
rateLimitResetTime: number;
@@ -98,7 +98,7 @@ export interface DetailedHealthCheck extends HealthStatus {
components: ComponentHealth[];
metrics: PerformanceMetrics;
dependencies: {
gitea: ComponentHealth;
github: ComponentHealth;
claude: ComponentHealth;
docker: ComponentHealth;
database?: ComponentHealth;
@@ -159,7 +159,7 @@ export interface MetricsSnapshot {
timestamp: string;
performance: PerformanceMetrics;
claude: ClaudeExecutionMetrics;
gitea: GiteaAPIMetrics;
github: GitHubAPIMetrics;
docker: DockerMetrics;
timeSeries: TimeSeries[];
}

View File

@@ -121,11 +121,9 @@ const logger = pino({
'AWS_SESSION_TOKEN',
'AWS_SECURITY_TOKEN',
'GITHUB_TOKEN',
'GITEA_TOKEN',
'GH_TOKEN',
'ANTHROPIC_API_KEY',
'GITHUB_WEBHOOK_SECRET',
'GITEA_WEBHOOK_SECRET',
'WEBHOOK_SECRET',
'BOT_TOKEN',
'API_KEY',
@@ -143,11 +141,9 @@ const logger = pino({
'*.AWS_SESSION_TOKEN',
'*.AWS_SECURITY_TOKEN',
'*.GITHUB_TOKEN',
'*.GITEA_TOKEN',
'*.GH_TOKEN',
'*.ANTHROPIC_API_KEY',
'*.GITHUB_WEBHOOK_SECRET',
'*.GITEA_WEBHOOK_SECRET',
'*.WEBHOOK_SECRET',
'*.BOT_TOKEN',
'*.API_KEY',
@@ -173,11 +169,9 @@ const logger = pino({
'envVars.AWS_SESSION_TOKEN',
'envVars.AWS_SECURITY_TOKEN',
'envVars.GITHUB_TOKEN',
'envVars.GITEA_TOKEN',
'envVars.GH_TOKEN',
'envVars.ANTHROPIC_API_KEY',
'envVars.GITHUB_WEBHOOK_SECRET',
'envVars.GITEA_WEBHOOK_SECRET',
'envVars.WEBHOOK_SECRET',
'envVars.BOT_TOKEN',
'envVars.API_KEY',
@@ -194,11 +188,9 @@ const logger = pino({
'env.AWS_SESSION_TOKEN',
'env.AWS_SECURITY_TOKEN',
'env.GITHUB_TOKEN',
'env.GITEA_TOKEN',
'env.GH_TOKEN',
'env.ANTHROPIC_API_KEY',
'env.GITHUB_WEBHOOK_SECRET',
'env.GITEA_WEBHOOK_SECRET',
'env.WEBHOOK_SECRET',
'env.BOT_TOKEN',
'env.API_KEY',
@@ -216,11 +208,9 @@ const logger = pino({
'process["env"]["AWS_SESSION_TOKEN"]',
'process["env"]["AWS_SECURITY_TOKEN"]',
'process["env"]["GITHUB_TOKEN"]',
'process["env"]["GITEA_TOKEN"]',
'process["env"]["GH_TOKEN"]',
'process["env"]["ANTHROPIC_API_KEY"]',
'process["env"]["GITHUB_WEBHOOK_SECRET"]',
'process["env"]["GITEA_WEBHOOK_SECRET"]',
'process["env"]["WEBHOOK_SECRET"]',
'process["env"]["BOT_TOKEN"]',
'process["env"]["API_KEY"]',
@@ -238,11 +228,9 @@ const logger = pino({
'["process.env.AWS_SESSION_TOKEN"]',
'["process.env.AWS_SECURITY_TOKEN"]',
'["process.env.GITHUB_TOKEN"]',
'["process.env.GITEA_TOKEN"]',
'["process.env.GH_TOKEN"]',
'["process.env.ANTHROPIC_API_KEY"]',
'["process.env.GITHUB_WEBHOOK_SECRET"]',
'["process.env.GITEA_WEBHOOK_SECRET"]',
'["process.env.WEBHOOK_SECRET"]',
'["process.env.BOT_TOKEN"]',
'["process.env.API_KEY"]',
@@ -341,7 +329,6 @@ const logger = pino({
'*.*.AWS_SECRET_ACCESS_KEY',
'*.*.AWS_ACCESS_KEY_ID',
'*.*.GITHUB_TOKEN',
'*.*.GITEA_TOKEN',
'*.*.ANTHROPIC_API_KEY',
'*.*.connectionString',
'*.*.DATABASE_URL',
@@ -358,7 +345,6 @@ const logger = pino({
'*.*.*.AWS_SECRET_ACCESS_KEY',
'*.*.*.AWS_ACCESS_KEY_ID',
'*.*.*.GITHUB_TOKEN',
'*.*.*.GITEA_TOKEN',
'*.*.*.ANTHROPIC_API_KEY',
'*.*.*.connectionString',
'*.*.*.DATABASE_URL',
@@ -375,7 +361,6 @@ const logger = pino({
'*.*.*.*.AWS_SECRET_ACCESS_KEY',
'*.*.*.*.AWS_ACCESS_KEY_ID',
'*.*.*.*.GITHUB_TOKEN',
'*.*.*.*.GITEA_TOKEN',
'*.*.*.*.ANTHROPIC_API_KEY',
'*.*.*.*.connectionString',
'*.*.*.*.DATABASE_URL'

View File

@@ -91,7 +91,6 @@ export function sanitizeEnvironmentValue(key: string, value: string): string {
'PASSWORD',
'CREDENTIAL',
'GITHUB_TOKEN',
'GITEA_TOKEN',
'ANTHROPIC_API_KEY',
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',

View File

@@ -27,17 +27,17 @@ class SecureCredentials {
*/
private loadCredentials(): void {
const credentialMappings: CredentialMappings = {
GITEA_TOKEN: {
file: process.env['GITEA_TOKEN_FILE'] ?? '/run/secrets/gitea_token',
env: 'GITEA_TOKEN'
GITHUB_TOKEN: {
file: process.env['GITHUB_TOKEN_FILE'] ?? '/run/secrets/github_token',
env: 'GITHUB_TOKEN'
},
ANTHROPIC_API_KEY: {
file: process.env['ANTHROPIC_API_KEY_FILE'] ?? '/run/secrets/anthropic_api_key',
env: 'ANTHROPIC_API_KEY'
},
GITEA_WEBHOOK_SECRET: {
file: process.env['GITEA_WEBHOOK_SECRET_FILE'] ?? '/run/secrets/gitea_webhook_secret',
env: 'GITEA_WEBHOOK_SECRET'
GITHUB_WEBHOOK_SECRET: {
file: process.env['GITHUB_WEBHOOK_SECRET_FILE'] ?? '/run/secrets/webhook_secret',
env: 'GITHUB_WEBHOOK_SECRET'
},
CLAUDE_WEBHOOK_SECRET: {
file: process.env['CLAUDE_WEBHOOK_SECRET_FILE'] ?? '/run/secrets/claude_webhook_secret',

View File

@@ -0,0 +1,377 @@
import crypto from 'crypto';
import type { Request } from 'express';
import { GitHubWebhookProvider } from '../../../../src/providers/github/GitHubWebhookProvider';
import type {
GitHubRepository,
GitHubUser,
GitHubIssue,
GitHubPullRequest
} from '../../../../src/types/github';
// Mock the logger
jest.mock('../../../../src/utils/logger', () => ({
createLogger: () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn()
})
}));
describe('GitHubWebhookProvider', () => {
let provider: GitHubWebhookProvider;
let mockReq: Partial<Request>;
beforeEach(() => {
provider = new GitHubWebhookProvider();
mockReq = {
headers: {},
body: {},
rawBody: ''
};
});
describe('verifySignature', () => {
it('should verify valid signature', async () => {
const secret = 'test-secret';
const payload = '{"test":"data"}';
const hmac = crypto.createHmac('sha256', secret);
const signature = 'sha256=' + hmac.update(payload).digest('hex');
mockReq.headers = { 'x-hub-signature-256': signature };
mockReq.rawBody = payload;
const result = await provider.verifySignature(mockReq as Request, secret);
expect(result).toBe(true);
});
it('should reject invalid signature', async () => {
mockReq.headers = { 'x-hub-signature-256': 'sha256=invalid' };
mockReq.rawBody = '{"test":"data"}';
const result = await provider.verifySignature(mockReq as Request, 'test-secret');
expect(result).toBe(false);
});
it('should reject missing signature', async () => {
mockReq.headers = {};
mockReq.rawBody = '{"test":"data"}';
const result = await provider.verifySignature(mockReq as Request, 'test-secret');
expect(result).toBe(false);
});
it('should handle missing rawBody', async () => {
const secret = 'test-secret';
const payload = { test: 'data' };
const payloadString = JSON.stringify(payload);
const hmac = crypto.createHmac('sha256', secret);
const signature = 'sha256=' + hmac.update(payloadString).digest('hex');
mockReq.headers = { 'x-hub-signature-256': signature };
mockReq.body = payload;
mockReq.rawBody = undefined;
const result = await provider.verifySignature(mockReq as Request, secret);
expect(result).toBe(true);
});
it('should handle signature verification errors', async () => {
mockReq.headers = { 'x-hub-signature-256': 'invalid-format' };
mockReq.rawBody = '{"test":"data"}';
const result = await provider.verifySignature(mockReq as Request, 'test-secret');
expect(result).toBe(false);
});
});
describe('parsePayload', () => {
it('should parse GitHub webhook payload', async () => {
const mockGitHubPayload = {
action: 'opened',
repository: { full_name: 'owner/repo' } as GitHubRepository,
sender: { login: 'user123' } as GitHubUser,
installation: {
id: 12345,
account: { login: 'org' } as GitHubUser
}
};
mockReq.headers = {
'x-github-event': 'issues',
'x-github-delivery': 'abc-123'
};
mockReq.body = mockGitHubPayload;
const result = await provider.parsePayload(mockReq as Request);
expect(result).toMatchObject({
id: 'abc-123',
event: 'issues.opened',
source: 'github',
githubEvent: 'issues',
githubDelivery: 'abc-123',
action: 'opened',
repository: mockGitHubPayload.repository,
sender: mockGitHubPayload.sender,
installation: mockGitHubPayload.installation,
data: mockGitHubPayload
});
expect(result.timestamp).toBeDefined();
});
it('should handle missing delivery ID', async () => {
mockReq.headers = {
'x-github-event': 'push'
};
mockReq.body = {};
const result = await provider.parsePayload(mockReq as Request);
expect(result.id).toBeDefined();
expect(result.id).not.toBe('');
expect(result.event).toBe('push');
});
it('should handle events without action', async () => {
mockReq.headers = {
'x-github-event': 'push',
'x-github-delivery': 'xyz-456'
};
mockReq.body = {
repository: { full_name: 'owner/repo' } as GitHubRepository
};
const result = await provider.parsePayload(mockReq as Request);
expect(result.event).toBe('push');
expect(result.action).toBeUndefined();
});
});
describe('getEventType', () => {
it('should return the event type', () => {
const payload = {
id: '123',
timestamp: '2024-01-01T00:00:00Z',
event: 'issues.opened',
source: 'github',
githubEvent: 'issues',
githubDelivery: 'abc-123',
data: {}
};
const result = provider.getEventType(payload);
expect(result).toBe('issues.opened');
});
});
describe('getEventDescription', () => {
it('should generate description with all parts', () => {
const payload = {
id: '123',
timestamp: '2024-01-01T00:00:00Z',
event: 'issues.opened',
source: 'github',
githubEvent: 'issues',
githubDelivery: 'abc-123',
action: 'opened',
repository: { full_name: 'owner/repo' } as GitHubRepository,
sender: { login: 'user123' } as GitHubUser,
data: {}
};
const result = provider.getEventDescription(payload);
expect(result).toBe('issues opened in owner/repo by user123');
});
it('should handle missing optional parts', () => {
const payload = {
id: '123',
timestamp: '2024-01-01T00:00:00Z',
event: 'ping',
source: 'github',
githubEvent: 'ping',
githubDelivery: 'abc-123',
data: {}
};
const result = provider.getEventDescription(payload);
expect(result).toBe('ping');
});
});
describe('transformRepository', () => {
it('should transform GitHub repository to generic format', () => {
const githubRepo: GitHubRepository = {
id: 12345,
name: 'repo',
full_name: 'owner/repo',
owner: { login: 'owner' } as GitHubUser,
private: false,
default_branch: 'main'
} as GitHubRepository;
const result = GitHubWebhookProvider.transformRepository(githubRepo);
expect(result).toEqual({
id: '12345',
name: 'repo',
fullName: 'owner/repo',
owner: 'owner',
isPrivate: false,
defaultBranch: 'main'
});
});
});
describe('transformUser', () => {
it('should transform GitHub user to generic format', () => {
const githubUser: GitHubUser = {
id: 123,
login: 'user123',
email: 'user@example.com',
name: 'User Name'
} as GitHubUser;
const result = GitHubWebhookProvider.transformUser(githubUser);
expect(result).toEqual({
id: '123',
username: 'user123',
email: 'user@example.com',
displayName: 'User Name'
});
});
it('should use login as displayName when name is missing', () => {
const githubUser: GitHubUser = {
id: 123,
login: 'user123'
} as GitHubUser;
const result = GitHubWebhookProvider.transformUser(githubUser);
expect(result.displayName).toBe('user123');
});
});
describe('transformIssue', () => {
it('should transform GitHub issue to generic format', () => {
const githubIssue: GitHubIssue = {
id: 1,
number: 42,
title: 'Test Issue',
body: 'Issue description',
state: 'open',
user: { id: 123, login: 'user123' } as GitHubUser,
labels: [{ name: 'bug' }, 'enhancement'],
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-02T00:00:00Z'
} as GitHubIssue;
const result = GitHubWebhookProvider.transformIssue(githubIssue);
expect(result).toEqual({
id: 1,
number: 42,
title: 'Test Issue',
body: 'Issue description',
state: 'open',
author: expect.objectContaining({
id: '123',
username: 'user123'
}),
labels: ['bug', 'enhancement'],
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-02T00:00:00Z')
});
});
it('should handle empty body and labels', () => {
const githubIssue: GitHubIssue = {
id: 1,
number: 42,
title: 'Test Issue',
body: null,
state: 'closed',
user: { id: 123, login: 'user123' } as GitHubUser,
labels: undefined,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-02T00:00:00Z'
} as unknown as GitHubIssue;
const result = GitHubWebhookProvider.transformIssue(githubIssue);
expect(result.body).toBe('');
expect(result.labels).toEqual([]);
});
});
describe('transformPullRequest', () => {
it('should transform GitHub PR to generic format', () => {
const githubPR: GitHubPullRequest = {
id: 1,
number: 42,
title: 'Test PR',
body: 'PR description',
state: 'open',
user: { id: 123, login: 'user123' } as GitHubUser,
labels: [{ name: 'feature' }],
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-02T00:00:00Z',
head: { ref: 'feature-branch' },
base: { ref: 'main' },
draft: false,
merged: false,
merged_at: null
} as GitHubPullRequest;
const result = GitHubWebhookProvider.transformPullRequest(githubPR);
expect(result).toEqual({
id: 1,
number: 42,
title: 'Test PR',
body: 'PR description',
state: 'open',
author: expect.objectContaining({
id: '123',
username: 'user123'
}),
labels: ['feature'],
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-02T00:00:00Z'),
sourceBranch: 'feature-branch',
targetBranch: 'main',
isDraft: false,
isMerged: false,
mergedAt: undefined
});
});
it('should handle merged PR', () => {
const githubPR: GitHubPullRequest = {
id: 1,
number: 42,
title: 'Test PR',
body: 'PR description',
state: 'closed',
user: { id: 123, login: 'user123' } as GitHubUser,
labels: [],
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-02T00:00:00Z',
head: { ref: 'feature-branch' },
base: { ref: 'main' },
draft: false,
merged: true,
merged_at: '2024-01-02T12:00:00Z'
} as GitHubPullRequest;
const result = GitHubWebhookProvider.transformPullRequest(githubPR);
expect(result.isMerged).toBe(true);
expect(result.mergedAt).toEqual(new Date('2024-01-02T12:00:00Z'));
});
});
});

View File

@@ -0,0 +1,111 @@
import { IssueOpenedHandler } from '../../../../../src/providers/github/handlers/IssueHandler';
// Mock dependencies
jest.mock('../../../../../src/utils/logger', () => ({
createLogger: () => ({
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
warn: jest.fn()
})
}));
jest.mock('../../../../../src/utils/secureCredentials', () => ({
SecureCredentials: jest.fn().mockImplementation(() => ({
loadCredentials: jest.fn(),
getCredential: jest.fn().mockReturnValue('mock-value')
})),
secureCredentials: {
loadCredentials: jest.fn(),
getCredential: jest.fn().mockReturnValue('mock-value')
}
}));
jest.mock('../../../../../src/services/claudeService');
jest.mock('../../../../../src/services/githubService');
const claudeService = require('../../../../../src/services/claudeService');
describe('IssueOpenedHandler', () => {
let handler: IssueOpenedHandler;
beforeEach(() => {
jest.clearAllMocks();
handler = new IssueOpenedHandler();
});
describe('handle', () => {
const mockPayload = {
event: 'issues.opened',
data: {
action: 'opened',
issue: {
id: 123,
number: 1,
title: 'Test Issue',
body: 'This is a test issue about authentication and API integration',
labels: [],
state: 'open',
user: {
login: 'testuser',
id: 1
},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
},
repository: {
id: 456,
name: 'test-repo',
full_name: 'owner/test-repo',
owner: {
login: 'owner',
id: 2
},
private: false
},
sender: {
login: 'testuser',
id: 1
}
}
};
const mockContext = {
timestamp: new Date(),
requestId: 'test-request-id'
};
it('should analyze and label new issues', async () => {
claudeService.processCommand = jest.fn().mockResolvedValue('Labels applied successfully');
const result = await handler.handle(mockPayload as any, mockContext);
expect(claudeService.processCommand).toHaveBeenCalledWith({
repoFullName: 'owner/test-repo',
issueNumber: 1,
command: expect.stringContaining('Analyze this GitHub issue'),
isPullRequest: false,
branchName: null,
operationType: 'auto-tagging'
});
expect(result).toEqual({
success: true,
message: 'Issue auto-tagged successfully',
data: {
repo: 'owner/test-repo',
issue: 1
}
});
});
it('should handle errors gracefully', async () => {
claudeService.processCommand = jest.fn().mockRejectedValue(new Error('Analysis failed'));
const result = await handler.handle(mockPayload as any, mockContext);
expect(result.success).toBe(false);
expect(result.error).toBe('Analysis failed');
});
});
});