Repository: wei/pull Branch: master Commit: 197574c0f0d1 Files: 41 Total size: 65.3 KB Directory structure: gitextract_6sspy7v9/ ├── .devcontainer/ │ ├── Dockerfile │ ├── pull-base/ │ │ ├── devcontainer.json │ │ └── docker-compose.yml │ └── pull-full/ │ ├── devcontainer.json │ └── docker-compose.yml ├── .dockerignore ├── .github/ │ ├── CODE_OF_CONDUCT.md │ ├── CONTRIBUTING.md │ ├── FUNDING.yml │ ├── dependabot.yml │ ├── pull.yml │ └── workflows/ │ ├── auto-tag.yml │ └── publish.yml ├── .gitignore ├── .hooks/ │ └── pre-commit ├── Dockerfile ├── LICENSE ├── README.md ├── _config.yml ├── deno.json ├── docker-compose.yml ├── scripts/ │ ├── full-sync.ts │ └── manual-process.ts ├── src/ │ ├── app.ts │ ├── configs/ │ │ ├── app-config.ts │ │ ├── database.ts │ │ └── redis.ts │ ├── index.ts │ ├── processor/ │ │ ├── index.ts │ │ └── pull.ts │ ├── router/ │ │ ├── index.ts │ │ ├── repo-handler.ts │ │ └── stats.ts │ ├── utils/ │ │ ├── get-pull-config.ts │ │ ├── get-repository-schedule.ts │ │ ├── helpers.ts │ │ ├── logger.ts │ │ ├── schema.test.ts │ │ └── schema.ts │ └── worker.ts └── static/ └── hello.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .devcontainer/Dockerfile ================================================ ARG VARIANT=ubuntu ARG DENO_VERSION=2.3.7 FROM denoland/deno:bin-${DENO_VERSION} AS deno FROM mcr.microsoft.com/vscode/devcontainers/base:${VARIANT} COPY --from=deno /deno /usr/local/bin/deno ================================================ FILE: .devcontainer/pull-base/devcontainer.json ================================================ { "name": "[Pull App] Base Dev Environment", "dockerComposeFile": [ "docker-compose.yml" ], "service": "dev", "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", "customizations": { "vscode": { "extensions": [ "github.copilot", "denoland.vscode-deno", "mongodb.mongodb-vscode" ] } }, "features": { "ghcr.io/devcontainers/features/github-cli:1": { "installDirectlyFromGitHubRelease": true, "version": "latest" }, "ghcr.io/jckimble/devcontainer-features/ngrok:3": { "version": "stable" } }, "forwardPorts": [ "app:3000", "mongodb:27017", "redis:6379" ], "postStartCommand": { "git-config": "git config --global --add safe.directory /workspaces/${localWorkspaceFolderBasename}; git config --global core.autocrlf true; git config --global core.editor nano", "deno-hooks": "deno task install-hooks" } } ================================================ FILE: .devcontainer/pull-base/docker-compose.yml ================================================ version: "3.8" services: dev: build: context: .. dockerfile: Dockerfile volumes: - ../../..:/workspaces:cached # Overrides default command so things don't shut down after the process ends. command: sleep infinity # Create a network and connect the app container to it networks: - devcontainer-network mongodb: image: mongo:8 restart: unless-stopped volumes: - mongodb-data:/data/db environment: MONGO_INITDB_ROOT_USERNAME: root MONGO_INITDB_ROOT_PASSWORD: mongodb_password # Connect using mongodb://root:mongodb_password@mongodb:27017/ # Add "forwardPorts": ["mongodb:27017"] to **devcontainer.json** to forward MongoDB locally. # (Adding the "ports" property to this file will not forward from a Codespace.) networks: - devcontainer-network redis: image: redis:7.4 restart: unless-stopped volumes: - redis-data:/data # Add "forwardPorts": ["redis:6379"] to **devcontainer.json** to forward Redis locally. # (Adding the "ports" property to this file will not forward from a Codespace.) networks: - devcontainer-network networks: devcontainer-network: volumes: mongodb-data: redis-data: ================================================ FILE: .devcontainer/pull-full/devcontainer.json ================================================ { "name": "[Pull App] Full Dev Environment (amd64)", "dockerComposeFile": [ "../pull-base/docker-compose.yml", "docker-compose.yml" ], "service": "dev", "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", "customizations": { "vscode": { "extensions": [ "github.copilot", "denoland.vscode-deno", "mongodb.mongodb-vscode" ] } }, "features": { "ghcr.io/devcontainers/features/github-cli:1": { "installDirectlyFromGitHubRelease": true, "version": "latest" }, "ghcr.io/jckimble/devcontainer-features/ngrok:3": { "version": "stable" } }, "forwardPorts": [ "app:3000", "mongodb:27017", "redis:6379", "mongo-express:8081", "redis-commander:8081", "bullboard:8083" ], "postStartCommand": { "git-config": "git config --global --add safe.directory /workspaces/${localWorkspaceFolderBasename}; git config --global core.autocrlf true; git config --global core.editor nano", "deno-hooks": "deno task install-hooks" } } ================================================ FILE: .devcontainer/pull-full/docker-compose.yml ================================================ version: "3.8" services: mongo-express: image: mongo-express restart: unless-stopped environment: ME_CONFIG_MONGODB_URL: mongodb://root:mongodb_password@mongodb:27017/ ME_CONFIG_BASICAUTH: false # Add "forwardPorts": ["mongo-express:8081"] to **devcontainer.json** to forward Mongo-express locally. # (Adding the "ports" property to this file will not forward from a Codespace.) networks: - devcontainer-network redis-commander: image: rediscommander/redis-commander:latest restart: unless-stopped environment: REDIS_HOSTS: redis # Add "forwardPorts": ["redis-commander:8081"] to **devcontainer.json** to forward Redis-commander locally. # (Adding the "ports" property to this file will not forward from a Codespace.) networks: - devcontainer-network bullboard: image: addono/bull-board:latest restart: unless-stopped environment: REDIS_HOST: redis PORT: 8083 # Add "forwardPorts": ["redis-commander:8081"] to **devcontainer.json** to forward Redis-commander locally. # (Adding the "ports" property to this file will not forward from a Codespace.) networks: - devcontainer-network ================================================ FILE: .dockerignore ================================================ node_modules coverage npm-debug.log *.pem .env .* docker-compose.yml Dockerfile *.md .gitignore .dockerignore ================================================ FILE: .github/CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: - Using welcoming and inclusive language - Being respectful of differing viewpoints and experiences - Gracefully accepting constructive criticism - Focusing on what is best for the community - Showing empathy towards other community members Examples of unacceptable behavior by participants include: - The use of sexualized language or imagery and unwelcome sexual attention or advances - Trolling, insulting/derogatory comments, and personal or political attacks - Public or private harassment - Publishing others' private information, such as a physical or electronic address, without explicit permission - Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at github@weispot.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] [homepage]: https://www.contributor-covenant.org [version]: https://www.contributor-covenant.org/version/1/4/ ================================================ FILE: .github/CONTRIBUTING.md ================================================ # Contributing to Pull Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. Please note that this project is released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms. ## Development Environment ### Prerequisites - [Deno](https://deno.land/#installation) 2 - MongoDB 8.0 - Redis 7.4 ### Development Containers We provide two development environments: 1. Base Environment: ```bash devcontainer open .devcontainer/pull-base ``` 2. Full Environment (includes monitoring tools which supports amd64 only): ```bash devcontainer open .devcontainer/pull-full ``` The full environment includes additional tools: - Mongo Express (port 8081) - Redis Commander (port 8082) - Bull Board (port 8083) ## Getting Started 1. Fork and clone the repository 2. Make sure you have [Deno](https://deno.land/#installation) installed 3. Install development hooks: ```bash deno task install-hooks ``` 4. Set up your GitHub App. Follow the [Probot documentation](https://probot.github.io/docs/development/) for detailed instructions on creating a GitHub App. 5. Set up your environment variables by copying the `.env.example` file to `.env` and filling in the required values: ```sh cp .env.example .env ``` 6. Start the development server: ```bash deno task dev ``` Start the development worker: ```bash deno task worker ``` ## Available Scripts - `deno task dev`: Start the app in development mode - `deno task dev:skip-full-sync`: Start without initial full sync - `deno task worker`: Start the background worker - `deno task test`: Run tests - `deno task check`: Run linter and formatter checks - `deno task full-sync`: Run full repository sync - `deno task manual-process /`: Process specific repository ## Application Architecture ### Core Components #### 1. Web Server (`src/index.ts`) The main Express.js application that: - Handles GitHub webhooks - Serves API endpoints - Initializes the scheduler #### 2. Background Worker (`src/worker.ts`) Processes repository sync jobs using BullMQ: - Handles job concurrency - Manages retries and failures - Processes jobs based on priority #### 3. Scheduler ([`@wei/probot-scheduler`](https://jsr.io/@wei/probot-scheduler)) Manages periodic repository checks: - Schedules jobs using cron expressions - Maintains job queues in Redis - Handles job priorities and scheduling #### 4. Database Layer - **MongoDB**: Stores repository and installation data - **Redis**: Manages job queues and caching - **Connections**: Managed through `src/configs/database.ts` and `src/configs/redis.ts` #### 5. Pull Processor (`src/processor/pull.ts`) The Pull processor is the core component that handles repository synchronization. Here's a detailed breakdown of its functionality: ```mermaid graph TD A[Routine Check] --> B{Has Diff?} B -->|Yes| C{Existing PR?} B -->|No| D[Skip] C -->|Yes| E[Check Auto Merge] C -->|No| F[Create PR] F --> E E --> G{Mergeable?} G -->|Yes| H[Process Merge] G -->|No| I[Handle Conflict] ``` ##### Main Components 1. **Routine Check (`routineCheck`):** - Iterates through configured rules in `pull.yml` - For each rule: - Normalizes base and upstream branch names - Checks for differences between branches - Creates or updates pull requests as needed 2. **Difference Detection (`hasDiff`):** - Compares commits between base and upstream branches - Handles special cases: - Large diffs that timeout - Missing branches - No common ancestor - Returns true if changes are detected 3. **Pull Request Management:** - **Updates (`getOpenPR`):** - Finds existing PRs created by the bot - Validates PR matches current sync rule - **Creation (`createPR`):** - Creates new pull request with standardized title - Assigns reviewers and labels - Updates PR body with tracking information 4. **Merge Process (`checkAutoMerge`, `processMerge`):** - Supports multiple merge methods: - `none`: No automatic merging - `merge`: Standard merge commit - `squash`: Squash and merge - `rebase`: Rebase and merge - `hardreset`: Force push to base branch - Handles merge conflicts: - Adds conflict label - Assigns conflict reviewers - Updates PR status ##### Error Handling The processor implements robust error handling: - Retries for mergeable status checks - Graceful handling of GitHub API limitations - Detailed logging for debugging - Conflict resolution workflows ##### Best Practices When modifying the Pull processor: 1. Maintain idempotency in operations 2. Implement proper error handling 3. Add detailed logging for debugging 4. Consider edge cases ## Submitting a Pull Request 1. Create a new branch: `git checkout -b my-branch-name` 2. Make your changes 3. Make sure the tests pass: ```bash deno test ``` 4. Format your code: ```bash deno fmt ``` 5. Run the linter: ```bash deno lint ``` 6. Push to your fork and submit a pull request 7. Pat yourself on the back and wait for your pull request to be reviewed and merged Here are a few things you can do that will increase the likelihood of your pull request being accepted: - Follow the style guide enforced by Deno's built-in formatter. You can format your code by running `deno fmt` - Write and update tests - Keep your changes as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) Work in Progress pull requests are also welcome to get feedback early on, or if there is something blocked you. ## Resources - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) - [GitHub Help](https://help.github.com) - [Deno Manual](https://deno.land/manual) ## License By contributing, you agree that your contributions will be licensed under its MIT License. ================================================ FILE: .github/FUNDING.yml ================================================ github: wei ================================================ FILE: .github/dependabot.yml ================================================ # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for more information: # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates # https://containers.dev/guide/dependabot version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" - package-ecosystem: "devcontainers" directory: "/" schedule: interval: "weekly" ================================================ FILE: .github/pull.yml ================================================ version: "1" rules: - base: master upstream: wei:master mergeMethod: hardreset ================================================ FILE: .github/workflows/auto-tag.yml ================================================ name: Auto Tag Release on Version Change on: workflow_dispatch: push: branches: - master paths: - "deno.json" jobs: auto-tag: runs-on: ubuntu-latest permissions: contents: write steps: - name: Checkout repository uses: actions/checkout@v6 with: token: ${{ secrets.CR_PAT }} - name: Get version from deno.json id: get_version run: | VERSION=$(jq -r .version deno.json) if ! git ls-remote --tags origin | grep -q "refs/tags/v${VERSION}"; then echo "version=$VERSION" >> $GITHUB_OUTPUT echo "Version found: $VERSION" else echo "Version already exists: $VERSION" fi - name: Create Git Tag if: steps.get_version.outputs.version != '' run: | git tag v${{ steps.get_version.outputs.version }} git push origin v${{ steps.get_version.outputs.version }} - name: Create GitHub Release if: steps.get_version.outputs.version != '' uses: softprops/action-gh-release@v2 with: tag_name: v${{ steps.get_version.outputs.version }} name: Release v${{ steps.get_version.outputs.version }} body: | 🤖 GitHub App: [Pull](https://github.com/apps/pull) draft: false prerelease: ${{ contains(steps.get_version.outputs.version, 'alpha') || contains(steps.get_version.outputs.version, 'beta') || contains(steps.get_version.outputs.version, 'rc') }} ================================================ FILE: .github/workflows/publish.yml ================================================ name: Create and publish a Docker image on: workflow_dispatch: pull_request: push: branches: - "master" tags: - "v*" env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: build-and-push-image: runs-on: ubuntu-latest permissions: contents: read packages: write attestations: write id-token: write steps: - name: Checkout repository uses: actions/checkout@v6 - name: Log in to the Container registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.CR_PAT }} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - name: Build and push Docker image id: push uses: docker/build-push-action@v6 with: context: . push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - name: Generate artifact attestation if: github.event_name != 'pull_request' uses: actions/attest-build-provenance@v2 with: subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true ================================================ FILE: .gitignore ================================================ node_modules coverage npm-debug.log *.pem .env ================================================ FILE: .hooks/pre-commit ================================================ #!/usr/bin/env sh . "$(dirname -- "$0")/_/hook.sh" deno task check ================================================ FILE: Dockerfile ================================================ ARG DENO_VERSION=2.3.7 FROM denoland/deno:alpine-${DENO_VERSION} ENV \ #################### ### Required ### #################### APP_ID= \ APP_NAME= \ APP_SLUG= \ WEBHOOK_SECRET= \ PRIVATE_KEY= \ #################### ### Optional ### #################### #SENTRY_DSN= \ #GHE_HOST= \ PORT=3000 \ LOG_FORMAT=short \ LOG_LEVEL=info \ WEBHOOK_PATH=/api/github/webhooks \ CONFIG_FILENAME=pull.yml \ DEFAULT_MERGE_METHOD=hardreset \ _= # Set working directory WORKDIR /app # Copy dependency files COPY deno.* . RUN deno install # Copy source code COPY . . # The app uses port 3000 by default EXPOSE 3000 # Command to run the app # CMD ["deno", "task", "dev"] # CMD ["deno", "task", "worker"] ================================================ FILE: LICENSE ================================================ The MIT License Copyright (c) 2024 Wei He Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================
Pull App
Probot Featured GitHub Stars
Repositories Installations Triggered #
## Introduction [![Version][version-badge]][version-url] [![Deno 2.0][deno-badge]][deno-url] [![TypeScript][ts-badge]][ts-url] [![License][license-badge]][license-url] > 🤖 a GitHub App that keeps your forks up-to-date with upstream via automated > pull requests. _Can you help keep this open source service alive? **[💖 Please sponsor : )][pull-sponsor]**_ ## Features - 🔄 **Automated Synchronization**: Ensures forks are updated by automatically creating pull requests to integrate new changes from upstream - ⚙️ **Flexible Configuration**: Customize sync behavior through `.github/pull.yml` configuration to accommodate different merge strategies, including merge, squash, rebase, and hard reset - 🕒 **Scheduled Updates**: Regularly checks for upstream changes periodically to ensure forks are always up-to-date - 👥 **Team Integration**: Facilitates collaboration by automatically adding assignees and reviewers to pull requests, honoring branch protection rules and working seamlessly with pull request checks and reviews - 🚀 **Enterprise Ready**: Supports GitHub Enterprise Server, ensuring a smooth integration process for enterprise-level projects ### Prerequisites - Upstream must be in the same fork network. - ⚠️ _Make a backup if you've made changes._ ## Getting Started **[⭐ Star this project][pull-repo]** (Highly recommended, starred users may receive priority over other users) ### Basic Setup - Just install **[ Pull app][pull-app]**. Pull app will automatically watch and pull in upstream's default (master) branch to yours using **hard reset** periodically. You can also manually [trigger](#trigger-manually) it anytime. ### Advanced Configuration (with config file) 1. Create a new branch. 2. Setup the new branch as default branch under repository Settings > Branches. 3. Add `.github/pull.yml` to your default branch. #### Most Common (behaves the same as Basic Setup) ```yaml version: "1" rules: - base: master upstream: wei:master # change `wei` to the owner of upstream repo mergeMethod: hardreset mergeUnstable: true ``` #### Advanced usage ```yaml version: "1" rules: # Array of rules - base: master # Required. Target branch upstream: wei:master # Required. Must be in the same fork network. mergeMethod: hardreset # Optional, one of [none, merge, squash, rebase, hardreset], Default: none. mergeUnstable: false # Optional, merge pull request even when the mergeable_state is not clean. Default: false - base: dev upstream: master # Required. Can be a branch in the same forked repo. assignees: # Optional - wei reviewers: # Optional - wei conflictReviewers: # Optional, on merge conflict assign a reviewer - wei label: ":arrow_heading_down: pull" # Optional conflictLabel: "merge-conflict" # Optional, on merge conflict assign a custom label, Default: merge-conflict ``` 4. Go to `https://pull.git.ci/check/${owner}/${repo}` to validate your `.github/pull.yml`. 5. Install **[ Pull app][pull-app]**. ### Trigger Manually You can manually trigger Pull by going to `https://pull.git.ci/process/${owner}/${repo}`. ### For Upstream Repository Owners For the most common use case (a single `master` branch), you can just direct users to install Pull with no configurations. If you need a more advanced setup (such as a `docs` branch in addition to `master`), consider adding `.github/pull.yml` to your repository pointing to yourself (see example). This will allow forks to install Pull and stay updated automatically. Example (assuming `owner` is your user or organization name): ```yaml version: "1" rules: - base: master upstream: owner:master mergeMethod: hardreset mergeUnstable: true - base: docs upstream: owner:docs mergeMethod: hardreset mergeUnstable: true ``` ## Contributing See [CONTRIBUTING.md](./.github/CONTRIBUTING.md) ## License [MIT](LICENSE) © [Wei He][pull-sponsor] ## Support _Can you help keep this open source service alive? **[💖 Please sponsor : )][pull-sponsor]**_ --- Made with ❤️ by [@wei](https://github.com/wei) [version-badge]: https://badgen.net/https/pull.git.ci/badges/version?label=Version&color=green&cache=300 [version-url]: https://pull.git.ci/version [deno-badge]: https://img.shields.io/badge/Deno%202.0-000000?logo=Deno&logoColor=ffffff [deno-url]: https://deno.com [ts-badge]: https://badgen.net/badge/_/TypeScript/blue?&label=&icon=typescript&cache=86400 [ts-url]: https://www.typescriptlang.org [license-badge]: https://badgen.net/badge/License/MIT/black?cache=86400 [license-url]: https://wei.mit-license.org [pull-app]: https://github.com/apps/pull [pull-repo]: https://github.com/wei/pull [pull-sponsor]: https://prod.download/pull-readme-sponsor ================================================ FILE: _config.yml ================================================ theme: jekyll-theme-cayman plugins: - jemoji ================================================ FILE: deno.json ================================================ { "version": "2.0.0-alpha.4", "tasks": { "dev": "deno run --env-file --allow-all src/index.ts", "dev:skip-full-sync": "deno task dev --skip-full-sync", "worker": "deno run --env-file --allow-all src/worker.ts", "full-sync": "deno run --env-file --allow-all scripts/full-sync.ts", "manual-process": "deno run --env-file --allow-all scripts/manual-process.ts", "hook": "deno run --allow-read --allow-run --allow-write https://deno.land/x/deno_hooks@0.1.1/mod.ts", "install-hooks": "deno task hook install", "test": "deno test --allow-net --allow-env --allow-read", "check": "deno lint && deno fmt --check && deno check ." }, "imports": { "@/": "./", "@octokit/plugin-rest-endpoint-methods": "npm:@octokit/plugin-rest-endpoint-methods@^13.2.6", "@probot/pino": "npm:@probot/pino@^2.5.0", "@std/assert": "jsr:@std/assert@^1.0.13", "@std/http": "jsr:@std/http@^1.0.18", "@wei/pluralize": "jsr:@wei/pluralize@^8.0.2", "@wei/probot-scheduler": "jsr:@wei/probot-scheduler@0.1.0-alpha.17", "bullmq": "npm:bullmq@^5.26.2", "express": "npm:express@^4.21.1", "ioredis": "npm:ioredis@^5.4.1", "mongoose": "npm:mongoose@^8.8.1", "pino": "npm:pino@^9.5.0", "probot": "npm:probot@^13.4.0", "zod": "npm:zod@^3.23.8" } } ================================================ FILE: docker-compose.yml ================================================ services: app: build: . restart: unless-stopped ports: - "3000:3000" env_file: - ./.env depends_on: - mongodb - redis # volumes: # - .:/app command: deno task dev ## Skip full-sync on startup: # command: deno task dev:skip-full-sync ## Run full-sync manually: # > docker exec -it pull-app-1 deno task full-sync worker: build: . restart: unless-stopped env_file: - ./.env depends_on: - mongodb - redis - app # volumes: # - .:/app command: deno task worker deploy: mode: replicated replicas: 3 # If running without swarm mode, you can use the following command to scale the worker: # > docker compose up -d --scale worker=3 mongodb: image: mongo:8 restart: unless-stopped environment: MONGO_INITDB_ROOT_USERNAME: root MONGO_INITDB_ROOT_PASSWORD: mongodb_password volumes: - mongodb-data:/data/db # ports: # - "27017:27017" redis: image: redis:7.4 restart: unless-stopped volumes: - redis-data:/data # ports: # - "6379:6379" # mongo-express: # image: mongo-express # restart: unless-stopped # depends_on: # - mongodb # environment: # ME_CONFIG_MONGODB_URL: mongodb://root:mongodb_password@mongodb:27017/ # ME_CONFIG_BASICAUTH: false # ports: # - "8081:8081" # redis-commander: # image: rediscommander/redis-commander:latest # restart: unless-stopped # environment: # REDIS_HOSTS: redis # depends_on: # - redis # ports: # - "8082:8081" # bullboard: # image: addono/bull-board:latest # restart: unless-stopped # environment: # REDIS_HOST: redis # PORT: 8083 # depends_on: # - redis # ports: # - "8083:8083" volumes: mongodb-data: redis-data: ================================================ FILE: scripts/full-sync.ts ================================================ import { createProbot } from "probot"; import { fullSync } from "@wei/probot-scheduler"; import logger from "@/src/utils/logger.ts"; import { connectMongoDB, disconnectMongoDB } from "@/src/configs/database.ts"; import { getRepositorySchedule } from "@/src/utils/get-repository-schedule.ts"; import { getRedisClient } from "@/src/configs/redis.ts"; import { appConfig } from "@/src/configs/app-config.ts"; async function main() { let exitCode = 0; let redisClient: ReturnType | undefined; try { await connectMongoDB(); redisClient = getRedisClient(`${appConfig.appSlug}-full-sync`); const probot = createProbot({ overrides: { log: logger } }); await fullSync(probot, { redisClient, getRepositorySchedule, }); } catch (error) { logger.error(error, "Error during full sync"); exitCode = 1; } finally { await disconnectMongoDB(); if (redisClient) { try { await redisClient.quit(); } catch { // ignore } } Deno.exit(exitCode); } } if (import.meta.main) { await main(); } ================================================ FILE: scripts/manual-process.ts ================================================ import { connectMongoDB, disconnectMongoDB } from "@/src/configs/database.ts"; import { JobPriority, RepositoryModel } from "@wei/probot-scheduler"; import { createProbot } from "probot"; import logger from "@/src/utils/logger.ts"; import { getPullConfig } from "@/src/utils/get-pull-config.ts"; import { Pull } from "@/src/processor/pull.ts"; async function main(full_name: string) { let exitCode = 0; try { await connectMongoDB(); logger.info(`🏃 Processing repo job ${full_name}`); try { // Get Octokit const probot = createProbot({ overrides: { log: logger } }); const repoRecord = await RepositoryModel.findOne({ full_name }); if (!repoRecord) { logger.error({ full_name }, `❌ Repo record not found`); throw new Error(`❌ Repo record not found`); } const { installation_id, owner: { login: owner }, name: repo } = repoRecord; const octokit = await probot.auth(installation_id); const config = await getPullConfig(octokit, logger, { installation_id, owner, repo, repository_id: 0, metadata: { cron: "", job_priority: JobPriority.Normal, repository_id: 0, }, }); if (!config) { logger.info(`⚠️ No config found, skipping`); return; } const pull = new Pull(octokit, { owner, repo, logger }, config); await pull.routineCheck(); logger.info(`✅ Repo job processed successfully`); } catch (error) { logger.error(error, "❌ Repo job failed"); } } catch (error) { logger.error(error, "Error processing"); exitCode = 1; } finally { await disconnectMongoDB(); Deno.exit(exitCode); } } if (import.meta.main) { const args = Deno.args; if (args.length !== 1) { logger.error( "Usage: deno task manual-process /", ); Deno.exit(1); } await main(args[0]); } ================================================ FILE: src/app.ts ================================================ import { Probot } from "probot"; import { createSchedulerApp, SchedulerAppOptions } from "@wei/probot-scheduler"; export default (app: Probot, opts: SchedulerAppOptions) => { createSchedulerApp(app, opts); }; ================================================ FILE: src/configs/app-config.ts ================================================ import { readEnvOptions } from "probot/lib/bin/read-env-options.js"; import denoJson from "@/deno.json" with { type: "json" }; function getAppConfig(env: Record = Deno.env.toObject()) { return { ...readEnvOptions(env), version: denoJson.version, appName: env.APP_NAME || "Pull", appSlug: env.APP_SLUG || "pull", botName: `${env.APP_SLUG || "pull"}[bot]`, configFilename: env.CONFIG_FILENAME || "pull.yml", mongoDBUrl: env.MONGODB_URL, port: parseInt(env.PORT || "3000", 10), webhookPath: env.WEBHOOK_PATH, defaultMergeMethod: env.DEFAULT_MERGE_METHOD || "hardreset", }; } export const appConfig = getAppConfig(); ================================================ FILE: src/configs/database.ts ================================================ import mongoose from "mongoose"; import { appConfig } from "@/src/configs/app-config.ts"; import log from "@/src/utils/logger.ts"; export const connectMongoDB = async () => { if (mongoose.connection.readyState !== 1) { await mongoose.connect(appConfig.mongoDBUrl!); } if (mongoose.connection.readyState !== 1) { throw new Error("[MongoDB] Failed to connect"); } log.info("[MongoDB] Connected"); }; export const disconnectMongoDB = async () => { await mongoose.disconnect(); log.info("[MongoDB] Disconnected"); }; ================================================ FILE: src/configs/redis.ts ================================================ import { appConfig } from "@/src/configs/app-config.ts"; import { Redis } from "ioredis"; export const getRedisClient = (name?: string) => { const redisClient = new Redis(appConfig.redisConfig!, { maxRetriesPerRequest: null, name, }); return redisClient; }; ================================================ FILE: src/index.ts ================================================ import express from "express"; import { createNodeMiddleware, createProbot } from "probot"; import { createSchedulerService } from "@wei/probot-scheduler"; import createSchedulerApp from "@/src/app.ts"; import { appConfig } from "@/src/configs/app-config.ts"; import log from "@/src/utils/logger.ts"; import { connectMongoDB, disconnectMongoDB } from "@/src/configs/database.ts"; import { getRedisClient } from "@/src/configs/redis.ts"; import createRouter from "@/src/router/index.ts"; import { getRepositorySchedule } from "@/src/utils/get-repository-schedule.ts"; const args = Deno.args; const skipFullSync = args.includes("--skip-full-sync"); await connectMongoDB(); const redisClient = getRedisClient(`${appConfig.appSlug}-app`); const probot = createProbot({ overrides: { log, }, }); const schedulerApp = createSchedulerApp.bind(null, probot, { // Optional: Skip the initial full sync skipFullSync, redisClient, // Define custom repository scheduling getRepositorySchedule, }); const schedulerService = createSchedulerService(probot, { redisClient, getRepositorySchedule, }); const server = express(); const gitHubWebhookPath = appConfig.webhookPath || "/api/github/webhooks"; server.use( gitHubWebhookPath, createNodeMiddleware(schedulerApp, { probot, webhooksPath: "/", }), ); server.use("/", createRouter(probot, schedulerService)); server.listen(appConfig.port, () => { log.info(`[Express] Server is running on port ${appConfig.port}`); }); Deno.addSignalListener("SIGINT", () => handleAppTermination("SIGINT")); Deno.addSignalListener("SIGTERM", () => handleAppTermination("SIGTERM")); function handleAppTermination(signal: string) { log.info(`[${signal}] Signal received: closing MongoDB connection`); disconnectMongoDB(); try { // Close Redis connection to avoid lingering connections redisClient.quit(); } catch { // ignore } log.info("[MongoDB] Connection closed due to app termination"); Deno.exit(0); } ================================================ FILE: src/processor/index.ts ================================================ import type { Job } from "bullmq"; import type { SchedulerJobData } from "@wei/probot-scheduler"; import type { Logger, Probot, ProbotOctokit } from "probot"; import logger from "@/src/utils/logger.ts"; import { getPullConfig } from "@/src/utils/get-pull-config.ts"; import { Pull } from "@/src/processor/pull.ts"; const TIMEOUT = 60 * 1000; function createTimeoutPromise(log: Logger) { return new Promise((_, reject) => { setTimeout(() => { log.warn("⏰ Job timed out after 1 minute"); reject(new Error("Job timed out after 1 minute")); }, TIMEOUT); }); } async function processRepo( octokit: ProbotOctokit, jobData: SchedulerJobData, log: Logger, ) { const { owner, repo } = jobData; const config = await getPullConfig(octokit, log, jobData); if (!config) { log.info(`⚠️ No config found, skipping`); return; } const pull = new Pull(octokit, { owner, repo, logger: log }, config); await pull.routineCheck(); } export function getRepoProcessor(probot: Probot) { return async function RepoJobProcessor(job: Job) { const log = logger.child({ jobId: job.id, jobData: job.data, }); log.info("🏃 Processing repo job"); try { const octokit = await probot.auth(job.data.installation_id); await Promise.race([ processRepo(octokit, job.data, log), createTimeoutPromise(log), ]); log.info(`✅ Repo job processed successfully`); } catch (error) { log.error(error, "❌ Repo job failed"); } }; } ================================================ FILE: src/processor/pull.ts ================================================ import { type Logger, ProbotOctokit } from "probot"; import pluralize from "@wei/pluralize"; import { type PullConfig, pullConfigSchema, type PullRule, } from "@/src/utils/schema.ts"; import type { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods"; import { appConfig } from "@/src/configs/app-config.ts"; import { logger as pullLogger } from "@/src/utils/logger.ts"; import { getPRBody, getPRTitle, timeout } from "@/src/utils/helpers.ts"; interface PullOptions { owner: string; repo: string; logger?: Logger; } type PullRequestData = RestEndpointMethodTypes["pulls"]["create"]["response"]["data"]; export class Pull { private github: ProbotOctokit; private owner: string; private repo: string; private fullName: string; private logger: Logger; private config: PullConfig; constructor( github: ProbotOctokit, { owner, repo, logger = pullLogger }: PullOptions, config: PullConfig, ) { this.github = github; this.owner = owner; this.repo = repo; this.fullName = `${owner}/${repo}`; this.logger = logger.child({ owner, repo, full_name: this.fullName, }); const result = pullConfigSchema.safeParse(config); if (!result.success) { throw new Error("Invalid config"); } this.config = result.data; } async routineCheck(): Promise { this.logger.info( { config: this.config }, `Routine Check - ${pluralize("rule", this.config.rules.length, true)}`, ); for (const rule of this.config.rules) { this.logger.debug({ rule }, `Routine Check for rule`); const { base, upstream, assignees, reviewers } = rule; const normalizedBase = base.toLowerCase(); const normalizedUpstream = upstream.toLowerCase().replace( `${this.owner.toLowerCase()}:`, "", ); if (normalizedBase === normalizedUpstream) { this.logger.debug( `${base} is same as ${upstream}`, ); continue; } if (!(await this.hasDiff(base, upstream))) { this.logger.debug( `${base} is in sync with ${upstream}`, ); continue; } const openPR = await this.getOpenPR(base, upstream); if (openPR) { this.logger.debug( `Found a PR from ${upstream} to ${base}`, ); await this.checkAutoMerge(openPR); } else { this.logger.info( `Creating PR from ${upstream} to ${base}`, ); const newPR = await this.createPR(base, upstream, assignees, reviewers); await this.checkAutoMerge(newPR); } } } private async checkAutoMerge( incomingPR: PullRequestData | null, config: { isMergeableMaxRetries?: number } = {}, ): Promise { if (!incomingPR) return false; const prNumber = incomingPR.number; this.logger.debug( `#${prNumber} Checking auto merged pull request`, ); const rule: PullRule | undefined = this.config.rules.find((r) => r.base.toLowerCase() === incomingPR.base.ref.toLowerCase() && (r.upstream.toLowerCase() === incomingPR.head.label.toLowerCase() || r.upstream.toLowerCase() === incomingPR.head.ref.toLowerCase()) ); if (!rule) { this.logger.debug( `#${prNumber} No rule found`, ); return false; } if (incomingPR.mergeable === false) { await this.handleMergeConflict(prNumber, rule); return false; } if ( rule.mergeMethod !== "none" && incomingPR.state === "open" && incomingPR.user.login === appConfig.botName ) { return await this.processMerge(prNumber, incomingPR, rule, config); } this.logger.debug( `#${prNumber} Skip processing`, ); return false; } private async handleMergeConflict( prNumber: number, rule?: PullRule, ): Promise { this.logger.debug( `#${prNumber} mergeable:false`, ); try { await this.github.issues.getLabel({ owner: this.owner, repo: this.repo, name: this.config.conflictLabel, }); } catch { await this.addLabel( this.config.conflictLabel, "ff0000", "Resolve conflicts manually", ); } await this.github.issues.update({ owner: this.owner, repo: this.repo, issue_number: prNumber, labels: [this.config.label, this.config.conflictLabel], body: getPRBody(this.fullName, prNumber), }); if (rule?.conflictReviewers?.length) { await this.addReviewers(prNumber, rule.conflictReviewers); } } private async processMerge( prNumber: number, incomingPR: PullRequestData, rule: PullRule, config: { isMergeableMaxRetries?: number }, ): Promise { const mergeableStatus = await this.getMergeableStatus( prNumber, incomingPR, rule, config, ); if (!mergeableStatus?.mergeable) return false; if (rule.mergeMethod === "hardreset") { try { this.logger.debug( `#${prNumber} Performing hard reset`, ); await this.hardResetCommit(incomingPR.base.ref, incomingPR.head.sha); this.logger.info( `#${prNumber} Hard reset successful`, ); return true; } catch (err) { this.logger.error( { err }, `#${prNumber} Hard reset failed`, ); return false; } } else if (rule.mergeMethod === "none") { this.logger.debug( `#${prNumber} Merge method is none, skip merging`, ); return true; } else { let mergeMethod = rule.mergeMethod; if (mergeMethod === "rebase" && !mergeableStatus.rebaseable) { mergeMethod = "merge"; } await this.mergePR(prNumber, mergeMethod); this.logger.info( `#${prNumber} Auto merged pull request using ${mergeMethod}`, ); return true; } } private async getMergeableStatus( prNumber: number, incomingPR: PullRequestData, rule: PullRule, config: { isMergeableMaxRetries?: number }, ) { const isInitiallyMergeable = incomingPR.mergeable && (incomingPR.mergeable_state === "clean" || (incomingPR.mergeable_state === "unstable" && rule.mergeUnstable)); if (isInitiallyMergeable) { return incomingPR; } return await this.isMergeable(prNumber, { maxRetries: config.isMergeableMaxRetries, }); } private async hasDiff(base: string, upstream: string): Promise { try { const comparison = await this.github.repos.compareCommits({ owner: this.owner, repo: this.repo, head: upstream, base, per_page: 1, }); return comparison.data.total_commits > 0; } catch (e) { if (e instanceof Error) { if (e.message.match(/this diff is taking too long to generate/i)) { return true; } else if (e.message.match(/not found/i)) { this.logger.debug( `${this.owner}:${base}...${upstream} Not found`, ); return false; } else if (e.message.match(/no common ancestor/i)) { this.logger.debug( `${this.owner}:${base}...${upstream} No common ancestor`, ); return false; } } this.logger.error( { err: e, head: upstream, }, `Unable to fetch diff`, ); return false; } } private async getOpenPR(base: string, head: string) { this.logger.debug( `Checking for open PRs from ${head} to ${base} created by ${appConfig.botName}`, ); const res = await this.github.issues.listForRepo({ owner: this.owner, repo: this.repo, creator: appConfig.botName, per_page: 100, }); if (res.data.length > 0) { this.logger.debug( `Found ${res.data.length} open ${pluralize("PR", res.data.length, true)} from ${appConfig.botName}`, ); for (const issue of res.data) { const pr = await this.github.pulls.get({ owner: this.owner, repo: this.repo, pull_number: issue.number, }); if ( pr.data.user.login === appConfig.botName && pr.data.base.label.replace(`${this.owner}:`, "") === base.replace(`${this.owner}:`, "") && pr.data.head.label.replace(`${this.owner}:`, "") === head.replace(`${this.owner}:`, "") ) { this.logger.debug( `Found open PR #${pr.data.number} from ${head} to ${base} created by ${appConfig.botName}`, ); return pr.data; } } } this.logger.debug( `No open PR found from ${head} to ${base} created by ${appConfig.botName}`, ); return null; } private async createPR( base: string, upstream: string, assignees: string[], reviewers: string[], ) { try { const createdPR = await this.github.pulls.create({ owner: this.owner, repo: this.repo, head: upstream, base, maintainer_can_modify: false, title: getPRTitle(base, upstream), body: getPRBody(this.fullName), }); const prNumber = createdPR.data.number; this.logger.debug( `#${prNumber} Created pull request`, ); try { await this.github.issues.lock({ owner: this.owner, repo: this.repo, issue_number: prNumber, }); } catch (err) { this.logger.error( { err }, `#${prNumber} Lock failed`, ); } await this.github.issues.update({ owner: this.owner, repo: this.repo, issue_number: prNumber, assignees, labels: [this.config.label], body: getPRBody(this.fullName, prNumber), }); await this.addReviewers(prNumber, reviewers); this.logger.debug( `#${prNumber} Updated pull request`, ); const pr = await this.github.pulls.get({ owner: this.owner, repo: this.repo, pull_number: prNumber, }); return pr.data; } catch (err) { this.logger.error( { err }, `Create PR from ${upstream} failed`, ); return null; } } private async isMergeable( prNumber: number, config: { maxRetries?: number } = {}, ): Promise { const maxRetries = config.maxRetries || 3; let attempts = 0; while (attempts++ < maxRetries) { const pr = await this.github.pulls.get({ owner: this.owner, repo: this.repo, pull_number: prNumber, }); this.logger.debug( `#${prNumber} Mergeability is ${pr.data.mergeable_state}`, ); if ( typeof pr.data.mergeable === "boolean" && pr.data.mergeable_state !== "unknown" ) { return pr.data.mergeable && pr.data.mergeable_state === "clean" ? pr.data : null; } // Wait a bit to see if the mergeable state changes await timeout(4500); } return null; } private async addReviewers( prNumber: number | undefined, allReviewers: string[] | undefined, ): Promise { if (!prNumber || !allReviewers?.length) return; const reviewers = allReviewers.filter((r) => !r.includes("/")); const teamReviewers = allReviewers .filter((r) => r.includes("/")) .map((r) => r.split("/")[1]); await this.github.pulls.requestReviewers({ owner: this.owner, repo: this.repo, pull_number: prNumber, team_reviewers: teamReviewers, reviewers, }); } private async addLabel( label: string | undefined, color = "ededed", description = "", ): Promise { if (!label) return; await this.github.issues.createLabel({ owner: this.owner, repo: this.repo, name: label, color, description, }); } private async mergePR( prNumber: number | undefined, mergeMethod: "merge" | "squash" | "rebase" = "merge", ): Promise { if (!prNumber) return; await this.github.pulls.merge({ owner: this.owner, repo: this.repo, pull_number: prNumber, merge_method: mergeMethod, }); } private async hardResetCommit( baseRef: string | undefined, sha: string, ): Promise { if (!baseRef || !sha) return; await this.github.git.updateRef({ owner: this.owner, repo: this.repo, ref: `heads/${baseRef}`, sha, force: true, }); } } ================================================ FILE: src/router/index.ts ================================================ import type { Request, Response } from "express"; import type { Probot } from "probot"; import express from "express"; import { createSchedulerService } from "@wei/probot-scheduler"; import { appConfig } from "@/src/configs/app-config.ts"; import getStatsHandlers from "@/src/router/stats.ts"; import getRepoHandlers from "@/src/router/repo-handler.ts"; const createRouter = ( app: Probot, schedulerService: ReturnType, ) => { const router = express.Router(); router.get("/", (_req: Request, res: Response) => { res.redirect("https://wei.github.io/pull"); }); router.get("/version", (_req: Request, res: Response) => { res.json({ name: appConfig.appName, version: appConfig.version }); }); router.get("/ping", (_req: Request, res: Response) => { res.json({ status: "pong" }); }); const { probotStatsHandler } = getStatsHandlers(app); router.get("/probot/stats", probotStatsHandler); const { checkHandler, processHandler } = getRepoHandlers( app, schedulerService, ); router.get("/check/:owner/:repo", checkHandler); router.get("/process/:owner/:repo", processHandler); router.post("/process/:owner/:repo", processHandler); return router; }; export default createRouter; ================================================ FILE: src/router/repo-handler.ts ================================================ import type { Request, Response } from "express"; import type { Probot } from "probot"; import { appConfig } from "@/src/configs/app-config.ts"; import { getPullConfig } from "@/src/utils/get-pull-config.ts"; import { createSchedulerService, JobPriority, RepositoryModel, } from "@wei/probot-scheduler"; function getRepoHandlers( app: Probot, schedulerService: ReturnType, ) { async function checkHandler(req: Request, res: Response) { const full_name = `${req.params.owner}/${req.params.repo}`; app.log.info({ full_name }, `Checking ${appConfig.configFilename}`); try { // Get Octokit const repoRecord = await RepositoryModel.findOne({ full_name }); if (!repoRecord) { app.log.error({ full_name }, `❌ Repo record not found`); throw new Error(`❌ Repo record not found`); } const { installation_id, id: repository_id, owner: { login: owner }, name: repo, } = repoRecord; const octokit = await app.auth(installation_id); const config = await getPullConfig(octokit, app.log, { installation_id, owner, repo, repository_id, metadata: { cron: "", job_priority: JobPriority.Normal, repository_id, }, }); if (!config) { return res.status(404).json({ status: "error", message: `Configuration file '${appConfig.configFilename}' not found`, }); } res.json(config); } catch (error) { app.log.error(error); res.status(500).json({ status: "error", message: error instanceof Error ? error.message : "Unknown error occurred", }); } } async function processHandler(req: Request, res: Response) { const full_name = `${req.params.owner}/${req.params.repo}`; app.log.info({ full_name }, `Processing`); try { await schedulerService.processRepository({ fullName: full_name }, true); res.json({ status: "queued" }); } catch (error) { app.log.error(error); res.status(500).json({ status: "error", message: error instanceof Error ? error.message : "Unknown error occurred", }); } } return { checkHandler, processHandler, }; } export default getRepoHandlers; ================================================ FILE: src/router/stats.ts ================================================ import type { Request, Response } from "express"; import { Probot } from "probot"; function getStatsHandlers(_app: Probot) { async function probotStatsHandler(_req: Request, res: Response) { const response = await fetch( "https://raw.githack.com/pull-app/stats/master/stats.json", ); const data = await response.json(); res.json(data); } return { probotStatsHandler, }; } export default getStatsHandlers; ================================================ FILE: src/utils/get-pull-config.ts ================================================ import type { Logger, ProbotOctokit } from "probot"; import { appConfig } from "@/src/configs/app-config.ts"; import { SchedulerJobData } from "@wei/probot-scheduler"; import { PullConfig, pullConfigSchema } from "@/src/utils/schema.ts"; import { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods"; async function getLivePullConfig( octokit: ProbotOctokit, log: Logger, jobData: SchedulerJobData, ): Promise { log.debug(`⚙️ Fetching live config`); const { owner, repo } = jobData; const { config } = await octokit.config.get({ owner, repo, path: `.github/${appConfig.configFilename}`, }); // Log config if found if (!config || !config.version) { log.warn("⚠️ No config found"); return null; } else { log.info({ config }, "⚙️ Config found"); } const result = pullConfigSchema.safeParse(config); if (!result.success) { throw new Error("Invalid config"); } return result.data; } function getDefaultPullConfig( repository: RestEndpointMethodTypes["repos"]["get"]["response"]["data"], log: Logger, ): PullConfig | null { log.debug(`⚙️ Fetching default config`); if (repository.fork && repository.parent) { const upstreamOwner = repository.parent.owner && repository.parent.owner.login; const defaultBranch = repository.parent.default_branch; if (upstreamOwner && defaultBranch) { log.debug( `Using default config ${defaultBranch}...${upstreamOwner}:${defaultBranch}`, ); const defaultConfig = { version: "1", rules: [ { base: `${defaultBranch}`, upstream: `${upstreamOwner}:${defaultBranch}`, mergeMethod: appConfig.defaultMergeMethod, mergeUnstable: true, }, ], }; const result = pullConfigSchema.safeParse(defaultConfig); if (!result.success) { throw new Error("Invalid default config"); } return result.data; } } return null; } export async function getPullConfig( octokit: ProbotOctokit, log: Logger, jobData: SchedulerJobData, ): Promise { log.info(`⚙️ Fetching config`); const { owner, repo } = jobData; const { data: repository } = await octokit.rest.repos.get({ owner, repo }); if (repository.archived) { log.debug(`⚠️ Repository is archived, skipping`); return null; // TODO Cancel scheduled job } let config = await getLivePullConfig(octokit, log, jobData); if (!config && !repository.fork) { return null; // TODO Cancel scheduled job } else if (!config) { config = getDefaultPullConfig(repository, log); } return config; } ================================================ FILE: src/utils/get-repository-schedule.ts ================================================ import { JobPriority, type RepositoryMetadataSchemaType, type RepositorySchemaType, } from "@wei/probot-scheduler"; import { getRandomCronSchedule } from "@/src/utils/helpers.ts"; // deno-lint-ignore require-await export async function getRepositorySchedule( repository: RepositorySchemaType, currentMetadata?: RepositoryMetadataSchemaType, ) { return { repository_id: repository.id, cron: currentMetadata?.cron ?? getRandomCronSchedule(), job_priority: currentMetadata?.job_priority ?? JobPriority.Normal, }; } ================================================ FILE: src/utils/helpers.ts ================================================ import { appConfig } from "@/src/configs/app-config.ts"; export const getRandomCronSchedule = () => { // Every 6 hours at a random minute const randomMinute = Math.floor(Math.random() * 60); const randomHour1 = Math.floor(Math.random() * 6); const randomHour2 = randomHour1 + 6; const randomHour3 = randomHour2 + 6; const randomHour4 = randomHour3 + 6; return `${randomMinute} ${randomHour1},${randomHour2},${randomHour3},${randomHour4} * * *`; }; export const timeout = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); export const getPRTitle = (ref: string, upstream: string): string => `[pull] ${ref} from ${upstream}`; export const getPRBody = (fullName: string, prNumber?: number): string => (prNumber ? `See [Commits](/${fullName}/pull/${prNumber}/commits) and [Changes](/${fullName}/pull/${prNumber}/files) for more details.` : `See Commits and Changes for more details.`) + `\n\n-----\nCreated by [ **pull[bot]**](https://github.com/wei/pull) (v${appConfig.version})` + "\n\n_Can you help keep this open source service alive? **[💖 Please sponsor : )](https://prod.download/pull-pr-sponsor)**_"; ================================================ FILE: src/utils/logger.ts ================================================ import { appConfig } from "@/src/configs/app-config.ts"; import { pino } from "pino"; import { getTransformStream, type LogLevel } from "@probot/pino"; export const createLogger = ( { name, logFormat = "pretty", logLevel = "info", logLevelInString = true, logMessageKey = "msg", }: { name: string; logFormat?: "json" | "pretty"; logLevel?: LogLevel | "silent"; logLevelInString?: boolean; logMessageKey?: string; }, ) => { const transform = getTransformStream({ logFormat: logFormat, logLevelInString: logLevelInString, }); transform.pipe(pino.destination(1)); const log = pino( { name, level: logLevel, messageKey: logMessageKey, }, transform, ); return log; }; export const logger = createLogger({ name: appConfig.appName, logFormat: appConfig.logFormat, logLevel: appConfig.logLevel, logLevelInString: appConfig.logLevelInString, logMessageKey: appConfig.logMessageKey, }); export default logger; ================================================ FILE: src/utils/schema.test.ts ================================================ import { PullConfig, pullConfigSchema } from "@/src/utils/schema.ts"; import { assertEquals } from "@std/assert"; // deno-fmt-ignore const validConfigs = [ { version: '1', rules: [{ base: 'master', upstream: 'upstream:master' }] }, { version: '1', rules: [{ base: 'master', upstream: 'upstream:master', autoMerge: true }], label: 'pull' }, { version: '1', rules: [{ base: 'master', upstream: 'upstream:master', autoMerge: true }], label: 'pull', conflictLabel: 'merge-conflict' }, { version: '1', rules: [{ base: 'master', upstream: 'upstream:master', autoMerge: true, assignees: ['wei'] }] }, { version: '1', rules: [{ base: 'master', upstream: 'upstream:master', autoMerge: false, reviewers: ['wei'] }] }, { version: '1', rules: [{ base: 'master', upstream: 'upstream:master', autoMerge: false, reviewers: ['wei'], conflictReviewers: ['saurabh702'] }] }, { version: '1', rules: [{ base: 'master', upstream: 'upstream:master', mergeMethod: 'squash', mergeUnstable: true }] }, { version: '1', rules: [{ base: 'master', upstream: 'upstream:master', mergeMethod: 'hardreset', assignees: ['wei'] }] }, ] as const; // deno-fmt-ignore const invalidConfigs = [ {}, { rules: {} }, { version: '' }, { version: '1' }, { version: '1', rules: [] }, { version: '1', rules: [{ base: 'master' }] }, { version: 1, rules: [{ base: 'master', upstream: 'upstream:master' }] }, { version: '1', rules: [{ base: 'master', upstream: 'upstream:master' }], label: 1 }, { version: '1', rules: [{ base: 'master', upstream: 'upstream:master' }], label: 1, conflictLabel: 2 }, { version: '1', rules: [{ base: 'master', upstream: 'upstream:master' }], label: '', conflictLabel: '' }, { version: '1', rules: [{ base: 'master', upstream: 'upstream:master' }], label: 'pull', conflictLabel: 1 }, { version: '1', rules: [{ base: 'master', upstream: 'upstream:master' }], label: 'pull', conflictLabel: '' }, { version: '1', rules: [{ base: 'master', upstream: 'upstream:master', assignees: '' }] }, { version: '1', rules: [{ base: 'master', upstream: 'upstream:master', reviewers: '' }] }, { version: '1', rules: [{ base: 'master', upstream: 'upstream:master', reviewers: '', conflictReviewers: '' }] }, { version: '1', rules: [{ base: 'master', upstream: '' }] }, { version: '1', rules: [{ base: 'master', autoMerge: 1 }] }, { version: '1', rules: [{ base: 'master', autoMerge: '' }] }, { version: '1', rules: [{ base: 'master', upstream: 'upstream:master', mergeMethod: '' }] }, { version: '1', rules: [{ base: 'master', upstream: 'upstream:master', mergeMethod: 'invalid' }] }, { version: '1', rules: [{ base: 'master', upstream: 'upstream:master', mergeMethod: true }] }, { version: '2', rules: [{ base: 'master', upstream: 'upstream:master', mergeMethod: "hardreset" }] }, ] as const; Deno.test("schema defaults", () => { const result = pullConfigSchema.parse({ version: "1", rules: [{ base: "master", upstream: "upstream:master" }], }); const expected = { version: "1", rules: [ { base: "master", upstream: "upstream:master", mergeMethod: "none", mergeUnstable: false, assignees: [], reviewers: [], conflictReviewers: [], }, ], label: ":arrow_heading_down: pull", conflictLabel: "merge-conflict", } as PullConfig; assertEquals(result, expected); }); for (const config of validConfigs) { Deno.test(`Valid config: ${JSON.stringify(config)}`, () => { const result = pullConfigSchema.safeParse(config); assertEquals( result.success, true, `Expected config to be valid, but got error: ${JSON.stringify(result)}`, ); }); } for (const config of invalidConfigs) { Deno.test(`Invalid config: ${JSON.stringify(config)}`, () => { const result = pullConfigSchema.safeParse(config); assertEquals( result.success, false, "Expected config to be invalid, but it was valid", ); }); } ================================================ FILE: src/utils/schema.ts ================================================ import { z } from "zod"; const pullMergeMethodEnum = z.enum([ "none", "merge", "squash", "rebase", "hardreset", ]); const pullRuleSchema = z.object({ base: z.string().min(1).describe("Destination local branch"), upstream: z.string().min(1).describe("Upstream owner:branch"), mergeMethod: pullMergeMethodEnum.default("none").describe( "Auto merge pull request using this merge method. one of [none, merge, squash, rebase, hardreset], Default: none", ), mergeUnstable: z.boolean().default(false).describe( "Merge pull request even when the mergeable state is not clean", ), assignees: z.array(z.string()).default([]).describe( "Assignees for the pull requests", ), reviewers: z.array(z.string()).default([]).describe( "Reviewers for the pull requests", ), conflictReviewers: z.array(z.string()).default([]).describe( "Merge Conflict Reviewers for the pull requests", ), }); const pullConfigSchema = z.object({ version: z.string().regex(/^1$/).describe( 'Version number (string), must be "1"', ), rules: z.array(pullRuleSchema).min(1).describe("Rules for pull requests"), label: z.string().min(1).default(":arrow_heading_down: pull").describe( "Label for the pull requests", ), conflictLabel: z.string().min(1).default("merge-conflict").describe( "Label for merge conflicts", ), }); // Export types derived from the schema type PullConfig = z.infer; type PullRule = z.infer; type PullMergeMethod = z.infer; export { type PullConfig, pullConfigSchema, type PullMergeMethod, type PullRule, }; ================================================ FILE: src/worker.ts ================================================ import { createSchedulerWorker } from "@wei/probot-scheduler"; import { Redis } from "ioredis"; import { appConfig } from "@/src/configs/app-config.ts"; import { getRepoProcessor } from "@/src/processor/index.ts"; import { createProbot } from "probot"; const probot = createProbot(); const RepoJobProcessor = getRepoProcessor(probot); const redisClient = new Redis(appConfig.redisConfig!, { maxRetriesPerRequest: null, name: `${appConfig.appSlug}-worker`, }); const MAX_RETAINED_JOBS = 1000; const JOB_RETENTION_SECONDS = 3600; // 1 hour const worker = createSchedulerWorker( RepoJobProcessor, { connection: redisClient, concurrency: 10, removeOnComplete: { count: MAX_RETAINED_JOBS, age: JOB_RETENTION_SECONDS, }, removeOnFail: { count: MAX_RETAINED_JOBS, age: JOB_RETENTION_SECONDS, }, }, ); worker.on("completed", (job) => { console.log(`Job ${job.id} completed successfully`); }); worker.on("failed", (job, err) => { console.error(`Job ${job?.id} failed: ${err.message}`); }); const gracefulShutdown = async (signal: string) => { console.log(`Received ${signal}, closing worker...`); await worker.close(); try { await redisClient.quit(); } catch { // ignore } Deno.exit(0); }; Deno.addSignalListener("SIGINT", () => gracefulShutdown("SIGINT")); Deno.addSignalListener("SIGTERM", () => gracefulShutdown("SIGTERM")); ================================================ FILE: static/hello.js ================================================ console.log("Hello, world!");