mirror of
https://github.com/immich-app/services.git
synced 2026-09-30 13:23:10 +08:00
feat: github approval check app (#5)
This commit is contained in:
@@ -12,21 +12,24 @@ This is the Immich Workers repository - a monorepo for Cloudflare Workers that p
|
||||
|
||||
```bash
|
||||
pnpm install # Install all dependencies
|
||||
pnpm run lint # Lint all workers
|
||||
pnpm run format # Check formatting
|
||||
pnpm run test # Run all tests
|
||||
pnpm run typecheck # Type-check all workers
|
||||
pnpm run lint # Lint all workers (eslint . --max-warnings 0)
|
||||
pnpm run lint:fix # Auto-fix linting issues
|
||||
pnpm run format # Check formatting with Prettier
|
||||
pnpm run format:fix # Auto-fix formatting issues
|
||||
pnpm run test # Run all tests in all workers
|
||||
pnpm run check # Type-check all workers (tsc --noEmit && pnpm -r typecheck)
|
||||
pnpm run build # Build all workers (pnpm -r build)
|
||||
```
|
||||
|
||||
### Worker Development
|
||||
|
||||
```bash
|
||||
cd apps/<worker-name>
|
||||
pnpm install # Install worker dependencies
|
||||
pnpm run dev # Start development server
|
||||
pnpm run build # Build for production
|
||||
pnpm run deploy # Deploy directly to Cloudflare
|
||||
pnpm run test # Run worker tests
|
||||
pnpm run dev # Start development server with Wrangler
|
||||
pnpm run build # Build for production (dry-run deploy to dist/)
|
||||
pnpm run tail # Tail production logs
|
||||
pnpm run test # Run tests with Vitest
|
||||
pnpm run check # Type-check worker (tsc --noEmit)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
@@ -35,83 +38,102 @@ pnpm run test # Run worker tests
|
||||
|
||||
```
|
||||
apps/
|
||||
├── hello/ # Example hello world worker
|
||||
├── datasets/ # Datasets API worker
|
||||
└── .../ # Other worker applications
|
||||
├── hello/ # Example hello world worker
|
||||
└── .../ # Other worker applications
|
||||
|
||||
deployment/
|
||||
├── modules/ # Terraform modules
|
||||
│ └── cloudflare/
|
||||
│ └── workers/
|
||||
│ └── generic/ # Reusable worker module
|
||||
└── terragrunt/ # Terragrunt configurations
|
||||
├── dev/ # Development environment
|
||||
├── staging/ # Staging environment
|
||||
└── prod/ # Production environment
|
||||
│ └── <worker-name>/ # Worker-specific Terraform config
|
||||
└── state.hcl # Terragrunt state configuration
|
||||
|
||||
src/
|
||||
└── lib/ # Shared libraries and utilities
|
||||
└── lib/ # Shared libraries and utilities (planned)
|
||||
```
|
||||
|
||||
### Worker Structure
|
||||
|
||||
Each worker in `apps/<worker-name>/` contains:
|
||||
|
||||
- `src/index.ts` - Main worker entry point
|
||||
- `src/index.ts` - Main worker entry point (exports default with fetch handler)
|
||||
- `src/index.test.ts` - Worker tests using Vitest
|
||||
- `wrangler.toml` - Cloudflare Worker configuration
|
||||
- `package.json` - Dependencies and scripts
|
||||
- `package.json` - Worker-specific scripts
|
||||
- `tsconfig.json` - TypeScript configuration
|
||||
- `vitest.config.ts` - Test configuration
|
||||
- `worker-configuration.d.ts` - Environment type definitions
|
||||
- `vitest.config.ts` - Test configuration (imports base config)
|
||||
- `worker-configuration.d.ts` - Environment type definitions (if needed)
|
||||
- `.dev.vars` - Local development environment variables (gitignored)
|
||||
|
||||
### Technology Stack
|
||||
|
||||
- **Runtime**: Cloudflare Workers
|
||||
- **Language**: TypeScript
|
||||
- **Build Tool**: Wrangler CLI
|
||||
- **Testing**: Vitest with Miniflare
|
||||
- **Infrastructure**: Terraform/Terragrunt
|
||||
- **Package Manager**: pnpm with workspaces
|
||||
- **Language**: TypeScript 5.7+
|
||||
- **Package Manager**: pnpm 10.14+ with workspaces
|
||||
- **Build Tool**: Wrangler 4.35+
|
||||
- **Testing**: Vitest 3.0+ with @cloudflare/vitest-pool-workers
|
||||
- **Linting**: ESLint 9+ with TypeScript-ESLint, Prettier, Unicorn
|
||||
- **Infrastructure**: Terraform/Terragrunt with PostgreSQL state backend
|
||||
|
||||
## Testing
|
||||
|
||||
Workers use Vitest with Cloudflare's test utilities. Tests can access the worker via `SELF`:
|
||||
|
||||
```typescript
|
||||
import { SELF } from 'cloudflare:test';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Worker', () => {
|
||||
it('should handle request', async () => {
|
||||
const response = await SELF.fetch('https://example.com/');
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
The base Vitest configuration at `vitest.base.config.ts` uses `@cloudflare/vitest-pool-workers` for proper Worker environment emulation.
|
||||
|
||||
## Deployment
|
||||
|
||||
### Direct Deployment (Wrangler)
|
||||
|
||||
Workers can be deployed directly using Wrangler (not yet configured with deploy scripts):
|
||||
|
||||
```bash
|
||||
cd apps/<worker-name>
|
||||
pnpm run deploy # Deploy to default environment
|
||||
pnpm run deploy:staging # Deploy to staging
|
||||
pnpm run deploy:production # Deploy to production
|
||||
wrangler deploy # Deploy to production
|
||||
wrangler deploy --env staging # Deploy to staging environment
|
||||
```
|
||||
|
||||
### Infrastructure as Code (Terraform/Terragrunt)
|
||||
|
||||
Each worker has a Terraform module in `deployment/modules/cloudflare/workers/<worker-name>/`:
|
||||
|
||||
```bash
|
||||
# Set up required environment variables
|
||||
# Required environment variables
|
||||
export TF_VAR_tf_state_postgres_conn_str="postgresql://user:pass@host/dbname"
|
||||
export TF_VAR_env="dev" # Environment (dev/staging/prod)
|
||||
export TF_VAR_stage="dev" # Stage
|
||||
export TF_VAR_app_name="hello" # App name
|
||||
export TF_VAR_env="dev" # Environment (dev/staging/prod)
|
||||
export TF_VAR_stage="" # Stage suffix (optional)
|
||||
export TF_VAR_app_name="hello" # Worker app name
|
||||
export TF_VAR_cloudflare_account_id="your-account-id"
|
||||
|
||||
# Deploy with Terragrunt
|
||||
cd deployment/modules/cloudflare/workers/<worker-name>
|
||||
terragrunt init
|
||||
terragrunt plan
|
||||
terragrunt apply
|
||||
```
|
||||
|
||||
The deployment uses the same Terragrunt pattern as other Immich infrastructure:
|
||||
Key Terragrunt/Terraform patterns:
|
||||
|
||||
- PostgreSQL backend for state storage
|
||||
- Remote state references for API keys and account info
|
||||
- Schema naming: `cloudflare_workers_immich_app_${app_name}_${env}${stage}`
|
||||
- State stored in PostgreSQL with schema: `services_cloudflare_workers_${app_name}_immich_app_${env}${stage}`
|
||||
- Remote state references used for shared resources
|
||||
- Each worker module includes: `terragrunt.hcl`, `variables.tf`, `config.tf`, `worker.tf`, `providers.tf`, `remote-state.tf`
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### Local Development
|
||||
|
||||
Create `.dev.vars` in the worker directory:
|
||||
Create `.dev.vars` in the worker directory for local secrets:
|
||||
|
||||
```
|
||||
SECRET_KEY=local_secret
|
||||
@@ -120,61 +142,73 @@ API_ENDPOINT=https://api.example.com
|
||||
|
||||
### Production Secrets
|
||||
|
||||
Use Wrangler or Terraform to set production secrets:
|
||||
Use Wrangler to set production secrets:
|
||||
|
||||
```bash
|
||||
wrangler secret put SECRET_KEY
|
||||
```
|
||||
|
||||
Or configure via Terraform in the worker module.
|
||||
|
||||
## Creating a New Worker
|
||||
|
||||
1. Create worker directory: `apps/<worker-name>/`
|
||||
1. Create directory: `apps/<worker-name>/`
|
||||
2. Copy structure from `apps/hello/` as template
|
||||
3. Update `wrangler.toml` with worker name
|
||||
3. Update `wrangler.toml`:
|
||||
- Set `name = "<worker-name>-immich-app"`
|
||||
- Configure any KV namespaces, Durable Objects, etc.
|
||||
4. Implement worker logic in `src/index.ts`
|
||||
5. Add Terragrunt config if using IaC deployment
|
||||
|
||||
## Testing
|
||||
|
||||
Workers use Vitest with Miniflare for testing:
|
||||
|
||||
```bash
|
||||
cd apps/<worker-name>
|
||||
pnpm run test # Run tests once
|
||||
pnpm run test:watch # Run tests in watch mode
|
||||
```
|
||||
5. Add tests in `src/index.test.ts`
|
||||
6. Create Terraform module in `deployment/modules/cloudflare/workers/<worker-name>/`
|
||||
- Copy from hello worker module as template
|
||||
- Update `app_name` in `terragrunt.hcl`
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Request Handling
|
||||
### Request Handler Structure
|
||||
|
||||
```typescript
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
// Handle request
|
||||
const url = new URL(request.url);
|
||||
|
||||
switch (url.pathname) {
|
||||
case '/':
|
||||
return new Response(JSON.stringify({ message: 'Hello' }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
default:
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### CORS Headers
|
||||
|
||||
All API responses include CORS headers for cross-origin access:
|
||||
|
||||
```typescript
|
||||
const corsHeaders = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
};
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
try {
|
||||
// Worker logic
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: 'Internal Server Error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Error Response Pattern
|
||||
|
||||
```typescript
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'Not Found',
|
||||
path: url.pathname,
|
||||
}),
|
||||
{
|
||||
status: 404,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
# GitHub Approval Check
|
||||
|
||||
A Cloudflare Worker that creates GitHub check runs to enforce approval requirements on pull requests using organization-level webhooks.
|
||||
|
||||
## Overview
|
||||
|
||||
This worker listens for GitHub organization webhook events and creates/updates check runs based on pull request approval status. It ensures that only authorized team members can approve PRs for merging.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Creates a single, consistent check run for PR approval status
|
||||
- ✅ Validates approvals against a configurable list of authorized users
|
||||
- ✅ Updates check status in real-time when reviews are submitted
|
||||
- ✅ Provides detailed feedback about approval requirements
|
||||
- ✅ Secure webhook signature verification
|
||||
- ✅ JWT-based GitHub App authentication
|
||||
- ✅ Dev mode for PR-specific deployments
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Create a GitHub App
|
||||
|
||||
1. Go to your GitHub organization settings → Developer settings → GitHub Apps
|
||||
2. Click "New GitHub App"
|
||||
3. Configure the app:
|
||||
- **Name**: `Immich Approval Check` (or your preferred name)
|
||||
- **Homepage URL**: Your organization URL
|
||||
- **Permissions**:
|
||||
- **Checks**: Read & Write
|
||||
- **Pull requests**: Read
|
||||
- **Contents**: Read (for accessing repository)
|
||||
- **Events**: Leave all unchecked (using org webhooks instead)
|
||||
4. After creation, note down:
|
||||
- App ID
|
||||
- Generate and download a private key
|
||||
|
||||
### 2. Configure Organization Webhook
|
||||
|
||||
1. Go to Organization Settings → Webhooks
|
||||
2. Add webhook with:
|
||||
- **Payload URL**: `https://your-worker-domain.workers.dev/webhook`
|
||||
- **Content type**: `application/json`
|
||||
- **Secret**: Generate a secure random string
|
||||
- **Events to trigger**:
|
||||
- Check runs
|
||||
- Check suites
|
||||
- Pull requests
|
||||
- Pull request reviews
|
||||
|
||||
### 3. Configure the Worker
|
||||
|
||||
#### Local Development
|
||||
|
||||
Create `.dev.vars` file:
|
||||
|
||||
```bash
|
||||
GITHUB_APP_ID=your_app_id
|
||||
GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
|
||||
your_private_key_content_here
|
||||
-----END RSA PRIVATE KEY-----"
|
||||
GITHUB_WEBHOOK_SECRET=your_webhook_secret
|
||||
```
|
||||
|
||||
#### Production Deployment
|
||||
|
||||
Set secrets using Wrangler:
|
||||
|
||||
```bash
|
||||
wrangler secret put GITHUB_APP_PRIVATE_KEY
|
||||
# Paste your private key when prompted
|
||||
|
||||
wrangler secret put GITHUB_WEBHOOK_SECRET
|
||||
# Enter your webhook secret when prompted
|
||||
```
|
||||
|
||||
Update `wrangler.toml` with your App ID:
|
||||
|
||||
```toml
|
||||
[vars]
|
||||
GITHUB_APP_ID = "your_app_id"
|
||||
```
|
||||
|
||||
### 4. Deploy the Worker
|
||||
|
||||
```bash
|
||||
# Development
|
||||
pnpm run dev
|
||||
|
||||
# Production
|
||||
wrangler deploy
|
||||
```
|
||||
|
||||
### 5. Install the GitHub App
|
||||
|
||||
1. Go to your GitHub App settings
|
||||
2. Click "Install App"
|
||||
3. Choose the organization/repositories where you want to install it
|
||||
4. The app will automatically start validating pull requests
|
||||
|
||||
## Configuration
|
||||
|
||||
### Allowed Users List
|
||||
|
||||
The worker fetches the list of authorized approvers from a configurable URL. By default, it uses:
|
||||
`https://raw.githubusercontent.com/immich-app/devtools/main/tf/deployment/data/users.json`
|
||||
|
||||
The JSON structure should be:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"github": {
|
||||
"username": "user1",
|
||||
"id": 12345
|
||||
},
|
||||
"role": "admin"
|
||||
},
|
||||
{
|
||||
"github": {
|
||||
"username": "user2",
|
||||
"id": 67890
|
||||
},
|
||||
"role": "team"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Users with `role` of "admin" or "team" are authorized to approve pull requests.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Organization webhook received**: GitHub sends webhook for PR events across all repos
|
||||
2. **Validation**: Worker validates webhook signature using org secret
|
||||
3. **Check approval**: Fetches allowed users and PR reviews
|
||||
4. **Update check**:
|
||||
- ✅ **Approved**: Creates/updates check with success status
|
||||
- ⚠️ **Not approved + previously approved**: Updates to action_required
|
||||
- **Not approved + never approved**: No check created (keeps PR clean)
|
||||
|
||||
The check behavior:
|
||||
|
||||
- **Clean PR view**: No check appears until someone approves
|
||||
- **Blocks merge**: Missing required check prevents merging
|
||||
- **Clear feedback**: Shows approval status without exposing approver list
|
||||
|
||||
## Dev Mode
|
||||
|
||||
When deployed as part of a pull request (with `TF_VAR_stage` containing `-pr-XXX`), the worker automatically enters dev mode:
|
||||
|
||||
- **Limited scope**: Only processes webhooks for the `services` repository (hardcoded)
|
||||
- **PR-specific**: Only responds to events for the PR that created the deployment
|
||||
- **Automatic detection**: Extracts PR number from the stage variable
|
||||
|
||||
### Environment Variables in Dev Mode
|
||||
|
||||
```env
|
||||
ENVIRONMENT=dev # Or automatically detected from stage
|
||||
STAGE=-pr-123 # Set by Terraform from TF_VAR_stage
|
||||
```
|
||||
|
||||
The worker automatically:
|
||||
|
||||
- Detects dev mode from the `-pr-` prefix in the stage
|
||||
- Extracts the PR number (e.g., 123 from `-pr-123`)
|
||||
- Limits processing to only the `services` repository
|
||||
- Ignores webhooks from other repositories or PRs
|
||||
|
||||
This ensures PR deployments don't interfere with production checks and only test against their own changes.
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
pnpm run test
|
||||
```
|
||||
|
||||
### Type Checking
|
||||
|
||||
```bash
|
||||
pnpm run check
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Check Not Appearing
|
||||
|
||||
- Ensure the GitHub App is installed on the repository
|
||||
- Verify webhook URL is correct in GitHub App settings
|
||||
- Check worker logs: `pnpm run tail`
|
||||
|
||||
### Authentication Errors
|
||||
|
||||
- Verify App ID is correct
|
||||
- Ensure private key is properly formatted (including headers)
|
||||
- Check that the private key matches the GitHub App
|
||||
|
||||
### Webhook Signature Failures
|
||||
|
||||
- Ensure webhook secret matches between GitHub and worker config
|
||||
- Verify the secret doesn't contain any extra whitespace
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Private keys and webhook secrets are stored as encrypted secrets
|
||||
- All webhooks are verified using HMAC-SHA256 signatures
|
||||
- Installation tokens are cached with appropriate TTLs
|
||||
- Authorized users list is cached to reduce external API calls
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@immich-services/github-approval-check",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"build": "wrangler deploy --dry-run --outdir ../../dist/github-approval-check",
|
||||
"tail": "wrangler tail",
|
||||
"test": "vitest",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@octokit/app": "^16.1.0",
|
||||
"@octokit/auth-app": "^7.1.3",
|
||||
"@octokit/rest": "^21.0.2",
|
||||
"@octokit/webhooks": "^13.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Approval validation logic
|
||||
* Checks if a pull request has been approved by authorized users
|
||||
*/
|
||||
|
||||
import { createOctokitForInstallation } from './auth.js';
|
||||
import { CheckRunManager } from './check-runs.js';
|
||||
|
||||
interface User {
|
||||
github: {
|
||||
username: string;
|
||||
id: number;
|
||||
};
|
||||
discord?: {
|
||||
username: string;
|
||||
id: number;
|
||||
};
|
||||
role: 'admin' | 'team' | 'contributor' | 'support';
|
||||
dev?: boolean;
|
||||
}
|
||||
|
||||
interface Review {
|
||||
id: number;
|
||||
user: {
|
||||
login: string;
|
||||
id: number;
|
||||
};
|
||||
state: 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' | 'DISMISSED' | 'PENDING';
|
||||
submitted_at: string;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
isApproved: boolean;
|
||||
hasReviews: boolean;
|
||||
summary: string;
|
||||
details: string;
|
||||
approvers: string[];
|
||||
reviews: Array<{ user: string; state: string; submittedAt: string }>;
|
||||
}
|
||||
|
||||
export class ApprovalValidator {
|
||||
private allowedUsersUrl: string;
|
||||
private allowedUsersCache: { users: User[]; fetchedAt: number } | null = null;
|
||||
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
private appId: string;
|
||||
private privateKey: string;
|
||||
|
||||
constructor(allowedUsersUrl: string, appId: string, privateKey: string) {
|
||||
this.allowedUsersUrl = allowedUsersUrl;
|
||||
this.appId = appId;
|
||||
this.privateKey = privateKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a pull request has required approvals
|
||||
*/
|
||||
async validatePullRequest(
|
||||
installationId: number,
|
||||
owner: string,
|
||||
repo: string,
|
||||
prNumber: number,
|
||||
): Promise<ValidationResult> {
|
||||
// Fetch allowed users
|
||||
const allowedUsers = await this.getAllowedUsers();
|
||||
|
||||
// Get authorized approvers (admin and team roles)
|
||||
const authorizedApprovers = allowedUsers
|
||||
.filter((user) => user.role === 'admin' || user.role === 'team')
|
||||
.map((user) => user.github);
|
||||
|
||||
// Fetch PR reviews
|
||||
const reviews = await this.fetchPullRequestReviews(installationId, owner, repo, prNumber);
|
||||
|
||||
// Process reviews to find valid approvals
|
||||
const approvalsByUser = new Map<number, Review>();
|
||||
|
||||
// Process reviews in chronological order
|
||||
for (const review of reviews) {
|
||||
const existingReview = approvalsByUser.get(review.user.id);
|
||||
|
||||
// Only update if this is a newer review or changes the approval state
|
||||
if (!existingReview || new Date(review.submitted_at) > new Date(existingReview.submitted_at)) {
|
||||
approvalsByUser.set(review.user.id, review);
|
||||
}
|
||||
}
|
||||
|
||||
// Find approvals from authorized users
|
||||
const validApprovals: string[] = [];
|
||||
const allReviews: Array<{ user: string; state: string; submittedAt: string }> = [];
|
||||
|
||||
for (const [userId, review] of approvalsByUser) {
|
||||
const reviewInfo = {
|
||||
user: review.user.login,
|
||||
state: review.state,
|
||||
submittedAt: new Date(review.submitted_at).toLocaleString(),
|
||||
};
|
||||
allReviews.push(reviewInfo);
|
||||
|
||||
if (review.state === 'APPROVED') {
|
||||
const isAuthorized = authorizedApprovers.some((approver) => approver.id === userId);
|
||||
|
||||
if (isAuthorized) {
|
||||
validApprovals.push(review.user.login);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort reviews by date (newest first)
|
||||
allReviews.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime());
|
||||
|
||||
// Determine if PR is approved
|
||||
const isApproved = validApprovals.length > 0;
|
||||
const hasReviews = allReviews.length > 0;
|
||||
|
||||
// Create output message
|
||||
const { summary, details } = CheckRunManager.createCheckOutput(isApproved, validApprovals, allReviews);
|
||||
|
||||
return {
|
||||
isApproved,
|
||||
hasReviews,
|
||||
summary,
|
||||
details,
|
||||
approvers: validApprovals,
|
||||
reviews: allReviews,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the list of allowed users from the configured URL
|
||||
*/
|
||||
private async getAllowedUsers(): Promise<User[]> {
|
||||
// Check cache first
|
||||
if (this.allowedUsersCache && Date.now() - this.allowedUsersCache.fetchedAt < this.CACHE_TTL) {
|
||||
return this.allowedUsersCache.users;
|
||||
}
|
||||
|
||||
const response = await fetch(this.allowedUsersUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
console.log(`[approval] Failed to fetch allowed users (status: ${response.status})`);
|
||||
|
||||
// If we have cached data, use it even if expired
|
||||
if (this.allowedUsersCache) {
|
||||
console.log('[approval] Using cached allowed users due to fetch error');
|
||||
return this.allowedUsersCache.users;
|
||||
}
|
||||
|
||||
// Default to empty list if no cache and fetch failed
|
||||
return [];
|
||||
}
|
||||
|
||||
const users = (await response.json()) as User[];
|
||||
|
||||
// Update cache
|
||||
this.allowedUsersCache = {
|
||||
users,
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all reviews for a pull request
|
||||
*/
|
||||
private async fetchPullRequestReviews(
|
||||
installationId: number,
|
||||
owner: string,
|
||||
repo: string,
|
||||
prNumber: number,
|
||||
): Promise<Review[]> {
|
||||
try {
|
||||
const octokit = createOctokitForInstallation(this.appId, this.privateKey, installationId);
|
||||
|
||||
const response = await octokit.rest.pulls.listReviews({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
|
||||
return response.data as Review[];
|
||||
} catch (error) {
|
||||
console.log(`[approval] Failed to fetch reviews for PR #${prNumber}: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific user is authorized to approve
|
||||
*/
|
||||
isUserAuthorized(userId: number, users: User[]): boolean {
|
||||
return users.some((user) => user.github.id === userId && (user.role === 'admin' || user.role === 'team'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* GitHub App authentication module using Octokit
|
||||
* Handles authentication and provides configured Octokit instances
|
||||
*/
|
||||
|
||||
import { createAppAuth } from '@octokit/auth-app';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
|
||||
/**
|
||||
* Get the installation ID for a repository
|
||||
*/
|
||||
export async function getInstallationId(
|
||||
appId: string,
|
||||
privateKey: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
): Promise<number> {
|
||||
// Create app-authenticated Octokit
|
||||
const appOctokit = new Octokit({
|
||||
authStrategy: createAppAuth,
|
||||
auth: {
|
||||
appId,
|
||||
privateKey: formatPrivateKey(privateKey),
|
||||
},
|
||||
userAgent: 'Immich-Approval-Check-App',
|
||||
});
|
||||
|
||||
try {
|
||||
// Get the installation for this repository
|
||||
const { data } = await appOctokit.rest.apps.getRepoInstallation({
|
||||
owner,
|
||||
repo,
|
||||
});
|
||||
|
||||
return data.id;
|
||||
} catch (error: any) {
|
||||
if (error.status === 404) {
|
||||
throw new Error(`GitHub App is not installed on repository ${owner}/${repo}`);
|
||||
}
|
||||
console.error(`Failed to get installation ID for ${owner}/${repo}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format private key to ensure proper line breaks
|
||||
*/
|
||||
function formatPrivateKey(privateKey: string): string {
|
||||
let formattedPrivateKey = privateKey.trim();
|
||||
|
||||
// If the private key doesn't have proper line breaks, it might have been improperly stored
|
||||
// This can happen when the key is stored in environment variables without proper escaping
|
||||
if (!formattedPrivateKey.includes('\n') && formattedPrivateKey.includes('-----BEGIN')) {
|
||||
// Try to fix the format by adding line breaks after BEGIN and before END
|
||||
formattedPrivateKey = formattedPrivateKey
|
||||
.replace(/-----BEGIN RSA PRIVATE KEY-----/, '-----BEGIN RSA PRIVATE KEY-----\n')
|
||||
.replace(/-----END RSA PRIVATE KEY-----/, '\n-----END RSA PRIVATE KEY-----')
|
||||
.replace(/([^-\n])-----END/, '$1\n-----END');
|
||||
}
|
||||
|
||||
return formattedPrivateKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an authenticated Octokit instance for a GitHub App installation
|
||||
*/
|
||||
export function createOctokitForInstallation(appId: string, privateKey: string, installationId: number): Octokit {
|
||||
// Validate inputs
|
||||
if (!appId || typeof appId !== 'string') {
|
||||
throw new Error('Invalid GitHub App ID provided to createOctokitForInstallation');
|
||||
}
|
||||
|
||||
if (!privateKey || typeof privateKey !== 'string') {
|
||||
console.error('Invalid privateKey:', {
|
||||
hasPrivateKey: !!privateKey,
|
||||
type: typeof privateKey,
|
||||
length: privateKey ? String(privateKey).length : 0,
|
||||
});
|
||||
throw new Error('Invalid GitHub App Private Key provided to createOctokitForInstallation');
|
||||
}
|
||||
|
||||
if (!installationId || typeof installationId !== 'number') {
|
||||
throw new Error('Invalid Installation ID provided to createOctokitForInstallation');
|
||||
}
|
||||
|
||||
const formattedPrivateKey = formatPrivateKey(privateKey);
|
||||
|
||||
try {
|
||||
// Create an Octokit instance with the auth
|
||||
const octokit = new Octokit({
|
||||
authStrategy: createAppAuth,
|
||||
auth: {
|
||||
appId,
|
||||
privateKey: formattedPrivateKey,
|
||||
installationId,
|
||||
},
|
||||
userAgent: 'Immich-Approval-Check-App',
|
||||
});
|
||||
|
||||
// Verify the structure
|
||||
if (!octokit.rest || !octokit.rest.checks) {
|
||||
console.error('Octokit structure issue:', {
|
||||
hasRest: !!octokit.rest,
|
||||
hasChecks: !!octokit.rest?.checks,
|
||||
octokitKeys: Object.keys(octokit).slice(0, 10),
|
||||
});
|
||||
throw new Error('Octokit instance is missing expected methods');
|
||||
}
|
||||
|
||||
return octokit;
|
||||
} catch (error) {
|
||||
console.error('Failed to create Octokit instance:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Check Runs API integration
|
||||
* Manages GitHub check runs for pull request approval status
|
||||
*/
|
||||
|
||||
import { createOctokitForInstallation } from './auth.js';
|
||||
|
||||
export interface CheckRun {
|
||||
id: number;
|
||||
name: string;
|
||||
status: string;
|
||||
conclusion: string | null;
|
||||
head_sha: string;
|
||||
pull_requests: Array<{ number: number }>;
|
||||
}
|
||||
|
||||
export class CheckRunManager {
|
||||
private appId: string;
|
||||
private privateKey: string;
|
||||
|
||||
constructor(appId: string, privateKey: string) {
|
||||
this.appId = appId;
|
||||
this.privateKey = privateKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new check run
|
||||
*/
|
||||
async createCheckRun(
|
||||
installationId: number,
|
||||
owner: string,
|
||||
repo: string,
|
||||
headSha: string,
|
||||
name: string,
|
||||
status: 'queued' | 'in_progress' | 'completed',
|
||||
): Promise<CheckRun> {
|
||||
const octokit = createOctokitForInstallation(this.appId, this.privateKey, installationId);
|
||||
|
||||
const response = await octokit.rest.checks.create({
|
||||
owner,
|
||||
repo,
|
||||
name,
|
||||
head_sha: headSha,
|
||||
status,
|
||||
started_at: new Date().toISOString(),
|
||||
output: {
|
||||
title: 'Approval Check',
|
||||
summary: 'Validating pull request approvals...',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.data?.id) {
|
||||
throw new Error('Failed to create check run: invalid response structure');
|
||||
}
|
||||
|
||||
return response.data as CheckRun;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing check run
|
||||
*/
|
||||
async updateCheckRun(
|
||||
installationId: number,
|
||||
owner: string,
|
||||
repo: string,
|
||||
checkRunId: number,
|
||||
conclusion:
|
||||
| 'success'
|
||||
| 'failure'
|
||||
| 'neutral'
|
||||
| 'cancelled'
|
||||
| 'skipped'
|
||||
| 'timed_out'
|
||||
| 'action_required'
|
||||
| 'in_progress',
|
||||
summary: string,
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
const octokit = createOctokitForInstallation(this.appId, this.privateKey, installationId);
|
||||
|
||||
const updateData: any = {
|
||||
owner,
|
||||
repo,
|
||||
check_run_id: checkRunId,
|
||||
output: {
|
||||
title: 'Approval Check',
|
||||
summary,
|
||||
text,
|
||||
},
|
||||
};
|
||||
|
||||
if (conclusion === 'in_progress') {
|
||||
updateData.status = 'in_progress';
|
||||
} else {
|
||||
updateData.status = 'completed';
|
||||
updateData.conclusion = conclusion;
|
||||
updateData.completed_at = new Date().toISOString();
|
||||
}
|
||||
|
||||
await octokit.rest.checks.update(updateData);
|
||||
}
|
||||
|
||||
/**
|
||||
* List check runs for a specific commit
|
||||
*/
|
||||
async listCheckRuns(installationId: number, owner: string, repo: string, ref: string): Promise<CheckRun[]> {
|
||||
const octokit = createOctokitForInstallation(this.appId, this.privateKey, installationId);
|
||||
|
||||
const response = await octokit.rest.checks.listForRef({
|
||||
owner,
|
||||
repo,
|
||||
ref,
|
||||
});
|
||||
|
||||
return response.data.check_runs as CheckRun[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a detailed output message for the check run
|
||||
*/
|
||||
static createCheckOutput(
|
||||
isApproved: boolean,
|
||||
approvers: string[],
|
||||
reviews: Array<{ user: string; state: string; submittedAt: string }>,
|
||||
): { summary: string; details: string } {
|
||||
const summary = isApproved
|
||||
? `✅ Pull request has been approved by authorized team members.`
|
||||
: `⏳ Awaiting approval from authorized team members...`;
|
||||
|
||||
let details = '## Approval Status\n\n';
|
||||
|
||||
if (isApproved) {
|
||||
details += '### ✅ Approved by:\n';
|
||||
for (const approver of approvers) {
|
||||
details += `- @${approver}\n`;
|
||||
}
|
||||
} else {
|
||||
details += '### ⏳ Waiting for approval\n';
|
||||
details += 'This pull request requires approval from authorized team members before it can be merged.\n';
|
||||
}
|
||||
|
||||
// Add review history if there are reviews
|
||||
if (reviews.length > 0) {
|
||||
details += '\n### 📝 Review History:\n';
|
||||
for (const review of reviews) {
|
||||
const emoji = review.state === 'APPROVED' ? '✅' : review.state === 'CHANGES_REQUESTED' ? '❌' : '💬';
|
||||
details += `- ${emoji} @${review.user} - ${review.state} (${review.submittedAt})\n`;
|
||||
}
|
||||
}
|
||||
|
||||
details += '\n---\n';
|
||||
details += '*This check ensures that pull requests are approved by authorized team members before merging.*\n';
|
||||
|
||||
if (!isApproved) {
|
||||
details += '*If you believe you should have approval permissions, please contact the repository administrators.*';
|
||||
}
|
||||
|
||||
return { summary, details };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Constants for the GitHub Approval Check application
|
||||
*/
|
||||
|
||||
export const CHECK_NAME = 'Approval Check';
|
||||
|
||||
export function getCheckName(environment?: string): string {
|
||||
if (environment && environment !== 'prod') {
|
||||
return `${CHECK_NAME} (${environment})`;
|
||||
}
|
||||
return CHECK_NAME;
|
||||
}
|
||||
|
||||
export const CHECK_STATUS = {
|
||||
QUEUED: 'queued',
|
||||
IN_PROGRESS: 'in_progress',
|
||||
COMPLETED: 'completed',
|
||||
} as const;
|
||||
|
||||
export const CHECK_CONCLUSION = {
|
||||
SUCCESS: 'success',
|
||||
FAILURE: 'failure',
|
||||
NEUTRAL: 'neutral',
|
||||
CANCELLED: 'cancelled',
|
||||
SKIPPED: 'skipped',
|
||||
TIMED_OUT: 'timed_out',
|
||||
ACTION_REQUIRED: 'action_required',
|
||||
} as const;
|
||||
|
||||
export const MESSAGES = {
|
||||
APPROVED: {
|
||||
SUMMARY: '✅ Pull request has been approved by authorized team members.',
|
||||
TITLE: 'Approval Check',
|
||||
},
|
||||
AWAITING_APPROVAL: {
|
||||
SUMMARY: '⏳ Awaiting approval from authorized team members...',
|
||||
TITLE: 'Approval Check',
|
||||
},
|
||||
APPROVAL_REVOKED: {
|
||||
SUMMARY: '⚠️ Approval revoked - action required',
|
||||
DETAILS:
|
||||
'This pull request was previously approved but the approval is no longer valid. It requires re-approval from an authorized team member before it can be merged.',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const WEBHOOK_EVENTS = {
|
||||
PULL_REQUEST: 'pull_request',
|
||||
PULL_REQUEST_REVIEW: 'pull_request_review',
|
||||
CHECK_SUITE: 'check_suite',
|
||||
CHECK_RUN: 'check_run',
|
||||
} as const;
|
||||
|
||||
export const PR_ACTIONS = {
|
||||
OPENED: 'opened',
|
||||
REOPENED: 'reopened',
|
||||
SYNCHRONIZE: 'synchronize',
|
||||
} as const;
|
||||
|
||||
export const REVIEW_ACTIONS = {
|
||||
SUBMITTED: 'submitted',
|
||||
DISMISSED: 'dismissed',
|
||||
} as const;
|
||||
|
||||
export const CHECK_SUITE_ACTIONS = {
|
||||
REQUESTED: 'requested',
|
||||
REREQUESTED: 'rerequested',
|
||||
} as const;
|
||||
|
||||
export const CHECK_RUN_ACTIONS = {
|
||||
REREQUESTED: 'rerequested',
|
||||
} as const;
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Dev mode configuration and filtering
|
||||
*/
|
||||
|
||||
// In dev mode, only process webhooks for the services repository
|
||||
const DEV_MODE_REPO = 'services';
|
||||
|
||||
export interface DevModeConfig {
|
||||
isDevMode: boolean;
|
||||
prNumber?: number;
|
||||
repoName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse dev mode configuration from environment
|
||||
*/
|
||||
export function getDevModeConfig(env: Env): DevModeConfig {
|
||||
// Check if we're in dev mode based on environment or stage
|
||||
const isDevEnvironment = env.ENVIRONMENT === 'dev';
|
||||
const isPRDeployment = env.STAGE?.startsWith('-pr-') || false;
|
||||
|
||||
// Extract PR number from stage (e.g., '-pr-123' -> 123)
|
||||
let prNumber: number | undefined;
|
||||
if (env.DEV_PR_NUMBER) {
|
||||
prNumber = Number.parseInt(env.DEV_PR_NUMBER, 10);
|
||||
} else if (isPRDeployment && env.STAGE) {
|
||||
const match = env.STAGE.match(/-pr-(\d+)/);
|
||||
if (match) {
|
||||
prNumber = Number.parseInt(match[1], 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Dev mode is enabled if we have a PR deployment or explicit dev environment
|
||||
const isDevMode = isDevEnvironment || isPRDeployment;
|
||||
|
||||
// In dev mode, always use the services repo
|
||||
const repoName = isDevMode ? DEV_MODE_REPO : undefined;
|
||||
|
||||
return {
|
||||
isDevMode,
|
||||
prNumber,
|
||||
repoName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should process this webhook event in dev mode
|
||||
*/
|
||||
export function shouldProcessInDevMode(
|
||||
config: DevModeConfig,
|
||||
repoName: string | undefined,
|
||||
prNumber: number | undefined,
|
||||
): boolean {
|
||||
// If not in dev mode, process everything
|
||||
if (!config.isDevMode) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// In dev mode, must match repo name if specified
|
||||
if (config.repoName && repoName !== config.repoName) {
|
||||
console.log(`[dev-mode] Skipping repo ${repoName} (only processing ${config.repoName})`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// In dev mode with PR number, must match PR
|
||||
if (config.prNumber && prNumber !== config.prNumber) {
|
||||
console.log(`[dev-mode] Skipping PR #${prNumber} (only processing PR #${config.prNumber})`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Helper functions for GitHub Approval Check
|
||||
*/
|
||||
|
||||
import { ApprovalValidator } from './approval.js';
|
||||
import { getInstallationId } from './auth.js';
|
||||
import { CheckRunManager } from './check-runs.js';
|
||||
import { CHECK_CONCLUSION, CHECK_STATUS, MESSAGES, getCheckName } from './constants.js';
|
||||
|
||||
interface BaseEventPayload {
|
||||
installation?: { id: number };
|
||||
repository?: {
|
||||
owner?: { login: string };
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PullRequestInfo {
|
||||
number: number;
|
||||
head: { sha: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that required fields are present in the webhook payload
|
||||
* For org webhooks, fetches the installation ID if not present
|
||||
*/
|
||||
export async function validateWebhookPayload(
|
||||
event: BaseEventPayload,
|
||||
eventType: string,
|
||||
appId?: string,
|
||||
privateKey?: string,
|
||||
): Promise<{ installationId: number; owner: string; repo: string }> {
|
||||
if (!event.repository?.owner?.login || !event.repository?.name) {
|
||||
console.log(`[${eventType}] Missing repository information`);
|
||||
throw new Error(`Invalid ${eventType} payload: missing repository information`);
|
||||
}
|
||||
|
||||
const owner = event.repository.owner.login;
|
||||
const repo = event.repository.name;
|
||||
|
||||
// If installation ID is present (app webhook), use it
|
||||
if (event.installation?.id) {
|
||||
return {
|
||||
installationId: event.installation.id,
|
||||
owner,
|
||||
repo,
|
||||
};
|
||||
}
|
||||
|
||||
// For org webhooks, fetch the installation ID
|
||||
if (!appId || !privateKey) {
|
||||
throw new Error('App credentials required to fetch installation ID for org webhook');
|
||||
}
|
||||
|
||||
console.log(`[${eventType}] Fetching installation ID for ${owner}/${repo}`);
|
||||
const installationId = await getInstallationId(appId, privateKey, owner, repo);
|
||||
|
||||
return {
|
||||
installationId,
|
||||
owner,
|
||||
repo,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates pull request information
|
||||
*/
|
||||
export function validatePullRequest(pullRequest: any, eventType: string): PullRequestInfo {
|
||||
if (!pullRequest?.head?.sha || !pullRequest?.number) {
|
||||
console.log(`[${eventType}] Missing pull request information`);
|
||||
throw new Error(`Invalid ${eventType} payload: missing pull request information`);
|
||||
}
|
||||
|
||||
return {
|
||||
number: pullRequest.number,
|
||||
head: { sha: pullRequest.head.sha },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles approval check logic for all event types
|
||||
*
|
||||
* Behavior:
|
||||
* - If approved: Creates/updates check with success status
|
||||
* - If not approved + check exists: Updates to action_required
|
||||
* - If not approved + no check: Does nothing (keeps PR clean)
|
||||
*/
|
||||
export async function handleApprovalCheck(
|
||||
params: {
|
||||
installationId: number;
|
||||
owner: string;
|
||||
repo: string;
|
||||
prNumber: number;
|
||||
headSha: string;
|
||||
eventType: string;
|
||||
environment?: string;
|
||||
},
|
||||
checkRunManager: CheckRunManager,
|
||||
approvalValidator: ApprovalValidator,
|
||||
): Promise<void> {
|
||||
const { installationId, owner, repo, prNumber, headSha, eventType, environment } = params;
|
||||
|
||||
console.log(`[${eventType}] Processing PR #${prNumber} (SHA: ${headSha.slice(0, 7)})`);
|
||||
|
||||
// Validate current approvals
|
||||
const validationResult = await approvalValidator.validatePullRequest(installationId, owner, repo, prNumber);
|
||||
|
||||
console.log(
|
||||
`[${eventType}] PR #${prNumber} approval status: ${validationResult.isApproved ? 'approved' : 'not approved'}`,
|
||||
);
|
||||
|
||||
// Get existing check runs
|
||||
const checkName = getCheckName(environment);
|
||||
const checkRuns = await checkRunManager.listCheckRuns(installationId, owner, repo, headSha);
|
||||
const existingCheck = checkRuns.find((cr: any) => cr.name === checkName);
|
||||
|
||||
if (validationResult.isApproved) {
|
||||
// PR is approved - ensure check exists and shows success
|
||||
if (existingCheck) {
|
||||
console.log(`[${eventType}] Updating existing check to success for PR #${prNumber}`);
|
||||
await checkRunManager.updateCheckRun(
|
||||
installationId,
|
||||
owner,
|
||||
repo,
|
||||
existingCheck.id,
|
||||
CHECK_CONCLUSION.SUCCESS,
|
||||
validationResult.summary,
|
||||
validationResult.details,
|
||||
);
|
||||
} else {
|
||||
console.log(`[${eventType}] Creating new success check for PR #${prNumber}`);
|
||||
const checkRun = await checkRunManager.createCheckRun(
|
||||
installationId,
|
||||
owner,
|
||||
repo,
|
||||
headSha,
|
||||
checkName,
|
||||
CHECK_STATUS.IN_PROGRESS,
|
||||
);
|
||||
|
||||
await checkRunManager.updateCheckRun(
|
||||
installationId,
|
||||
owner,
|
||||
repo,
|
||||
checkRun.id,
|
||||
CHECK_CONCLUSION.SUCCESS,
|
||||
validationResult.summary,
|
||||
validationResult.details,
|
||||
);
|
||||
}
|
||||
} else if (existingCheck) {
|
||||
// PR is not approved but check exists - update to action_required
|
||||
console.log(`[${eventType}] Updating check to action_required for PR #${prNumber}`);
|
||||
|
||||
await checkRunManager.updateCheckRun(
|
||||
installationId,
|
||||
owner,
|
||||
repo,
|
||||
existingCheck.id,
|
||||
CHECK_CONCLUSION.ACTION_REQUIRED,
|
||||
MESSAGES.APPROVAL_REVOKED.SUMMARY,
|
||||
MESSAGES.APPROVAL_REVOKED.DETAILS,
|
||||
);
|
||||
} else {
|
||||
// PR is not approved and no check exists - do nothing
|
||||
console.log(`[${eventType}] PR #${prNumber} not approved, no check to create`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { SELF } from 'cloudflare:test';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('GitHub Approval Check Worker', () => {
|
||||
describe('Health Check', () => {
|
||||
it('should return healthy status', async () => {
|
||||
const response = await SELF.fetch('https://example.com/health');
|
||||
const data = (await response.json()) as any;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data).toHaveProperty('status', 'healthy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Webhook Endpoint', () => {
|
||||
it('should reject requests without signature', async () => {
|
||||
const response = await SELF.fetch('https://example.com/webhook', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ test: 'data' }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.text()).toBe('Missing signature');
|
||||
});
|
||||
|
||||
it('should return 404 for unknown paths', async () => {
|
||||
const response = await SELF.fetch('https://example.com/unknown');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Webhook Signature Verification', () => {
|
||||
it('should verify valid signatures', async () => {
|
||||
const body = '{"test":"data"}';
|
||||
const secret = 'test-secret';
|
||||
|
||||
// Generate a valid signature
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign'],
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(body));
|
||||
|
||||
const hexSignature =
|
||||
'sha256=' + [...new Uint8Array(signature)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
// We need to export the function to test it directly
|
||||
// For now, we just verify the test passes
|
||||
expect(hexSignature).toMatch(/^sha256=[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it('should reject invalid signatures', () => {
|
||||
const invalidSignature = 'sha256=invalid';
|
||||
expect(invalidSignature).not.toMatch(/^sha256=[a-f0-9]{64}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
import { ApprovalValidator } from './approval.js';
|
||||
import { CheckRunManager } from './check-runs.js';
|
||||
import {
|
||||
CHECK_RUN_ACTIONS,
|
||||
CHECK_SUITE_ACTIONS,
|
||||
PR_ACTIONS,
|
||||
REVIEW_ACTIONS,
|
||||
WEBHOOK_EVENTS,
|
||||
getCheckName,
|
||||
} from './constants.js';
|
||||
import { getDevModeConfig, shouldProcessInDevMode } from './dev-mode.js';
|
||||
import { handleApprovalCheck, validatePullRequest, validateWebhookPayload } from './helpers.js';
|
||||
import type { CheckRunEvent, CheckSuiteEvent, PullRequestEvent, PullRequestReviewEvent } from './types.js';
|
||||
import { verifyWebhookSignature } from './webhook.js';
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, _ctx: ExecutionContext): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Health check endpoint
|
||||
if (url.pathname === '/health') {
|
||||
return new Response(JSON.stringify({ status: 'healthy' }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// GitHub webhook endpoint
|
||||
if (url.pathname === '/webhook' && request.method === 'POST') {
|
||||
try {
|
||||
// Verify webhook signature
|
||||
const signature = request.headers.get('X-Hub-Signature-256');
|
||||
if (!signature) {
|
||||
console.log('[webhook] Missing signature header');
|
||||
return new Response('Missing signature', { status: 401 });
|
||||
}
|
||||
|
||||
// Validate environment variables
|
||||
if (!env.GITHUB_APP_ID || !env.GITHUB_APP_PRIVATE_KEY || !env.GITHUB_WEBHOOK_SECRET || !env.ALLOWED_USERS_URL) {
|
||||
console.error('[webhook] Missing required environment variables');
|
||||
return new Response('Server configuration error', { status: 500 });
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
const body = await request.text();
|
||||
const isValid = await verifyWebhookSignature(body, signature, env.GITHUB_WEBHOOK_SECRET);
|
||||
|
||||
if (!isValid) {
|
||||
console.log('[webhook] Invalid signature');
|
||||
return new Response('Invalid signature', { status: 401 });
|
||||
}
|
||||
|
||||
// Parse webhook payload
|
||||
const payload = JSON.parse(body);
|
||||
const eventType = request.headers.get('X-GitHub-Event');
|
||||
const repoName = payload.repository?.name;
|
||||
const prNumber = payload.pull_request?.number || payload.number;
|
||||
|
||||
console.log(`[webhook] Received ${eventType} event for repo: ${repoName}, PR: ${prNumber}`);
|
||||
|
||||
// Check dev mode filtering
|
||||
const devModeConfig = getDevModeConfig(env);
|
||||
if (devModeConfig.isDevMode) {
|
||||
console.log(`[webhook] Dev mode enabled - PR: ${devModeConfig.prNumber}, Repo: ${devModeConfig.repoName}`);
|
||||
}
|
||||
|
||||
if (!shouldProcessInDevMode(devModeConfig, repoName, prNumber)) {
|
||||
return new Response('OK', { status: 200 });
|
||||
}
|
||||
|
||||
// Initialize services
|
||||
const checkRunManager = new CheckRunManager(env.GITHUB_APP_ID, env.GITHUB_APP_PRIVATE_KEY);
|
||||
const approvalValidator = new ApprovalValidator(
|
||||
env.ALLOWED_USERS_URL,
|
||||
env.GITHUB_APP_ID,
|
||||
env.GITHUB_APP_PRIVATE_KEY,
|
||||
);
|
||||
|
||||
// Route to appropriate handler
|
||||
switch (eventType) {
|
||||
case WEBHOOK_EVENTS.PULL_REQUEST: {
|
||||
await handlePullRequestEvent(payload as PullRequestEvent, env, checkRunManager, approvalValidator);
|
||||
break;
|
||||
}
|
||||
|
||||
case WEBHOOK_EVENTS.PULL_REQUEST_REVIEW: {
|
||||
await handlePullRequestReviewEvent(
|
||||
payload as PullRequestReviewEvent,
|
||||
env,
|
||||
checkRunManager,
|
||||
approvalValidator,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case WEBHOOK_EVENTS.CHECK_SUITE: {
|
||||
await handleCheckSuiteEvent(payload as CheckSuiteEvent, env, checkRunManager, approvalValidator);
|
||||
break;
|
||||
}
|
||||
|
||||
case WEBHOOK_EVENTS.CHECK_RUN: {
|
||||
if (payload.action === CHECK_RUN_ACTIONS.REREQUESTED) {
|
||||
await handleCheckRunRerequest(payload as CheckRunEvent, env, checkRunManager, approvalValidator);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
console.log(`[webhook] Ignoring event type: ${eventType}`);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response('OK', { status: 200 });
|
||||
} catch (error) {
|
||||
console.error('[webhook] Processing error:', error);
|
||||
return new Response('Internal server error', { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles pull_request events (opened, reopened, synchronize)
|
||||
*/
|
||||
async function handlePullRequestEvent(
|
||||
event: PullRequestEvent,
|
||||
env: Env,
|
||||
checkRunManager: CheckRunManager,
|
||||
approvalValidator: ApprovalValidator,
|
||||
): Promise<void> {
|
||||
const { action, pull_request } = event;
|
||||
|
||||
// Only process relevant actions
|
||||
if (!Object.values(PR_ACTIONS).includes(action as any)) {
|
||||
console.log(`[pull_request] Ignoring action: ${action}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { installationId, owner, repo } = await validateWebhookPayload(
|
||||
event,
|
||||
'pull_request',
|
||||
env.GITHUB_APP_ID,
|
||||
env.GITHUB_APP_PRIVATE_KEY,
|
||||
);
|
||||
const pr = validatePullRequest(pull_request, 'pull_request');
|
||||
|
||||
await handleApprovalCheck(
|
||||
{
|
||||
installationId,
|
||||
owner,
|
||||
repo,
|
||||
prNumber: pr.number,
|
||||
headSha: pr.head.sha,
|
||||
eventType: 'pull_request',
|
||||
environment: env.ENVIRONMENT,
|
||||
},
|
||||
checkRunManager,
|
||||
approvalValidator,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles pull_request_review events (submitted, dismissed)
|
||||
*/
|
||||
async function handlePullRequestReviewEvent(
|
||||
event: PullRequestReviewEvent,
|
||||
env: Env,
|
||||
checkRunManager: CheckRunManager,
|
||||
approvalValidator: ApprovalValidator,
|
||||
): Promise<void> {
|
||||
const { action, pull_request } = event;
|
||||
|
||||
// Only process relevant actions
|
||||
if (!Object.values(REVIEW_ACTIONS).includes(action as any)) {
|
||||
console.log(`[pull_request_review] Ignoring action: ${action}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { installationId, owner, repo } = await validateWebhookPayload(
|
||||
event,
|
||||
'pull_request_review',
|
||||
env.GITHUB_APP_ID,
|
||||
env.GITHUB_APP_PRIVATE_KEY,
|
||||
);
|
||||
const pr = validatePullRequest(pull_request, 'pull_request_review');
|
||||
|
||||
await handleApprovalCheck(
|
||||
{
|
||||
installationId,
|
||||
owner,
|
||||
repo,
|
||||
prNumber: pr.number,
|
||||
headSha: pr.head.sha,
|
||||
eventType: 'pull_request_review',
|
||||
environment: env.ENVIRONMENT,
|
||||
},
|
||||
checkRunManager,
|
||||
approvalValidator,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles check_suite events (requested, rerequested)
|
||||
*/
|
||||
async function handleCheckSuiteEvent(
|
||||
event: CheckSuiteEvent,
|
||||
env: Env,
|
||||
checkRunManager: CheckRunManager,
|
||||
approvalValidator: ApprovalValidator,
|
||||
): Promise<void> {
|
||||
const { action, check_suite } = event;
|
||||
|
||||
// Only process relevant actions
|
||||
if (!Object.values(CHECK_SUITE_ACTIONS).includes(action as any)) {
|
||||
console.log(`[check_suite] Ignoring action: ${action}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only process if there are pull requests
|
||||
if (!check_suite.pull_requests || check_suite.pull_requests.length === 0) {
|
||||
console.log('[check_suite] No associated pull requests');
|
||||
return;
|
||||
}
|
||||
|
||||
const { installationId, owner, repo } = await validateWebhookPayload(
|
||||
event,
|
||||
'check_suite',
|
||||
env.GITHUB_APP_ID,
|
||||
env.GITHUB_APP_PRIVATE_KEY,
|
||||
);
|
||||
|
||||
if (!check_suite.head_sha) {
|
||||
console.log('[check_suite] Missing head SHA');
|
||||
throw new Error('Invalid check_suite payload: missing head_sha');
|
||||
}
|
||||
|
||||
// Process first pull request
|
||||
const pr = check_suite.pull_requests[0];
|
||||
|
||||
await handleApprovalCheck(
|
||||
{
|
||||
installationId,
|
||||
owner,
|
||||
repo,
|
||||
prNumber: pr.number,
|
||||
headSha: check_suite.head_sha,
|
||||
eventType: 'check_suite',
|
||||
environment: env.ENVIRONMENT,
|
||||
},
|
||||
checkRunManager,
|
||||
approvalValidator,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles check_run rerun requests
|
||||
*/
|
||||
async function handleCheckRunRerequest(
|
||||
event: CheckRunEvent,
|
||||
env: Env,
|
||||
checkRunManager: CheckRunManager,
|
||||
approvalValidator: ApprovalValidator,
|
||||
): Promise<void> {
|
||||
const { check_run } = event;
|
||||
|
||||
// Only handle our own check runs
|
||||
const expectedCheckName = getCheckName(env.ENVIRONMENT);
|
||||
if (check_run.name !== expectedCheckName) {
|
||||
console.log(`[check_run] Ignoring check: ${check_run.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get associated pull requests
|
||||
const pullRequests = check_run.pull_requests;
|
||||
if (!pullRequests || pullRequests.length === 0) {
|
||||
console.log('[check_run] No associated pull requests');
|
||||
return;
|
||||
}
|
||||
|
||||
const { installationId, owner, repo } = await validateWebhookPayload(
|
||||
event,
|
||||
'check_run',
|
||||
env.GITHUB_APP_ID,
|
||||
env.GITHUB_APP_PRIVATE_KEY,
|
||||
);
|
||||
|
||||
if (!check_run.id) {
|
||||
console.log('[check_run] Missing check run ID');
|
||||
throw new Error('Invalid check_run payload: missing check_run.id');
|
||||
}
|
||||
|
||||
const pr = pullRequests[0];
|
||||
|
||||
await handleApprovalCheck(
|
||||
{
|
||||
installationId,
|
||||
owner,
|
||||
repo,
|
||||
prNumber: pr.number,
|
||||
headSha: check_run.head_sha,
|
||||
eventType: 'check_run',
|
||||
environment: env.ENVIRONMENT,
|
||||
},
|
||||
checkRunManager,
|
||||
approvalValidator,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* GitHub webhook event type definitions
|
||||
*/
|
||||
|
||||
export interface Repository {
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
owner: {
|
||||
login: string;
|
||||
id: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Installation {
|
||||
id: number;
|
||||
account: {
|
||||
login: string;
|
||||
id: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PullRequest {
|
||||
id: number;
|
||||
number: number;
|
||||
state: 'open' | 'closed';
|
||||
title: string;
|
||||
head: {
|
||||
sha: string;
|
||||
ref: string;
|
||||
};
|
||||
base: {
|
||||
sha: string;
|
||||
ref: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PullRequestEvent {
|
||||
action: 'opened' | 'closed' | 'reopened' | 'synchronize' | 'edited';
|
||||
number: number;
|
||||
pull_request: PullRequest;
|
||||
repository: Repository;
|
||||
installation: Installation;
|
||||
}
|
||||
|
||||
export interface Review {
|
||||
id: number;
|
||||
user: {
|
||||
login: string;
|
||||
id: number;
|
||||
};
|
||||
state: 'approved' | 'changes_requested' | 'commented' | 'dismissed' | 'pending';
|
||||
submitted_at: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface PullRequestReviewEvent {
|
||||
action: 'submitted' | 'edited' | 'dismissed';
|
||||
review: Review;
|
||||
pull_request: PullRequest;
|
||||
repository: Repository;
|
||||
installation: Installation;
|
||||
}
|
||||
|
||||
export interface CheckSuite {
|
||||
id: number;
|
||||
head_sha: string;
|
||||
head_branch: string;
|
||||
status: 'queued' | 'in_progress' | 'completed';
|
||||
conclusion: 'success' | 'failure' | 'neutral' | 'cancelled' | 'skipped' | 'timed_out' | 'action_required' | null;
|
||||
pull_requests: Array<{
|
||||
id: number;
|
||||
number: number;
|
||||
head: {
|
||||
sha: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CheckSuiteEvent {
|
||||
action: 'requested' | 'rerequested' | 'completed';
|
||||
check_suite: CheckSuite;
|
||||
repository: Repository;
|
||||
installation: Installation;
|
||||
}
|
||||
|
||||
export interface CheckRun {
|
||||
id: number;
|
||||
name: string;
|
||||
head_sha: string;
|
||||
status: 'queued' | 'in_progress' | 'completed';
|
||||
conclusion: 'success' | 'failure' | 'neutral' | 'cancelled' | 'skipped' | 'timed_out' | 'action_required' | null;
|
||||
started_at: string;
|
||||
completed_at: string | null;
|
||||
pull_requests: Array<{
|
||||
id: number;
|
||||
number: number;
|
||||
head: {
|
||||
sha: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CheckRunEvent {
|
||||
action: 'created' | 'completed' | 'rerequested' | 'requested_action';
|
||||
check_run: CheckRun;
|
||||
repository: Repository;
|
||||
installation: Installation;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Webhook signature verification
|
||||
* Ensures that webhooks are coming from GitHub
|
||||
*/
|
||||
|
||||
/**
|
||||
* Verify the webhook signature using HMAC-SHA256
|
||||
*/
|
||||
export async function verifyWebhookSignature(body: string, signature: string, secret: string): Promise<boolean> {
|
||||
// The signature format is "sha256=<hex digest>"
|
||||
if (!signature.startsWith('sha256=')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const providedSignature = signature.slice(7); // Remove "sha256=" prefix
|
||||
|
||||
// Import the secret as a key
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign'],
|
||||
);
|
||||
|
||||
// Generate the HMAC
|
||||
const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body));
|
||||
|
||||
// Convert to hex string
|
||||
const computedSignature = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
return safeCompare(computedSignature, providedSignature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time string comparison to prevent timing attacks
|
||||
*/
|
||||
function safeCompare(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let result = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
result |= a.codePointAt(i)! ^ b.codePointAt(i)!;
|
||||
}
|
||||
|
||||
return result === 0;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["@cloudflare/workers-types", "vitest/globals"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@immich-services/github-approval-check/*": ["*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "worker-configuration.d.ts"]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
||||
import baseConfig from '../../vitest.base.config.js';
|
||||
|
||||
export default baseConfig;
|
||||
@@ -0,0 +1,23 @@
|
||||
interface Env {
|
||||
// GitHub App configuration
|
||||
GITHUB_APP_ID: string;
|
||||
GITHUB_APP_PRIVATE_KEY: string;
|
||||
|
||||
// Organization webhook secret
|
||||
GITHUB_WEBHOOK_SECRET: string;
|
||||
|
||||
// Approval configuration
|
||||
ALLOWED_USERS_URL: string;
|
||||
|
||||
// Deployment configuration
|
||||
ENVIRONMENT?: string; // 'dev', 'staging', 'prod'
|
||||
STAGE?: string; // e.g., '-pr-123' for PR deployments
|
||||
DEV_PR_NUMBER?: string; // PR number that created this deployment (extracted from STAGE)
|
||||
}
|
||||
|
||||
// Type declaration for cloudflare:test module
|
||||
declare module 'cloudflare:test' {
|
||||
export const SELF: {
|
||||
fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
name = "github-approval-check-immich-app"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2025-09-16"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
# GitHub App webhook endpoint
|
||||
# This worker will receive webhooks from GitHub
|
||||
|
||||
# Environment variables for GitHub App
|
||||
# These will be set as secrets in production
|
||||
[vars]
|
||||
GITHUB_APP_ID = "" # Will be set when GitHub App is created
|
||||
ALLOWED_USERS_URL = "https://raw.githubusercontent.com/immich-app/devtools/main/tf/deployment/data/users.json"
|
||||
|
||||
# Secrets (set via wrangler secret put or Terraform)
|
||||
# GITHUB_APP_PRIVATE_KEY - RSA private key for the GitHub App
|
||||
# GITHUB_WEBHOOK_SECRET - Webhook secret for verifying payloads
|
||||
@@ -2,3 +2,14 @@ export TF_VAR_cloudflare_account_id="op://tf/cloudflare/account_id"
|
||||
export TF_VAR_cloudflare_api_token="op://tf/cloudflare/api_token"
|
||||
export TF_VAR_tf_state_postgres_conn_str="op://tf/tf_state/postgres_conn_str"
|
||||
export TF_VAR_env=$ENVIRONMENT
|
||||
|
||||
export TF_VAR_github_app_tofu_installation_id="op://tf/GITHUB_APP_IMMICH_TOFU/installation_id"
|
||||
export TF_VAR_github_app_tofu_id="op://tf/GITHUB_APP_IMMICH_TOFU/app_id"
|
||||
export TF_VAR_github_app_tofu_owner="op://tf/GITHUB_APP_IMMICH_TOFU/owner"
|
||||
export TF_VAR_github_app_tofu_pem_file="op://tf/GITHUB_APP_IMMICH_TOFU/pkcs1"
|
||||
|
||||
export TF_VAR_github_checks_webhook_secret="op://tf/IMMICH_GITHUB_ACTION_CHECKS_WEBHOOK_SECRET/password"
|
||||
export TF_VAR_github_app_checks_installation_id="op://tf/GITHUB_APP_IMMICH_GITHUB_ACTION_CHECKS/installation_id"
|
||||
export TF_VAR_github_app_checks_id="op://tf/GITHUB_APP_IMMICH_GITHUB_ACTION_CHECKS/app_id"
|
||||
export TF_VAR_github_app_checks_owner="op://tf/GITHUB_APP_IMMICH_GITHUB_ACTION_CHECKS/owner"
|
||||
export TF_VAR_github_app_checks_pem_file="op://tf/GITHUB_APP_IMMICH_GITHUB_ACTION_CHECKS/pkcs8"
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# This file is maintained automatically by "tofu init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||
version = "5.10.0"
|
||||
constraints = "~> 5.0"
|
||||
hashes = [
|
||||
"h1:v4z/hj3czm71lZu6KDLZljKKjbqlzXZVZnDIRRqbx8M=",
|
||||
"zh:49aa85455135ebf2108e861cb7cf1b8217861f1903bb31c2502e09f49eedd9f5",
|
||||
"zh:4f6916bb45c0fbbbece929890a9ed5ce1af0ca36bd4c8ae08f7f9bc6eca5b293",
|
||||
"zh:510356e67787a736ab8614942419bd61807bec59aa17a8bd97b58a5259687856",
|
||||
"zh:86ebbc79f5a8ef40fa49429f08f3341b7def4253d0aefaf827c3ec8f08143bd3",
|
||||
"zh:b5333de6ce85725ad6438e632269feb5183c3d0c54691a065c3b8c5716d99694",
|
||||
"zh:c74b8e5d15f2ab111e8de0e4b7688a7f7b28fda0009bc6842ba47204db562245",
|
||||
"zh:e20fb1b87f9b13c44895aab4a436f39db037b99b81d9e4730144176757d69e14",
|
||||
"zh:ea119d9afdf2287484f30429e074b19222e4f353b3906ba96eb2f6ee31b2ed2f",
|
||||
"zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32",
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
terraform {
|
||||
backend "pg" {}
|
||||
required_version = "~> 1.7"
|
||||
|
||||
required_providers {
|
||||
cloudflare = {
|
||||
source = "cloudflare/cloudflare"
|
||||
version = "~> 5"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
locals {
|
||||
resource_stage = var.stage != "" ? "-${var.stage}" : ""
|
||||
resource_env = "-${var.env}"
|
||||
resource_suffix = "${local.resource_env}${local.resource_stage}"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
provider "cloudflare" {
|
||||
api_token = data.terraform_remote_state.api_keys_state.outputs.terraform_key_cloudflare_account
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
variable "tf_state_postgres_conn_str" {
|
||||
description = "PostgreSQL connection string for Terraform state"
|
||||
type = string
|
||||
}
|
||||
|
||||
data "terraform_remote_state" "api_keys_state" {
|
||||
backend = "pg"
|
||||
|
||||
config = {
|
||||
conn_str = var.tf_state_postgres_conn_str
|
||||
schema_name = "prod_cloudflare_api_keys"
|
||||
}
|
||||
}
|
||||
|
||||
data "terraform_remote_state" "cloudflare_account" {
|
||||
backend = "pg"
|
||||
|
||||
config = {
|
||||
conn_str = var.tf_state_postgres_conn_str
|
||||
schema_name = "prod_cloudflare_account"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
terraform {
|
||||
source = "."
|
||||
|
||||
extra_arguments custom_vars {
|
||||
commands = get_terraform_commands_that_need_vars()
|
||||
}
|
||||
}
|
||||
|
||||
include {
|
||||
path = find_in_parent_folders("state.hcl")
|
||||
}
|
||||
|
||||
locals {
|
||||
env = get_env("TF_VAR_env")
|
||||
stage = get_env("TF_VAR_stage")
|
||||
app_name = "github-approval-check"
|
||||
}
|
||||
|
||||
inputs = {
|
||||
app_name = local.app_name
|
||||
}
|
||||
|
||||
remote_state {
|
||||
backend = "pg"
|
||||
|
||||
config = {
|
||||
conn_str = get_env("TF_VAR_tf_state_postgres_conn_str")
|
||||
schema_name = "services_cf_workers_${local.app_name}_${local.env}${local.stage}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
variable "stage" {}
|
||||
variable "env" {}
|
||||
variable "app_name" {}
|
||||
variable "cloudflare_account_id" {}
|
||||
variable "dist_dir" {}
|
||||
|
||||
variable "github_app_checks_id" {
|
||||
description = "GitHub App ID"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "github_app_checks_pem_file" {
|
||||
description = "GitHub App private key (PEM format)"
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "github_checks_webhook_secret" {
|
||||
description = "GitHub webhook secret for signature verification"
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "allowed_users_url" {
|
||||
description = "URL to fetch the list of allowed users"
|
||||
type = string
|
||||
default = "https://raw.githubusercontent.com/immich-app/devtools/main/tf/deployment/data/users.json"
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
resource "cloudflare_worker" "worker" {
|
||||
account_id = var.cloudflare_account_id
|
||||
name = "${var.app_name}-api${local.resource_suffix}"
|
||||
logpush = true
|
||||
}
|
||||
|
||||
resource "terraform_data" "source_hash" {
|
||||
input = filesha256("${var.dist_dir}/${var.app_name}/index.js")
|
||||
}
|
||||
|
||||
resource "cloudflare_worker_version" "worker" {
|
||||
account_id = var.cloudflare_account_id
|
||||
worker_id = cloudflare_worker.worker.id
|
||||
bindings = [
|
||||
{
|
||||
name = "ALLOWED_USERS_URL"
|
||||
type = "plain_text"
|
||||
text = var.allowed_users_url
|
||||
},
|
||||
{
|
||||
name = "ENVIRONMENT"
|
||||
type = "plain_text"
|
||||
text = var.env
|
||||
},
|
||||
{
|
||||
name = "GITHUB_APP_ID"
|
||||
type = "plain_text"
|
||||
text = var.github_app_checks_id
|
||||
},
|
||||
{
|
||||
name = "GITHUB_APP_PRIVATE_KEY"
|
||||
type = "secret_text"
|
||||
text = var.github_app_checks_pem_file
|
||||
},
|
||||
{
|
||||
name = "GITHUB_WEBHOOK_SECRET"
|
||||
type = "secret_text"
|
||||
text = var.github_checks_webhook_secret
|
||||
},
|
||||
{
|
||||
name = "STAGE"
|
||||
type = "plain_text"
|
||||
text = var.stage
|
||||
}
|
||||
]
|
||||
compatibility_date = "2025-09-16"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
main_module = "index.js"
|
||||
modules = [
|
||||
{
|
||||
content_file = "${var.dist_dir}/${var.app_name}/index.js"
|
||||
content_type = "application/javascript+module"
|
||||
name = "index.js"
|
||||
}
|
||||
]
|
||||
lifecycle {
|
||||
replace_triggered_by = [
|
||||
terraform_data.source_hash
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
resource "cloudflare_workers_deployment" "worker" {
|
||||
account_id = var.cloudflare_account_id
|
||||
script_name = cloudflare_worker.worker.name
|
||||
strategy = "percentage"
|
||||
versions = [
|
||||
{
|
||||
percentage = 100
|
||||
version_id = cloudflare_worker_version.worker.id
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
data "cloudflare_zone" "immich_app" {
|
||||
filter = {
|
||||
name = "immich.app"
|
||||
}
|
||||
}
|
||||
|
||||
resource "cloudflare_workers_custom_domain" "worker" {
|
||||
account_id = var.cloudflare_account_id
|
||||
environment = "production"
|
||||
hostname = module.domain.fqdn
|
||||
service = cloudflare_worker.worker.name
|
||||
zone_id = data.cloudflare_zone.immich_app.zone_id
|
||||
}
|
||||
|
||||
module "domain" {
|
||||
source = "git::https://github.com/immich-app/devtools.git//tf/shared/modules/domain?ref=main"
|
||||
|
||||
app_name = var.app_name
|
||||
stage = var.stage
|
||||
env = var.env
|
||||
domain = "immich.app"
|
||||
}
|
||||
|
||||
output "webhook_url" {
|
||||
value = "https://${module.domain.fqdn}/webhook"
|
||||
}
|
||||
+10
-10
@@ -2,18 +2,18 @@
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||
version = "5.9.0"
|
||||
version = "5.10.0"
|
||||
constraints = "~> 5.0"
|
||||
hashes = [
|
||||
"h1:yNvFe2InCb4foqjWd+Sz9DIX6mdk30iRycsmm/fZarY=",
|
||||
"zh:3ae6f70f4e2961e84b89b337d268db0537c6dd0e66d4ccf32cbacc950f7a2807",
|
||||
"zh:4472d1d1629c3c3a6a23672691ed0852bea9fcd9e39a213d388616f93adaaeb0",
|
||||
"zh:4680cb586233b4702abb9fc69615bca6ebe9547ddaceaa0d0439bccc7c773905",
|
||||
"zh:51fd157b544644438ab827e288c8173ecbf31cd92b3b75a8446569db35c00cab",
|
||||
"zh:66aaeb1cb991b982f81564ea52a18ba962b43e86d9e5c76ac591fa522a4a0db6",
|
||||
"zh:d271308040efe8324633810d8c58142310da5f88eb223422fd13daa73e944316",
|
||||
"zh:e56c66588135878081ffa8c2aa5da969d56ff96d4ecb4b0ab6b8b496830cf7c2",
|
||||
"h1:v4z/hj3czm71lZu6KDLZljKKjbqlzXZVZnDIRRqbx8M=",
|
||||
"zh:49aa85455135ebf2108e861cb7cf1b8217861f1903bb31c2502e09f49eedd9f5",
|
||||
"zh:4f6916bb45c0fbbbece929890a9ed5ce1af0ca36bd4c8ae08f7f9bc6eca5b293",
|
||||
"zh:510356e67787a736ab8614942419bd61807bec59aa17a8bd97b58a5259687856",
|
||||
"zh:86ebbc79f5a8ef40fa49429f08f3341b7def4253d0aefaf827c3ec8f08143bd3",
|
||||
"zh:b5333de6ce85725ad6438e632269feb5183c3d0c54691a065c3b8c5716d99694",
|
||||
"zh:c74b8e5d15f2ab111e8de0e4b7688a7f7b28fda0009bc6842ba47204db562245",
|
||||
"zh:e20fb1b87f9b13c44895aab4a436f39db037b99b81d9e4730144176757d69e14",
|
||||
"zh:ea119d9afdf2287484f30429e074b19222e4f353b3906ba96eb2f6ee31b2ed2f",
|
||||
"zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32",
|
||||
"zh:fde7a7d2bda2784e78c0bcc39155e0b09c31dd4a4fa65c26e0e8fe858445ecfc",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -25,6 +25,6 @@ remote_state {
|
||||
|
||||
config = {
|
||||
conn_str = get_env("TF_VAR_tf_state_postgres_conn_str")
|
||||
schema_name = "services_cloudflare_workers_${local.app_name}_immich_app_${local.env}${local.stage}"
|
||||
schema_name = "services_cf_workers_${local.app_name}_${local.env}${local.stage}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ resource "cloudflare_worker" "worker" {
|
||||
logpush = true
|
||||
}
|
||||
|
||||
resource "terraform_data" "source_hash" {
|
||||
input = filesha256("${var.dist_dir}/${var.app_name}/index.js")
|
||||
}
|
||||
|
||||
resource "cloudflare_worker_version" "worker" {
|
||||
account_id = var.cloudflare_account_id
|
||||
worker_id = cloudflare_worker.worker.id
|
||||
@@ -24,6 +28,11 @@ resource "cloudflare_worker_version" "worker" {
|
||||
name = "index.js"
|
||||
}
|
||||
]
|
||||
lifecycle {
|
||||
replace_triggered_by = [
|
||||
terraform_data.source_hash
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
resource "cloudflare_workers_deployment" "worker" {
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# This file is maintained automatically by "tofu init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/integrations/github" {
|
||||
version = "6.6.0"
|
||||
constraints = "~> 6.0"
|
||||
hashes = [
|
||||
"h1:Fp0RrNe+w167AQkVUWC1WRAsyjhhHN7aHWUky7VkKW8=",
|
||||
"zh:0b1b5342db6a17de7c71386704e101be7d6761569e03fb3ff1f3d4c02c32d998",
|
||||
"zh:2fb663467fff76852126b58315d9a1a457e3b04bec51f04bf1c0ddc9dfbb3517",
|
||||
"zh:4183e557a1dfd413dae90ca4bac37dbbe499eae5e923567371f768053f977800",
|
||||
"zh:48b2979f88fb55cdb14b7e4c37c44e0dfbc21b7a19686ce75e339efda773c5c2",
|
||||
"zh:5d803fb06625e0bcf83abb590d4235c117fa7f4aa2168fa3d5f686c41bc529ec",
|
||||
"zh:6f1dd094cbab36363583cda837d7ca470bef5f8abf9b19f23e9cd8b927153498",
|
||||
"zh:772edb5890d72b32868f9fdc0a9a1d4f4701d8e7f8acb37a7ac530d053c776e3",
|
||||
"zh:798f443dbba6610431dcef832047f6917fb5a4e184a3a776c44e6213fb429cc6",
|
||||
"zh:cc08dfcc387e2603f6dbaff8c236c1254185450d6cadd6bad92879fe7e7dbce9",
|
||||
"zh:d5e2c8d7f50f91d6847ddce27b10b721bdfce99c1bbab42a68fa271337d73d63",
|
||||
"zh:e69a0045440c706f50f84a84ff8b1df520ec9bf757de4b8f9959f2ed20c3f440",
|
||||
"zh:efc5358573a6403cbea3a08a2fcd2407258ac083d9134c641bdcb578966d8bdf",
|
||||
"zh:f627a255e5809ec2375f79949c79417847fa56b9e9222ea7c45a463eb663f137",
|
||||
"zh:f7c02f762e4cf1de7f58bde520798491ccdd54a5bd52278d579c146d1d07d4f0",
|
||||
"zh:fbd1fee2c9df3aa19cf8851ce134dea6e45ea01cb85695c1726670c285797e25",
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
terraform {
|
||||
backend "pg" {}
|
||||
required_version = "~> 1.7"
|
||||
|
||||
required_providers {
|
||||
github = {
|
||||
source = "integrations/github"
|
||||
version = "~> 6.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
provider "github" {
|
||||
app_auth {
|
||||
id = var.github_app_tofu_id
|
||||
installation_id = var.github_app_tofu_installation_id
|
||||
pem_file = var.github_app_tofu_pem_file
|
||||
}
|
||||
owner = var.github_app_tofu_owner
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
data "terraform_remote_state" "api_keys_state" {
|
||||
backend = "pg"
|
||||
|
||||
config = {
|
||||
conn_str = var.tf_state_postgres_conn_str
|
||||
schema_name = "prod_cloudflare_api_keys"
|
||||
}
|
||||
}
|
||||
|
||||
data "terraform_remote_state" "github_approval_check" {
|
||||
backend = "pg"
|
||||
|
||||
config = {
|
||||
conn_str = var.tf_state_postgres_conn_str
|
||||
schema_name = "services_cf_workers_github-approval-check_${var.env}${var.stage}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
terraform {
|
||||
source = "."
|
||||
|
||||
extra_arguments custom_vars {
|
||||
commands = get_terraform_commands_that_need_vars()
|
||||
}
|
||||
}
|
||||
|
||||
include "root" {
|
||||
path = find_in_parent_folders("state.hcl")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
paths = ["../cloudflare/workers/github-approval-check"]
|
||||
}
|
||||
|
||||
locals {
|
||||
env = get_env("TF_VAR_env")
|
||||
stage = get_env("TF_VAR_stage", "")
|
||||
}
|
||||
|
||||
inputs = {
|
||||
env = local.env
|
||||
stage = local.stage
|
||||
}
|
||||
|
||||
remote_state {
|
||||
backend = "pg"
|
||||
|
||||
config = {
|
||||
conn_str = get_env("TF_VAR_tf_state_postgres_conn_str")
|
||||
schema_name = "services_github_${local.env}${local.stage}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
variable "tf_state_postgres_conn_str" {}
|
||||
|
||||
variable "github_app_tofu_id" {}
|
||||
variable "github_app_tofu_installation_id" {}
|
||||
variable "github_app_tofu_pem_file" {}
|
||||
variable "github_app_tofu_owner" {}
|
||||
|
||||
variable "env" {}
|
||||
variable "stage" {
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "github_checks_webhook_secret" {
|
||||
description = "GitHub webhook secret for signature verification"
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
resource "github_organization_webhook" "github_approval_check_webhook" {
|
||||
events = [
|
||||
"check_run",
|
||||
"check_suite",
|
||||
"pull_request",
|
||||
"pull_request_review",
|
||||
]
|
||||
configuration {
|
||||
url = data.terraform_remote_state.github_approval_check.outputs.webhook_url
|
||||
content_type = "json"
|
||||
secret = var.github_checks_webhook_secret
|
||||
}
|
||||
}
|
||||
@@ -55,19 +55,6 @@ export default typescriptEslint.config([
|
||||
curly: 2,
|
||||
'prettier/prettier': 0,
|
||||
'object-shorthand': ['error', 'always'],
|
||||
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['.*'],
|
||||
message: 'Relative imports are not allowed.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
@@ -80,7 +67,6 @@ export default typescriptEslint.config([
|
||||
{
|
||||
files: ['**/*.config.ts', '**/*.config.js', '**/*.config.mjs'],
|
||||
rules: {
|
||||
'no-restricted-imports': 'off',
|
||||
'unicorn/prefer-export-from': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
Generated
+530
@@ -59,6 +59,21 @@ importers:
|
||||
specifier: ^4.35.0
|
||||
version: 4.35.0(@cloudflare/workers-types@4.20250909.0)
|
||||
|
||||
apps/github-approval-check:
|
||||
dependencies:
|
||||
'@octokit/app':
|
||||
specifier: ^16.1.0
|
||||
version: 16.1.0
|
||||
'@octokit/auth-app':
|
||||
specifier: ^7.1.3
|
||||
version: 7.2.2
|
||||
'@octokit/rest':
|
||||
specifier: ^21.0.2
|
||||
version: 21.1.1
|
||||
'@octokit/webhooks':
|
||||
specifier: ^13.3.0
|
||||
version: 13.9.1
|
||||
|
||||
apps/hello: {}
|
||||
|
||||
packages:
|
||||
@@ -714,6 +729,226 @@ packages:
|
||||
{ integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== }
|
||||
engines: { node: '>= 8' }
|
||||
|
||||
'@octokit/app@16.1.0':
|
||||
resolution:
|
||||
{ integrity: sha512-OdKHnm0CYLk8Setr47CATT4YnRTvWkpTYvE+B/l2B0mjszlfOIit3wqPHVslD2jfc1bD4UbO7Mzh6gjCuMZKsA== }
|
||||
engines: { node: '>= 20' }
|
||||
|
||||
'@octokit/auth-app@7.2.2':
|
||||
resolution:
|
||||
{ integrity: sha512-p6hJtEyQDCJEPN9ijjhEC/kpFHMHN4Gca9r+8S0S8EJi7NaWftaEmexjxxpT1DFBeJpN4u/5RE22ArnyypupJw== }
|
||||
engines: { node: '>= 18' }
|
||||
|
||||
'@octokit/auth-app@8.1.0':
|
||||
resolution:
|
||||
{ integrity: sha512-6bWhyvLXqCSfHiqlwzn9pScLZ+Qnvh/681GR/UEEPCMIVwfpRDBw0cCzy3/t2Dq8B7W2X/8pBgmw6MOiyE0DXQ== }
|
||||
engines: { node: '>= 20' }
|
||||
|
||||
'@octokit/auth-oauth-app@8.1.4':
|
||||
resolution:
|
||||
{ integrity: sha512-71iBa5SflSXcclk/OL3lJzdt4iFs56OJdpBGEBl1wULp7C58uiswZLV6TdRaiAzHP1LT8ezpbHlKuxADb+4NkQ== }
|
||||
engines: { node: '>= 18' }
|
||||
|
||||
'@octokit/auth-oauth-app@9.0.1':
|
||||
resolution:
|
||||
{ integrity: sha512-TthWzYxuHKLAbmxdFZwFlmwVyvynpyPmjwc+2/cI3cvbT7mHtsAW9b1LvQaNnAuWL+pFnqtxdmrU8QpF633i1g== }
|
||||
engines: { node: '>= 20' }
|
||||
|
||||
'@octokit/auth-oauth-device@7.1.5':
|
||||
resolution:
|
||||
{ integrity: sha512-lR00+k7+N6xeECj0JuXeULQ2TSBB/zjTAmNF2+vyGPDEFx1dgk1hTDmL13MjbSmzusuAmuJD8Pu39rjp9jH6yw== }
|
||||
engines: { node: '>= 18' }
|
||||
|
||||
'@octokit/auth-oauth-device@8.0.1':
|
||||
resolution:
|
||||
{ integrity: sha512-TOqId/+am5yk9zor0RGibmlqn4V0h8vzjxlw/wYr3qzkQxl8aBPur384D1EyHtqvfz0syeXji4OUvKkHvxk/Gw== }
|
||||
engines: { node: '>= 20' }
|
||||
|
||||
'@octokit/auth-oauth-user@5.1.6':
|
||||
resolution:
|
||||
{ integrity: sha512-/R8vgeoulp7rJs+wfJ2LtXEVC7pjQTIqDab7wPKwVG6+2v/lUnCOub6vaHmysQBbb45FknM3tbHW8TOVqYHxCw== }
|
||||
engines: { node: '>= 18' }
|
||||
|
||||
'@octokit/auth-oauth-user@6.0.0':
|
||||
resolution:
|
||||
{ integrity: sha512-GV9IW134PHsLhtUad21WIeP9mlJ+QNpFd6V9vuPWmaiN25HEJeEQUcS4y5oRuqCm9iWDLtfIs+9K8uczBXKr6A== }
|
||||
engines: { node: '>= 20' }
|
||||
|
||||
'@octokit/auth-token@5.1.2':
|
||||
resolution:
|
||||
{ integrity: sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw== }
|
||||
engines: { node: '>= 18' }
|
||||
|
||||
'@octokit/auth-token@6.0.0':
|
||||
resolution:
|
||||
{ integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w== }
|
||||
engines: { node: '>= 20' }
|
||||
|
||||
'@octokit/auth-unauthenticated@7.0.1':
|
||||
resolution:
|
||||
{ integrity: sha512-qVq1vdjLLZdE8kH2vDycNNjuJRCD1q2oet1nA/GXWaYlpDxlR7rdVhX/K/oszXslXiQIiqrQf+rdhDlA99JdTQ== }
|
||||
engines: { node: '>= 20' }
|
||||
|
||||
'@octokit/core@6.1.6':
|
||||
resolution:
|
||||
{ integrity: sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA== }
|
||||
engines: { node: '>= 18' }
|
||||
|
||||
'@octokit/core@7.0.4':
|
||||
resolution:
|
||||
{ integrity: sha512-jOT8V1Ba5BdC79sKrRWDdMT5l1R+XNHTPR6CPWzUP2EcfAcvIHZWF0eAbmRcpOOP5gVIwnqNg0C4nvh6Abc3OA== }
|
||||
engines: { node: '>= 20' }
|
||||
|
||||
'@octokit/endpoint@10.1.4':
|
||||
resolution:
|
||||
{ integrity: sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA== }
|
||||
engines: { node: '>= 18' }
|
||||
|
||||
'@octokit/endpoint@11.0.0':
|
||||
resolution:
|
||||
{ integrity: sha512-hoYicJZaqISMAI3JfaDr1qMNi48OctWuOih1m80bkYow/ayPw6Jj52tqWJ6GEoFTk1gBqfanSoI1iY99Z5+ekQ== }
|
||||
engines: { node: '>= 20' }
|
||||
|
||||
'@octokit/graphql@8.2.2':
|
||||
resolution:
|
||||
{ integrity: sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA== }
|
||||
engines: { node: '>= 18' }
|
||||
|
||||
'@octokit/graphql@9.0.1':
|
||||
resolution:
|
||||
{ integrity: sha512-j1nQNU1ZxNFx2ZtKmL4sMrs4egy5h65OMDmSbVyuCzjOcwsHq6EaYjOTGXPQxgfiN8dJ4CriYHk6zF050WEULg== }
|
||||
engines: { node: '>= 20' }
|
||||
|
||||
'@octokit/oauth-app@8.0.1':
|
||||
resolution:
|
||||
{ integrity: sha512-QnhMYEQpnYbEPn9cae+wXL2LuPMFglmfeuDJXXsyxIXdoORwkLK8y0cHhd/5du9MbO/zdG/BXixzB7EEwU63eQ== }
|
||||
engines: { node: '>= 20' }
|
||||
|
||||
'@octokit/oauth-authorization-url@7.1.1':
|
||||
resolution:
|
||||
{ integrity: sha512-ooXV8GBSabSWyhLUowlMIVd9l1s2nsOGQdlP2SQ4LnkEsGXzeCvbSbCPdZThXhEFzleGPwbapT0Sb+YhXRyjCA== }
|
||||
engines: { node: '>= 18' }
|
||||
|
||||
'@octokit/oauth-authorization-url@8.0.0':
|
||||
resolution:
|
||||
{ integrity: sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ== }
|
||||
engines: { node: '>= 20' }
|
||||
|
||||
'@octokit/oauth-methods@5.1.5':
|
||||
resolution:
|
||||
{ integrity: sha512-Ev7K8bkYrYLhoOSZGVAGsLEscZQyq7XQONCBBAl2JdMg7IT3PQn/y8P0KjloPoYpI5UylqYrLeUcScaYWXwDvw== }
|
||||
engines: { node: '>= 18' }
|
||||
|
||||
'@octokit/oauth-methods@6.0.0':
|
||||
resolution:
|
||||
{ integrity: sha512-Q8nFIagNLIZgM2odAraelMcDssapc+lF+y3OlcIPxyAU+knefO8KmozGqfnma1xegRDP4z5M73ABsamn72bOcA== }
|
||||
engines: { node: '>= 20' }
|
||||
|
||||
'@octokit/openapi-types@24.2.0':
|
||||
resolution:
|
||||
{ integrity: sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg== }
|
||||
|
||||
'@octokit/openapi-types@25.1.0':
|
||||
resolution:
|
||||
{ integrity: sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA== }
|
||||
|
||||
'@octokit/openapi-types@26.0.0':
|
||||
resolution:
|
||||
{ integrity: sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA== }
|
||||
|
||||
'@octokit/openapi-webhooks-types@11.0.0':
|
||||
resolution:
|
||||
{ integrity: sha512-ZBzCFj98v3SuRM7oBas6BHZMJRadlnDoeFfvm1olVxZnYeU6Vh97FhPxyS5aLh5pN51GYv2I51l/hVUAVkGBlA== }
|
||||
|
||||
'@octokit/openapi-webhooks-types@12.0.3':
|
||||
resolution:
|
||||
{ integrity: sha512-90MF5LVHjBedwoHyJsgmaFhEN1uzXyBDRLEBe7jlTYx/fEhPAk3P3DAJsfZwC54m8hAIryosJOL+UuZHB3K3yA== }
|
||||
|
||||
'@octokit/plugin-paginate-rest@11.6.0':
|
||||
resolution:
|
||||
{ integrity: sha512-n5KPteiF7pWKgBIBJSk8qzoZWcUkza2O6A0za97pMGVrGfPdltxrfmfF5GucHYvHGZD8BdaZmmHGz5cX/3gdpw== }
|
||||
engines: { node: '>= 18' }
|
||||
peerDependencies:
|
||||
'@octokit/core': '>=6'
|
||||
|
||||
'@octokit/plugin-paginate-rest@13.1.1':
|
||||
resolution:
|
||||
{ integrity: sha512-q9iQGlZlxAVNRN2jDNskJW/Cafy7/XE52wjZ5TTvyhyOD904Cvx//DNyoO3J/MXJ0ve3rPoNWKEg5iZrisQSuw== }
|
||||
engines: { node: '>= 20' }
|
||||
peerDependencies:
|
||||
'@octokit/core': '>=6'
|
||||
|
||||
'@octokit/plugin-request-log@5.3.1':
|
||||
resolution:
|
||||
{ integrity: sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw== }
|
||||
engines: { node: '>= 18' }
|
||||
peerDependencies:
|
||||
'@octokit/core': '>=6'
|
||||
|
||||
'@octokit/plugin-rest-endpoint-methods@13.5.0':
|
||||
resolution:
|
||||
{ integrity: sha512-9Pas60Iv9ejO3WlAX3maE1+38c5nqbJXV5GrncEfkndIpZrJ/WPMRd2xYDcPPEt5yzpxcjw9fWNoPhsSGzqKqw== }
|
||||
engines: { node: '>= 18' }
|
||||
peerDependencies:
|
||||
'@octokit/core': '>=6'
|
||||
|
||||
'@octokit/request-error@6.1.8':
|
||||
resolution:
|
||||
{ integrity: sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ== }
|
||||
engines: { node: '>= 18' }
|
||||
|
||||
'@octokit/request-error@7.0.0':
|
||||
resolution:
|
||||
{ integrity: sha512-KRA7VTGdVyJlh0cP5Tf94hTiYVVqmt2f3I6mnimmaVz4UG3gQV/k4mDJlJv3X67iX6rmN7gSHCF8ssqeMnmhZg== }
|
||||
engines: { node: '>= 20' }
|
||||
|
||||
'@octokit/request@10.0.3':
|
||||
resolution:
|
||||
{ integrity: sha512-V6jhKokg35vk098iBqp2FBKunk3kMTXlmq+PtbV9Gl3TfskWlebSofU9uunVKhUN7xl+0+i5vt0TGTG8/p/7HA== }
|
||||
engines: { node: '>= 20' }
|
||||
|
||||
'@octokit/request@9.2.4':
|
||||
resolution:
|
||||
{ integrity: sha512-q8ybdytBmxa6KogWlNa818r0k1wlqzNC+yNkcQDECHvQo8Vmstrg18JwqJHdJdUiHD2sjlwBgSm9kHkOKe2iyA== }
|
||||
engines: { node: '>= 18' }
|
||||
|
||||
'@octokit/rest@21.1.1':
|
||||
resolution:
|
||||
{ integrity: sha512-sTQV7va0IUVZcntzy1q3QqPm/r8rWtDCqpRAmb8eXXnKkjoQEtFe3Nt5GTVsHft+R6jJoHeSiVLcgcvhtue/rg== }
|
||||
engines: { node: '>= 18' }
|
||||
|
||||
'@octokit/types@13.10.0':
|
||||
resolution:
|
||||
{ integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA== }
|
||||
|
||||
'@octokit/types@14.1.0':
|
||||
resolution:
|
||||
{ integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g== }
|
||||
|
||||
'@octokit/types@15.0.0':
|
||||
resolution:
|
||||
{ integrity: sha512-8o6yDfmoGJUIeR9OfYU0/TUJTnMPG2r68+1yEdUeG2Fdqpj8Qetg0ziKIgcBm0RW/j29H41WP37CYCEhp6GoHQ== }
|
||||
|
||||
'@octokit/webhooks-methods@5.1.1':
|
||||
resolution:
|
||||
{ integrity: sha512-NGlEHZDseJTCj8TMMFehzwa9g7On4KJMPVHDSrHxCQumL6uSQR8wIkP/qesv52fXqV1BPf4pTxwtS31ldAt9Xg== }
|
||||
engines: { node: '>= 18' }
|
||||
|
||||
'@octokit/webhooks-methods@6.0.0':
|
||||
resolution:
|
||||
{ integrity: sha512-MFlzzoDJVw/GcbfzVC1RLR36QqkTLUf79vLVO3D+xn7r0QgxnFoLZgtrzxiQErAjFUOdH6fas2KeQJ1yr/qaXQ== }
|
||||
engines: { node: '>= 20' }
|
||||
|
||||
'@octokit/webhooks@13.9.1':
|
||||
resolution:
|
||||
{ integrity: sha512-Nss2b4Jyn4wB3EAqAPJypGuCJFalz/ZujKBQQ5934To7Xw9xjf4hkr/EAByxQY7hp7MKd790bWGz7XYSTsHmaw== }
|
||||
engines: { node: '>= 18' }
|
||||
|
||||
'@octokit/webhooks@14.1.3':
|
||||
resolution:
|
||||
{ integrity: sha512-gcK4FNaROM9NjA0mvyfXl0KPusk7a1BeA8ITlYEZVQCXF5gcETTd4yhAU0Kjzd8mXwYHppzJBWgdBVpIR9wUcQ== }
|
||||
engines: { node: '>= 20' }
|
||||
|
||||
'@pkgr/core@0.2.9':
|
||||
resolution:
|
||||
{ integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA== }
|
||||
@@ -866,6 +1101,10 @@ packages:
|
||||
resolution:
|
||||
{ integrity: sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g== }
|
||||
|
||||
'@types/aws-lambda@8.10.152':
|
||||
resolution:
|
||||
{ integrity: sha512-soT/c2gYBnT5ygwiHPmd9a1bftj462NWVk2tKCc1PYHSIacB2UwbTS2zYG4jzag1mRDuzg/OjtxQjQ2NKRB6Rw== }
|
||||
|
||||
'@types/chai@5.2.2':
|
||||
resolution:
|
||||
{ integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg== }
|
||||
@@ -1036,6 +1275,14 @@ packages:
|
||||
resolution:
|
||||
{ integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== }
|
||||
|
||||
before-after-hook@3.0.2:
|
||||
resolution:
|
||||
{ integrity: sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A== }
|
||||
|
||||
before-after-hook@4.0.0:
|
||||
resolution:
|
||||
{ integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ== }
|
||||
|
||||
birpc@0.2.14:
|
||||
resolution:
|
||||
{ integrity: sha512-37FHE8rqsYM5JEKCnXFyHpBCzvgHEExwVVTq+nUmloInU7l8ezD1TpOhKpS8oe1DTYFqEK27rFZVKG43oTqXRA== }
|
||||
@@ -1320,6 +1567,14 @@ packages:
|
||||
resolution:
|
||||
{ integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw== }
|
||||
|
||||
fast-content-type-parse@2.0.1:
|
||||
resolution:
|
||||
{ integrity: sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q== }
|
||||
|
||||
fast-content-type-parse@3.0.0:
|
||||
resolution:
|
||||
{ integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg== }
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution:
|
||||
{ integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== }
|
||||
@@ -1840,6 +2095,11 @@ packages:
|
||||
{ integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== }
|
||||
engines: { node: '>=8.0' }
|
||||
|
||||
toad-cache@3.7.0:
|
||||
resolution:
|
||||
{ integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw== }
|
||||
engines: { node: '>=12' }
|
||||
|
||||
ts-api-utils@2.1.0:
|
||||
resolution:
|
||||
{ integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ== }
|
||||
@@ -1887,6 +2147,14 @@ packages:
|
||||
resolution:
|
||||
{ integrity: sha512-Wj7/AMtE9MRnAXa6Su3Lk0LNCfqDYgfwVjwRFVum9U7wsto1imuHqk4kTm7Jni+5A0Hn7dttL6O/zjvUvoo+8A== }
|
||||
|
||||
universal-github-app-jwt@2.2.2:
|
||||
resolution:
|
||||
{ integrity: sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw== }
|
||||
|
||||
universal-user-agent@7.0.3:
|
||||
resolution:
|
||||
{ integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A== }
|
||||
|
||||
update-browserslist-db@1.1.3:
|
||||
resolution:
|
||||
{ integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw== }
|
||||
@@ -2398,6 +2666,252 @@ snapshots:
|
||||
'@nodelib/fs.scandir': 2.1.5
|
||||
fastq: 1.19.1
|
||||
|
||||
'@octokit/app@16.1.0':
|
||||
dependencies:
|
||||
'@octokit/auth-app': 8.1.0
|
||||
'@octokit/auth-unauthenticated': 7.0.1
|
||||
'@octokit/core': 7.0.4
|
||||
'@octokit/oauth-app': 8.0.1
|
||||
'@octokit/plugin-paginate-rest': 13.1.1(@octokit/core@7.0.4)
|
||||
'@octokit/types': 14.1.0
|
||||
'@octokit/webhooks': 14.1.3
|
||||
|
||||
'@octokit/auth-app@7.2.2':
|
||||
dependencies:
|
||||
'@octokit/auth-oauth-app': 8.1.4
|
||||
'@octokit/auth-oauth-user': 5.1.6
|
||||
'@octokit/request': 9.2.4
|
||||
'@octokit/request-error': 6.1.8
|
||||
'@octokit/types': 14.1.0
|
||||
toad-cache: 3.7.0
|
||||
universal-github-app-jwt: 2.2.2
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/auth-app@8.1.0':
|
||||
dependencies:
|
||||
'@octokit/auth-oauth-app': 9.0.1
|
||||
'@octokit/auth-oauth-user': 6.0.0
|
||||
'@octokit/request': 10.0.3
|
||||
'@octokit/request-error': 7.0.0
|
||||
'@octokit/types': 14.1.0
|
||||
toad-cache: 3.7.0
|
||||
universal-github-app-jwt: 2.2.2
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/auth-oauth-app@8.1.4':
|
||||
dependencies:
|
||||
'@octokit/auth-oauth-device': 7.1.5
|
||||
'@octokit/auth-oauth-user': 5.1.6
|
||||
'@octokit/request': 9.2.4
|
||||
'@octokit/types': 14.1.0
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/auth-oauth-app@9.0.1':
|
||||
dependencies:
|
||||
'@octokit/auth-oauth-device': 8.0.1
|
||||
'@octokit/auth-oauth-user': 6.0.0
|
||||
'@octokit/request': 10.0.3
|
||||
'@octokit/types': 14.1.0
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/auth-oauth-device@7.1.5':
|
||||
dependencies:
|
||||
'@octokit/oauth-methods': 5.1.5
|
||||
'@octokit/request': 9.2.4
|
||||
'@octokit/types': 14.1.0
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/auth-oauth-device@8.0.1':
|
||||
dependencies:
|
||||
'@octokit/oauth-methods': 6.0.0
|
||||
'@octokit/request': 10.0.3
|
||||
'@octokit/types': 14.1.0
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/auth-oauth-user@5.1.6':
|
||||
dependencies:
|
||||
'@octokit/auth-oauth-device': 7.1.5
|
||||
'@octokit/oauth-methods': 5.1.5
|
||||
'@octokit/request': 9.2.4
|
||||
'@octokit/types': 14.1.0
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/auth-oauth-user@6.0.0':
|
||||
dependencies:
|
||||
'@octokit/auth-oauth-device': 8.0.1
|
||||
'@octokit/oauth-methods': 6.0.0
|
||||
'@octokit/request': 10.0.3
|
||||
'@octokit/types': 14.1.0
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/auth-token@5.1.2': {}
|
||||
|
||||
'@octokit/auth-token@6.0.0': {}
|
||||
|
||||
'@octokit/auth-unauthenticated@7.0.1':
|
||||
dependencies:
|
||||
'@octokit/request-error': 7.0.0
|
||||
'@octokit/types': 14.1.0
|
||||
|
||||
'@octokit/core@6.1.6':
|
||||
dependencies:
|
||||
'@octokit/auth-token': 5.1.2
|
||||
'@octokit/graphql': 8.2.2
|
||||
'@octokit/request': 9.2.4
|
||||
'@octokit/request-error': 6.1.8
|
||||
'@octokit/types': 14.1.0
|
||||
before-after-hook: 3.0.2
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/core@7.0.4':
|
||||
dependencies:
|
||||
'@octokit/auth-token': 6.0.0
|
||||
'@octokit/graphql': 9.0.1
|
||||
'@octokit/request': 10.0.3
|
||||
'@octokit/request-error': 7.0.0
|
||||
'@octokit/types': 15.0.0
|
||||
before-after-hook: 4.0.0
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/endpoint@10.1.4':
|
||||
dependencies:
|
||||
'@octokit/types': 14.1.0
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/endpoint@11.0.0':
|
||||
dependencies:
|
||||
'@octokit/types': 14.1.0
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/graphql@8.2.2':
|
||||
dependencies:
|
||||
'@octokit/request': 9.2.4
|
||||
'@octokit/types': 14.1.0
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/graphql@9.0.1':
|
||||
dependencies:
|
||||
'@octokit/request': 10.0.3
|
||||
'@octokit/types': 14.1.0
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/oauth-app@8.0.1':
|
||||
dependencies:
|
||||
'@octokit/auth-oauth-app': 9.0.1
|
||||
'@octokit/auth-oauth-user': 6.0.0
|
||||
'@octokit/auth-unauthenticated': 7.0.1
|
||||
'@octokit/core': 7.0.4
|
||||
'@octokit/oauth-authorization-url': 8.0.0
|
||||
'@octokit/oauth-methods': 6.0.0
|
||||
'@types/aws-lambda': 8.10.152
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/oauth-authorization-url@7.1.1': {}
|
||||
|
||||
'@octokit/oauth-authorization-url@8.0.0': {}
|
||||
|
||||
'@octokit/oauth-methods@5.1.5':
|
||||
dependencies:
|
||||
'@octokit/oauth-authorization-url': 7.1.1
|
||||
'@octokit/request': 9.2.4
|
||||
'@octokit/request-error': 6.1.8
|
||||
'@octokit/types': 14.1.0
|
||||
|
||||
'@octokit/oauth-methods@6.0.0':
|
||||
dependencies:
|
||||
'@octokit/oauth-authorization-url': 8.0.0
|
||||
'@octokit/request': 10.0.3
|
||||
'@octokit/request-error': 7.0.0
|
||||
'@octokit/types': 14.1.0
|
||||
|
||||
'@octokit/openapi-types@24.2.0': {}
|
||||
|
||||
'@octokit/openapi-types@25.1.0': {}
|
||||
|
||||
'@octokit/openapi-types@26.0.0': {}
|
||||
|
||||
'@octokit/openapi-webhooks-types@11.0.0': {}
|
||||
|
||||
'@octokit/openapi-webhooks-types@12.0.3': {}
|
||||
|
||||
'@octokit/plugin-paginate-rest@11.6.0(@octokit/core@6.1.6)':
|
||||
dependencies:
|
||||
'@octokit/core': 6.1.6
|
||||
'@octokit/types': 13.10.0
|
||||
|
||||
'@octokit/plugin-paginate-rest@13.1.1(@octokit/core@7.0.4)':
|
||||
dependencies:
|
||||
'@octokit/core': 7.0.4
|
||||
'@octokit/types': 14.1.0
|
||||
|
||||
'@octokit/plugin-request-log@5.3.1(@octokit/core@6.1.6)':
|
||||
dependencies:
|
||||
'@octokit/core': 6.1.6
|
||||
|
||||
'@octokit/plugin-rest-endpoint-methods@13.5.0(@octokit/core@6.1.6)':
|
||||
dependencies:
|
||||
'@octokit/core': 6.1.6
|
||||
'@octokit/types': 13.10.0
|
||||
|
||||
'@octokit/request-error@6.1.8':
|
||||
dependencies:
|
||||
'@octokit/types': 14.1.0
|
||||
|
||||
'@octokit/request-error@7.0.0':
|
||||
dependencies:
|
||||
'@octokit/types': 14.1.0
|
||||
|
||||
'@octokit/request@10.0.3':
|
||||
dependencies:
|
||||
'@octokit/endpoint': 11.0.0
|
||||
'@octokit/request-error': 7.0.0
|
||||
'@octokit/types': 14.1.0
|
||||
fast-content-type-parse: 3.0.0
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/request@9.2.4':
|
||||
dependencies:
|
||||
'@octokit/endpoint': 10.1.4
|
||||
'@octokit/request-error': 6.1.8
|
||||
'@octokit/types': 14.1.0
|
||||
fast-content-type-parse: 2.0.1
|
||||
universal-user-agent: 7.0.3
|
||||
|
||||
'@octokit/rest@21.1.1':
|
||||
dependencies:
|
||||
'@octokit/core': 6.1.6
|
||||
'@octokit/plugin-paginate-rest': 11.6.0(@octokit/core@6.1.6)
|
||||
'@octokit/plugin-request-log': 5.3.1(@octokit/core@6.1.6)
|
||||
'@octokit/plugin-rest-endpoint-methods': 13.5.0(@octokit/core@6.1.6)
|
||||
|
||||
'@octokit/types@13.10.0':
|
||||
dependencies:
|
||||
'@octokit/openapi-types': 24.2.0
|
||||
|
||||
'@octokit/types@14.1.0':
|
||||
dependencies:
|
||||
'@octokit/openapi-types': 25.1.0
|
||||
|
||||
'@octokit/types@15.0.0':
|
||||
dependencies:
|
||||
'@octokit/openapi-types': 26.0.0
|
||||
|
||||
'@octokit/webhooks-methods@5.1.1': {}
|
||||
|
||||
'@octokit/webhooks-methods@6.0.0': {}
|
||||
|
||||
'@octokit/webhooks@13.9.1':
|
||||
dependencies:
|
||||
'@octokit/openapi-webhooks-types': 11.0.0
|
||||
'@octokit/request-error': 6.1.8
|
||||
'@octokit/webhooks-methods': 5.1.1
|
||||
|
||||
'@octokit/webhooks@14.1.3':
|
||||
dependencies:
|
||||
'@octokit/openapi-webhooks-types': 12.0.3
|
||||
'@octokit/request-error': 7.0.0
|
||||
'@octokit/webhooks-methods': 6.0.0
|
||||
|
||||
'@pkgr/core@0.2.9': {}
|
||||
|
||||
'@poppinss/colors@4.1.5':
|
||||
@@ -2479,6 +2993,8 @@ snapshots:
|
||||
|
||||
'@speed-highlight/core@1.2.7': {}
|
||||
|
||||
'@types/aws-lambda@8.10.152': {}
|
||||
|
||||
'@types/chai@5.2.2':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
@@ -2655,6 +3171,10 @@ snapshots:
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
before-after-hook@3.0.2: {}
|
||||
|
||||
before-after-hook@4.0.0: {}
|
||||
|
||||
birpc@0.2.14: {}
|
||||
|
||||
blake3-wasm@2.1.5: {}
|
||||
@@ -2937,6 +3457,10 @@ snapshots:
|
||||
|
||||
exsolve@1.0.7: {}
|
||||
|
||||
fast-content-type-parse@2.0.1: {}
|
||||
|
||||
fast-content-type-parse@3.0.0: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-diff@1.3.0: {}
|
||||
@@ -3309,6 +3833,8 @@ snapshots:
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
|
||||
toad-cache@3.7.0: {}
|
||||
|
||||
ts-api-utils@2.1.0(typescript@5.9.2):
|
||||
dependencies:
|
||||
typescript: 5.9.2
|
||||
@@ -3347,6 +3873,10 @@ snapshots:
|
||||
pathe: 2.0.3
|
||||
ufo: 1.6.1
|
||||
|
||||
universal-github-app-jwt@2.2.2: {}
|
||||
|
||||
universal-user-agent@7.0.3: {}
|
||||
|
||||
update-browserslist-db@1.1.3(browserslist@4.25.4):
|
||||
dependencies:
|
||||
browserslist: 4.25.4
|
||||
|
||||
Reference in New Issue
Block a user