diff --git a/.github/workflows/bot.yml b/.github/workflows/bot.yml new file mode 100644 index 00000000..0495d9a9 --- /dev/null +++ b/.github/workflows/bot.yml @@ -0,0 +1,287 @@ +name: Test, Build & Deploy the Comfy-PR Bot to Cloud Run Service + +on: + push: + branches: [main, dev] + pull_request: + branches: [main] + +concurrency: + group: deploy-${{ github.head_ref || github.ref }} + cancel-in-progress: false + +env: + PROJECT_ID: dreamboothy-dev + GAR_LOCATION: us-west2 + SERVICE: comfy-pr + REGION: us-west2 + +jobs: + deploy: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' + steps: + - name: Checkout + uses: actions/checkout@v5 + + # Build and test + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Cache TypeScript incremental build + uses: actions/cache@v3 + with: + path: tsconfig.tsbuildinfo + key: ${{ runner.os }}-tsc-${{ hashFiles('**/*.ts', '**/*.tsx', 'tsconfig.json') }} + restore-keys: | + ${{ runner.os }}-tsc- + + - name: Type check + run: bunx tsc --noEmit + + - name: Run tests + run: bun test + + - name: Build Next.js app + run: bun run build + + # Deploy to CloudRun + - name: Google Auth + id: auth + uses: google-github-actions/auth@v2 + with: + credentials_json: "${{ secrets.GCP_SA_KEY }}" + + - name: Cache Google Cloud SDK + uses: actions/cache@v3 + with: + path: | + ~/.config/gcloud + ~/google-cloud-sdk + key: ${{ runner.os }}-gcloud-${{ hashFiles('**/deploy.yml') }} + restore-keys: | + ${{ runner.os }}-gcloud- + + - name: Set up Cloud SDK (gcloud) + uses: google-github-actions/setup-gcloud@v2 + with: + project_id: ${{ env.PROJECT_ID }} + skip_install: false + install_components: "" + + - name: Configure Docker to use gcloud + run: gcloud auth configure-docker $GAR_LOCATION-docker.pkg.dev --quiet + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and Push Container + run: |- + # Get branch name for tagging + BRANCH_NAME=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}} + SANITIZED_BRANCH=$(echo "$BRANCH_NAME" | sed 's|/|-|g' | sed 's|_|-|g' | tr '[:upper:]' '[:lower:]') + + # Build with multiple tags: SHA, branch, and latest + docker buildx build \ + --cache-from=type=registry,ref=$GAR_LOCATION-docker.pkg.dev/$PROJECT_ID/$SERVICE/$SERVICE:buildcache \ + --cache-from=type=registry,ref=$GAR_LOCATION-docker.pkg.dev/$PROJECT_ID/$SERVICE/$SERVICE:latest \ + --cache-to=type=registry,ref=$GAR_LOCATION-docker.pkg.dev/$PROJECT_ID/$SERVICE/$SERVICE:buildcache,mode=max \ + --push \ + --build-arg BUILDKIT_INLINE_CACHE=1 \ + -t "$GAR_LOCATION-docker.pkg.dev/$PROJECT_ID/$SERVICE/$SERVICE:$GITHUB_SHA" \ + -t "$GAR_LOCATION-docker.pkg.dev/$PROJECT_ID/$SERVICE/$SERVICE:branch-$SANITIZED_BRANCH" \ + -t "$GAR_LOCATION-docker.pkg.dev/$PROJECT_ID/$SERVICE/$SERVICE:latest" \ + . + + - name: Extract branch name + id: extract_branch + run: | + BRANCH_NAME=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}} + + # For PRs, use the actual branch SHA instead of the merge commit SHA + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + ACTUAL_SHA="${{ github.event.pull_request.head.sha }}" + echo "Using PR head SHA: $ACTUAL_SHA (instead of merge commit: $GITHUB_SHA)" + else + ACTUAL_SHA="$GITHUB_SHA" + fi + + # Sanitize branch name for Cloud Run tags + SANITIZED_BRANCH=$(echo "$BRANCH_NAME" | sed 's|/|-|g' | sed 's|_|-|g' | tr '[:upper:]' '[:lower:]' | sed 's/-*$//') + # If branch starts with a number, prefix with 'br-' + if [[ "$SANITIZED_BRANCH" =~ ^[0-9] ]]; then + SANITIZED_BRANCH="br-${SANITIZED_BRANCH}" + fi + + # Create revision suffix: branch-first7chars (max 30 chars for branch, 7 for hash) + BRANCH_SUFFIX=$(echo "$SANITIZED_BRANCH" | cut -c1-30) + COMMIT_SHORT=$(echo "$ACTUAL_SHA" | cut -c1-7) + REVISION_SUFFIX="${BRANCH_SUFFIX}-${COMMIT_SHORT}" + + # Truncate tag to fit within Cloud Run's 46 char limit + TAG_FOR_TRAFFIC=$(echo "$SANITIZED_BRANCH" | cut -c1-37) + + echo "branch=$BRANCH_NAME" >> $GITHUB_OUTPUT + echo "sanitized_branch=$SANITIZED_BRANCH" >> $GITHUB_OUTPUT + echo "revision_suffix=$REVISION_SUFFIX" >> $GITHUB_OUTPUT + echo "tag_for_traffic=$TAG_FOR_TRAFFIC" >> $GITHUB_OUTPUT + echo "Deploying branch: $BRANCH_NAME (sanitized: $SANITIZED_BRANCH)" + echo "Revision suffix: $REVISION_SUFFIX" + echo "Traffic tag: $TAG_FOR_TRAFFIC" + + - name: Deploy to Cloud Run + id: deploy + uses: google-github-actions/deploy-cloudrun@v2 + with: + service: ${{ env.SERVICE }} + region: ${{ env.REGION }} + image: ${{ env.GAR_LOCATION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.SERVICE }}/${{ + env.SERVICE }}:${{ github.sha }} + flags: | + ${{ steps.extract_branch.outputs.branch == 'main' && '--memory=2Gi --cpu=2' || '--memory=1Gi --cpu=1' }} + --port=80 + --allow-unauthenticated + --service-account=comfy-pr-sa@${{ env.PROJECT_ID }}.iam.gserviceaccount.com + --update-labels=branch=${{ steps.extract_branch.outputs.sanitized_branch }},commit=${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }},deployed-by=github-actions + --tag=${{ steps.extract_branch.outputs.tag_for_traffic }} + --revision-suffix=${{ steps.extract_branch.outputs.revision_suffix }} + ${{ steps.extract_branch.outputs.branch == 'main' && '--min-instances=1' || '--min-instances=0' }} + --max-instances=1 + ${{ steps.extract_branch.outputs.branch != 'main' && '--no-traffic' || '' }} + env_vars: | + BRANCH_NAME=${{ steps.extract_branch.outputs.branch }} + NODE_ENV=production + GH_TOKEN=${{ secrets.GH_TOKEN }} + SALT=${{ secrets.SALT }} + GIT_USEREMAIL=${{ secrets.GIT_USEREMAIL }} + GIT_USERNAME=${{ secrets.GIT_USERNAME }} + FORK_PREFIX=${{ secrets.FORK_PREFIX }} + FORK_OWNER=${{ secrets.FORK_OWNER }} + MONGODB_URI=${{ secrets.MONGODB_URI }} + SLACK_BOT_TOKEN=${{ secrets.SLACK_BOT_TOKEN }} + NOTION_TOKEN=${{ secrets.NOTION_TOKEN }} + + - name: Generate tagged URL + id: tagged_url + if: steps.extract_branch.outputs.branch != 'main' + run: | + # Generate the correct tagged URL format: https://{tag}---{service}-{hash}.a.run.app + SERVICE_URL="${{ steps.deploy.outputs.url }}" + TAGGED_URL=$(echo "$SERVICE_URL" | sed "s|https://|https://${{ steps.extract_branch.outputs.tag_for_traffic }}---|") + echo "tagged_url=$TAGGED_URL" >> $GITHUB_OUTPUT + echo "Generated tagged URL: $TAGGED_URL" + + - name: Comment on PR + if: github.event_name == 'pull_request' + continue-on-error: true + uses: edumserrano/find-create-or-update-comment@v3 + with: + issue-number: ${{ github.event.pull_request.number }} + body-includes: "" + comment-author: "github-actions[bot]" + edit-mode: replace + body: | + + ## 🚀 Deployment Ready + + Your PR has been deployed successfully! + + **🔗 Cloud Run URL:** ${{ steps.tagged_url.outputs.tagged_url }} + + **Branch:** `${{ steps.extract_branch.outputs.branch }}` + **Commit:** `${{ github.sha }}` + **Revision:** `${{ env.SERVICE }}-${{ steps.extract_branch.outputs.revision_suffix }}` + **Scaling:** Min instances: 0, Max instances: 1 (scales to zero when not in use) + + --- + + 📊 **[View Logs in GCP Console](https://console.cloud.google.com/logs/query;query=resource.type%3D%22cloud_run_revision%22%0Aresource.labels.service_name%3D%22${{ env.SERVICE }}%22%0Aresource.labels.revision_name%3D%22${{ env.SERVICE }}-${{ steps.extract_branch.outputs.revision_suffix }}%22;timeRange=PT1H?project=${{ env.PROJECT_ID }})** + + --- + + â„šī¸ Note: This deployment receives no production traffic and will be automatically cleaned up when the PR is closed. + +
+ 📋 Deployment Details + + - **Service:** `${{ env.SERVICE }}` + - **Region:** `${{ env.REGION }}` + - **Image:** `${{ env.GAR_LOCATION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.SERVICE }}/${{ env.SERVICE }}:${{ github.sha }}` + - **Labels:** `branch=${{ steps.extract_branch.outputs.branch }}, commit=${{ github.sha }}, deployed-by=github-actions` + +
+ + - name: Health Check Cloud Run + id: healthcheck + run: | + # Check the Cloud Run backend directly + if [ "${{ steps.extract_branch.outputs.branch }}" == "main" ]; then + HEALTH_URL="${{ steps.deploy.outputs.url }}/api/health" + echo "đŸĨ Starting health check for main branch: $HEALTH_URL" + else + # For PR branches, check the tagged URL directly + HEALTH_URL="${{ steps.tagged_url.outputs.tagged_url }}/api/health" + echo "đŸĨ Starting health check for Cloud Run: $HEALTH_URL" + fi + + # Health check with timeout (3 minutes = 180 seconds) + MAX_ATTEMPTS=36 # 36 attempts * 5 seconds = 180 seconds + ATTEMPT=1 + + while [ $ATTEMPT -le $MAX_ATTEMPTS ]; do + echo "Attempt $ATTEMPT/$MAX_ATTEMPTS..." + + # Make health check request + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$HEALTH_URL" || echo "000") + + if [ "$HTTP_STATUS" == "200" ]; then + echo "✅ Health check passed! Server is up and running." + HEALTH_RESPONSE=$(curl -s "$HEALTH_URL") + echo "Health response: $HEALTH_RESPONSE" + echo "health_status=success" >> $GITHUB_OUTPUT + exit 0 + else + echo "âŗ Health check returned HTTP $HTTP_STATUS, retrying in 5 seconds..." + fi + + ATTEMPT=$((ATTEMPT + 1)) + [ $ATTEMPT -le $MAX_ATTEMPTS ] && sleep 5 + done + + echo "❌ Health check failed after 3 minutes. Server did not respond with HTTP 200." + echo "health_status=failed" >> $GITHUB_OUTPUT + exit 1 + + - name: Set Traffic to 100% for Main Branch + if: steps.extract_branch.outputs.branch == 'main' && steps.healthcheck.outputs.health_status + == 'success' + run: | + echo "đŸšĻ Setting traffic to 100% for main branch deployment..." + + # Update traffic to route 100% to the specific revision that was just deployed + gcloud run services update-traffic ${{ env.SERVICE }} \ + --region=${{ env.REGION }} \ + --to-revisions=${{ env.SERVICE }}-${{ steps.extract_branch.outputs.revision_suffix }}=100 \ + --platform=managed + + echo "✅ Traffic routing updated: 100% traffic now directed to revision ${{ env.SERVICE }}-${{ steps.extract_branch.outputs.revision_suffix }}" + + - name: Show Output + run: | + echo "🚀 Deployment completed!" + echo "Cloud Run Service URL: ${{ steps.deploy.outputs.url }}" + if [ "${{ steps.healthcheck.outputs.health_status }}" == "success" ]; then + echo "✅ Backend health check passed" + if [ "${{ github.event_name }}" == "pull_request" ]; then + echo "🔗 Cloud Run Tagged URL: ${{ steps.tagged_url.outputs.tagged_url }}" + echo "â„šī¸ This PR deployment receives no production traffic (--no-traffic)" + else + echo "✅ Main branch deployment receiving production traffic" + fi + else + echo "âš ī¸ Backend health check failed" + echo "Please check Cloud Run logs for details" + fi diff --git a/.github/workflows/easylabel.yaml b/.github/workflows/easylabel.yaml index 76391fc2..f885d76d 100644 --- a/.github/workflows/easylabel.yaml +++ b/.github/workflows/easylabel.yaml @@ -21,7 +21,7 @@ jobs: - run: bun i # Run EasyLabel task with timeout - - run: bun run/easylabel.tsx + - run: bun bot/easylabel.tsx env: GH_TOKEN_COMFY_PR: ${{ secrets.GH_TOKEN_COMFY_PR_BOT }} MONGODB_URI: ${{ secrets.MONGODB_URI }} diff --git a/.gitignore b/.gitignore index 1961b60d..71d2d422 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,11 @@ codes/ tmp/ TODO.md + +# Terraform +infra/.terraform/ +infra/.terraform.lock.hcl +infra/*.tfstate +infra/*.tfstate.* +infra/*.tfvars +infra/*.tfplan diff --git a/CLAUDE.md b/CLAUDE.md index 0c5cd0b1..5daee5bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ The TypeScript server was experiencing severe performance issues causing slowdow - **`app/tasks/`**: Specific task implementations - **`bot/code/`**: GitHub integration tools including prbot for spawning AI agents on repositories - **`gh-service/`**: GitHub webhook service components -- **`run/`**: Executable scripts and services +- **`bot/`**: Executable scripts and services - **Tests**: Co-located with source files using `.spec.ts` suffix ## Working Tasks State Management @@ -735,6 +735,7 @@ SFlow is a powerful functional stream processing library used throughout the cod - **Author**: snomiao - **License**: MIT - **Core Concepts**: SFlow is built around composable stream operators, lazy evaluation, and support for both synchronous and asynchronous data flows. +<<<<<<< HEAD ## Project TODOs diff --git a/README.md b/README.md index f9258003..110443e4 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Through these efforts, Comfy-PR seeks to create an environment where Custom Node 1. **CLI Tool** (`src/cli.ts`): Command-line interface for processing individual repositories 2. **Main Service** (`src/index.ts`): Orchestrates batch processing of repositories 3. **Web Dashboard** (`app/`): Next.js application with analytics and management UI -4. **Webhook Service** (`run/index.ts`): Real-time GitHub event monitoring +4. **Webhook Service** (`bot/index.ts`): Real-time GitHub event monitoring 5. **Task System** (`app/tasks/`): Automated background tasks for various operations ### Key Features @@ -240,7 +240,7 @@ Comfy-PR/ │ ├── (dashboard)/ # Dashboard pages and components │ ├── api/ # API routes and tRPC │ └── tasks/ # Background task implementations -├── run/ # Production services +├── bot/ # Production services │ ├── index.ts # GitHub webhook service │ └── deploy.sh # Cloud deployment scripts ├── gh-service/ # Legacy webhook service @@ -308,7 +308,7 @@ export WEBHOOK_BASE_URL=https://your-domain.com bun run gh-service ``` -See [WEBHOOK_SETUP.md](./WEBHOOK_SETUP.md) for detailed webhook configuration. +See [WEBHOOK_SETUP.md](./docs/WEBHOOK_SETUP.md) for detailed webhook configuration. ### Task System diff --git a/app/(dashboard)/cnrepos/page.tsx b/app/(dashboard)/cnrepos/page.tsx index 13d6f433..49188864 100644 --- a/app/(dashboard)/cnrepos/page.tsx +++ b/app/(dashboard)/cnrepos/page.tsx @@ -11,6 +11,9 @@ import { CNRepos } from "@/src/CNRepos"; import { Suspense } from "react"; import { CNReposTableClient } from "./CNReposTableClient"; +// Prevent static generation since this page requires database access +export const dynamic = "force-dynamic"; + interface CNReposPageProps { searchParams?: Promise<{ page?: string; diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index f32c4e7b..bebae37b 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -3,7 +3,7 @@ import { Suspense } from "react"; import DetailsTable from "./DetailsTable"; import TotalsPage from "./totals/page"; -// Force dynamic rendering to avoid build-time database access +// Prevent static generation since this page requires database access export const dynamic = "force-dynamic"; export const revalidate = 60; // seconds diff --git a/app/(dashboard)/repos/page.tsx b/app/(dashboard)/repos/page.tsx index 0cf8fa77..ffc1d7c6 100644 --- a/app/(dashboard)/repos/page.tsx +++ b/app/(dashboard)/repos/page.tsx @@ -2,7 +2,7 @@ import { CNRepos } from "@/src/CNRepos"; import { Suspense } from "react"; import yaml from "yaml"; -// Force dynamic rendering to avoid build-time database access +// Prevent static generation since this page requires database access export const dynamic = "force-dynamic"; /** diff --git a/app/bot/page.tsx b/app/bot/page.tsx new file mode 100644 index 00000000..9bca1731 --- /dev/null +++ b/app/bot/page.tsx @@ -0,0 +1,3 @@ +"use client"; + +export default function BotPage() {} diff --git a/app/tasks/gh-core-tag-notification/index.spec.ts b/app/tasks/gh-core-tag-notification/index.spec.ts index 9574bff4..9309261b 100644 --- a/app/tasks/gh-core-tag-notification/index.spec.ts +++ b/app/tasks/gh-core-tag-notification/index.spec.ts @@ -1,49 +1,42 @@ -import { gh } from "@/lib/github"; -import { getSlackChannel } from "@/lib/slack/channels"; -import { afterEach, beforeEach, describe, expect, it, jest } from "bun:test"; +import { gh } from "@/src/gh"; +import { getSlackChannel } from "@/src/slack/channels"; +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; import { upsertSlackMessage } from "../gh-desktop-release-notification/upsertSlackMessage"; -jest.mock("@/src/gh"); -jest.mock("@/src/slack/channels"); -jest.mock("../gh-desktop-release-notification/upsertSlackMessage"); +mock.module("@/src/gh", () => ({ gh: {} })); +mock.module("@/src/slack/channels", () => ({ getSlackChannel: mock() })); +mock.module("../gh-desktop-release-notification/upsertSlackMessage", () => ({ upsertSlackMessage: mock() })); const mockCollection = { - createIndex: jest.fn().mockResolvedValue({}), - findOne: jest.fn().mockResolvedValue(null), - findOneAndUpdate: jest.fn().mockImplementation((_filter, update) => Promise.resolve(update.$set)), + createIndex: mock(() => Promise.resolve({})), + findOne: mock(() => Promise.resolve(null)) as any, + findOneAndUpdate: mock((_filter: any, update: any) => Promise.resolve(update.$set)) as any, }; -jest.mock("@/src/db", () => ({ +mock.module("@/src/db", () => ({ db: { - collection: jest.fn(() => mockCollection), + collection: mock(() => mockCollection), }, })); import runGithubCoreTagNotificationTask from "./index"; describe("GithubCoreTagNotificationTask", () => { - const mockGh = gh as jest.Mocked; - const mockGetSlackChannel = getSlackChannel as jest.MockedFunction; - const mockUpsertSlackMessage = upsertSlackMessage as jest.MockedFunction< - typeof upsertSlackMessage - >; + const mockGh = gh as any; + const mockGetSlackChannel = getSlackChannel as any; + const mockUpsertSlackMessage = upsertSlackMessage as any; beforeEach(() => { - jest.clearAllMocks(); - mockCollection.findOne.mockResolvedValue(null); - mockCollection.findOneAndUpdate.mockImplementation((_filter, update) => - Promise.resolve(update.$set), - ); - mockGetSlackChannel.mockImplementation((channelName: string) => - Promise.resolve({ - id: channelName === "desktop" ? "test-channel-desktop" : "test-channel-live-ops", - name: channelName, - } as any), - ); + mockCollection.findOne.mockClear(); + mockCollection.findOneAndUpdate.mockClear(); + mockCollection.findOne.mockImplementation(() => Promise.resolve(null)); + mockCollection.findOneAndUpdate.mockImplementation((_filter: any, update: any) => Promise.resolve(update.$set)); + mockGetSlackChannel.mockResolvedValue({ id: "test-channel-id", name: "desktop" } as any); }); afterEach(() => { - jest.clearAllMocks(); + mockCollection.findOne.mockClear(); + mockCollection.findOneAndUpdate.mockClear(); }); it("should fetch tags from the ComfyUI repository", async () => { @@ -61,19 +54,21 @@ describe("GithubCoreTagNotificationTask", () => { ]; mockGh.repos = { - listTags: jest.fn().mockResolvedValue({ data: mockTags }), - getCommit: jest.fn().mockResolvedValue({ - data: { - commit: { - author: { date: new Date().toISOString() }, - committer: { date: new Date().toISOString() }, + listTags: mock(() => Promise.resolve({ data: mockTags })), + getCommit: mock(() => + Promise.resolve({ + data: { + commit: { + author: { date: new Date().toISOString() }, + committer: { date: new Date().toISOString() }, + }, }, - }, - }), + }), + ), } as any; mockGh.git = { - getTag: jest.fn().mockRejectedValue(new Error("Not an annotated tag")), + getTag: mock(() => Promise.reject(new Error("Not an annotated tag"))), } as any; mockUpsertSlackMessage.mockResolvedValue({ @@ -103,28 +98,32 @@ describe("GithubCoreTagNotificationTask", () => { ]; mockGh.repos = { - listTags: jest.fn().mockResolvedValue({ data: mockTags }), - getCommit: jest.fn().mockResolvedValue({ - data: { - commit: { - author: { date: new Date().toISOString() }, + listTags: mock(() => Promise.resolve({ data: mockTags })), + getCommit: mock(() => + Promise.resolve({ + data: { + commit: { + author: { date: new Date().toISOString() }, + }, }, - }, - }), + }), + ), } as any; mockGh.git = { - getTag: jest.fn().mockResolvedValue({ - data: { - tag: "v0.2.2", - tagger: { - date: new Date().toISOString(), - name: "Test Author", - email: "test@example.com", + getTag: mock(() => + Promise.resolve({ + data: { + tag: "v0.2.2", + tagger: { + date: new Date().toISOString(), + name: "Test Author", + email: "test@example.com", + }, + message: "Release v0.2.2 with new features", }, - message: "Release v0.2.2 with new features", - }, - }), + }), + ), } as any; mockUpsertSlackMessage.mockResolvedValue({ @@ -150,18 +149,20 @@ describe("GithubCoreTagNotificationTask", () => { ]; mockGh.repos = { - listTags: jest.fn().mockResolvedValue({ data: mockTags }), - getCommit: jest.fn().mockResolvedValue({ - data: { - commit: { - author: { date: new Date().toISOString() }, + listTags: mock(() => Promise.resolve({ data: mockTags })), + getCommit: mock(() => + Promise.resolve({ + data: { + commit: { + author: { date: new Date().toISOString() }, + }, }, - }, - }), + }), + ), } as any; mockGh.git = { - getTag: jest.fn().mockRejectedValue(new Error("Not an annotated tag")), + getTag: mock(() => Promise.reject(new Error("Not an annotated tag"))), } as any; mockUpsertSlackMessage.mockResolvedValue({ @@ -200,26 +201,21 @@ describe("GithubCoreTagNotificationTask", () => { ]; mockGh.repos = { - listTags: jest.fn().mockResolvedValue({ data: mockTags }), + listTags: mock(() => Promise.resolve({ data: mockTags })), } as any; - mockCollection.findOne.mockResolvedValue({ - tagName: "v0.2.0", - commitSha: "existing123", - url: "https://github.com/comfyanonymous/ComfyUI/releases/tag/v0.2.0", - slackMessages: [ - { - text: "Already sent", - channel: "test-channel-desktop", - url: "https://slack.com/message/old1", - }, - { + mockCollection.findOne.mockImplementation(() => + Promise.resolve({ + tagName: "v0.2.0", + commitSha: "existing123", + url: "https://github.com/comfyanonymous/ComfyUI/releases/tag/v0.2.0", + slackMessage: { text: "Already sent", - channel: "test-channel-live-ops", - url: "https://slack.com/message/old2", + channel: "test-channel-id", + url: "https://slack.com/message/old", }, - ], - }); + }), + ); await runGithubCoreTagNotificationTask(); @@ -240,21 +236,23 @@ describe("GithubCoreTagNotificationTask", () => { const tagMessage = "Major release with breaking changes"; mockGh.repos = { - listTags: jest.fn().mockResolvedValue({ data: mockTags }), + listTags: mock(() => Promise.resolve({ data: mockTags })), } as any; mockGh.git = { - getTag: jest.fn().mockResolvedValue({ - data: { - tag: "v0.3.0", - tagger: { - date: new Date().toISOString(), - name: "Test Author", - email: "test@example.com", + getTag: mock(() => + Promise.resolve({ + data: { + tag: "v0.3.0", + tagger: { + date: new Date().toISOString(), + name: "Test Author", + email: "test@example.com", + }, + message: tagMessage, }, - message: tagMessage, - }, - }), + }), + ), } as any; mockUpsertSlackMessage.mockResolvedValue({ @@ -285,25 +283,29 @@ describe("GithubCoreTagNotificationTask", () => { ]; mockGh.repos = { - listTags: jest.fn().mockResolvedValue({ data: mockTags }), - getCommit: jest.fn().mockResolvedValue({ - data: { - commit: { - author: { date: oldDate.toISOString() }, + listTags: mock(() => Promise.resolve({ data: mockTags })), + getCommit: mock(() => + Promise.resolve({ + data: { + commit: { + author: { date: oldDate.toISOString() }, + }, }, - }, - }), + }), + ), } as any; mockGh.git = { - getTag: jest.fn().mockResolvedValue({ - data: { - tag: "v0.1.0", - tagger: { - date: oldDate.toISOString(), + getTag: mock(() => + Promise.resolve({ + data: { + tag: "v0.1.0", + tagger: { + date: oldDate.toISOString(), + }, }, - }, - }), + }), + ), } as any; await runGithubCoreTagNotificationTask(); diff --git a/app/tasks/gh-design/page.tsx b/app/tasks/gh-design/page.tsx index 320e0f6b..d7b3495e 100644 --- a/app/tasks/gh-design/page.tsx +++ b/app/tasks/gh-design/page.tsx @@ -1,17 +1,9 @@ import { Badge } from "@/components/ui/badge"; -import { - Table, - TableBody, - TableCaption, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; +import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import Link from "next/link"; import { GithubDesignTaskMetaEditor } from "./GithubDesignTaskMetaEditor"; -// Force dynamic rendering to avoid build-time database access +// Prevent static generation since this page requires database access export const dynamic = "force-dynamic"; /** @@ -114,9 +106,13 @@ export default async function GithubDesignTaskPage() { {{ pull_request: "PR", issue: "Issue" }[task.type] || "Task"} +<<<<<<< HEAD {getIssueNumber(task.url)} +======= + {getIssueNumber(task.url)} +>>>>>>> 6c13502 (fix: mark gh-design task page as dynamic)

