Compare commits

...

12 Commits

Author SHA1 Message Date
cad59dc677 Remove Unraid template with hardcoded secrets
This file contained actual tokens and secrets which should not be in the repo.
Unraid templates belong in /boot/config/plugins/dockerMan/templates-user/ locally.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 22:12:14 +01:00
cf71cd7104 fix: Add GITEA_API_URL to ClaudeEnvironmentVars type
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 21:34:59 +01:00
752cc3a0fc feat: Add Gitea support to claudecode container
- Pass GITEA_API_URL to container environment
- Update entrypoint to clone from Gitea instead of GitHub
- Extract git host from GITEA_API_URL dynamically

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 21:27:36 +01:00
8f55bfac35 fix: use Unraid docker GID (281) instead of standard (999)
Fixes Docker socket permission denied error when running on Unraid.
The container's docker group must match the host's docker GID for
socket access to work properly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 11:37:04 +01:00
fea55b9d94 Fix Unraid template format
- Shell: sh instead of bash (consistent with other templates)
- Category: Productivity: Tools:Utilities (correct format)
- ExtraParams: Empty (restart policy set elsewhere)
- DateInstalled: Self-closing tag
- TailscaleStateDir: Added required element

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 11:04:02 +01:00
b4527b8d2f Add Unraid Docker template
Template includes all configuration options for running claude-hub on Unraid:
- Gitea API integration settings
- Bot configuration
- Docker socket access for spawning Claude containers
- Optional Anthropic API key or subscription-based auth

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 10:47:00 +01:00
65dd97e46b Fix TypeScript errors in Gitea provider 2025-12-23 10:37:13 +01:00
b6d5f0f399 Fix Dockerfile: remove outdated package version pins 2025-12-23 10:34:07 +01:00
00cfc5ffbb Replace GitHub with Gitea support
- Add Gitea webhook provider with signature verification (x-gitea-signature)
- Add GiteaApiClient for REST API interactions
- Add handlers for issues, PRs, and workflow events (CI failure detection)
- Update secure credentials to use GITEA_TOKEN
- Add GITEA_TOKEN redaction in logger and sanitize utilities
- Remove all GitHub-specific code (provider, routes, controllers, services, types, tests)
- Update documentation with Gitea webhook setup instructions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 10:31:51 +01:00
dependabot[bot]
3c8aebced8 chore(deps-dev): bump @types/body-parser from 1.19.5 to 1.19.6 (#184)
Bumps [@types/body-parser](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/body-parser) from 1.19.5 to 1.19.6.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/body-parser)

---
updated-dependencies:
- dependency-name: "@types/body-parser"
  dependency-version: 1.19.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-20 11:16:08 -05:00
dependabot[bot]
c067efa13e chore(deps-dev): bump @babel/core from 7.27.3 to 7.27.4 (#167)
Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.27.3 to 7.27.4.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.4/packages/babel-core)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-version: 7.27.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-20 11:15:52 -05:00
Cheffromspace
65a590784c feat: Add Claude API documentation and improve session validation (#181)
* feat: Implement Claude orchestration with session management

- Add CLAUDE_WEBHOOK_SECRET for webhook authentication
- Fix Docker volume mounting for Claude credentials
- Capture Claude's internal session ID from stream-json output
- Update entrypoint script to support OUTPUT_FORMAT=stream-json
- Fix environment variable naming (REPOSITORY -> REPO_FULL_NAME)
- Enable parallel session execution with proper authentication
- Successfully tested creating PRs via orchestrated sessions

This enables the webhook to create and manage Claude Code sessions that can:
- Clone repositories
- Create feature branches
- Implement code changes
- Commit and push changes
- Create pull requests

All while capturing Claude's internal session ID for potential resumption.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: Update SessionManager tests for new implementation

- Update test to expect docker volume create instead of docker create
- Add unref() method to mock process objects to fix test environment error
- Update spawn expectations to match new docker run implementation
- Fix tests for both startSession and queueSession methods

Tests now pass in CI environment.

* feat: Add Claude API documentation and improve session validation

- Add comprehensive Swagger/OpenAPI documentation for Claude webhook API
- Add improved validation for session dependencies to handle edge cases
- Add hackathon-specific Docker Compose configuration
- Update SessionHandler to validate dependency UUIDs and filter invalid values
- Update SessionManager to properly handle sessions without dependencies
- Add API endpoint documentation with examples and schemas

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* test: Add comprehensive tests for SessionHandler dependency validation

Add test coverage for dependency validation logic in SessionHandler:
- Filter out invalid dependency values (empty strings, whitespace, "none")
- Validate UUID format for dependencies
- Handle mixed valid and invalid dependencies
- Support empty dependency arrays
- Handle arrays with only filtered values

This improves test coverage from ~91% to ~97% for SessionHandler.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: Address PR #181 review comments

- Remove unused docker-compose.hackathon.yml file
- Extract UUID regex to constant for better maintainability
- Document breaking changes in BREAKING_CHANGES.md
- Add comprehensive examples to Swagger documentation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-06-05 00:45:52 -05:00
42 changed files with 4152 additions and 3771 deletions

View File

@@ -11,26 +11,27 @@ 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/github_token (or path in GITHUB_TOKEN_FILE)
# - /run/secrets/gitea_token (or path in GITEA_TOKEN_FILE)
# - /run/secrets/anthropic_api_key (or path in ANTHROPIC_API_KEY_FILE)
# - /run/secrets/webhook_secret (or path in GITHUB_WEBHOOK_SECRET_FILE)
# - /run/secrets/gitea_webhook_secret (or path in GITEA_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/github_token.txt
# 3. Mount in docker-compose or use GITHUB_TOKEN_FILE=/path/to/secret
# 2. Add secret files: echo "your-secret" > secrets/gitea_token.txt
# 3. Mount in docker-compose or use GITEA_TOKEN_FILE=/path/to/secret
# ============================
# GitHub Webhook Settings
GITHUB_WEBHOOK_SECRET=your_webhook_secret_here
GITHUB_TOKEN=ghp_your_github_token_here
# 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
# Bot Configuration (REQUIRED)
BOT_USERNAME=@ClaudeBot
@@ -40,9 +41,9 @@ BOT_EMAIL=claude@example.com
AUTHORIZED_USERS=admin,username2,username3
DEFAULT_AUTHORIZED_USER=admin
# Default GitHub Configuration for CLI
DEFAULT_GITHUB_OWNER=your-org
DEFAULT_GITHUB_USER=your-username
# Default Gitea Configuration for CLI
DEFAULT_GITEA_OWNER=your-org
DEFAULT_GITEA_USER=your-username
DEFAULT_BRANCH=main
# Claude API Settings
@@ -102,13 +103,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)
# GITHUB_TOKEN_FILE=/run/secrets/github_token
# GITEA_TOKEN_FILE=/run/secrets/gitea_token
# ANTHROPIC_API_KEY_FILE=/run/secrets/anthropic_api_key
# GITHUB_WEBHOOK_SECRET_FILE=/run/secrets/webhook_secret
# GITEA_WEBHOOK_SECRET_FILE=/run/secrets/gitea_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/github # Webhook endpoint URL
# WEBHOOK_URL=http://localhost:3002/api/webhooks/gitea # Webhook endpoint URL

20
BREAKING_CHANGES.md Normal file
View File

@@ -0,0 +1,20 @@
# Breaking Changes
## PR #181 - Enhanced Session Validation and API Documentation
### Event Pattern Change
- **Changed**: Session handler event pattern changed from `session` to `session*`
- **Impact**: Any integrations listening for specific session events may need to update their event filtering logic
- **Migration**: Update event listeners to use wildcard pattern matching or specific event names (e.g., `session.create`, `session.start`)
### Volume Naming Pattern
- **Changed**: Volume naming pattern in SessionManager changed to use a more consistent format
- **Previous**: Various inconsistent naming patterns
- **New**: Standardized naming with session ID prefixes
- **Impact**: Existing volumes created with old naming patterns may not be recognized
- **Migration**: Existing sessions may need to be recreated or volumes renamed to match new pattern
### API Validation
- **Added**: Strict UUID validation for session dependencies
- **Impact**: Sessions with invalid dependency IDs will now be rejected
- **Migration**: Ensure all dependency IDs are valid UUIDs before creating sessions

119
CLAUDE.md
View File

