forked from claude-did-this/claude-hub
Compare commits
1 Commits
main
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a7768d6d0 |
29
.env.example
29
.env.example
@@ -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
|
||||
|
||||
2
.github/workflows/cli-tests.yml
vendored
2
.github/workflows/cli-tests.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -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'
|
||||
|
||||
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/pull-request.yml
vendored
2
.github/workflows/pull-request.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/security.yml
vendored
2
.github/workflows/security.yml
vendored
@@ -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
119
CLAUDE.md
@@ -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:
|
||||
|
||||
|
||||
27
Dockerfile
27
Dockerfile
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
1719
src/controllers/githubController.ts
Normal file
1719
src/controllers/githubController.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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];
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Gitea webhook event handlers
|
||||
*/
|
||||
|
||||
export { issueOpenedHandler, issueCommentCreatedHandler } from './issueHandler';
|
||||
export {
|
||||
pullRequestOpenedHandler,
|
||||
pullRequestCommentCreatedHandler,
|
||||
pullRequestSynchronizedHandler
|
||||
} from './pullRequestHandler';
|
||||
export { workflowRunCompletedHandler, workflowJobCompletedHandler } from './workflowHandler';
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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.`
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
209
src/providers/github/GitHubWebhookProvider.ts
Normal file
209
src/providers/github/GitHubWebhookProvider.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
122
src/providers/github/handlers/IssueHandler.ts
Normal file
122
src/providers/github/handlers/IssueHandler.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
25
src/providers/github/index.ts
Normal file
25
src/providers/github/index.ts
Normal 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
10
src/routes/github.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
797
src/services/githubService.ts
Normal file
797
src/services/githubService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
229
src/types/github.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
377
test/unit/providers/github/GitHubWebhookProvider.test.ts
Normal file
377
test/unit/providers/github/GitHubWebhookProvider.test.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
111
test/unit/providers/github/handlers/IssueHandler.test.ts
Normal file
111
test/unit/providers/github/handlers/IssueHandler.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user