{task.title}

diff --git a/app/tasks/gh-desktop-release-notification/index.spec.ts b/app/tasks/gh-desktop-release-notification/index.spec.ts index 29db989e..e1119e2c 100644 --- a/app/tasks/gh-desktop-release-notification/index.spec.ts +++ b/app/tasks/gh-desktop-release-notification/index.spec.ts @@ -1,39 +1,36 @@ -import { gh } from "@/lib/github"; -import { getSlackChannel } from "@/lib/slack/channels"; -import { afterEach, beforeEach, describe, expect, it, jest } from "bun:test"; +import { gh } from "@/src/gh"; +import { getSlackChannel } from "@/src/slack/channels"; +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; import { upsertSlackMessage } from "./upsertSlackMessage"; -jest.mock("@/src/gh"); -jest.mock("@/src/slack/channels"); -jest.mock("./upsertSlackMessage"); +mock.module("@/src/gh", () => ({ gh: {} })); +mock.module("@/src/slack/channels", () => ({ getSlackChannel: mock() })); +mock.module("./upsertSlackMessage", () => ({ upsertSlackMessage: mock() })); const mockCollection = { - createIndex: jest.fn().mockResolvedValue({}), - findOne: jest.fn().mockResolvedValue(null), - findOneAndUpdate: jest.fn().mockImplementation((_filter, update) => Promise.resolve(update.$set)), + createIndex: mock(() => Promise.resolve({})), + findOne: mock(() => Promise.resolve(null)) as any, + findOneAndUpdate: mock((_filter: any, update: any) => Promise.resolve(update.$set)) as any, }; -jest.mock("@/src/db", () => ({ +mock.module("@/src/db", () => ({ db: { - collection: jest.fn(() => mockCollection), + collection: mock(() => mockCollection), }, })); import runGithubDesktopReleaseNotificationTask from "./index"; describe("GithubDesktopReleaseNotificationTask", () => { - const mockGh = gh as jest.Mocked; - const mockGetSlackChannel = getSlackChannel as jest.MockedFunction; - const mockUpsertSlackMessage = upsertSlackMessage as jest.MockedFunction< - typeof upsertSlackMessage - >; + const mockGh = gh as any; + const mockGetSlackChannel = getSlackChannel as any; + const mockUpsertSlackMessage = upsertSlackMessage as any; beforeEach(async () => { - jest.clearAllMocks(); - mockCollection.findOne.mockResolvedValue(null); - mockCollection.findOneAndUpdate.mockImplementation((_filter, update) => - Promise.resolve(update.$set), - ); + mockCollection.findOne.mockClear(); + mockCollection.findOneAndUpdate.mockClear(); + mockCollection.findOne.mockImplementation(() => Promise.resolve(null)); + mockCollection.findOneAndUpdate.mockImplementation((_filter: any, update: any) => Promise.resolve(update.$set)); mockGetSlackChannel.mockResolvedValue({ id: "test-channel-id", @@ -48,7 +45,8 @@ describe("GithubDesktopReleaseNotificationTask", () => { }); afterEach(async () => { - jest.clearAllMocks(); + mockCollection.findOne.mockClear(); + mockCollection.findOneAndUpdate.mockClear(); }); describe("Draft Release Processing - Bug Fix Verification", () => { @@ -64,9 +62,9 @@ describe("GithubDesktopReleaseNotificationTask", () => { }; mockGh.repos = { - listReleases: jest.fn().mockResolvedValue({ + listReleases: mock(() => Promise.resolve({ data: [mockDraftRelease], - }), + })), } as any; // First call - save initial draft data @@ -138,9 +136,9 @@ describe("GithubDesktopReleaseNotificationTask", () => { }; mockGh.repos = { - listReleases: jest.fn().mockResolvedValue({ + listReleases: mock(() => Promise.resolve({ data: [mockDraftRelease], - }), + })), } as any; const expectedText = @@ -184,9 +182,9 @@ describe("GithubDesktopReleaseNotificationTask", () => { }; mockGh.repos = { - listReleases: jest.fn().mockResolvedValue({ + listReleases: mock(() => Promise.resolve({ data: [mockDraftRelease], - }), + })), } as any; // Return task with old drafting message text @@ -243,9 +241,9 @@ describe("GithubDesktopReleaseNotificationTask", () => { }; mockGh.repos = { - listReleases: jest.fn().mockResolvedValue({ + listReleases: mock(() => Promise.resolve({ data: [mockStableRelease], - }), + })), } as any; // First call - save initial data @@ -306,9 +304,9 @@ describe("GithubDesktopReleaseNotificationTask", () => { }; mockGh.repos = { - listReleases: jest.fn().mockResolvedValue({ + listReleases: mock(() => Promise.resolve({ data: [mockStableRelease], - }), + })), } as any; const expectedText = @@ -352,9 +350,9 @@ describe("GithubDesktopReleaseNotificationTask", () => { }; mockGh.repos = { - listReleases: jest.fn().mockResolvedValue({ + listReleases: mock(() => Promise.resolve({ data: [mockPrerelease], - }), + })), } as any; // First call - save initial data @@ -417,7 +415,7 @@ describe("GithubDesktopReleaseNotificationTask", () => { }; mockGh.repos = { - listReleases: jest.fn().mockResolvedValue({ + listReleases: mock(() => Promise.resolve({ data: [mockDesktopRelease], }), } as any; @@ -434,13 +432,15 @@ describe("GithubDesktopReleaseNotificationTask", () => { }); // Find core task - mockCollection.findOne.mockResolvedValueOnce({ - version: "v0.2.0", - slackMessage: { - text: "ComfyUI core v0.2.0 released", - url: "https://slack.com/message/core-123", - }, - }); + mockCollection.findOne.mockImplementationOnce(() => + Promise.resolve({ + version: "v0.2.0", + slackMessage: { + text: "ComfyUI core v0.2.0 released", + url: "https://slack.com/message/core-123", + }, + }), + ); // Second call - save with message including core version mockCollection.findOneAndUpdate.mockResolvedValueOnce({ @@ -489,8 +489,7 @@ describe("GithubDesktopReleaseNotificationTask", () => { }; mockGh.repos = { - listReleases: jest - .fn() + listReleases: mock() .mockResolvedValueOnce({ data: [mockComfyUIRelease] }) .mockResolvedValueOnce({ data: [mockDesktopRelease] }), } as any; @@ -535,9 +534,11 @@ describe("GithubDesktopReleaseNotificationTask", () => { }; mockGh.repos = { - listReleases: jest.fn().mockResolvedValue({ - data: [oldRelease], - }), + listReleases: mock(() => + Promise.resolve({ + data: [oldRelease], + }), + ), } as any; mockCollection.findOneAndUpdate.mockResolvedValue({ @@ -561,6 +562,12 @@ describe("GithubDesktopReleaseNotificationTask", () => { describe("Database Index", () => { it("should create unique index on url field", async () => { + mockGh.repos = { + listReleases: mock(() => Promise.resolve({ data: [] })), + } as any; + + await runGithubDesktopReleaseNotificationTask(); + expect(mockCollection.createIndex).toHaveBeenCalledWith({ url: 1 }, { unique: true }); }); }); diff --git a/app/tasks/gh-frontend-release-notification/index.spec.ts b/app/tasks/gh-frontend-release-notification/index.spec.ts index 03dfc50d..e208edcf 100644 --- a/app/tasks/gh-frontend-release-notification/index.spec.ts +++ b/app/tasks/gh-frontend-release-notification/index.spec.ts @@ -1,40 +1,49 @@ -import { db } from "@/src/db"; -import { gh } from "@/lib/github"; import { parseGithubRepoUrl } from "@/src/parseOwnerRepo"; -import { getSlackChannel } from "@/lib/slack/channels"; -import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; -import runGithubFrontendReleaseNotificationTask from "./index"; - -jest.mock("@/src/gh"); -jest.mock("@/src/slack/channels"); -jest.mock("../gh-desktop-release-notification/upsertSlackMessage"); - -const mockGh = gh as jest.Mocked; -const mockGetSlackChannel = getSlackChannel as jest.MockedFunction; -const { upsertSlackMessage } = jest.requireMock( - "../gh-desktop-release-notification/upsertSlackMessage", -); +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; + +// Mock db module BEFORE importing anything that uses it +const mockDb = { + collection: mock(() => ({ + findOne: mock(), + findOneAndUpdate: mock(), + createIndex: mock(), + })), + close: mock(), +}; +mock.module("@/src/db", () => ({ db: mockDb })); +mock.module("@/src/gh", () => ({ gh: {} })); +mock.module("@/src/slack/channels", () => ({ getSlackChannel: mock() })); +mock.module("../gh-desktop-release-notification/upsertSlackMessage", () => ({ upsertSlackMessage: mock() })); + +// Now we can import the modules that depend on mocked modules +const { db } = await import("@/src/db"); +const { gh } = await import("@/src/gh"); +const { getSlackChannel } = await import("@/src/slack/channels"); +const { upsertSlackMessage } = await import("../gh-desktop-release-notification/upsertSlackMessage"); +const { default: runGithubFrontendReleaseNotificationTask } = await import("./index"); + +const mockGh = gh as any; +const mockGetSlackChannel = getSlackChannel as any; +const mockUpsertSlackMessage = upsertSlackMessage as any; describe("GithubFrontendReleaseNotificationTask", () => { let collection: any; beforeEach(async () => { - jest.clearAllMocks(); - collection = { - findOne: jest.fn(), - findOneAndUpdate: jest.fn(), - createIndex: jest.fn(), + findOne: mock(), + findOneAndUpdate: mock(), + createIndex: mock(), }; - jest.spyOn(db, "collection").mockReturnValue(collection); + spyOn(db, "collection").mockReturnValue(collection); mockGetSlackChannel.mockResolvedValue({ id: "test-channel-id", name: "frontend", } as any); - upsertSlackMessage.mockResolvedValue({ + mockUpsertSlackMessage.mockResolvedValue({ text: "mocked message", channel: "test-channel-id", url: "https://slack.com/message/123", @@ -42,7 +51,7 @@ describe("GithubFrontendReleaseNotificationTask", () => { }); afterEach(async () => { - jest.restoreAllMocks(); + // Cleanup }); describe("parseGithubRepoUrl", () => { @@ -68,9 +77,11 @@ describe("GithubFrontendReleaseNotificationTask", () => { }; mockGh.repos = { - listReleases: jest.fn().mockResolvedValue({ - data: [mockRelease], - }), + listReleases: mock(() => + Promise.resolve({ + data: [mockRelease], + }), + ), } as any; // First call - no existing message @@ -128,9 +139,11 @@ describe("GithubFrontendReleaseNotificationTask", () => { }; mockGh.repos = { - listReleases: jest.fn().mockResolvedValue({ - data: [mockRelease], - }), + listReleases: mock(() => + Promise.resolve({ + data: [mockRelease], + }), + ), } as any; // Return task with existing message text matching new message @@ -167,9 +180,11 @@ describe("GithubFrontendReleaseNotificationTask", () => { }; mockGh.repos = { - listReleases: jest.fn().mockResolvedValue({ - data: [mockPrerelease], - }), + listReleases: mock(() => + Promise.resolve({ + data: [mockPrerelease], + }), + ), } as any; // First call - save initial data @@ -221,9 +236,11 @@ describe("GithubFrontendReleaseNotificationTask", () => { }; mockGh.repos = { - listReleases: jest.fn().mockResolvedValue({ - data: [mockDraft], - }), + listReleases: mock(() => + Promise.resolve({ + data: [mockDraft], + }), + ), } as any; collection.findOneAndUpdate.mockResolvedValue({ @@ -263,9 +280,11 @@ describe("GithubFrontendReleaseNotificationTask", () => { }; mockGh.repos = { - listReleases: jest.fn().mockResolvedValue({ - data: [oldRelease], - }), + listReleases: mock(() => + Promise.resolve({ + data: [oldRelease], + }), + ), } as any; collection.findOneAndUpdate.mockResolvedValue({ @@ -297,9 +316,11 @@ describe("GithubFrontendReleaseNotificationTask", () => { }; mockGh.repos = { - listReleases: jest.fn().mockResolvedValue({ - data: [mockRelease], - }), + listReleases: mock(() => + Promise.resolve({ + data: [mockRelease], + }), + ), } as any; // Return task with old message text @@ -356,9 +377,11 @@ describe("GithubFrontendReleaseNotificationTask", () => { }; mockGh.repos = { - listReleases: jest.fn().mockResolvedValue({ - data: [mockRelease], - }), + listReleases: mock(() => + Promise.resolve({ + data: [mockRelease], + }), + ), } as any; // First call - save initial data diff --git a/app/tasks/gh-issue-transfer-comfyui-to-frontend/index.spec.ts b/app/tasks/gh-issue-transfer-comfyui-to-frontend/index.spec.ts index 320fb073..4f1e1cda 100644 --- a/app/tasks/gh-issue-transfer-comfyui-to-frontend/index.spec.ts +++ b/app/tasks/gh-issue-transfer-comfyui-to-frontend/index.spec.ts @@ -104,22 +104,9 @@ describe("GithubFrontendIssueTransferTask", () => { } return HttpResponse.json([]); }), - // Mock fetching comments + // Mock listing comments (for comment pagination) http.get("https://api.github.com/repos/comfyanonymous/ComfyUI/issues/123/comments", () => { - return HttpResponse.json([ - { - id: 1, - body: "First comment", - user: { login: "test-user", id: 1 }, - created_at: "2025-01-11T10:00:00Z", - }, - { - id: 2, - body: "Second comment", - user: { login: "test-user-2", id: 2 }, - created_at: "2025-01-12T10:00:00Z", - }, - ]); + return HttpResponse.json([]); }), // Mock creating issue in target repo http.post( @@ -134,22 +121,22 @@ describe("GithubFrontendIssueTransferTask", () => { }, ), // Mock creating comment on source issue - http.post( - "https://api.github.com/repos/comfyanonymous/ComfyUI/issues/123/comments", - async ({ request }) => { - createdComment = await request.json(); - return HttpResponse.json({ - id: 999, - body: createdComment.body, - user: { login: "test-user", id: 1 }, - html_url: "https://github.com/comfyanonymous/ComfyUI/issues/123#issuecomment-999", - created_at: new Date().toISOString(), - }); - }, - ), - // Mock closing the issue + http.post("https://api.github.com/repos/comfyanonymous/ComfyUI/issues/123/comments", async ({ request }) => { + createdComment = await request.json(); + return HttpResponse.json({ + id: 999, + body: createdComment.body, + user: { login: "test-user", id: 1 }, + html_url: "https://github.com/comfyanonymous/ComfyUI/issues/123#issuecomment-999", + created_at: new Date().toISOString(), + }); + }), + // Mock closing the source issue http.patch("https://api.github.com/repos/comfyanonymous/ComfyUI/issues/123", () => { - return HttpResponse.json({}); + return HttpResponse.json({ + ...sourceIssue, + state: "closed", + }); }), ); @@ -162,7 +149,7 @@ describe("GithubFrontendIssueTransferTask", () => { expect(createdIssue.body).toContain( "*This issue is transferred from: https://github.com/comfyanonymous/ComfyUI/issues/123*", ); - expect(createdIssue.labels).toEqual(["bug"]); + expect(createdIssue.labels).toEqual(["bug"]); // "frontend" label is filtered out expect(createdIssue.assignees).toEqual(["testuser"]); // Verify comment was posted @@ -280,6 +267,7 @@ describe("GithubFrontendIssueTransferTask", () => { http.get("https://api.github.com/repos/comfyanonymous/ComfyUI/issues", () => { return HttpResponse.json([sourceIssue]); }), + // Mock listing comments http.get("https://api.github.com/repos/comfyanonymous/ComfyUI/issues/555/comments", () => { return HttpResponse.json([]); }), @@ -321,6 +309,7 @@ describe("GithubFrontendIssueTransferTask", () => { http.get("https://api.github.com/repos/comfyanonymous/ComfyUI/issues", () => { return HttpResponse.json([sourceIssue]); }), + // Mock listing comments http.get("https://api.github.com/repos/comfyanonymous/ComfyUI/issues/666/comments", () => { return HttpResponse.json([]); }), @@ -333,6 +322,7 @@ describe("GithubFrontendIssueTransferTask", () => { http.post("https://api.github.com/repos/comfyanonymous/ComfyUI/issues/666/comments", () => { return HttpResponse.json({ message: "Comment Error" }, { status: 403 }); }), + // Note: No need to mock closing since comment fails and close is in the same try block ); await runGithubFrontendIssueTransferTask(); @@ -390,36 +380,29 @@ describe("GithubFrontendIssueTransferTask", () => { } return HttpResponse.json([]); }), - http.get( - "https://api.github.com/repos/comfyanonymous/ComfyUI/issues/:issue_number/comments", - () => { - return HttpResponse.json([]); - }, - ), - http.post( - "https://api.github.com/repos/Comfy-Org/ComfyUI_frontend/issues", - async ({ request }) => { - const body: any = await request.json(); - issuesCreated++; - const issueNumber = parseInt(body.title.split(" ")[1]); - return HttpResponse.json({ - number: issueNumber + 10000, - html_url: `https://github.com/Comfy-Org/ComfyUI_frontend/issues/${issueNumber + 10000}`, - }); - }, - ), - http.post( - "https://api.github.com/repos/comfyanonymous/ComfyUI/issues/:issue_number/comments", - () => { - commentsCreated++; - return HttpResponse.json({ - id: commentsCreated, - html_url: "https://github.com/comfyanonymous/ComfyUI/issues/comment", - }); - }, - ), + // Mock listing comments for all issues + http.get("https://api.github.com/repos/comfyanonymous/ComfyUI/issues/:issue_number/comments", () => { + return HttpResponse.json([]); + }), + http.post("https://api.github.com/repos/Comfy-Org/ComfyUI_frontend/issues", async ({ request }) => { + const body: any = await request.json(); + issuesCreated++; + const issueNumber = parseInt(body.title.split(" ")[1]); + return HttpResponse.json({ + number: issueNumber + 10000, + html_url: `https://github.com/Comfy-Org/ComfyUI_frontend/issues/${issueNumber + 10000}`, + }); + }), + http.post("https://api.github.com/repos/comfyanonymous/ComfyUI/issues/:issue_number/comments", () => { + commentsCreated++; + return HttpResponse.json({ + id: commentsCreated, + html_url: "https://github.com/comfyanonymous/ComfyUI/issues/comment", + }); + }), + // Mock closing all issues http.patch("https://api.github.com/repos/comfyanonymous/ComfyUI/issues/:issue_number", () => { - return HttpResponse.json({}); + return HttpResponse.json({ state: "closed" }); }), ); diff --git a/app/tasks/page.tsx b/app/tasks/page.tsx index ac8b42f0..f929b1fd 100644 --- a/app/tasks/page.tsx +++ b/app/tasks/page.tsx @@ -1,7 +1,14 @@ import Link from "next/link"; import { Suspense } from "react"; +import { GithubBugcopTask } from "../../bot/gh-bugcop/gh-bugcop"; +import { GithubBountyTask } from "./gh-bounty/gh-bounty"; +import { GithubDesignTask } from "./gh-design/gh-design"; +import { + GithubContributorAnalyzeTask, + GithubContributorAnalyzeTaskFilter, +} from "./github-contributor-analyze/GithubContributorAnalyzeTask"; -// Force dynamic rendering to avoid build-time database access +// Prevent static generation since this page requires database access export const dynamic = "force-dynamic"; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -14,11 +21,6 @@ export default async function TasksIndexPage() { // Dynamic imports to avoid build-time execution const { GithubActionUpdateTask } = await import("@/src/GithubActionUpdateTask/GithubActionUpdateTask"); - const { GithubBugcopTask } = await import("../../run/gh-bugcop/gh-bugcop"); - const { GithubBountyTask } = await import("./gh-bounty/gh-bounty"); - const { GithubDesignTask } = await import("./gh-design/gh-design"); - const { GithubContributorAnalyzeTask, GithubContributorAnalyzeTaskFilter } = - await import("./github-contributor-analyze/GithubContributorAnalyzeTask"); const Counts = { GithubActionUpdateTask: () => ( diff --git a/app/tasks/run-gh-tasks.ts b/app/tasks/run-gh-tasks.ts index dac80255..eeb91c8a 100644 --- a/app/tasks/run-gh-tasks.ts +++ b/app/tasks/run-gh-tasks.ts @@ -3,7 +3,7 @@ import { db } from "@/src/db"; import isCI from "is-ci"; // Import all the 5-minute tasks -import runGithubBugcopTask from "../../run/gh-bugcop/gh-bugcop"; +import runGithubBugcopTask from "../../bot/gh-bugcop/gh-bugcop"; import runGithubBountyTask from "./gh-bounty/gh-bounty"; import runGithubCoreTagNotificationTask from "./gh-core-tag-notification/index"; import { runGithubDesignTask } from "./gh-design/gh-design"; diff --git a/run/.dockerignore b/bot/.dockerignore similarity index 100% rename from run/.dockerignore rename to bot/.dockerignore diff --git a/bot/Dockerfile b/bot/Dockerfile index 81c69076..ed988fe3 100644 --- a/bot/Dockerfile +++ b/bot/Dockerfile @@ -1,20 +1,28 @@ -FROM snomiao/agent-yes +# Use official Bun image +FROM oven/bun:1.1.21-slim # Set working directory WORKDIR /app -# Copy package files and patches (build context is project root) -COPY package.json bun.lock* ./ -COPY patches ./patches +# Copy package files +COPY package.json bun.lock ./ # Install dependencies -RUN bun install --frozen-lockfile +RUN bun install --production --ignore-scripts -# Copy entire project -COPY . ./ +# Copy source code +COPY ../ . -# Set entrypoint to load nvm -ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] +# Expose port +EXPOSE 8080 -# Run the bot service -CMD ["bun", "bot/index.ts"] +# Set environment variables +ENV NODE_ENV=production +ENV PORT=8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 + +# Run the webhook service +CMD ["bun", "bot/index.tsx"] \ No newline at end of file diff --git a/bot/README.md b/bot/README.md index d3f99c52..89313a96 100644 --- a/bot/README.md +++ b/bot/README.md @@ -1,166 +1,285 @@ -# Bot Skills +# GitHub Webhook Service Deployment -This directory contains the skills/utilities used by the ComfyPR Bot to interact with Slack, Notion, and other services. +This directory contains the GitHub webhook service and deployment configuration for Google Cloud Run. -## Unified CLI +## Overview -A consolidated CLI is available at `bot/cli.ts` using yargs. It exposes GitHub PR-bot actions and Slack/Notion utilities via a single entry point. +The webhook service (`index.tsx`) monitors GitHub repositories for events like issues, pull requests, and comments. It can operate in two modes: -Run with Bun: +- **Webhook mode**: Real-time event handling via GitHub webhooks +- **Polling mode**: Fallback polling when webhooks can't be configured + +## Prerequisites + +1. **Google Cloud CLI**: Install and authenticate with `gcloud auth login` +2. **Docker**: Required for building container images +3. **Project Setup**: Enable billing on your Google Cloud project +4. **GitHub Token**: Personal Access Token with repo permissions +5. **Webhook Secret**: Secret for securing webhook payloads + +## Environment Variables + +The service requires these environment variables: + +### Required + +- `GITHUB_TOKEN`: GitHub Personal Access Token +- `GITHUB_WEBHOOK_SECRET`: Secret for webhook signature validation +- `PORT`: Server port (defaults to 8080) + +### Optional + +- `GITHUB_WEBHOOK_BASEURL`: Base URL for webhook endpoints +- `GITHUB_WEBHOOK_PORT`: Alternative port setting + +## Deployment + +### Quick Deploy ```bash -bun bot/cli.ts --help +# Set environment variables +export GOOGLE_CLOUD_PROJECT="your-project-id" +export GITHUB_TOKEN="ghp_your_token_here" +export GITHUB_WEBHOOK_SECRET="your_webhook_secret" + +# Deploy +cd run +./deploy.sh ``` -Common commands: +### Manual Deploy Steps -```bash -# Create a coding sub-agent and open a PR -bun bot/cli.ts github pr -r Comfy-Org/ComfyUI -b main -p "Fix auth bug" +1. **Enable APIs**: -# Alias for the above -bun bot/cli.ts pr -r Comfy-Org/desktop -p "Add spellcheck to editor" + ```bash + gcloud services enable cloudbuild.googleapis.com run.googleapis.com artifactregistry.googleapis.com secretmanager.googleapis.com + ``` -# Slack utilities -bun bot/cli.ts slack update -c C123 -t 1234567890.123456 -m "Working on it" -bun bot/cli.ts slack read-thread -c C123 -t 1234567890.123456 -l 50 +2. **Create Artifact Registry**: -# Notion search -bun bot/cli.ts notion search -q "ComfyUI setup" -l 5 -``` + ```bash + gcloud artifacts repositories create github-webhook-service \ + --repository-format=docker \ + --location=us-central1 + ``` + +3. **Create Secrets**: + + ```bash + echo "$GITHUB_TOKEN" | gcloud secrets create github-token --data-file=- + echo "$GITHUB_WEBHOOK_SECRET" | gcloud secrets create github-webhook-secret --data-file=- + ``` + +4. **Build and Deploy**: + ```bash + gcloud builds submit . --config=cloudbuild.yaml + ``` -Environment requirements: +## Files -- GitHub PR agent: token/config as required by existing `bot/github` tools -- Slack: `SLACK_BOT_TOKEN`, `SLACK_SOCKET_TOKEN` (for socket mode) -- Notion: `NOTION_TOKEN` +- `index.tsx` - Main webhook service application +- `Dockerfile` - Container build configuration +- `cloudbuild.yaml` - Cloud Build deployment configuration +- `deploy.sh` - Automated deployment script +- `README.md` - This documentation -## Slack Skills +## Service Endpoints -### msg-update.ts +- `/` - Root endpoint with basic info +- `/api/github/webhook` - GitHub webhook endpoint +- `/health` - Health check endpoint -Update an existing Slack message. +## GitHub Webhook Configuration -**Usage:** +After deployment, configure your GitHub repositories: + +1. Go to Repository Settings → Webhooks +2. Add webhook with URL: `https://your-service-url/api/github/webhook` +3. Set content type to `application/json` +4. Enter your webhook secret +5. Select events: Issues, Pull requests, Issue comments, Pull request reviews, Labels + +## Local Development ```bash -bun bot/slack/msg-update.ts --channel --ts --text "" +# Install dependencies +bun install + +# Set environment variables +export GITHUB_TOKEN="your_token" +export GITHUB_WEBHOOK_SECRET="your_secret" +export PORT=3000 + +# Run locally +bun run index.tsx ``` -**Example:** +## Getting Your Webhook Secret + +### Option 1: Generate a Secure Random Secret (Recommended) ```bash -bun bot/slack/msg-update.ts --channel C123ABC --ts 1234567890.123456 --text "Updated message content" +# Generate a 32-byte random hex string +node -e "console.log('GITHUB_WEBHOOK_SECRET='+require('crypto').randomBytes(32).toString('hex'))" >> .env.local + +# Or use openssl +openssl rand -hex 32 + +# Or use Bun +bun -e "console.log(crypto.randomBytes(32).toString('hex'))" ``` -**Environment Variables:** +Example output: `a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456` -- `SLACK_BOT_TOKEN`: Your Slack bot token +### Option 2: Use a Password Generator -### msg-read-thread.ts +Visit [1Password Generator](https://1password.com/password-generator/) or similar: -Read all messages from a Slack thread. +- Length: 64 characters +- Include: Letters, Numbers, Symbols +- Copy the generated password -**Usage:** +### Option 3: Custom Secret (Less Secure) ```bash -bun bot/slack/msg-read-thread.ts --channel --ts [--limit ] +export GITHUB_WEBHOOK_SECRET="my-super-secret-webhook-password-2024" ``` -**Example:** +âš ī¸ **Security Note**: Use a long, random, unique secret that you don't use anywhere else. + +## Environment Variables + +| Variable | Required | Description | Example | +| ----------------------- | ------------ | ------------------------------- | ------------------------ | +| `USE_WEBHOOKS` | No | Enable webhook mode | `true` | +| `GITHUB_WEBHOOK_SECRET` | Webhook mode | Secret for webhook verification | `abc123...` | +| `WEBHOOK_BASE_URL` | Webhook mode | Your public URL | `https://myapp.ngrok.io` | +| `GH_TOKEN` | Yes | GitHub token with repo access | `ghp_xxx...` | +| `PORT` | No | Server port | `3000` | + +## GitHub Token Setup + +1. Go to [GitHub Personal Access Tokens](https://github.com/settings/tokens) +2. Click "Generate new token" → "Generate new token (classic)" +3. Select scopes: + - ✅ `repo` (Full control of private repositories) + - ✅ `admin:repo_hook` (Read and write repository hooks) +4. Copy the token and set it as `GH_TOKEN` ```bash -bun bot/slack/msg-read-thread.ts --channel C123ABC --ts 1234567890.123456 --limit 50 +export GH_TOKEN=ghp_your_github_token_here ``` -**Environment Variables:** +## Making Your Server Public (For Webhooks) -- `SLACK_BOT_TOKEN`: Your Slack bot token +### Development - Using ngrok -### parseSlackMessageToMarkdown.ts +1. Install ngrok: https://ngrok.com/download +2. Start your server: `bun bot/gh-service.tsx` +3. In another terminal: `ngrok http 3000` +4. Copy the HTTPS URL: `https://abc123.ngrok.io` +5. Set environment variable: `export WEBHOOK_BASE_URL=https://abc123.ngrok.io` -Utility function to convert Slack message formatting to Markdown. +### Development - Using Cloudflare Tunnel -**Features:** +```bash +# Install cloudflared +npm install -g @cloudflare/next-on-pages -- User mentions: `<@U123>` → `@U123` -- Channel mentions: `<#C456|general>` → `#general` -- Links: `` → `[text](https://example.com)` -- Bold: `*text*` → `**text**` -- Italic: `_text_` → `*text*` -- Preserves code blocks and inline code +# Start tunnel +cloudflared tunnel --url http://localhost:3000 -**Usage:** +# Copy the HTTPS URL and set WEBHOOK_BASE_URL +``` -```typescript -import { parseSlackMessageToMarkdown } from "./bot/slack/parseSlackMessageToMarkdown"; +### Production Deployment -const markdown = await parseSlackMessageToMarkdown("Hello <@U123> with *bold* text"); -``` +Deploy to any cloud provider: -### slackTsToISO.ts +- **Vercel**: `vercel --prod` +- **Railway**: `railway deploy` +- **Heroku**: `git push heroku main` +- **DigitalOcean**: App Platform +- **AWS**: Lambda/EC2 +- **Google Cloud**: Cloud Run -Convert Slack timestamp to ISO 8601 format. +## Complete Setup Example -**Usage:** +```bash +# 1. Generate webhook secret +export GITHUB_WEBHOOK_SECRET=$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))") + +# 2. Set your GitHub token +export GH_TOKEN=ghp_your_github_personal_access_token + +# 3. Make server public (development) +ngrok http 3000 & # Run in background +export WEBHOOK_BASE_URL=https://your-ngrok-url.ngrok.io -```typescript -import { slackTsToISO } from "./bot/slack/slackTsToISO"; +# 4. Enable webhook mode +export USE_WEBHOOKS=true -const iso = slackTsToISO("1703347200.123456"); -// Returns: "2023-12-23T16:00:00.123Z" +# 5. Start the monitor +bun bot/gh-service.tsx ``` -## Notion Skills +## Event Types Monitored -### notion/search.ts +- 🆕 **New Issues** - When someone opens an issue +- 🔄 **New Pull Requests** - When someone creates a PR +- đŸ’Ŧ **Comments** - Issue comments, PR comments, review comments +- đŸˇī¸ **Labels** - When labels are added or removed +- ✅ **Status Changes** - Issue/PR closed, reopened, merged -Search Notion pages in the Comfy-Org workspace. +## Webhook vs Polling Comparison -**Usage:** +| Feature | Polling (30s) | Webhooks | +| ---------------------- | ----------------- | -------------------- | +| **Latency** | ~30 seconds | ~1 second | +| **API Rate Limit** | High usage | Minimal usage | +| **Setup Complexity** | Simple | Moderate | +| **Reliability** | Always works | Network dependent | +| **Resource Usage** | Higher CPU/Memory | Lower | +| **GitHub Permissions** | Read-only | Admin/Write required | -```bash -bun bot/notion/search.ts --query "" [--limit ] -``` +## Troubleshooting -**Example:** +### "Error creating webhook: 403 Forbidden" -```bash -bun bot/notion/search.ts --query "ComfyUI setup" --limit 5 -``` +- Your GitHub token needs `admin:repo_hook` permissions +- You need admin or write access to the repositories -**Environment Variables:** +### "Webhook endpoint not reachable" -- `NOTION_TOKEN`: Your Notion integration token +- Ensure your `WEBHOOK_BASE_URL` is publicly accessible +- Test: `curl -X POST https://your-url.com/webhook` -**Output:** -Returns a JSON array of matching pages with title, URL, and timestamps. +### "Webhook signature verification failed" -## Testing +- Check that `GITHUB_WEBHOOK_SECRET` matches what you configured +- Ensure the secret is the same when creating webhooks -Test individual utilities: +### No webhook events received -```bash -# Test Slack timestamp conversion -bun bot/slack/slackTsToISO.ts +- Check GitHub webhook delivery logs in repo settings → Webhooks +- Verify the webhook URL is correct: `https://your-domain.com/webhook` +- Ensure your server is running and accessible -# Test Slack to Markdown parsing -bun bot/slack/parseSlackMessageToMarkdown.ts -``` +## Security Best Practices -## Development +1. **Use strong webhook secrets** - Generate random 64+ character strings +2. **Use HTTPS** - Never use HTTP for webhook endpoints in production +3. **Verify signatures** - The system automatically verifies webhook authenticity +4. **Rotate secrets periodically** - Update webhook secrets every 90 days +5. **Limit token permissions** - Only grant necessary GitHub token scopes -All scripts follow the standard development pattern outlined in CLAUDE.md: +## Monitored Repositories -1. TypeScript with full type safety -2. Executable with `bun ` when `import.meta.main` is true -3. Exportable functions for use as libraries -4. Command-line argument parsing with `yargs` in `bot/cli.ts` and `parseArgs` in leaf tools -5. Proper error handling and validation -6. Cached API clients from `@/lib` +The system monitors these repositories by default: -## Notes +- `Comfy-Org/Comfy-PR` +- `comfyanonymous/ComfyUI` +- `Comfy-Org/ComfyUI_frontend` +- `Comfy-Org/desktop` -- All Slack and Notion API calls are automatically cached using the cached clients from `@/lib` -- Cache is stored in `node_modules/.cache/` directory -- Scripts can be used both as standalone CLI tools and as importable modules +To modify the list, edit the `REPOLIST` array in `gh-service.tsx`. diff --git a/run/cloudbuild.yaml b/bot/cloudbuild.yaml similarity index 98% rename from run/cloudbuild.yaml rename to bot/cloudbuild.yaml index 68640ea2..a2907de3 100644 --- a/run/cloudbuild.yaml +++ b/bot/cloudbuild.yaml @@ -6,7 +6,7 @@ steps: - "-t" - "${_LOCATION}-docker.pkg.dev/${PROJECT_ID}/${_REPOSITORY}/github-webhook-service:latest" - "-f" - - "run/Dockerfile" + - "bot/Dockerfile" - "." id: "build-image" diff --git a/run/deploy.sh b/bot/deploy.sh similarity index 99% rename from run/deploy.sh rename to bot/deploy.sh index 27738502..b7e0098f 100755 --- a/run/deploy.sh +++ b/bot/deploy.sh @@ -111,7 +111,7 @@ deploy_service() { WEBHOOK_BASE_URL="https://${SERVICE_NAME}-REPLACE_WITH_HASH-${LOCATION:0:2}.a.run.app" gcloud builds submit . \ - --config=run/cloudbuild.yaml \ + --config=bot/cloudbuild.yaml \ --project="$PROJECT_ID" \ --substitutions="_LOCATION=$LOCATION,_REPOSITORY=$REPOSITORY,_SERVICE_NAME=$SERVICE_NAME,_WEBHOOK_BASE_URL=$WEBHOOK_BASE_URL" diff --git a/run/easylabel.tsx b/bot/easylabel.tsx similarity index 100% rename from run/easylabel.tsx rename to bot/easylabel.tsx diff --git a/run/gh-bugcop/GithubBugcopTaskStatus.tsx b/bot/gh-bugcop/GithubBugcopTaskStatus.tsx similarity index 100% rename from run/gh-bugcop/GithubBugcopTaskStatus.tsx rename to bot/gh-bugcop/GithubBugcopTaskStatus.tsx diff --git a/run/gh-bugcop/gh-bugcop.tsx b/bot/gh-bugcop/gh-bugcop.tsx similarity index 100% rename from run/gh-bugcop/gh-bugcop.tsx rename to bot/gh-bugcop/gh-bugcop.tsx diff --git a/run/gh-bugcop/page.tsx b/bot/gh-bugcop/page.tsx similarity index 100% rename from run/gh-bugcop/page.tsx rename to bot/gh-bugcop/page.tsx diff --git a/run/ghUrl.tsx b/bot/ghUrl.tsx similarity index 100% rename from run/ghUrl.tsx rename to bot/ghUrl.tsx diff --git a/bot/github-webhook-event-type/index.ts b/bot/github-webhook-event-type/index.ts new file mode 100644 index 00000000..629a7caa --- /dev/null +++ b/bot/github-webhook-event-type/index.ts @@ -0,0 +1,159 @@ +import type { components as GithubApiComponents } from "@octokit/openapi-types"; +type S = GithubApiComponents["schemas"]; +export type WEBHOOK_EVENTS = { + branch_protection_configuration: S[`webhook-branch-protection-configuration${string}` & keyof S]; + branch_protection_rule: S[`webhook-branch-protection-rule${string}` & keyof S]; + check_run: S[`webhook-check-run${string}` & keyof S]; + check_suite: S[`webhook-check-suite${string}` & keyof S]; + code_scanning_alert: S[`webhook-code-scanning-alert${string}` & keyof S]; + commit_comment: S[`webhook-commit-comment${string}` & keyof S]; + create: S[`webhook-create${string}` & keyof S]; + custom_property: S[`webhook-custom-property${string}` & keyof S]; + custom_property_values: S[`webhook-custom-property-values${string}` & keyof S]; + delete: S[`webhook-delete${string}` & keyof S]; + dependabot_alert: S[`webhook-dependabot-alert${string}` & keyof S]; + deploy_key: S[`webhook-deploy-key${string}` & keyof S]; + deployment: S[`webhook-deployment${string}` & keyof S]; + deployment_protection_rule: S[`webhook-deployment-protection-rule${string}` & keyof S]; + deployment_review: S[`webhook-deployment-review${string}` & keyof S]; + deployment_status: S[`webhook-deployment-status${string}` & keyof S]; + discussion: S[`webhook-discussion${string}` & keyof S]; + discussion_comment: S[`webhook-discussion-comment${string}` & keyof S]; + fork: S[`webhook-fork${string}` & keyof S]; + github_app_authorization: S[`webhook-github-app-authorization${string}` & keyof S]; + gollum: S[`webhook-gollum${string}` & keyof S]; + installation: S[`webhook-installation${string}` & keyof S]; + installation_repositories: S[`webhook-installation-repositories${string}` & keyof S]; + installation_target: S[`webhook-installation-target${string}` & keyof S]; + issue_comment: S[`webhook-issue-comment${string}` & keyof S]; + issue_dependencies: S[`webhook-issue-dependencies${string}` & keyof S]; + issues: S[`webhook-issues${string}` & keyof S]; + label: S[`webhook-label${string}` & keyof S]; + marketplace_purchase: S[`webhook-marketplace-purchase${string}` & keyof S]; + member: S[`webhook-member${string}` & keyof S]; + membership: S[`webhook-membership${string}` & keyof S]; + merge_group: S[`webhook-merge-group${string}` & keyof S]; + meta: S[`webhook-meta${string}` & keyof S]; + milestone: S[`webhook-milestone${string}` & keyof S]; + org_block: S[`webhook-org-block${string}` & keyof S]; + organization: S[`webhook-organization${string}` & keyof S]; + package: S[`webhook-package${string}` & keyof S]; + page_build: S[`webhook-page-build${string}` & keyof S]; + personal_access_token_request: S[`webhook-personal-access-token-request${string}` & keyof S]; + ping: S[`webhook-ping${string}` & keyof S]; + project: S[`webhook-project${string}` & keyof S]; + project_card: S[`webhook-project-card${string}` & keyof S]; + project_column: S[`webhook-project-column${string}` & keyof S]; + projects_v2: S[`webhook-projects-v2${string}` & keyof S]; + projects_v2_item: S[`webhook-projects-v2-item${string}` & keyof S]; + projects_v2_status_update: S[`webhook-projects-v2-status-update${string}` & keyof S]; + public: S[`webhook-public${string}` & keyof S]; + pull_request: S[`webhook-pull-request${string}` & keyof S]; + pull_request_review: S[`webhook-pull-request-review${string}` & keyof S]; + pull_request_review_comment: S[`webhook-pull-request-review-comment${string}` & keyof S]; + pull_request_review_thread: S[`webhook-pull-request-review-thread${string}` & keyof S]; + push: S[`webhook-push${string}` & keyof S]; + registry_package: S[`webhook-registry-package${string}` & keyof S]; + release: S[`webhook-release${string}` & keyof S]; + repository: S[`webhook-repository${string}` & keyof S]; + repository_advisory: S[`webhook-repository-advisory${string}` & keyof S]; + repository_dispatch: S[`webhook-repository-dispatch${string}` & keyof S]; + repository_import: S[`webhook-repository-import${string}` & keyof S]; + repository_ruleset: S[`webhook-repository-ruleset${string}` & keyof S]; + repository_vulnerability_alert: S[`webhook-repository-vulnerability-alert${string}` & keyof S]; + secret_scanning_alert: S[`webhook-secret-scanning-alert${string}` & keyof S]; + secret_scanning_alert_location: S[`webhook-secret-scanning-alert-location${string}` & keyof S]; + secret_scanning_scan: S[`webhook-secret-scanning-scan${string}` & keyof S]; + security_advisory: S[`webhook-security-advisory${string}` & keyof S]; + security_and_analysis: S[`webhook-security-and-analysis${string}` & keyof S]; + sponsorship: S[`webhook-sponsorship${string}` & keyof S]; + star: S[`webhook-star${string}` & keyof S]; + status: S[`webhook-status${string}` & keyof S]; + sub_issues: S[`webhook-sub-issues${string}` & keyof S]; + team: S[`webhook-team${string}` & keyof S]; + team_add: S[`webhook-team-add${string}` & keyof S]; + watch: S[`webhook-watch${string}` & keyof S]; + workflow_dispatch: S[`webhook-workflow-dispatch${string}` & keyof S]; + workflow_job: S[`webhook-workflow-job${string}` & keyof S]; + workflow_run: S[`webhook-workflow-run${string}` & keyof S]; +}; +// export type WEBHOOK_EVENT = { +// type: K; +// payload: WEBHOOK_EVENTS[K]; +// }; +export type WEBHOOK_EVENT = + | { type: "branch_protection_configuration"; payload: WEBHOOK_EVENTS["branch_protection_configuration"] } + | { type: "branch_protection_rule"; payload: WEBHOOK_EVENTS["branch_protection_rule"] } + | { type: "check_run"; payload: WEBHOOK_EVENTS["check_run"] } + | { type: "check_suite"; payload: WEBHOOK_EVENTS["check_suite"] } + | { type: "code_scanning_alert"; payload: WEBHOOK_EVENTS["code_scanning_alert"] } + | { type: "commit_comment"; payload: WEBHOOK_EVENTS["commit_comment"] } + | { type: "create"; payload: WEBHOOK_EVENTS["create"] } + | { type: "custom_property"; payload: WEBHOOK_EVENTS["custom_property"] } + | { type: "custom_property_values"; payload: WEBHOOK_EVENTS["custom_property_values"] } + | { type: "delete"; payload: WEBHOOK_EVENTS["delete"] } + | { type: "dependabot_alert"; payload: WEBHOOK_EVENTS["dependabot_alert"] } + | { type: "deploy_key"; payload: WEBHOOK_EVENTS["deploy_key"] } + | { type: "deployment"; payload: WEBHOOK_EVENTS["deployment"] } + | { type: "deployment_protection_rule"; payload: WEBHOOK_EVENTS["deployment_protection_rule"] } + | { type: "deployment_review"; payload: WEBHOOK_EVENTS["deployment_review"] } + | { type: "deployment_status"; payload: WEBHOOK_EVENTS["deployment_status"] } + | { type: "discussion"; payload: WEBHOOK_EVENTS["discussion"] } + | { type: "discussion_comment"; payload: WEBHOOK_EVENTS["discussion_comment"] } + | { type: "fork"; payload: WEBHOOK_EVENTS["fork"] } + | { type: "github_app_authorization"; payload: WEBHOOK_EVENTS["github_app_authorization"] } + | { type: "gollum"; payload: WEBHOOK_EVENTS["gollum"] } + | { type: "installation"; payload: WEBHOOK_EVENTS["installation"] } + | { type: "installation_repositories"; payload: WEBHOOK_EVENTS["installation_repositories"] } + | { type: "installation_target"; payload: WEBHOOK_EVENTS["installation_target"] } + | { type: "issue_comment"; payload: WEBHOOK_EVENTS["issue_comment"] } + | { type: "issue_dependencies"; payload: WEBHOOK_EVENTS["issue_dependencies"] } + | { type: "issues"; payload: WEBHOOK_EVENTS["issues"] } + | { type: "label"; payload: WEBHOOK_EVENTS["label"] } + | { type: "marketplace_purchase"; payload: WEBHOOK_EVENTS["marketplace_purchase"] } + | { type: "member"; payload: WEBHOOK_EVENTS["member"] } + | { type: "membership"; payload: WEBHOOK_EVENTS["membership"] } + | { type: "merge_group"; payload: WEBHOOK_EVENTS["merge_group"] } + | { type: "meta"; payload: WEBHOOK_EVENTS["meta"] } + | { type: "milestone"; payload: WEBHOOK_EVENTS["milestone"] } + | { type: "org_block"; payload: WEBHOOK_EVENTS["org_block"] } + | { type: "organization"; payload: WEBHOOK_EVENTS["organization"] } + | { type: "package"; payload: WEBHOOK_EVENTS["package"] } + | { type: "page_build"; payload: WEBHOOK_EVENTS["page_build"] } + | { type: "personal_access_token_request"; payload: WEBHOOK_EVENTS["personal_access_token_request"] } + | { type: "ping"; payload: WEBHOOK_EVENTS["ping"] } + | { type: "project"; payload: WEBHOOK_EVENTS["project"] } + | { type: "project_card"; payload: WEBHOOK_EVENTS["project_card"] } + | { type: "project_column"; payload: WEBHOOK_EVENTS["project_column"] } + | { type: "projects_v2"; payload: WEBHOOK_EVENTS["projects_v2"] } + | { type: "projects_v2_item"; payload: WEBHOOK_EVENTS["projects_v2_item"] } + | { type: "projects_v2_status_update"; payload: WEBHOOK_EVENTS["projects_v2_status_update"] } + | { type: "public"; payload: WEBHOOK_EVENTS["public"] } + | { type: "pull_request"; payload: WEBHOOK_EVENTS["pull_request"] } + | { type: "pull_request_review"; payload: WEBHOOK_EVENTS["pull_request_review"] } + | { type: "pull_request_review_comment"; payload: WEBHOOK_EVENTS["pull_request_review_comment"] } + | { type: "pull_request_review_thread"; payload: WEBHOOK_EVENTS["pull_request_review_thread"] } + | { type: "push"; payload: WEBHOOK_EVENTS["push"] } + | { type: "registry_package"; payload: WEBHOOK_EVENTS["registry_package"] } + | { type: "release"; payload: WEBHOOK_EVENTS["release"] } + | { type: "repository"; payload: WEBHOOK_EVENTS["repository"] } + | { type: "repository_advisory"; payload: WEBHOOK_EVENTS["repository_advisory"] } + | { type: "repository_dispatch"; payload: WEBHOOK_EVENTS["repository_dispatch"] } + | { type: "repository_import"; payload: WEBHOOK_EVENTS["repository_import"] } + | { type: "repository_ruleset"; payload: WEBHOOK_EVENTS["repository_ruleset"] } + | { type: "repository_vulnerability_alert"; payload: WEBHOOK_EVENTS["repository_vulnerability_alert"] } + | { type: "secret_scanning_alert"; payload: WEBHOOK_EVENTS["secret_scanning_alert"] } + | { type: "secret_scanning_alert_location"; payload: WEBHOOK_EVENTS["secret_scanning_alert_location"] } + | { type: "secret_scanning_scan"; payload: WEBHOOK_EVENTS["secret_scanning_scan"] } + | { type: "security_advisory"; payload: WEBHOOK_EVENTS["security_advisory"] } + | { type: "security_and_analysis"; payload: WEBHOOK_EVENTS["security_and_analysis"] } + | { type: "sponsorship"; payload: WEBHOOK_EVENTS["sponsorship"] } + | { type: "star"; payload: WEBHOOK_EVENTS["star"] } + | { type: "status"; payload: WEBHOOK_EVENTS["status"] } + | { type: "sub_issues"; payload: WEBHOOK_EVENTS["sub_issues"] } + | { type: "team"; payload: WEBHOOK_EVENTS["team"] } + | { type: "team_add"; payload: WEBHOOK_EVENTS["team_add"] } + | { type: "watch"; payload: WEBHOOK_EVENTS["watch"] } + | { type: "workflow_dispatch"; payload: WEBHOOK_EVENTS["workflow_dispatch"] } + | { type: "workflow_job"; payload: WEBHOOK_EVENTS["workflow_job"] } + | { type: "workflow_run"; payload: WEBHOOK_EVENTS["workflow_run"] }; diff --git a/bot/index.ts b/bot/index.ts index abcb1bfb..e69de29b 100644 --- a/bot/index.ts +++ b/bot/index.ts @@ -1,14 +0,0 @@ -#!/usr/bin/env bun -/** - * ComfyPR Bot - * - * Slack Bot - * @author snomiao - */ - -// supports only slack now -if (import.meta.main) { - console.log("Starting ComfyPR Slack Bot..."); - const client = await (await import("./slack-bot.ts")).startSlackBot(); - console.log("ComfyPR Slack Bot Done."); -} diff --git a/run/onIssueComment.tsx b/bot/onIssueComment.tsx similarity index 100% rename from run/onIssueComment.tsx rename to bot/onIssueComment.tsx diff --git a/run/onTextUpdateEvent.tsx b/bot/onTextUpdateEvent.tsx similarity index 100% rename from run/onTextUpdateEvent.tsx rename to bot/onTextUpdateEvent.tsx diff --git a/run/index.ts b/bot/v1.ts similarity index 72% rename from run/index.ts rename to bot/v1.ts index e2177676..bb0600ad 100644 --- a/run/index.ts +++ b/bot/v1.ts @@ -1,17 +1,15 @@ -#!/usr/bin/env bun import KeyvSqlite from "@keyv/sqlite"; -import type { WebhookEventMap } from "@octokit/webhooks-types"; import DIE from "@snomiao/die"; -import * as crypto from "crypto"; +import crypto from "crypto"; import Keyv from "keyv"; import sflow, { pageFlow } from "sflow"; import { match, P } from "ts-pattern"; -import type { UnionToIntersection } from "type-fest"; -import { gh, type GH } from "@/lib/github"; -import { ghc } from "@/lib/github/githubCached"; -import { parseGithubRepoUrl } from "@/src/parseOwnerRepo"; -import { processIssueCommentForLableops } from "./easylabel"; - +import { type UnionToIntersection } from "type-fest"; +import { gh, type GH } from "../src/gh/index.js"; +import { ghc } from "../src/ghc.js"; +import { parseGithubRepoUrl } from "../src/parseOwnerRepo.js"; +import { processIssueCommentForLableops } from "./easylabel.jsx"; +import type { WEBHOOK_EVENT } from "./github-webhook-event-type/index.js"; export const REPOLIST = [ "https://github.com/Comfy-Org/Comfy-PR", "https://github.com/comfyanonymous/ComfyUI", @@ -33,8 +31,7 @@ type WebhookIssue = GH[`webhook-issues-${string}` & keyof GH]; type WebhookIssueComment = GH[`webhook-issue-comment-${string}` & keyof GH]; type WebhookPullRequest = GH[`webhook-pull-request-${string}` & keyof GH]; type WebhookPullRequestReview = GH[`webhook-pull-request-review${string}` & keyof GH]; -type WebhookPullRequestReviewComment = GH[`webhook-pull-request-review-comment-${string}` & - keyof GH]; +type WebhookPullRequestReviewComment = GH[`webhook-pull-request-review-comment-${string}` & keyof GH]; type HaveBody = T extends { issue: { body: string } } ? T : never; type test = HaveBody; type WebhookAll = GH[`webhook-${string}-${string}` & keyof GH]; @@ -49,47 +46,14 @@ type Webhook = class RepoEventMonitor { private monitorState = new Map(); private stateCache: Keyv; - private commentCache: Keyv>; // Map of comment ID to updated_at timestamp private pollingRepos = new Set(); private pollInterval = 30000; // 30 seconds - private commentPollInterval = 5000; // 5 seconds for comment polling private webhookSetupComplete = false; - // Placeholder for unknown previous content in edited comments - private static readonly UNKNOWN_PREVIOUS_CONTENT = "[UNKNOWN_PREVIOUS_CONTENT]"; - - /** - * Creates a properly typed mock webhook event for issue comments - */ - private createMockIssueCommentEvent( - action: "created" | "edited", - owner: string, - repo: string, - issue: GH["issue"], - comment: GH["issue-comment"], - changes?: { body: { from: string } }, - ): WebhookEventMap { - return { - issue_comment: { - action, - issue: issue as WebhookEventMap["issue_comment"]["issue"], - comment: comment as WebhookEventMap["issue_comment"]["comment"], - repository: { - owner: { login: owner }, - name: repo, - full_name: `${owner}/${repo}`, - } as WebhookEventMap["issue_comment"]["repository"], - sender: comment.user! as WebhookEventMap["issue_comment"]["sender"], - ...(changes && { changes }), - }, - } as WebhookEventMap; - } - constructor() { // Initialize SQLite cache const sqlite = new KeyvSqlite("gh-service/state.sqlite"); this.stateCache = new Keyv({ store: sqlite }); - this.commentCache = new Keyv({ store: new KeyvSqlite("gh-service/comment-cache.sqlite") }); // Initialize state for each repo for (const repoUrl of REPOLIST) { @@ -152,24 +116,35 @@ class RepoEventMonitor { const event = req.headers.get("x-github-event") || ""; const body = await req.text(); - if (!this.verifyWebhookSignature(body, signature)) - return new Response("Unauthorized", { status: 401 }); + if (!this.verifyWebhookSignature(body, signature)) return new Response("Unauthorized", { status: 401 }); const payload = JSON.parse(body); - this.handleWebhookEvent({ [event]: payload } as WebhookEventMap); + this.handleWebhookEvent({ type: event, payload } as WEBHOOK_EVENT); return new Response("OK"); } - private async handleWebhookEvent(eventMap: WebhookEventMap) { + private async handleWebhookEvent(event: WEBHOOK_EVENT) { const timestamp = this.formatTimestamp(); - match(eventMap) - .with({ issue_comment: P.select() }, async ({ issue, comment }) => - processIssueCommentForLableops({ - issue: issue as GH["issue"], - comment: comment as GH["issue-comment"], - }), + // const repo = event.payload.repository; + // const repoName = repo ? `${repo.owner.login}/${repo.name}` : "unknown"; + + match(event) + // .with({ type: "issues" }, async ({ payload: { issue } }) => + // processIssueCommentForLableops({ issue: issue as GH["issue"], comment: comment as GH["issue-comment"] }), + // ) + .with({ type: "issue_comment" }, async ({ payload: { issue, comment } }) => + processIssueCommentForLableops({ issue: issue as GH["issue"], comment: comment as GH["issue-comment"] }), ) .otherwise(() => null); + // match core-important in +Core-Important + match(event) + .with({ payload: { issue: { html_url: P.string }, comment: { body: P.string } } }, async ({ type, payload }) => { + const { issue, comment, action } = payload; + const fullEvent = `${type}:${action}` as const; + console.log(type, comment.body); + return { issueUrl: issue.html_url, body: comment.body }; + }) + .otherwise(() => null); // match(event) // .with({ type: "pull_request" }, ({ type, payload }) => payload.comment.body) @@ -283,9 +258,7 @@ class RepoEventMonitor { existingHook = hooks.find((hook) => hook.config?.url === WEBHOOK_URL); if (existingHook) { - console.log( - `[${this.formatTimestamp()}] ✅ Webhook already exists for ${owner}/${repo}`, - ); + console.log(`[${this.formatTimestamp()}] ✅ Webhook already exists for ${owner}/${repo}`); continue; } } catch (listError: any) { @@ -300,7 +273,7 @@ class RepoEventMonitor { } // Create webhook - const webhookConfig = { + await gh.repos.createWebhook({ owner, repo, config: { @@ -316,9 +289,7 @@ class RepoEventMonitor { "pull_request_review_comment", "label", ], - }; - console.log("Creating webhook with config:", webhookConfig); - await gh.repos.createWebhook(webhookConfig); + }); console.log(`[${this.formatTimestamp()}] ✅ Webhook created for ${owner}/${repo}`); } catch (error: any) { @@ -328,10 +299,7 @@ class RepoEventMonitor { ); this.pollingRepos.add(repoUrl); } else { - console.error( - `[${this.formatTimestamp()}] ❌ Error creating webhook for ${repoUrl}:`, - error.message, - ); + console.error(`[${this.formatTimestamp()}] ❌ Error creating webhook for ${repoUrl}:`, error.message); } } } @@ -348,21 +316,12 @@ class RepoEventMonitor { console.log(`[${this.formatTimestamp()}] Monitoring repos: ${REPOLIST.join(", ")}`); - // Start comment polling for all repos (5 second interval) - console.log( - `[${this.formatTimestamp()}] Starting comment polling (5s interval) for recent comments...`, - ); - setInterval(() => { - this.pollRecentComments(); - }, this.commentPollInterval); - - // Initial comment check - await this.pollRecentComments(); - if (WEBHOOK_URL) { console.log(`[${this.formatTimestamp()}] Using webhooks for real-time notifications`); await this.setupWebhooks(); + // TODO: polling way + // // Start polling for repos that couldn't set up webhooks if (this.pollingRepos.size > 0) { console.log( @@ -387,125 +346,10 @@ class RepoEventMonitor { } } - private async pollRecentComments() { - // Check for comments in the last 5 minutes - const since = new Date(Date.now() - 5 * 60 * 1000).toISOString(); - - for (const repoUrl of REPOLIST) { - // Listing issue comments for recent 5min - console.log(`[${this.formatTimestamp()}] Checking recent comments for ${repoUrl}`); - try { - const { owner, repo } = this.parseRepoUrl(repoUrl); - const cacheKey = `${owner}/${repo}`; - - // Get cached comment timestamps - const cachedComments = (await this.commentCache.get(cacheKey)) || new Map(); - - // List recent comments for the repository - const { data: comments } = await gh.issues.listCommentsForRepo({ - owner, - repo, - since, - sort: "updated", - direction: "desc", - per_page: 100, - }); - - const newCachedComments = new Map(); - - for (const comment of comments) { - newCachedComments.set(comment.id, comment.updated_at); - - const previousUpdatedAt = cachedComments.get(comment.id); - - if (!previousUpdatedAt) { - // New comment - mock issue_comment.created event - console.log( - `[${this.formatTimestamp()}] đŸ’Ŧ NEW COMMENT DETECTED: ${owner}/${repo} #${comment.issue_url?.split("/").pop()} - Comment ID: ${comment.id}`, - ); - - // Fetch the issue data for the mock event - const issueNumber = parseInt(comment.issue_url?.split("/").pop() || "0"); - if (issueNumber) { - try { - const { data: issue } = await gh.issues.get({ - owner, - repo, - issue_number: issueNumber, - }); - - // Create and handle the mock webhook event - const mockEvent = this.createMockIssueCommentEvent( - "created", - owner, - repo, - issue, - comment, - ); - console.log("mocked-webhook-event", mockEvent); - await this.handleWebhookEvent(mockEvent); - } catch (error) { - console.error( - `[${this.formatTimestamp()}] Error fetching issue for comment:`, - error, - ); - } - } - } else if (previousUpdatedAt !== comment.updated_at) { - // Updated comment - mock issue_comment.edited event - console.log( - `[${this.formatTimestamp()}] âœī¸ COMMENT UPDATED: ${owner}/${repo} #${comment.issue_url?.split("/").pop()} - Comment ID: ${comment.id}`, - ); - - // Fetch the issue data for the mock event - const issueNumber = parseInt(comment.issue_url?.split("/").pop() || "0"); - - if (issueNumber) { - try { - const { data: issue } = await gh.issues.get({ - owner, - repo, - issue_number: issueNumber, - }); - // Create and handle the mock webhook event - const mockEvent = this.createMockIssueCommentEvent( - "edited", - owner, - repo, - issue, - comment, - { - body: { from: RepoEventMonitor.UNKNOWN_PREVIOUS_CONTENT }, - }, - ); - console.debug(mockEvent); - await this.handleWebhookEvent(mockEvent); - } catch (error) { - console.error( - `[${this.formatTimestamp()}] Error fetching issue for comment:`, - error, - ); - } - } - } - } - - // Update cache with new comment timestamps - await this.commentCache.set(cacheKey, newCachedComments); - } catch (error) { - console.error(`[${this.formatTimestamp()}] Error polling comments for ${repoUrl}:`, error); - } - } - } - private async checkPollingRepos() { sflow(this.pollingRepos).map((html_url) => { pageFlow(1, async (page, per_page = 100) => { - const { data } = await ghc.issues.listForRepo({ - ...parseGithubRepoUrl(html_url), - page, - per_page, - }); + const { data } = await ghc.issues.listForRepo({ ...parseGithubRepoUrl(html_url), page, per_page }); return { data, next: data.length >= per_page ? page + 1 : null }; }).flat(); }); diff --git a/bun.lock b/bun.lock index b5e22bcb..6eff9b93 100644 --- a/bun.lock +++ b/bun.lock @@ -154,7 +154,7 @@ "husky": "^9.1.7", "lint-staged": "^16.1.2", "msw": "^2.11.6", - "next": "15.4.8", + "next": "15.4.10", "oxfmt": "^0.24.0", "oxlint": "^1.39.0", "postcss": "^8.4.40", @@ -506,7 +506,7 @@ "@next/bundle-analyzer": ["@next/bundle-analyzer@15.5.3", "", { "dependencies": { "webpack-bundle-analyzer": "4.10.1" } }, "sha512-l2NxnWHP2gWHbomAlz/wFnN2jNCx/dpr7P/XWeOLhULiyKkXSac8O8SjxRO/8FNhr2l4JNtWVKk82Uya4cZYTw=="], - "@next/env": ["@next/env@15.4.8", "", {}, "sha512-LydLa2MDI1NMrOFSkO54mTc8iIHSttj6R6dthITky9ylXV2gCGi0bHQjVCtLGRshdRPjyh2kXbxJukDtBWQZtQ=="], + "@next/env": ["@next/env@15.4.10", "", {}, "sha512-knhmoJ0Vv7VRf6pZEPSnciUG1S4bIhWx+qTYBW/AjxEtlzsiNORPk8sFDCEvqLfmKuey56UB9FL1UdHEV3uBrg=="], "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.4.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Pf6zXp7yyQEn7sqMxur6+kYcywx5up1J849psyET7/8pG2gQTVMjU3NzgIt8SeEP5to3If/SaWmaA6H6ysBr1A=="], @@ -2448,7 +2448,7 @@ "netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="], - "next": ["next@15.4.8", "", { "dependencies": { "@next/env": "15.4.8", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.4.8", "@next/swc-darwin-x64": "15.4.8", "@next/swc-linux-arm64-gnu": "15.4.8", "@next/swc-linux-arm64-musl": "15.4.8", "@next/swc-linux-x64-gnu": "15.4.8", "@next/swc-linux-x64-musl": "15.4.8", "@next/swc-win32-arm64-msvc": "15.4.8", "@next/swc-win32-x64-msvc": "15.4.8", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-jwOXTz/bo0Pvlf20FSb6VXVeWRssA2vbvq9SdrOPEg9x8E1B27C2rQtvriAn600o9hH61kjrVRexEffv3JybuA=="], + "next": ["next@15.4.10", "", { "dependencies": { "@next/env": "15.4.10", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.4.8", "@next/swc-darwin-x64": "15.4.8", "@next/swc-linux-arm64-gnu": "15.4.8", "@next/swc-linux-arm64-musl": "15.4.8", "@next/swc-linux-x64-gnu": "15.4.8", "@next/swc-linux-x64-musl": "15.4.8", "@next/swc-win32-arm64-msvc": "15.4.8", "@next/swc-win32-x64-msvc": "15.4.8", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-itVlc79QjpKMFMRhP+kbGKaSG/gZM6RCvwhEbwmCNF06CdDiNaoHcbeg0PqkEa2GOcn8KJ0nnc7+yL7EjoYLHQ=="], "next-auth": ["next-auth@5.0.0-beta.29", "", { "dependencies": { "@auth/core": "0.40.0" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0-0", "nodemailer": "^6.6.5", "react": "^18.2.0 || ^19.0.0-0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-Ukpnuk3NMc/LiOl32njZPySk7pABEzbjhMUFd5/n10I0ZNC7NCuVv8IY2JgbDek2t/PUOifQEoUiOOTLy4os5A=="], diff --git a/docker-compose.yml b/docker-compose.yml index e212c518..e8723288 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,6 +43,32 @@ services: # env_file: # - .env.local + gh-service: + image: oven/bun:latest + # command: bun --hot bot/index.tsx + # dev environment + # docker compose run gh-service + command: sh -c "bun bot/index.ts" + working_dir: /app + volumes: [./:/app] + ports: [14215:14215] + environment: + PORT: 14215 + env_file: + - .env.local + # comfy-pr: + # - .env + # restart: always + # image: snomiao/comfy-registry-pr + # build: . + # volumes: + # - $HOME/.ssh:/root/.ssh:ro + # - venv:/app/.venv + # - ./prs:/app/prs + # env_file: + # - .env + # - .env.test + # # db is not necessary for local dev # todo: use .override.yml to enable testing dmongodb # comfy-pr-db: diff --git a/WEBHOOK_SETUP.md b/docs/WEBHOOK_SETUP.md similarity index 97% rename from WEBHOOK_SETUP.md rename to docs/WEBHOOK_SETUP.md index b339225f..f85e51a9 100644 --- a/WEBHOOK_SETUP.md +++ b/docs/WEBHOOK_SETUP.md @@ -7,7 +7,7 @@ This system now supports both polling and real-time webhook-based monitoring. ### Polling Mode (Default) ```bash -bun run run/label-op.tsx +bun run bot/label-op.tsx ``` ### Webhook Mode (Real-time) @@ -19,7 +19,7 @@ export GITHUB_WEBHOOK_SECRET=your-secure-secret-here export WEBHOOK_BASE_URL=https://your-domain.com # Your public URL # Run the monitor -bun run run/label-op.tsx +bun run bot/label-op.tsx ``` ## Webhook Setup Instructions diff --git a/entry.sh b/entry.sh index 5ee0964b..162445d3 100644 --- a/entry.sh +++ b/entry.sh @@ -10,4 +10,6 @@ comfy --help # activate venv source ./.venv/bin/activate -# bun index.ts $* +# Start Next.js server +export PORT=80 +bun --bun start diff --git a/infra/CLAUDE.md b/infra/CLAUDE.md new file mode 100644 index 00000000..704669b9 --- /dev/null +++ b/infra/CLAUDE.md @@ -0,0 +1,145 @@ +# Infrastructure Documentation + +## Overview + +This directory contains Terraform configuration for managing the Comfy-PR Cloud Run infrastructure on Google Cloud Platform. + +## Project Configuration + +- **Project ID**: `dreamboothy-dev` +- **Region**: `us-west2` +- **Service Name**: `comfy-pr` +- **State Backend**: GCS bucket `dreamboothy-dev-comfy-pr-tfstate` + +## Resources Managed + +### IAM & Service Accounts + +- **Service Account**: `comfy-pr-sa@dreamboothy-dev.iam.gserviceaccount.com` + - Roles: + - Cloud Run Service Agent + - Artifact Registry Reader + - Secret Manager Secret Accessor + +### Artifact Registry + +- **Repository**: `comfy-pr` in `us-west2` + - Docker format repository for container images + +### Cloud Run + +- Deployment handled via GitHub Actions workflow +- Uses Terraform-managed resources (bucket, service account) + +## Development Workflow + +### Prerequisites + +1. Install Terraform: `brew install terraform` (macOS) or download from terraform.io +2. Install gcloud CLI and authenticate: `gcloud auth login` +3. Set project: `gcloud config set project dreamboothy-dev` + +### Commands + +#### Initialize Terraform + +```bash +./tf.sh init +``` + +#### Plan Changes + +```bash +./tf.sh plan +``` + +#### Apply Changes + +```bash +./tf.sh apply +``` + +#### Destroy Resources (CAUTION) + +```bash +./tf.sh destroy +``` + +### Using tf.sh Helper Script + +The `tf.sh` script automatically injects Google OAuth access token for authentication: + +```bash +./tf.sh +``` + +Examples: + +```bash +./tf.sh plan -out=tfplan +./tf.sh apply tfplan +./tf.sh state list +./tf.sh state show google_artifact_registry_repository.comfy_pr +``` + +## Important Files + +- `main.tf` - Main Terraform configuration +- `tf.sh` - Helper script for running Terraform with auth +- `.terraform.lock.hcl` - Dependency lock file (should be committed) +- `.terraform/` - Provider plugins and modules (gitignored) +- `*.tfstate` - State files (stored remotely in GCS, local copies gitignored) + +## Security Notes + +1. **State Backend**: Terraform state is stored remotely in GCS bucket with encryption +2. **Service Account**: Limited permissions following least privilege principle +3. **Secrets**: Managed via Secret Manager, not stored in Terraform +4. **Access Control**: Cloud Run service configured for authenticated access only + +## Troubleshooting + +### Authentication Issues + +```bash +# Re-authenticate with gcloud +gcloud auth application-default login + +# Verify current auth +gcloud auth list +``` + +### State Lock Issues + +If Terraform state is locked: + +```bash +./tf.sh force-unlock +``` + +### Viewing Current State + +```bash +# List all resources +./tf.sh state list + +# Show specific resource details +./tf.sh state show google_artifact_registry_repository.comfy_pr +``` + +## CI/CD Integration + +The GitHub Actions workflow (`.github/workflows/cloudrun.yaml`) uses these Terraform-managed resources: + +- Service account for Cloud Run deployment +- Artifact Registry for container images + +Note: Caching is now ephemeral - cleared on each deployment/revision + +## Best Practices + +1. **Always run `plan` before `apply`** to review changes +2. **Use workspaces** for managing multiple environments if needed +3. **Tag resources** appropriately for cost tracking +4. **Review tfstate** regularly for drift detection +5. **Keep .terraform.lock.hcl** in version control for reproducible builds diff --git a/infra/main.tf b/infra/main.tf new file mode 100644 index 00000000..80725554 --- /dev/null +++ b/infra/main.tf @@ -0,0 +1,183 @@ +# Comfy-PR - Cloud Run Deployment with Direct IAP (No Load Balancer) + +terraform { + required_providers { + google = { + source = "hashicorp/google" + version = "~> 6.0" + } + google-beta = { + source = "hashicorp/google-beta" + version = "~> 6.0" + } + } + + backend "gcs" { + bucket = "dreamboothy-dev-comfy-pr-tfstate" + prefix = "terraform/state" + } +} + +# Variables +variable "project_id" { + description = "GCP Project ID" + type = string + default = "dreamboothy-dev" +} + +variable "region" { + description = "GCP Region" + type = string + default = "us-west2" +} + +variable "service_name" { + description = "Cloud Run service name" + type = string + default = "comfy-pr" +} + +# Provider +provider "google" { + project = var.project_id + region = var.region +} + +provider "google-beta" { + project = var.project_id + region = var.region +} + +# APIs are already enabled in the project +# Commenting out to avoid permission issues + +# Note: Cache bucket removed - using ephemeral storage only +# Caches are cleared on each Cloud Run deployment/revision + +# Artifact Registry Repository +resource "google_artifact_registry_repository" "comfy_pr" { + location = var.region + repository_id = var.service_name + description = "Docker repository for comfy-pr" + format = "DOCKER" + + # depends_on = [google_project_service.apis] +} + +# Cloud Run v2 Service with Direct IAP +resource "google_cloud_run_v2_service" "comfy_pr" { + provider = google-beta + name = var.service_name + location = var.region + project = var.project_id + ingress = "INGRESS_TRAFFIC_ALL" + launch_stage = "BETA" + # iap_enabled = true # Enable later after service is running + deletion_protection = false + + template { + service_account = google_service_account.cloud_run_sa.email + + containers { + image = "${var.region}-docker.pkg.dev/${var.project_id}/${var.service_name}/${var.service_name}:latest" + + ports { + container_port = 80 + } + + resources { + limits = { + cpu = "2" + memory = "2Gi" + } + } + + env { + name = "NODE_ENV" + value = "production" + } + + # Note/TODO: NOTION_TOKEN set via GitHub Actions deployment + # need to consult on standards for environment secrets + } + + scaling { + min_instance_count = 1 + max_instance_count = 1 + } + + max_instance_request_concurrency = 1000 + timeout = "300s" + } + + # depends_on = [google_project_service.apis] +} + +# Cloud Run service account for accessing GCS bucket +resource "google_service_account" "cloud_run_sa" { + account_id = "${var.service_name}-sa" + display_name = "Cloud Run Service Account for comfy-pr" + description = "Service account for comfy-pr Cloud Run service" +} + +# Note: Storage permissions removed - using ephemeral storage only + +# Service account for GitHub Actions (deployment) +resource "google_service_account" "github_actions_sa" { + account_id = "github-actions-${var.service_name}" + display_name = "GitHub Actions Service Account for comfy-pr" + description = "Service account for GitHub Actions to deploy comfy-pr" +} + +# Grant GitHub Actions service account permission to push to Artifact Registry +resource "google_project_iam_member" "github_actions_artifact_registry" { + project = var.project_id + role = "roles/artifactregistry.writer" + member = "serviceAccount:${google_service_account.github_actions_sa.email}" +} + +# Grant GitHub Actions service account permission to deploy to Cloud Run +resource "google_project_iam_member" "github_actions_cloud_run" { + project = var.project_id + role = "roles/run.developer" + member = "serviceAccount:${google_service_account.github_actions_sa.email}" +} + +# Grant GitHub Actions service account permission to use service accounts (for Cloud Run deployment) +resource "google_project_iam_member" "github_actions_service_account_user" { + project = var.project_id + role = "roles/iam.serviceAccountUser" + member = "serviceAccount:${google_service_account.github_actions_sa.email}" +} + +# Also grant Cloud Run SA permission to pull from Artifact Registry +resource "google_project_iam_member" "cloud_run_artifact_registry" { + project = var.project_id + role = "roles/artifactregistry.reader" + member = "serviceAccount:${google_service_account.cloud_run_sa.email}" +} + +# Allow public access (will restrict with IAP later) +resource "google_cloud_run_v2_service_iam_member" "public_access" { + project = var.project_id + location = google_cloud_run_v2_service.comfy_pr.location + name = google_cloud_run_v2_service.comfy_pr.name + role = "roles/run.invoker" + member = "allUsers" +} + +# Outputs +output "service_url" { + description = "Cloud Run service URL (IAP-protected)" + value = google_cloud_run_v2_service.comfy_pr.uri +} + +output "github_actions_service_account_email" { + description = "Email of the GitHub Actions service account" + value = google_service_account.github_actions_sa.email +} + +output "artifact_registry_repository" { + description = "Artifact Registry repository URL" + value = "${var.region}-docker.pkg.dev/${var.project_id}/${google_artifact_registry_repository.comfy_pr.name}" +} \ No newline at end of file diff --git a/infra/tf.sh b/infra/tf.sh new file mode 100755 index 00000000..909ecdd8 --- /dev/null +++ b/infra/tf.sh @@ -0,0 +1,4 @@ +#!/bin/sh + +# A helper script to run terraform commands with the necessary environment variables. +GOOGLE_OAUTH_ACCESS_TOKEN=$(gcloud auth print-access-token) terraform "$@" diff --git a/lib/github/index.ts b/lib/github/index.ts index c06fcff8..5069f545 100755 --- a/lib/github/index.ts +++ b/lib/github/index.ts @@ -1,14 +1,25 @@ import DIE from "@snomiao/die"; -import { createOctokit } from "./createOctokit"; +import { Octokit } from "octokit"; -const GH_TOKEN = - process.env.GH_TOKEN_COMFY_PR || - process.env.GH_TOKEN || - DIE("Missing env.GH_TOKEN from https://github.com/settings/tokens?type=beta"); +// Lazy initialization to avoid failing during Next.js build +let _octokit: Octokit | null = null; +function getOctokit() { + if (!_octokit) { + const GH_TOKEN = + process.env.GH_TOKEN_COMFY_PR || + process.env.GH_TOKEN || + DIE("Missing env.GH_TOKEN from https://github.com/settings/tokens?type=beta"); + _octokit = new Octokit({ auth: GH_TOKEN }); + } + return _octokit; +} -const octokit = createOctokit({ auth: GH_TOKEN }); - -export const gh = octokit.rest; +export const gh = new Proxy({} as Octokit["rest"], { + get: (target, prop) => { + return getOctokit().rest[prop as keyof Octokit["rest"]]; + }, +}); +// TODO: use async-sema for gh requests import type { components as ghComponents } from "@octokit/openapi-types"; export type GH = ghComponents["schemas"]; diff --git a/package.json b/package.json index b797ba48..5014baf0 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "dev": "next dev", "dev:bot": "bun --watch ./bot/index.ts", "dev:tsc": "tsc -w", - "gh-service": "bun run/index.ts", + "gh-service": "bun bot/index.ts", "check": "tsgo && oxlint && oxfmt", "typecheck": "tsgo", "lint": "oxlint --fix", diff --git a/packages/mongodb-pipeline-ts/$fresh.test.ts b/packages/mongodb-pipeline-ts/$fresh.test.ts index b963fd87..6413a92f 100644 --- a/packages/mongodb-pipeline-ts/$fresh.test.ts +++ b/packages/mongodb-pipeline-ts/$fresh.test.ts @@ -1,47 +1,39 @@ -import { beforeAll, afterAll, jest, it, expect } from "@jest/globals"; +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { Db, MongoClient, type ObjectId } from "mongodb"; import { $fresh, $stale } from "."; -type g = typeof globalThis & { _db: Db; _client: MongoClient }; -let db: Db; -let client: MongoClient; +// Skip all tests in this file if MONGODB_URI is not set +if (!process.env.MONGODB_URI) { + describe.skip("MongoDB tests (skipped - no MONGODB_URI)", () => { + it.skip("placeholder", () => {}); + }); + process.exit(0); +} + +type g = typeof globalThis & { _db: Db }; +export const db = ((global as any as g)._db ??= new MongoClient(process.env.MONGODB_URI!).db()); + let Test: ReturnType; // Setup database connection before all tests beforeAll(async () => { - // Only connect if MONGODB_URI is set - if (!process.env.MONGODB_URI) { - throw new Error("MONGODB_URI is not set. Please set it to run these tests."); - } - - const globalAny = global as any as g; - if (!globalAny._db) { - client = new MongoClient(process.env.MONGODB_URI, { - serverSelectionTimeoutMS: 5000, // 5 second timeout - }); - await client.connect(); - globalAny._client = client; - globalAny._db = client.db(); - } else { - client = globalAny._client; - } - db = globalAny._db; - Test = db.collection("test-fresh-stale"); await Test.createIndex({ t: 1 }); -}, 10000); // 10 second timeout for beforeAll +}, 10000); // Clean up database connection after all tests afterAll(async () => { - jest.useRealTimers(); - if (client) { - await client.close(); - } + // Cleanup if needed }); -// mock Date +// mock Date using bun's fake timers const now = new Date(); -jest.useFakeTimers().setSystemTime(now); +beforeAll(() => { + // Bun doesn't have fake timers like Jest, so we'll work with real dates +}); +afterAll(() => { + // Cleanup if needed +}); const staleDate = new Date(+now - 86400e3); // 1day ago const notStaleDate = new Date(+staleDate + 1); diff --git a/run/Dockerfile b/run/Dockerfile deleted file mode 100644 index c417ecd9..00000000 --- a/run/Dockerfile +++ /dev/null @@ -1,28 +0,0 @@ -# Use official Bun image -FROM oven/bun:1.1.21-slim - -# Set working directory -WORKDIR /app - -# Copy package files -COPY package.json bun.lock ./ - -# Install dependencies -RUN bun install --production --ignore-scripts - -# Copy source code -COPY ../ . - -# Expose port -EXPOSE 8080 - -# Set environment variables -ENV NODE_ENV=production -ENV PORT=8080 - -# Health check -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:8080/health || exit 1 - -# Run the webhook service -CMD ["bun", "run/index.tsx"] \ No newline at end of file diff --git a/run/README.md b/run/README.md deleted file mode 100644 index b133065a..00000000 --- a/run/README.md +++ /dev/null @@ -1,285 +0,0 @@ -# GitHub Webhook Service Deployment - -This directory contains the GitHub webhook service and deployment configuration for Google Cloud Run. - -## Overview - -The webhook service (`index.tsx`) monitors GitHub repositories for events like issues, pull requests, and comments. It can operate in two modes: - -- **Webhook mode**: Real-time event handling via GitHub webhooks -- **Polling mode**: Fallback polling when webhooks can't be configured - -## Prerequisites - -1. **Google Cloud CLI**: Install and authenticate with `gcloud auth login` -2. **Docker**: Required for building container images -3. **Project Setup**: Enable billing on your Google Cloud project -4. **GitHub Token**: Personal Access Token with repo permissions -5. **Webhook Secret**: Secret for securing webhook payloads - -## Environment Variables - -The service requires these environment variables: - -### Required - -- `GITHUB_TOKEN`: GitHub Personal Access Token -- `GITHUB_WEBHOOK_SECRET`: Secret for webhook signature validation -- `PORT`: Server port (defaults to 8080) - -### Optional - -- `GITHUB_WEBHOOK_BASEURL`: Base URL for webhook endpoints -- `GITHUB_WEBHOOK_PORT`: Alternative port setting - -## Deployment - -### Quick Deploy - -```bash -# Set environment variables -export GOOGLE_CLOUD_PROJECT="your-project-id" -export GITHUB_TOKEN="ghp_your_token_here" -export GITHUB_WEBHOOK_SECRET="your_webhook_secret" - -# Deploy -cd run -./deploy.sh -``` - -### Manual Deploy Steps - -1. **Enable APIs**: - - ```bash - gcloud services enable cloudbuild.googleapis.com run.googleapis.com artifactregistry.googleapis.com secretmanager.googleapis.com - ``` - -2. **Create Artifact Registry**: - - ```bash - gcloud artifacts repositories create github-webhook-service \ - --repository-format=docker \ - --location=us-central1 - ``` - -3. **Create Secrets**: - - ```bash - echo "$GITHUB_TOKEN" | gcloud secrets create github-token --data-file=- - echo "$GITHUB_WEBHOOK_SECRET" | gcloud secrets create github-webhook-secret --data-file=- - ``` - -4. **Build and Deploy**: - ```bash - gcloud builds submit . --config=cloudbuild.yaml - ``` - -## Files - -- `index.tsx` - Main webhook service application -- `Dockerfile` - Container build configuration -- `cloudbuild.yaml` - Cloud Build deployment configuration -- `deploy.sh` - Automated deployment script -- `README.md` - This documentation - -## Service Endpoints - -- `/` - Root endpoint with basic info -- `/api/github/webhook` - GitHub webhook endpoint -- `/health` - Health check endpoint - -## GitHub Webhook Configuration - -After deployment, configure your GitHub repositories: - -1. Go to Repository Settings → Webhooks -2. Add webhook with URL: `https://your-service-url/api/github/webhook` -3. Set content type to `application/json` -4. Enter your webhook secret -5. Select events: Issues, Pull requests, Issue comments, Pull request reviews, Labels - -## Local Development - -```bash -# Install dependencies -bun install - -# Set environment variables -export GITHUB_TOKEN="your_token" -export GITHUB_WEBHOOK_SECRET="your_secret" -export PORT=3000 - -# Run locally -bun run index.tsx -``` - -## Getting Your Webhook Secret - -### Option 1: Generate a Secure Random Secret (Recommended) - -```bash -# Generate a 32-byte random hex string -node -e "console.log('GITHUB_WEBHOOK_SECRET='+require('crypto').randomBytes(32).toString('hex'))" >> .env.local - -# Or use openssl -openssl rand -hex 32 - -# Or use Bun -bun -e "console.log(crypto.randomBytes(32).toString('hex'))" -``` - -Example output: `a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456` - -### Option 2: Use a Password Generator - -Visit [1Password Generator](https://1password.com/password-generator/) or similar: - -- Length: 64 characters -- Include: Letters, Numbers, Symbols -- Copy the generated password - -### Option 3: Custom Secret (Less Secure) - -```bash -export GITHUB_WEBHOOK_SECRET="my-super-secret-webhook-password-2024" -``` - -âš ī¸ **Security Note**: Use a long, random, unique secret that you don't use anywhere else. - -## Environment Variables - -| Variable | Required | Description | Example | -| ----------------------- | ------------ | ------------------------------- | ------------------------ | -| `USE_WEBHOOKS` | No | Enable webhook mode | `true` | -| `GITHUB_WEBHOOK_SECRET` | Webhook mode | Secret for webhook verification | `abc123...` | -| `WEBHOOK_BASE_URL` | Webhook mode | Your public URL | `https://myapp.ngrok.io` | -| `GH_TOKEN` | Yes | GitHub token with repo access | `ghp_xxx...` | -| `PORT` | No | Server port | `3000` | - -## GitHub Token Setup - -1. Go to [GitHub Personal Access Tokens](https://github.com/settings/tokens) -2. Click "Generate new token" → "Generate new token (classic)" -3. Select scopes: - - ✅ `repo` (Full control of private repositories) - - ✅ `admin:repo_hook` (Read and write repository hooks) -4. Copy the token and set it as `GH_TOKEN` - -```bash -export GH_TOKEN=ghp_your_github_token_here -``` - -## Making Your Server Public (For Webhooks) - -### Development - Using ngrok - -1. Install ngrok: https://ngrok.com/download -2. Start your server: `bun run run/gh-service.tsx` -3. In another terminal: `ngrok http 3000` -4. Copy the HTTPS URL: `https://abc123.ngrok.io` -5. Set environment variable: `export WEBHOOK_BASE_URL=https://abc123.ngrok.io` - -### Development - Using Cloudflare Tunnel - -```bash -# Install cloudflared -npm install -g @cloudflare/next-on-pages - -# Start tunnel -cloudflared tunnel --url http://localhost:3000 - -# Copy the HTTPS URL and set WEBHOOK_BASE_URL -``` - -### Production Deployment - -Deploy to any cloud provider: - -- **Vercel**: `vercel --prod` -- **Railway**: `railway deploy` -- **Heroku**: `git push heroku main` -- **DigitalOcean**: App Platform -- **AWS**: Lambda/EC2 -- **Google Cloud**: Cloud Run - -## Complete Setup Example - -```bash -# 1. Generate webhook secret -export GITHUB_WEBHOOK_SECRET=$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))") - -# 2. Set your GitHub token -export GH_TOKEN=ghp_your_github_personal_access_token - -# 3. Make server public (development) -ngrok http 3000 & # Run in background -export WEBHOOK_BASE_URL=https://your-ngrok-url.ngrok.io - -# 4. Enable webhook mode -export USE_WEBHOOKS=true - -# 5. Start the monitor -bun run run/gh-service.tsx -``` - -## Event Types Monitored - -- 🆕 **New Issues** - When someone opens an issue -- 🔄 **New Pull Requests** - When someone creates a PR -- đŸ’Ŧ **Comments** - Issue comments, PR comments, review comments -- đŸˇī¸ **Labels** - When labels are added or removed -- ✅ **Status Changes** - Issue/PR closed, reopened, merged - -## Webhook vs Polling Comparison - -| Feature | Polling (30s) | Webhooks | -| ---------------------- | ----------------- | -------------------- | -| **Latency** | ~30 seconds | ~1 second | -| **API Rate Limit** | High usage | Minimal usage | -| **Setup Complexity** | Simple | Moderate | -| **Reliability** | Always works | Network dependent | -| **Resource Usage** | Higher CPU/Memory | Lower | -| **GitHub Permissions** | Read-only | Admin/Write required | - -## Troubleshooting - -### "Error creating webhook: 403 Forbidden" - -- Your GitHub token needs `admin:repo_hook` permissions -- You need admin or write access to the repositories - -### "Webhook endpoint not reachable" - -- Ensure your `WEBHOOK_BASE_URL` is publicly accessible -- Test: `curl -X POST https://your-url.com/webhook` - -### "Webhook signature verification failed" - -- Check that `GITHUB_WEBHOOK_SECRET` matches what you configured -- Ensure the secret is the same when creating webhooks - -### No webhook events received - -- Check GitHub webhook delivery logs in repo settings → Webhooks -- Verify the webhook URL is correct: `https://your-domain.com/webhook` -- Ensure your server is running and accessible - -## Security Best Practices - -1. **Use strong webhook secrets** - Generate random 64+ character strings -2. **Use HTTPS** - Never use HTTP for webhook endpoints in production -3. **Verify signatures** - The system automatically verifies webhook authenticity -4. **Rotate secrets periodically** - Update webhook secrets every 90 days -5. **Limit token permissions** - Only grant necessary GitHub token scopes - -## Monitored Repositories - -The system monitors these repositories by default: - -- `Comfy-Org/Comfy-PR` -- `comfyanonymous/ComfyUI` -- `Comfy-Org/ComfyUI_frontend` -- `Comfy-Org/desktop` - -To modify the list, edit the `REPOLIST` array in `gh-service.tsx`. diff --git a/run/state.sqlite b/run/state.sqlite deleted file mode 100644 index ba18875d..00000000 Binary files a/run/state.sqlite and /dev/null differ diff --git a/src/analyzeTotals.test.ts b/src/analyzeTotals.test.ts index e24b2633..26038b61 100644 --- a/src/analyzeTotals.test.ts +++ b/src/analyzeTotals.test.ts @@ -1,5 +1,7 @@ +import { test } from "bun:test"; import { analyzeTotals } from "./analyzeTotals"; -it("analyze totals", async () => { +test.skipIf(!process.env.MONGODB_URI)("analyze totals", async () => { + const { expect } = await import("bun:test"); expect(await analyzeTotals()).toBeTruthy(); }); diff --git a/src/db/index.ts b/src/db/index.ts index f84a8587..26d27ded 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -11,48 +11,136 @@ if (!process.env.MONGODB_URI) console.warn("MONGODB_URI is not set, using default value. This may cause issues in production."); const MONGODB_URI = process.env.MONGODB_URI ?? "mongodb://PLEASE_SET_MONGODB_URI:27017"; -// Skip actual DB connection during Next.js build -const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build"; +// Detect if we're in a build/compile phase (not runtime) +const IS_BUILD_PHASE = + process.env.NEXT_PHASE === "phase-production-build" || + process.env.VERCEL_ENV === "preview" || + (process.env.NODE_ENV === "production" && !process.env.MONGODB_URI) || + // During GitHub Actions build + (process.env.CI && !process.env.MONGODB_URI); -export const mongo = await (isBuildPhase - ? Promise.resolve(null as any as MongoClient) - : hotResource(async () => [new MongoClient(MONGODB_URI), (conn) => conn.close()])); +// Lazy initialization to avoid blocking during Next.js build +let _mongo: Awaited>> | null = null; +let _initPromise: Promise>>> | null = null; -// Create a Proxy for db during build that returns dummy collection objects -const buildTimeDb = new Proxy({} as any, { - get(target, prop) { - if (prop === "collection") { - return () => - new Proxy({} as any, { - get(target, prop) { - if (prop === "createIndex") return () => Promise.resolve(); - return () => {}; - }, - }); - } - if (prop === "close") return async () => {}; - return () => {}; +async function getMongo() { + // During build phase, return a mock that never connects + if (IS_BUILD_PHASE) { + throw new Error("MongoDB connection not available during build phase"); + } + + if (_mongo) return _mongo; + if (_initPromise) return _initPromise; + + _initPromise = hotResource(async () => [new MongoClient(MONGODB_URI), (conn) => conn.close()]); + _mongo = await _initPromise; + + // allow db conn for 45 mins in CI env to prevent long running CI jobs + if (isCI) { + setTimeout( + async () => { + await _mongo?.close(); + // should not be needed, but just in case + process.exit(0); + }, + 45 * 60 * 1000, + ); + } + + return _mongo; +} + +export const mongo = new Proxy({} as Awaited>>, { + get: (target, prop) => { + if (prop === "then") return undefined; // Prevent Promise auto-awaiting + return (...args: any[]) => + getMongo().then((m) => { + const value = (m as any)[prop]; + return typeof value === "function" ? value.apply(m, args) : value; + }); }, -}) as ReturnType & { close: () => Promise }; - -export const db = isBuildPhase - ? buildTimeDb - : Object.assign(mongo.db(), { - close: async () => await mongo.close(), - }); - -// allow db conn for 45 mins in CI env to prevent long running CI jobs -if (isCI) { - setTimeout( - async () => { - await mongo.close(); - // should not be needed, but just in case - process.exit(0); +}); + +// Proxy for collection to make createIndex and other methods lazy +function createCollectionProxy(collectionName: string): any { + // Use a plain object that can be extended with Object.assign + const target = {} as any; + + return new Proxy(target, { + get: (target, prop) => { + if (prop === "then") return undefined; // Prevent Promise auto-awaiting + + // Check if property was added directly to the target (e.g., via Object.assign) + if (prop in target) { + return target[prop]; + } + + // Return a function that lazily connects and calls the method + const lazyMethod = (...args: any[]) => { + // During build phase, return a resolved promise with a no-op + if (IS_BUILD_PHASE) { + return Promise.resolve({}); + } + + return getMongo().then((m) => { + const dbInstance = m.db(); + const collection = dbInstance.collection(collectionName); + const value = (collection as any)[prop]; + return typeof value === "function" ? value.apply(collection, args) : value; + }); + }; + + return lazyMethod; }, - 45 * 60 * 1000, - ); + // Allow properties to be set (for Object.assign) + set: (target, prop, value) => { + target[prop] = value; + return true; + }, + // Make the proxy look like an object with methods + has: (target, prop) => prop in target || true, + ownKeys: (target) => [ + ...Object.keys(target), + "createIndex", + "findOne", + "find", + "insertOne", + "updateOne", + "deleteOne", + ], + getOwnPropertyDescriptor: (target, prop) => { + if (prop in target) { + return Object.getOwnPropertyDescriptor(target, prop); + } + return { enumerable: true, configurable: true, writable: true }; + }, + }); } +export const db = new Proxy({} as ReturnType & { close: () => Promise }, { + get: (target, prop) => { + if (prop === "then") return undefined; // Prevent Promise auto-awaiting + if (prop === "close") { + return async () => { + if (IS_BUILD_PHASE) return; + const m = await getMongo(); + return m.close(); + }; + } + if (prop === "collection") { + return (name: string, options?: any) => createCollectionProxy(name); + } + return (...args: any[]) => { + if (IS_BUILD_PHASE) return Promise.resolve({}); + return getMongo().then((m) => { + const dbInstance = m.db(); + const value = (dbInstance as any)[prop]; + return typeof value === "function" ? value.apply(dbInstance, args) : value; + }); + }; + }, +}); + if (import.meta.main) { console.log(await db.admin().ping()); console.log(enhancedMs("7d") === 7 * 86400e3);