@@ -2,14 +2,14 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Claude GitHub Webhook
## Claude Gitea Webhook
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.
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.
## Documentation Structure
- `/docs/complete-workflow.md` - Comprehensive workflow documentation
- `/docs/github-workflow.md` - GitHub-specific integration details
- `/docs/gitea-workflow.md` - Gitea-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,14 +80,13 @@ 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 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
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
- Verify labels are applied based on issue content analysis
### Label Management
- Setup repository labels: `GITHUB_TOKEN=your_token node scripts/utils/setup-repository-labels.js owner/repo`
- Setup repository labels: `GITEA_TOKEN=your_token node scripts/utils/setup-repository-labels.js owner/repo`
### CLI Commands
- Basic usage: `./cli/claude-webhook myrepo "Your command for Claude"`
@@ -131,45 +130,53 @@ 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 `GitHub` tools (no file editing or bash execution)
- **Minimal Tool Access**: Uses only `Read` and `Gitea` API tools (no file editing or bash execution)
- **Dedicated Container**: Runs in specialized container with restricted entrypoint script
- **CLI-Based**: Uses `gh` CLI commands directly instead of JSON parsing for better reliability
- **API-Based**: Uses Gitea REST API directly for reliable label management
**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
1. New issue triggers `issues.opened` webhook from Gitea
2. Dedicated Claude container starts with `claudecode-tagging-entrypoint.sh`
3. Claude analyzes issue content using minimal tools
4. Labels applied directly via `gh issue edit --add-label` commands
4. Labels applied via Gitea REST API
5. No comments posted (silent operation)
6. Fallback to keyword-based labeling if CLI approach fails
6. Fallback to keyword-based labeling if API approach fails
### Automated PR Review
The system automatically triggers comprehensive PR reviews when all checks pass:
- **Trigger**: `check_suite` webhook event with `conclusion: 'success'`
- **Scope**: Reviews all PRs associated with the successful check suite
- **Trigger**: `pull_request` webhook events
- **Scope**: Reviews all PRs as requested
- **Process**: Claude performs security, logic, performance, and code quality analysis
- **Output**: Detailed review comments, line-specific feedback, and approval/change requests
- **Integration**: Uses GitHub CLI (`gh`) commands for seamless review workflow
- **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
## Architecture Overview
### Core Components
1. **Express Server** (`src/index.ts`): Main application entry point that sets up middleware, routes, and error handling
2. **Routes**:
- GitHub Webhook: `/api/webhooks/github` - Processes GitHub webhook events
- Webhook Router: `/api/webhooks/:provider` - Processes webhook events from configured providers
- Claude API: `/api/claude` - Direct API access to Claude
- Health Check: `/health` - Service status monitoring
3. **Controllers**:
- `githubController.ts` - Handles webhook verification and processing
3. **Providers**:
- `providers/gitea/` - Gitea webhook handling and API client
- `providers/claude/` - Claude orchestration and session management
4. **Services**:
- `claudeService.ts` - Interfaces with Claude Code CLI
- `githubService.ts` - Handles GitHub API interactions
- `providers/gitea/GiteaApiClient.ts` - Handles Gitea REST API interactions
5. **Utilities**:
- `logger.ts` - Logging functionality with redaction capability
- `awsCredentialProvider.ts` - Secure AWS credential management
@@ -179,8 +186,9 @@ The system automatically triggers comprehensive PR reviews when all checks pass:
The system uses different execution modes based on operation type:
**Operation Types:**
- **Auto-tagging**: Minimal permissions (`Read`, `GitHub` tools only)
- **Auto-tagging**: Minimal permissions (`Read` tool only, uses Gitea API)
- **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:**
@@ -190,8 +198,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,GitHub`)
- `claudecode-entrypoint.sh`: Full tools for general operations (`--allowedTools Bash,Create,Edit,Read,Write,GitHub`)
- `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`)
**DevContainer Configuration:**
The repository includes a `.devcontainer` configuration for development:
@@ -202,11 +210,13 @@ The repository includes a `.devcontainer` configuration for development:
- Automatic firewall initialization via post-create command
### Workflow
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
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
## AWS Authentication
The service supports multiple AWS authentication methods, with a focus on security:
@@ -218,7 +228,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
- Webhook signature verification using HMAC-SHA256 (`x-gitea-signature` header)
- Credential scanning in pre-commit hooks
- Container isolation for Claude execution
- AWS profile-based authentication
@@ -229,26 +239,55 @@ The `awsCredentialProvider.ts` utility handles credential retrieval and rotation
## Configuration
- Environment variables are loaded from `.env` file
- AWS Bedrock credentials for Claude access
- GitHub tokens and webhook secrets
- Gitea tokens and webhook secrets
- Container execution settings
- Webhook URL and port configuration
### Required Environment Variables
- `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_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_EMAIL`: Email address used for git commits made by the bot
- `GITHUB_WEBHOOK_SECRET`: Secret for validating GitHub webhook payloads
- `GITHUB_TOKEN`: GitHub token for API access
- `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
- `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 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_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_CONDITIONAL_TIMEOUT_MS`: Time to wait for conditional jobs that never start before skipping them (default: `"300000"` = 5 minutes).
### Gitea Webhook Setup
To configure Gitea to send webhooks to this service:
1. Go to your repository's **Settings****Webhooks****Add Webhook****Gitea**
2. Configure the webhook:
- **Target URL**: `https://your-claude-hub-domain/api/webhooks/gitea`
- **HTTP Method**: POST
- **Content Type**: application/json
- **Secret**: Match the `GITEA_WEBHOOK_SECRET` environment variable
3. Select events to trigger the webhook:
- **Issues**: For issue auto-tagging
- **Issue Comment**: For bot mentions in issue comments
- **Pull Request**: For PR events
- **Pull Request Comment**: For bot mentions in PR comments
- **Workflow Run**: For CI failure detection (Gitea Actions)
- **Workflow Job**: For job-level CI failure detection
4. Save the webhook and test with the "Test Delivery" button
### Gitea Personal Access Token
Create a personal access token with these scopes:
- `read:repository` - Read repository content
- `write:issue` - Create/edit issues and comments
- `write:repository` - Push commits, create branches/PRs
Generate at: `https://your-gitea-instance/user/settings/applications`
## TypeScript Infrastructure
The project is configured with TypeScript for enhanced type safety and developer experience:

View File

