forked from claude-did-this/claude-hub
Compare commits
12 Commits
feat/claud
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cad59dc677 | |||
| cf71cd7104 | |||
| 752cc3a0fc | |||
| 8f55bfac35 | |||
| fea55b9d94 | |||
| b4527b8d2f | |||
| 65dd97e46b | |||
| b6d5f0f399 | |||
| 00cfc5ffbb | |||
|
|
3c8aebced8 | ||
|
|
c067efa13e | ||
|
|
65a590784c |
29
.env.example
29
.env.example
@@ -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
20
BREAKING_CHANGES.md
Normal 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
119
CLAUDE.md
@@ -2,14 +2,14 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Claude 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:
|
||||
|
||||
|
||||
27
Dockerfile
27
Dockerfile
@@ -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
569
claude-api-swagger.yaml
Normal 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
941
docs/claude-webhook-api.md
Normal 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
50
package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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];
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
704
src/providers/gitea/GiteaApiClient.ts
Normal file
704
src/providers/gitea/GiteaApiClient.ts
Normal 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();
|
||||
209
src/providers/gitea/GiteaWebhookProvider.ts
Normal file
209
src/providers/gitea/GiteaWebhookProvider.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import crypto from 'crypto';
|
||||
import { createLogger } from '../../utils/logger';
|
||||
import type { WebhookRequest } from '../../types/express';
|
||||
import type {
|
||||
WebhookProvider,
|
||||
BaseWebhookPayload,
|
||||
RepositoryInfo,
|
||||
UserInfo,
|
||||
IssueInfo,
|
||||
PullRequestInfo
|
||||
} from '../../types/webhook';
|
||||
import type {
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
11
src/providers/gitea/handlers/index.ts
Normal file
11
src/providers/gitea/handlers/index.ts
Normal 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';
|
||||
291
src/providers/gitea/handlers/issueHandler.ts
Normal file
291
src/providers/gitea/handlers/issueHandler.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
308
src/providers/gitea/handlers/pullRequestHandler.ts
Normal file
308
src/providers/gitea/handlers/pullRequestHandler.ts
Normal 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.`
|
||||
};
|
||||
}
|
||||
};
|
||||
303
src/providers/gitea/handlers/workflowHandler.ts
Normal file
303
src/providers/gitea/handlers/workflowHandler.ts
Normal 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
|
||||
};
|
||||
53
src/providers/gitea/index.ts
Normal file
53
src/providers/gitea/index.ts
Normal 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';
|
||||
320
src/providers/gitea/types.ts
Normal file
320
src/providers/gitea/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user