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