@@ -63,28 +63,29 @@ FROM node:24-slim AS production
# Set shell with pipefail option for better error handling
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# Install runtime dependencies with pinned versions
# Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
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 \
git \
curl \
python3 \
python3-pip \
python3-venv \
expect \
ca-certificates \
gnupg \
lsb-release \
&& rm -rf /var/lib/apt/lists/*
# Install Docker CLI (not the daemon, just the client) with consolidated RUN and pinned versions
# Install Docker CLI (not the daemon, just the client)
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=5:27.* \
&& apt-get install -y --no-install-recommends docker-ce-cli \
&& rm -rf /var/lib/apt/lists/*
# Create docker group first, then create a non-root user for running the application
RUN groupadd -g 999 docker 2>/dev/null || true \
# Note: GID 281 matches Unraid's docker group for socket access
RUN groupadd -g 281 docker 2>/dev/null || true \
&& useradd -m -u 1001 -s /bin/bash claudeuser \
&& usermod -aG docker claudeuser 2>/dev/null || true

569
claude-api-swagger.yaml Normal file
View File

@@ -0,0 +1,569 @@
openapi: 3.0.3
info:
title: Claude Webhook API
description: |
API for creating and managing Claude Code sessions for automated code generation, analysis, and orchestration.
This API enables parallel execution of multiple Claude instances for complex software engineering tasks.
version: 1.0.0
contact:
name: Claude Hub Support
url: https://github.com/claude-hub/claude-hub
servers:
- url: https://your-domain.com
description: Production server
- url: http://localhost:3002
description: Local development server
security:
- bearerAuth: []
paths:
/health:
get:
summary: Health check
description: Check the health status of the API and its dependencies
tags:
- System
security: []
responses:
'200':
description: Service is healthy
content:
application/json:
schema:
$ref: '#/components/schemas/HealthCheckResponse'
/api/webhooks/health:
get:
summary: Webhook health check
description: Check the health status of webhook providers
tags:
- System
security: []
responses:
'200':
description: Webhook providers are healthy
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: healthy
providers:
type: array
items:
type: object
properties:
name:
type: string
handlerCount:
type: integer
/api/webhooks/github:
post:
summary: GitHub webhook endpoint (legacy)
description: Legacy endpoint for GitHub webhooks. Use /api/webhooks/github instead.
deprecated: true
tags:
- Webhooks
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
'200':
description: Webhook processed successfully
'401':
description: Invalid webhook signature
'404':
description: Webhook event not handled
/api/webhooks/{provider}:
post:
summary: Generic webhook endpoint
description: Process webhooks from various providers (github, claude)
tags:
- Webhooks
security: []
parameters:
- name: provider
in: path
required: true
schema:
type: string
enum: [github, claude]
description: The webhook provider name
requestBody:
required: true
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/ClaudeWebhookRequest'
- $ref: '#/components/schemas/GitHubWebhookPayload'
examples:
createSession:
summary: Create a new Claude session
value:
type: session.create
session:
type: implementation
project:
repository: acme/webapp
branch: feature/user-auth
requirements: Implement JWT authentication middleware for Express.js with refresh token support
context: Use existing User model, bcrypt for passwords, and jsonwebtoken library
dependencies: []
createSessionWithDependencies:
summary: Create a session that depends on others
value:
type: session.create
session:
type: testing
project:
repository: acme/webapp
branch: feature/user-auth
requirements: Write comprehensive integration tests for the JWT authentication middleware
context: Test all edge cases including token expiration, invalid tokens, and refresh flow
dependencies:
- 550e8400-e29b-41d4-a716-446655440000
- 660e8400-e29b-41d4-a716-446655440001
startSession:
summary: Start an existing session
value:
type: session.start
sessionId: 550e8400-e29b-41d4-a716-446655440000
orchestrate:
summary: Create an orchestration with multiple sessions
value:
type: orchestrate
autoStart: true
project:
repository: acme/webapp
branch: feature/complete-auth
requirements: |
Implement a complete authentication system:
1. JWT middleware with refresh tokens
2. User registration and login endpoints
3. Password reset functionality
4. Integration tests for all auth endpoints
context: Use existing User model, PostgreSQL database, and follow REST API conventions
responses:
'200':
description: Webhook processed successfully
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookResponse'
examples:
sessionCreated:
summary: Session created successfully
value:
success: true
message: Session created successfully
data:
session:
id: 550e8400-e29b-41d4-a716-446655440000
type: implementation
status: initializing
containerId: claude-session-550e8400
project:
repository: acme/webapp
branch: feature/user-auth
requirements: Implement JWT authentication middleware for Express.js with refresh token support
context: Use existing User model, bcrypt for passwords, and jsonwebtoken library
dependencies: []
sessionStarted:
summary: Session started with dependencies
value:
success: true
message: Session queued, waiting for dependencies
data:
session:
id: 660e8400-e29b-41d4-a716-446655440001
status: pending
waitingFor:
- 550e8400-e29b-41d4-a716-446655440000
'400':
description: Bad request
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Unauthorized - Invalid token or signature
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: Provider not found or session not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: Conflict - Session already started
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'429':
description: Too many requests
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: Too many webhook requests
message:
type: string
example: Too many webhook requests from this IP, please try again later.
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
description: Use CLAUDE_WEBHOOK_SECRET as the bearer token
schemas:
HealthCheckResponse:
type: object
properties:
status:
type: string
enum: [ok, degraded]
timestamp:
type: string
format: date-time
startup:
type: object
properties:
totalStartupTime:
type: integer
milestones:
type: array
items:
type: object
docker:
type: object
properties:
available:
type: boolean
error:
type: string
nullable: true
checkTime:
type: integer
nullable: true
claudeCodeImage:
type: object
properties:
available:
type: boolean
error:
type: string
nullable: true
checkTime:
type: integer
nullable: true
healthCheckDuration:
type: integer
ClaudeWebhookRequest:
oneOf:
- $ref: '#/components/schemas/SessionCreateRequest'
- $ref: '#/components/schemas/SessionStartRequest'
- $ref: '#/components/schemas/SessionGetRequest'
- $ref: '#/components/schemas/SessionOutputRequest'
- $ref: '#/components/schemas/SessionListRequest'
- $ref: '#/components/schemas/OrchestrateRequest'
discriminator:
propertyName: type
mapping:
session.create: '#/components/schemas/SessionCreateRequest'
session.start: '#/components/schemas/SessionStartRequest'
session.get: '#/components/schemas/SessionGetRequest'
session.output: '#/components/schemas/SessionOutputRequest'
session.list: '#/components/schemas/SessionListRequest'
orchestrate: '#/components/schemas/OrchestrateRequest'
SessionCreateRequest:
type: object
required:
- type
- session
properties:
type:
type: string
enum: [session.create]
session:
type: object
required:
- type
- project
properties:
type:
type: string
enum: [implementation, analysis, testing, review, coordination]
description: Type of Claude session
project:
type: object
required:
- repository
- requirements
properties:
repository:
type: string
pattern: '^[a-zA-Z0-9-]+/[a-zA-Z0-9-_.]+$'
example: acme/webapp
description: GitHub repository in owner/repo format
branch:
type: string
example: feature/user-auth
description: Target branch name
requirements:
type: string
example: Implement JWT authentication middleware for Express.js
description: Clear description of what Claude should do
context:
type: string
example: Use existing User model and bcrypt for password hashing
description: Additional context about the codebase or requirements
dependencies:
type: array
items:
type: string
format: uuid
description: Array of session IDs that must complete before this session starts
SessionStartRequest:
type: object
required:
- type
- sessionId
properties:
type:
type: string
enum: [session.start]
sessionId:
type: string
format: uuid
example: 550e8400-e29b-41d4-a716-446655440000
SessionGetRequest:
type: object
required:
- type
- sessionId
properties:
type:
type: string
enum: [session.get]
sessionId:
type: string
format: uuid
SessionOutputRequest:
type: object
required:
- type
- sessionId
properties:
type:
type: string
enum: [session.output]
sessionId:
type: string
format: uuid
SessionListRequest:
type: object
required:
- type
properties:
type:
type: string
enum: [session.list]
orchestrationId:
type: string
format: uuid
description: Filter sessions by orchestration ID
OrchestrateRequest:
type: object
required:
- type
- project
properties:
type:
type: string
enum: [orchestrate]
sessionType:
type: string
enum: [coordination]
default: coordination
autoStart:
type: boolean
default: false
description: Whether to start the session immediately
project:
type: object
required:
- repository
- requirements
properties:
repository:
type: string
pattern: '^[a-zA-Z0-9-]+/[a-zA-Z0-9-_.]+$'
branch:
type: string
requirements:
type: string
context:
type: string
WebhookResponse:
type: object
properties:
success:
type: boolean
message:
type: string
data:
type: object
additionalProperties: true
ErrorResponse:
type: object
properties:
success:
type: boolean
example: false
error:
type: string
example: Session not found
Session:
type: object
properties:
id:
type: string
format: uuid
type:
type: string
enum: [implementation, analysis, testing, review, coordination]
status:
type: string
enum: [pending, initializing, running, completed, failed, cancelled]
containerId:
type: string
nullable: true
claudeSessionId:
type: string
nullable: true
project:
type: object
properties:
repository:
type: string
branch:
type: string
requirements:
type: string
context:
type: string
dependencies:
type: array
items:
type: string
format: uuid
startedAt:
type: string
format: date-time
nullable: true
completedAt:
type: string
format: date-time
nullable: true
output:
type: object
nullable: true
error:
type: string
nullable: true
SessionOutput:
type: object
properties:
logs:
type: array
items:
type: string
artifacts:
type: array
items:
type: object
properties:
type:
type: string
enum: [file, commit, pr, issue, comment]
path:
type: string
content:
type: string
sha:
type: string
url:
type: string
metadata:
type: object
additionalProperties: true
summary:
type: string
example: Implemented JWT authentication middleware with refresh token support
nextSteps:
type: array
items:
type: string
example: [Add rate limiting, Implement password reset flow]
GitHubWebhookPayload:
type: object
description: GitHub webhook payload (simplified schema)
properties:
action:
type: string
repository:
type: object
properties:
full_name:
type: string
sender:
type: object
properties:
login:
type: string
tags:
- name: System
description: System health and status endpoints
- name: Webhooks
description: Webhook processing endpoints
- name: Sessions
description: Claude session management operations

941
docs/claude-webhook-api.md Normal file
View File

@@ -0,0 +1,941 @@
# Claude Webhook API Documentation
## Overview
The Claude Webhook API provides endpoints for creating and managing Claude Code sessions for automated code generation, analysis, and orchestration. This API is designed to enable parallel execution of multiple Claude instances for complex software engineering tasks.
## API Design Philosophy
This API follows a simple, focused design:
- **Single responsibility**: Each session handles one specific task
- **Orchestration via MCP/LLM agents**: Complex workflows are managed by the calling agent, not the API
- **Consistent response format**: All responses follow the same structure for predictable parsing
## Base Configuration
### Base URL
```
POST https://your-domain.com/api/webhooks/claude
```
### Authentication
All requests require Bearer token authentication:
```http
Authorization: Bearer <CLAUDE_WEBHOOK_SECRET>
Content-Type: application/json
```
### Response Format
All API responses follow this consistent structure:
```json
{
"success": boolean,
"message": "string", // Human-readable status message
"data": object, // Response data (when success=true)
"error": "string" // Error description (when success=false)
}
```
### Rate Limiting
- Currently not implemented (planned for future release)
- Recommended client-side rate limiting: 10 requests per minute
## Endpoints
### 1. Create Session
Creates a new Claude Code session. Sessions can be configured with dependencies, metadata, and execution options.
**Endpoint:** `POST /api/webhooks/claude`
**Type:** `session.create`
#### Request Body
```json
{
"type": "session.create",
"session": {
"type": "implementation | analysis | testing | review | coordination",
"project": {
"repository": "string", // Required: "owner/repo" format
"branch": "string", // Optional: target branch
"requirements": "string", // Required: task description
"context": "string" // Optional: additional context
},
"dependencies": ["string"], // Optional: array of session IDs to wait for
"metadata": { // Optional: custom metadata
"batchId": "string", // Group related sessions
"tags": ["string"], // Categorization tags
"priority": "string" // Priority level
}
},
"options": { // Optional: execution options
"autoStart": boolean, // Start when dependencies complete (default: false)
"timeout": number, // Custom timeout in seconds (default: 1800)
"notifyUrl": "string" // Webhook URL for completion notification
}
}
```
#### Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `type` | string | Yes | Must be "session.create" |
| `session` | object | Yes | Session configuration object |
| `session.type` | string | Yes | Type of session: `implementation`, `analysis`, `testing`, `review`, or `coordination` |
| `session.project` | object | Yes | Project information |
| `session.project.repository` | string | Yes | GitHub repository in "owner/repo" format |
| `session.project.branch` | string | No | Target branch name (defaults to main/master) |
| `session.project.requirements` | string | Yes | Clear description of what Claude should do |
| `session.project.context` | string | No | Additional context about the codebase or requirements |
| `session.dependencies` | string[] | No | Array of valid UUID session IDs that must complete before this session starts (filters out "none", empty strings) |
| `session.metadata` | object | No | Custom metadata for organizing sessions |
| `session.metadata.batchId` | string | No | User-provided ID for grouping related sessions |
| `session.metadata.tags` | string[] | No | Tags for categorization |
| `session.metadata.priority` | string | No | Priority level (high, medium, low) |
| `options` | object | No | Execution options |
| `options.autoStart` | boolean | No | Automatically start when dependencies complete (default: false) |
| `options.timeout` | number | No | Custom timeout in seconds (default: 1800 = 30 minutes) |
| `options.notifyUrl` | string | No | Webhook URL to call on completion/failure |
#### Response
```json
{
"success": true,
"message": "Session created successfully",
"data": {
"session": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"type": "implementation",
"status": "pending",
"project": {
"repository": "acme/webapp",
"branch": "feature/user-auth",
"requirements": "Implement JWT authentication middleware",
"context": "Use existing User model"
},
"dependencies": [],
"metadata": {
"batchId": "auth-feature-batch",
"tags": ["feature", "auth"],
"priority": "high"
},
"options": {
"autoStart": false,
"timeout": 1800,
"notifyUrl": null
},
"containerId": null,
"claudeSessionId": null,
"createdAt": "2024-01-06T10:00:00Z",
"startedAt": null,
"completedAt": null,
"output": null,
"error": null
}
}
}
```
#### Example
```bash
curl -X POST https://your-domain.com/api/webhooks/claude \
-H "Authorization: Bearer your-secret-token" \
-H "Content-Type: application/json" \
-d '{
"type": "session.create",
"session": {
"type": "implementation",
"project": {
"repository": "acme/webapp",
"branch": "feature/user-auth",
"requirements": "Implement JWT authentication middleware for Express.js",
"context": "Use existing User model and bcrypt for password hashing"
},
"dependencies": []
}
}'
```
---
### 2. Start Session
Starts a previously created session or queues it if dependencies aren't met.
**Endpoint:** `POST /api/webhooks/claude`
**Type:** `session.start`
#### Request Body
```json
{
"type": "session.start",
"sessionId": "string"
}
```
#### Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `type` | string | Yes | Must be "session.start" |
| `sessionId` | string | Yes | UUID of the session to start |
#### Response
```json
{
"success": true,
"message": "Session started successfully",
"data": {
"session": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "initializing", // or "running" if started immediately
"containerId": "docker-container-id",
"claudeSessionId": "claude-internal-session-id",
// ... full session object
}
}
}
```
For queued sessions (waiting on dependencies):
```json
{
"success": true,
"message": "Session queued",
"data": {
"session": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "pending",
// ... full session object
},
"queueStatus": {
"waitingFor": ["dependency-session-id-1", "dependency-session-id-2"],
"estimatedStartTime": null
}
}
}
```
#### Example
```bash
curl -X POST https://your-domain.com/api/webhooks/claude \
-H "Authorization: Bearer your-secret-token" \
-H "Content-Type: application/json" \
-d '{
"type": "session.start",
"sessionId": "550e8400-e29b-41d4-a716-446655440000"
}'
```
---
### 3. Get Session Status
Retrieves the current status and details of a session.
**Endpoint:** `POST /api/webhooks/claude`
**Type:** `session.get`
#### Request Body
```json
{
"type": "session.get",
"sessionId": "string"
}
```
#### Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `type` | string | Yes | Must be "session.get" |
| `sessionId` | string | Yes | UUID of the session to query |
#### Response
```json
{
"success": true,
"message": "Session found",
"data": {
"session": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"type": "implementation",
"status": "running",
"containerId": "docker-container-id",
"claudeSessionId": "claude-internal-session-id",
"project": {
"repository": "acme/webapp",
"branch": "feature/user-auth",
"requirements": "Implement JWT authentication middleware",
"context": "Use existing User model"
},
"dependencies": [],
"metadata": {},
"options": {},
"createdAt": "2024-01-06T10:00:00Z",
"startedAt": "2024-01-06T10:30:00Z",
"completedAt": null,
"output": null,
"error": null
}
}
}
```
#### Session Status Values
- `pending` - Session created but not started
- `initializing` - Container is being created
- `running` - Session is actively executing
- `completed` - Session finished successfully
- `failed` - Session encountered an error
- `cancelled` - Session was manually cancelled
---
### 4. Get Session Output
Retrieves the output and artifacts from a completed session.
**Endpoint:** `POST /api/webhooks/claude`
**Type:** `session.output`
#### Request Body
```json
{
"type": "session.output",
"sessionId": "string"
}
```
#### Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `type` | string | Yes | Must be "session.output" |
| `sessionId` | string | Yes | UUID of the session |
#### Response
```json
{
"success": true,
"message": "Session output retrieved",
"data": {
"output": {
"logs": ["Container started", "Running Claude command...", "Task completed"],
"artifacts": [
{
"type": "file",
"path": "src/middleware/auth.js",
"content": "// JWT authentication middleware\n...",
"sha": "abc123...",
"url": "https://github.com/acme/webapp/blob/feature/user-auth/src/middleware/auth.js",
"metadata": {
"linesAdded": 150,
"linesRemoved": 0
}
}
],
"summary": "Implemented JWT authentication middleware with refresh token support",
"nextSteps": ["Add rate limiting", "Implement password reset flow"],
"executionTime": 180, // seconds
"resourceUsage": {
"cpuTime": 45.2,
"memoryPeak": "512MB"
}
}
}
}
```
Note: The current implementation returns a simplified structure. Full artifact details and metadata are planned for future releases.
---
### 5. List Sessions
Lists all sessions, optionally filtered by orchestration ID.
**Endpoint:** `POST /api/webhooks/claude`
**Type:** `session.list`
#### Request Body
```json
{
"type": "session.list",
"orchestrationId": "string" // Optional
}
```
#### Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `type` | string | Yes | Must be "session.list" |
| `orchestrationId` | string | No | Filter sessions by orchestration ID |
#### Response
```json
{
"success": true,
"message": "Sessions retrieved",
"data": {
"sessions": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"type": "implementation",
"status": "completed",
"project": {
"repository": "acme/webapp",
"branch": "feature/user-auth",
"requirements": "Implement JWT authentication",
"context": null
},
"dependencies": [],
"metadata": {
"batchId": "auth-feature-batch",
"tags": ["feature", "auth"]
},
"createdAt": "2024-01-06T10:00:00Z",
"startedAt": "2024-01-06T10:30:00Z",
"completedAt": "2024-01-06T10:45:00Z",
"error": null
},
{
"id": "660e8400-e29b-41d4-a716-446655440001",
"type": "testing",
"status": "running",
"project": {
"repository": "acme/webapp",
"branch": "feature/user-auth",
"requirements": "Write tests for JWT middleware"
},
"dependencies": ["550e8400-e29b-41d4-a716-446655440000"],
"metadata": {
"batchId": "auth-feature-batch",
"tags": ["testing"]
},
"createdAt": "2024-01-06T10:46:00Z",
"startedAt": "2024-01-06T10:47:00Z",
"completedAt": null,
"error": null
}
]
}
}
```
## Session Types
### implementation
For implementing new features or functionality. Claude will:
- Analyze requirements
- Write production-ready code
- Follow existing patterns and conventions
- Create or modify files as needed
### analysis
For analyzing existing code. Claude will:
- Review code structure and patterns
- Identify potential issues
- Suggest improvements
- Document findings
### testing
For creating and running tests. Claude will:
- Write unit and integration tests
- Ensure code coverage
- Validate functionality
- Fix failing tests
### review
For code review tasks. Claude will:
- Review pull requests
- Check for security issues
- Validate best practices
- Provide feedback
### coordination
For orchestrating multiple sessions. Claude will:
- Break down complex tasks
- Create dependent sessions
- Monitor progress
- Coordinate results
## Dependency Management
Sessions can depend on other sessions using the `dependencies` parameter:
```json
{
"type": "session.create",
"session": {
"type": "testing",
"project": {
"repository": "acme/webapp",
"requirements": "Write tests for the JWT authentication middleware"
},
"dependencies": ["implementation-session-id"]
}
}
```
### Dependency Behavior
- Sessions with dependencies won't start until all dependencies are `completed`
- If any dependency fails, the dependent session will be marked as `failed`
- Circular dependencies are detected and rejected
- Maximum dependency depth is 10 levels
## Error Handling
### Error Response Format
```json
{
"success": false,
"error": "Error description"
}
```
### Common Error Codes
- `400` - Bad Request (invalid parameters)
- `401` - Unauthorized (invalid token)
- `404` - Not Found (session doesn't exist)
- `409` - Conflict (session already started)
- `429` - Too Many Requests (rate limit exceeded)
- `500` - Internal Server Error
### Example Error Response
```json
{
"success": false,
"error": "Session not found: 550e8400-e29b-41d4-a716-446655440000"
}
```
## Best Practices
### 1. Clear Requirements
Provide detailed, actionable requirements:
```json
{
"requirements": "Implement JWT authentication middleware with:\n- Access token (15min expiry)\n- Refresh token (7 days expiry)\n- Token blacklisting for logout\n- Rate limiting per user"
}
```
### 2. Use Dependencies Wisely
Chain related tasks:
```
analysis → implementation → testing → review
```
### 3. Provide Context
Include relevant context about your codebase:
```json
{
"context": "We use Express.js with TypeScript, Prisma ORM, and follow REST API conventions. Authentication should integrate with existing User model."
}
```
### 4. Monitor Session Status
Poll session status every 5-10 seconds:
```bash
while [ "$status" != "completed" ]; do
status=$(curl -s -X POST ... | jq -r '.data.status')
sleep 5
done
```
### 5. Handle Failures Gracefully
Check session status and error messages:
```javascript
if (response.data.status === 'failed') {
console.error('Session failed:', response.data.error);
// Implement retry logic or alternative approach
}
```
## Integration Examples
### Node.js/TypeScript
```typescript
import axios from 'axios';
const CLAUDE_API_URL = 'https://your-domain.com/api/webhooks/claude';
const AUTH_TOKEN = process.env.CLAUDE_WEBHOOK_SECRET;
async function createAndRunSession() {
// Create session
const createResponse = await axios.post(
CLAUDE_API_URL,
{
type: 'session.create',
session: {
type: 'implementation',
project: {
repository: 'acme/webapp',
requirements: 'Implement user profile API endpoints',
context: 'Use existing auth middleware'
}
}
},
{
headers: {
'Authorization': `Bearer ${AUTH_TOKEN}`,
'Content-Type': 'application/json'
}
}
);
const sessionId = createResponse.data.data.sessionId;
// Start session
await axios.post(
CLAUDE_API_URL,
{
type: 'session.start',
sessionId
},
{
headers: {
'Authorization': `Bearer ${AUTH_TOKEN}`,
'Content-Type': 'application/json'
}
}
);
// Poll for completion
let status = 'running';
while (status === 'running' || status === 'initializing') {
await new Promise(resolve => setTimeout(resolve, 5000));
const statusResponse = await axios.post(
CLAUDE_API_URL,
{
type: 'session.get',
sessionId
},
{
headers: {
'Authorization': `Bearer ${AUTH_TOKEN}`,
'Content-Type': 'application/json'
}
}
);
status = statusResponse.data.data.status;
}
// Get output
if (status === 'completed') {
const outputResponse = await axios.post(
CLAUDE_API_URL,
{
type: 'session.output',
sessionId
},
{
headers: {
'Authorization': `Bearer ${AUTH_TOKEN}`,
'Content-Type': 'application/json'
}
}
);
console.log('Session completed:', outputResponse.data.data.summary);
console.log('Artifacts:', outputResponse.data.data.artifacts);
}
}
```
### Python
```python
import requests
import time
import os
CLAUDE_API_URL = 'https://your-domain.com/api/webhooks/claude'
AUTH_TOKEN = os.environ['CLAUDE_WEBHOOK_SECRET']
headers = {
'Authorization': f'Bearer {AUTH_TOKEN}',
'Content-Type': 'application/json'
}
# Create session
create_response = requests.post(
CLAUDE_API_URL,
json={
'type': 'session.create',
'session': {
'type': 'implementation',
'project': {
'repository': 'acme/webapp',
'requirements': 'Implement user profile API endpoints'
}
}
},
headers=headers
)
session_id = create_response.json()['data']['sessionId']
# Start session
requests.post(
CLAUDE_API_URL,
json={
'type': 'session.start',
'sessionId': session_id
},
headers=headers
)
# Poll for completion
status = 'running'
while status in ['running', 'initializing']:
time.sleep(5)
status_response = requests.post(
CLAUDE_API_URL,
json={
'type': 'session.get',
'sessionId': session_id
},
headers=headers
)
status = status_response.json()['data']['status']
# Get output
if status == 'completed':
output_response = requests.post(
CLAUDE_API_URL,
json={
'type': 'session.output',
'sessionId': session_id
},
headers=headers
)
output = output_response.json()['data']
print(f"Summary: {output['summary']}")
print(f"Artifacts: {output['artifacts']}")
```
## LLM Agent Integration Guide
This section provides specific guidance for LLM agents (via MCP servers or other integrations) consuming this API.
### Response Parsing
All responses follow a consistent structure, making them easy to parse:
```typescript
interface ApiResponse<T> {
success: boolean;
message: string;
data?: T; // Present when success=true
error?: string; // Present when success=false
}
```
### Session Orchestration Pattern
Since this API focuses on single-session creation, orchestration should be handled by the LLM agent:
```python
# Example: LLM agent orchestrating a feature implementation
async def implement_feature(repo: str, feature_desc: str):
# 1. Create analysis session
analysis = await create_session(
type="analysis",
requirements=f"Analyze codebase for implementing: {feature_desc}"
)
# 2. Wait for analysis to complete
await wait_for_completion(analysis.id)
# 3. Create implementation session based on analysis
implementation = await create_session(
type="implementation",
requirements=f"Implement {feature_desc} based on analysis",
dependencies=[analysis.id]
)
# 4. Create testing session
testing = await create_session(
type="testing",
requirements=f"Write tests for {feature_desc}",
dependencies=[implementation.id],
options={"autoStart": true} # Auto-start when ready
)
return {
"analysis": analysis.id,
"implementation": implementation.id,
"testing": testing.id
}
```
### Polling Best Practices
```javascript
async function pollSession(sessionId, maxAttempts = 120) {
const pollInterval = 5000; // 5 seconds
let attempts = 0;
while (attempts < maxAttempts) {
const response = await getSession(sessionId);
const status = response.data.session.status;
if (['completed', 'failed', 'cancelled'].includes(status)) {
return response.data.session;
}
// Exponential backoff for long-running sessions
const delay = status === 'pending' ? pollInterval * 2 : pollInterval;
await sleep(delay);
attempts++;
}
throw new Error('Session polling timeout');
}
```
### Batch Processing Pattern
Use metadata to group related sessions:
```json
{
"type": "session.create",
"session": {
"type": "implementation",
"project": { ... },
"metadata": {
"batchId": "feature-xyz-batch",
"tags": ["feature", "priority-high"],
"priority": "high"
}
}
}
```
Then query all sessions in a batch:
```json
{
"type": "session.list",
"orchestrationId": "feature-xyz-batch"
}
```
### Error Handling
```python
def handle_api_response(response):
if response.status_code == 429:
# Rate limited - implement exponential backoff
retry_after = int(response.headers.get('Retry-After', 60))
time.sleep(retry_after)
return retry_request()
data = response.json()
if not data['success']:
error = data.get('error', 'Unknown error')
if 'not found' in error:
# Handle missing session
pass
elif 'already started' in error:
# Session already running - just poll for status
pass
else:
raise ApiError(error)
return data['data']
```
### Dependency Graph Building
```typescript
class SessionGraph {
private sessions: Map<string, Session> = new Map();
addSession(session: Session) {
this.sessions.set(session.id, session);
}
getExecutionOrder(): string[] {
// Topological sort to determine execution order
const visited = new Set<string>();
const order: string[] = [];
const visit = (id: string) => {
if (visited.has(id)) return;
visited.add(id);
const session = this.sessions.get(id);
if (session?.dependencies) {
session.dependencies.forEach(dep => visit(dep));
}
order.push(id);
};
this.sessions.forEach((_, id) => visit(id));
return order;
}
}
```
### Optimizing for Claude Code
When creating sessions for Claude Code:
1. **Clear Requirements**: Be specific and actionable
```json
{
"requirements": "Implement REST API endpoint POST /api/users with:\n- Request validation (email, password)\n- Password hashing with bcrypt\n- Store in PostgreSQL users table\n- Return JWT token\n- Handle duplicate email error",
"context": "Using Express.js, TypeScript, Prisma ORM. Follow existing auth patterns in src/middleware/auth.ts"
}
```
2. **Provide Context**: Reference existing code patterns
```json
{
"context": "Follow patterns in src/controllers/. Use existing error handling middleware. See src/types/user.ts for User interface."
}
```
3. **Use Session Types Effectively**:
- `analysis` - Before implementing, understand the codebase
- `implementation` - Write the actual code
- `testing` - Ensure code works and has coverage
- `review` - Final quality check
- `coordination` - For complex multi-part tasks
### Performance Tips
1. **Parallel Sessions**: Create independent sessions simultaneously
2. **Reuse Analysis**: Cache analysis results for similar tasks
3. **Smart Dependencies**: Only add dependencies when truly needed
4. **Batch Operations**: Group related sessions with metadata
## Troubleshooting
### Session Stuck in "pending"
- Check if dependencies are completed
- Verify Docker daemon is running
- Check system resources (CPU, memory)
- Use `session.get` to check dependency status
### Authentication Errors
- Verify Bearer token matches CLAUDE_WEBHOOK_SECRET
- Ensure Authorization header is properly formatted
- Check token hasn't been rotated
### Session Failures
- Review session output for error messages
- Check Docker container logs
- Verify repository access permissions
- Ensure Claude API credentials are valid
### Timeout Issues
- Default timeout is 30 minutes per session
- For longer tasks, break into smaller sessions
- Use custom timeout in options: `{"timeout": 3600}`
## Changelog
### v2.0.0 (2024-01-08)
- **BREAKING**: Removed orchestration endpoint (use session.create with type="coordination")
- **BREAKING**: Updated response structures (all data wrapped in `data.session` or `data.sessions`)
- Added enhanced session creation with metadata and options
- Added autoStart option for dependency-based execution
- Added timeout and notification options
- Improved dependency validation (filters invalid UUIDs)
### v1.0.0 (2024-01-06)
- Initial release with session management
- Support for 5 session types
- Dependency management
- Orchestration capabilities

50
package-lock.json generated
View File

@@ -9,7 +9,6 @@
"version": "0.1.1",
"dependencies": {
"@octokit/rest": "^22.0.0",
"@types/uuid": "^10.0.0",
"axios": "^1.6.2",
"body-parser": "^2.2.0",
"commander": "^14.0.0",
@@ -22,14 +21,15 @@
"uuid": "^11.1.0"
},
"devDependencies": {
"@babel/core": "^7.27.3",
"@babel/core": "^7.27.4",
"@babel/preset-env": "^7.27.2",
"@jest/globals": "^30.0.0-beta.3",
"@types/body-parser": "^1.19.5",
"@types/body-parser": "^1.19.6",
"@types/express": "^5.0.2",
"@types/jest": "^29.5.14",
"@types/node": "^22.15.23",
"@types/supertest": "^6.0.3",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.33.0",
"@typescript-eslint/parser": "^8.33.0",
"babel-jest": "^29.7.0",
@@ -86,20 +86,21 @@
}
},
"node_modules/@babel/core": {
"version": "7.27.3",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.3.tgz",
"integrity": "sha512-hyrN8ivxfvJ4i0fIJuV4EOlV0WDMz5Ui4StRTgVaAvWeiRCilXgwVvxJKtFQ3TKtHgJscB2YiXKGNJuVwhQMtA==",
"version": "7.27.4",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz",
"integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.27.3",
"@babel/helper-compilation-targets": "^7.27.2",
"@babel/helper-module-transforms": "^7.27.3",
"@babel/helpers": "^7.27.3",
"@babel/parser": "^7.27.3",
"@babel/helpers": "^7.27.4",
"@babel/parser": "^7.27.4",
"@babel/template": "^7.27.2",
"@babel/traverse": "^7.27.3",
"@babel/traverse": "^7.27.4",
"@babel/types": "^7.27.3",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
@@ -366,10 +367,11 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.27.3",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.3.tgz",
"integrity": "sha512-h/eKy9agOya1IGuLaZ9tEUgz+uIRXcbtOhRtUyyMf8JFmn1iT13vnl/IGVWSkdOCG/pC57U4S1jnAabAavTMwg==",
"version": "7.27.4",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.4.tgz",
"integrity": "sha512-Y+bO6U+I7ZKaM5G5rDUZiYfUvQPUibYmAFe7EnKdnKBbVXDZxvp+MWOH5gYciY0EPk4EScsuFMQBbEfpdRKSCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.27.2",
"@babel/types": "^7.27.3"
@@ -379,10 +381,11 @@
}
},
"node_modules/@babel/parser": {
"version": "7.27.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.3.tgz",
"integrity": "sha512-xyYxRj6+tLNDTWi0KCBcZ9V7yg3/lwL9DWh9Uwh/RIVlIfFidggcgxKX3GCXwCiswwcGRawBKbEg2LG/Y8eJhw==",
"version": "7.27.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz",
"integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.27.3"
},
@@ -1636,14 +1639,15 @@
}
},
"node_modules/@babel/traverse": {
"version": "7.27.3",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.3.tgz",
"integrity": "sha512-lId/IfN/Ye1CIu8xG7oKBHXd2iNb2aW1ilPszzGcJug6M8RCKfVNcYhpI5+bMvFYjK7lXIM0R+a+6r8xhHp2FQ==",
"version": "7.27.4",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz",
"integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.27.3",
"@babel/parser": "^7.27.3",
"@babel/parser": "^7.27.4",
"@babel/template": "^7.27.2",
"@babel/types": "^7.27.3",
"debug": "^4.3.1",
@@ -3108,10 +3112,11 @@
}
},
"node_modules/@types/body-parser": {
"version": "1.19.5",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
"integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==",
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
@@ -3315,6 +3320,7 @@
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/yargs": {

View File

@@ -48,10 +48,10 @@
"uuid": "^11.1.0"
},
"devDependencies": {
"@babel/core": "^7.27.3",
"@babel/core": "^7.27.4",
"@babel/preset-env": "^7.27.2",
"@jest/globals": "^30.0.0-beta.3",
"@types/body-parser": "^1.19.5",
"@types/body-parser": "^1.19.6",
"@types/express": "^5.0.2",
"@types/jest": "^29.5.14",
"@types/node": "^22.15.23",

View File

@@ -53,22 +53,22 @@ else
echo "WARNING: No Claude authentication source found at /home/node/.claude." >&2
fi
# 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
# 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
else
echo "No GitHub token provided, skipping GitHub authentication"
echo "No Gitea token provided, skipping Git authentication" >&2
GIT_HOST=""
fi
# Clone the repository as node user
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
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
cd /workspace/repo
else
echo "Skipping repository clone - missing GitHub token or repository name" >&2
echo "Skipping repository clone - missing token or repository name" >&2
cd /workspace
fi

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -4,7 +4,6 @@ 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';
@@ -99,8 +98,7 @@ app.use(
startupMetrics.recordMilestone('middleware_configured', 'Express middleware configured');
// Routes
app.use('/api/webhooks/github', githubRoutes); // Legacy endpoint
app.use('/api/webhooks', webhookRoutes); // New modular webhook endpoint
app.use('/api/webhooks', webhookRoutes); // Modular webhook endpoint
startupMetrics.recordMilestone('routes_configured', 'API routes configured');

View File

@@ -11,6 +11,9 @@ import { randomUUID } from 'crypto';
const logger = createLogger('SessionHandler');
// UUID validation regex pattern
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
interface SessionCreatePayload {
type: 'session.create';
session: Partial<ClaudeSession>;
@@ -126,6 +129,27 @@ export class SessionHandler implements WebhookEventHandler<ClaudeWebhookPayload>
};
}
// Validate dependencies
if (partialSession.dependencies && partialSession.dependencies.length > 0) {
// Filter out invalid dependency values
const validDependencies = partialSession.dependencies.filter(dep => {
return dep && dep.trim() !== '' && dep.toLowerCase() !== 'none';
});
// Check that all remaining dependencies are valid UUIDs
const invalidDependencies = validDependencies.filter(dep => !UUID_REGEX.test(dep));
if (invalidDependencies.length > 0) {
return {
success: false,
error: `Invalid dependency IDs (not valid UUIDs): ${invalidDependencies.join(', ')}`
};
}
// Update dependencies to only include valid ones
partialSession.dependencies = validDependencies;
}
// Create full session object
const session: ClaudeSession = {
id: partialSession.id ?? randomUUID(),

View File

@@ -81,7 +81,7 @@ export class SessionManager {
'-e',
`SESSION_TYPE=${session.type}`,
'-e',
`GITHUB_TOKEN=${process.env.GITHUB_TOKEN ?? ''}`,
`GITEA_TOKEN=${process.env.GITEA_TOKEN ?? ''}`,
'-e',
`REPO_FULL_NAME=${session.project.repository}`,
'-e',
@@ -181,6 +181,12 @@ export class SessionManager {
* Queue a session to start when dependencies are met
*/
async queueSession(session: ClaudeSession): Promise<void> {
// If session has no dependencies, start immediately
if (!session.dependencies || session.dependencies.length === 0) {
await this.startSession(session);
return;
}
// Check if all dependencies are completed
const allDependenciesMet = session.dependencies.every(depId => {
const dep = this.sessions.get(depId);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,25 +0,0 @@
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();

View File

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

View File

@@ -12,13 +12,13 @@ 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/github').catch(err => {
logger.error({ err }, 'Failed to initialize GitHub provider');
});
import('../providers/claude').catch(err => {
logger.error({ err }, 'Failed to initialize Claude provider');
});
import('../providers/gitea').catch(err => {
logger.error({ err }, 'Failed to initialize Gitea provider');
});
}
/**
@@ -90,11 +90,10 @@ router.get('/health', (_req, res) => {
});
/**
* Legacy GitHub webhook endpoint (for backward compatibility)
* POST /api/webhooks/github
* Gitea webhook endpoint
* POST /api/webhooks/gitea
*
* This is handled by the generic endpoint above, but we'll keep
* this documentation for clarity
* This is handled by the generic endpoint above
*/
export default router;

View File

@@ -52,13 +52,12 @@ export async function processCommand({
'Processing command with Claude'
);
const githubToken = secureCredentials.get('GITHUB_TOKEN');
const giteaToken = secureCredentials.get('GITEA_TOKEN');
// In test mode, skip execution and return a mock response
// 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) {
// Gitea tokens are typically alphanumeric strings
const isValidGiteaToken = giteaToken && giteaToken.length > 0;
if (process.env['NODE_ENV'] === 'test' || !isValidGiteaToken) {
logger.info(
{
repo: repoFullName,
@@ -74,9 +73,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 GitHub CLI to interact with issues, PRs, and comments
4. Use Gitea API to interact with issues, PRs, and comments
For real functionality, please configure valid GitHub and Claude API tokens.`;
For real functionality, please configure valid Gitea and Claude API tokens.`;
// Always sanitize responses, even in test mode
return sanitizeBotMentions(testResponse);
@@ -124,7 +123,7 @@ For real functionality, please configure valid GitHub and Claude API tokens.`;
branchName,
operationType,
fullPrompt,
githubToken
giteaToken
});
// Run the container
@@ -215,7 +214,7 @@ For real functionality, please configure valid GitHub and Claude API tokens.`;
containerName,
dockerArgs: sanitizedArgs,
dockerImageName,
githubToken,
giteaToken,
repoFullName,
issueNumber
});
@@ -252,7 +251,7 @@ function createPrompt({
command: string;
}): string {
if (operationType === 'auto-tagging') {
return `You are Claude, an AI assistant analyzing a GitHub issue for automatic label assignment.
return `You are Claude, an AI assistant analyzing a Gitea issue for automatic label assignment.
**Context:**
- Repository: ${repoFullName}
@@ -261,19 +260,19 @@ function createPrompt({
**Available Tools:**
- Read: Access repository files and issue content
- GitHub: Use 'gh' CLI for label operations only
- Gitea API: Use curl with GITEA_TOKEN to manage labels
**Task:**
Analyze the issue and apply appropriate labels using GitHub CLI commands. Use these categories:
Analyze the issue and apply appropriate labels using Gitea API. 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 run 'gh label list' to see available labels
1. First list available labels via Gitea API
2. Analyze the issue content
3. Use 'gh issue edit #{issueNumber} --add-label "label1,label2,label3"' to apply labels
3. Use Gitea API to apply labels to the issue
4. Do NOT comment on the issue - only apply labels
**User Request:**
@@ -281,7 +280,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 GitHub ${isPullRequest ? 'pull request' : 'issue'}.
return `You are ${process.env.BOT_USERNAME}, an AI assistant responding to a Gitea ${isPullRequest ? 'pull request' : 'issue'}.
**Context:**
- Repository: ${repoFullName}
@@ -290,7 +289,7 @@ Complete the auto-tagging task using only the minimal required tools.`;
- Running in: Unattended mode
**Important Instructions:**
1. You have full GitHub CLI access via the 'gh' command
1. You have access to Gitea via the GITEA_TOKEN environment variable
2. When writing code:
- Always create a feature branch for new work
- Make commits with descriptive messages
@@ -300,17 +299,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 'gh issue comment' or 'gh pr comment' to provide updates on your progress
5. Use Gitea API to post comments and 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 GitHub will render correctly
- Return clean, human-readable markdown that Gitea 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 'gh issue comment' or 'gh pr comment' to post this acknowledgment immediately
- Use Gitea API to post this acknowledgment immediately
- This lets the user know their request was received and is being processed
**User Request:**
@@ -330,7 +329,7 @@ function createEnvironmentVars({
branchName,
operationType,
fullPrompt,
githubToken
giteaToken
}: {
repoFullName: string;
issueNumber: number | null;
@@ -338,7 +337,7 @@ function createEnvironmentVars({
branchName: string | null;
operationType: OperationType;
fullPrompt: string;
githubToken: string;
giteaToken: string;
}): ClaudeEnvironmentVars {
return {
REPO_FULL_NAME: repoFullName,
@@ -347,7 +346,8 @@ function createEnvironmentVars({
BRANCH_NAME: branchName ?? '',
OPERATION_TYPE: operationType,
COMMAND: fullPrompt,
GITHUB_TOKEN: githubToken,
GITEA_TOKEN: giteaToken,
GITEA_API_URL: process.env.GITEA_API_URL ?? '',
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 = [
'GITHUB_TOKEN',
'GITEA_TOKEN',
'ANTHROPIC_API_KEY',
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
@@ -504,7 +504,7 @@ function handleDockerExecutionError(
containerName: string;
dockerArgs: string[];
dockerImageName: string;
githubToken: string;
giteaToken: string;
repoFullName: string;
issueNumber: number | null;
}
@@ -518,7 +518,7 @@ function handleDockerExecutionError(
// Sensitive values to redact
const sensitiveValues = [
context.githubToken,
context.giteaToken,
secureCredentials.get('ANTHROPIC_API_KEY')
].filter(val => val && val.length > 0);
@@ -535,9 +535,7 @@ 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
/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
/sk-[a-zA-Z0-9]{32,}/g // API key pattern
];
sensitivePatterns.forEach(pattern => {
@@ -652,13 +650,11 @@ function handleGeneralError(
/AWS_ACCESS_KEY_ID="[^"]+"/g,
/AWS_SECRET_ACCESS_KEY="[^"]+"/g,
/AWS_SESSION_TOKEN="[^"]+"/g,
/GITHUB_TOKEN="[^"]+"/g,
/GITEA_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
/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
/sk-[a-zA-Z0-9]{32,}/g // API key pattern
];
sensitivePatterns.forEach(pattern => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,27 @@
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: GitHubWebhookPayload;
body: WebhookPayload;
}
export interface ClaudeAPIRequest extends Request {
@@ -101,9 +116,11 @@ export interface RequestLogData {
}
export interface WebhookHeaders {
'x-github-event'?: string;
'x-github-delivery'?: string;
'x-hub-signature-256'?: string;
// Gitea headers
'x-gitea-event'?: string;
'x-gitea-delivery'?: string;
'x-gitea-signature'?: string;
// Generic headers
'user-agent'?: string;
'content-type'?: string;
}

View File

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

View File

@@ -1,5 +1,4 @@
// Central export file for all types
export * from './github';
export * from './claude';
export * from './aws';
export * from './express';
@@ -33,14 +32,13 @@ 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 GitHubWebhookPayload {
export function isWebhookPayload(obj: unknown): obj is { repository: unknown; sender: unknown } {
return typeof obj === 'object' && obj !== null && 'repository' in obj && 'sender' in obj;
}
@@ -55,7 +53,6 @@ export function isAWSCredentials(obj: unknown): obj is AWSCredentials {
}
// Common type aliases for convenience
export type WebhookPayload = GitHubWebhookPayload;
export type ClaudeOptions = ClaudeCommandOptions;
export type AWSCreds = AWSCredentials;
export type AppConfig = ApplicationConfig;

View File

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

View File

@@ -121,9 +121,11 @@ 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',
@@ -141,9 +143,11 @@ 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',
@@ -169,9 +173,11 @@ 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',
@@ -188,9 +194,11 @@ 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',
@@ -208,9 +216,11 @@ 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"]',
@@ -228,9 +238,11 @@ 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"]',
@@ -329,6 +341,7 @@ const logger = pino({
'*.*.AWS_SECRET_ACCESS_KEY',
'*.*.AWS_ACCESS_KEY_ID',
'*.*.GITHUB_TOKEN',
'*.*.GITEA_TOKEN',
'*.*.ANTHROPIC_API_KEY',
'*.*.connectionString',
'*.*.DATABASE_URL',
@@ -345,6 +358,7 @@ const logger = pino({
'*.*.*.AWS_SECRET_ACCESS_KEY',
'*.*.*.AWS_ACCESS_KEY_ID',
'*.*.*.GITHUB_TOKEN',
'*.*.*.GITEA_TOKEN',
'*.*.*.ANTHROPIC_API_KEY',
'*.*.*.connectionString',
'*.*.*.DATABASE_URL',
@@ -361,6 +375,7 @@ const logger = pino({
'*.*.*.*.AWS_SECRET_ACCESS_KEY',
'*.*.*.*.AWS_ACCESS_KEY_ID',
'*.*.*.*.GITHUB_TOKEN',
'*.*.*.*.GITEA_TOKEN',
'*.*.*.*.ANTHROPIC_API_KEY',
'*.*.*.*.connectionString',
'*.*.*.*.DATABASE_URL'

View File

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

View File

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

View File

@@ -108,6 +108,156 @@ describe('SessionHandler', () => {
expect(response.success).toBe(false);
expect(response.error).toBe('Requirements are required for session creation');
});
it('should filter out invalid dependency values', async () => {
mockSessionManager.createContainer.mockResolvedValue('container-123');
const payload: ClaudeWebhookPayload = {
data: {
type: 'session.create',
session: {
project: {
repository: 'owner/repo',
requirements: 'Test requirements'
},
dependencies: ['', ' ', 'none', 'None', 'NONE', '550e8400-e29b-41d4-a716-446655440000']
}
},
metadata: {}
};
const response = await handler.handle(payload, mockContext);
expect(response.success).toBe(true);
// Only the valid UUID should remain
expect(response.data?.session.dependencies).toEqual(['550e8400-e29b-41d4-a716-446655440000']);
});
it('should fail with invalid UUID format in dependencies', async () => {
const payload: ClaudeWebhookPayload = {
data: {
type: 'session.create',
session: {
project: {
repository: 'owner/repo',
requirements: 'Test requirements'
},
dependencies: ['not-a-uuid', 'invalid-uuid-format', '12345']
}
},
metadata: {}
};
const response = await handler.handle(payload, mockContext);
expect(response.success).toBe(false);
expect(response.error).toBe(
'Invalid dependency IDs (not valid UUIDs): not-a-uuid, invalid-uuid-format, 12345'
);
});
it('should accept valid UUID dependencies', async () => {
mockSessionManager.createContainer.mockResolvedValue('container-123');
const validUUIDs = [
'550e8400-e29b-41d4-a716-446655440000',
'f47ac10b-58cc-4372-a567-0e02b2c3d479',
'6ba7b810-9dad-11d1-80b4-00c04fd430c8'
];
const payload: ClaudeWebhookPayload = {
data: {
type: 'session.create',
session: {
project: {
repository: 'owner/repo',
requirements: 'Test requirements'
},
dependencies: validUUIDs
}
},
metadata: {}
};
const response = await handler.handle(payload, mockContext);
expect(response.success).toBe(true);
expect(response.data?.session.dependencies).toEqual(validUUIDs);
});
it('should handle mixed valid and invalid dependencies', async () => {
const payload: ClaudeWebhookPayload = {
data: {
type: 'session.create',
session: {
project: {
repository: 'owner/repo',
requirements: 'Test requirements'
},
dependencies: [
'550e8400-e29b-41d4-a716-446655440000', // valid
'', // empty - filtered out
'none', // filtered out
'not-a-uuid', // invalid format
'f47ac10b-58cc-4372-a567-0e02b2c3d479' // valid
]
}
},
metadata: {}
};
const response = await handler.handle(payload, mockContext);
expect(response.success).toBe(false);
expect(response.error).toBe('Invalid dependency IDs (not valid UUIDs): not-a-uuid');
});
it('should handle empty dependencies array', async () => {
mockSessionManager.createContainer.mockResolvedValue('container-123');
const payload: ClaudeWebhookPayload = {
data: {
type: 'session.create',
session: {
project: {
repository: 'owner/repo',
requirements: 'Test requirements'
},
dependencies: []
}
},
metadata: {}
};
const response = await handler.handle(payload, mockContext);
expect(response.success).toBe(true);
expect(response.data?.session.dependencies).toEqual([]);
});
it('should handle dependencies with only filtered values', async () => {
mockSessionManager.createContainer.mockResolvedValue('container-123');
const payload: ClaudeWebhookPayload = {
data: {
type: 'session.create',
session: {
project: {
repository: 'owner/repo',
requirements: 'Test requirements'
},
dependencies: ['', ' ', 'none', 'None']
}
},
metadata: {}
};
const response = await handler.handle(payload, mockContext);
expect(response.success).toBe(true);
// All values filtered out, should result in empty array
expect(response.data?.session.dependencies).toEqual([]);
});
});
describe('session.get', () => {

View File

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

View File

@@ -1,111 +0,0 @@
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');
});
});
});