From 1cf8d0653d6e30ebc57c0774877595d7b959ffb5 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Sat, 24 Jan 2026 16:42:35 +1300 Subject: [PATCH 1/2] DAT-1076: Add test utilities module with type builders Add a comprehensive test utilities module (testutil.rs) that provides builder patterns for creating test fixtures of core types. This reduces boilerplate in tests and ensures consistent test data across the codebase. Builders included: - IssueBuilder: For creating Issue test instances with presets for Linear/Sentry - FixAttemptBuilder: For creating FixAttempt with successful/failed/merged presets - ClaudeResultBuilder: For creating ClaudeResult instances - MatchResultBuilder: For creating MatchResult instances - ActivityLogEntryBuilder: For activity log entries - ClaudeExecutionBuilder: For Claude execution records - ErrorPatternBuilder: For error pattern instances - ProcessingMetricBuilder: For processing metrics - PromptExperimentBuilder: For prompt experiments - FixAttemptStatsBuilder: For fix attempt statistics - SourceStatsBuilder: For per-source statistics - AnalyticsSummaryBuilder: For analytics summaries Each builder implements Default and provides convenient factory methods for common test scenarios (e.g., FixAttemptBuilder::successful()). Includes 20 comprehensive tests for all builders. Co-Authored-By: Claude Opus 4.5 --- .github/dependabot.yml | 49 + .github/formula/claudear.rb.template | 38 + .github/workflows/ci.yml | 136 + .github/workflows/release.yml | 302 ++ .github/workflows/security.yml | 54 + .gitignore | 14 + Cargo.lock | 4391 ++++++++++++++++++++++++++ Cargo.toml | 106 + Dockerfile | 63 + Makefile | 216 ++ README.md | 664 ++++ claudear.example.yaml | 239 ++ dashboard/Dockerfile | 34 + dashboard/Dockerfile.dev | 16 + dashboard/bun.lock | 651 ++++ dashboard/bunfig.toml | 3 + dashboard/docker/nginx.conf | 45 + dashboard/index.html | 13 + dashboard/package.json | 40 + dashboard/postcss.config.js | 6 + dashboard/src/App.tsx | 386 +++ dashboard/src/components/ui/card.tsx | 78 + dashboard/src/index.css | 59 + dashboard/src/lib/api.ts | 129 + dashboard/src/lib/utils.ts | 6 + dashboard/src/main.tsx | 10 + dashboard/tailwind.config.js | 60 + dashboard/test/api.test.ts | 242 ++ dashboard/test/setup.ts | 3 + dashboard/test/utils.test.ts | 46 + dashboard/tsconfig.json | 25 + dashboard/tsconfig.node.json | 10 + dashboard/vite.config.ts | 20 + deny.toml | 54 + docker-compose.dev.yml | 31 + docker-compose.yml | 77 + src/api/mod.rs | 88 + src/api/routes.rs | 953 ++++++ src/config.rs | 1495 +++++++++ src/discord/client.rs | 779 +++++ src/discord/mod.rs | 15 + src/discord/thread_manager.rs | 1558 +++++++++ src/discord/types.rs | 619 ++++ src/env_writer.rs | 360 +++ src/error.rs | 479 +++ src/feedback/analyzer.rs | 590 ++++ src/feedback/embeddings.rs | 554 ++++ src/feedback/mod.rs | 16 + src/feedback/outcomes.rs | 1002 ++++++ src/github.rs | 2350 ++++++++++++++ src/inference/context.rs | 540 ++++ src/inference/mod.rs | 325 ++ src/ipc/client.rs | 290 ++ src/ipc/mod.rs | 123 + src/ipc/protocol.rs | 352 +++ src/ipc/server.rs | 611 ++++ src/lib.rs | 77 + src/main.rs | 1713 ++++++++++ src/notifier/console.rs | 250 ++ src/notifier/discord.rs | 1109 +++++++ src/notifier/email.rs | 400 +++ src/notifier/mod.rs | 758 +++++ src/notifier/push.rs | 455 +++ src/notifier/sms.rs | 845 +++++ src/repo/discovery.rs | 267 ++ src/repo/git.rs | 160 + src/repo/index.rs | 502 +++ src/repo/mod.rs | 15 + src/repo/relationships.rs | 861 +++++ src/reports/generator.rs | 570 ++++ src/reports/mod.rs | 9 + src/reports/scheduler.rs | 570 ++++ src/retry.rs | 690 ++++ src/runner.rs | 990 ++++++ src/source/linear.rs | 1789 +++++++++++ src/source/mod.rs | 296 ++ src/source/sentry.rs | 2321 ++++++++++++++ src/storage/analytics.rs | 392 +++ src/storage/mod.rs | 114 + src/storage/sqlite.rs | 2924 +++++++++++++++++ src/storage/vectorlite.rs | 260 ++ src/templates/loader.rs | 323 ++ src/templates/mod.rs | 75 + src/templates/renderer.rs | 704 +++++ src/testutil.rs | 1292 ++++++++ src/types.rs | 1425 +++++++++ src/watcher.rs | 2017 ++++++++++++ src/webhook/configurator.rs | 348 ++ src/webhook/linear.rs | 774 +++++ src/webhook/linear_api.rs | 392 +++ src/webhook/mod.rs | 82 + src/webhook/sentry.rs | 691 ++++ src/webhook/sentry_api.rs | 413 +++ src/webhook/server.rs | 1563 +++++++++ 94 files changed, 49851 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/formula/claudear.rb.template create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/security.yml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 claudear.example.yaml create mode 100644 dashboard/Dockerfile create mode 100644 dashboard/Dockerfile.dev create mode 100644 dashboard/bun.lock create mode 100644 dashboard/bunfig.toml create mode 100644 dashboard/docker/nginx.conf create mode 100644 dashboard/index.html create mode 100644 dashboard/package.json create mode 100644 dashboard/postcss.config.js create mode 100644 dashboard/src/App.tsx create mode 100644 dashboard/src/components/ui/card.tsx create mode 100644 dashboard/src/index.css create mode 100644 dashboard/src/lib/api.ts create mode 100644 dashboard/src/lib/utils.ts create mode 100644 dashboard/src/main.tsx create mode 100644 dashboard/tailwind.config.js create mode 100644 dashboard/test/api.test.ts create mode 100644 dashboard/test/setup.ts create mode 100644 dashboard/test/utils.test.ts create mode 100644 dashboard/tsconfig.json create mode 100644 dashboard/tsconfig.node.json create mode 100644 dashboard/vite.config.ts create mode 100644 deny.toml create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yml create mode 100644 src/api/mod.rs create mode 100644 src/api/routes.rs create mode 100644 src/config.rs create mode 100644 src/discord/client.rs create mode 100644 src/discord/mod.rs create mode 100644 src/discord/thread_manager.rs create mode 100644 src/discord/types.rs create mode 100644 src/env_writer.rs create mode 100644 src/error.rs create mode 100644 src/feedback/analyzer.rs create mode 100644 src/feedback/embeddings.rs create mode 100644 src/feedback/mod.rs create mode 100644 src/feedback/outcomes.rs create mode 100644 src/github.rs create mode 100644 src/inference/context.rs create mode 100644 src/inference/mod.rs create mode 100644 src/ipc/client.rs create mode 100644 src/ipc/mod.rs create mode 100644 src/ipc/protocol.rs create mode 100644 src/ipc/server.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/notifier/console.rs create mode 100644 src/notifier/discord.rs create mode 100644 src/notifier/email.rs create mode 100644 src/notifier/mod.rs create mode 100644 src/notifier/push.rs create mode 100644 src/notifier/sms.rs create mode 100644 src/repo/discovery.rs create mode 100644 src/repo/git.rs create mode 100644 src/repo/index.rs create mode 100644 src/repo/mod.rs create mode 100644 src/repo/relationships.rs create mode 100644 src/reports/generator.rs create mode 100644 src/reports/mod.rs create mode 100644 src/reports/scheduler.rs create mode 100644 src/retry.rs create mode 100644 src/runner.rs create mode 100644 src/source/linear.rs create mode 100644 src/source/mod.rs create mode 100644 src/source/sentry.rs create mode 100644 src/storage/analytics.rs create mode 100644 src/storage/mod.rs create mode 100644 src/storage/sqlite.rs create mode 100644 src/storage/vectorlite.rs create mode 100644 src/templates/loader.rs create mode 100644 src/templates/mod.rs create mode 100644 src/templates/renderer.rs create mode 100644 src/testutil.rs create mode 100644 src/types.rs create mode 100644 src/watcher.rs create mode 100644 src/webhook/configurator.rs create mode 100644 src/webhook/linear.rs create mode 100644 src/webhook/linear_api.rs create mode 100644 src/webhook/mod.rs create mode 100644 src/webhook/sentry.rs create mode 100644 src/webhook/sentry_api.rs create mode 100644 src/webhook/server.rs diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..c74c61f9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,49 @@ +version: 2 +updates: + # Rust dependencies + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 10 + reviewers: + - "jakebarnby" + labels: + - "dependencies" + - "rust" + commit-message: + prefix: "chore(deps)" + groups: + rust-minor: + patterns: + - "*" + update-types: + - "minor" + - "patch" + + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "github-actions" + commit-message: + prefix: "chore(ci)" + + # Docker + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 3 + labels: + - "dependencies" + - "docker" + commit-message: + prefix: "chore(docker)" diff --git a/.github/formula/claudear.rb.template b/.github/formula/claudear.rb.template new file mode 100644 index 00000000..d0eb9530 --- /dev/null +++ b/.github/formula/claudear.rb.template @@ -0,0 +1,38 @@ +# typed: false +# frozen_string_literal: true + +class Claudear < Formula + desc "High-performance watcher service that monitors issue trackers and spawns Claude Code agents to own resolution" + homepage "https://github.com/{{REPO_OWNER}}/claudear" + version "{{VERSION}}" + license "MIT" + + on_macos do + on_intel do + url "https://github.com/{{REPO_OWNER}}/claudear/releases/download/v{{VERSION}}/claudear-macos-amd64.tar.gz" + sha256 "{{SHA256_MACOS_AMD64}}" + end + + on_arm do + url "https://github.com/{{REPO_OWNER}}/claudear/releases/download/v{{VERSION}}/claudear-macos-arm64.tar.gz" + sha256 "{{SHA256_MACOS_ARM64}}" + end + end + + on_linux do + on_intel do + url "https://github.com/{{REPO_OWNER}}/claudear/releases/download/v{{VERSION}}/claudear-linux-amd64.tar.gz" + sha256 "{{SHA256_LINUX_AMD64}}" + end + end + + def install + bin.install "claudear-macos-amd64" => "claudear" if OS.mac? && Hardware::CPU.intel? + bin.install "claudear-macos-arm64" => "claudear" if OS.mac? && Hardware::CPU.arm? + bin.install "claudear-linux-amd64" => "claudear" if OS.linux? && Hardware::CPU.intel? + end + + test do + system "#{bin}/claudear", "--version" + end +end diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..d8bd032c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,136 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: "-Dwarnings" + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-action@stable + with: + components: rustfmt, clippy + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Run tests + run: cargo test --all-features + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-action@stable + with: + components: rustfmt, clippy + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Run clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + build: + name: Build + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + - os: macos-latest + target: x86_64-apple-darwin + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-action@stable + with: + targets: ${{ matrix.target }} + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Build release + run: cargo build --release --target ${{ matrix.target }} + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: claudear-${{ matrix.target }} + path: target/${{ matrix.target }}/release/claudear + + coverage: + name: Code Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-action@stable + + - name: Install cargo-tarpaulin + run: cargo install cargo-tarpaulin + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-coverage-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-coverage- + + - name: Generate coverage report + run: cargo tarpaulin --out Xml --all-features + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..40bb25bc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,302 @@ +name: Release + +on: + push: + tags: + - 'v*' + +env: + CARGO_TERM_COLOR: always + +jobs: + create-release: + name: Create Release + runs-on: ubuntu-latest + outputs: + upload_url: ${{ steps.create_release.outputs.upload_url }} + steps: + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref_name }} + release_name: Release ${{ github.ref_name }} + draft: false + prerelease: ${{ contains(github.ref_name, '-') }} + + build-release: + name: Build Release + needs: create-release + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + asset_name: claudear-linux-amd64 + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + asset_name: claudear-linux-amd64-musl + - os: macos-latest + target: x86_64-apple-darwin + asset_name: claudear-macos-amd64 + - os: macos-latest + target: aarch64-apple-darwin + asset_name: claudear-macos-arm64 + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-action@stable + with: + targets: ${{ matrix.target }} + + - name: Install musl tools (Linux musl) + if: matrix.target == 'x86_64-unknown-linux-musl' + run: sudo apt-get install -y musl-tools + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.target }}-cargo- + + - name: Build release binary + run: cargo build --release --target ${{ matrix.target }} + + - name: Package binary + run: | + mkdir -p release + cp target/${{ matrix.target }}/release/claudear release/${{ matrix.asset_name }} + cd release + tar -czvf ${{ matrix.asset_name }}.tar.gz ${{ matrix.asset_name }} + + - name: Upload Release Asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ needs.create-release.outputs.upload_url }} + asset_path: release/${{ matrix.asset_name }}.tar.gz + asset_name: ${{ matrix.asset_name }}.tar.gz + asset_content_type: application/gzip + + publish-docker: + name: Publish Docker Image + needs: create-release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + publish-homebrew: + name: Publish to Homebrew + needs: build-release + runs-on: ubuntu-latest + if: ${{ !contains(github.ref_name, '-') }} # Skip prereleases + steps: + - uses: actions/checkout@v4 + + - name: Get version + id: version + run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT + + - name: Download release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p assets + gh release download ${{ github.ref_name }} \ + --pattern "claudear-*.tar.gz" \ + --dir assets + + - name: Calculate checksums + id: checksums + run: | + echo "sha256_macos_amd64=$(sha256sum assets/claudear-macos-amd64.tar.gz | cut -d ' ' -f 1)" >> $GITHUB_OUTPUT + echo "sha256_macos_arm64=$(sha256sum assets/claudear-macos-arm64.tar.gz | cut -d ' ' -f 1)" >> $GITHUB_OUTPUT + echo "sha256_linux_amd64=$(sha256sum assets/claudear-linux-amd64.tar.gz | cut -d ' ' -f 1)" >> $GITHUB_OUTPUT + + - name: Generate Homebrew formula + run: | + sed -e "s/{{VERSION}}/${{ steps.version.outputs.version }}/g" \ + -e "s/{{REPO_OWNER}}/${{ github.repository_owner }}/g" \ + -e "s/{{SHA256_MACOS_AMD64}}/${{ steps.checksums.outputs.sha256_macos_amd64 }}/g" \ + -e "s/{{SHA256_MACOS_ARM64}}/${{ steps.checksums.outputs.sha256_macos_arm64 }}/g" \ + -e "s/{{SHA256_LINUX_AMD64}}/${{ steps.checksums.outputs.sha256_linux_amd64 }}/g" \ + .github/formula/claudear.rb.template > claudear.rb + + - name: Push to Homebrew tap + env: + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + run: | + git clone https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/${{ github.repository_owner }}/homebrew-tap.git + cp claudear.rb homebrew-tap/Formula/ + cd homebrew-tap + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Formula/claudear.rb + git commit -m "Update claudear to ${{ steps.version.outputs.version }}" + git push + + build-deb: + name: Build Debian Package + needs: create-release + runs-on: ubuntu-latest + strategy: + matrix: + include: + - target: x86_64-unknown-linux-gnu + arch: amd64 + - target: aarch64-unknown-linux-gnu + arch: arm64 + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-action@stable + with: + targets: ${{ matrix.target }} + + - name: Install cross-compilation tools + if: matrix.target == 'aarch64-unknown-linux-gnu' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + + - name: Install cargo-deb + run: cargo install cargo-deb + + - name: Build binary + run: cargo build --release --target ${{ matrix.target }} + env: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + + - name: Build Debian package + run: cargo deb --target ${{ matrix.target }} --no-build + + - name: Upload deb to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + DEB_FILE=$(find target/${{ matrix.target }}/debian -name "*.deb" | head -1) + gh release upload ${{ github.ref_name }} "$DEB_FILE" --clobber + + publish-apt: + name: Publish to APT Repository + needs: build-deb + runs-on: ubuntu-latest + if: ${{ !contains(github.ref_name, '-') }} # Skip prereleases + steps: + - uses: actions/checkout@v4 + + - name: Install APT repository tools + run: | + sudo apt-get update + sudo apt-get install -y dpkg-dev apt-utils gnupg + + - name: Download deb packages + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p debs + gh release download ${{ github.ref_name }} \ + --pattern "*.deb" \ + --dir debs + + - name: Import GPG key + env: + APT_GPG_PRIVATE_KEY: ${{ secrets.APT_GPG_PRIVATE_KEY }} + run: | + echo "$APT_GPG_PRIVATE_KEY" | gpg --batch --import + + - name: Clone APT repository + env: + APT_REPO_TOKEN: ${{ secrets.APT_REPO_TOKEN }} + run: | + git clone https://x-access-token:${APT_REPO_TOKEN}@github.com/${{ github.repository_owner }}/apt-repo.git + + - name: Update APT repository + env: + GPG_KEY_ID: ${{ secrets.APT_GPG_KEY_ID }} + run: | + # Copy new packages + cp debs/*.deb apt-repo/pool/main/ + + cd apt-repo + + # Generate Packages file + mkdir -p dists/stable/main/binary-amd64 + mkdir -p dists/stable/main/binary-arm64 + + dpkg-scanpackages --arch amd64 pool/ > dists/stable/main/binary-amd64/Packages + dpkg-scanpackages --arch arm64 pool/ > dists/stable/main/binary-arm64/Packages + + gzip -k -f dists/stable/main/binary-amd64/Packages + gzip -k -f dists/stable/main/binary-arm64/Packages + + # Generate Release file + cd dists/stable + cat > Release << EOF + Origin: claudear + Label: claudear + Suite: stable + Codename: stable + Architectures: amd64 arm64 + Components: main + Description: Claude Watchers APT Repository + EOF + + # Add checksums + apt-ftparchive release . >> Release + + # Sign Release file + gpg --batch --yes --default-key "$GPG_KEY_ID" -abs -o Release.gpg Release + gpg --batch --yes --default-key "$GPG_KEY_ID" --clearsign -o InRelease Release + + cd ../.. + + # Commit and push + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "Update packages for ${{ github.ref_name }}" + git push diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 00000000..5867b64d --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,54 @@ +name: Security + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # Run every Monday at 9am UTC + - cron: '0 9 * * 1' + +jobs: + audit: + name: Security Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-action@stable + + - name: Install cargo-audit + run: cargo install cargo-audit + + - name: Run security audit + run: cargo audit + + deny: + name: Dependency Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-action@stable + + - name: Install cargo-deny + run: cargo install cargo-deny + + - name: Run cargo-deny + run: cargo deny check + continue-on-error: true + + dependency-review: + name: Dependency Review + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v4 + + - name: Dependency Review + uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..fbaed2f2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +node_modules/ +dist/ +.env +*.db +*.log +.DS_Store + + +# Added by cargo + +/target +.claude/plans +claudear.yaml +.idea diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..8555020c --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4391 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "ar_archive_writer" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c269894b6fe5e9d7ada0cf69b5bf847ff35bc25fc271f08e1d080fce80339a" +dependencies = [ + "object", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bitstream-io" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +dependencies = [ + "core2", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + +[[package]] +name = "clap" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "claudear" +version = "1.0.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "chrono", + "clap", + "dirs 5.0.1", + "fastembed", + "futures", + "graphql_client", + "hex", + "hmac", + "http-body-util", + "lettre", + "libc", + "mockall", + "regex-lite", + "reqwest 0.12.28", + "rusqlite", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "subtle", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "urlencoding", + "walkdir", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dary_heap" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06d2e3287df1c007e74221c49ca10a95d557349e54b3a75dc2fb14712c751f04" +dependencies = [ + "serde", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.114", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" + +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastembed" +version = "4.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04c269a76bfc6cea69553b7d040acb16c793119cebd97c756d21e08d0f075ff8" +dependencies = [ + "anyhow", + "hf-hub", + "image", + "ndarray", + "ort", + "ort-sys", + "rayon", + "serde_json", + "tokenizers", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" + +[[package]] +name = "flate2" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fragile" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "graphql-introspection-query" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2a4732cf5140bd6c082434494f785a19cfb566ab07d1382c3671f5812fed6d" +dependencies = [ + "serde", +] + +[[package]] +name = "graphql-parser" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a818c0d883d7c0801df27be910917750932be279c7bc82dc541b8769425f409" +dependencies = [ + "combine", + "thiserror 1.0.69", +] + +[[package]] +name = "graphql_client" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50cfdc7f34b7f01909d55c2dcb71d4c13cbcbb4a1605d6c8bd760d654c1144b" +dependencies = [ + "graphql_query_derive", + "reqwest 0.11.27", + "serde", + "serde_json", +] + +[[package]] +name = "graphql_client_codegen" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e27ed0c2cf0c0cc52c6bcf3b45c907f433015e580879d14005386251842fb0a" +dependencies = [ + "graphql-introspection-query", + "graphql-parser", + "heck 0.4.1", + "lazy_static", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 1.0.109", +] + +[[package]] +name = "graphql_query_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83febfa838f898cfa73dfaa7a8eb69ff3409021ac06ee94cfb3d622f6eeb1a97" +dependencies = [ + "graphql_client_codegen", + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hf-hub" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" +dependencies = [ + "dirs 6.0.0", + "http 1.4.0", + "indicatif", + "libc", + "log", + "native-tls", + "rand", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 2.0.18", + "ureq", + "windows-sys 0.60.2", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.4.0", + "hyper 1.8.1", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.1", + "system-configuration 0.6.1", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core 0.5.1", + "zune-jpeg 0.5.11", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "lettre" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "native-tls", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "socket2 0.6.1", + "tokio", + "tokio-native-tls", + "url", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", + "redox_syscall 0.7.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "macro_rules_attribute" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" +dependencies = [ + "macro_rules_attribute-proc_macro", + "paste", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mockall" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndarray" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "onig" +version = "6.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" +dependencies = [ + "bitflags 2.10.0", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ort" +version = "2.0.0-rc.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52afb44b6b0cffa9bf45e4d37e5a4935b0334a51570658e279e9e3e6cf324aa5" +dependencies = [ + "ndarray", + "ort-sys", + "tracing", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41d7757331aef2d04b9cb09b45583a59217628beaf91895b7e76187b6e8c088" +dependencies = [ + "flate2", + "pkg-config", + "sha2", + "tar", + "ureq", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags 2.10.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro2" +version = "1.0.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn 2.0.114", +] + +[[package]] +name = "psm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d11f2fedc3b7dafdc2851bc52f277377c5473d378859be234bc7ebb593144d01" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "pxfm" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand", + "rand_chacha", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools", + "rayon", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-tls 0.5.0", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-rustls", + "hyper-tls 0.6.0", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rgb" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.10.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom 7.1.3", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stacker" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1f8b29fb42aafcea4edeeb6b2f2d7ecd0d969c48b4cf0d2e64aafc471dd6e59" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys 0.5.0", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "system-configuration-sys 0.6.0", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg 0.4.21", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokenizers" +version = "0.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a620b996116a59e184c2fa2dfd8251ea34a36d0a514758c6f966386bd2e03476" +dependencies = [ + "ahash", + "aho-corasick", + "compact_str", + "dary_heap", + "derive_builder", + "esaxx-rs", + "getrandom 0.3.4", + "itertools", + "log", + "macro_rules_attribute", + "monostate", + "onig", + "paste", + "rand", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.18", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.1", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "http-range-header", + "httpdate", + "iri-string", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "native-tls", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "socks", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.5", +] + +[[package]] +name = "webpki-roots" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zmij" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2959ca473aae96a14ecedf501d20b3608d2825ba280d5adb57d651721885b0c2" +dependencies = [ + "zune-core 0.5.1", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..404cd962 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,106 @@ +[package] +name = "claudear" +version = "1.0.0" +edition = "2021" + +[[bin]] +name = "claudear" +path = "src/main.rs" + +[lib] +name = "claudear" +path = "src/lib.rs" + +[dependencies] +# Async runtime +tokio = { version = "1", features = ["full", "process", "signal"] } + +# HTTP server & client +axum = { version = "0.8", features = ["macros"] } +reqwest = { version = "0.12", features = ["json"] } +tower = { version = "0.5", features = ["limit"] } +tower-http = { version = "0.6", features = ["trace", "cors", "fs", "limit"] } + +# Database +rusqlite = { version = "0.32", features = ["bundled", "load_extension"] } + +# Embeddings +fastembed = "4" + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# CLI +clap = { version = "4", features = ["derive", "env"] } + +# Configuration +serde_yaml = "0.9" + +# Time +chrono = { version = "0.4", features = ["serde"] } + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Error handling +thiserror = "2" +anyhow = "1" + +# Crypto for webhook signatures +hmac = "0.12" +sha2 = "0.10" +hex = "0.4" +subtle = "2" + +# GraphQL for Linear +graphql_client = { version = "0.14", features = ["reqwest"] } + +# Async utilities +futures = "0.3" +tokio-stream = "0.1" +async-trait = "0.1" + +# Regex +regex-lite = "0.1" + +# File system utilities +walkdir = "2" +dirs = "5" + +# URL encoding +urlencoding = "2" + +# Email (SMTP) +lettre = { version = "0.11", default-features = false, features = ["tokio1", "tokio1-native-tls", "smtp-transport", "builder"] } + +# Process checking on Unix +[target.'cfg(target_os = "macos")'.dependencies] +libc = "0.2" + +[dev-dependencies] +tempfile = "3" +mockall = "0.13" +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +strip = true + +[package.metadata.deb] +maintainer = "Claudear Contributors" +copyright = "2024" +license-file = ["LICENSE", "0"] +extended-description = """\ +A high-performance unified watcher service that monitors issue trackers \ +(Linear, Sentry) and automatically spawns Claude Code agents to fix \ +issues and create pull requests.""" +section = "utils" +priority = "optional" +assets = [ + ["target/release/claudear", "usr/bin/", "755"], +] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..f227264f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,63 @@ +# Build stage +FROM rust:1.75-bookworm AS builder + +WORKDIR /app + +# Install dependencies +RUN apt-get update && apt-get install -y \ + pkg-config \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy manifests +COPY Cargo.toml Cargo.lock ./ + +# Create dummy src to cache dependencies +RUN mkdir src && echo "fn main() {}" > src/main.rs && echo "" > src/lib.rs + +# Build dependencies (this layer is cached) +RUN cargo build --release && rm -rf src + +# Copy source code +COPY src ./src + +# Build application +RUN touch src/main.rs src/lib.rs && cargo build --release + +# Runtime stage +FROM debian:bookworm-slim + +WORKDIR /app + +# Install runtime dependencies +RUN apt-get update && apt-get install -y \ + ca-certificates \ + libssl3 \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copy binary from builder +COPY --from=builder /app/target/release/claudear /usr/local/bin/claudear + +# Create non-root user +RUN useradd -m -u 1000 appuser + +# Create directories for data and cache +RUN mkdir -p /app/data /root/.cache/fastembed && \ + chown -R appuser:appuser /app /root/.cache + +USER appuser + +# Set environment variables +ENV PROJECT_DIR=/app/project +ENV DATA_DIR=/app/data + +# Expose ports +EXPOSE 3100 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:3100/api/health || exit 1 + +# Default command +CMD ["claudear", "dashboard"] diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..ae9ec61b --- /dev/null +++ b/Makefile @@ -0,0 +1,216 @@ +# Claudear Makefile +# ================== + +BINARY_NAME := claudear +INSTALL_PATH := /usr/local/bin +CARGO := cargo +BUN := bun + +# Detect OS for install command +UNAME_S := $(shell uname -s) +ifeq ($(UNAME_S),Darwin) + INSTALL_CMD := install -m 755 +else + INSTALL_CMD := install -Dm755 +endif + +.PHONY: all build build-release install uninstall clean test test-all lint fmt check \ + dashboard dashboard-build dashboard-dev dashboard-test \ + docker docker-build docker-up docker-down docker-logs docker-clean \ + dev run help + +# Default target +all: build + +# ============================================================================= +# CLI Targets +# ============================================================================= + +## build: Build the CLI in debug mode +build: + $(CARGO) build + +## build-release: Build the CLI in release mode (optimized) +build-release: + $(CARGO) build --release + +## install: Install the CLI to /usr/local/bin (requires sudo) +install: build-release + sudo $(INSTALL_CMD) target/release/$(BINARY_NAME) $(INSTALL_PATH)/$(BINARY_NAME) + @echo "Installed $(BINARY_NAME) to $(INSTALL_PATH)" + +## uninstall: Remove the CLI from /usr/local/bin +uninstall: + sudo rm -f $(INSTALL_PATH)/$(BINARY_NAME) + @echo "Uninstalled $(BINARY_NAME) from $(INSTALL_PATH)" + +## clean: Remove build artifacts +clean: + $(CARGO) clean + rm -rf dashboard/dist dashboard/node_modules + +## test: Run Rust tests +test: + $(CARGO) test + +## test-all: Run all tests (Rust + Dashboard) +test-all: test dashboard-test + +## lint: Run clippy linter +lint: + $(CARGO) clippy -- -D warnings + +## fmt: Format Rust code +fmt: + $(CARGO) fmt + +## fmt-check: Check Rust code formatting +fmt-check: + $(CARGO) fmt -- --check + +## check: Run all checks (format, lint, test) +check: fmt-check lint test + +# ============================================================================= +# Dashboard Targets +# ============================================================================= + +## dashboard: Install dashboard dependencies +dashboard: + cd dashboard && $(BUN) install + +## dashboard-build: Build the dashboard for production +dashboard-build: dashboard + cd dashboard && $(BUN) run build + +## dashboard-dev: Start dashboard development server +dashboard-dev: dashboard + cd dashboard && $(BUN) run dev + +## dashboard-test: Run dashboard tests +dashboard-test: dashboard + cd dashboard && $(BUN) test + +## dashboard-typecheck: Run TypeScript type checking +dashboard-typecheck: dashboard + cd dashboard && $(BUN) run typecheck + +# ============================================================================= +# Docker Targets +# ============================================================================= + +## docker: Build and start all Docker services +docker: docker-build docker-up + +## docker-build: Build Docker images +docker-build: + docker compose build + +## docker-up: Start Docker services +docker-up: + docker compose up -d + +## docker-down: Stop Docker services +docker-down: + docker compose down + +## docker-logs: Show Docker logs (follow mode) +docker-logs: + docker compose logs -f + +## docker-clean: Remove Docker containers, images, and volumes +docker-clean: + docker compose down -v --rmi local + +## docker-dev: Start development Docker environment +docker-dev: + docker compose -f docker-compose.dev.yml up --build + +# ============================================================================= +# Development Targets +# ============================================================================= + +## dev: Run the CLI in development mode with hot reload +dev: + $(CARGO) watch -x run + +## run: Run the CLI (debug build) +run: build + ./target/debug/$(BINARY_NAME) + +## run-release: Run the CLI (release build) +run-release: build-release + ./target/release/$(BINARY_NAME) + +## watch: Watch for changes and run tests +watch: + $(CARGO) watch -x test + +## doc: Generate and open documentation +doc: + $(CARGO) doc --open + +## audit: Run security audit on dependencies +audit: + $(CARGO) audit + +## outdated: Check for outdated dependencies +outdated: + $(CARGO) outdated + +## update: Update dependencies +update: + $(CARGO) update + +# ============================================================================= +# Release Targets +# ============================================================================= + +## release-deb: Build a .deb package (requires cargo-deb) +release-deb: build-release + $(CARGO) deb + +## release-all: Build release binaries for all platforms +release-all: + @echo "Building for current platform..." + $(CARGO) build --release + @echo "Note: Cross-compilation requires 'cross' or platform-specific toolchains" + +# ============================================================================= +# Database Targets +# ============================================================================= + +## db-reset: Reset the SQLite database +db-reset: + rm -f data/claudear.db + @echo "Database reset. Will be recreated on next run." + +## db-backup: Backup the SQLite database +db-backup: + @mkdir -p backups + cp data/claudear.db backups/claudear-$(shell date +%Y%m%d-%H%M%S).db + @echo "Database backed up to backups/" + +# ============================================================================= +# Help +# ============================================================================= + +## help: Show this help message +help: + @echo "Claudear - Available targets:" + @echo "" + @echo "CLI:" + @grep -E '^## [a-z]' $(MAKEFILE_LIST) | grep -E '(build|install|uninstall|clean|test|lint|fmt|check|run|dev|watch|doc|audit|outdated|update):' | \ + sed 's/## / /' | sed 's/: /\t/' | column -t -s ' ' + @echo "" + @echo "Dashboard:" + @grep -E '^## dashboard' $(MAKEFILE_LIST) | sed 's/## / /' | sed 's/: /\t/' | column -t -s ' ' + @echo "" + @echo "Docker:" + @grep -E '^## docker' $(MAKEFILE_LIST) | sed 's/## / /' | sed 's/: /\t/' | column -t -s ' ' + @echo "" + @echo "Database:" + @grep -E '^## db-' $(MAKEFILE_LIST) | sed 's/## / /' | sed 's/: /\t/' | column -t -s ' ' + @echo "" + @echo "Release:" + @grep -E '^## release' $(MAKEFILE_LIST) | sed 's/## / /' | sed 's/: /\t/' | column -t -s ' ' diff --git a/README.md b/README.md new file mode 100644 index 00000000..87bd0e9d --- /dev/null +++ b/README.md @@ -0,0 +1,664 @@ +# Claudear + +A high-performance unified watcher service written in Rust that monitors issue trackers and error monitoring services, automatically spawning Claude Code agents to fix issues and create pull requests. + +## Features + +- **Multi-Source Support**: Monitor Linear issues and Sentry errors from a single service +- **PR Merge Tracking**: Automatically track when PRs are merged and resolve issues +- **Automatic Retries**: Exponential backoff retry logic for failed attempts +- **Custom Prompt Templates**: Use AGENT.md for project-specific Claude instructions +- **Analytics Dashboard**: Real-time web UI with statistics, attempt history, and source metrics +- **Scheduled Reports**: Automated daily/weekly status reports via notifications +- **Multi-Repository Support**: Dependency tracking and cascading change detection +- **AI Feedback Loop**: Learn from past outcomes to improve future prompts +- **Multiple Notification Channels**: Discord, Email (SMTP), SMS (Twilio), Push (Pushover) +- **Webhook Support**: Real-time event processing via webhooks +- **SQLite Tracking**: Persistent tracking of fix attempts with full history +- **Priority-Based Processing**: Urgent/escalating issues are processed first +- **Rate Limiting**: Configurable concurrent processing and delays +- **Extensible Architecture**: Easy to add new sources and notifiers + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Claudear │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Linear │ │ Sentry │ │ GitHub │ ← Sources │ +│ │ Source │ │ Source │ │ (Monitor)│ (IssueSource) │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ +│ │ │ │ │ +│ └──────┬──────┴──────┬──────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌────────────────────────┐ │ +│ │ Watcher │ ← Core orchestrator │ +│ │ (polls, matches, │ │ +│ │ coordinates) │ │ +│ └───────────┬────────────┘ │ +│ │ │ +│ ┌──────────────┼──────────────────────┐ │ +│ │ │ │ │ │ +│ ▼ ▼ ▼ ▼ │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ Claude │ │ SQLite │ │Notifiers│ │ PR │ │ +│ │ Runner │ │ Tracker │ │(Discord,│ │ Monitor │ │ +│ │ │ │ │ │Email,..)│ │ │ │ +│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Installation + +### From Source + +```bash +# Clone the repository +git clone https://github.com/abnegate/claudear.git +cd claudear + +# Build the release binary +cargo build --release + +# The binary is at target/release/claudear +``` + +### Pre-built Binaries + +Download from the [releases page](https://github.com/abnegate/claudear/releases). + +### Homebrew (macOS/Linux) + +```bash +# Add the tap +brew tap abnegate/tap + +# Install +brew install claudear +``` + +### APT (Debian/Ubuntu) + +```bash +# Add GPG key +curl -fsSL https://abnegate.github.io/apt-repo/pubkey.gpg | sudo gpg --dearmor -o /usr/share/keyrings/claudear.gpg + +# Add repository +echo "deb [signed-by=/usr/share/keyrings/claudear.gpg] https://abnegate.github.io/apt-repo stable main" | sudo tee /etc/apt/sources.list.d/claudear.list + +# Install +sudo apt update +sudo apt install claudear +``` + +### Docker + +```bash +docker pull ghcr.io/abnegate/claudear:latest +``` + +## Configuration + +Claudear uses a YAML configuration file. Create your config file: + +```bash +cp claudear.example.yaml claudear.yaml +``` + +Edit `claudear.yaml` with your settings. + +### Config File Location + +By default, the tool looks for `claudear.yaml` in the current directory. You can specify a different path: + +```bash +claudear --config /path/to/config.yaml poll +``` + +### Environment Variable Overrides + +All configuration values can be overridden by environment variables. This is useful for: +- Keeping secrets out of version control +- Container deployments +- CI/CD environments + +Environment variable names follow this pattern: +- `PROJECT_DIR` for `project_dir` +- `LINEAR_API_KEY` for `linear.api_key` +- `SENTRY_AUTH_TOKEN` for `sentry.auth_token` + +### Minimal Configuration + +```yaml +project_dir: /path/to/your/project + +linear: + api_key: lin_api_xxxx +``` + +### Full Configuration Example + +See `claudear.example.yaml` for a complete configuration file with all options documented. + +### Key Configuration Sections + +| Section | Description | +|---------|-------------| +| `project_dir` | Path to the project for Claude to work on (REQUIRED) | +| `linear` | Linear integration settings (API key, trigger labels/states) | +| `sentry` | Sentry integration settings (auth token, org slug) | +| `github` | GitHub PR monitoring (token, auto-resolve on merge) | +| `discord` | Discord notification webhook | +| `email` | SMTP email notifications | +| `sms` | Twilio SMS notifications | +| `push` | Pushover push notifications | +| `retry` | Retry configuration (max retries, backoff delays) | + +## Usage + +### First-Time Setup + +```bash +# Mark all existing issues as seen (run this first!) +claudear seed +``` + +### Polling Mode + +```bash +# Poll all enabled sources (default 5 minute interval) +claudear poll + +# Poll with custom interval (in milliseconds) +claudear poll 60000 +``` + +### Webhook Mode + +```bash +# Start webhook server on default port (3100) +claudear webhook + +# Start on custom port +claudear webhook 8080 +``` + +#### Webhook Auto-Configuration + +The watcher can automatically register webhooks with Linear and Sentry using their APIs, eliminating manual webhook setup. + +**Requirements:** +- API keys for the sources you want to configure (Linear API key, Sentry auth token) + +**Usage:** + +```bash +# Auto-configure webhooks and start the server +claudear webhook --setup-webhooks --base-url https://my-server.example.com:3100 + +# Use a custom .env file for storing secrets +claudear webhook --setup-webhooks --base-url https://... --env-file /path/to/.env +``` + +**What it does:** +1. Connects to Linear/Sentry APIs using your existing API keys +2. Creates webhooks pointing to your server (`/webhook/linear`, `/webhook/sentry`) +3. Retrieves the webhook signing secrets returned by the APIs +4. Writes secrets to your `.env` file (`LINEAR_WEBHOOK_SECRET`, `SENTRY_CLIENT_SECRET`) +5. Starts the webhook server with the configured secrets + +**Example workflow:** + +```bash +# 1. Set up your .env with API keys +echo "LINEAR_API_KEY=lin_api_xxxxx" >> .env + +# 2. Run with auto-configuration +claudear webhook --setup-webhooks --base-url https://my-server.example.com:3100 + +# Output: +# === Webhook Configuration Complete === +# +# Linear: +# Status: Configured +# Webhook ID: abc123 +# Secret: lin_...xyz (saved to .env) +``` + +**Notes:** +- Linear webhooks are created at the organization level (or team-scoped if `LINEAR_TEAM_ID` is set) +- Sentry webhooks are created for each project in `SENTRY_PROJECT_SLUGS` +- If a webhook with the same URL already exists, configuration will fail (delete it manually first) +- Secrets are automatically quoted if they contain special characters + +### PR Monitoring + +```bash +# Check pending PRs once +claudear monitor-prs + +# Run continuously +claudear monitor-prs --continuous + +# List all tracked PRs +claudear list-prs +``` + +### Manual Operations + +```bash +# Trigger a Linear issue +claudear trigger linear abc123-def456 + +# Trigger a Sentry issue +claudear trigger sentry 12345678 + +# Reset a failed attempt +claudear reset sentry 12345678 + +# View statistics +claudear stats + +# List configured sources +claudear sources +``` + +### Retry Management + +```bash +# List issues eligible for retry +claudear list-retries + +# Process all ready retries now +claudear process-retries +``` + +### Dashboard + +```bash +# Start dashboard API server on default port (3100) +claudear dashboard + +# Start on custom port +claudear dashboard 8080 + +# Serve built dashboard files (full UI) +claudear dashboard --dashboard-dir ./dashboard/dist +``` + +The dashboard provides: +- Real-time statistics overview (total attempts, success rate, merge rate) +- Status breakdown (pending, success, merged, closed, failed, cannot fix) +- Source-by-source metrics +- Recent attempts list with PR links +- Retryable issues view + +API endpoints available: +- `GET /api/health` - Health check +- `GET /api/stats` - Statistics +- `GET /api/stats/overview` - Full dashboard data +- `GET /api/attempts` - List attempts (with filtering) +- `GET /api/attempts/:id` - Single attempt detail +- `GET /api/sources` - Source information +- `GET /api/retries` - Retryable issues + +### Scheduled Reports + +```bash +# Preview a report (daily, weekly, or monthly) +claudear report-preview daily +claudear report-preview weekly + +# Generate and send a report immediately +claudear report-send daily + +# Start the report scheduler (background daemon) +claudear report-scheduler --daily --hour 9 + +# Start with both daily and weekly reports +claudear report-scheduler --daily --weekly --hour 9 +``` + +Reports include: +- Issues attempted, succeeded, failed, and "cannot fix" +- Success and failure rates +- PRs created, merged, and closed +- Source-by-source breakdown +- Current pending and retryable issues + +Reports are sent via all configured notification channels (Discord, Email, SMS, Push). + +### Multi-Repository Support + +Track dependencies between repositories and understand cascading changes. + +```bash +# List tracked repositories +claudear repos-list + +# Add a repository +claudear repos-add my-app --path /path/to/my-app --github-url myorg/my-app + +# Link repositories (upstream -> downstream) +claudear repos-link my-lib my-app --dep-type npm + +# View the dependency graph +claudear repos-graph + +# View from a specific root +claudear repos-graph --root my-lib + +# See what cascades from a change +claudear repos-cascade my-lib +``` + +**Default Appwrite relationships are pre-configured:** +- `utopia-*` -> `appwrite` -> `cloud` + +When changes are made to an upstream repository, the tool can identify which downstream repositories may need updates. + +### AI Feedback Loop + +The feedback system learns from past fix attempts to improve future prompts. + +**Features:** +- Tracks outcomes of all fix attempts (merged, closed, failed) +- Extracts keywords and patterns from issues +- Finds similar past issues using text similarity +- Generates suggestions based on successful and failed attempts +- Enhances prompts with learnings from similar issues + +**Usage in code:** +```rust +use claudear::feedback::{FeedbackAnalyzer, Outcome}; + +let mut analyzer = FeedbackAnalyzer::new(); + +// Record an outcome +analyzer.record_outcome(&attempt, &issue, "prompt used", Outcome::Merged)?; + +// Add learnings +analyzer.add_learnings(outcome_id, "Check null values in API responses")?; + +// Find similar issues and get suggestions for a new issue +let similar = analyzer.find_similar(&new_issue); +let suggestions = analyzer.suggest_improvements(&new_issue); + +// Generate enhanced prompt +let enhanced_prompt = analyzer.enhance_prompt("Fix this bug", &new_issue); +``` + +### Dry Run Mode + +```bash +# See what would be processed without actually running Claude +claudear dry-run +``` + +## Fix Attempt Lifecycle + +``` +┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ +│ Pending │────>│ Success │────>│ Merged │────>│Resolved │ +└─────────┘ └─────────┘ └─────────┘ └─────────┘ + │ │ │ + │ │ ▼ + │ │ ┌─────────┐ + │ └─────────>│ Closed │──┐ + │ └─────────┘ │ + │ │ retry + ▼ │ +┌─────────┐ │ +│ Failed │<─────────────────────────────────┘ +└────┬────┘ + │ retry (max 2) + ▼ +┌───────────┐ +│Cannot Fix │ +└───────────┘ +``` + +- **Pending**: Fix attempt in progress +- **Success**: PR created successfully +- **Merged**: PR was merged, issue auto-resolved on source +- **Closed**: PR was closed without merging (triggers retry) +- **Failed**: Fix attempt failed (triggers retry) +- **Cannot Fix**: Max retries reached, issue cannot be automatically fixed + +## Custom Prompt Templates + +You can customize Claude's behavior by creating an `AGENT.md` file in your project root. This file will be prepended to all prompts sent to Claude. + +### Example AGENT.md + +```markdown +# Project Guidelines + +## Code Style +- Use TypeScript strict mode +- Follow Airbnb style guide +- Keep functions under 50 lines + +## Testing +- Write unit tests for all new functions +- Aim for 80% code coverage +- Use Jest for testing + +## PR Guidelines +- Keep PRs focused on a single issue +- Include tests with all changes +- Update documentation as needed +``` + +When the watcher detects an AGENT.md file, it will include these instructions in the prompt: + +``` +[Contents of AGENT.md] + +--- + +Fix the following Linear issue: +... +``` + +This allows you to customize Claude's behavior for your specific project without modifying the watcher code. + +## Running as a Service + +### macOS (launchd) + +Create `~/Library/LaunchAgents/com.claudear.plist`: + +```xml + + + + + Label + com.claudear + ProgramArguments + + /usr/local/bin/claudear + poll + + WorkingDirectory + /Users/YOUR_USER/Local/your-project + EnvironmentVariables + + PROJECT_DIR + /Users/YOUR_USER/Local/your-project + + RunAtLoad + + KeepAlive + + StandardOutPath + /tmp/claudear.log + StandardErrorPath + /tmp/claudear.log + + +``` + +Load it: + +```bash +launchctl load ~/Library/LaunchAgents/com.claudear.plist +``` + +### Linux (systemd) + +Create `/etc/systemd/system/claudear.service`: + +```ini +[Unit] +Description=Claudear +After=network.target + +[Service] +Type=simple +User=YOUR_USER +WorkingDirectory=/home/YOUR_USER/Local/your-project +ExecStart=/usr/local/bin/claudear poll +Environment=PROJECT_DIR=/home/YOUR_USER/Local/your-project +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +Enable and start: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable claudear +sudo systemctl start claudear +``` + +## Development + +### Building + +```bash +# Debug build +cargo build + +# Release build +cargo build --release +``` + +### Testing + +The project has 1100+ tests covering all modules. + +```bash +# Run all tests +cargo test + +# Run with output +cargo test -- --nocapture + +# Run tests for a specific module +cargo test config::tests + +# Run a specific test +cargo test test_exponential_backoff_delay +``` + +### Linting + +```bash +# Check formatting +cargo fmt --check + +# Run clippy +cargo clippy +``` + +## Docker + +### Build and Run + +```bash +# Build the Docker image +docker build -t claudear . + +# Run with docker-compose (recommended) +docker-compose up -d + +# Run standalone with config file +docker run -d \ + -p 3100:3100 \ + -v $(pwd)/claudear.yaml:/app/claudear.yaml \ + -v $(pwd):/app/project \ + -v claudear-data:/app/data \ + claudear + +# Or use environment variables to override config +docker run -d \ + -p 3100:3100 \ + -v $(pwd)/claudear.yaml:/app/claudear.yaml \ + -v $(pwd):/app/project \ + -v claudear-data:/app/data \ + -e LINEAR_API_KEY=your-key \ + claudear +``` + +### Docker Compose + +The included `docker-compose.yml` provides: +- Claudear service +- PostgreSQL with pgvector for AI similarity search +- Volume persistence for data + +```bash +# Start all services +docker-compose up -d + +# View logs +docker-compose logs -f + +# Stop services +docker-compose down +``` + +## CI/CD + +The project includes GitHub Actions workflows for: + +- **CI** (`ci.yml`): Runs on push/PR to main/develop + - Tests on Ubuntu + - Linting (rustfmt, clippy) + - Build for Linux and macOS + - Code coverage via tarpaulin + +- **Release** (`release.yml`): Runs on version tags + - Creates GitHub release + - Builds binaries for Linux, macOS (Intel & ARM) + - Publishes Docker image to GHCR + - Publishes to Homebrew tap + - Builds and publishes Debian packages to APT repository + +- **Security** (`security.yml`): Runs weekly and on main + - Cargo audit for vulnerabilities + - Cargo deny for license/dependency checks + - Dependency review for PRs + +### Creating a Release + +```bash +# Tag and push +git tag v1.0.0 +git push origin v1.0.0 +``` + +This triggers the release workflow to build and publish artifacts. + +## License + +MIT diff --git a/claudear.example.yaml b/claudear.example.yaml new file mode 100644 index 00000000..66925ad6 --- /dev/null +++ b/claudear.example.yaml @@ -0,0 +1,239 @@ +# ============================================ +# Claudear Configuration +# ============================================ +# Copy this file to claudear.yaml and fill in your values. +# Environment variables can override any setting (useful for secrets in containers). + +# ============================================ +# Core Configuration (REQUIRED) +# ============================================ + +# Working directory where repositories are cloned +work_dir: ~/.claudear/repos + +# ============================================ +# Repository Discovery (REQUIRED) +# ============================================ +# Claudear auto-discovers repositories from known organizations. +# Issues are automatically routed to the correct repository by +# inferring the target repo from file paths in stack traces and issue content. + +# GitHub organizations to track +# Repositories from these orgs will be indexed for issue-to-repo inference +known_orgs: + - utopia-php + - appwrite + - appwrite-labs + - open-runtimes + +# Paths to scan for local repository clones +# These directories are scanned to find repos matching known_orgs +auto_discover_paths: + - ~/Local + +# ============================================ +# Processing Configuration +# ============================================ + +# Polling interval in milliseconds (default: 300000 = 5 minutes) +poll_interval_ms: 300000 + +# SQLite database path (default: ./claudear.db) +db_path: ./claudear.db + +# Webhook server port (default: 3100) +webhook_port: 3100 + +# ============================================ +# Rate Limiting +# ============================================ + +# Max issues to process per poll cycle (default: 5) +max_issues_per_cycle: 5 + +# Max concurrent issue processing (default: 1) +max_concurrent: 1 + +# Delay between processing issues in ms (default: 5000) +processing_delay_ms: 5000 + +# ============================================ +# Retry Configuration +# ============================================ + +retry: + # Maximum retry attempts for failed fixes (default: 2) + max_retries: 2 + + # Base delay between retries in ms (default: 60000 = 1 minute) + # Uses exponential backoff: delay = base_delay * 2^retry_count + base_delay_ms: 60000 + + # Maximum delay between retries in ms (default: 3600000 = 1 hour) + max_delay_ms: 3600000 + +# ============================================ +# Embeddings Configuration (for similarity search) +# ============================================ +# Uses fastembed for local embedding generation - no external services required. +# Models are downloaded automatically on first use and cached locally. + +embeddings: + # Embedding model to use (default: nomic) + # Options: nomic (768d), minilm (384d, faster), bge (384d) + model: nomic + + # Custom cache directory for embedding models (optional) + # cache_dir: ~/.cache/fastembed + +# ============================================ +# Discord Notifications (Optional) +# ============================================ + +discord: + # Discord webhook URL for notifications + webhook_url: https://discord.com/api/webhooks/... + + # Discord user ID to mention in notifications + user_id: "" + +# ============================================ +# Linear Configuration +# ============================================ +# To use Linear, provide an API key from Linear Settings > API + +linear: + # Enable/disable Linear source (default: true if api_key provided) + enabled: true + + # Linear API key (REQUIRED for Linear) + api_key: lin_api_xxxx + + # Labels that trigger automation + trigger_labels: + - auto-implement + - claude + + # States that trigger automation + trigger_states: + - backlog + - todo + + # Optional: Filter by team ID + team_id: "" + + # Optional: Filter by project ID + project_id: "" + + # Optional: Webhook signature verification secret + # Set via LINEAR_WEBHOOK_SECRET env var for security + webhook_secret: "" + +# ============================================ +# Sentry Configuration +# ============================================ +# To use Sentry, provide an auth token from https://sentry.io/settings/account/api/auth-tokens/ + +sentry: + # Enable/disable Sentry source (default: true if auth_token provided) + enabled: true + + # Sentry auth token (REQUIRED for Sentry) + auth_token: "" + + # Sentry organization slug (REQUIRED if using Sentry) + org_slug: your-org + + # Optional: Filter by project slugs + project_slugs: [] + + # Number of top issues to fetch (default: 100) + top_issues_count: 100 + + # Time period for fetching top issues (default: 24h) + # Options: 1h (1 hour), 12h (12 hours), 24h (1 day), 7d (1 week), 30d (1 month) + top_issues_period: 24h + + # Minimum event count for issue to be processed (default: 10) + min_event_count: 10 + + # Percentage increase to consider issue escalating (default: 50) + escalation_threshold_percent: 50 + + # Optional: Webhook client secret for signature verification + # Set via SENTRY_CLIENT_SECRET env var for security + client_secret: "" + +# ============================================ +# GitHub PR Monitoring (Optional) +# ============================================ + +github: + # GitHub personal access token (for checking PR status) + token: ghp_xxxxxxxxxxxx + + # PR status check interval in milliseconds (default: 60000 = 1 minute) + poll_interval_ms: 60000 + + # Auto-resolve issues on Linear/Sentry when PRs merge (default: true) + auto_resolve_on_merge: true + +# ============================================ +# Email Notifications (Optional) +# ============================================ + +email: + # SMTP server host + smtp_host: smtp.gmail.com + + # SMTP server port (default: 587) + smtp_port: 587 + + # SMTP username + smtp_username: "" + + # SMTP password + smtp_password: "" + + # Sender email address + from_address: "" + + # Recipient email addresses + to_addresses: [] + + # Use TLS (default: true) + use_tls: true + +# ============================================ +# SMS Notifications via Twilio (Optional) +# ============================================ + +sms: + # Twilio Account SID + account_sid: "" + + # Twilio Auth Token + auth_token: "" + + # Twilio phone number (sender) + from_number: "" + + # Recipient phone numbers + to_numbers: [] + +# ============================================ +# Push Notifications via Pushover (Optional) +# ============================================ + +push: + # Pushover API token + api_token: "" + + # Pushover user key + user_key: "" + + # Optional: Device name (sends to all devices if empty) + device: "" + + # Optional: Priority (-2 to 2) + priority: 0 diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile new file mode 100644 index 00000000..130a8256 --- /dev/null +++ b/dashboard/Dockerfile @@ -0,0 +1,34 @@ +# Build stage +FROM oven/bun:1 AS builder + +WORKDIR /app + +# Copy package files +COPY package.json bun.lockb ./ + +# Install dependencies +RUN bun install --frozen-lockfile + +# Copy source files +COPY . . + +# Build +RUN bun run build + +# Production stage - serve with nginx +FROM nginx:alpine + +# Copy built files +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy nginx config +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf + +# Expose port +EXPOSE 80 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/dashboard/Dockerfile.dev b/dashboard/Dockerfile.dev new file mode 100644 index 00000000..2855f202 --- /dev/null +++ b/dashboard/Dockerfile.dev @@ -0,0 +1,16 @@ +# Development Dockerfile for dashboard with hot reload +FROM oven/bun:1 + +WORKDIR /app + +# Copy package files +COPY package.json bun.lockb ./ + +# Install dependencies +RUN bun install + +# Expose dev server port +EXPOSE 5173 + +# Start dev server +CMD ["bun", "run", "dev", "--host", "0.0.0.0"] diff --git a/dashboard/bun.lock b/dashboard/bun.lock new file mode 100644 index 00000000..6c18f7bf --- /dev/null +++ b/dashboard/bun.lock @@ -0,0 +1,651 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "claudear-dashboard", + "dependencies": { + "@radix-ui/react-slot": "^1.0.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "lucide-react": "^0.312.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "recharts": "^2.12.0", + "swr": "^2.2.4", + "tailwind-merge": "^2.2.0", + "tailwindcss-animate": "^1.0.7", + }, + "devDependencies": { + "@happy-dom/global-registrator": "^14.0.0", + "@testing-library/react": "^14.2.0", + "@types/bun": "latest", + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.17", + "postcss": "^8.4.33", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.3", + "vite": "^5.0.12", + }, + }, + }, + "packages": { + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + + "@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], + + "@babel/compat-data": ["@babel/compat-data@7.28.6", "", {}, "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg=="], + + "@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="], + + "@babel/generator": ["@babel/generator@7.28.6", "", { "dependencies": { "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], + + "@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + + "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="], + + "@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@14.12.3", "", { "dependencies": { "happy-dom": "^14.12.3" } }, "sha512-VL6mjnIhqD1l9zYBmdIx5Q3qNiNY0ZBByrDV2Z7hHDMsjhqwaVI2/uQZ7uyCsgRWvubSG1yZ74sEK2inDBBw/w=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.56.0", "", { "os": "android", "cpu": "arm" }, "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.56.0", "", { "os": "android", "cpu": "arm64" }, "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.56.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.56.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.56.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.56.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.56.0", "", { "os": "linux", "cpu": "arm" }, "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.56.0", "", { "os": "linux", "cpu": "arm" }, "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.56.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.56.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.56.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.56.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.56.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.56.0", "", { "os": "linux", "cpu": "x64" }, "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.56.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.56.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.56.0", "", { "os": "none", "cpu": "arm64" }, "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.56.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.56.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g=="], + + "@testing-library/dom": ["@testing-library/dom@9.3.4", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.1.3", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "pretty-format": "^27.0.2" } }, "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ=="], + + "@testing-library/react": ["@testing-library/react@14.3.1", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@testing-library/dom": "^9.0.0", "@types/react-dom": "^18.0.0" }, "peerDependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" } }, "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], + + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], + + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + + "@types/react": ["@types/react@18.3.27", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w=="], + + "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + + "aria-query": ["aria-query@5.1.3", "", { "dependencies": { "deep-equal": "^2.0.5" } }, "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ=="], + + "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], + + "autoprefixer": ["autoprefixer@10.4.23", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="], + + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.17", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ=="], + + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + + "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001765", "", {}, "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + + "deep-equal": ["deep-equal@2.2.3", "", { "dependencies": { "array-buffer-byte-length": "^1.0.0", "call-bind": "^1.0.5", "es-get-iterator": "^1.1.3", "get-intrinsic": "^1.2.2", "is-arguments": "^1.1.1", "is-array-buffer": "^3.0.2", "is-date-object": "^1.0.5", "is-regex": "^1.1.4", "is-shared-array-buffer": "^1.0.2", "isarray": "^2.0.5", "object-is": "^1.1.5", "object-keys": "^1.1.1", "object.assign": "^4.1.4", "regexp.prototype.flags": "^1.5.1", "side-channel": "^1.0.4", "which-boxed-primitive": "^1.0.2", "which-collection": "^1.0.1", "which-typed-array": "^1.1.13" } }, "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], + + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + + "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.277", "", {}, "sha512-wKXFZw4erWmmOz5N/grBoJ2XrNJGDFMu2+W5ACHza5rHtvsqrK4gb6rnLC7XxKB9WlJ+RmyQatuEXmtm86xbnw=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-get-iterator": ["es-get-iterator@1.1.3", "", { "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.3", "has-symbols": "^1.0.3", "is-arguments": "^1.1.1", "is-map": "^2.0.2", "is-set": "^2.0.2", "is-string": "^1.0.7", "isarray": "^2.0.5", "stop-iteration-iterator": "^1.0.0" } }, "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + + "fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + + "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "happy-dom": ["happy-dom@14.12.3", "", { "dependencies": { "entities": "^4.5.0", "webidl-conversions": "^7.0.0", "whatwg-mimetype": "^3.0.0" } }, "sha512-vsYlEs3E9gLwA1Hp+w3qzu+RUDFf4VTT8cyKqVICoZ2k7WM++Qyd2LwzyTi5bqMJFiIC/vNpTDYuxdreENRK/g=="], + + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + + "is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="], + + "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], + + "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], + + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + + "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], + + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + + "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + + "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], + + "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], + + "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], + + "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], + + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], + + "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], + + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + + "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "lucide-react": ["lucide-react@0.312.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, "sha512-3UZsqyswRXjW4t+nw+InICewSimjPKHuSxiFYqTshv9xkK3tPPntXk/lvXc9pKlXIxm3v9WKyoxcrB6YHhP+dg=="], + + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "object-is": ["object-is@1.1.6", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1" } }, "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], + + "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], + + "postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="], + + "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], + + "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="], + + "postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + + "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + + "react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="], + + "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], + + "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], + + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + + "recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="], + + "recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="], + + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + + "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rollup": ["rollup@4.56.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.56.0", "@rollup/rollup-android-arm64": "4.56.0", "@rollup/rollup-darwin-arm64": "4.56.0", "@rollup/rollup-darwin-x64": "4.56.0", "@rollup/rollup-freebsd-arm64": "4.56.0", "@rollup/rollup-freebsd-x64": "4.56.0", "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", "@rollup/rollup-linux-arm-musleabihf": "4.56.0", "@rollup/rollup-linux-arm64-gnu": "4.56.0", "@rollup/rollup-linux-arm64-musl": "4.56.0", "@rollup/rollup-linux-loong64-gnu": "4.56.0", "@rollup/rollup-linux-loong64-musl": "4.56.0", "@rollup/rollup-linux-ppc64-gnu": "4.56.0", "@rollup/rollup-linux-ppc64-musl": "4.56.0", "@rollup/rollup-linux-riscv64-gnu": "4.56.0", "@rollup/rollup-linux-riscv64-musl": "4.56.0", "@rollup/rollup-linux-s390x-gnu": "4.56.0", "@rollup/rollup-linux-x64-gnu": "4.56.0", "@rollup/rollup-linux-x64-musl": "4.56.0", "@rollup/rollup-openbsd-x64": "4.56.0", "@rollup/rollup-openharmony-arm64": "4.56.0", "@rollup/rollup-win32-arm64-msvc": "4.56.0", "@rollup/rollup-win32-ia32-msvc": "4.56.0", "@rollup/rollup-win32-x64-gnu": "4.56.0", "@rollup/rollup-win32-x64-msvc": "4.56.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + + "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], + + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "swr": ["swr@2.3.8", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gaCPRVoMq8WGDcWj9p4YWzCMPHzE0WNl6W8ADIx9c3JBEIdMkJGMzW+uzXvxHMltwcYACr9jP+32H8/hgwMR7w=="], + + "tailwind-merge": ["tailwind-merge@2.6.0", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="], + + "tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], + + "tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="], + + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], + + "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], + + "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], + + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + + "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], + + "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], + + "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + } +} diff --git a/dashboard/bunfig.toml b/dashboard/bunfig.toml new file mode 100644 index 00000000..980d6f32 --- /dev/null +++ b/dashboard/bunfig.toml @@ -0,0 +1,3 @@ +[test] +preload = ["./test/setup.ts"] +coverage = true diff --git a/dashboard/docker/nginx.conf b/dashboard/docker/nginx.conf new file mode 100644 index 00000000..77cbae51 --- /dev/null +++ b/dashboard/docker/nginx.conf @@ -0,0 +1,45 @@ +server { + listen 80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # SPA routing - serve index.html for all routes + location / { + try_files $uri $uri/ /index.html; + } + + # API proxy - forward to backend + location /api/ { + proxy_pass http://app:3100; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + # Webhook endpoints - forward to backend + location /webhook/ { + proxy_pass http://app:3100; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/dashboard/index.html b/dashboard/index.html new file mode 100644 index 00000000..f5d1d167 --- /dev/null +++ b/dashboard/index.html @@ -0,0 +1,13 @@ + + + + + + + Claude Watchers Dashboard + + +
+ + + diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 00000000..5faab8db --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,40 @@ +{ + "name": "claudear-dashboard", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "bun run --bun vite", + "build": "bun run tsc && bun run --bun vite build", + "preview": "bun run --bun vite preview", + "test": "bun test", + "test:watch": "bun test --watch", + "test:coverage": "bun test --coverage", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@radix-ui/react-slot": "^1.0.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "lucide-react": "^0.312.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "recharts": "^2.12.0", + "swr": "^2.2.4", + "tailwind-merge": "^2.2.0", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@happy-dom/global-registrator": "^14.0.0", + "@testing-library/react": "^14.2.0", + "@types/bun": "latest", + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.17", + "postcss": "^8.4.33", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.3", + "vite": "^5.0.12" + } +} diff --git a/dashboard/postcss.config.js b/dashboard/postcss.config.js new file mode 100644 index 00000000..2e7af2b7 --- /dev/null +++ b/dashboard/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx new file mode 100644 index 00000000..ddbca107 --- /dev/null +++ b/dashboard/src/App.tsx @@ -0,0 +1,386 @@ +import useSWR from 'swr' +import { fetchOverview, getHealth, getRetries, type Overview, type AttemptSummary, type Health, type RetriesResponse } from './lib/api' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './components/ui/card' +import { + Activity, + CheckCircle2, + XCircle, + GitMerge, + Clock, + AlertTriangle, + ExternalLink, + RefreshCw, + Server, + Database, +} from 'lucide-react' + +function App() { + const { data: overview, error: overviewError, isLoading: overviewLoading } = useSWR('overview', fetchOverview, { + refreshInterval: 10000, // Refresh every 10 seconds + }) + + const { data: health } = useSWR('health', getHealth, { + refreshInterval: 30000, // Refresh every 30 seconds + }) + + const { data: retries } = useSWR('retries', getRetries, { + refreshInterval: 10000, + }) + + if (overviewError) { + return ( +
+ + + Error + + Failed to connect to the API. Make sure the dashboard server is running. + + + + + claudear dashboard + + + +
+ ) + } + + if (overviewLoading || !overview) { + return ( +
+
Loading...
+
+ ) + } + + const { stats, success_rate, merge_rate, recent_attempts, sources } = overview + + return ( +
+
+
+
+

Claudear Dashboard

+

Monitor automated issue fixing

+
+ {health && ( +
+
+ + + {health.status === 'ok' ? 'Healthy' : 'Degraded'} + +
+
+ + + {health.database.status === 'ok' ? 'Connected' : 'Error'} + +
+ v{health.version} + Uptime: {formatUptime(health.uptime_secs)} +
+ )} +
+
+ +
+ {/* Stats Overview */} +
+ } + description="All fix attempts" + /> + } + description={`${stats.success + stats.merged} successful`} + /> + } + description={`${stats.merged} merged PRs`} + /> + } + description="In progress" + /> +
+ + {/* Status Breakdown */} +
+ + + + + + +
+ + {/* Retries Section */} + {retries && (retries.retryable.length > 0 || retries.ready.length > 0) && ( + + + + + Retries + + + {retries.ready.length} ready now · {retries.retryable.length} total retryable · Max {retries.max_retries} retries + + + + {retries.ready.length > 0 && ( +
+

Ready for Retry

+
+ {retries.ready.map((attempt) => ( + + ))} +
+
+ )} + {retries.retryable.length > 0 && retries.ready.length < retries.retryable.length && ( +
+

Waiting for Backoff

+
+ {retries.retryable + .filter(a => !retries.ready.some(r => r.id === a.id)) + .map((attempt) => ( + + ))} +
+
+ )} +
+
+ )} + +
+ {/* Sources */} + + + Sources + Issues by source + + + {sources.length === 0 ? ( +

No sources configured

+ ) : ( +
+ {sources.map((source) => ( + + ))} +
+ )} +
+
+ + {/* Recent Attempts */} + + + Recent Attempts + Latest fix attempts + + + {recent_attempts.length === 0 ? ( +

No attempts yet

+ ) : ( +
+ {recent_attempts.slice(0, 8).map((attempt) => ( + + ))} +
+ )} +
+
+
+ + {/* Per-Source Breakdown */} + {Object.keys(stats.by_source).length > 0 && ( + + + Source Breakdown + Detailed statistics by source + + +
+ + + + + + + + + + + + + + {Object.entries(stats.by_source).map(([name, sourceStats]) => ( + + + + + + + + + + ))} + +
SourceTotalSuccessFailedMergedClosedCannot Fix
{name}{sourceStats.total}{sourceStats.success}{sourceStats.failed}{sourceStats.merged}{sourceStats.closed}{sourceStats.cannot_fix}
+
+
+
+ )} +
+
+ ) +} + +function formatUptime(seconds: number): string { + if (seconds < 60) return `${seconds}s` + if (seconds < 3600) return `${Math.floor(seconds / 60)}m` + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m` + return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h` +} + +function StatsCard({ + title, + value, + icon, + description, +}: { + title: string + value: string | number + icon: React.ReactNode + description: string +}) { + return ( + + + {title} + {icon} + + +
{value}
+

{description}

+
+
+ ) +} + +function StatusCard({ status, count }: { status: string; count: number }) { + const config: Record = { + pending: { color: 'text-yellow-500', icon: }, + success: { color: 'text-green-500', icon: }, + merged: { color: 'text-purple-500', icon: }, + closed: { color: 'text-orange-500', icon: }, + failed: { color: 'text-red-500', icon: }, + cannot_fix: { color: 'text-gray-500', icon: }, + } + + const { color, icon } = config[status] || { color: 'text-gray-500', icon: null } + + return ( + + +
+ + {status.replace('_', ' ')} + + {icon} +
+
{count}
+
+
+ ) +} + +function SourceRow({ source }: { source: { name: string; total: number; success: number; failed: number; merged: number; success_rate: number } }) { + return ( +
+
+

{source.name}

+

+ {source.total} total · {source.merged} merged +

+
+
+

{source.success_rate.toFixed(1)}%

+

success

+
+
+ ) +} + +function AttemptRow({ attempt }: { attempt: AttemptSummary }) { + const statusColors: Record = { + pending: 'bg-yellow-100 text-yellow-800', + success: 'bg-green-100 text-green-800', + merged: 'bg-purple-100 text-purple-800', + closed: 'bg-orange-100 text-orange-800', + failed: 'bg-red-100 text-red-800', + cannot_fix: 'bg-gray-100 text-gray-800', + } + + const statusColor = statusColors[attempt.status] || 'bg-gray-100 text-gray-800' + const date = new Date(attempt.attempted_at) + const timeAgo = getTimeAgo(date) + + return ( +
+
+ + {attempt.status.replace('_', ' ')} + +
+

{attempt.short_id}

+
+ {attempt.source} + {attempt.retry_count > 0 && ( + + + {attempt.retry_count} + + )} +
+
+
+
+ {timeAgo} + {attempt.pr_url && ( + + + + )} +
+
+ ) +} + +function getTimeAgo(date: Date): string { + const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000) + + if (seconds < 60) return 'just now' + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago` + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago` + return `${Math.floor(seconds / 86400)}d ago` +} + +export default App diff --git a/dashboard/src/components/ui/card.tsx b/dashboard/src/components/ui/card.tsx new file mode 100644 index 00000000..c1da9bee --- /dev/null +++ b/dashboard/src/components/ui/card.tsx @@ -0,0 +1,78 @@ +import * as React from "react" +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/dashboard/src/index.css b/dashboard/src/index.css new file mode 100644 index 00000000..00b08e39 --- /dev/null +++ b/dashboard/src/index.css @@ -0,0 +1,59 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 221.2 83.2% 53.3%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 221.2 83.2% 53.3%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 224.3 76.3% 48%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts new file mode 100644 index 00000000..cb021280 --- /dev/null +++ b/dashboard/src/lib/api.ts @@ -0,0 +1,129 @@ +const API_BASE = '/api'; + +export interface Stats { + total: number; + pending: number; + success: number; + failed: number; + merged: number; + closed: number; + cannot_fix: number; + by_source: Record; +} + +export interface SourceStats { + total: number; + success: number; + failed: number; + merged: number; + closed: number; + cannot_fix: number; +} + +export interface AttemptSummary { + id: number; + source: string; + short_id: string; + title: string; + status: string; + pr_url: string | null; + attempted_at: string; + retry_count: number; +} + +export interface SourceSummary { + name: string; + total: number; + success: number; + failed: number; + merged: number; + success_rate: number; +} + +export interface Overview { + stats: Stats; + success_rate: number; + merge_rate: number; + recent_attempts: AttemptSummary[]; + sources: SourceSummary[]; +} + +export interface AttemptsResponse { + attempts: AttemptSummary[]; + total: number; + page: number; + per_page: number; +} + +export async function fetchOverview(): Promise { + const res = await fetch(`${API_BASE}/stats/overview`); + if (!res.ok) throw new Error('Failed to fetch overview'); + return res.json(); +} + +export async function getStats(): Promise { + const res = await fetch(`${API_BASE}/stats`); + if (!res.ok) throw new Error('Failed to fetch stats'); + return res.json(); +} + +export async function fetchAttempts(params?: { + status?: string; + source?: string; + page?: number; + per_page?: number; +}): Promise { + const searchParams = new URLSearchParams(); + if (params?.status) searchParams.set('status', params.status); + if (params?.source) searchParams.set('source', params.source); + if (params?.page) searchParams.set('page', params.page.toString()); + if (params?.per_page) searchParams.set('per_page', params.per_page.toString()); + + const res = await fetch(`${API_BASE}/attempts?${searchParams}`); + if (!res.ok) throw new Error('Failed to fetch attempts'); + return res.json(); +} + +export interface Health { + status: string; + version: string; + uptime_secs: number; + database: { + status: string; + error?: string; + }; +} + +export async function getHealth(): Promise { + const res = await fetch(`${API_BASE}/health`); + if (!res.ok) throw new Error('Failed to fetch health'); + return res.json(); +} + +export interface RetriesResponse { + retryable: AttemptSummary[]; + ready: AttemptSummary[]; + max_retries: number; +} + +export async function getRetries(): Promise { + const res = await fetch(`${API_BASE}/retries`); + if (!res.ok) throw new Error('Failed to fetch retries'); + return res.json(); +} + +export interface SourceInfo { + name: string; + enabled: boolean; + config: Record; +} + +export interface SourcesResponse { + sources: SourceInfo[]; +} + +export async function getSources(): Promise { + const res = await fetch(`${API_BASE}/sources`); + if (!res.ok) throw new Error('Failed to fetch sources'); + return res.json(); +} diff --git a/dashboard/src/lib/utils.ts b/dashboard/src/lib/utils.ts new file mode 100644 index 00000000..d084ccad --- /dev/null +++ b/dashboard/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx new file mode 100644 index 00000000..964aeb4c --- /dev/null +++ b/dashboard/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/dashboard/tailwind.config.js b/dashboard/tailwind.config.js new file mode 100644 index 00000000..cd07f402 --- /dev/null +++ b/dashboard/tailwind.config.js @@ -0,0 +1,60 @@ +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: ["class"], + content: [ + './index.html', + './src/**/*.{ts,tsx}', + ], + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + }, + }, + plugins: [require("tailwindcss-animate")], +} diff --git a/dashboard/test/api.test.ts b/dashboard/test/api.test.ts new file mode 100644 index 00000000..1c17d665 --- /dev/null +++ b/dashboard/test/api.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test"; +import { + fetchOverview, + fetchStats, + fetchAttempts, + fetchHealth, + type Overview, + type Stats, + type AttemptsResponse, +} from "../src/lib/api"; + +describe("api", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + // Reset fetch mock before each test + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + describe("fetchOverview", () => { + test("fetches overview data successfully", async () => { + const mockOverview: Overview = { + stats: { + total: 100, + pending: 5, + success: 50, + failed: 10, + merged: 30, + closed: 3, + cannot_fix: 2, + by_source: {}, + }, + success_rate: 80, + merge_rate: 30, + recent_attempts: [], + sources: [], + }; + + globalThis.fetch = mock(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockOverview), + } as Response) + ); + + const result = await fetchOverview(); + expect(result).toEqual(mockOverview); + expect(fetch).toHaveBeenCalledWith("/api/stats/overview"); + }); + + test("throws on non-ok response", async () => { + globalThis.fetch = mock(() => + Promise.resolve({ + ok: false, + } as Response) + ); + + expect(fetchOverview()).rejects.toThrow("Failed to fetch overview"); + }); + }); + + describe("fetchStats", () => { + test("fetches stats successfully", async () => { + const mockStats: Stats = { + total: 100, + pending: 5, + success: 50, + failed: 10, + merged: 30, + closed: 3, + cannot_fix: 2, + by_source: {}, + }; + + globalThis.fetch = mock(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockStats), + } as Response) + ); + + const result = await fetchStats(); + expect(result).toEqual(mockStats); + expect(fetch).toHaveBeenCalledWith("/api/stats"); + }); + + test("throws on error", async () => { + globalThis.fetch = mock(() => + Promise.resolve({ + ok: false, + } as Response) + ); + + expect(fetchStats()).rejects.toThrow("Failed to fetch stats"); + }); + }); + + describe("fetchAttempts", () => { + test("fetches attempts without params", async () => { + const mockResponse: AttemptsResponse = { + attempts: [], + total: 0, + page: 1, + per_page: 20, + }; + + globalThis.fetch = mock(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockResponse), + } as Response) + ); + + const result = await fetchAttempts(); + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith("/api/attempts?"); + }); + + test("fetches attempts with status filter", async () => { + const mockResponse: AttemptsResponse = { + attempts: [], + total: 0, + page: 1, + per_page: 20, + }; + + globalThis.fetch = mock(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockResponse), + } as Response) + ); + + await fetchAttempts({ status: "pending" }); + expect(fetch).toHaveBeenCalledWith("/api/attempts?status=pending"); + }); + + test("fetches attempts with source filter", async () => { + const mockResponse: AttemptsResponse = { + attempts: [], + total: 0, + page: 1, + per_page: 20, + }; + + globalThis.fetch = mock(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockResponse), + } as Response) + ); + + await fetchAttempts({ source: "linear" }); + expect(fetch).toHaveBeenCalledWith("/api/attempts?source=linear"); + }); + + test("fetches attempts with pagination", async () => { + const mockResponse: AttemptsResponse = { + attempts: [], + total: 100, + page: 2, + per_page: 10, + }; + + globalThis.fetch = mock(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockResponse), + } as Response) + ); + + await fetchAttempts({ page: 2, per_page: 10 }); + expect(fetch).toHaveBeenCalledWith("/api/attempts?page=2&per_page=10"); + }); + + test("fetches attempts with all params", async () => { + const mockResponse: AttemptsResponse = { + attempts: [], + total: 50, + page: 1, + per_page: 25, + }; + + globalThis.fetch = mock(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockResponse), + } as Response) + ); + + await fetchAttempts({ + status: "success", + source: "sentry", + page: 1, + per_page: 25, + }); + expect(fetch).toHaveBeenCalledWith( + "/api/attempts?status=success&source=sentry&page=1&per_page=25" + ); + }); + + test("throws on error", async () => { + globalThis.fetch = mock(() => + Promise.resolve({ + ok: false, + } as Response) + ); + + expect(fetchAttempts()).rejects.toThrow("Failed to fetch attempts"); + }); + }); + + describe("fetchHealth", () => { + test("fetches health status", async () => { + const mockHealth = { status: "ok", version: "1.0.0" }; + + globalThis.fetch = mock(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockHealth), + } as Response) + ); + + const result = await fetchHealth(); + expect(result).toEqual(mockHealth); + expect(fetch).toHaveBeenCalledWith("/api/health"); + }); + + test("throws on error", async () => { + globalThis.fetch = mock(() => + Promise.resolve({ + ok: false, + } as Response) + ); + + expect(fetchHealth()).rejects.toThrow("Failed to fetch health"); + }); + }); +}); diff --git a/dashboard/test/setup.ts b/dashboard/test/setup.ts new file mode 100644 index 00000000..7f712d02 --- /dev/null +++ b/dashboard/test/setup.ts @@ -0,0 +1,3 @@ +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +GlobalRegistrator.register(); diff --git a/dashboard/test/utils.test.ts b/dashboard/test/utils.test.ts new file mode 100644 index 00000000..17e4fc94 --- /dev/null +++ b/dashboard/test/utils.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { cn } from "../src/lib/utils"; + +describe("utils", () => { + describe("cn", () => { + test("merges class names", () => { + expect(cn("foo", "bar")).toBe("foo bar"); + }); + + test("handles conditional classes", () => { + expect(cn("foo", false && "bar", "baz")).toBe("foo baz"); + }); + + test("handles undefined values", () => { + expect(cn("foo", undefined, "bar")).toBe("foo bar"); + }); + + test("handles null values", () => { + expect(cn("foo", null, "bar")).toBe("foo bar"); + }); + + test("merges tailwind classes correctly", () => { + expect(cn("p-4", "p-2")).toBe("p-2"); + }); + + test("handles complex tailwind merges", () => { + expect(cn("px-4 py-2", "px-2")).toBe("py-2 px-2"); + }); + + test("handles array of classes", () => { + expect(cn(["foo", "bar"])).toBe("foo bar"); + }); + + test("handles object syntax", () => { + expect(cn({ foo: true, bar: false, baz: true })).toBe("foo baz"); + }); + + test("handles empty strings", () => { + expect(cn("", "foo", "")).toBe("foo"); + }); + + test("handles whitespace strings", () => { + expect(cn(" ", "foo", " ")).toBe("foo"); + }); + }); +}); diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json new file mode 100644 index 00000000..c20738e2 --- /dev/null +++ b/dashboard/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/dashboard/tsconfig.node.json b/dashboard/tsconfig.node.json new file mode 100644 index 00000000..42872c59 --- /dev/null +++ b/dashboard/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts new file mode 100644 index 00000000..1dfaad27 --- /dev/null +++ b/dashboard/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + proxy: { + '/api': { + target: 'http://localhost:3100', + changeOrigin: true, + }, + }, + }, +}) diff --git a/deny.toml b/deny.toml new file mode 100644 index 00000000..280c785f --- /dev/null +++ b/deny.toml @@ -0,0 +1,54 @@ +# Configuration for cargo-deny +# https://embarkstudios.github.io/cargo-deny/ + +[advisories] +vulnerability = "deny" +unmaintained = "warn" +yanked = "deny" +notice = "warn" +ignore = [] + +[licenses] +unlicensed = "deny" +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "CC0-1.0", + "Zlib", + "Unicode-DFS-2016", +] +copyleft = "warn" +allow-osi-fsf-free = "either" +default = "deny" +confidence-threshold = 0.8 + +[[licenses.clarify]] +name = "ring" +expression = "MIT AND ISC AND OpenSSL" +license-files = [ + { path = "LICENSE", hash = 0xbd0eed23 } +] + +[bans] +multiple-versions = "warn" +wildcards = "allow" +highlight = "all" +workspace-default-features = "allow" +external-default-features = "allow" + +deny = [] +skip = [] +skip-tree = [] + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] + +[sources.allow-org] +github = [] diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..e1bdbd01 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,31 @@ +# Development overrides +# Usage: docker compose -f docker-compose.yml -f docker-compose.dev.yml up + +services: + app: + build: + context: . + dockerfile: Dockerfile + target: builder + command: cargo watch -x "run -- dashboard" + volumes: + - .:/app + - cargo_cache:/usr/local/cargo/registry + - target_cache:/app/target + environment: + RUST_LOG: debug + RUST_BACKTRACE: 1 + + dashboard: + build: + context: ./dashboard + dockerfile: Dockerfile.dev + volumes: + - ./dashboard:/app + - /app/node_modules + environment: + VITE_API_URL: http://localhost:3100 + +volumes: + cargo_cache: + target_cache: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..a6ce27af --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,77 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + container_name: claudear-app + restart: unless-stopped + environment: + # Data directory (SQLite database stored here) + DATA_DIR: /app/data + + # Project directory (mounted) + PROJECT_DIR: /app/project + + # Webhook port + WEBHOOK_PORT: 3100 + + # Embedding model (default: nomic, options: nomic, minilm, bge) + EMBEDDING_MODEL: ${EMBEDDING_MODEL:-nomic} + + # Source configurations (from .env) + LINEAR_API_KEY: ${LINEAR_API_KEY:-} + LINEAR_ENABLED: ${LINEAR_ENABLED:-true} + LINEAR_TRIGGER_LABELS: ${LINEAR_TRIGGER_LABELS:-auto-implement,claude} + LINEAR_TRIGGER_STATES: ${LINEAR_TRIGGER_STATES:-backlog,todo} + LINEAR_WEBHOOK_SECRET: ${LINEAR_WEBHOOK_SECRET:-} + + SENTRY_AUTH_TOKEN: ${SENTRY_AUTH_TOKEN:-} + SENTRY_ORG_SLUG: ${SENTRY_ORG_SLUG:-} + SENTRY_ENABLED: ${SENTRY_ENABLED:-true} + SENTRY_CLIENT_SECRET: ${SENTRY_CLIENT_SECRET:-} + + GITHUB_TOKEN: ${GITHUB_TOKEN:-} + GITHUB_AUTO_RESOLVE_ON_MERGE: ${GITHUB_AUTO_RESOLVE_ON_MERGE:-true} + + # Notifications + DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL:-} + DISCORD_USER_ID: ${DISCORD_USER_ID:-} + DISCORD_BOT_TOKEN: ${DISCORD_BOT_TOKEN:-} + DISCORD_CHANNEL_ID: ${DISCORD_CHANNEL_ID:-} + + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USERNAME: ${SMTP_USERNAME:-} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} + EMAIL_FROM: ${EMAIL_FROM:-} + EMAIL_TO: ${EMAIL_TO:-} + volumes: + - ./data:/app/data + - ${PROJECT_DIR:-.}:/app/project:ro + - ./claudear.yaml:/app/claudear.yaml:ro + # Cache embedding models between container restarts + - embedding_cache:/root/.cache/fastembed + ports: + - "${APP_PORT:-3100}:3100" + networks: + - edge + + dashboard: + build: + context: ./dashboard + dockerfile: Dockerfile + container_name: claudear-dashboard + restart: unless-stopped + depends_on: + - app + ports: + - "${DASHBOARD_PORT:-8080}:80" + networks: + - edge + +networks: + edge: + driver: bridge + +volumes: + embedding_cache: diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 00000000..6a1a2686 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,88 @@ +//! Dashboard API endpoints. +//! +//! Provides REST API for the analytics dashboard. + +mod routes; + +pub use routes::{create_api_router, create_api_router_with_dashboard}; + +use crate::config::Config; +use crate::error::Result; +use crate::storage::FixAttemptTracker; +use std::path::PathBuf; +use std::sync::Arc; +use tower_http::cors::{Any, CorsLayer}; + +/// API server configuration. +pub struct ApiServer { + config: Config, + tracker: Arc, + port: u16, + dashboard_dir: Option, +} + +impl ApiServer { + /// Create a new API server. + pub fn new(config: Config, tracker: Arc) -> Self { + let port = config.webhook_port; // Reuse webhook port for now + Self { + config, + tracker, + port, + dashboard_dir: None, + } + } + + /// Create a new API server with custom port. + pub fn with_port(config: Config, tracker: Arc, port: u16) -> Self { + Self { + config, + tracker, + port, + dashboard_dir: None, + } + } + + /// Create a new API server with dashboard directory. + pub fn with_dashboard( + config: Config, + tracker: Arc, + port: u16, + dashboard_dir: PathBuf, + ) -> Self { + Self { + config, + tracker, + port, + dashboard_dir: Some(dashboard_dir), + } + } + + /// Start the API server. + pub async fn start(self) -> Result<()> { + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + let app = create_api_router_with_dashboard( + self.config.clone(), + self.tracker.clone(), + self.dashboard_dir.clone(), + ) + .layer(cors); + + let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", self.port)).await?; + + tracing::info!("Dashboard API server listening on port {}", self.port); + if self.dashboard_dir.is_some() { + tracing::info!("Dashboard available at http://localhost:{}", self.port); + } else { + tracing::info!("API only mode - serve dashboard separately or provide --dashboard-dir"); + } + + axum::serve(listener, app).await?; + + Ok(()) + } +} diff --git a/src/api/routes.rs b/src/api/routes.rs new file mode 100644 index 00000000..6a8b7852 --- /dev/null +++ b/src/api/routes.rs @@ -0,0 +1,953 @@ +//! API route handlers for the dashboard. + +use crate::config::Config; +use crate::storage::FixAttemptTracker; +use crate::types::{FixAttempt, FixAttemptStats, FixAttemptStatus}; +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + routing::get, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Instant; +use tower_http::services::{ServeDir, ServeFile}; + +/// Shared state for API handlers. +#[derive(Clone)] +pub struct ApiState { + pub config: Config, + pub tracker: Arc, + /// Instant when the server started, for uptime calculation. + pub start_time: Instant, +} + +/// Create the API router. +pub fn create_api_router(config: Config, tracker: Arc) -> Router { + create_api_router_with_dashboard(config, tracker, None) +} + +/// Create the API router with optional dashboard static file serving. +pub fn create_api_router_with_dashboard( + config: Config, + tracker: Arc, + dashboard_dir: Option, +) -> Router { + let state = ApiState { + config, + tracker, + start_time: Instant::now(), + }; + + let api_routes = Router::new() + .route("/api/health", get(health_handler)) + .route("/api/stats", get(stats_handler)) + .route("/api/stats/overview", get(overview_handler)) + .route("/api/attempts", get(attempts_handler)) + .route("/api/attempts/{id}", get(attempt_detail_handler)) + .route("/api/sources", get(sources_handler)) + .route("/api/retries", get(retries_handler)) + .with_state(state); + + // If dashboard directory is provided, serve static files + if let Some(dashboard_path) = dashboard_dir { + let index_file = dashboard_path.join("index.html"); + let serve_dir = + ServeDir::new(&dashboard_path).not_found_service(ServeFile::new(&index_file)); + + api_routes.fallback_service(serve_dir) + } else { + api_routes + } +} + +#[derive(Serialize)] +struct HealthResponse { + status: String, + version: String, + uptime_secs: u64, + database: DatabaseStatus, +} + +#[derive(Serialize)] +struct DatabaseStatus { + status: String, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Serialize)] +struct OverviewResponse { + stats: FixAttemptStats, + success_rate: f64, + merge_rate: f64, + recent_attempts: Vec, + sources: Vec, +} + +#[derive(Serialize, Clone)] +struct AttemptSummary { + id: i64, + source: String, + short_id: String, + title: String, + status: String, + pr_url: Option, + attempted_at: String, + retry_count: u32, +} + +#[derive(Serialize)] +struct SourceSummary { + name: String, + total: usize, + success: usize, + failed: usize, + merged: usize, + success_rate: f64, +} + +#[derive(Serialize)] +struct AttemptsResponse { + attempts: Vec, + total: usize, + page: usize, + per_page: usize, +} + +#[derive(Serialize)] +struct SourcesResponse { + sources: Vec, +} + +#[derive(Serialize)] +struct SourceInfo { + name: String, + enabled: bool, + config: serde_json::Value, +} + +#[derive(Serialize)] +struct RetriesResponse { + retryable: Vec, + ready: Vec, + max_retries: u32, +} + +#[derive(Deserialize)] +struct AttemptsQuery { + status: Option, + source: Option, + page: Option, + per_page: Option, +} + +async fn health_handler(State(state): State) -> Json { + let uptime_secs = state.start_time.elapsed().as_secs(); + + // Check database connectivity by attempting to get stats + let database = match state.tracker.get_stats() { + Ok(_) => DatabaseStatus { + status: "ok".to_string(), + error: None, + }, + Err(e) => DatabaseStatus { + status: "error".to_string(), + error: Some(e.to_string()), + }, + }; + + let overall_status = if database.status == "ok" { + "ok" + } else { + "degraded" + }; + + Json(HealthResponse { + status: overall_status.to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + uptime_secs, + database, + }) +} + +async fn stats_handler(State(state): State) -> Result, StatusCode> { + state + .tracker + .get_stats() + .map(Json) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +async fn overview_handler( + State(state): State, +) -> Result, StatusCode> { + let stats = state + .tracker + .get_stats() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Calculate rates + let completed = stats.merged + stats.closed + stats.failed + stats.cannot_fix; + let success_rate = if stats.total > 0 { + (stats.success + stats.merged) as f64 / stats.total as f64 * 100.0 + } else { + 0.0 + }; + let merge_rate = if completed > 0 { + stats.merged as f64 / completed as f64 * 100.0 + } else { + 0.0 + }; + + // Get recent attempts (last 10) + let recent = get_attempts(&state.tracker, Some(10)); + + // Build source summaries + let sources: Vec = stats + .by_source + .iter() + .map(|(name, s)| { + let rate = if s.total > 0 { + (s.success + s.merged) as f64 / s.total as f64 * 100.0 + } else { + 0.0 + }; + SourceSummary { + name: name.clone(), + total: s.total, + success: s.success, + failed: s.failed, + merged: s.merged, + success_rate: rate, + } + }) + .collect(); + + Ok(Json(OverviewResponse { + stats, + success_rate, + merge_rate, + recent_attempts: recent, + sources, + })) +} + +async fn attempts_handler( + State(state): State, + Query(query): Query, +) -> Result, StatusCode> { + let page = query.page.unwrap_or(1); + let per_page = query.per_page.unwrap_or(20).min(100); + + // Get all attempts and filter + let all_attempts = get_attempts(&state.tracker, None); + + let filtered: Vec = all_attempts + .into_iter() + .filter(|a| { + let status_match = query + .status + .as_ref() + .map(|s| a.status.to_lowercase() == s.to_lowercase()) + .unwrap_or(true); + let source_match = query + .source + .as_ref() + .map(|s| a.source.to_lowercase() == s.to_lowercase()) + .unwrap_or(true); + status_match && source_match + }) + .collect(); + + let total = filtered.len(); + let start = (page - 1) * per_page; + let attempts: Vec = filtered.into_iter().skip(start).take(per_page).collect(); + + Ok(Json(AttemptsResponse { + attempts, + total, + page, + per_page, + })) +} + +async fn attempt_detail_handler( + State(state): State, + Path(id): Path, +) -> Result, StatusCode> { + // We need to find the attempt by ID across all statuses + for status in [ + FixAttemptStatus::Pending, + FixAttemptStatus::Success, + FixAttemptStatus::Failed, + FixAttemptStatus::Merged, + FixAttemptStatus::Closed, + FixAttemptStatus::CannotFix, + ] { + if let Ok(attempts) = state.tracker.get_attempts_by_status(status) { + if let Some(attempt) = attempts.into_iter().find(|a| a.id == id) { + return Ok(Json(attempt)); + } + } + } + + Err(StatusCode::NOT_FOUND) +} + +async fn sources_handler(State(state): State) -> Json { + let mut sources = Vec::new(); + + if let Some(ref linear) = state.config.linear { + sources.push(SourceInfo { + name: "linear".to_string(), + enabled: linear.enabled, + config: serde_json::json!({ + "trigger_labels": linear.trigger_labels, + "trigger_states": linear.trigger_states, + "has_webhook_secret": linear.webhook_secret.is_some(), + }), + }); + } + + if let Some(ref sentry) = state.config.sentry { + sources.push(SourceInfo { + name: "sentry".to_string(), + enabled: sentry.enabled, + config: serde_json::json!({ + "org_slug": sentry.org_slug, + "project_slugs": sentry.project_slugs, + "min_event_count": sentry.min_event_count, + "has_client_secret": sentry.client_secret.is_some(), + }), + }); + } + + Json(SourcesResponse { sources }) +} + +async fn retries_handler( + State(state): State, +) -> Result, StatusCode> { + use crate::retry::RetryManager; + + let max_retries = state.config.retry.max_retries; + + let retryable = state + .tracker + .get_retryable_issues(max_retries) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Create RetryManager to compute which attempts are ready for retry + let retry_manager = RetryManager::new(state.config.retry.clone(), state.tracker.clone()); + + let retryable_summaries: Vec = + retryable.iter().map(attempt_to_summary).collect(); + + // Filter to find attempts that are ready for retry now + let ready_summaries: Vec = retryable + .iter() + .filter(|a| retry_manager.is_ready_for_retry(a)) + .map(attempt_to_summary) + .collect(); + + Ok(Json(RetriesResponse { + retryable: retryable_summaries, + ready: ready_summaries, + max_retries, + })) +} + +fn attempt_to_summary(attempt: &FixAttempt) -> AttemptSummary { + AttemptSummary { + id: attempt.id, + source: attempt.source.clone(), + short_id: attempt.short_id.clone(), + title: attempt.short_id.clone(), // We don't store title, use short_id + status: attempt.status.to_string(), + pr_url: attempt.pr_url.clone(), + attempted_at: attempt.attempted_at.to_rfc3339(), + retry_count: attempt.retry_count, + } +} + +/// Get attempts from tracker, optionally limited. +fn get_attempts(tracker: &Arc, limit: Option) -> Vec { + let mut all: Vec = Vec::new(); + + for status in [ + FixAttemptStatus::Pending, + FixAttemptStatus::Success, + FixAttemptStatus::Failed, + FixAttemptStatus::Merged, + FixAttemptStatus::Closed, + FixAttemptStatus::CannotFix, + ] { + if let Ok(attempts) = tracker.get_attempts_by_status(status) { + all.extend(attempts); + } + } + + // Sort by attempted_at descending + all.sort_by(|a, b| b.attempted_at.cmp(&a.attempted_at)); + + let iter = all.into_iter().map(|a| attempt_to_summary(&a)); + + match limit { + Some(n) => iter.take(n).collect(), + None => iter.collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{ + DiscordConfig, EmailConfig, GitHubConfig, PushConfig, RetryConfig, + SmsConfig, + }; + use crate::storage::SqliteTracker; + use axum::body::Body; + use axum::http::Request; + use http_body_util::BodyExt; + use tower::ServiceExt; + + fn test_config() -> Config { + Config { + work_dir: "/tmp/repos".into(), + known_orgs: vec!["test-org".to_string()], + auto_discover_paths: vec![], + poll_interval_ms: 300_000, + webhook_port: 3100, + db_path: ":memory:".into(), + max_issues_per_cycle: 5, + max_concurrent: 1, + processing_delay_ms: 5000, + max_activity_entries: 100, + ipc_timeout_secs: 30, + claude_timeout_secs: 21600, + discord: DiscordConfig::default(), + email: EmailConfig::default(), + sms: SmsConfig::default(), + push: PushConfig::default(), + github: GitHubConfig::default(), + retry: RetryConfig::default(), + linear: None, + sentry: None, + } + } + + fn create_test_tracker() -> Arc { + Arc::new(SqliteTracker::in_memory().unwrap()) + } + + #[tokio::test] + async fn test_health_endpoint() { + let config = test_config(); + let tracker = create_test_tracker(); + let router = create_api_router(config, tracker); + + let response = router + .oneshot( + Request::builder() + .uri("/api/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_stats_endpoint() { + let config = test_config(); + let tracker = create_test_tracker(); + let router = create_api_router(config, tracker); + + let response = router + .oneshot( + Request::builder() + .uri("/api/stats") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_overview_endpoint() { + let config = test_config(); + let tracker = create_test_tracker(); + let router = create_api_router(config, tracker); + + let response = router + .oneshot( + Request::builder() + .uri("/api/stats/overview") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_attempts_endpoint() { + let config = test_config(); + let tracker = create_test_tracker(); + let router = create_api_router(config, tracker); + + let response = router + .oneshot( + Request::builder() + .uri("/api/attempts") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_attempts_with_pagination() { + let config = test_config(); + let tracker = create_test_tracker(); + let router = create_api_router(config, tracker); + + let response = router + .oneshot( + Request::builder() + .uri("/api/attempts?page=1&per_page=10") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_attempts_with_filter() { + let config = test_config(); + let tracker = create_test_tracker(); + let router = create_api_router(config, tracker); + + let response = router + .oneshot( + Request::builder() + .uri("/api/attempts?status=success&source=linear") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_attempt_detail_not_found() { + let config = test_config(); + let tracker = create_test_tracker(); + let router = create_api_router(config, tracker); + + let response = router + .oneshot( + Request::builder() + .uri("/api/attempts/99999") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn test_sources_endpoint() { + let config = test_config(); + let tracker = create_test_tracker(); + let router = create_api_router(config, tracker); + + let response = router + .oneshot( + Request::builder() + .uri("/api/sources") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_retries_endpoint() { + let config = test_config(); + let tracker = create_test_tracker(); + let router = create_api_router(config, tracker); + + let response = router + .oneshot( + Request::builder() + .uri("/api/retries") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } + + #[test] + fn test_attempt_to_summary() { + let attempt = FixAttempt { + id: 1, + source: "linear".to_string(), + issue_id: "123".to_string(), + short_id: "PROJ-123".to_string(), + status: FixAttemptStatus::Success, + pr_url: Some("https://github.com/org/repo/pull/1".to_string()), + github_repo: None, + github_pr_number: None, + error_message: None, + attempted_at: chrono::Utc::now(), + resolved_at: None, + merged_at: None, + retry_count: 0, + last_retry_at: None, + }; + + let summary = attempt_to_summary(&attempt); + assert_eq!(summary.id, 1); + assert_eq!(summary.source, "linear"); + assert_eq!(summary.short_id, "PROJ-123"); + assert_eq!(summary.status, "success"); + assert!(summary.pr_url.is_some()); + } + + #[test] + fn test_health_response_serialization() { + let response = HealthResponse { + status: "ok".to_string(), + version: "1.0.0".to_string(), + uptime_secs: 3600, + database: DatabaseStatus { + status: "ok".to_string(), + error: None, + }, + }; + let json = serde_json::to_string(&response).unwrap(); + assert!(json.contains("ok")); + assert!(json.contains("1.0.0")); + assert!(json.contains("3600")); + assert!(json.contains("database")); + } + + #[test] + fn test_health_response_with_database_error() { + let response = HealthResponse { + status: "degraded".to_string(), + version: "1.0.0".to_string(), + uptime_secs: 100, + database: DatabaseStatus { + status: "error".to_string(), + error: Some("Connection failed".to_string()), + }, + }; + let json = serde_json::to_string(&response).unwrap(); + assert!(json.contains("degraded")); + assert!(json.contains("error")); + assert!(json.contains("Connection failed")); + } + + #[test] + fn test_attempts_response_serialization() { + let response = AttemptsResponse { + attempts: vec![], + total: 0, + page: 1, + per_page: 20, + }; + let json = serde_json::to_string(&response).unwrap(); + assert!(json.contains("\"total\":0")); + assert!(json.contains("\"page\":1")); + } + + #[test] + fn test_source_summary_serialization() { + let summary = SourceSummary { + name: "linear".to_string(), + total: 100, + success: 80, + failed: 10, + merged: 70, + success_rate: 80.0, + }; + let json = serde_json::to_string(&summary).unwrap(); + assert!(json.contains("linear")); + assert!(json.contains("100")); + assert!(json.contains("80.0")); + } + + #[test] + fn test_source_info_serialization() { + let info = SourceInfo { + name: "sentry".to_string(), + enabled: true, + config: serde_json::json!({"key": "value"}), + }; + let json = serde_json::to_string(&info).unwrap(); + assert!(json.contains("sentry")); + assert!(json.contains("true")); + assert!(json.contains("value")); + } + + #[test] + fn test_overview_response_serialization() { + let overview = OverviewResponse { + stats: FixAttemptStats::default(), + success_rate: 85.5, + merge_rate: 75.0, + recent_attempts: vec![], + sources: vec![], + }; + let json = serde_json::to_string(&overview).unwrap(); + assert!(json.contains("85.5")); + assert!(json.contains("stats")); + } + + #[test] + fn test_retries_response_serialization() { + let retries = RetriesResponse { + retryable: vec![], + ready: vec![], + max_retries: 3, + }; + let json = serde_json::to_string(&retries).unwrap(); + assert!(json.contains("retryable")); + assert!(json.contains("3")); + } + + #[test] + fn test_sources_response_serialization() { + let sources = SourcesResponse { + sources: vec![SourceInfo { + name: "linear".to_string(), + enabled: true, + config: serde_json::json!({}), + }], + }; + let json = serde_json::to_string(&sources).unwrap(); + assert!(json.contains("linear")); + } + + #[test] + fn test_attempt_summary_serialization() { + let summary = AttemptSummary { + id: 1, + source: "linear".to_string(), + short_id: "PROJ-1".to_string(), + title: "Fix bug".to_string(), + status: "success".to_string(), + pr_url: Some("https://github.com/org/repo/pull/1".to_string()), + attempted_at: "2024-01-01T00:00:00Z".to_string(), + retry_count: 0, + }; + let json = serde_json::to_string(&summary).unwrap(); + assert!(json.contains("linear")); + assert!(json.contains("PROJ-1")); + assert!(json.contains("success")); + } + + #[test] + fn test_attempts_query_deserialization() { + let query: AttemptsQuery = serde_json::from_str( + r#"{ + "page": 2, + "per_page": 50, + "status": "success", + "source": "linear" + }"#, + ) + .unwrap(); + assert_eq!(query.page, Some(2)); + assert_eq!(query.per_page, Some(50)); + assert_eq!(query.status, Some("success".to_string())); + assert_eq!(query.source, Some("linear".to_string())); + } + + #[test] + fn test_attempts_query_defaults() { + let query: AttemptsQuery = serde_json::from_str("{}").unwrap(); + assert!(query.page.is_none()); + assert!(query.per_page.is_none()); + assert!(query.status.is_none()); + assert!(query.source.is_none()); + } + + #[tokio::test] + async fn test_404_for_unknown_endpoint() { + let config = test_config(); + let tracker = create_test_tracker(); + let router = create_api_router(config, tracker); + + let response = router + .oneshot( + Request::builder() + .uri("/api/unknown") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn test_health_response_content() { + let config = test_config(); + let tracker = create_test_tracker(); + let router = create_api_router(config, tracker); + + let response = router + .oneshot( + Request::builder() + .uri("/api/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let body_str = String::from_utf8(body.to_vec()).unwrap(); + assert!(body_str.contains("\"status\":\"ok\"")); + assert!(body_str.contains("version")); + assert!(body_str.contains("uptime_secs")); + assert!(body_str.contains("database")); + } + + #[tokio::test] + async fn test_stats_response_content() { + let config = test_config(); + let tracker = create_test_tracker(); + let router = create_api_router(config, tracker); + + let response = router + .oneshot( + Request::builder() + .uri("/api/stats") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let stats: FixAttemptStats = serde_json::from_slice(&body).unwrap(); + assert_eq!(stats.total, 0); + } + + #[tokio::test] + async fn test_attempts_response_content() { + let config = test_config(); + let tracker = create_test_tracker(); + let router = create_api_router(config, tracker); + + let response = router + .oneshot( + Request::builder() + .uri("/api/attempts") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let body_str = String::from_utf8(body.to_vec()).unwrap(); + assert!(body_str.contains("\"page\":1")); + assert!(body_str.contains("\"per_page\":20")); + } + + #[tokio::test] + async fn test_attempts_pagination_limits() { + let config = test_config(); + let tracker = create_test_tracker(); + let router = create_api_router(config, tracker); + + // Test that per_page is capped at 100 + let response = router + .oneshot( + Request::builder() + .uri("/api/attempts?per_page=200") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let body_str = String::from_utf8(body.to_vec()).unwrap(); + assert!(body_str.contains("\"per_page\":100")); // Should be capped + } + + #[test] + fn test_attempt_to_summary_without_pr_url() { + let attempt = FixAttempt { + id: 2, + source: "sentry".to_string(), + issue_id: "456".to_string(), + short_id: "SENTRY-456".to_string(), + status: FixAttemptStatus::Failed, + pr_url: None, + github_repo: None, + github_pr_number: None, + error_message: Some("Error message".to_string()), + attempted_at: chrono::Utc::now(), + resolved_at: None, + merged_at: None, + retry_count: 2, + last_retry_at: Some(chrono::Utc::now()), + }; + + let summary = attempt_to_summary(&attempt); + assert_eq!(summary.id, 2); + assert_eq!(summary.source, "sentry"); + assert!(summary.pr_url.is_none()); + assert_eq!(summary.retry_count, 2); + assert_eq!(summary.status, "failed"); + } + + #[test] + fn test_source_summary_zero_values() { + let summary = SourceSummary { + name: "empty".to_string(), + total: 0, + success: 0, + failed: 0, + merged: 0, + success_rate: 0.0, + }; + let json = serde_json::to_string(&summary).unwrap(); + assert!(json.contains("empty")); + assert!(json.contains("0")); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 00000000..ff2ee6f9 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,1495 @@ +//! Configuration loading and validation. +//! +//! Configuration is loaded from a YAML file (`claudear.yaml` by default). +//! Environment variables can override any YAML values. + +use crate::error::{Error, Result}; +use serde::{Deserialize, Serialize}; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Default config file name. +pub const DEFAULT_CONFIG_FILE: &str = "claudear.yaml"; + +/// Main configuration for the application. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct Config { + /// Working directory for cloning repositories. + /// Repositories will be cloned into subdirectories of this path. + pub work_dir: PathBuf, + /// Known organizations to scan for repositories. + /// Repos from these orgs will be discovered automatically. + #[serde(default)] + pub known_orgs: Vec, + /// Paths to scan for auto-discovery of repositories. + /// Will scan for git repos from known_orgs in these directories. + #[serde(default)] + pub auto_discover_paths: Vec, + /// Polling interval in milliseconds. + pub poll_interval_ms: u64, + /// Webhook server port. + pub webhook_port: u16, + /// Database path for tracking. + pub db_path: PathBuf, + /// Maximum issues to process per poll cycle. + pub max_issues_per_cycle: usize, + /// Maximum concurrent issue processing. + pub max_concurrent: usize, + /// Delay between processing issues (ms). + pub processing_delay_ms: u64, + /// Maximum number of activity entries to keep in the IPC server (default: 10,000). + pub max_activity_entries: usize, + /// IPC request timeout in seconds (default: 30). + pub ipc_timeout_secs: u64, + /// Claude process execution timeout in seconds (default: 21600 = 6 hours). + pub claude_timeout_secs: u64, + /// Discord configuration. + pub discord: DiscordConfig, + /// Email configuration. + pub email: EmailConfig, + /// SMS configuration. + pub sms: SmsConfig, + /// Push notification configuration. + pub push: PushConfig, + /// GitHub configuration for PR monitoring. + pub github: GitHubConfig, + /// Retry configuration. + pub retry: RetryConfig, + /// Linear source configuration. + pub linear: Option, + /// Sentry source configuration. + pub sentry: Option, +} + +impl Default for Config { + fn default() -> Self { + Self { + work_dir: PathBuf::new(), + known_orgs: Vec::new(), + auto_discover_paths: Vec::new(), + poll_interval_ms: 300_000, + webhook_port: 3100, + db_path: PathBuf::from("claudear.db"), + max_issues_per_cycle: 5, + max_concurrent: 1, + processing_delay_ms: 5000, + max_activity_entries: 10_000, + ipc_timeout_secs: 30, + claude_timeout_secs: 21600, // 6 hours + discord: DiscordConfig::default(), + email: EmailConfig::default(), + sms: SmsConfig::default(), + push: PushConfig::default(), + github: GitHubConfig::default(), + retry: RetryConfig::default(), + linear: None, + sentry: None, + } + } +} + +/// Retry configuration for failed fix attempts. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct RetryConfig { + /// Maximum number of retry attempts (default: 2). + pub max_retries: u32, + /// Base delay between retries in milliseconds (default: 60000 = 1 minute). + pub base_delay_ms: u64, + /// Maximum delay between retries in milliseconds (default: 3600000 = 1 hour). + pub max_delay_ms: u64, +} + +impl Default for RetryConfig { + fn default() -> Self { + Self { + max_retries: 2, + base_delay_ms: 60_000, // 1 minute + max_delay_ms: 3_600_000, // 1 hour + } + } +} + +/// Discord notification configuration. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct DiscordConfig { + /// Discord webhook URL for notifications. + pub webhook_url: Option, + /// Discord user ID to mention in notifications. + pub user_id: Option, +} + +/// Email (SMTP) notification configuration. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct EmailConfig { + /// SMTP server host. + pub smtp_host: Option, + /// SMTP server port (default: 587). + pub smtp_port: u16, + /// SMTP username. + pub smtp_username: Option, + /// SMTP password. + pub smtp_password: Option, + /// Sender email address. + pub from_address: Option, + /// Recipient email addresses. + pub to_addresses: Vec, + /// Use TLS (default: true). + pub use_tls: bool, +} + +/// SMS notification configuration (via Twilio). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct SmsConfig { + /// Twilio Account SID. + pub account_sid: Option, + /// Twilio Auth Token. + pub auth_token: Option, + /// Twilio phone number (sender). + pub from_number: Option, + /// Recipient phone numbers. + pub to_numbers: Vec, +} + +/// Push notification configuration (via Pushover). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct PushConfig { + /// Pushover API token. + pub api_token: Option, + /// Pushover user key. + pub user_key: Option, + /// Device name (optional, sends to all devices if empty). + pub device: Option, + /// Priority level (-2 to 2). + pub priority: Option, +} + +/// GitHub configuration for PR monitoring. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct GitHubConfig { + /// GitHub personal access token. + pub token: Option, + /// Poll interval for checking PR status (ms). + pub poll_interval_ms: u64, + /// Whether to auto-resolve issues when PRs merge. + pub auto_resolve_on_merge: bool, +} + +/// Linear source configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct LinearConfig { + /// Whether this source is enabled. + pub enabled: bool, + /// Linear API key. + pub api_key: String, + /// Labels that trigger automation. + pub trigger_labels: Vec, + /// States that trigger automation. + pub trigger_states: Vec, + /// Optional team filter. + pub team_id: Option, + /// Optional project filter. + pub project_id: Option, + /// Webhook signature verification secret. + pub webhook_secret: Option, +} + +impl Default for LinearConfig { + fn default() -> Self { + Self { + enabled: true, + api_key: String::new(), + trigger_labels: vec!["auto-implement".to_string(), "claude".to_string()], + trigger_states: vec!["backlog".to_string(), "todo".to_string()], + team_id: None, + project_id: None, + webhook_secret: None, + } + } +} + +/// Time period for fetching top Sentry issues. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum TopIssuesPeriod { + /// 1 hour + #[serde(alias = "1h", alias = "1hr", alias = "hour")] + OneHour, + /// 12 hours + #[serde(alias = "12h", alias = "12hr", alias = "12hrs")] + TwelveHours, + /// 24 hours (1 day) - default + #[default] + #[serde(alias = "24h", alias = "1d", alias = "day", alias = "1day")] + OneDay, + /// 7 days (1 week) + #[serde(alias = "7d", alias = "1w", alias = "week", alias = "1week")] + OneWeek, + /// 30 days (1 month) + #[serde(alias = "30d", alias = "1m", alias = "month", alias = "1month")] + OneMonth, +} + +impl TopIssuesPeriod { + /// Convert to Sentry API statsPeriod parameter value. + pub fn to_stats_period(&self) -> &'static str { + match self { + TopIssuesPeriod::OneHour => "1h", + TopIssuesPeriod::TwelveHours => "12h", + TopIssuesPeriod::OneDay => "24h", + TopIssuesPeriod::OneWeek => "7d", + TopIssuesPeriod::OneMonth => "30d", + } + } +} + +impl std::str::FromStr for TopIssuesPeriod { + type Err = String; + + fn from_str(s: &str) -> std::result::Result { + match s.to_lowercase().as_str() { + "1h" | "1hr" | "hour" | "one_hour" => Ok(TopIssuesPeriod::OneHour), + "12h" | "12hr" | "12hrs" | "twelve_hours" => Ok(TopIssuesPeriod::TwelveHours), + "24h" | "1d" | "day" | "1day" | "one_day" => Ok(TopIssuesPeriod::OneDay), + "7d" | "1w" | "week" | "1week" | "one_week" => Ok(TopIssuesPeriod::OneWeek), + "30d" | "1m" | "month" | "1month" | "one_month" => Ok(TopIssuesPeriod::OneMonth), + _ => Err(format!("Invalid time period: {}", s)), + } + } +} + +impl std::fmt::Display for TopIssuesPeriod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TopIssuesPeriod::OneHour => write!(f, "1h"), + TopIssuesPeriod::TwelveHours => write!(f, "12h"), + TopIssuesPeriod::OneDay => write!(f, "24h"), + TopIssuesPeriod::OneWeek => write!(f, "7d"), + TopIssuesPeriod::OneMonth => write!(f, "30d"), + } + } +} + +/// Sentry source configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct SentryConfig { + /// Whether this source is enabled. + pub enabled: bool, + /// Sentry auth token. + pub auth_token: String, + /// Sentry organization slug. + pub org_slug: String, + /// Project slugs to filter. + pub project_slugs: Vec, + /// Number of top issues to fetch. + pub top_issues_count: usize, + /// Time period for fetching top issues (default: 24h). + pub top_issues_period: TopIssuesPeriod, + /// Minimum event count for issue to be processed. + pub min_event_count: usize, + /// Percentage increase to consider issue escalating. + pub escalation_threshold_percent: u32, + /// Webhook client secret for signature verification. + pub client_secret: Option, +} + +impl Default for SentryConfig { + fn default() -> Self { + Self { + enabled: true, + auth_token: String::new(), + org_slug: String::new(), + project_slugs: Vec::new(), + top_issues_count: 100, + top_issues_period: TopIssuesPeriod::default(), + min_event_count: 10, + escalation_threshold_percent: 50, + client_secret: None, + } + } +} + + +impl Config { + /// Load configuration from a YAML file with environment variable overrides. + /// + /// This is the primary way to load configuration. It: + /// 1. Reads the YAML config file + /// 2. Applies any environment variable overrides + /// 3. Validates required fields + pub fn load>(path: P) -> Result { + let path = path.as_ref(); + let content = fs::read_to_string(path).map_err(|e| { + Error::config(format!( + "Failed to read config file '{}': {}", + path.display(), + e + )) + })?; + + let mut config: Config = serde_yaml::from_str(&content).map_err(|e| { + Error::config(format!( + "Failed to parse YAML config '{}': {}", + path.display(), + e + )) + })?; + + // Apply environment variable overrides + config.apply_env_overrides(); + + // Validate project directory configuration + config.validate_project_config()?; + + Ok(config) + } + + /// Validate minimal configuration needed for loading. + /// + /// Only validates `work_dir` is set. Repository validation is done + /// in `validate()` for commands that actually need repositories. + fn validate_project_config(&self) -> Result<()> { + if self.work_dir.as_os_str().is_empty() { + return Err(Error::config( + "'work_dir' is required - path where repositories will be cloned", + )); + } + + Ok(()) + } + + /// Load configuration from the default config file path. + pub fn load_default() -> Result { + Self::load(DEFAULT_CONFIG_FILE) + } + + /// Load configuration from YAML string (useful for testing). + pub fn from_yaml(yaml: &str) -> Result { + let config: Config = serde_yaml::from_str(yaml) + .map_err(|e| Error::config(format!("Failed to parse YAML: {}", e)))?; + Ok(config) + } + + /// Apply environment variable overrides to the config. + /// Environment variables take precedence over YAML values. + fn apply_env_overrides(&mut self) { + // Core settings + if let Ok(v) = env::var("WORK_DIR") { + if !v.is_empty() { + self.work_dir = v.into(); + } + } + if let Ok(v) = env::var("KNOWN_ORGS") { + if !v.is_empty() { + self.known_orgs = v.split(',').map(|s| s.trim().to_string()).collect(); + } + } + if let Ok(v) = env::var("AUTO_DISCOVER_PATHS") { + if !v.is_empty() { + self.auto_discover_paths = v.split(',').map(|s| s.trim().to_string()).collect(); + } + } + if let Some(v) = env::var("POLL_INTERVAL_MS") + .ok() + .and_then(|v| v.parse().ok()) + { + self.poll_interval_ms = v; + } + if let Some(v) = env::var("WEBHOOK_PORT").ok().and_then(|v| v.parse().ok()) { + self.webhook_port = v; + } + if let Ok(v) = env::var("DB_PATH") { + if !v.is_empty() { + self.db_path = v.into(); + } + } + if let Some(v) = env::var("MAX_ISSUES_PER_CYCLE") + .ok() + .and_then(|v| v.parse().ok()) + { + self.max_issues_per_cycle = v; + } + if let Some(v) = env::var("MAX_CONCURRENT").ok().and_then(|v| v.parse().ok()) { + self.max_concurrent = v; + } + if let Some(v) = env::var("PROCESSING_DELAY_MS") + .ok() + .and_then(|v| v.parse().ok()) + { + self.processing_delay_ms = v; + } + if let Some(v) = env::var("MAX_ACTIVITY_ENTRIES") + .ok() + .and_then(|v| v.parse().ok()) + { + self.max_activity_entries = v; + } + if let Some(v) = env::var("IPC_TIMEOUT_SECS") + .ok() + .and_then(|v| v.parse().ok()) + { + self.ipc_timeout_secs = v; + } + if let Some(v) = env::var("CLAUDE_TIMEOUT_SECS") + .ok() + .and_then(|v| v.parse().ok()) + { + self.claude_timeout_secs = v; + } + + // Discord + if let Ok(v) = env::var("DISCORD_WEBHOOK_URL") { + self.discord.webhook_url = Some(v).filter(|s| !s.is_empty()); + } + if let Ok(v) = env::var("DISCORD_USER_ID") { + self.discord.user_id = Some(v).filter(|s| !s.is_empty()); + } + + // Email + if let Ok(v) = env::var("SMTP_HOST") { + self.email.smtp_host = Some(v).filter(|s| !s.is_empty()); + } + if let Some(v) = env::var("SMTP_PORT").ok().and_then(|v| v.parse().ok()) { + self.email.smtp_port = v; + } + if let Ok(v) = env::var("SMTP_USERNAME") { + self.email.smtp_username = Some(v).filter(|s| !s.is_empty()); + } + if let Ok(v) = env::var("SMTP_PASSWORD") { + self.email.smtp_password = Some(v).filter(|s| !s.is_empty()); + } + if let Ok(v) = env::var("EMAIL_FROM") { + self.email.from_address = Some(v).filter(|s| !s.is_empty()); + } + if let Ok(v) = env::var("EMAIL_TO") { + if !v.is_empty() { + self.email.to_addresses = v.split(',').map(|s| s.trim().to_string()).collect(); + } + } + if let Ok(v) = env::var("SMTP_TLS") { + self.email.use_tls = v.to_lowercase() == "true" || v == "1"; + } + + // SMS + if let Ok(v) = env::var("TWILIO_ACCOUNT_SID") { + self.sms.account_sid = Some(v).filter(|s| !s.is_empty()); + } + if let Ok(v) = env::var("TWILIO_AUTH_TOKEN") { + self.sms.auth_token = Some(v).filter(|s| !s.is_empty()); + } + if let Ok(v) = env::var("TWILIO_FROM_NUMBER") { + self.sms.from_number = Some(v).filter(|s| !s.is_empty()); + } + if let Ok(v) = env::var("TWILIO_TO_NUMBERS") { + if !v.is_empty() { + self.sms.to_numbers = v.split(',').map(|s| s.trim().to_string()).collect(); + } + } + + // Push + if let Ok(v) = env::var("PUSHOVER_API_TOKEN") { + self.push.api_token = Some(v).filter(|s| !s.is_empty()); + } + if let Ok(v) = env::var("PUSHOVER_USER_KEY") { + self.push.user_key = Some(v).filter(|s| !s.is_empty()); + } + if let Ok(v) = env::var("PUSHOVER_DEVICE") { + self.push.device = Some(v).filter(|s| !s.is_empty()); + } + if let Ok(v) = env::var("PUSHOVER_PRIORITY") { + self.push.priority = v.parse().ok(); + } + + // GitHub + if let Ok(v) = env::var("GITHUB_TOKEN") { + self.github.token = Some(v).filter(|s| !s.is_empty()); + } + if let Some(v) = env::var("GITHUB_POLL_INTERVAL_MS") + .ok() + .and_then(|v| v.parse().ok()) + { + self.github.poll_interval_ms = v; + } + if let Ok(v) = env::var("GITHUB_AUTO_RESOLVE_ON_MERGE") { + self.github.auto_resolve_on_merge = v.to_lowercase() == "true" || v == "1"; + } + + // Retry + if let Some(v) = env::var("RETRY_MAX_RETRIES") + .ok() + .and_then(|v| v.parse().ok()) + { + self.retry.max_retries = v; + } + if let Some(v) = env::var("RETRY_BASE_DELAY_MS") + .ok() + .and_then(|v| v.parse().ok()) + { + self.retry.base_delay_ms = v; + } + if let Some(v) = env::var("RETRY_MAX_DELAY_MS") + .ok() + .and_then(|v| v.parse().ok()) + { + self.retry.max_delay_ms = v; + } + + // Linear - apply overrides to existing config or create new one + self.apply_linear_env_overrides(); + + // Sentry - apply overrides to existing config or create new one + self.apply_sentry_env_overrides(); + } + + /// Apply Linear environment variable overrides. + fn apply_linear_env_overrides(&mut self) { + // If LINEAR_API_KEY is set in env, ensure we have a LinearConfig + if let Ok(api_key) = env::var("LINEAR_API_KEY") { + if !api_key.is_empty() { + let linear = self.linear.get_or_insert_with(LinearConfig::default); + linear.api_key = api_key; + } + } + + // Apply other overrides if we have a LinearConfig + if let Some(ref mut linear) = self.linear { + if let Ok(v) = env::var("LINEAR_ENABLED") { + linear.enabled = v.to_lowercase() == "true" || v == "1"; + } + if let Ok(v) = env::var("LINEAR_TRIGGER_LABELS") { + if !v.is_empty() { + linear.trigger_labels = v.split(',').map(|s| s.trim().to_string()).collect(); + } + } + if let Ok(v) = env::var("LINEAR_TRIGGER_STATES") { + if !v.is_empty() { + linear.trigger_states = v.split(',').map(|s| s.trim().to_string()).collect(); + } + } + if let Ok(v) = env::var("LINEAR_TEAM_ID") { + linear.team_id = Some(v).filter(|s| !s.is_empty()); + } + if let Ok(v) = env::var("LINEAR_PROJECT_ID") { + linear.project_id = Some(v).filter(|s| !s.is_empty()); + } + if let Ok(v) = env::var("LINEAR_WEBHOOK_SECRET") { + linear.webhook_secret = Some(v).filter(|s| !s.is_empty()); + } + } + } + + /// Apply Sentry environment variable overrides. + fn apply_sentry_env_overrides(&mut self) { + // If SENTRY_AUTH_TOKEN is set in env, ensure we have a SentryConfig + if let Ok(auth_token) = env::var("SENTRY_AUTH_TOKEN") { + if !auth_token.is_empty() { + let sentry = self.sentry.get_or_insert_with(SentryConfig::default); + sentry.auth_token = auth_token; + } + } + + // Apply other overrides if we have a SentryConfig + if let Some(ref mut sentry) = self.sentry { + if let Ok(v) = env::var("SENTRY_ENABLED") { + sentry.enabled = v.to_lowercase() == "true" || v == "1"; + } + if let Ok(v) = env::var("SENTRY_ORG_SLUG") { + if !v.is_empty() { + sentry.org_slug = v; + } + } + if let Ok(v) = env::var("SENTRY_PROJECT_SLUGS") { + if !v.is_empty() { + sentry.project_slugs = v.split(',').map(|s| s.trim().to_string()).collect(); + } + } + if let Some(v) = env::var("SENTRY_TOP_ISSUES_COUNT") + .ok() + .and_then(|v| v.parse().ok()) + { + sentry.top_issues_count = v; + } + if let Some(v) = env::var("SENTRY_TOP_ISSUES_PERIOD") + .ok() + .and_then(|v| v.parse().ok()) + { + sentry.top_issues_period = v; + } + if let Some(v) = env::var("SENTRY_MIN_EVENT_COUNT") + .ok() + .and_then(|v| v.parse().ok()) + { + sentry.min_event_count = v; + } + if let Some(v) = env::var("SENTRY_ESCALATION_THRESHOLD") + .ok() + .and_then(|v| v.parse().ok()) + { + sentry.escalation_threshold_percent = v; + } + if let Ok(v) = env::var("SENTRY_CLIENT_SECRET") { + sentry.client_secret = Some(v).filter(|s| !s.is_empty()); + } + } + } + + /// Validate that at least one source is configured and enabled. + pub fn validate(&self) -> Result<()> { + let has_linear = self + .linear + .as_ref() + .is_some_and(|c| c.enabled && !c.api_key.is_empty()); + let has_sentry = self + .sentry + .as_ref() + .is_some_and(|c| c.enabled && !c.auth_token.is_empty()); + + if !has_linear && !has_sentry { + return Err(Error::config( + "No sources configured. Configure linear or sentry in config file with valid API credentials.", + )); + } + + // Validate Sentry has org_slug if enabled + if let Some(ref sentry) = self.sentry { + if sentry.enabled && !sentry.auth_token.is_empty() && sentry.org_slug.is_empty() { + return Err(Error::config( + "sentry.org_slug is required when Sentry is enabled", + )); + } + } + + Ok(()) + } + + /// Check if Linear source is enabled. + pub fn is_linear_enabled(&self) -> bool { + self.linear.as_ref().is_some_and(|c| c.enabled) + } + + /// Check if Sentry source is enabled. + pub fn is_sentry_enabled(&self) -> bool { + self.sentry.as_ref().is_some_and(|c| c.enabled) + } + + /// Check if GitHub PR monitoring is enabled. + pub fn is_github_enabled(&self) -> bool { + self.github.token.is_some() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::io::Write; + use std::sync::Mutex; + use tempfile::NamedTempFile; + + // Mutex to prevent parallel execution of env-modifying tests + static ENV_MUTEX: Mutex<()> = Mutex::new(()); + + // All environment variables that Config reads + const CONFIG_ENV_VARS: &[&str] = &[ + "WORK_DIR", + "KNOWN_ORGS", + "AUTO_DISCOVER_PATHS", + "POLL_INTERVAL_MS", + "WEBHOOK_PORT", + "DB_PATH", + "MAX_ISSUES_PER_CYCLE", + "MAX_CONCURRENT", + "PROCESSING_DELAY_MS", + "LINEAR_API_KEY", + "LINEAR_ENABLED", + "LINEAR_TRIGGER_LABELS", + "LINEAR_TRIGGER_STATES", + "LINEAR_TEAM_ID", + "LINEAR_PROJECT_ID", + "LINEAR_WEBHOOK_SECRET", + "SENTRY_AUTH_TOKEN", + "SENTRY_ORG_SLUG", + "SENTRY_ENABLED", + "SENTRY_PROJECT_SLUGS", + "SENTRY_TOP_ISSUES_COUNT", + "SENTRY_MIN_EVENT_COUNT", + "SENTRY_ESCALATION_THRESHOLD", + "SENTRY_CLIENT_SECRET", + "DISCORD_WEBHOOK_URL", + "DISCORD_USER_ID", + "SMTP_HOST", + "SMTP_PORT", + "SMTP_USERNAME", + "SMTP_PASSWORD", + "EMAIL_FROM", + "EMAIL_TO", + "SMTP_TLS", + "TWILIO_ACCOUNT_SID", + "TWILIO_AUTH_TOKEN", + "TWILIO_FROM_NUMBER", + "TWILIO_TO_NUMBERS", + "PUSHOVER_API_TOKEN", + "PUSHOVER_USER_KEY", + "PUSHOVER_DEVICE", + "PUSHOVER_PRIORITY", + "GITHUB_TOKEN", + "GITHUB_POLL_INTERVAL_MS", + "GITHUB_AUTO_RESOLVE_ON_MERGE", + "RETRY_MAX_RETRIES", + "RETRY_BASE_DELAY_MS", + "RETRY_MAX_DELAY_MS", + ]; + + fn with_env(vars: &[(&str, &str)], f: F) -> R + where + F: FnOnce() -> R, + { + // Lock to prevent parallel execution + let _lock = ENV_MUTEX.lock().unwrap(); + + // Save all existing config env vars + let saved: Vec<(String, Option)> = CONFIG_ENV_VARS + .iter() + .map(|&key| (key.to_string(), env::var(key).ok())) + .collect(); + + // Clear all config env vars first + for &key in CONFIG_ENV_VARS { + env::remove_var(key); + } + + // Set only the vars specified for this test + for (key, value) in vars { + env::set_var(key, value); + } + + let result = f(); + + // Clean up: remove all vars we set + for (key, _) in vars { + env::remove_var(key); + } + + // Restore original environment + for (key, value) in saved { + match value { + Some(v) => env::set_var(&key, v), + None => env::remove_var(&key), + } + } + + result + } + + fn create_temp_yaml(content: &str) -> NamedTempFile { + let mut file = NamedTempFile::new().unwrap(); + file.write_all(content.as_bytes()).unwrap(); + file + } + + #[test] + fn test_from_yaml_minimal() { + let yaml = r#" +work_dir: /tmp/repos +known_orgs: + - appwrite + - utopia-php +linear: + api_key: lin_test_key +"#; + let config = Config::from_yaml(yaml).unwrap(); + assert_eq!(config.work_dir, PathBuf::from("/tmp/repos")); + assert_eq!(config.known_orgs, vec!["appwrite", "utopia-php"]); + assert!(config.linear.is_some()); + assert_eq!(config.linear.as_ref().unwrap().api_key, "lin_test_key"); + } + + #[test] + fn test_from_yaml_full_config() { + let yaml = r#" +work_dir: /path/to/repos +known_orgs: + - appwrite + - utopia-php +auto_discover_paths: + - ~/Local + - ~/Projects +poll_interval_ms: 600000 +webhook_port: 8080 +db_path: /custom/db.sqlite +max_issues_per_cycle: 10 +max_concurrent: 3 +processing_delay_ms: 10000 + +discord: + webhook_url: https://discord.com/api/webhooks/123/abc + user_id: "987654321" + +email: + smtp_host: smtp.example.com + smtp_port: 465 + smtp_username: user@example.com + smtp_password: secret + from_address: noreply@example.com + to_addresses: + - admin@example.com + - team@example.com + use_tls: true + +sms: + account_sid: AC123 + auth_token: token123 + from_number: "+15555555555" + to_numbers: + - "+16666666666" + +push: + api_token: pushover_token + user_key: user_key + device: iPhone + priority: 1 + +github: + token: ghp_token123 + poll_interval_ms: 30000 + auto_resolve_on_merge: false + +retry: + max_retries: 5 + base_delay_ms: 30000 + max_delay_ms: 7200000 + +linear: + enabled: true + api_key: lin_api_key + trigger_labels: + - auto + - implement + trigger_states: + - todo + - backlog + team_id: team_123 + project_id: proj_456 + webhook_secret: webhook_secret + +sentry: + enabled: true + auth_token: sentry_token + org_slug: my-org + project_slugs: + - frontend + - backend + top_issues_count: 50 + min_event_count: 5 + escalation_threshold_percent: 25 + client_secret: client_secret +"#; + let config = Config::from_yaml(yaml).unwrap(); + + assert_eq!(config.work_dir, PathBuf::from("/path/to/repos")); + assert_eq!(config.known_orgs, vec!["appwrite", "utopia-php"]); + assert_eq!(config.auto_discover_paths, vec!["~/Local", "~/Projects"]); + assert_eq!(config.poll_interval_ms, 600000); + assert_eq!(config.webhook_port, 8080); + assert_eq!(config.db_path, PathBuf::from("/custom/db.sqlite")); + assert_eq!(config.max_issues_per_cycle, 10); + assert_eq!(config.max_concurrent, 3); + assert_eq!(config.processing_delay_ms, 10000); + + // Discord + assert_eq!( + config.discord.webhook_url, + Some("https://discord.com/api/webhooks/123/abc".to_string()) + ); + assert_eq!(config.discord.user_id, Some("987654321".to_string())); + + // Email + assert_eq!(config.email.smtp_host, Some("smtp.example.com".to_string())); + assert_eq!(config.email.smtp_port, 465); + assert!(config.email.use_tls); + + // Linear + let linear = config.linear.unwrap(); + assert!(linear.enabled); + assert_eq!(linear.api_key, "lin_api_key"); + assert_eq!(linear.trigger_labels, vec!["auto", "implement"]); + assert_eq!(linear.team_id, Some("team_123".to_string())); + + // Sentry + let sentry = config.sentry.unwrap(); + assert!(sentry.enabled); + assert_eq!(sentry.auth_token, "sentry_token"); + assert_eq!(sentry.org_slug, "my-org"); + assert_eq!(sentry.top_issues_count, 50); + } + + /// Helper to create a minimal valid config YAML for tests. + fn test_config_yaml() -> &'static str { + r#" +work_dir: /tmp/repos +known_orgs: + - appwrite +linear: + api_key: test_key +"# + } + + #[test] + fn test_from_yaml_with_defaults() { + let config = Config::from_yaml(test_config_yaml()).unwrap(); + + // Check that defaults are applied + assert_eq!(config.poll_interval_ms, 300_000); + assert_eq!(config.webhook_port, 3100); + assert_eq!(config.max_issues_per_cycle, 5); + assert_eq!(config.max_concurrent, 1); + assert_eq!(config.processing_delay_ms, 5000); + + // Linear defaults + let linear = config.linear.unwrap(); + assert!(linear.enabled); + assert_eq!( + linear.trigger_labels, + vec!["auto-implement".to_string(), "claude".to_string()] + ); + assert_eq!( + linear.trigger_states, + vec!["backlog".to_string(), "todo".to_string()] + ); + } + + #[test] + fn test_from_yaml_invalid_yaml() { + let yaml = r#" +work_dir: /tmp/repos +known_orgs: + this is wrong: not valid +"#; + let result = Config::from_yaml(yaml); + assert!(result.is_err()); + } + + #[test] + fn test_load_from_file() { + let file = create_temp_yaml(test_config_yaml()); + + with_env(&[], || { + let config = Config::load(file.path()).unwrap(); + assert_eq!(config.work_dir, PathBuf::from("/tmp/repos")); + assert_eq!(config.known_orgs, vec!["appwrite"]); + assert!(config.linear.is_some()); + }); + } + + #[test] + fn test_load_file_not_found() { + with_env(&[], || { + let result = Config::load("/nonexistent/path/config.yaml"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Failed to read")); + }); + } + + #[test] + fn test_load_missing_work_dir() { + let yaml = r#" +known_orgs: + - appwrite +"#; + let file = create_temp_yaml(yaml); + + with_env(&[], || { + let result = Config::load(file.path()); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("work_dir")); + }); + } + + #[test] + fn test_load_without_known_orgs_succeeds() { + // Config can load without known_orgs and auto_discover_paths + let yaml = r#" +work_dir: /tmp/repos +linear: + api_key: test_key +"#; + let file = create_temp_yaml(yaml); + + with_env(&[], || { + let config = Config::load(file.path()).unwrap(); + assert!(config.known_orgs.is_empty()); + assert!(config.auto_discover_paths.is_empty()); + // validate() should succeed since we have a source configured + assert!(config.validate().is_ok()); + }); + } + + #[test] + fn test_env_override_work_dir() { + let yaml = r#" +work_dir: /yaml/path +"#; + let file = create_temp_yaml(yaml); + + with_env(&[("WORK_DIR", "/env/path")], || { + let config = Config::load(file.path()).unwrap(); + assert_eq!(config.work_dir, PathBuf::from("/env/path")); + }); + } + + #[test] + fn test_env_override_known_orgs() { + let yaml = r#" +work_dir: /tmp/repos +known_orgs: + - yaml-org +"#; + let file = create_temp_yaml(yaml); + + with_env(&[("KNOWN_ORGS", "env-org1, env-org2")], || { + let config = Config::load(file.path()).unwrap(); + assert_eq!(config.known_orgs, vec!["env-org1", "env-org2"]); + }); + } + + #[test] + fn test_env_override_auto_discover_paths() { + let yaml = r#" +work_dir: /tmp/repos +auto_discover_paths: + - ~/yaml/path +"#; + let file = create_temp_yaml(yaml); + + with_env(&[("AUTO_DISCOVER_PATHS", "~/env/path1, ~/env/path2")], || { + let config = Config::load(file.path()).unwrap(); + assert_eq!( + config.auto_discover_paths, + vec!["~/env/path1", "~/env/path2"] + ); + }); + } + + #[test] + fn test_env_override_core_settings() { + let yaml = r#" +work_dir: /tmp/repos +poll_interval_ms: 100000 +webhook_port: 3000 +linear: + api_key: lin_key +"#; + let file = create_temp_yaml(yaml); + + with_env( + &[ + ("POLL_INTERVAL_MS", "200000"), + ("WEBHOOK_PORT", "4000"), + ("MAX_ISSUES_PER_CYCLE", "20"), + ], + || { + let config = Config::load(file.path()).unwrap(); + assert_eq!(config.poll_interval_ms, 200000); + assert_eq!(config.webhook_port, 4000); + assert_eq!(config.max_issues_per_cycle, 20); + }, + ); + } + + #[test] + fn test_env_override_linear_api_key() { + let yaml = r#" +work_dir: /tmp/repos +linear: + api_key: yaml_key +"#; + let file = create_temp_yaml(yaml); + + with_env(&[("LINEAR_API_KEY", "env_key")], || { + let config = Config::load(file.path()).unwrap(); + assert_eq!(config.linear.as_ref().unwrap().api_key, "env_key"); + }); + } + + #[test] + fn test_env_creates_linear_config_when_missing() { + let yaml = r#" +work_dir: /tmp/repos +"#; + let file = create_temp_yaml(yaml); + + with_env(&[("LINEAR_API_KEY", "env_key")], || { + let config = Config::load(file.path()).unwrap(); + assert!(config.linear.is_some()); + assert_eq!(config.linear.as_ref().unwrap().api_key, "env_key"); + }); + } + + #[test] + fn test_env_override_sentry() { + let yaml = r#" +work_dir: /tmp/repos +sentry: + auth_token: yaml_token + org_slug: yaml-org +"#; + let file = create_temp_yaml(yaml); + + with_env( + &[ + ("SENTRY_AUTH_TOKEN", "env_token"), + ("SENTRY_ORG_SLUG", "env-org"), + ], + || { + let config = Config::load(file.path()).unwrap(); + let sentry = config.sentry.unwrap(); + assert_eq!(sentry.auth_token, "env_token"); + assert_eq!(sentry.org_slug, "env-org"); + }, + ); + } + + #[test] + fn test_env_creates_sentry_config_when_missing() { + let yaml = r#" +work_dir: /tmp/repos +"#; + let file = create_temp_yaml(yaml); + + with_env( + &[ + ("SENTRY_AUTH_TOKEN", "env_token"), + ("SENTRY_ORG_SLUG", "env-org"), + ], + || { + let config = Config::load(file.path()).unwrap(); + assert!(config.sentry.is_some()); + assert_eq!(config.sentry.as_ref().unwrap().auth_token, "env_token"); + }, + ); + } + + #[test] + fn test_env_override_discord() { + let yaml = r#" +work_dir: /tmp/repos +discord: + webhook_url: https://yaml.webhook +linear: + api_key: key +"#; + let file = create_temp_yaml(yaml); + + with_env(&[("DISCORD_WEBHOOK_URL", "https://env.webhook")], || { + let config = Config::load(file.path()).unwrap(); + assert_eq!( + config.discord.webhook_url, + Some("https://env.webhook".to_string()) + ); + }); + } + + #[test] + fn test_env_override_github() { + let yaml = r#" +work_dir: /tmp/repos +github: + token: yaml_token + poll_interval_ms: 30000 + auto_resolve_on_merge: true +linear: + api_key: key +"#; + let file = create_temp_yaml(yaml); + + with_env( + &[ + ("GITHUB_TOKEN", "env_token"), + ("GITHUB_POLL_INTERVAL_MS", "60000"), + ("GITHUB_AUTO_RESOLVE_ON_MERGE", "false"), + ], + || { + let config = Config::load(file.path()).unwrap(); + assert_eq!(config.github.token, Some("env_token".to_string())); + assert_eq!(config.github.poll_interval_ms, 60000); + assert!(!config.github.auto_resolve_on_merge); + }, + ); + } + + #[test] + fn test_validation_no_sources() { + let config = Config::default(); + assert!(config.validate().is_err()); + } + + #[test] + #[allow(clippy::field_reassign_with_default)] + fn test_validation_with_linear() { + let mut config = Config::default(); + config.linear = Some(LinearConfig { + enabled: true, + api_key: "test_key".into(), + ..Default::default() + }); + assert!(config.validate().is_ok()); + } + + #[test] + #[allow(clippy::field_reassign_with_default)] + fn test_validation_with_sentry() { + let mut config = Config::default(); + config.sentry = Some(SentryConfig { + enabled: true, + auth_token: "test_token".into(), + org_slug: "test_org".into(), + ..Default::default() + }); + assert!(config.validate().is_ok()); + } + + #[test] + #[allow(clippy::field_reassign_with_default)] + fn test_validation_sentry_missing_org_slug() { + let mut config = Config::default(); + config.sentry = Some(SentryConfig { + enabled: true, + auth_token: "test_token".into(), + org_slug: String::new(), // Empty org_slug + ..Default::default() + }); + assert!(config.validate().is_err()); + assert!(config + .validate() + .unwrap_err() + .to_string() + .contains("org_slug")); + } + + #[test] + #[allow(clippy::field_reassign_with_default)] + fn test_validation_disabled_sources_fail() { + let mut config = Config::default(); + config.linear = Some(LinearConfig { + enabled: false, + api_key: "test_key".into(), + ..Default::default() + }); + assert!(config.validate().is_err()); + } + + #[test] + #[allow(clippy::field_reassign_with_default)] + fn test_validation_empty_api_key_fails() { + let mut config = Config::default(); + config.linear = Some(LinearConfig { + enabled: true, + api_key: String::new(), + ..Default::default() + }); + assert!(config.validate().is_err()); + } + + #[test] + fn test_config_default() { + let config = Config::default(); + assert_eq!(config.poll_interval_ms, 300_000); + assert_eq!(config.webhook_port, 3100); + assert_eq!(config.max_issues_per_cycle, 5); + assert_eq!(config.max_concurrent, 1); + assert_eq!(config.processing_delay_ms, 5000); + assert!(config.linear.is_none()); + assert!(config.sentry.is_none()); + } + + #[test] + fn test_retry_config_default() { + let config = RetryConfig::default(); + assert_eq!(config.max_retries, 2); + assert_eq!(config.base_delay_ms, 60_000); + assert_eq!(config.max_delay_ms, 3_600_000); + } + + #[test] + fn test_linear_config_default() { + let config = LinearConfig::default(); + assert!(config.enabled); + assert!(config.api_key.is_empty()); + assert_eq!( + config.trigger_labels, + vec!["auto-implement".to_string(), "claude".to_string()] + ); + assert_eq!( + config.trigger_states, + vec!["backlog".to_string(), "todo".to_string()] + ); + } + + #[test] + fn test_sentry_config_default() { + let config = SentryConfig::default(); + assert!(config.enabled); + assert!(config.auth_token.is_empty()); + assert!(config.org_slug.is_empty()); + assert_eq!(config.top_issues_count, 100); + assert_eq!(config.top_issues_period, TopIssuesPeriod::OneDay); + assert_eq!(config.min_event_count, 10); + assert_eq!(config.escalation_threshold_percent, 50); + } + + #[test] + fn test_top_issues_period_to_stats_period() { + assert_eq!(TopIssuesPeriod::OneHour.to_stats_period(), "1h"); + assert_eq!(TopIssuesPeriod::TwelveHours.to_stats_period(), "12h"); + assert_eq!(TopIssuesPeriod::OneDay.to_stats_period(), "24h"); + assert_eq!(TopIssuesPeriod::OneWeek.to_stats_period(), "7d"); + assert_eq!(TopIssuesPeriod::OneMonth.to_stats_period(), "30d"); + } + + #[test] + fn test_top_issues_period_from_str() { + // 1 hour variants + assert_eq!("1h".parse::(), Ok(TopIssuesPeriod::OneHour)); + assert_eq!("1hr".parse::(), Ok(TopIssuesPeriod::OneHour)); + assert_eq!("hour".parse::(), Ok(TopIssuesPeriod::OneHour)); + assert_eq!("one_hour".parse::(), Ok(TopIssuesPeriod::OneHour)); + + // 12 hours variants + assert_eq!("12h".parse::(), Ok(TopIssuesPeriod::TwelveHours)); + assert_eq!("12hr".parse::(), Ok(TopIssuesPeriod::TwelveHours)); + assert_eq!("12hrs".parse::(), Ok(TopIssuesPeriod::TwelveHours)); + assert_eq!("twelve_hours".parse::(), Ok(TopIssuesPeriod::TwelveHours)); + + // 1 day variants + assert_eq!("24h".parse::(), Ok(TopIssuesPeriod::OneDay)); + assert_eq!("1d".parse::(), Ok(TopIssuesPeriod::OneDay)); + assert_eq!("day".parse::(), Ok(TopIssuesPeriod::OneDay)); + assert_eq!("1day".parse::(), Ok(TopIssuesPeriod::OneDay)); + assert_eq!("one_day".parse::(), Ok(TopIssuesPeriod::OneDay)); + + // 1 week variants + assert_eq!("7d".parse::(), Ok(TopIssuesPeriod::OneWeek)); + assert_eq!("1w".parse::(), Ok(TopIssuesPeriod::OneWeek)); + assert_eq!("week".parse::(), Ok(TopIssuesPeriod::OneWeek)); + assert_eq!("1week".parse::(), Ok(TopIssuesPeriod::OneWeek)); + assert_eq!("one_week".parse::(), Ok(TopIssuesPeriod::OneWeek)); + + // 1 month variants + assert_eq!("30d".parse::(), Ok(TopIssuesPeriod::OneMonth)); + assert_eq!("1m".parse::(), Ok(TopIssuesPeriod::OneMonth)); + assert_eq!("month".parse::(), Ok(TopIssuesPeriod::OneMonth)); + assert_eq!("1month".parse::(), Ok(TopIssuesPeriod::OneMonth)); + assert_eq!("one_month".parse::(), Ok(TopIssuesPeriod::OneMonth)); + + // Case insensitivity + assert_eq!("1H".parse::(), Ok(TopIssuesPeriod::OneHour)); + assert_eq!("ONE_WEEK".parse::(), Ok(TopIssuesPeriod::OneWeek)); + + // Invalid + assert!("invalid".parse::().is_err()); + assert!("2h".parse::().is_err()); + assert!("".parse::().is_err()); + } + + #[test] + fn test_top_issues_period_display() { + assert_eq!(format!("{}", TopIssuesPeriod::OneHour), "1h"); + assert_eq!(format!("{}", TopIssuesPeriod::TwelveHours), "12h"); + assert_eq!(format!("{}", TopIssuesPeriod::OneDay), "24h"); + assert_eq!(format!("{}", TopIssuesPeriod::OneWeek), "7d"); + assert_eq!(format!("{}", TopIssuesPeriod::OneMonth), "30d"); + } + + #[test] + fn test_top_issues_period_default() { + assert_eq!(TopIssuesPeriod::default(), TopIssuesPeriod::OneDay); + } + + #[test] + fn test_is_linear_enabled() { + let mut config = Config::default(); + assert!(!config.is_linear_enabled()); + + config.linear = Some(LinearConfig { + enabled: true, + ..Default::default() + }); + assert!(config.is_linear_enabled()); + + config.linear.as_mut().unwrap().enabled = false; + assert!(!config.is_linear_enabled()); + } + + #[test] + fn test_is_sentry_enabled() { + let mut config = Config::default(); + assert!(!config.is_sentry_enabled()); + + config.sentry = Some(SentryConfig { + enabled: true, + ..Default::default() + }); + assert!(config.is_sentry_enabled()); + + config.sentry.as_mut().unwrap().enabled = false; + assert!(!config.is_sentry_enabled()); + } + + #[test] + fn test_is_github_enabled() { + let mut config = Config::default(); + assert!(!config.is_github_enabled()); + + config.github.token = Some("ghp_test".to_string()); + assert!(config.is_github_enabled()); + } + + #[test] + fn test_config_yaml_roundtrip() { + let yaml = r#" +work_dir: /tmp/repos +known_orgs: + - appwrite + - utopia-php +auto_discover_paths: + - ~/Local +poll_interval_ms: 500000 +linear: + enabled: true + api_key: test_key + trigger_labels: + - label1 + - label2 +"#; + let config = Config::from_yaml(yaml).unwrap(); + let serialized = serde_yaml::to_string(&config).unwrap(); + let deserialized: Config = serde_yaml::from_str(&serialized).unwrap(); + + assert_eq!(config.work_dir, deserialized.work_dir); + assert_eq!(config.known_orgs, deserialized.known_orgs); + assert_eq!(config.auto_discover_paths, deserialized.auto_discover_paths); + assert_eq!(config.poll_interval_ms, deserialized.poll_interval_ms); + assert_eq!( + config.linear.as_ref().unwrap().api_key, + deserialized.linear.as_ref().unwrap().api_key + ); + } + + #[test] + fn test_retry_config_serialization() { + let config = RetryConfig::default(); + let yaml = serde_yaml::to_string(&config).unwrap(); + assert!(yaml.contains("max_retries")); + assert!(yaml.contains("base_delay_ms")); + assert!(yaml.contains("max_delay_ms")); + } +} diff --git a/src/discord/client.rs b/src/discord/client.rs new file mode 100644 index 00000000..3eb3b80c --- /dev/null +++ b/src/discord/client.rs @@ -0,0 +1,779 @@ +//! Discord API client for thread management. + +use super::types::{ + CreateMessageParams, CreateThreadParams, DiscordChannel, DiscordMessage, DiscordThread, +}; +use crate::error::{Error, Result}; +use async_trait::async_trait; +use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE}; + +const DISCORD_API_BASE: &str = "https://discord.com/api/v10"; + +/// HTTP response abstraction for testability. +#[derive(Debug)] +pub struct HttpResponse { + pub status: u16, + pub body: String, +} + +impl HttpResponse { + pub fn is_success(&self) -> bool { + (200..300).contains(&self.status) + } + + pub fn json(&self) -> Result { + serde_json::from_str(&self.body) + .map_err(|e| Error::notifier("discord", format!("Failed to parse response: {}", e))) + } +} + +/// Trait for HTTP client operations to enable testing. +#[async_trait] +pub trait DiscordHttpClient: Send + Sync { + async fn get(&self, url: &str) -> Result; + async fn post(&self, url: &str, body: serde_json::Value) -> Result; + async fn patch(&self, url: &str, body: serde_json::Value) -> Result; +} + +/// Default HTTP client using reqwest. +pub struct ReqwestDiscordClient { + client: reqwest::Client, +} + +impl ReqwestDiscordClient { + pub fn new(bot_token: &str) -> Result { + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bot {}", bot_token)) + .map_err(|_| Error::config("Invalid bot token format"))?, + ); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + + let client = reqwest::Client::builder() + .default_headers(headers) + .build() + .map_err(|e| Error::network(format!("Failed to create HTTP client: {}", e)))?; + + Ok(Self { client }) + } +} + +#[async_trait] +impl DiscordHttpClient for ReqwestDiscordClient { + async fn get(&self, url: &str) -> Result { + let response = self.client.get(url).send().await?; + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + Ok(HttpResponse { status, body }) + } + + async fn post(&self, url: &str, body: serde_json::Value) -> Result { + let response = self.client.post(url).json(&body).send().await?; + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + Ok(HttpResponse { status, body }) + } + + async fn patch(&self, url: &str, body: serde_json::Value) -> Result { + let response = self.client.patch(url).json(&body).send().await?; + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + Ok(HttpResponse { status, body }) + } +} + +/// Discord API client for managing threads and messages. +pub struct DiscordClient { + http: H, + bot_token: String, +} + +impl DiscordClient { + /// Create a new Discord client with a bot token. + pub fn new(bot_token: impl Into) -> Result { + let bot_token = bot_token.into(); + if bot_token.is_empty() { + return Err(Error::config("DISCORD_BOT_TOKEN is required")); + } + + let http = ReqwestDiscordClient::new(&bot_token)?; + Ok(Self { http, bot_token }) + } +} + +impl DiscordClient { + /// Create a new Discord client with a custom HTTP client. + pub fn with_http_client(bot_token: impl Into, http: H) -> Result { + let bot_token = bot_token.into(); + if bot_token.is_empty() { + return Err(Error::config("DISCORD_BOT_TOKEN is required")); + } + Ok(Self { http, bot_token }) + } + + /// Get the bot token (for verification purposes). + pub fn bot_token(&self) -> &str { + &self.bot_token + } + + /// Get a channel by ID. + pub async fn get_channel(&self, channel_id: &str) -> Result { + let url = format!("{}/channels/{}", DISCORD_API_BASE, channel_id); + let response = self.http.get(&url).await?; + + if !response.is_success() { + return Err(Error::notifier( + "discord", + format!( + "Failed to get channel ({}): {}", + response.status, response.body + ), + )); + } + + response.json() + } + + /// Create a thread in a channel (without a starting message). + pub async fn create_thread( + &self, + channel_id: &str, + params: CreateThreadParams, + ) -> Result { + let url = format!("{}/channels/{}/threads", DISCORD_API_BASE, channel_id); + let body = serde_json::to_value(¶ms).map_err(|e| { + Error::notifier("discord", format!("Failed to serialize params: {}", e)) + })?; + let response = self.http.post(&url, body).await?; + + if !response.is_success() { + return Err(Error::notifier( + "discord", + format!( + "Failed to create thread ({}): {}", + response.status, response.body + ), + )); + } + + response.json() + } + + /// Create a thread from an existing message. + pub async fn create_thread_from_message( + &self, + channel_id: &str, + message_id: &str, + params: CreateThreadParams, + ) -> Result { + let url = format!( + "{}/channels/{}/messages/{}/threads", + DISCORD_API_BASE, channel_id, message_id + ); + let body = serde_json::to_value(¶ms).map_err(|e| { + Error::notifier("discord", format!("Failed to serialize params: {}", e)) + })?; + let response = self.http.post(&url, body).await?; + + if !response.is_success() { + return Err(Error::notifier( + "discord", + format!( + "Failed to create thread from message ({}): {}", + response.status, response.body + ), + )); + } + + response.json() + } + + /// Get a thread by ID. + pub async fn get_thread(&self, thread_id: &str) -> Result { + let url = format!("{}/channels/{}", DISCORD_API_BASE, thread_id); + let response = self.http.get(&url).await?; + + if !response.is_success() { + return Err(Error::notifier( + "discord", + format!( + "Failed to get thread ({}): {}", + response.status, response.body + ), + )); + } + + response.json() + } + + /// Send a message to a channel or thread. + pub async fn send_message( + &self, + channel_id: &str, + params: CreateMessageParams, + ) -> Result { + let url = format!("{}/channels/{}/messages", DISCORD_API_BASE, channel_id); + let body = serde_json::to_value(¶ms).map_err(|e| { + Error::notifier("discord", format!("Failed to serialize params: {}", e)) + })?; + let response = self.http.post(&url, body).await?; + + if !response.is_success() { + return Err(Error::notifier( + "discord", + format!( + "Failed to send message ({}): {}", + response.status, response.body + ), + )); + } + + response.json() + } + + /// Archive a thread. + pub async fn archive_thread(&self, thread_id: &str) -> Result { + let url = format!("{}/channels/{}", DISCORD_API_BASE, thread_id); + let response = self + .http + .patch(&url, serde_json::json!({ "archived": true })) + .await?; + + if !response.is_success() { + return Err(Error::notifier( + "discord", + format!( + "Failed to archive thread ({}): {}", + response.status, response.body + ), + )); + } + + response.json() + } + + /// Unarchive a thread. + pub async fn unarchive_thread(&self, thread_id: &str) -> Result { + let url = format!("{}/channels/{}", DISCORD_API_BASE, thread_id); + let response = self + .http + .patch(&url, serde_json::json!({ "archived": false })) + .await?; + + if !response.is_success() { + return Err(Error::notifier( + "discord", + format!( + "Failed to unarchive thread ({}): {}", + response.status, response.body + ), + )); + } + + response.json() + } + + /// List active threads in a channel. + pub async fn list_active_threads(&self, guild_id: &str) -> Result> { + let url = format!("{}/guilds/{}/threads/active", DISCORD_API_BASE, guild_id); + let response = self.http.get(&url).await?; + + if !response.is_success() { + return Err(Error::notifier( + "discord", + format!( + "Failed to list threads ({}): {}", + response.status, response.body + ), + )); + } + + #[derive(serde::Deserialize)] + struct ThreadsResponse { + threads: Vec, + } + + let threads_response: ThreadsResponse = response.json()?; + Ok(threads_response.threads) + } +} + +/// Mock HTTP client for testing (only available in tests). +#[cfg(test)] +pub mod mock { + use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + + /// Mock HTTP client for testing. + pub struct MockDiscordClient { + get_responses: Mutex>, + post_responses: Mutex>, + patch_responses: Mutex>, + } + + impl MockDiscordClient { + pub fn new() -> Self { + Self { + get_responses: Mutex::new(HashMap::new()), + post_responses: Mutex::new(HashMap::new()), + patch_responses: Mutex::new(HashMap::new()), + } + } + + pub fn mock_get(&self, url: impl Into, status: u16, body: impl Into) { + self.get_responses.lock().unwrap().insert( + url.into(), + HttpResponse { + status, + body: body.into(), + }, + ); + } + + pub fn mock_post(&self, url: impl Into, status: u16, body: impl Into) { + self.post_responses.lock().unwrap().insert( + url.into(), + HttpResponse { + status, + body: body.into(), + }, + ); + } + + pub fn mock_patch(&self, url: impl Into, status: u16, body: impl Into) { + self.patch_responses.lock().unwrap().insert( + url.into(), + HttpResponse { + status, + body: body.into(), + }, + ); + } + } + + #[async_trait] + impl DiscordHttpClient for MockDiscordClient { + async fn get(&self, url: &str) -> Result { + let responses = self.get_responses.lock().unwrap(); + if let Some(r) = responses.get(url) { + Ok(HttpResponse { + status: r.status, + body: r.body.clone(), + }) + } else { + Ok(HttpResponse { + status: 404, + body: "Not found".to_string(), + }) + } + } + + async fn post(&self, url: &str, _body: serde_json::Value) -> Result { + let responses = self.post_responses.lock().unwrap(); + if let Some(r) = responses.get(url) { + Ok(HttpResponse { + status: r.status, + body: r.body.clone(), + }) + } else { + Ok(HttpResponse { + status: 404, + body: "Not found".to_string(), + }) + } + } + + async fn patch(&self, url: &str, _body: serde_json::Value) -> Result { + let responses = self.patch_responses.lock().unwrap(); + if let Some(r) = responses.get(url) { + Ok(HttpResponse { + status: r.status, + body: r.body.clone(), + }) + } else { + Ok(HttpResponse { + status: 404, + body: "Not found".to_string(), + }) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::mock::MockDiscordClient; + use super::*; + + fn mock_channel_json() -> &'static str { + r#"{"id": "123", "type": 0, "name": "test-channel"}"# + } + + fn mock_thread_json() -> &'static str { + r#"{"id": "456", "type": 11, "name": "test-thread", "parent_id": "123", "owner_id": "789"}"# + } + + fn mock_message_json() -> &'static str { + r#"{"id": "999", "channel_id": "123", "content": "Hello", "timestamp": "2024-01-01T00:00:00Z", "author": {"id": "111", "username": "bot"}}"# + } + + #[test] + fn test_http_response_is_success() { + assert!(HttpResponse { + status: 200, + body: "".to_string() + } + .is_success()); + assert!(HttpResponse { + status: 201, + body: "".to_string() + } + .is_success()); + assert!(!HttpResponse { + status: 400, + body: "".to_string() + } + .is_success()); + assert!(!HttpResponse { + status: 500, + body: "".to_string() + } + .is_success()); + } + + #[test] + fn test_http_response_json() { + let response = HttpResponse { + status: 200, + body: r#"{"id": "123"}"#.to_string(), + }; + let parsed: serde_json::Value = response.json().unwrap(); + assert_eq!(parsed["id"], "123"); + } + + #[test] + fn test_http_response_json_error() { + let response = HttpResponse { + status: 200, + body: "invalid".to_string(), + }; + let result: Result = response.json(); + assert!(result.is_err()); + } + + #[test] + fn test_client_requires_token() { + let result = DiscordClient::new(""); + assert!(result.is_err()); + } + + #[test] + fn test_client_creation() { + let result = DiscordClient::new("test_token"); + assert!(result.is_ok()); + let client = result.unwrap(); + assert_eq!(client.bot_token(), "test_token"); + } + + #[test] + fn test_with_http_client_requires_token() { + let mock = MockDiscordClient::new(); + let result = DiscordClient::with_http_client("", mock); + assert!(result.is_err()); + } + + #[test] + fn test_with_http_client_success() { + let mock = MockDiscordClient::new(); + let result = DiscordClient::with_http_client("token", mock); + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_get_channel_success() { + let mock = MockDiscordClient::new(); + mock.mock_get( + "https://discord.com/api/v10/channels/123", + 200, + mock_channel_json(), + ); + + let client = DiscordClient::with_http_client("token", mock).unwrap(); + let channel = client.get_channel("123").await.unwrap(); + assert_eq!(channel.id, "123"); + } + + #[tokio::test] + async fn test_get_channel_error() { + let mock = MockDiscordClient::new(); + mock.mock_get("https://discord.com/api/v10/channels/123", 404, "Not found"); + + let client = DiscordClient::with_http_client("token", mock).unwrap(); + let result = client.get_channel("123").await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Failed to get channel")); + } + + #[tokio::test] + async fn test_create_thread_success() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/123/threads", + 200, + mock_thread_json(), + ); + + let client = DiscordClient::with_http_client("token", mock).unwrap(); + let params = CreateThreadParams::public("Test Thread"); + let thread = client.create_thread("123", params).await.unwrap(); + assert_eq!(thread.id, "456"); + } + + #[tokio::test] + async fn test_create_thread_error() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/123/threads", + 403, + "Forbidden", + ); + + let client = DiscordClient::with_http_client("token", mock).unwrap(); + let params = CreateThreadParams::public("Test"); + let result = client.create_thread("123", params).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_create_thread_from_message_success() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/123/messages/999/threads", + 200, + mock_thread_json(), + ); + + let client = DiscordClient::with_http_client("token", mock).unwrap(); + let params = CreateThreadParams::public("Test"); + let thread = client + .create_thread_from_message("123", "999", params) + .await + .unwrap(); + assert_eq!(thread.id, "456"); + } + + #[tokio::test] + async fn test_create_thread_from_message_error() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/123/messages/999/threads", + 400, + "Bad request", + ); + + let client = DiscordClient::with_http_client("token", mock).unwrap(); + let params = CreateThreadParams::public("Test"); + let result = client + .create_thread_from_message("123", "999", params) + .await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_get_thread_success() { + let mock = MockDiscordClient::new(); + mock.mock_get( + "https://discord.com/api/v10/channels/456", + 200, + mock_thread_json(), + ); + + let client = DiscordClient::with_http_client("token", mock).unwrap(); + let thread = client.get_thread("456").await.unwrap(); + assert_eq!(thread.id, "456"); + } + + #[tokio::test] + async fn test_get_thread_error() { + let mock = MockDiscordClient::new(); + mock.mock_get("https://discord.com/api/v10/channels/456", 404, "Not found"); + + let client = DiscordClient::with_http_client("token", mock).unwrap(); + let result = client.get_thread("456").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_send_message_success() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/123/messages", + 200, + mock_message_json(), + ); + + let client = DiscordClient::with_http_client("token", mock).unwrap(); + let params = CreateMessageParams::text("Hello"); + let message = client.send_message("123", params).await.unwrap(); + assert_eq!(message.id, "999"); + } + + #[tokio::test] + async fn test_send_message_error() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/123/messages", + 403, + "Forbidden", + ); + + let client = DiscordClient::with_http_client("token", mock).unwrap(); + let params = CreateMessageParams::text("Hello"); + let result = client.send_message("123", params).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_archive_thread_success() { + let mock = MockDiscordClient::new(); + mock.mock_patch( + "https://discord.com/api/v10/channels/456", + 200, + mock_thread_json(), + ); + + let client = DiscordClient::with_http_client("token", mock).unwrap(); + let thread = client.archive_thread("456").await.unwrap(); + assert_eq!(thread.id, "456"); + } + + #[tokio::test] + async fn test_archive_thread_error() { + let mock = MockDiscordClient::new(); + mock.mock_patch("https://discord.com/api/v10/channels/456", 403, "Forbidden"); + + let client = DiscordClient::with_http_client("token", mock).unwrap(); + let result = client.archive_thread("456").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_unarchive_thread_success() { + let mock = MockDiscordClient::new(); + mock.mock_patch( + "https://discord.com/api/v10/channels/456", + 200, + mock_thread_json(), + ); + + let client = DiscordClient::with_http_client("token", mock).unwrap(); + let thread = client.unarchive_thread("456").await.unwrap(); + assert_eq!(thread.id, "456"); + } + + #[tokio::test] + async fn test_unarchive_thread_error() { + let mock = MockDiscordClient::new(); + mock.mock_patch( + "https://discord.com/api/v10/channels/456", + 500, + "Server error", + ); + + let client = DiscordClient::with_http_client("token", mock).unwrap(); + let result = client.unarchive_thread("456").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_list_active_threads_success() { + let mock = MockDiscordClient::new(); + mock.mock_get( + "https://discord.com/api/v10/guilds/guild1/threads/active", + 200, + r#"{"threads": [{"id": "456", "type": 11, "name": "thread1", "parent_id": "123", "owner_id": "789"}]}"#, + ); + + let client = DiscordClient::with_http_client("token", mock).unwrap(); + let threads = client.list_active_threads("guild1").await.unwrap(); + assert_eq!(threads.len(), 1); + assert_eq!(threads[0].id, "456"); + } + + #[tokio::test] + async fn test_list_active_threads_error() { + let mock = MockDiscordClient::new(); + mock.mock_get( + "https://discord.com/api/v10/guilds/guild1/threads/active", + 403, + "Forbidden", + ); + + let client = DiscordClient::with_http_client("token", mock).unwrap(); + let result = client.list_active_threads("guild1").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_list_active_threads_empty() { + let mock = MockDiscordClient::new(); + mock.mock_get( + "https://discord.com/api/v10/guilds/guild1/threads/active", + 200, + r#"{"threads": []}"#, + ); + + let client = DiscordClient::with_http_client("token", mock).unwrap(); + let threads = client.list_active_threads("guild1").await.unwrap(); + assert!(threads.is_empty()); + } + + #[test] + fn test_client_with_string() { + let token = String::from("my_token"); + let client = DiscordClient::new(token).unwrap(); + assert_eq!(client.bot_token(), "my_token"); + } + + #[test] + fn test_discord_api_base_url() { + assert_eq!(DISCORD_API_BASE, "https://discord.com/api/v10"); + } + + #[test] + fn test_create_thread_params_public() { + let params = CreateThreadParams::public("Test Thread"); + assert_eq!(params.name, "Test Thread"); + assert_eq!(params.thread_type, Some(11)); + assert_eq!(params.auto_archive_duration, Some(10080)); + } + + #[test] + fn test_create_thread_params_private() { + let params = CreateThreadParams::private("Private Thread"); + assert_eq!(params.name, "Private Thread"); + assert_eq!(params.thread_type, Some(12)); + } + + #[test] + fn test_create_message_params_text() { + let params = CreateMessageParams::text("Hello"); + assert_eq!(params.content, "Hello".to_string()); + assert!(params.embeds.is_none()); + } + + #[test] + fn test_create_message_params_with_embed() { + let embed = super::super::types::MessageEmbed::new() + .title("Test Title") + .description("Test description"); + let params = CreateMessageParams::with_embed("Content", embed); + assert_eq!(params.content, "Content".to_string()); + assert!(params.embeds.is_some()); + } +} diff --git a/src/discord/mod.rs b/src/discord/mod.rs new file mode 100644 index 00000000..15fce04a --- /dev/null +++ b/src/discord/mod.rs @@ -0,0 +1,15 @@ +//! Advanced Discord integration with threading support. +//! +//! This module provides Discord thread management for PR-related conversations, +//! allowing follow-up messages to be posted in dedicated threads. + +mod client; +mod thread_manager; +mod types; + +pub use client::DiscordClient; +pub use thread_manager::ThreadManager; +pub use types::{ + CreateMessageParams, CreateThreadParams, DiscordChannel, DiscordMessage, DiscordThread, + DiscordUser, ThreadState, +}; diff --git a/src/discord/thread_manager.rs b/src/discord/thread_manager.rs new file mode 100644 index 00000000..c2c0bac0 --- /dev/null +++ b/src/discord/thread_manager.rs @@ -0,0 +1,1558 @@ +//! Thread manager for PR discussions. + +use super::client::{DiscordClient, DiscordHttpClient, ReqwestDiscordClient}; +use super::types::{CreateMessageParams, CreateThreadParams, MessageEmbed, ThreadState}; +use crate::error::{Error, Result}; +use crate::types::Issue; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// Colors for different notification types. +mod colors { + pub const SUCCESS: u32 = 0x2ecc71; // Green + pub const ERROR: u32 = 0xe74c3c; // Red + pub const INFO: u32 = 0x3498db; // Blue + pub const WARNING: u32 = 0xf39c12; // Orange + pub const PURPLE: u32 = 0x9b59b6; // Purple + pub const REVIEW: u32 = 0x5865f2; // Discord blurple +} + +/// Manages Discord threads for PR discussions. +pub struct ThreadManager { + client: DiscordClient, + channel_id: String, + /// Map of PR URL -> Thread state + threads: Arc>>, + /// User ID to mention + user_id: Option, +} + +impl ThreadManager { + /// Create a new thread manager. + pub fn new( + bot_token: impl Into, + channel_id: impl Into, + user_id: Option, + ) -> Result { + let client = DiscordClient::new(bot_token)?; + Ok(Self { + client, + channel_id: channel_id.into(), + threads: Arc::new(RwLock::new(HashMap::new())), + user_id, + }) + } +} + +impl ThreadManager { + /// Create a new thread manager with a custom Discord client. + pub fn with_client( + client: DiscordClient, + channel_id: impl Into, + user_id: Option, + ) -> Self { + Self { + client, + channel_id: channel_id.into(), + threads: Arc::new(RwLock::new(HashMap::new())), + user_id, + } + } + + /// Get user mention string if configured. + fn user_mention(&self) -> Option { + self.user_id.as_ref().map(|id| format!("<@{}>", id)) + } + + /// Create a thread for a new PR. + pub async fn create_pr_thread( + &self, + issue: &Issue, + pr_url: &str, + pr_number: i64, + ) -> Result { + // Check if thread already exists + { + let threads = self.threads.read().await; + if let Some(state) = threads.get(pr_url) { + return Ok(state.clone()); + } + } + + // Create thread name + let thread_name = format!("PR #{}: {} ({})", pr_number, issue.short_id, issue.source); + + // Create thread + let thread = self + .client + .create_thread(&self.channel_id, CreateThreadParams::public(&thread_name)) + .await?; + + // Send initial message to thread + let mention = self.user_mention(); + let content = match mention { + Some(m) => format!("{} New PR created for issue {}", m, issue.short_id), + None => format!("New PR created for issue {}", issue.short_id), + }; + + let embed = MessageEmbed::new() + .title(format!("PR Created: {}", issue.short_id)) + .description(&issue.title) + .url(pr_url) + .color(colors::SUCCESS) + .field( + "Issue", + format!("[{}]({})", issue.short_id, issue.url), + true, + ) + .field("Source", &issue.source, true) + .field("Priority", issue.priority.to_string(), true) + .footer("Claude Watchers") + .timestamp(chrono::Utc::now().to_rfc3339()); + + let message = self + .client + .send_message(&thread.id, CreateMessageParams::with_embed(content, embed)) + .await?; + + // Create and store thread state + let state = ThreadState { + thread_id: thread.id.clone(), + thread_name: thread.name, + channel_id: self.channel_id.clone(), + pr_url: pr_url.to_string(), + issue_id: issue.id.clone(), + source: issue.source.clone(), + created_at: chrono::Utc::now().to_rfc3339(), + is_active: true, + last_message_id: Some(message.id), + }; + + { + let mut threads = self.threads.write().await; + threads.insert(pr_url.to_string(), state.clone()); + } + + Ok(state) + } + + /// Post a review notification to the PR thread. + pub async fn notify_review_submitted( + &self, + pr_url: &str, + reviewer: &str, + review_state: &str, + body: Option<&str>, + ) -> Result<()> { + let thread_id = { + let threads = self.threads.read().await; + threads.get(pr_url).map(|s| s.thread_id.clone()) + }; + + let thread_id = match thread_id { + Some(id) => id, + None => return Ok(()), // No thread for this PR + }; + + let (title, color) = match review_state.to_lowercase().as_str() { + "approved" => ("Review Approved", colors::SUCCESS), + "changes_requested" => ("Changes Requested", colors::WARNING), + "commented" => ("Review Comment", colors::INFO), + "dismissed" => ("Review Dismissed", colors::PURPLE), + _ => ("Review Submitted", colors::REVIEW), + }; + + let mention = self.user_mention(); + let content = match mention { + Some(m) => format!("{} {} by {}", m, title, reviewer), + None => format!("{} by {}", title, reviewer), + }; + + let mut embed = MessageEmbed::new() + .title(title) + .color(color) + .field("Reviewer", reviewer, true) + .field("State", review_state, true) + .footer("Claude Watchers") + .timestamp(chrono::Utc::now().to_rfc3339()); + + if let Some(review_body) = body { + if !review_body.is_empty() { + let truncated = if review_body.len() > 1000 { + format!("{}...", &review_body[..997]) + } else { + review_body.to_string() + }; + embed = embed.description(truncated); + } + } + + self.client + .send_message(&thread_id, CreateMessageParams::with_embed(content, embed)) + .await?; + + Ok(()) + } + + /// Post a review comment to the PR thread. + pub async fn notify_review_comment( + &self, + pr_url: &str, + commenter: &str, + file_path: Option<&str>, + comment: &str, + ) -> Result<()> { + let thread_id = { + let threads = self.threads.read().await; + threads.get(pr_url).map(|s| s.thread_id.clone()) + }; + + let thread_id = match thread_id { + Some(id) => id, + None => return Ok(()), // No thread for this PR + }; + + let truncated_comment = if comment.len() > 1000 { + format!("{}...", &comment[..997]) + } else { + comment.to_string() + }; + + let mut embed = MessageEmbed::new() + .title("Review Comment") + .description(&truncated_comment) + .color(colors::INFO) + .field("By", commenter, true) + .footer("Claude Watchers") + .timestamp(chrono::Utc::now().to_rfc3339()); + + if let Some(path) = file_path { + embed = embed.field("File", format!("`{}`", path), true); + } + + let content = format!("Comment from {}", commenter); + + self.client + .send_message(&thread_id, CreateMessageParams::with_embed(content, embed)) + .await?; + + Ok(()) + } + + /// Notify that an agent is working on review comments. + pub async fn notify_agent_started(&self, pr_url: &str, task_description: &str) -> Result<()> { + let thread_id = { + let threads = self.threads.read().await; + threads.get(pr_url).map(|s| s.thread_id.clone()) + }; + + let thread_id = match thread_id { + Some(id) => id, + None => return Ok(()), // No thread for this PR + }; + + let mention = self.user_mention(); + let content = match mention { + Some(m) => format!("{} Agent started working on review feedback", m), + None => "Agent started working on review feedback".to_string(), + }; + + let embed = MessageEmbed::new() + .title("Agent Working") + .description(task_description) + .color(colors::INFO) + .footer("Claude Watchers") + .timestamp(chrono::Utc::now().to_rfc3339()); + + self.client + .send_message(&thread_id, CreateMessageParams::with_embed(content, embed)) + .await?; + + Ok(()) + } + + /// Notify that an agent completed its work. + pub async fn notify_agent_completed( + &self, + pr_url: &str, + commit_url: Option<&str>, + ) -> Result<()> { + let thread_id = { + let threads = self.threads.read().await; + threads.get(pr_url).map(|s| s.thread_id.clone()) + }; + + let thread_id = match thread_id { + Some(id) => id, + None => return Ok(()), // No thread for this PR + }; + + let mention = self.user_mention(); + let content = match mention { + Some(m) => format!("{} Agent completed review feedback", m), + None => "Agent completed review feedback".to_string(), + }; + + let mut embed = MessageEmbed::new() + .title("Agent Completed") + .description("Review feedback has been addressed") + .color(colors::SUCCESS) + .footer("Claude Watchers") + .timestamp(chrono::Utc::now().to_rfc3339()); + + if let Some(url) = commit_url { + embed = embed.field("Commit", format!("[View changes]({})", url), false); + } + + self.client + .send_message(&thread_id, CreateMessageParams::with_embed(content, embed)) + .await?; + + Ok(()) + } + + /// Notify that an agent failed. + pub async fn notify_agent_failed(&self, pr_url: &str, error: &str) -> Result<()> { + let thread_id = { + let threads = self.threads.read().await; + threads.get(pr_url).map(|s| s.thread_id.clone()) + }; + + let thread_id = match thread_id { + Some(id) => id, + None => return Ok(()), // No thread for this PR + }; + + let mention = self.user_mention(); + let content = match mention { + Some(m) => format!("{} Agent failed to address review feedback", m), + None => "Agent failed to address review feedback".to_string(), + }; + + let truncated_error = if error.len() > 1000 { + format!("{}...", &error[..997]) + } else { + error.to_string() + }; + + let embed = MessageEmbed::new() + .title("Agent Failed") + .description(&truncated_error) + .color(colors::ERROR) + .footer("Claude Watchers") + .timestamp(chrono::Utc::now().to_rfc3339()); + + self.client + .send_message(&thread_id, CreateMessageParams::with_embed(content, embed)) + .await?; + + Ok(()) + } + + /// Notify that the PR was merged. + pub async fn notify_pr_merged(&self, pr_url: &str) -> Result<()> { + let thread_id = { + let threads = self.threads.read().await; + threads.get(pr_url).map(|s| s.thread_id.clone()) + }; + + let thread_id = match thread_id { + Some(id) => id, + None => return Ok(()), // No thread for this PR + }; + + let mention = self.user_mention(); + let content = match mention { + Some(m) => format!("{} PR merged!", m), + None => "PR merged!".to_string(), + }; + + let embed = MessageEmbed::new() + .title("PR Merged") + .description("The pull request has been merged. Issue resolved.") + .color(colors::SUCCESS) + .footer("Claude Watchers") + .timestamp(chrono::Utc::now().to_rfc3339()); + + self.client + .send_message(&thread_id, CreateMessageParams::with_embed(content, embed)) + .await?; + + // Archive the thread + self.client.archive_thread(&thread_id).await?; + + // Update thread state + { + let mut threads = self.threads.write().await; + if let Some(state) = threads.get_mut(pr_url) { + state.is_active = false; + } + } + + Ok(()) + } + + /// Notify that the PR was closed without merging. + pub async fn notify_pr_closed(&self, pr_url: &str) -> Result<()> { + let thread_id = { + let threads = self.threads.read().await; + threads.get(pr_url).map(|s| s.thread_id.clone()) + }; + + let thread_id = match thread_id { + Some(id) => id, + None => return Ok(()), // No thread for this PR + }; + + let mention = self.user_mention(); + let content = match mention { + Some(m) => format!("{} PR closed without merging", m), + None => "PR closed without merging".to_string(), + }; + + let embed = MessageEmbed::new() + .title("PR Closed") + .description("The pull request was closed without merging.") + .color(colors::WARNING) + .footer("Claude Watchers") + .timestamp(chrono::Utc::now().to_rfc3339()); + + self.client + .send_message(&thread_id, CreateMessageParams::with_embed(content, embed)) + .await?; + + // Archive the thread + self.client.archive_thread(&thread_id).await?; + + // Update thread state + { + let mut threads = self.threads.write().await; + if let Some(state) = threads.get_mut(pr_url) { + state.is_active = false; + } + } + + Ok(()) + } + + /// Send a custom message to a PR thread. + pub async fn send_to_thread(&self, pr_url: &str, message: &str) -> Result<()> { + let thread_id = { + let threads = self.threads.read().await; + threads.get(pr_url).map(|s| s.thread_id.clone()) + }; + + let thread_id = match thread_id { + Some(id) => id, + None => { + return Err(Error::notifier( + "discord", + format!("No thread found for PR: {}", pr_url), + )) + } + }; + + self.client + .send_message(&thread_id, CreateMessageParams::text(message)) + .await?; + + Ok(()) + } + + /// Get thread state for a PR. + pub async fn get_thread_state(&self, pr_url: &str) -> Option { + let threads = self.threads.read().await; + threads.get(pr_url).cloned() + } + + /// Load thread states from storage. + pub async fn load_threads(&self, states: Vec) { + let mut threads = self.threads.write().await; + for state in states { + if state.is_active { + threads.insert(state.pr_url.clone(), state); + } + } + } + + /// Get all active thread states. + pub async fn get_active_threads(&self) -> Vec { + let threads = self.threads.read().await; + threads.values().filter(|s| s.is_active).cloned().collect() + } + + /// Get all thread states (for persistence). + pub async fn get_all_threads(&self) -> Vec { + let threads = self.threads.read().await; + threads.values().cloned().collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::discord::client::mock::MockDiscordClient; + use crate::types::{IssuePriority, IssueStatus}; + + fn create_test_issue() -> Issue { + Issue { + id: "issue-123".to_string(), + short_id: "TEST-123".to_string(), + title: "Fix the bug".to_string(), + description: Some("Bug description".to_string()), + url: "https://linear.app/test/issue/123".to_string(), + source: "linear".to_string(), + priority: IssuePriority::Medium, + status: IssueStatus::Open, + metadata: Default::default(), + created_at: None, + updated_at: None, + } + } + + fn mock_thread_json() -> &'static str { + r#"{"id": "thread-456", "type": 11, "name": "test-thread", "parent_id": "channel123", "owner_id": "bot123"}"# + } + + fn mock_message_json() -> &'static str { + r#"{"id": "msg-789", "channel_id": "thread-456", "content": "Hello", "timestamp": "2024-01-01T00:00:00Z", "author": {"id": "bot123", "username": "bot"}}"# + } + + fn create_manager_with_mock(mock: MockDiscordClient) -> ThreadManager { + let client = DiscordClient::with_http_client("token", mock).unwrap(); + ThreadManager::with_client(client, "channel123", None) + } + + fn create_manager_with_mock_and_user( + mock: MockDiscordClient, + ) -> ThreadManager { + let client = DiscordClient::with_http_client("token", mock).unwrap(); + ThreadManager::with_client(client, "channel123", Some("user456".to_string())) + } + + #[test] + fn test_thread_manager_requires_token() { + let result = ThreadManager::new("", "channel123", None); + assert!(result.is_err()); + } + + #[test] + fn test_thread_manager_creation() { + let result = ThreadManager::new("test_token", "channel123", Some("user123".to_string())); + assert!(result.is_ok()); + } + + #[test] + fn test_with_client() { + let mock = MockDiscordClient::new(); + let client = DiscordClient::with_http_client("token", mock).unwrap(); + let manager = ThreadManager::with_client(client, "channel123", Some("user".to_string())); + assert_eq!(manager.channel_id, "channel123"); + assert_eq!(manager.user_id, Some("user".to_string())); + } + + #[tokio::test] + async fn test_thread_state_storage() { + let manager = ThreadManager::new("test_token", "channel123", None).unwrap(); + + let state = ThreadState::new( + "thread-1", + "PR: Test", + "channel123", + "https://github.com/test/repo/pull/1", + "issue-1", + "linear", + ); + + manager.load_threads(vec![state.clone()]).await; + + let retrieved = manager + .get_thread_state("https://github.com/test/repo/pull/1") + .await; + assert!(retrieved.is_some()); + assert_eq!(retrieved.unwrap().thread_id, "thread-1"); + } + + #[tokio::test] + async fn test_get_active_threads() { + let manager = ThreadManager::new("test_token", "channel123", None).unwrap(); + + let active_state = ThreadState::new( + "thread-1", + "PR: Active", + "channel123", + "https://github.com/test/repo/pull/1", + "issue-1", + "linear", + ); + + let mut inactive_state = ThreadState::new( + "thread-2", + "PR: Inactive", + "channel123", + "https://github.com/test/repo/pull/2", + "issue-2", + "linear", + ); + inactive_state.is_active = false; + + manager + .load_threads(vec![active_state, inactive_state]) + .await; + + let active = manager.get_active_threads().await; + assert_eq!(active.len(), 1); + assert_eq!(active[0].thread_id, "thread-1"); + } + + #[tokio::test] + async fn test_get_all_threads() { + let manager = ThreadManager::new("test_token", "channel123", None).unwrap(); + + let state1 = ThreadState::new( + "thread-1", + "PR: 1", + "channel123", + "https://pr/1", + "issue-1", + "linear", + ); + let state2 = ThreadState::new( + "thread-2", + "PR: 2", + "channel123", + "https://pr/2", + "issue-2", + "sentry", + ); + let mut state3 = ThreadState::new( + "thread-3", + "PR: 3", + "channel123", + "https://pr/3", + "issue-3", + "github", + ); + state3.is_active = false; + + manager.load_threads(vec![state1, state2, state3]).await; + + // get_all_threads returns all (2 active only since load_threads filters inactive) + let all = manager.get_all_threads().await; + assert_eq!(all.len(), 2); + } + + #[tokio::test] + async fn test_get_thread_state_not_found() { + let manager = ThreadManager::new("test_token", "channel123", None).unwrap(); + + let result = manager.get_thread_state("https://unknown/pr/999").await; + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_send_to_thread_not_found() { + let manager = ThreadManager::new("test_token", "channel123", None).unwrap(); + + let result = manager + .send_to_thread("https://unknown/pr/999", "Hello") + .await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("No thread found")); + } + + #[test] + fn test_user_mention_with_id() { + let manager = ThreadManager::new("token", "channel", Some("user123".to_string())).unwrap(); + let mention = manager.user_mention(); + assert_eq!(mention, Some("<@user123>".to_string())); + } + + #[test] + fn test_user_mention_without_id() { + let manager = ThreadManager::new("token", "channel", None).unwrap(); + let mention = manager.user_mention(); + assert!(mention.is_none()); + } + + #[test] + fn test_colors() { + assert_eq!(colors::SUCCESS, 0x2ecc71); + assert_eq!(colors::ERROR, 0xe74c3c); + assert_eq!(colors::INFO, 0x3498db); + assert_eq!(colors::WARNING, 0xf39c12); + assert_eq!(colors::PURPLE, 0x9b59b6); + assert_eq!(colors::REVIEW, 0x5865f2); + } + + #[tokio::test] + async fn test_load_threads_only_loads_active() { + let manager = ThreadManager::new("token", "channel", None).unwrap(); + + let mut inactive = ThreadState::new("t1", "Test", "ch", "https://pr/1", "i1", "linear"); + inactive.is_active = false; + + let active = ThreadState::new("t2", "Test 2", "ch", "https://pr/2", "i2", "sentry"); + + manager.load_threads(vec![inactive, active]).await; + + // Only active should be loaded + let threads = manager.get_all_threads().await; + assert_eq!(threads.len(), 1); + assert_eq!(threads[0].thread_id, "t2"); + } + + #[tokio::test] + async fn test_notify_review_submitted_no_thread() { + let manager = ThreadManager::new("token", "channel", None).unwrap(); + + // Should return Ok when no thread exists + let result = manager + .notify_review_submitted("https://unknown", "reviewer", "approved", None) + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_review_comment_no_thread() { + let manager = ThreadManager::new("token", "channel", None).unwrap(); + + let result = manager + .notify_review_comment("https://unknown", "commenter", None, "Comment") + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_agent_started_no_thread() { + let manager = ThreadManager::new("token", "channel", None).unwrap(); + + let result = manager + .notify_agent_started("https://unknown", "Working on it") + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_agent_completed_no_thread() { + let manager = ThreadManager::new("token", "channel", None).unwrap(); + + let result = manager + .notify_agent_completed("https://unknown", Some("https://commit")) + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_agent_failed_no_thread() { + let manager = ThreadManager::new("token", "channel", None).unwrap(); + + let result = manager + .notify_agent_failed("https://unknown", "Error message") + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_pr_merged_no_thread() { + let manager = ThreadManager::new("token", "channel", None).unwrap(); + + let result = manager.notify_pr_merged("https://unknown").await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_pr_closed_no_thread() { + let manager = ThreadManager::new("token", "channel", None).unwrap(); + + let result = manager.notify_pr_closed("https://unknown").await; + assert!(result.is_ok()); + } + + #[test] + fn test_create_test_issue() { + let issue = create_test_issue(); + assert_eq!(issue.id, "issue-123"); + assert_eq!(issue.short_id, "TEST-123"); + assert_eq!(issue.source, "linear"); + assert_eq!(issue.priority, IssuePriority::Medium); + } + + #[tokio::test] + async fn test_load_empty_threads() { + let manager = ThreadManager::new("token", "channel", None).unwrap(); + manager.load_threads(vec![]).await; + + let threads = manager.get_all_threads().await; + assert!(threads.is_empty()); + } + + // Mock-based tests for HTTP-dependent functionality + + #[tokio::test] + async fn test_create_pr_thread_success() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/channel123/threads", + 200, + mock_thread_json(), + ); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-456/messages", + 200, + mock_message_json(), + ); + + let manager = create_manager_with_mock(mock); + let issue = create_test_issue(); + let result = manager + .create_pr_thread(&issue, "https://github.com/test/pr/1", 1) + .await; + + assert!(result.is_ok()); + let state = result.unwrap(); + assert_eq!(state.thread_id, "thread-456"); + assert_eq!(state.pr_url, "https://github.com/test/pr/1"); + assert_eq!(state.issue_id, "issue-123"); + assert!(state.is_active); + } + + #[tokio::test] + async fn test_create_pr_thread_with_user_mention() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/channel123/threads", + 200, + mock_thread_json(), + ); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-456/messages", + 200, + mock_message_json(), + ); + + let manager = create_manager_with_mock_and_user(mock); + let issue = create_test_issue(); + let result = manager + .create_pr_thread(&issue, "https://github.com/test/pr/1", 1) + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_create_pr_thread_returns_existing() { + let mock = MockDiscordClient::new(); + let manager = create_manager_with_mock(mock); + + // Pre-load a thread state + let state = ThreadState::new( + "existing-thread", + "PR: Existing", + "channel123", + "https://github.com/test/pr/1", + "issue-123", + "linear", + ); + manager.load_threads(vec![state]).await; + + // Calling create_pr_thread for same PR should return existing + let issue = create_test_issue(); + let result = manager + .create_pr_thread(&issue, "https://github.com/test/pr/1", 1) + .await; + + assert!(result.is_ok()); + assert_eq!(result.unwrap().thread_id, "existing-thread"); + } + + #[tokio::test] + async fn test_create_pr_thread_api_error() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/channel123/threads", + 403, + "Forbidden", + ); + + let manager = create_manager_with_mock(mock); + let issue = create_test_issue(); + let result = manager + .create_pr_thread(&issue, "https://github.com/test/pr/1", 1) + .await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_notify_review_submitted_approved() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 200, + mock_message_json(), + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager + .notify_review_submitted("https://pr/1", "reviewer1", "approved", None) + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_review_submitted_changes_requested() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 200, + mock_message_json(), + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager + .notify_review_submitted( + "https://pr/1", + "reviewer1", + "changes_requested", + Some("Please fix this"), + ) + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_review_submitted_commented() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 200, + mock_message_json(), + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager + .notify_review_submitted("https://pr/1", "reviewer1", "commented", Some("Looks good")) + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_review_submitted_dismissed() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 200, + mock_message_json(), + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager + .notify_review_submitted("https://pr/1", "reviewer1", "dismissed", None) + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_review_submitted_unknown_state() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 200, + mock_message_json(), + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager + .notify_review_submitted("https://pr/1", "reviewer1", "unknown_state", None) + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_review_submitted_with_long_body() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 200, + mock_message_json(), + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + // Body longer than 1000 chars should be truncated + let long_body = "x".repeat(1500); + let result = manager + .notify_review_submitted("https://pr/1", "reviewer1", "commented", Some(&long_body)) + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_review_submitted_with_user_mention() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 200, + mock_message_json(), + ); + + let manager = create_manager_with_mock_and_user(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager + .notify_review_submitted("https://pr/1", "reviewer1", "approved", None) + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_review_comment_with_file() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 200, + mock_message_json(), + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager + .notify_review_comment( + "https://pr/1", + "commenter", + Some("src/main.rs"), + "Fix this line", + ) + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_review_comment_without_file() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 200, + mock_message_json(), + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager + .notify_review_comment("https://pr/1", "commenter", None, "General comment") + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_review_comment_long_comment() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 200, + mock_message_json(), + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let long_comment = "x".repeat(1500); + let result = manager + .notify_review_comment("https://pr/1", "commenter", None, &long_comment) + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_agent_started_with_thread() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 200, + mock_message_json(), + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager + .notify_agent_started("https://pr/1", "Working on fixes") + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_agent_started_with_user_mention() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 200, + mock_message_json(), + ); + + let manager = create_manager_with_mock_and_user(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager + .notify_agent_started("https://pr/1", "Working on fixes") + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_agent_completed_with_commit() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 200, + mock_message_json(), + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager + .notify_agent_completed("https://pr/1", Some("https://github.com/commit/abc123")) + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_agent_completed_without_commit() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 200, + mock_message_json(), + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager.notify_agent_completed("https://pr/1", None).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_agent_completed_with_user_mention() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 200, + mock_message_json(), + ); + + let manager = create_manager_with_mock_and_user(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager.notify_agent_completed("https://pr/1", None).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_agent_failed_with_thread() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 200, + mock_message_json(), + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager + .notify_agent_failed("https://pr/1", "Error occurred") + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_agent_failed_long_error() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 200, + mock_message_json(), + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let long_error = "x".repeat(1500); + let result = manager + .notify_agent_failed("https://pr/1", &long_error) + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_agent_failed_with_user_mention() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 200, + mock_message_json(), + ); + + let manager = create_manager_with_mock_and_user(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager.notify_agent_failed("https://pr/1", "Error").await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_pr_merged_with_thread() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 200, + mock_message_json(), + ); + mock.mock_patch( + "https://discord.com/api/v10/channels/thread-1", + 200, + mock_thread_json(), + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager.notify_pr_merged("https://pr/1").await; + assert!(result.is_ok()); + + // Thread should be marked inactive + let thread_state = manager.get_thread_state("https://pr/1").await.unwrap(); + assert!(!thread_state.is_active); + } + + #[tokio::test] + async fn test_notify_pr_merged_with_user_mention() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 200, + mock_message_json(), + ); + mock.mock_patch( + "https://discord.com/api/v10/channels/thread-1", + 200, + mock_thread_json(), + ); + + let manager = create_manager_with_mock_and_user(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager.notify_pr_merged("https://pr/1").await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_pr_closed_with_thread() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 200, + mock_message_json(), + ); + mock.mock_patch( + "https://discord.com/api/v10/channels/thread-1", + 200, + mock_thread_json(), + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager.notify_pr_closed("https://pr/1").await; + assert!(result.is_ok()); + + // Thread should be marked inactive + let thread_state = manager.get_thread_state("https://pr/1").await.unwrap(); + assert!(!thread_state.is_active); + } + + #[tokio::test] + async fn test_notify_pr_closed_with_user_mention() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 200, + mock_message_json(), + ); + mock.mock_patch( + "https://discord.com/api/v10/channels/thread-1", + 200, + mock_thread_json(), + ); + + let manager = create_manager_with_mock_and_user(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager.notify_pr_closed("https://pr/1").await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_send_to_thread_success() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 200, + mock_message_json(), + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager + .send_to_thread("https://pr/1", "Hello thread!") + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_send_to_thread_api_error() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 403, + "Forbidden", + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager.send_to_thread("https://pr/1", "Hello").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_notify_review_submitted_api_error() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 500, + "Internal error", + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager + .notify_review_submitted("https://pr/1", "reviewer1", "approved", None) + .await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_notify_review_comment_api_error() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 500, + "Internal error", + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager + .notify_review_comment("https://pr/1", "commenter", None, "Comment") + .await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_notify_agent_started_api_error() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 500, + "Internal error", + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager + .notify_agent_started("https://pr/1", "Working") + .await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_notify_agent_completed_api_error() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 500, + "Internal error", + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager.notify_agent_completed("https://pr/1", None).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_notify_agent_failed_api_error() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 500, + "Internal error", + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager.notify_agent_failed("https://pr/1", "Error").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_notify_pr_merged_send_message_error() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 500, + "Internal error", + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager.notify_pr_merged("https://pr/1").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_notify_pr_merged_archive_error() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 200, + mock_message_json(), + ); + mock.mock_patch( + "https://discord.com/api/v10/channels/thread-1", + 500, + "Archive failed", + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager.notify_pr_merged("https://pr/1").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_notify_pr_closed_send_message_error() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 500, + "Internal error", + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager.notify_pr_closed("https://pr/1").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_notify_pr_closed_archive_error() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 200, + mock_message_json(), + ); + mock.mock_patch( + "https://discord.com/api/v10/channels/thread-1", + 500, + "Archive failed", + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + let result = manager.notify_pr_closed("https://pr/1").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_notify_review_submitted_empty_body() { + let mock = MockDiscordClient::new(); + mock.mock_post( + "https://discord.com/api/v10/channels/thread-1/messages", + 200, + mock_message_json(), + ); + + let manager = create_manager_with_mock(mock); + let state = ThreadState::new("thread-1", "PR: Test", "ch", "https://pr/1", "i1", "linear"); + manager.load_threads(vec![state]).await; + + // Empty body should not add description to embed + let result = manager + .notify_review_submitted("https://pr/1", "reviewer1", "commented", Some("")) + .await; + assert!(result.is_ok()); + } +} diff --git a/src/discord/types.rs b/src/discord/types.rs new file mode 100644 index 00000000..1fb79782 --- /dev/null +++ b/src/discord/types.rs @@ -0,0 +1,619 @@ +//! Discord API types for thread management. + +use serde::{Deserialize, Serialize}; + +/// A Discord user. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiscordUser { + /// User ID. + pub id: String, + /// Username. + pub username: String, + /// Discriminator (legacy, may be 0). + #[serde(default)] + pub discriminator: String, + /// Avatar hash. + pub avatar: Option, + /// Whether the user is a bot. + #[serde(default)] + pub bot: bool, +} + +/// A Discord channel. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiscordChannel { + /// Channel ID. + pub id: String, + /// Channel type. + #[serde(rename = "type")] + pub channel_type: u8, + /// Guild ID (if applicable). + pub guild_id: Option, + /// Channel name. + pub name: Option, + /// Parent channel ID (for threads). + pub parent_id: Option, +} + +/// A Discord thread. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiscordThread { + /// Thread channel ID. + pub id: String, + /// Thread type (11 = public, 12 = private). + #[serde(rename = "type")] + pub thread_type: u8, + /// Guild ID. + pub guild_id: Option, + /// Thread name. + pub name: String, + /// Parent channel ID. + pub parent_id: Option, + /// Owner ID. + pub owner_id: Option, + /// Whether the thread is archived. + #[serde(default)] + pub archived: bool, + /// Whether the thread is locked. + #[serde(default)] + pub locked: bool, + /// Message count. + pub message_count: Option, + /// Member count. + pub member_count: Option, +} + +impl DiscordThread { + /// Check if the thread is still active (not archived or locked). + pub fn is_active(&self) -> bool { + !self.archived && !self.locked + } +} + +/// A Discord message. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiscordMessage { + /// Message ID. + pub id: String, + /// Channel ID. + pub channel_id: String, + /// Message author. + pub author: Option, + /// Message content. + pub content: String, + /// Timestamp. + pub timestamp: String, + /// Thread associated with this message (if started). + pub thread: Option, +} + +/// Parameters for creating a thread. +#[derive(Debug, Clone, Serialize)] +pub struct CreateThreadParams { + /// Thread name (1-100 characters). + pub name: String, + /// Auto-archive duration in minutes (60, 1440, 4320, 10080). + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_archive_duration: Option, + /// Thread type (11 = public, 12 = private). + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub thread_type: Option, + /// Whether to send a rate limit error rather than being slow. + #[serde(skip_serializing_if = "Option::is_none")] + pub rate_limit_per_user: Option, +} + +impl CreateThreadParams { + /// Create params for a public thread. + pub fn public(name: impl Into) -> Self { + let name = name.into(); + let name = if name.len() > 100 { + name[..100].to_string() + } else { + name + }; + Self { + name, + auto_archive_duration: Some(10080), // 7 days + thread_type: Some(11), // Public thread + rate_limit_per_user: None, + } + } + + /// Create params for a private thread. + pub fn private(name: impl Into) -> Self { + let name = name.into(); + let name = if name.len() > 100 { + name[..100].to_string() + } else { + name + }; + Self { + name, + auto_archive_duration: Some(10080), // 7 days + thread_type: Some(12), // Private thread + rate_limit_per_user: None, + } + } + + /// Set auto-archive duration. + pub fn with_auto_archive(mut self, minutes: u32) -> Self { + self.auto_archive_duration = Some(minutes); + self + } +} + +/// Parameters for creating a message. +#[derive(Debug, Clone, Serialize)] +pub struct CreateMessageParams { + /// Message content (up to 2000 characters). + pub content: String, + /// Whether this is a TTS message. + #[serde(skip_serializing_if = "Option::is_none")] + pub tts: Option, + /// Embeds. + #[serde(skip_serializing_if = "Option::is_none")] + pub embeds: Option>, +} + +impl CreateMessageParams { + /// Create simple text message. + pub fn text(content: impl Into) -> Self { + let content = content.into(); + let content = if content.len() > 2000 { + format!("{}...", &content[..1997]) + } else { + content + }; + Self { + content, + tts: None, + embeds: None, + } + } + + /// Create message with embed. + pub fn with_embed(content: impl Into, embed: MessageEmbed) -> Self { + let content = content.into(); + let content = if content.len() > 2000 { + format!("{}...", &content[..1997]) + } else { + content + }; + Self { + content, + tts: None, + embeds: Some(vec![embed]), + } + } +} + +/// A Discord embed. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessageEmbed { + /// Embed title. + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + /// Embed description. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Embed URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + /// Embed color. + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, + /// Embed fields. + #[serde(skip_serializing_if = "Option::is_none")] + pub fields: Option>, + /// Footer. + #[serde(skip_serializing_if = "Option::is_none")] + pub footer: Option, + /// Timestamp. + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp: Option, +} + +impl MessageEmbed { + /// Create a simple embed. + pub fn new() -> Self { + Self { + title: None, + description: None, + url: None, + color: None, + fields: None, + footer: None, + timestamp: None, + } + } + + /// Set title. + pub fn title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + /// Set description. + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + /// Set URL. + pub fn url(mut self, url: impl Into) -> Self { + self.url = Some(url.into()); + self + } + + /// Set color. + pub fn color(mut self, color: u32) -> Self { + self.color = Some(color); + self + } + + /// Add a field. + pub fn field( + mut self, + name: impl Into, + value: impl Into, + inline: bool, + ) -> Self { + let field = EmbedField { + name: name.into(), + value: value.into(), + inline: Some(inline), + }; + self.fields.get_or_insert_with(Vec::new).push(field); + self + } + + /// Set footer. + pub fn footer(mut self, text: impl Into) -> Self { + self.footer = Some(EmbedFooter { text: text.into() }); + self + } + + /// Set timestamp. + pub fn timestamp(mut self, ts: impl Into) -> Self { + self.timestamp = Some(ts.into()); + self + } +} + +impl Default for MessageEmbed { + fn default() -> Self { + Self::new() + } +} + +/// An embed field. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmbedField { + /// Field name. + pub name: String, + /// Field value. + pub value: String, + /// Whether inline. + #[serde(skip_serializing_if = "Option::is_none")] + pub inline: Option, +} + +/// An embed footer. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmbedFooter { + /// Footer text. + pub text: String, +} + +/// Thread state for tracking. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ThreadState { + /// Thread ID. + pub thread_id: String, + /// Thread name. + pub thread_name: String, + /// Parent channel ID. + pub channel_id: String, + /// Associated PR URL. + pub pr_url: String, + /// Associated issue ID. + pub issue_id: String, + /// Issue source. + pub source: String, + /// Created timestamp. + pub created_at: String, + /// Whether the thread is still active. + pub is_active: bool, + /// Last message ID in thread. + pub last_message_id: Option, +} + +impl ThreadState { + /// Create a new thread state. + pub fn new( + thread_id: impl Into, + thread_name: impl Into, + channel_id: impl Into, + pr_url: impl Into, + issue_id: impl Into, + source: impl Into, + ) -> Self { + Self { + thread_id: thread_id.into(), + thread_name: thread_name.into(), + channel_id: channel_id.into(), + pr_url: pr_url.into(), + issue_id: issue_id.into(), + source: source.into(), + created_at: chrono::Utc::now().to_rfc3339(), + is_active: true, + last_message_id: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_thread_params_public() { + let params = CreateThreadParams::public("Test Thread"); + assert_eq!(params.name, "Test Thread"); + assert_eq!(params.thread_type, Some(11)); + assert_eq!(params.auto_archive_duration, Some(10080)); + } + + #[test] + fn test_create_thread_params_private() { + let params = CreateThreadParams::private("Private Thread"); + assert_eq!(params.name, "Private Thread"); + assert_eq!(params.thread_type, Some(12)); + } + + #[test] + fn test_create_thread_params_truncates_long_name() { + let long_name = "a".repeat(150); + let params = CreateThreadParams::public(&long_name); + assert_eq!(params.name.len(), 100); + } + + #[test] + fn test_create_message_params_text() { + let params = CreateMessageParams::text("Hello"); + assert_eq!(params.content, "Hello"); + assert!(params.embeds.is_none()); + } + + #[test] + fn test_create_message_params_truncates() { + let long_content = "a".repeat(3000); + let params = CreateMessageParams::text(&long_content); + assert_eq!(params.content.len(), 2000); + assert!(params.content.ends_with("...")); + } + + #[test] + fn test_message_embed_builder() { + let embed = MessageEmbed::new() + .title("Test") + .description("Desc") + .color(0xFF0000) + .field("Field1", "Value1", true) + .footer("Footer"); + + assert_eq!(embed.title, Some("Test".to_string())); + assert_eq!(embed.description, Some("Desc".to_string())); + assert_eq!(embed.color, Some(0xFF0000)); + assert!(embed.fields.is_some()); + assert_eq!(embed.fields.as_ref().unwrap().len(), 1); + assert!(embed.footer.is_some()); + } + + #[test] + fn test_thread_state_new() { + let state = ThreadState::new( + "123456", + "PR: Fix Bug", + "channel123", + "https://github.com/user/repo/pull/1", + "issue-1", + "linear", + ); + + assert_eq!(state.thread_id, "123456"); + assert_eq!(state.thread_name, "PR: Fix Bug"); + assert!(state.is_active); + assert!(state.last_message_id.is_none()); + } + + #[test] + fn test_discord_thread_is_active() { + let active_thread = DiscordThread { + id: "123".to_string(), + thread_type: 11, + guild_id: None, + name: "Test".to_string(), + parent_id: None, + owner_id: None, + archived: false, + locked: false, + message_count: None, + member_count: None, + }; + assert!(active_thread.is_active()); + + let archived_thread = DiscordThread { + archived: true, + ..active_thread.clone() + }; + assert!(!archived_thread.is_active()); + + let locked_thread = DiscordThread { + locked: true, + ..active_thread + }; + assert!(!locked_thread.is_active()); + } + + #[test] + fn test_create_thread_params_with_auto_archive() { + let params = CreateThreadParams::public("Test").with_auto_archive(60); + assert_eq!(params.auto_archive_duration, Some(60)); + } + + #[test] + fn test_create_message_params_with_embed() { + let embed = MessageEmbed::new().title("Title").description("Desc"); + let params = CreateMessageParams::with_embed("Message", embed); + assert!(params.embeds.is_some()); + assert_eq!(params.embeds.unwrap().len(), 1); + } + + #[test] + fn test_message_embed_url() { + let embed = MessageEmbed::new().url("https://example.com"); + assert_eq!(embed.url, Some("https://example.com".to_string())); + } + + #[test] + fn test_message_embed_timestamp() { + let embed = MessageEmbed::new().timestamp("2024-01-01T00:00:00Z"); + assert_eq!(embed.timestamp, Some("2024-01-01T00:00:00Z".to_string())); + } + + #[test] + fn test_message_embed_multiple_fields() { + let embed = MessageEmbed::new() + .field("F1", "V1", true) + .field("F2", "V2", false) + .field("F3", "V3", true); + let fields = embed.fields.unwrap(); + assert_eq!(fields.len(), 3); + assert_eq!(fields[0].name, "F1"); + assert!(fields[0].inline.unwrap()); + assert!(!fields[1].inline.unwrap()); + } + + #[test] + fn test_thread_state_set_last_message() { + let mut state = ThreadState::new("t1", "Thread", "c1", "https://pr", "i1", "linear"); + assert!(state.last_message_id.is_none()); + state.last_message_id = Some("msg123".to_string()); + assert_eq!(state.last_message_id.unwrap(), "msg123"); + } + + #[test] + fn test_thread_state_deactivate() { + let mut state = ThreadState::new("t1", "Thread", "c1", "https://pr", "i1", "linear"); + assert!(state.is_active); + state.is_active = false; + assert!(!state.is_active); + } + + #[test] + fn test_discord_user_serialization() { + let user = DiscordUser { + id: "123".to_string(), + username: "testuser".to_string(), + discriminator: "0001".to_string(), + avatar: Some("abc123".to_string()), + bot: false, + }; + let json = serde_json::to_string(&user).unwrap(); + assert!(json.contains("testuser")); + assert!(json.contains("0001")); + } + + #[test] + fn test_discord_user_bot_flag() { + let bot_user = DiscordUser { + id: "456".to_string(), + username: "bot".to_string(), + discriminator: "0000".to_string(), + avatar: None, + bot: true, + }; + assert!(bot_user.bot); + } + + #[test] + fn test_discord_channel_serialization() { + let channel = DiscordChannel { + id: "123".to_string(), + channel_type: 0, + guild_id: Some("guild123".to_string()), + name: Some("general".to_string()), + parent_id: None, + }; + let json = serde_json::to_string(&channel).unwrap(); + assert!(json.contains("general")); + assert!(json.contains("guild123")); + } + + #[test] + fn test_discord_message_serialization() { + let message = DiscordMessage { + id: "msg1".to_string(), + channel_id: "chan1".to_string(), + author: Some(DiscordUser { + id: "user1".to_string(), + username: "author".to_string(), + discriminator: "0".to_string(), + avatar: None, + bot: false, + }), + content: "Hello world".to_string(), + timestamp: "2024-01-01T00:00:00Z".to_string(), + thread: None, + }; + let json = serde_json::to_string(&message).unwrap(); + assert!(json.contains("Hello world")); + assert!(json.contains("author")); + } + + #[test] + fn test_embed_field_serialization() { + let field = EmbedField { + name: "Test".to_string(), + value: "Value".to_string(), + inline: Some(true), + }; + let json = serde_json::to_string(&field).unwrap(); + assert!(json.contains("Test")); + assert!(json.contains("Value")); + assert!(json.contains("true")); + } + + #[test] + fn test_embed_footer_serialization() { + let footer = EmbedFooter { + text: "Footer text".to_string(), + }; + let json = serde_json::to_string(&footer).unwrap(); + assert!(json.contains("Footer text")); + } + + #[test] + fn test_message_embed_default() { + let embed = MessageEmbed::default(); + assert!(embed.title.is_none()); + assert!(embed.description.is_none()); + assert!(embed.color.is_none()); + } + + #[test] + fn test_create_thread_params_exactly_100_chars() { + let name = "a".repeat(100); + let params = CreateThreadParams::public(&name); + assert_eq!(params.name.len(), 100); + } + + #[test] + fn test_create_message_params_exactly_2000_chars() { + let content = "a".repeat(2000); + let params = CreateMessageParams::text(&content); + assert_eq!(params.content.len(), 2000); + } +} diff --git a/src/env_writer.rs b/src/env_writer.rs new file mode 100644 index 00000000..237be13d --- /dev/null +++ b/src/env_writer.rs @@ -0,0 +1,360 @@ +//! Utility for writing to .env files. + +use crate::error::{Error, Result}; +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +/// Updates or appends key-value pairs in a .env file. +/// +/// This function: +/// - Creates the file if it doesn't exist +/// - Updates existing keys with new values +/// - Appends new keys at the end +/// - Preserves comments and formatting +pub fn update_env_file(path: &Path, updates: &HashMap) -> Result<()> { + let content = if path.exists() { + fs::read_to_string(path) + .map_err(|e| Error::config(format!("Failed to read .env file at {:?}: {}", path, e)))? + } else { + String::new() + }; + + let updated_content = update_env_content(&content, updates); + + fs::write(path, updated_content) + .map_err(|e| Error::config(format!("Failed to write .env file at {:?}: {}", path, e)))?; + + Ok(()) +} + +/// Updates .env content string with new key-value pairs. +/// +/// Returns the updated content. +fn update_env_content(content: &str, updates: &HashMap) -> String { + let mut result_lines: Vec = Vec::new(); + let mut updated_keys: std::collections::HashSet = std::collections::HashSet::new(); + + for line in content.lines() { + let trimmed = line.trim(); + + // Preserve empty lines and comments + if trimmed.is_empty() || trimmed.starts_with('#') { + result_lines.push(line.to_string()); + continue; + } + + // Parse key=value (handle various formats) + if let Some((key, _)) = parse_env_line(trimmed) { + if let Some(new_value) = updates.get(&key) { + // Update existing key + result_lines.push(format!("{}={}", key, quote_value(new_value))); + updated_keys.insert(key); + } else { + // Keep original line + result_lines.push(line.to_string()); + } + } else { + // Keep unrecognized lines + result_lines.push(line.to_string()); + } + } + + // Append new keys that weren't in the original file + let mut new_keys: Vec<_> = updates + .iter() + .filter(|(k, _)| !updated_keys.contains(*k)) + .collect(); + new_keys.sort_by(|(a, _), (b, _)| a.cmp(b)); // Sort for deterministic output + + if !new_keys.is_empty() { + // Add a blank line before new keys if the file isn't empty and doesn't end with blank line + if !result_lines.is_empty() { + let last_line = result_lines.last().unwrap(); + if !last_line.trim().is_empty() { + result_lines.push(String::new()); + } + } + + // Add comment for auto-generated section + result_lines.push("# Auto-configured webhook secrets".to_string()); + + for (key, value) in new_keys { + result_lines.push(format!("{}={}", key, quote_value(value))); + } + } + + // Ensure file ends with newline + let mut result = result_lines.join("\n"); + if !result.is_empty() && !result.ends_with('\n') { + result.push('\n'); + } + + result +} + +/// Parse a line into key and value. +fn parse_env_line(line: &str) -> Option<(String, String)> { + let line = line.trim(); + + // Skip comments and empty lines + if line.is_empty() || line.starts_with('#') { + return None; + } + + // Find the first '=' sign + let eq_pos = line.find('=')?; + let key = line[..eq_pos].trim().to_string(); + let value = line[eq_pos + 1..].trim(); + + // Remove surrounding quotes if present + let value = if (value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\'')) + { + value[1..value.len() - 1].to_string() + } else { + value.to_string() + }; + + Some((key, value)) +} + +/// Quote a value if it contains special characters. +fn quote_value(value: &str) -> String { + // Quote if contains spaces, quotes, or special shell characters + if value.contains(' ') + || value.contains('"') + || value.contains('\'') + || value.contains('#') + || value.contains('$') + || value.contains('\n') + || value.contains('\\') + { + // Use double quotes and escape internal quotes + let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{}\"", escaped) + } else { + value.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use tempfile::TempDir; + + #[test] + fn test_parse_env_line_simple() { + let (key, value) = parse_env_line("KEY=value").unwrap(); + assert_eq!(key, "KEY"); + assert_eq!(value, "value"); + } + + #[test] + fn test_parse_env_line_with_spaces() { + let (key, value) = parse_env_line(" KEY = value ").unwrap(); + assert_eq!(key, "KEY"); + assert_eq!(value, "value"); + } + + #[test] + fn test_parse_env_line_double_quoted() { + let (key, value) = parse_env_line("KEY=\"quoted value\"").unwrap(); + assert_eq!(key, "KEY"); + assert_eq!(value, "quoted value"); + } + + #[test] + fn test_parse_env_line_single_quoted() { + let (key, value) = parse_env_line("KEY='quoted value'").unwrap(); + assert_eq!(key, "KEY"); + assert_eq!(value, "quoted value"); + } + + #[test] + fn test_parse_env_line_empty_value() { + let (key, value) = parse_env_line("KEY=").unwrap(); + assert_eq!(key, "KEY"); + assert_eq!(value, ""); + } + + #[test] + fn test_parse_env_line_comment() { + assert!(parse_env_line("# comment").is_none()); + } + + #[test] + fn test_parse_env_line_empty() { + assert!(parse_env_line("").is_none()); + assert!(parse_env_line(" ").is_none()); + } + + #[test] + fn test_parse_env_line_value_with_equals() { + let (key, value) = parse_env_line("KEY=value=with=equals").unwrap(); + assert_eq!(key, "KEY"); + assert_eq!(value, "value=with=equals"); + } + + #[test] + fn test_quote_value_simple() { + assert_eq!(quote_value("simple"), "simple"); + } + + #[test] + fn test_quote_value_with_spaces() { + assert_eq!(quote_value("with spaces"), "\"with spaces\""); + } + + #[test] + fn test_quote_value_with_quotes() { + assert_eq!(quote_value("with\"quote"), "\"with\\\"quote\""); + } + + #[test] + fn test_quote_value_with_hash() { + assert_eq!(quote_value("with#hash"), "\"with#hash\""); + } + + #[test] + fn test_update_env_content_new_key() { + let content = "EXISTING=value\n"; + let mut updates = HashMap::new(); + updates.insert("NEW_KEY".to_string(), "new_value".to_string()); + + let result = update_env_content(content, &updates); + assert!(result.contains("EXISTING=value")); + assert!(result.contains("NEW_KEY=new_value")); + assert!(result.contains("# Auto-configured webhook secrets")); + } + + #[test] + fn test_update_env_content_update_existing() { + let content = "KEY=old_value\n"; + let mut updates = HashMap::new(); + updates.insert("KEY".to_string(), "new_value".to_string()); + + let result = update_env_content(content, &updates); + assert!(result.contains("KEY=new_value")); + assert!(!result.contains("old_value")); + } + + #[test] + fn test_update_env_content_preserve_comments() { + let content = "# This is a comment\nKEY=value\n"; + let mut updates = HashMap::new(); + updates.insert("KEY".to_string(), "new_value".to_string()); + + let result = update_env_content(content, &updates); + assert!(result.contains("# This is a comment")); + assert!(result.contains("KEY=new_value")); + } + + #[test] + fn test_update_env_content_preserve_empty_lines() { + let content = "KEY1=value1\n\nKEY2=value2\n"; + let mut updates = HashMap::new(); + updates.insert("KEY1".to_string(), "new1".to_string()); + + let result = update_env_content(content, &updates); + assert!(result.contains("KEY1=new1")); + assert!(result.contains("\n\n")); // Empty line preserved + } + + #[test] + fn test_update_env_content_empty_file() { + let content = ""; + let mut updates = HashMap::new(); + updates.insert("NEW_KEY".to_string(), "value".to_string()); + + let result = update_env_content(content, &updates); + assert!(result.contains("NEW_KEY=value")); + } + + #[test] + fn test_update_env_content_multiple_updates() { + let content = "KEY1=old1\nKEY2=old2\n"; + let mut updates = HashMap::new(); + updates.insert("KEY1".to_string(), "new1".to_string()); + updates.insert("KEY3".to_string(), "new3".to_string()); + + let result = update_env_content(content, &updates); + assert!(result.contains("KEY1=new1")); + assert!(result.contains("KEY2=old2")); + assert!(result.contains("KEY3=new3")); + } + + #[test] + fn test_update_env_file_creates_new() { + let temp_dir = TempDir::new().unwrap(); + let env_path = temp_dir.path().join(".env"); + + let mut updates = HashMap::new(); + updates.insert("KEY".to_string(), "value".to_string()); + + update_env_file(&env_path, &updates).unwrap(); + + let content = fs::read_to_string(&env_path).unwrap(); + assert!(content.contains("KEY=value")); + } + + #[test] + fn test_update_env_file_updates_existing() { + let temp_dir = TempDir::new().unwrap(); + let env_path = temp_dir.path().join(".env"); + + // Create initial file + fs::write(&env_path, "EXISTING=old\n").unwrap(); + + let mut updates = HashMap::new(); + updates.insert("EXISTING".to_string(), "new".to_string()); + updates.insert("NEW_KEY".to_string(), "value".to_string()); + + update_env_file(&env_path, &updates).unwrap(); + + let content = fs::read_to_string(&env_path).unwrap(); + assert!(content.contains("EXISTING=new")); + assert!(content.contains("NEW_KEY=value")); + } + + #[test] + fn test_update_env_content_ends_with_newline() { + let content = "KEY=value"; + let updates = HashMap::new(); + + let result = update_env_content(content, &updates); + assert!(result.ends_with('\n')); + } + + #[test] + fn test_parse_env_line_no_equals() { + assert!(parse_env_line("NOEQUALS").is_none()); + } + + #[test] + fn test_quote_value_with_backslash() { + let result = quote_value("path\\to\\file"); + assert_eq!(result, "\"path\\\\to\\\\file\""); + } + + #[test] + fn test_quote_value_with_dollar() { + assert_eq!(quote_value("value$var"), "\"value$var\""); + } + + #[test] + fn test_update_env_content_preserves_order() { + let content = "FIRST=1\nSECOND=2\nTHIRD=3\n"; + let mut updates = HashMap::new(); + updates.insert("SECOND".to_string(), "updated".to_string()); + + let result = update_env_content(content, &updates); + let first_pos = result.find("FIRST").unwrap(); + let second_pos = result.find("SECOND").unwrap(); + let third_pos = result.find("THIRD").unwrap(); + + assert!(first_pos < second_pos); + assert!(second_pos < third_pos); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 00000000..f52390f6 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,479 @@ +//! Error types for the claudear application. + +use thiserror::Error; + +/// Main error type for the application. +#[derive(Error, Debug)] +pub enum Error { + #[error("Configuration error: {0}")] + Config(String), + + #[error("Database error: {0}")] + Database(#[from] rusqlite::Error), + + #[error("HTTP request error: {0}")] + Http(#[from] reqwest::Error), + + #[error("JSON serialization error: {0}")] + Json(#[from] serde_json::Error), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Source error ({source_name}): {message}")] + Source { + source_name: String, + message: String, + }, + + #[error("Webhook error: {0}")] + Webhook(String), + + #[error("Claude runner error: {0}")] + Runner(String), + + #[error("Notifier error ({notifier}): {message}")] + Notifier { notifier: String, message: String }, + + #[error("Issue not found: {source_name}:{issue_id}")] + IssueNotFound { + source_name: String, + issue_id: String, + }, + + #[error("Invalid signature")] + InvalidSignature, + + #[error("Network error: {0}")] + Network(String), + + #[error("API error: {0}")] + Api(String), + + #[error("Storage error: {0}")] + Storage(String), + + #[error("Git error: {0}")] + Git(String), + + #[error("{0}")] + Other(String), +} + +impl Error { + pub fn config(msg: impl Into) -> Self { + Self::Config(msg.into()) + } + + pub fn source(source_name: impl Into, message: impl Into) -> Self { + Self::Source { + source_name: source_name.into(), + message: message.into(), + } + } + + pub fn webhook(msg: impl Into) -> Self { + Self::Webhook(msg.into()) + } + + pub fn runner(msg: impl Into) -> Self { + Self::Runner(msg.into()) + } + + pub fn notifier(notifier: impl Into, message: impl Into) -> Self { + Self::Notifier { + notifier: notifier.into(), + message: message.into(), + } + } + + pub fn issue_not_found(source_name: impl Into, issue_id: impl Into) -> Self { + Self::IssueNotFound { + source_name: source_name.into(), + issue_id: issue_id.into(), + } + } + + pub fn network(msg: impl Into) -> Self { + Self::Network(msg.into()) + } + + pub fn api(msg: impl Into) -> Self { + Self::Api(msg.into()) + } + + pub fn storage(msg: impl Into) -> Self { + Self::Storage(msg.into()) + } + + pub fn git(msg: impl Into) -> Self { + Self::Git(msg.into()) + } + + pub fn io(msg: impl Into) -> Self { + Self::Other(format!("IO error: {}", msg.into())) + } +} + +/// Result type alias for the application. +pub type Result = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_config() { + let err = Error::config("missing variable"); + assert!(matches!(err, Error::Config(_))); + assert_eq!(err.to_string(), "Configuration error: missing variable"); + } + + #[test] + fn test_error_source() { + let err = Error::source("linear", "API rate limit"); + assert!(matches!(err, Error::Source { .. })); + assert_eq!(err.to_string(), "Source error (linear): API rate limit"); + } + + #[test] + fn test_error_webhook() { + let err = Error::webhook("invalid payload"); + assert!(matches!(err, Error::Webhook(_))); + assert_eq!(err.to_string(), "Webhook error: invalid payload"); + } + + #[test] + fn test_error_runner() { + let err = Error::runner("process crashed"); + assert!(matches!(err, Error::Runner(_))); + assert_eq!(err.to_string(), "Claude runner error: process crashed"); + } + + #[test] + fn test_error_notifier() { + let err = Error::notifier("discord", "rate limited"); + assert!(matches!(err, Error::Notifier { .. })); + assert_eq!(err.to_string(), "Notifier error (discord): rate limited"); + } + + #[test] + fn test_error_issue_not_found() { + let err = Error::issue_not_found("sentry", "12345"); + assert!(matches!(err, Error::IssueNotFound { .. })); + assert_eq!(err.to_string(), "Issue not found: sentry:12345"); + } + + #[test] + fn test_error_network() { + let err = Error::network("connection refused"); + assert!(matches!(err, Error::Network(_))); + assert_eq!(err.to_string(), "Network error: connection refused"); + } + + #[test] + fn test_error_api() { + let err = Error::api("rate limited"); + assert!(matches!(err, Error::Api(_))); + assert_eq!(err.to_string(), "API error: rate limited"); + } + + #[test] + fn test_error_invalid_signature() { + let err = Error::InvalidSignature; + assert!(matches!(err, Error::InvalidSignature)); + assert_eq!(err.to_string(), "Invalid signature"); + } + + #[test] + fn test_error_other() { + let err = Error::Other("something else".into()); + assert!(matches!(err, Error::Other(_))); + assert_eq!(err.to_string(), "something else"); + } + + #[test] + fn test_error_debug() { + let err = Error::config("test"); + let debug_str = format!("{:?}", err); + assert!(debug_str.contains("Config")); + } + + #[test] + fn test_error_from_io() { + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"); + let err: Error = io_err.into(); + assert!(matches!(err, Error::Io(_))); + assert!(err.to_string().contains("file not found")); + } + + #[test] + fn test_error_from_json() { + let json_err = serde_json::from_str::("not valid json").unwrap_err(); + let err: Error = json_err.into(); + assert!(matches!(err, Error::Json(_))); + } + + #[test] + fn test_error_config_string_conversion() { + let err = Error::config(String::from("dynamic message")); + assert_eq!(err.to_string(), "Configuration error: dynamic message"); + } + + #[test] + fn test_error_config_str_conversion() { + let err = Error::config("static message"); + assert_eq!(err.to_string(), "Configuration error: static message"); + } + + #[test] + fn test_error_source_display_format() { + let err = Error::source("github", "token expired"); + // Check the formatted output matches expected pattern + let display = err.to_string(); + assert!(display.contains("github")); + assert!(display.contains("token expired")); + } + + #[test] + fn test_error_notifier_display_format() { + let err = Error::notifier("email", "SMTP connection failed"); + let display = err.to_string(); + assert!(display.contains("email")); + assert!(display.contains("SMTP connection failed")); + } + + #[test] + fn test_result_type_alias() { + fn test_fn() -> Result { + Ok(42) + } + assert_eq!(test_fn().unwrap(), 42); + } + + #[test] + fn test_result_type_alias_error() { + fn test_fn() -> Result { + Err(Error::config("failed")) + } + assert!(test_fn().is_err()); + } + + #[test] + fn test_error_send_sync() { + fn assert_send_sync() {} + // Error should be Send + Sync for use in async contexts + // This will fail to compile if Error doesn't implement these traits + assert_send_sync::(); + } + + #[test] + fn test_error_source_empty_strings() { + let err = Error::source("", ""); + assert!(err.to_string().contains("Source error")); + } + + #[test] + fn test_error_notifier_empty_strings() { + let err = Error::notifier("", ""); + assert!(err.to_string().contains("Notifier error")); + } + + #[test] + fn test_error_issue_not_found_empty_strings() { + let err = Error::issue_not_found("", ""); + assert!(err.to_string().contains("Issue not found")); + } + + #[test] + fn test_error_config_long_message() { + let long_msg = "x".repeat(10000); + let err = Error::config(&long_msg); + assert_eq!(err.to_string().len(), 10000 + "Configuration error: ".len()); + } + + #[test] + fn test_error_runner_special_chars() { + let err = Error::runner("Error with & \"chars\""); + assert!(err.to_string().contains("")); + assert!(err.to_string().contains("&")); + } + + #[test] + fn test_error_webhook_unicode() { + let err = Error::webhook("错误信息 🔥"); + assert!(err.to_string().contains("错误信息")); + } + + #[test] + fn test_error_network_multiline() { + let err = Error::network("Line 1\nLine 2\nLine 3"); + assert!(err.to_string().contains("Line 1")); + } + + #[test] + fn test_result_map() { + fn success_fn() -> Result { + Ok(42) + } + + let mapped = success_fn().map(|v| v * 2); + assert_eq!(mapped.unwrap(), 84); + } + + #[test] + fn test_result_and_then() { + fn double(x: i32) -> Result { + Ok(x * 2) + } + + let result: Result = Ok(21); + let chained = result.and_then(double); + assert_eq!(chained.unwrap(), 42); + } + + #[test] + fn test_error_from_database() { + // Create a mock database error + let db_err = rusqlite::Error::InvalidQuery; + let err: Error = db_err.into(); + assert!(matches!(err, Error::Database(_))); + } + + #[test] + fn test_error_equality_by_variant() { + let err1 = Error::InvalidSignature; + let err2 = Error::InvalidSignature; + // They produce the same error message + assert_eq!(err1.to_string(), err2.to_string()); + } + + #[test] + fn test_error_config_from_owned_string() { + let owned = String::from("owned message"); + let err = Error::config(owned); + assert!(err.to_string().contains("owned message")); + } + + #[test] + fn test_error_all_constructors_return_error() { + // Verify all constructors create valid Error variants + let errors = vec![ + Error::config("test"), + Error::source("src", "msg"), + Error::webhook("test"), + Error::runner("test"), + Error::notifier("notify", "msg"), + Error::issue_not_found("src", "id"), + Error::network("test"), + Error::api("test"), + Error::InvalidSignature, + Error::Other("test".into()), + ]; + + for err in errors { + // All should produce non-empty error messages + assert!(!err.to_string().is_empty()); + } + } + + #[test] + fn test_error_display_vs_debug() { + let err = Error::config("test message"); + let display = err.to_string(); + let debug = format!("{:?}", err); + + // Display is the user-friendly message + assert!(display.contains("Configuration error")); + // Debug contains variant name + assert!(debug.contains("Config")); + } + + #[test] + #[allow(clippy::unnecessary_literal_unwrap)] + fn test_result_unwrap_or() { + let ok_result: Result = Ok(42); + assert_eq!(ok_result.unwrap_or(0), 42); + + let err_result: Result = Err(Error::config("error")); + assert_eq!(err_result.unwrap_or(0), 0); + } + + #[test] + fn test_result_is_ok_is_err() { + let ok_result: Result<()> = Ok(()); + assert!(ok_result.is_ok()); + assert!(ok_result.is_ok()); + + let err_result: Result<()> = Err(Error::config("error")); + assert!(err_result.is_err()); + assert!(err_result.is_err()); + } + + #[test] + fn test_error_source_with_newlines() { + let err = Error::source("linear", "Error\nWith\nNewlines"); + assert!(err.to_string().contains("Error")); + } + + #[test] + fn test_error_notifier_various_notifiers() { + for notifier in ["discord", "email", "sms", "push", "console"] { + let err = Error::notifier(notifier, "test error"); + assert!(err.to_string().contains(notifier)); + } + } + + #[test] + fn test_error_source_various_sources() { + for source in ["linear", "sentry", "github", "jira"] { + let err = Error::source(source, "API error"); + assert!(err.to_string().contains(source)); + } + } + + #[test] + fn test_result_ok() { + let result: Result<&str> = Ok("success"); + assert_eq!(result.ok(), Some("success")); + + let err_result: Result<&str> = Err(Error::config("error")); + assert_eq!(err_result.ok(), None); + } + + #[test] + fn test_result_err() { + let result: Result = Ok(42); + assert!(result.err().is_none()); + + let err_result: Result = Err(Error::config("error")); + assert!(err_result.err().is_some()); + } + + #[test] + fn test_error_invalid_signature_display() { + let err = Error::InvalidSignature; + assert_eq!(err.to_string(), "Invalid signature"); + } + + #[test] + fn test_error_other_with_empty() { + let err = Error::Other(String::new()); + assert!(err.to_string().is_empty()); + } + + #[test] + fn test_error_chaining() { + fn inner_op() -> Result { + Err(Error::runner("inner failure")) + } + + fn outer_op() -> Result { + inner_op() + } + + let result = outer_op(); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("inner failure")); + } +} diff --git a/src/feedback/analyzer.rs b/src/feedback/analyzer.rs new file mode 100644 index 00000000..a747195f --- /dev/null +++ b/src/feedback/analyzer.rs @@ -0,0 +1,590 @@ +//! Feedback analyzer for improving prompts based on past outcomes. + +use super::outcomes::{FixOutcome, Outcome, OutcomeTracker}; +use crate::error::Result; +use crate::types::{FixAttempt, Issue}; +use serde::{Deserialize, Serialize}; + +/// A similar issue found in past outcomes. +#[derive(Debug, Clone, Serialize)] +pub struct SimilarIssue { + /// The similar past outcome. + pub outcome: FixOutcome, + /// Similarity score (0.0 to 1.0). + pub similarity: f64, +} + +/// A suggestion for improving prompts. +#[derive(Debug, Clone, Serialize)] +pub struct PromptSuggestion { + /// Type of suggestion. + pub suggestion_type: SuggestionType, + /// The suggestion text. + pub text: String, + /// Confidence level (0.0 to 1.0). + pub confidence: f64, + /// Source of this suggestion (which outcomes it's based on). + pub based_on: Vec, +} + +/// Types of prompt suggestions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SuggestionType { + /// Add context about what worked before. + AddContext, + /// Avoid a pattern that failed before. + AvoidPattern, + /// Include specific instructions based on past success. + IncludeInstruction, + /// Warning about a common failure mode. + Warning, +} + +/// Analyzes past fix outcomes to improve future prompts. +pub struct FeedbackAnalyzer { + tracker: OutcomeTracker, + min_similarity: f64, + max_similar_results: usize, +} + +impl FeedbackAnalyzer { + /// Create a new feedback analyzer. + pub fn new() -> Self { + Self { + tracker: OutcomeTracker::new(), + min_similarity: 0.1, + max_similar_results: 5, + } + } + + /// Create with custom settings. + pub fn with_settings(min_similarity: f64, max_similar_results: usize) -> Self { + Self { + tracker: OutcomeTracker::new(), + min_similarity, + max_similar_results, + } + } + + /// Record an outcome. + pub fn record_outcome( + &mut self, + attempt: &FixAttempt, + issue: &Issue, + prompt: &str, + outcome: Outcome, + ) -> Result { + let fix_outcome = FixOutcome::from_attempt(attempt, issue, prompt, outcome); + self.tracker.record(fix_outcome) + } + + /// Find similar past issues. + pub fn find_similar(&self, issue: &Issue) -> Vec { + self.tracker + .all() + .iter() + .map(|o| { + let similarity = o.similarity_to_issue(issue); + SimilarIssue { + outcome: o.clone(), + similarity, + } + }) + .filter(|s| s.similarity >= self.min_similarity) + .take(self.max_similar_results) + .collect() + } + + /// Generate suggestions for improving the prompt based on past outcomes. + pub fn suggest_improvements(&self, issue: &Issue) -> Vec { + let similar = self.find_similar(issue); + let mut suggestions = Vec::new(); + + // Analyze successful vs failed patterns + let successful: Vec<_> = similar + .iter() + .filter(|s| s.outcome.outcome.is_success()) + .collect(); + let failed: Vec<_> = similar + .iter() + .filter(|s| !s.outcome.outcome.is_success()) + .collect(); + + // If we have successful examples, learn from them + if !successful.is_empty() { + // Check if there are learnings we can use + for s in &successful { + if let Some(ref learnings) = s.outcome.learnings { + suggestions.push(PromptSuggestion { + suggestion_type: SuggestionType::AddContext, + text: format!( + "Similar issue was fixed successfully. Learning: {}", + learnings + ), + confidence: s.similarity, + based_on: vec![s.outcome.id], + }); + } + } + + // Add generic success context + if successful.len() >= 2 { + let ids: Vec<_> = successful.iter().map(|s| s.outcome.id).collect(); + suggestions.push(PromptSuggestion { + suggestion_type: SuggestionType::AddContext, + text: format!( + "{} similar issues were fixed successfully in the past.", + successful.len() + ), + confidence: successful.iter().map(|s| s.similarity).sum::() + / successful.len() as f64, + based_on: ids, + }); + } + } + + // If we have failed examples, learn what to avoid + if !failed.is_empty() { + // Check for common error types + let error_types: Vec<_> = failed + .iter() + .filter_map(|s| s.outcome.error_type.as_ref()) + .collect(); + + if !error_types.is_empty() { + let ids: Vec<_> = failed.iter().map(|s| s.outcome.id).collect(); + suggestions.push(PromptSuggestion { + suggestion_type: SuggestionType::Warning, + text: format!( + "Similar issues have failed before with errors: {}. Be careful.", + error_types + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + ), + confidence: failed.iter().map(|s| s.similarity).sum::() + / failed.len() as f64, + based_on: ids, + }); + } + + // If more failed than succeeded, add warning + if failed.len() > successful.len() && similar.len() >= 3 { + suggestions.push(PromptSuggestion { + suggestion_type: SuggestionType::Warning, + text: format!( + "Caution: {} out of {} similar issues failed. This may be difficult to fix automatically.", + failed.len(), + similar.len() + ), + confidence: 0.7, + based_on: failed.iter().map(|s| s.outcome.id).collect(), + }); + } + } + + // Sort by confidence + suggestions.sort_by(|a, b| { + b.confidence + .partial_cmp(&a.confidence) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + suggestions + } + + /// Build an enhanced prompt with feedback learnings. + pub fn enhance_prompt(&self, base_prompt: &str, issue: &Issue) -> String { + let suggestions = self.suggest_improvements(issue); + + if suggestions.is_empty() { + return base_prompt.to_string(); + } + + let mut enhanced = String::new(); + + // Add learnings section + enhanced.push_str("# Learnings from Similar Issues\n\n"); + + for suggestion in suggestions.iter().take(3) { + let prefix = match suggestion.suggestion_type { + SuggestionType::AddContext => "Context:", + SuggestionType::AvoidPattern => "Avoid:", + SuggestionType::IncludeInstruction => "Instruction:", + SuggestionType::Warning => "Warning:", + }; + enhanced.push_str(&format!("- {} {}\n", prefix, suggestion.text)); + } + + enhanced.push_str("\n---\n\n"); + enhanced.push_str(base_prompt); + + enhanced + } + + /// Get the outcome tracker for direct access. + pub fn tracker(&self) -> &OutcomeTracker { + &self.tracker + } + + /// Get mutable outcome tracker. + pub fn tracker_mut(&mut self) -> &mut OutcomeTracker { + &mut self.tracker + } + + /// Get overall success rate. + pub fn overall_success_rate(&self) -> f64 { + self.tracker.success_rate(None) + } + + /// Get success rate for a specific source. + pub fn source_success_rate(&self, source: &str) -> f64 { + self.tracker.success_rate(Some(source)) + } + + /// Get common error patterns. + pub fn common_errors(&self, limit: usize) -> Vec<(String, usize)> { + self.tracker.common_errors(limit) + } + + /// Add learnings to an outcome. + pub fn add_learnings(&mut self, outcome_id: i64, learnings: &str) -> Result<()> { + self.tracker.add_learnings(outcome_id, learnings) + } +} + +impl Default for FeedbackAnalyzer { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{FixAttemptStatus, IssuePriority, IssueStatus}; + + fn create_test_issue(title: &str, description: &str, source: &str) -> Issue { + Issue { + id: format!("{}-1", source), + short_id: format!("{}-1", source.to_uppercase()), + title: title.to_string(), + description: Some(description.to_string()), + url: "https://example.com".to_string(), + source: source.to_string(), + priority: IssuePriority::Medium, + status: IssueStatus::Open, + metadata: Default::default(), + created_at: None, + updated_at: None, + } + } + + fn create_test_attempt(source: &str) -> FixAttempt { + FixAttempt { + id: 1, + source: source.to_string(), + issue_id: format!("{}-1", source), + short_id: format!("{}-1", source.to_uppercase()), + status: FixAttemptStatus::Success, + pr_url: Some("https://github.com/test/pr/1".to_string()), + github_repo: None, + github_pr_number: None, + error_message: None, + attempted_at: chrono::Utc::now(), + resolved_at: None, + merged_at: None, + retry_count: 0, + last_retry_at: None, + } + } + + #[test] + fn test_record_and_find() { + let mut analyzer = FeedbackAnalyzer::new(); + + let issue = create_test_issue("API timeout error", "Timeout in user service", "linear"); + let attempt = create_test_attempt("linear"); + + let id = analyzer + .record_outcome(&attempt, &issue, "Fix the timeout", Outcome::Merged) + .unwrap(); + + assert_eq!(id, 1); + + let search = create_test_issue("API returns timeout", "Service timeout issue", "linear"); + let similar = analyzer.find_similar(&search); + + assert!(!similar.is_empty()); + } + + #[test] + fn test_suggest_improvements() { + let mut analyzer = FeedbackAnalyzer::new(); + + // Record some outcomes + let issue1 = create_test_issue( + "Database connection error", + "PostgreSQL connection fails", + "linear", + ); + let attempt1 = create_test_attempt("linear"); + analyzer + .record_outcome(&attempt1, &issue1, "prompt", Outcome::Merged) + .unwrap(); + + let mut attempt2 = create_test_attempt("linear"); + attempt2.error_message = Some("Connection timeout".to_string()); + let issue2 = create_test_issue("Database timeout", "PostgreSQL times out", "linear"); + analyzer + .record_outcome(&attempt2, &issue2, "prompt", Outcome::Failed) + .unwrap(); + + // Get suggestions for similar issue + let new_issue = create_test_issue( + "Database connection problem", + "PostgreSQL connection issue", + "linear", + ); + let suggestions = analyzer.suggest_improvements(&new_issue); + + // Should have some suggestions based on similar issues + assert!(!suggestions.is_empty() || analyzer.find_similar(&new_issue).is_empty()); + } + + #[test] + fn test_enhance_prompt() { + let mut analyzer = FeedbackAnalyzer::new(); + + // Record a successful outcome with learnings + let issue = create_test_issue("API error", "Server returns 500", "sentry"); + let attempt = create_test_attempt("sentry"); + let id = analyzer + .record_outcome(&attempt, &issue, "Fix the API", Outcome::Merged) + .unwrap(); + + analyzer + .add_learnings(id, "Check error handling in catch blocks") + .unwrap(); + + // Enhance a prompt for similar issue + let new_issue = create_test_issue("API 500 error", "Server error in API", "sentry"); + let enhanced = analyzer.enhance_prompt("Fix this bug", &new_issue); + + // Enhanced prompt should contain the base prompt + assert!(enhanced.contains("Fix this bug")); + } + + #[test] + fn test_success_rate() { + let mut analyzer = FeedbackAnalyzer::new(); + let issue = create_test_issue("Test", "Test desc", "linear"); + let attempt = create_test_attempt("linear"); + + analyzer + .record_outcome(&attempt, &issue, "p", Outcome::Merged) + .unwrap(); + analyzer + .record_outcome(&attempt, &issue, "p", Outcome::Merged) + .unwrap(); + analyzer + .record_outcome(&attempt, &issue, "p", Outcome::Failed) + .unwrap(); + + let rate = analyzer.overall_success_rate(); + assert!((rate - 0.666).abs() < 0.01); // ~66% success + } + + #[test] + fn test_common_errors() { + let mut analyzer = FeedbackAnalyzer::new(); + + let issue = create_test_issue("Test", "Test", "linear"); + let mut attempt = create_test_attempt("linear"); + + attempt.error_message = Some("Connection timed out".to_string()); + analyzer + .record_outcome(&attempt, &issue, "p", Outcome::Failed) + .unwrap(); + analyzer + .record_outcome(&attempt, &issue, "p", Outcome::Failed) + .unwrap(); + + attempt.error_message = Some("Permission denied".to_string()); + analyzer + .record_outcome(&attempt, &issue, "p", Outcome::Failed) + .unwrap(); + + let errors = analyzer.common_errors(5); + assert!(!errors.is_empty()); + + // timeout should be most common (2 occurrences) + assert_eq!(errors[0].0, "timeout"); + assert_eq!(errors[0].1, 2); + } + + #[test] + fn test_analyzer_default() { + let analyzer = FeedbackAnalyzer::default(); + assert!((analyzer.overall_success_rate() - 0.0).abs() < 0.01); + } + + #[test] + fn test_analyzer_with_settings() { + let analyzer = FeedbackAnalyzer::with_settings(0.5, 10); + // Just ensure it creates without error + assert!(analyzer + .find_similar(&create_test_issue("Test", "Test", "linear")) + .is_empty()); + } + + #[test] + fn test_source_success_rate() { + let mut analyzer = FeedbackAnalyzer::new(); + let issue = create_test_issue("Test", "Test", "linear"); + let attempt = create_test_attempt("linear"); + + analyzer + .record_outcome(&attempt, &issue, "p", Outcome::Merged) + .unwrap(); + analyzer + .record_outcome(&attempt, &issue, "p", Outcome::Failed) + .unwrap(); + + let rate = analyzer.source_success_rate("linear"); + assert!((rate - 0.5).abs() < 0.1); + } + + #[test] + fn test_source_success_rate_no_data() { + let analyzer = FeedbackAnalyzer::new(); + let rate = analyzer.source_success_rate("nonexistent"); + assert!((rate - 0.0).abs() < 0.01); + } + + #[test] + fn test_tracker_accessor() { + let analyzer = FeedbackAnalyzer::new(); + let tracker = analyzer.tracker(); + assert!(tracker.all().is_empty()); + } + + #[test] + fn test_tracker_mut_accessor() { + let mut analyzer = FeedbackAnalyzer::new(); + let _ = analyzer.tracker_mut(); + // Just verify we can access it mutably + } + + #[test] + fn test_similar_issue_fields() { + let outcome = FixOutcome { + id: 1, + attempt_id: 100, + source: "linear".to_string(), + issue_id: "123".to_string(), + issue_text: "Test issue title".to_string(), + prompt_used: "Fix it".to_string(), + outcome: Outcome::Merged, + error_type: None, + learnings: None, + keywords: vec!["test".to_string()], + created_at: chrono::Utc::now(), + }; + + let similar = SimilarIssue { + outcome, + similarity: 0.85, + }; + + assert!((similar.similarity - 0.85).abs() < 0.01); + assert_eq!(similar.outcome.issue_id, "123"); + } + + #[test] + fn test_prompt_suggestion_fields() { + let suggestion = PromptSuggestion { + suggestion_type: SuggestionType::AddContext, + text: "Add more context".to_string(), + confidence: 0.75, + based_on: vec![1, 2, 3], + }; + + assert_eq!(suggestion.suggestion_type, SuggestionType::AddContext); + assert_eq!(suggestion.text, "Add more context"); + assert!((suggestion.confidence - 0.75).abs() < 0.01); + assert_eq!(suggestion.based_on.len(), 3); + } + + #[test] + fn test_suggestion_types() { + assert_eq!(SuggestionType::AddContext, SuggestionType::AddContext); + assert_eq!(SuggestionType::AvoidPattern, SuggestionType::AvoidPattern); + assert_eq!( + SuggestionType::IncludeInstruction, + SuggestionType::IncludeInstruction + ); + assert_eq!(SuggestionType::Warning, SuggestionType::Warning); + assert_ne!(SuggestionType::AddContext, SuggestionType::Warning); + } + + #[test] + fn test_enhance_prompt_no_suggestions() { + let analyzer = FeedbackAnalyzer::new(); + let issue = create_test_issue("Unique Test", "Unique Description", "linear"); + + let enhanced = analyzer.enhance_prompt("Base prompt", &issue); + assert_eq!(enhanced, "Base prompt"); + } + + #[test] + fn test_find_similar_no_data() { + let analyzer = FeedbackAnalyzer::new(); + let issue = create_test_issue("Test", "Test", "linear"); + + let similar = analyzer.find_similar(&issue); + assert!(similar.is_empty()); + } + + #[test] + fn test_common_errors_limit() { + let mut analyzer = FeedbackAnalyzer::new(); + let issue = create_test_issue("Test", "Test", "linear"); + let mut attempt = create_test_attempt("linear"); + + // Add various error types + for err_type in &["timeout", "permission", "network", "syntax", "runtime"] { + attempt.error_message = Some(format!("{} error", err_type)); + analyzer + .record_outcome(&attempt, &issue, "p", Outcome::Failed) + .unwrap(); + } + + let errors = analyzer.common_errors(2); + assert_eq!(errors.len(), 2); + } + + #[test] + fn test_add_learnings() { + let mut analyzer = FeedbackAnalyzer::new(); + let issue = create_test_issue("Test", "Test", "linear"); + let attempt = create_test_attempt("linear"); + + let id = analyzer + .record_outcome(&attempt, &issue, "prompt", Outcome::Merged) + .unwrap(); + analyzer + .add_learnings(id, "Important lesson learned") + .unwrap(); + + // Verify the learning was added + let tracker = analyzer.tracker(); + let outcomes = tracker.all(); + let outcome = outcomes.iter().find(|o| o.id == id).unwrap(); + assert_eq!( + outcome.learnings.as_ref().unwrap(), + "Important lesson learned" + ); + } +} diff --git a/src/feedback/embeddings.rs b/src/feedback/embeddings.rs new file mode 100644 index 00000000..75a015a9 --- /dev/null +++ b/src/feedback/embeddings.rs @@ -0,0 +1,554 @@ +//! Embedding generation using fastembed (local, no external dependencies). +//! +//! Uses the Nomic Embed Text model for generating embeddings. + +use crate::error::{Error, Result}; +use fastembed::{EmbeddingModel, InitOptions, TextEmbedding}; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// Configuration for the embedding service. +#[derive(Debug, Clone)] +pub struct EmbeddingConfig { + /// Model to use for embeddings. + pub model: EmbeddingModel, + /// Whether to show download progress. + pub show_download_progress: bool, + /// Custom cache directory (uses system default if None). + pub cache_dir: Option, +} + +impl Default for EmbeddingConfig { + fn default() -> Self { + Self { + model: EmbeddingModel::NomicEmbedTextV15, + show_download_progress: true, + cache_dir: None, + } + } +} + +impl EmbeddingConfig { + /// Create from environment variables. + pub fn from_env() -> Self { + let model = std::env::var("EMBEDDING_MODEL") + .ok() + .and_then(|m| match m.to_lowercase().as_str() { + "nomic-embed-text" | "nomic" => Some(EmbeddingModel::NomicEmbedTextV15), + "all-minilm" | "minilm" => Some(EmbeddingModel::AllMiniLML6V2), + "bge-small" | "bge" => Some(EmbeddingModel::BGESmallENV15), + _ => None, + }) + .unwrap_or(EmbeddingModel::NomicEmbedTextV15); + + let cache_dir = std::env::var("EMBEDDING_CACHE_DIR").ok(); + + Self { + model, + show_download_progress: true, + cache_dir, + } + } + + /// Use a smaller, faster model. + pub fn fast() -> Self { + Self { + model: EmbeddingModel::AllMiniLML6V2, + show_download_progress: true, + cache_dir: None, + } + } +} + +/// Client for generating embeddings using fastembed. +/// +/// Thread-safe wrapper around the TextEmbedding model. +pub struct EmbeddingClient { + model: Arc>, + dimension: usize, + model_name: String, +} + +impl EmbeddingClient { + /// Create a new embedding client. + /// + /// This will download the model on first use if not cached. + pub fn new(config: EmbeddingConfig) -> Result { + let dimension = match config.model { + EmbeddingModel::NomicEmbedTextV15 => 768, + EmbeddingModel::NomicEmbedTextV1 => 768, + EmbeddingModel::AllMiniLML6V2 => 384, + EmbeddingModel::BGESmallENV15 => 384, + EmbeddingModel::BGEBaseENV15 => 768, + EmbeddingModel::BGELargeENV15 => 1024, + _ => 768, // Default + }; + + let model_name = format!("{:?}", config.model); + + let mut init_options = InitOptions::new(config.model) + .with_show_download_progress(config.show_download_progress); + + if let Some(cache_dir) = config.cache_dir { + init_options = init_options.with_cache_dir(cache_dir.into()); + } + + let model = TextEmbedding::try_new(init_options) + .map_err(|e| Error::Other(format!("Failed to initialize embedding model: {}", e)))?; + + tracing::info!( + "Initialized embedding model: {} ({}d)", + model_name, + dimension + ); + + Ok(Self { + model: Arc::new(Mutex::new(model)), + dimension, + model_name, + }) + } + + /// Create with default configuration from environment. + pub fn from_env() -> Result { + Self::new(EmbeddingConfig::from_env()) + } + + /// Generate embedding for text. + pub async fn embed(&self, text: &str) -> Result> { + let model = self.model.lock().await; + + let embeddings = model + .embed(vec![text], None) + .map_err(|e| Error::Other(format!("Failed to generate embedding: {}", e)))?; + + embeddings + .into_iter() + .next() + .ok_or_else(|| Error::Other("No embedding returned".to_string())) + } + + /// Generate embeddings for multiple texts. + pub async fn embed_batch(&self, texts: &[&str]) -> Result>> { + let model = self.model.lock().await; + + let texts_owned: Vec = texts.iter().map(|s| s.to_string()).collect(); + + model + .embed(texts_owned, None) + .map_err(|e| Error::Other(format!("Failed to generate embeddings: {}", e))) + } + + /// Check if the embedding model is available. + pub fn is_available(&self) -> bool { + true // Always available since it's embedded + } + + /// Get the model name. + pub fn model(&self) -> &str { + &self.model_name + } + + /// Get the embedding dimension for the configured model. + pub fn dimension(&self) -> usize { + self.dimension + } +} + +/// Calculate cosine similarity between two vectors. +pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { + if a.len() != b.len() || a.is_empty() { + return 0.0; + } + + let mut dot_product = 0.0; + let mut norm_a = 0.0; + let mut norm_b = 0.0; + + for i in 0..a.len() { + dot_product += a[i] * b[i]; + norm_a += a[i] * a[i]; + norm_b += b[i] * b[i]; + } + + let norm_a = norm_a.sqrt(); + let norm_b = norm_b.sqrt(); + + if norm_a == 0.0 || norm_b == 0.0 { + return 0.0; + } + + dot_product / (norm_a * norm_b) +} + +/// Calculate Euclidean distance between two vectors. +pub fn euclidean_distance(a: &[f32], b: &[f32]) -> f32 { + if a.len() != b.len() { + return f32::MAX; + } + + a.iter() + .zip(b.iter()) + .map(|(x, y)| (x - y).powi(2)) + .sum::() + .sqrt() +} + +/// Normalize a vector to unit length. +pub fn normalize(v: &[f32]) -> Vec { + let norm: f32 = v.iter().map(|x| x * x).sum::().sqrt(); + if norm == 0.0 { + return v.to_vec(); + } + v.iter().map(|x| x / norm).collect() +} + +/// Embedding with metadata for similarity search results. +#[derive(Debug, Clone)] +pub struct EmbeddingResult { + /// The ID of the item. + pub id: i64, + /// The similarity score (0.0 to 1.0 for cosine). + pub similarity: f32, + /// The original text (optional). + pub text: Option, +} + +/// In-memory vector store for testing and small-scale use. +pub struct MemoryVectorStore { + embeddings: Vec<(i64, Vec, Option)>, +} + +impl MemoryVectorStore { + /// Create a new empty vector store. + pub fn new() -> Self { + Self { + embeddings: Vec::new(), + } + } + + /// Add an embedding to the store. + pub fn add(&mut self, id: i64, embedding: Vec, text: Option) { + self.embeddings.push((id, embedding, text)); + } + + /// Remove an embedding by ID. + pub fn remove(&mut self, id: i64) { + self.embeddings.retain(|(i, _, _)| *i != id); + } + + /// Search for similar embeddings. + pub fn search(&self, query: &[f32], limit: usize, min_similarity: f32) -> Vec { + let mut results: Vec<_> = self + .embeddings + .iter() + .map(|(id, emb, text)| { + let similarity = cosine_similarity(query, emb); + EmbeddingResult { + id: *id, + similarity, + text: text.clone(), + } + }) + .filter(|r| r.similarity >= min_similarity) + .collect(); + + results.sort_by(|a, b| { + b.similarity + .partial_cmp(&a.similarity) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + results.truncate(limit); + results + } + + /// Get the number of embeddings in the store. + pub fn len(&self) -> usize { + self.embeddings.len() + } + + /// Check if the store is empty. + pub fn is_empty(&self) -> bool { + self.embeddings.is_empty() + } + + /// Clear all embeddings. + pub fn clear(&mut self) { + self.embeddings.clear(); + } +} + +impl Default for MemoryVectorStore { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cosine_similarity_identical() { + let a = vec![1.0, 2.0, 3.0]; + let b = vec![1.0, 2.0, 3.0]; + let similarity = cosine_similarity(&a, &b); + assert!((similarity - 1.0).abs() < 0.0001); + } + + #[test] + fn test_cosine_similarity_orthogonal() { + let a = vec![1.0, 0.0, 0.0]; + let b = vec![0.0, 1.0, 0.0]; + let similarity = cosine_similarity(&a, &b); + assert!(similarity.abs() < 0.0001); + } + + #[test] + fn test_cosine_similarity_opposite() { + let a = vec![1.0, 2.0, 3.0]; + let b = vec![-1.0, -2.0, -3.0]; + let similarity = cosine_similarity(&a, &b); + assert!((similarity + 1.0).abs() < 0.0001); + } + + #[test] + fn test_cosine_similarity_empty() { + let a: Vec = vec![]; + let b: Vec = vec![]; + assert_eq!(cosine_similarity(&a, &b), 0.0); + } + + #[test] + fn test_cosine_similarity_different_lengths() { + let a = vec![1.0, 2.0]; + let b = vec![1.0, 2.0, 3.0]; + assert_eq!(cosine_similarity(&a, &b), 0.0); + } + + #[test] + fn test_euclidean_distance_identical() { + let a = vec![1.0, 2.0, 3.0]; + let b = vec![1.0, 2.0, 3.0]; + let distance = euclidean_distance(&a, &b); + assert!(distance.abs() < 0.0001); + } + + #[test] + fn test_euclidean_distance() { + let a = vec![0.0, 0.0]; + let b = vec![3.0, 4.0]; + let distance = euclidean_distance(&a, &b); + assert!((distance - 5.0).abs() < 0.0001); + } + + #[test] + fn test_normalize() { + let v = vec![3.0, 4.0]; + let normalized = normalize(&v); + let norm: f32 = normalized.iter().map(|x| x * x).sum::().sqrt(); + assert!((norm - 1.0).abs() < 0.0001); + } + + #[test] + fn test_normalize_zero_vector() { + let v = vec![0.0, 0.0, 0.0]; + let normalized = normalize(&v); + assert_eq!(normalized, v); + } + + #[test] + fn test_memory_vector_store() { + let mut store = MemoryVectorStore::new(); + + store.add(1, vec![1.0, 0.0, 0.0], Some("First".to_string())); + store.add(2, vec![0.9, 0.1, 0.0], Some("Second".to_string())); + store.add(3, vec![0.0, 1.0, 0.0], Some("Third".to_string())); + + assert_eq!(store.len(), 3); + + let query = vec![1.0, 0.0, 0.0]; + let results = store.search(&query, 2, 0.0); + + assert_eq!(results.len(), 2); + assert_eq!(results[0].id, 1); + assert_eq!(results[1].id, 2); + } + + #[test] + fn test_memory_vector_store_min_similarity() { + let mut store = MemoryVectorStore::new(); + + store.add(1, vec![1.0, 0.0, 0.0], None); + store.add(2, vec![0.0, 1.0, 0.0], None); + + let query = vec![1.0, 0.0, 0.0]; + let results = store.search(&query, 10, 0.9); + + // Only the first item should match with similarity >= 0.9 + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, 1); + } + + #[test] + fn test_memory_vector_store_remove() { + let mut store = MemoryVectorStore::new(); + + store.add(1, vec![1.0, 0.0], None); + store.add(2, vec![0.0, 1.0], None); + + assert_eq!(store.len(), 2); + + store.remove(1); + + assert_eq!(store.len(), 1); + } + + #[test] + fn test_embedding_config_default() { + let config = EmbeddingConfig::default(); + assert!(matches!(config.model, EmbeddingModel::NomicEmbedTextV15)); + } + + #[test] + fn test_embedding_config_fast() { + let config = EmbeddingConfig::fast(); + assert!(matches!(config.model, EmbeddingModel::AllMiniLML6V2)); + } + + #[test] + fn test_cosine_similarity_zero_vectors() { + let a = vec![0.0, 0.0, 0.0]; + let b = vec![1.0, 2.0, 3.0]; + // Should return 0 for zero vector + assert_eq!(cosine_similarity(&a, &b), 0.0); + } + + #[test] + fn test_cosine_similarity_partial_overlap() { + let a = vec![1.0, 1.0, 0.0]; + let b = vec![1.0, 0.0, 1.0]; + let similarity = cosine_similarity(&a, &b); + // Should be around 0.5 + assert!(similarity > 0.3 && similarity < 0.7); + } + + #[test] + fn test_euclidean_distance_different_lengths() { + let a = vec![1.0, 2.0]; + let b = vec![1.0]; + let distance = euclidean_distance(&a, &b); + assert_eq!(distance, f32::MAX); + } + + #[test] + fn test_euclidean_distance_unit_vectors() { + let a = vec![1.0, 0.0, 0.0]; + let b = vec![0.0, 1.0, 0.0]; + let distance = euclidean_distance(&a, &b); + // Should be sqrt(2) ≈ 1.414 + assert!((distance - 1.414).abs() < 0.01); + } + + #[test] + fn test_normalize_unit_vector() { + let v = vec![1.0, 0.0, 0.0]; + let normalized = normalize(&v); + // Already unit length + assert!((normalized[0] - 1.0).abs() < 0.0001); + assert!(normalized[1].abs() < 0.0001); + assert!(normalized[2].abs() < 0.0001); + } + + #[test] + fn test_normalize_large_vector() { + let v = vec![100.0, 0.0]; + let normalized = normalize(&v); + let norm: f32 = normalized.iter().map(|x| x * x).sum::().sqrt(); + assert!((norm - 1.0).abs() < 0.0001); + } + + #[test] + fn test_memory_vector_store_search_empty() { + let store = MemoryVectorStore::new(); + let query = vec![1.0, 0.0]; + let results = store.search(&query, 10, 0.0); + assert!(results.is_empty()); + } + + #[test] + fn test_memory_vector_store_search_limit() { + let mut store = MemoryVectorStore::new(); + store.add(1, vec![1.0, 0.0, 0.0], None); + store.add(2, vec![0.9, 0.1, 0.0], None); + store.add(3, vec![0.8, 0.2, 0.0], None); + store.add(4, vec![0.7, 0.3, 0.0], None); + + let query = vec![1.0, 0.0, 0.0]; + let results = store.search(&query, 2, 0.0); + + assert_eq!(results.len(), 2); + } + + #[test] + fn test_memory_vector_store_with_text() { + let mut store = MemoryVectorStore::new(); + store.add(1, vec![1.0, 0.0], Some("First item".to_string())); + store.add(2, vec![0.0, 1.0], Some("Second item".to_string())); + + let query = vec![1.0, 0.0]; + let results = store.search(&query, 1, 0.0); + + assert_eq!(results[0].id, 1); + assert_eq!(results[0].text, Some("First item".to_string())); + } + + #[test] + fn test_embedding_result_fields() { + let result = EmbeddingResult { + id: 42, + similarity: 0.95, + text: Some("test text".to_string()), + }; + + assert_eq!(result.id, 42); + assert!((result.similarity - 0.95).abs() < 0.0001); + assert_eq!(result.text, Some("test text".to_string())); + } + + #[test] + fn test_embedding_result_no_text() { + let result = EmbeddingResult { + id: 1, + similarity: 0.5, + text: None, + }; + + assert!(result.text.is_none()); + } + + #[test] + fn test_memory_vector_store_empty() { + let store = MemoryVectorStore::new(); + assert!(store.is_empty()); + assert_eq!(store.len(), 0); + } + + #[test] + fn test_memory_vector_store_clear() { + let mut store = MemoryVectorStore::new(); + store.add(1, vec![1.0, 0.0], None); + store.add(2, vec![0.0, 1.0], None); + + assert_eq!(store.len(), 2); + store.clear(); + assert!(store.is_empty()); + } + + #[test] + fn test_memory_vector_store_default() { + let store = MemoryVectorStore::default(); + assert!(store.is_empty()); + } +} diff --git a/src/feedback/mod.rs b/src/feedback/mod.rs new file mode 100644 index 00000000..af072419 --- /dev/null +++ b/src/feedback/mod.rs @@ -0,0 +1,16 @@ +//! AI Feedback Loop module. +//! +//! Learns from past fix attempts to improve future prompts. +//! Tracks outcomes of merged vs closed PRs and uses similarity +//! matching to find relevant learnings. + +mod analyzer; +mod embeddings; +mod outcomes; + +pub use analyzer::{FeedbackAnalyzer, PromptSuggestion, SimilarIssue}; +pub use embeddings::{ + cosine_similarity, euclidean_distance, normalize, EmbeddingClient, EmbeddingConfig, + EmbeddingResult, MemoryVectorStore, +}; +pub use outcomes::{FixOutcome, Outcome, OutcomeTracker}; diff --git a/src/feedback/outcomes.rs b/src/feedback/outcomes.rs new file mode 100644 index 00000000..2e774e0b --- /dev/null +++ b/src/feedback/outcomes.rs @@ -0,0 +1,1002 @@ +//! Outcome tracking for fix attempts. + +use crate::error::Result; +use crate::types::{FixAttempt, Issue}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// The outcome of a fix attempt. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Outcome { + /// PR was merged successfully + Merged, + /// PR was closed without merging + Closed, + /// Fix attempt failed before creating PR + Failed, + /// Issue could not be fixed after retries + CannotFix, +} + +impl Outcome { + /// Whether this outcome is considered successful. + pub fn is_success(&self) -> bool { + matches!(self, Outcome::Merged) + } + + /// Parse from string. + pub fn parse(s: &str) -> Option { + match s.to_lowercase().as_str() { + "merged" => Some(Outcome::Merged), + "closed" => Some(Outcome::Closed), + "failed" => Some(Outcome::Failed), + "cannot_fix" | "cannotfix" => Some(Outcome::CannotFix), + _ => None, + } + } + + /// Convert to string. + pub fn as_str(&self) -> &'static str { + match self { + Outcome::Merged => "merged", + Outcome::Closed => "closed", + Outcome::Failed => "failed", + Outcome::CannotFix => "cannot_fix", + } + } +} + +/// A recorded fix outcome with associated learnings. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FixOutcome { + /// Unique identifier. + pub id: i64, + /// Associated fix attempt ID. + pub attempt_id: i64, + /// Source of the issue. + pub source: String, + /// Issue ID. + pub issue_id: String, + /// Issue title/description (for similarity matching). + pub issue_text: String, + /// Prompt that was used. + pub prompt_used: String, + /// Outcome of the fix. + pub outcome: Outcome, + /// Categorized error type (if failed). + pub error_type: Option, + /// AI-generated learnings from this outcome. + pub learnings: Option, + /// Keywords extracted from the issue. + pub keywords: Vec, + /// When this outcome was recorded. + pub created_at: DateTime, +} + +impl FixOutcome { + /// Create a new outcome from a fix attempt. + pub fn from_attempt( + attempt: &FixAttempt, + issue: &Issue, + prompt: &str, + outcome: Outcome, + ) -> Self { + let description = issue.description.as_deref().unwrap_or(""); + let keywords = Self::extract_keywords(&issue.title, description); + + Self { + id: 0, // Set by storage + attempt_id: attempt.id, + source: attempt.source.clone(), + issue_id: attempt.issue_id.clone(), + issue_text: format!("{}\n\n{}", issue.title, description), + prompt_used: prompt.to_string(), + outcome, + error_type: attempt + .error_message + .as_ref() + .map(|e| Self::categorize_error(e)), + learnings: None, + keywords, + created_at: Utc::now(), + } + } + + /// Extract keywords from title and description. + fn extract_keywords(title: &str, description: &str) -> Vec { + let text = format!("{} {}", title, description).to_lowercase(); + + // Common programming keywords to look for + let significant_words: Vec<&str> = text + .split(|c: char| !c.is_alphanumeric() && c != '_') + .filter(|w| w.len() > 3) + .filter(|w| !is_common_word(w)) + .take(20) + .collect(); + + significant_words.into_iter().map(String::from).collect() + } + + /// Categorize an error message into a type. + fn categorize_error(error: &str) -> String { + let error_lower = error.to_lowercase(); + + if error_lower.contains("timeout") || error_lower.contains("timed out") { + "timeout".to_string() + } else if error_lower.contains("permission") || error_lower.contains("access denied") { + "permission".to_string() + } else if error_lower.contains("syntax") || error_lower.contains("parse") { + "syntax".to_string() + } else if error_lower.contains("test") && error_lower.contains("fail") { + "test_failure".to_string() + } else if error_lower.contains("build") && error_lower.contains("fail") { + "build_failure".to_string() + } else if error_lower.contains("not found") || error_lower.contains("missing") { + "not_found".to_string() + } else if error_lower.contains("conflict") { + "conflict".to_string() + } else { + "unknown".to_string() + } + } + + /// Calculate text similarity score with another outcome (0.0 to 1.0). + pub fn similarity(&self, other: &FixOutcome) -> f64 { + // Use keyword overlap as a simple similarity metric + let self_keywords: std::collections::HashSet<_> = self.keywords.iter().collect(); + let other_keywords: std::collections::HashSet<_> = other.keywords.iter().collect(); + + if self_keywords.is_empty() || other_keywords.is_empty() { + return 0.0; + } + + let intersection = self_keywords.intersection(&other_keywords).count() as f64; + let union = self_keywords.union(&other_keywords).count() as f64; + + if union == 0.0 { + 0.0 + } else { + intersection / union // Jaccard similarity + } + } + + /// Calculate text similarity with an issue (for finding similar past issues). + pub fn similarity_to_issue(&self, issue: &Issue) -> f64 { + let description = issue.description.as_deref().unwrap_or(""); + let issue_keywords = Self::extract_keywords(&issue.title, description); + let self_keywords: std::collections::HashSet<_> = self.keywords.iter().collect(); + let other_keywords: std::collections::HashSet<&String> = issue_keywords.iter().collect(); + + if self_keywords.is_empty() || other_keywords.is_empty() { + return 0.0; + } + + let intersection = self_keywords.intersection(&other_keywords).count() as f64; + let union = self_keywords.union(&other_keywords).count() as f64; + + if union == 0.0 { + 0.0 + } else { + intersection / union + } + } +} + +/// Check if a word is too common to be a useful keyword. +fn is_common_word(word: &str) -> bool { + const COMMON_WORDS: &[&str] = &[ + "the", + "a", + "an", + "is", + "are", + "was", + "were", + "be", + "been", + "being", + "have", + "has", + "had", + "do", + "does", + "did", + "will", + "would", + "could", + "should", + "may", + "might", + "must", + "shall", + "can", + "need", + "dare", + "this", + "that", + "these", + "those", + "what", + "which", + "who", + "whom", + "when", + "where", + "why", + "how", + "all", + "each", + "every", + "both", + "few", + "more", + "most", + "other", + "some", + "such", + "than", + "too", + "very", + "just", + "also", + "only", + "now", + "then", + "here", + "there", + "with", + "from", + "into", + "onto", + "upon", + "over", + "under", + "above", + "below", + "between", + "among", + "through", + "during", + "before", + "after", + "about", + "against", + "without", + "within", + "throughout", + "around", + "and", + "but", + "or", + "nor", + "for", + "yet", + "so", + "because", + "although", + "while", + "if", + "unless", + "until", + "since", + "once", + "whereas", + "error", + "issue", + "problem", + "bug", + "fix", + "fixed", + "fixing", + ]; + + COMMON_WORDS.contains(&word) +} + +/// Tracks fix outcomes in memory (can be persisted to DB later). +pub struct OutcomeTracker { + outcomes: Vec, + next_id: i64, +} + +impl OutcomeTracker { + /// Create a new outcome tracker. + pub fn new() -> Self { + Self { + outcomes: Vec::new(), + next_id: 1, + } + } + + /// Record a new outcome. + pub fn record(&mut self, mut outcome: FixOutcome) -> Result { + outcome.id = self.next_id; + self.next_id += 1; + + let id = outcome.id; + self.outcomes.push(outcome); + Ok(id) + } + + /// Find similar past outcomes for an issue. + pub fn find_similar( + &self, + issue: &Issue, + limit: usize, + min_similarity: f64, + ) -> Vec<&FixOutcome> { + let mut scored: Vec<_> = self + .outcomes + .iter() + .map(|o| (o, o.similarity_to_issue(issue))) + .filter(|(_, score)| *score >= min_similarity) + .collect(); + + scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + + scored.into_iter().take(limit).map(|(o, _)| o).collect() + } + + /// Get outcomes by result. + pub fn get_by_outcome(&self, outcome: Outcome) -> Vec<&FixOutcome> { + self.outcomes + .iter() + .filter(|o| o.outcome == outcome) + .collect() + } + + /// Get success rate for a source. + pub fn success_rate(&self, source: Option<&str>) -> f64 { + let filtered: Vec<_> = match source { + Some(s) => self.outcomes.iter().filter(|o| o.source == s).collect(), + None => self.outcomes.iter().collect(), + }; + + if filtered.is_empty() { + return 0.0; + } + + let successes = filtered.iter().filter(|o| o.outcome.is_success()).count(); + successes as f64 / filtered.len() as f64 + } + + /// Get common error types. + pub fn common_errors(&self, limit: usize) -> Vec<(String, usize)> { + let mut error_counts: HashMap = HashMap::new(); + + for outcome in &self.outcomes { + if let Some(ref error_type) = outcome.error_type { + *error_counts.entry(error_type.clone()).or_insert(0) += 1; + } + } + + let mut counts: Vec<_> = error_counts.into_iter().collect(); + counts.sort_by(|a, b| b.1.cmp(&a.1)); + counts.truncate(limit); + counts + } + + /// Get all outcomes. + pub fn all(&self) -> &[FixOutcome] { + &self.outcomes + } + + /// Add learnings to an outcome. + pub fn add_learnings(&mut self, id: i64, learnings: &str) -> Result<()> { + if let Some(outcome) = self.outcomes.iter_mut().find(|o| o.id == id) { + outcome.learnings = Some(learnings.to_string()); + } + Ok(()) + } +} + +impl Default for OutcomeTracker { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{IssuePriority, IssueStatus}; + + fn create_test_issue(title: &str, description: &str) -> Issue { + Issue { + id: "test-1".to_string(), + short_id: "TEST-1".to_string(), + title: title.to_string(), + description: Some(description.to_string()), + url: "https://example.com".to_string(), + source: "test".to_string(), + priority: IssuePriority::Medium, + status: IssueStatus::Open, + metadata: Default::default(), + created_at: None, + updated_at: None, + } + } + + fn create_test_attempt() -> FixAttempt { + FixAttempt { + id: 1, + source: "test".to_string(), + issue_id: "test-1".to_string(), + short_id: "TEST-1".to_string(), + status: crate::types::FixAttemptStatus::Success, + pr_url: Some("https://github.com/test/pr/1".to_string()), + github_repo: None, + github_pr_number: None, + error_message: None, + attempted_at: Utc::now(), + resolved_at: None, + merged_at: None, + retry_count: 0, + last_retry_at: None, + } + } + + #[test] + fn test_outcome_parse() { + assert_eq!(Outcome::parse("merged"), Some(Outcome::Merged)); + assert_eq!(Outcome::parse("closed"), Some(Outcome::Closed)); + assert_eq!(Outcome::parse("failed"), Some(Outcome::Failed)); + assert_eq!(Outcome::parse("cannot_fix"), Some(Outcome::CannotFix)); + assert_eq!(Outcome::parse("invalid"), None); + } + + #[test] + fn test_outcome_is_success() { + assert!(Outcome::Merged.is_success()); + assert!(!Outcome::Closed.is_success()); + assert!(!Outcome::Failed.is_success()); + } + + #[test] + fn test_extract_keywords() { + let keywords = FixOutcome::extract_keywords( + "Database connection timeout", + "The database connection times out when processing large queries", + ); + + assert!(keywords.contains(&"database".to_string())); + assert!(keywords.contains(&"connection".to_string())); + assert!(keywords.contains(&"timeout".to_string())); + assert!(keywords.contains(&"queries".to_string())); + } + + #[test] + fn test_categorize_error() { + assert_eq!( + FixOutcome::categorize_error("Connection timed out"), + "timeout" + ); + assert_eq!( + FixOutcome::categorize_error("Permission denied"), + "permission" + ); + assert_eq!( + FixOutcome::categorize_error("Syntax error on line 5"), + "syntax" + ); + assert_eq!(FixOutcome::categorize_error("Tests failed"), "test_failure"); + assert_eq!( + FixOutcome::categorize_error("Build failed"), + "build_failure" + ); + assert_eq!(FixOutcome::categorize_error("File not found"), "not_found"); + } + + #[test] + fn test_similarity() { + let issue1 = create_test_issue( + "Database timeout error", + "Connection to PostgreSQL times out", + ); + let issue2 = create_test_issue("Database connection issue", "PostgreSQL connection fails"); + let attempt = create_test_attempt(); + + let outcome1 = FixOutcome::from_attempt(&attempt, &issue1, "test prompt", Outcome::Merged); + let outcome2 = FixOutcome::from_attempt(&attempt, &issue2, "test prompt", Outcome::Closed); + + let similarity = outcome1.similarity(&outcome2); + assert!(similarity > 0.0); // Should have some overlap + assert!(similarity <= 1.0); + } + + #[test] + fn test_outcome_tracker() { + let mut tracker = OutcomeTracker::new(); + let issue = create_test_issue("Test issue", "Description here"); + let attempt = create_test_attempt(); + + let outcome = FixOutcome::from_attempt(&attempt, &issue, "prompt", Outcome::Merged); + let id = tracker.record(outcome).unwrap(); + + assert_eq!(id, 1); + assert_eq!(tracker.all().len(), 1); + } + + #[test] + fn test_find_similar() { + let mut tracker = OutcomeTracker::new(); + let attempt = create_test_attempt(); + + // Add some outcomes + let issue1 = + create_test_issue("API endpoint returns 500", "Server error in users endpoint"); + let outcome1 = FixOutcome::from_attempt(&attempt, &issue1, "prompt", Outcome::Merged); + tracker.record(outcome1).unwrap(); + + let issue2 = create_test_issue("CSS styling broken", "Buttons not aligned properly"); + let outcome2 = FixOutcome::from_attempt(&attempt, &issue2, "prompt", Outcome::Closed); + tracker.record(outcome2).unwrap(); + + // Search for similar + let search_issue = create_test_issue("API returns error 500", "Error in the API endpoint"); + let similar = tracker.find_similar(&search_issue, 5, 0.0); + + assert!(!similar.is_empty()); + } + + #[test] + fn test_success_rate() { + let mut tracker = OutcomeTracker::new(); + let attempt = create_test_attempt(); + let issue = create_test_issue("Test", "Test"); + + tracker + .record(FixOutcome::from_attempt( + &attempt, + &issue, + "p", + Outcome::Merged, + )) + .unwrap(); + tracker + .record(FixOutcome::from_attempt( + &attempt, + &issue, + "p", + Outcome::Merged, + )) + .unwrap(); + tracker + .record(FixOutcome::from_attempt( + &attempt, + &issue, + "p", + Outcome::Closed, + )) + .unwrap(); + tracker + .record(FixOutcome::from_attempt( + &attempt, + &issue, + "p", + Outcome::Failed, + )) + .unwrap(); + + let rate = tracker.success_rate(None); + assert!((rate - 0.5).abs() < 0.01); // 50% success rate + } + + #[test] + fn test_outcome_as_str() { + assert_eq!(Outcome::Merged.as_str(), "merged"); + assert_eq!(Outcome::Closed.as_str(), "closed"); + assert_eq!(Outcome::Failed.as_str(), "failed"); + assert_eq!(Outcome::CannotFix.as_str(), "cannot_fix"); + } + + #[test] + fn test_outcome_parse_case_insensitive() { + assert_eq!(Outcome::parse("MERGED"), Some(Outcome::Merged)); + assert_eq!(Outcome::parse("Closed"), Some(Outcome::Closed)); + assert_eq!(Outcome::parse("FAILED"), Some(Outcome::Failed)); + assert_eq!(Outcome::parse("CannotFix"), Some(Outcome::CannotFix)); + } + + #[test] + fn test_categorize_error_all_types() { + assert_eq!(FixOutcome::categorize_error("Request timed out"), "timeout"); + assert_eq!( + FixOutcome::categorize_error("Access denied error"), + "permission" + ); + assert_eq!( + FixOutcome::categorize_error("Parse error in JSON"), + "syntax" + ); + assert_eq!( + FixOutcome::categorize_error("3 tests failed"), + "test_failure" + ); + assert_eq!( + FixOutcome::categorize_error("Build failed with errors"), + "build_failure" + ); + assert_eq!( + FixOutcome::categorize_error("Module not found"), + "not_found" + ); + assert_eq!( + FixOutcome::categorize_error("Missing dependency"), + "not_found" + ); + assert_eq!(FixOutcome::categorize_error("Merge conflict"), "conflict"); + assert_eq!(FixOutcome::categorize_error("Some random error"), "unknown"); + } + + #[test] + fn test_is_common_word() { + assert!(is_common_word("the")); + assert!(is_common_word("is")); + assert!(is_common_word("error")); + assert!(is_common_word("fix")); + assert!(!is_common_word("database")); + assert!(!is_common_word("postgresql")); + assert!(!is_common_word("timeout")); + } + + #[test] + fn test_extract_keywords_filters_short_words() { + let keywords = FixOutcome::extract_keywords("A bug in the API", "It is bad"); + // Short words like "a", "in", "is", "it" should be filtered + for kw in &keywords { + assert!(kw.len() > 3); + } + } + + #[test] + fn test_extract_keywords_max_count() { + let long_text = "word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12 word13 word14 word15 word16 word17 word18 word19 word20 word21 word22 word23 word24 word25"; + let keywords = FixOutcome::extract_keywords(long_text, ""); + assert!(keywords.len() <= 20); + } + + #[test] + fn test_similarity_identical() { + let issue = create_test_issue("Same title", "Same description"); + let attempt = create_test_attempt(); + let outcome1 = FixOutcome::from_attempt(&attempt, &issue, "prompt", Outcome::Merged); + let outcome2 = FixOutcome::from_attempt(&attempt, &issue, "prompt", Outcome::Merged); + + let similarity = outcome1.similarity(&outcome2); + assert_eq!(similarity, 1.0); // Identical should be 1.0 + } + + #[test] + fn test_similarity_completely_different() { + let attempt = create_test_attempt(); + let issue1 = create_test_issue("PostgreSQL database error", "Connection to database fails"); + let issue2 = create_test_issue("JavaScript CSS styling", "Frontend React component"); + + let outcome1 = FixOutcome::from_attempt(&attempt, &issue1, "prompt", Outcome::Merged); + let outcome2 = FixOutcome::from_attempt(&attempt, &issue2, "prompt", Outcome::Merged); + + let similarity = outcome1.similarity(&outcome2); + assert!(similarity < 0.3); // Very different topics + } + + #[test] + fn test_similarity_empty_keywords() { + let attempt = create_test_attempt(); + let issue1 = Issue { + id: "1".to_string(), + short_id: "1".to_string(), + title: "a b c".to_string(), // Only short words + description: None, + url: "url".to_string(), + source: "test".to_string(), + priority: IssuePriority::Medium, + status: IssueStatus::Open, + metadata: Default::default(), + created_at: None, + updated_at: None, + }; + + let outcome = FixOutcome::from_attempt(&attempt, &issue1, "prompt", Outcome::Merged); + + // Empty keywords should result in 0 similarity + let issue2 = create_test_issue("Database error", "Connection fails"); + assert_eq!(outcome.similarity_to_issue(&issue2), 0.0); + } + + #[test] + fn test_outcome_tracker_default() { + let tracker = OutcomeTracker::default(); + assert!(tracker.all().is_empty()); + } + + #[test] + fn test_outcome_tracker_increments_id() { + let mut tracker = OutcomeTracker::new(); + let attempt = create_test_attempt(); + let issue = create_test_issue("Test", "Test"); + + let id1 = tracker + .record(FixOutcome::from_attempt( + &attempt, + &issue, + "p", + Outcome::Merged, + )) + .unwrap(); + let id2 = tracker + .record(FixOutcome::from_attempt( + &attempt, + &issue, + "p", + Outcome::Merged, + )) + .unwrap(); + let id3 = tracker + .record(FixOutcome::from_attempt( + &attempt, + &issue, + "p", + Outcome::Merged, + )) + .unwrap(); + + assert_eq!(id1, 1); + assert_eq!(id2, 2); + assert_eq!(id3, 3); + } + + #[test] + fn test_get_by_outcome() { + let mut tracker = OutcomeTracker::new(); + let attempt = create_test_attempt(); + let issue = create_test_issue("Test", "Test"); + + tracker + .record(FixOutcome::from_attempt( + &attempt, + &issue, + "p", + Outcome::Merged, + )) + .unwrap(); + tracker + .record(FixOutcome::from_attempt( + &attempt, + &issue, + "p", + Outcome::Closed, + )) + .unwrap(); + tracker + .record(FixOutcome::from_attempt( + &attempt, + &issue, + "p", + Outcome::Merged, + )) + .unwrap(); + + let merged = tracker.get_by_outcome(Outcome::Merged); + assert_eq!(merged.len(), 2); + + let closed = tracker.get_by_outcome(Outcome::Closed); + assert_eq!(closed.len(), 1); + + let failed = tracker.get_by_outcome(Outcome::Failed); + assert!(failed.is_empty()); + } + + #[test] + fn test_success_rate_by_source() { + let mut tracker = OutcomeTracker::new(); + let issue = create_test_issue("Test", "Test"); + + // Linear: 1 success, 1 fail = 50% + let mut linear_attempt = create_test_attempt(); + linear_attempt.source = "linear".to_string(); + let mut linear_outcome = + FixOutcome::from_attempt(&linear_attempt, &issue, "p", Outcome::Merged); + linear_outcome.source = "linear".to_string(); + tracker.record(linear_outcome).unwrap(); + + let mut linear_outcome2 = + FixOutcome::from_attempt(&linear_attempt, &issue, "p", Outcome::Failed); + linear_outcome2.source = "linear".to_string(); + tracker.record(linear_outcome2).unwrap(); + + // Sentry: 2 successes = 100% + let mut sentry_attempt = create_test_attempt(); + sentry_attempt.source = "sentry".to_string(); + let mut sentry_outcome = + FixOutcome::from_attempt(&sentry_attempt, &issue, "p", Outcome::Merged); + sentry_outcome.source = "sentry".to_string(); + tracker.record(sentry_outcome.clone()).unwrap(); + tracker.record(sentry_outcome).unwrap(); + + let linear_rate = tracker.success_rate(Some("linear")); + assert!((linear_rate - 0.5).abs() < 0.01); + + let sentry_rate = tracker.success_rate(Some("sentry")); + assert!((sentry_rate - 1.0).abs() < 0.01); + } + + #[test] + fn test_success_rate_empty() { + let tracker = OutcomeTracker::new(); + assert_eq!(tracker.success_rate(None), 0.0); + assert_eq!(tracker.success_rate(Some("nonexistent")), 0.0); + } + + #[test] + fn test_common_errors() { + let mut tracker = OutcomeTracker::new(); + let issue = create_test_issue("Test", "Test"); + let mut attempt = create_test_attempt(); + + attempt.error_message = Some("Connection timed out".to_string()); + tracker + .record(FixOutcome::from_attempt( + &attempt, + &issue, + "p", + Outcome::Failed, + )) + .unwrap(); + tracker + .record(FixOutcome::from_attempt( + &attempt, + &issue, + "p", + Outcome::Failed, + )) + .unwrap(); + tracker + .record(FixOutcome::from_attempt( + &attempt, + &issue, + "p", + Outcome::Failed, + )) + .unwrap(); + + attempt.error_message = Some("Permission denied".to_string()); + tracker + .record(FixOutcome::from_attempt( + &attempt, + &issue, + "p", + Outcome::Failed, + )) + .unwrap(); + tracker + .record(FixOutcome::from_attempt( + &attempt, + &issue, + "p", + Outcome::Failed, + )) + .unwrap(); + + attempt.error_message = Some("Build failed".to_string()); + tracker + .record(FixOutcome::from_attempt( + &attempt, + &issue, + "p", + Outcome::Failed, + )) + .unwrap(); + + let errors = tracker.common_errors(10); + assert_eq!(errors.len(), 3); + assert_eq!(errors[0].0, "timeout"); + assert_eq!(errors[0].1, 3); + assert_eq!(errors[1].0, "permission"); + assert_eq!(errors[1].1, 2); + } + + #[test] + fn test_add_learnings() { + let mut tracker = OutcomeTracker::new(); + let issue = create_test_issue("Test", "Test"); + let attempt = create_test_attempt(); + + let id = tracker + .record(FixOutcome::from_attempt( + &attempt, + &issue, + "p", + Outcome::Merged, + )) + .unwrap(); + + tracker + .add_learnings(id, "Important learning here") + .unwrap(); + + let outcome = &tracker.all()[0]; + assert_eq!( + outcome.learnings, + Some("Important learning here".to_string()) + ); + } + + #[test] + fn test_add_learnings_nonexistent_id() { + let mut tracker = OutcomeTracker::new(); + + // Should not panic + let result = tracker.add_learnings(999, "Learning"); + assert!(result.is_ok()); + } + + #[test] + fn test_find_similar_with_min_similarity() { + let mut tracker = OutcomeTracker::new(); + let attempt = create_test_attempt(); + + let issue1 = create_test_issue( + "PostgreSQL database timeout", + "Connection to PostgreSQL times out", + ); + tracker + .record(FixOutcome::from_attempt( + &attempt, + &issue1, + "p", + Outcome::Merged, + )) + .unwrap(); + + let issue2 = create_test_issue("JavaScript button styling", "React component CSS issue"); + tracker + .record(FixOutcome::from_attempt( + &attempt, + &issue2, + "p", + Outcome::Closed, + )) + .unwrap(); + + // Search for PostgreSQL issue - should find issue1 with high similarity + let search = create_test_issue("PostgreSQL connection error", "Database connection fails"); + + let _high_min = tracker.find_similar(&search, 10, 0.3); + // Should only find the PostgreSQL one (if similarity is above 0.3) + // Results depend on keyword extraction + + let low_min = tracker.find_similar(&search, 10, 0.0); + // With 0 min, should find both + assert!(!low_min.is_empty()); + } + + #[test] + fn test_fix_outcome_from_attempt_sets_fields() { + let issue = create_test_issue("Test Title", "Test Description"); + let mut attempt = create_test_attempt(); + attempt.error_message = Some("Connection timed out".to_string()); + + let outcome = FixOutcome::from_attempt(&attempt, &issue, "test prompt", Outcome::Failed); + + assert_eq!(outcome.source, "test"); + assert_eq!(outcome.issue_id, "test-1"); + assert_eq!(outcome.prompt_used, "test prompt"); + assert_eq!(outcome.outcome, Outcome::Failed); + assert_eq!(outcome.error_type, Some("timeout".to_string())); + assert!(outcome.issue_text.contains("Test Title")); + assert!(outcome.issue_text.contains("Test Description")); + assert!(!outcome.keywords.is_empty()); + } + + #[test] + fn test_outcome_serde() { + let outcome = Outcome::Merged; + let json = serde_json::to_string(&outcome).unwrap(); + assert_eq!(json, "\"Merged\""); + + let parsed: Outcome = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, Outcome::Merged); + } +} diff --git a/src/github.rs b/src/github.rs new file mode 100644 index 00000000..b7e51e85 --- /dev/null +++ b/src/github.rs @@ -0,0 +1,2350 @@ +//! GitHub PR monitoring and issue resolution. + +use crate::config::GitHubConfig; +use crate::error::{Error, Result}; +use crate::storage::FixAttemptTracker; +use crate::types::{FixAttempt, PrReviewRecord}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +/// HTTP response abstraction for testability. +#[derive(Debug)] +pub struct HttpResponse { + pub status: u16, + pub body: String, +} + +impl HttpResponse { + /// Check if the status is successful (2xx). + pub fn is_success(&self) -> bool { + (200..300).contains(&self.status) + } + + /// Check if the status is 404 Not Found. + pub fn is_not_found(&self) -> bool { + self.status == 404 + } + + /// Parse the body as JSON. + pub fn json(&self) -> Result { + serde_json::from_str(&self.body) + .map_err(|e| Error::Other(format!("JSON parse error: {}", e))) + } +} + +/// Trait for HTTP client operations to enable testing. +#[async_trait] +pub trait HttpClient: Send + Sync { + /// Perform a GET request with headers. + async fn get(&self, url: &str, headers: Vec<(&str, String)>) -> Result; +} + +/// Default HTTP client using reqwest. +pub struct ReqwestHttpClient { + client: reqwest::Client, +} + +impl ReqwestHttpClient { + /// Create a new reqwest-based HTTP client. + pub fn new() -> Self { + Self { + client: reqwest::Client::new(), + } + } +} + +impl Default for ReqwestHttpClient { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl HttpClient for ReqwestHttpClient { + async fn get(&self, url: &str, headers: Vec<(&str, String)>) -> Result { + let mut request = self.client.get(url); + for (name, value) in headers { + request = request.header(name, value); + } + let response = request.send().await?; + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + Ok(HttpResponse { status, body }) + } +} + +/// GitHub API client for PR monitoring. +pub struct GitHubClient { + config: GitHubConfig, + http: H, +} + +#[derive(Debug, Deserialize)] +struct PullRequest { + state: String, + merged: bool, +} + +/// A GitHub PR review. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrReview { + /// Review ID. + pub id: i64, + /// Review state (APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED, PENDING). + pub state: String, + /// Review body/comment. + pub body: Option, + /// Reviewer user. + pub user: GitHubUser, + /// When the review was submitted. + pub submitted_at: Option, + /// HTML URL to the review. + pub html_url: Option, +} + +/// A GitHub user. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitHubUser { + /// User ID. + pub id: i64, + /// Username/login. + pub login: String, + /// User type (User, Bot, etc.). + #[serde(rename = "type")] + pub user_type: Option, +} + +/// A GitHub PR review comment. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrReviewComment { + /// Comment ID. + pub id: i64, + /// File path the comment is on. + pub path: String, + /// Line position in the diff. + pub position: Option, + /// Original line position. + pub original_position: Option, + /// Comment body. + pub body: String, + /// User who wrote the comment. + pub user: GitHubUser, + /// When the comment was created. + pub created_at: String, + /// When the comment was last updated. + pub updated_at: String, + /// HTML URL to the comment. + pub html_url: String, + /// Associated review ID if part of a review. + pub pull_request_review_id: Option, + /// Start line (for multi-line comments). + pub start_line: Option, + /// Line number. + pub line: Option, + /// Side of the diff (LEFT or RIGHT). + pub side: Option, +} + +impl GitHubClient { + /// Create a new GitHub client with the default HTTP client. + pub fn new(config: GitHubConfig) -> Self { + Self { + config, + http: ReqwestHttpClient::new(), + } + } +} + +impl GitHubClient { + /// Create a new GitHub client with a custom HTTP client. + pub fn with_http_client(config: GitHubConfig, http: H) -> Self { + Self { config, http } + } + + /// Check if configured (has token). + pub fn is_enabled(&self) -> bool { + self.config.token.is_some() + } + + /// Build standard GitHub API headers. + fn build_headers(&self, token: &str) -> Vec<(&'static str, String)> { + vec![ + ("Authorization", format!("Bearer {}", token)), + ("Accept", "application/vnd.github+json".to_string()), + ("User-Agent", "claudear".to_string()), + ("X-GitHub-Api-Version", "2022-11-28".to_string()), + ] + } + + /// Get the status of a PR. + pub async fn get_pr_status(&self, repo: &str, pr_number: i64) -> Result { + let token = self + .config + .token + .as_ref() + .ok_or_else(|| Error::config("GitHub token not configured"))?; + + let url = format!("https://api.github.com/repos/{}/pulls/{}", repo, pr_number); + let headers = self.build_headers(token); + + let response = self.http.get(&url, headers).await?; + + if response.is_not_found() { + return Err(Error::Other(format!( + "PR not found: {}/pull/{}", + repo, pr_number + ))); + } + + if !response.is_success() { + return Err(Error::Other(format!("GitHub API error: {}", response.body))); + } + + let pr: PullRequest = response.json()?; + + Ok(if pr.merged { + PrStatus::Merged + } else if pr.state == "closed" { + PrStatus::Closed + } else { + PrStatus::Open + }) + } + + /// Get reviews for a PR. + pub async fn get_pr_reviews(&self, repo: &str, pr_number: i64) -> Result> { + let token = self + .config + .token + .as_ref() + .ok_or_else(|| Error::config("GitHub token not configured"))?; + + let url = format!( + "https://api.github.com/repos/{}/pulls/{}/reviews", + repo, pr_number + ); + let headers = self.build_headers(token); + + let response = self.http.get(&url, headers).await?; + + if !response.is_success() { + return Err(Error::Other(format!( + "GitHub API error ({}): {}", + response.status, response.body + ))); + } + + response.json() + } + + /// Get review comments for a PR. + pub async fn get_pr_review_comments( + &self, + repo: &str, + pr_number: i64, + ) -> Result> { + let token = self + .config + .token + .as_ref() + .ok_or_else(|| Error::config("GitHub token not configured"))?; + + let url = format!( + "https://api.github.com/repos/{}/pulls/{}/comments", + repo, pr_number + ); + let headers = self.build_headers(token); + + let response = self.http.get(&url, headers).await?; + + if !response.is_success() { + return Err(Error::Other(format!( + "GitHub API error ({}): {}", + response.status, response.body + ))); + } + + response.json() + } + + /// Get reviews for a PR that haven't been processed yet. + pub async fn get_new_reviews( + &self, + repo: &str, + pr_number: i64, + since: Option<&str>, + ) -> Result> { + let reviews = self.get_pr_reviews(repo, pr_number).await?; + + if let Some(since_time) = since { + Ok(reviews + .into_iter() + .filter(|r| { + r.submitted_at + .as_ref() + .map(|t| t.as_str() > since_time) + .unwrap_or(false) + }) + .collect()) + } else { + Ok(reviews) + } + } + + /// Get review comments since a given time. + pub async fn get_new_review_comments( + &self, + repo: &str, + pr_number: i64, + since: Option<&str>, + ) -> Result> { + let comments = self.get_pr_review_comments(repo, pr_number).await?; + + if let Some(since_time) = since { + Ok(comments + .into_iter() + .filter(|c| c.updated_at.as_str() > since_time) + .collect()) + } else { + Ok(comments) + } + } + + /// Get the GitHub token (if configured). + pub fn token(&self) -> Option<&str> { + self.config.token.as_deref() + } +} + +/// Status of a GitHub PR. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PrStatus { + Open, + Merged, + Closed, +} + +/// PR Monitor that watches for merged PRs and updates issue status. +pub struct PrMonitor { + github: GitHubClient, + tracker: Arc, + auto_resolve: bool, +} + +impl PrMonitor { + /// Create a new PR monitor with the default HTTP client. + pub fn new( + github: GitHubClient, + tracker: Arc, + auto_resolve: bool, + ) -> Self { + Self { + github, + tracker, + auto_resolve, + } + } +} + +impl PrMonitor { + /// Create a new PR monitor with a custom HTTP client. + pub fn with_http_client( + github: GitHubClient, + tracker: Arc, + auto_resolve: bool, + ) -> Self { + Self { + github, + tracker, + auto_resolve, + } + } + + /// Check all pending PRs and update their status. + pub async fn check_pending_prs(&self) -> Result> { + if !self.github.is_enabled() { + return Ok(vec![]); + } + + let pending_prs = self.tracker.get_pending_prs()?; + let mut updates = Vec::new(); + + for attempt in pending_prs { + if let Some(update) = self.check_pr(&attempt).await? { + updates.push(update); + } + } + + Ok(updates) + } + + /// Check a single PR and update its status if needed. + async fn check_pr(&self, attempt: &FixAttempt) -> Result> { + let repo = match &attempt.github_repo { + Some(r) => r, + None => return Ok(None), + }; + + let pr_number = match attempt.github_pr_number { + Some(n) => n, + None => return Ok(None), + }; + + let status = match self.github.get_pr_status(repo, pr_number).await { + Ok(s) => s, + Err(e) => { + tracing::warn!( + source = "github", + repo = %repo, + pr_number = pr_number, + error = %e, + "Failed to check PR" + ); + return Ok(None); + } + }; + + match status { + PrStatus::Merged => { + tracing::info!(source = "github", repo = %repo, pr_number = pr_number, "PR has been merged!"); + self.tracker + .mark_merged(&attempt.source, &attempt.issue_id)?; + + // Log activity event + let pr_url = attempt.pr_url.clone().unwrap_or_default(); + let activity = crate::types::ActivityLogEntry::new( + "pr_merged", + format!("PR merged: {}", pr_url), + ) + .with_source(attempt.source.clone()) + .with_issue(attempt.issue_id.clone(), attempt.short_id.clone()) + .with_metadata(serde_json::json!({ + "pr_url": pr_url, + "repo": repo, + "pr_number": pr_number + })); + let _ = self.tracker.record_activity(&activity); + + Ok(Some(PrStatusUpdate { + source: attempt.source.clone(), + issue_id: attempt.issue_id.clone(), + short_id: attempt.short_id.clone(), + pr_url, + new_status: PrStatus::Merged, + should_resolve: self.auto_resolve, + })) + } + PrStatus::Closed => { + tracing::info!( + source = "github", + repo = %repo, + pr_number = pr_number, + "PR was closed without merging" + ); + self.tracker + .mark_closed(&attempt.source, &attempt.issue_id)?; + + // Log activity event + let pr_url = attempt.pr_url.clone().unwrap_or_default(); + let activity = crate::types::ActivityLogEntry::new( + "pr_closed", + format!("PR closed without merge: {}", pr_url), + ) + .with_source(attempt.source.clone()) + .with_issue(attempt.issue_id.clone(), attempt.short_id.clone()) + .with_metadata(serde_json::json!({ + "pr_url": pr_url, + "repo": repo, + "pr_number": pr_number + })); + let _ = self.tracker.record_activity(&activity); + + Ok(Some(PrStatusUpdate { + source: attempt.source.clone(), + issue_id: attempt.issue_id.clone(), + short_id: attempt.short_id.clone(), + pr_url, + new_status: PrStatus::Closed, + should_resolve: false, + })) + } + PrStatus::Open => Ok(None), + } + } +} + +/// Update information for a PR status change. +#[derive(Debug, Clone)] +pub struct PrStatusUpdate { + pub source: String, + pub issue_id: String, + pub short_id: String, + pub pr_url: String, + pub new_status: PrStatus, + /// Whether the issue should be resolved on the source. + pub should_resolve: bool, +} + +/// State for tracking PR reviews. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrReviewState { + /// PR URL. + pub pr_url: String, + /// Repository (owner/repo). + pub repo: String, + /// PR number. + pub pr_number: i64, + /// Source issue ID. + pub issue_id: String, + /// Source (linear, sentry, etc.). + pub source: String, + /// Last review ID processed. + pub last_review_id: Option, + /// Last review timestamp processed. + pub last_review_time: Option, + /// Last comment ID processed. + pub last_comment_id: Option, + /// Last comment timestamp processed. + pub last_comment_time: Option, + /// Whether this PR is still being watched. + pub is_active: bool, +} + +impl PrReviewState { + /// Create a new review state. + pub fn new( + pr_url: impl Into, + repo: impl Into, + pr_number: i64, + issue_id: impl Into, + source: impl Into, + ) -> Self { + Self { + pr_url: pr_url.into(), + repo: repo.into(), + pr_number, + issue_id: issue_id.into(), + source: source.into(), + last_review_id: None, + last_review_time: None, + last_comment_id: None, + last_comment_time: None, + is_active: true, + } + } +} + +/// An event from a PR review watcher. +#[derive(Debug, Clone)] +pub enum ReviewEvent { + /// A new review was submitted. + ReviewSubmitted { + pr_url: String, + repo: String, + pr_number: i64, + review: PrReview, + }, + /// New review comments were added. + CommentsAdded { + pr_url: String, + repo: String, + pr_number: i64, + comments: Vec, + }, +} + +impl ReviewEvent { + /// Get the PR URL for this event. + pub fn pr_url(&self) -> &str { + match self { + ReviewEvent::ReviewSubmitted { pr_url, .. } => pr_url, + ReviewEvent::CommentsAdded { pr_url, .. } => pr_url, + } + } + + /// Check if this event requires agent action. + pub fn requires_action(&self) -> bool { + match self { + ReviewEvent::ReviewSubmitted { review, .. } => { + // Only process reviews that request changes or have comments + matches!( + review.state.to_uppercase().as_str(), + "CHANGES_REQUESTED" | "COMMENTED" + ) + } + ReviewEvent::CommentsAdded { comments, .. } => !comments.is_empty(), + } + } + + /// Get a summary of feedback for the agent. + pub fn get_feedback_summary(&self) -> String { + match self { + ReviewEvent::ReviewSubmitted { review, .. } => { + let mut summary = format!( + "Review from @{} ({})\n", + review.user.login, + review.state.to_uppercase() + ); + if let Some(body) = &review.body { + if !body.is_empty() { + summary.push_str(&format!("\nReview comment:\n{}\n", body)); + } + } + summary + } + ReviewEvent::CommentsAdded { comments, .. } => { + let mut summary = String::new(); + for comment in comments { + summary.push_str(&format!( + "Comment from @{} on `{}`", + comment.user.login, comment.path + )); + if let Some(line) = comment.line { + summary.push_str(&format!(" (line {})", line)); + } + summary.push_str(&format!(":\n{}\n\n", comment.body)); + } + summary + } + } + } +} + +/// Watches PRs for review activity. +pub struct ReviewWatcher { + github: GitHubClient, + /// Map of PR URL -> review state + states: std::sync::RwLock>, + /// Optional tracker for recording reviews to the database + tracker: Option>, +} + +impl ReviewWatcher { + /// Create a new review watcher with the default HTTP client. + pub fn new(github: GitHubClient) -> Self { + Self { + github, + states: std::sync::RwLock::new(std::collections::HashMap::new()), + tracker: None, + } + } + + /// Create a new review watcher with a tracker for analytics. + pub fn with_tracker(github: GitHubClient, tracker: Arc) -> Self { + Self { + github, + states: std::sync::RwLock::new(std::collections::HashMap::new()), + tracker: Some(tracker), + } + } +} + +impl ReviewWatcher { + /// Create a new review watcher with a custom HTTP client. + pub fn with_http_client(github: GitHubClient) -> Self { + Self { + github, + states: std::sync::RwLock::new(std::collections::HashMap::new()), + tracker: None, + } + } + + /// Create a new review watcher with a custom HTTP client and tracker. + pub fn with_http_client_and_tracker( + github: GitHubClient, + tracker: Arc, + ) -> Self { + Self { + github, + states: std::sync::RwLock::new(std::collections::HashMap::new()), + tracker: Some(tracker), + } + } + + /// Check if the watcher is enabled. + pub fn is_enabled(&self) -> bool { + self.github.is_enabled() + } + + /// Start watching a PR for reviews. + pub fn watch_pr(&self, state: PrReviewState) { + let mut states = self.states.write().unwrap(); + states.insert(state.pr_url.clone(), state); + } + + /// Stop watching a PR. + pub fn unwatch_pr(&self, pr_url: &str) { + let mut states = self.states.write().unwrap(); + if let Some(state) = states.get_mut(pr_url) { + state.is_active = false; + } + } + + /// Get the state for a PR. + pub fn get_state(&self, pr_url: &str) -> Option { + let states = self.states.read().unwrap(); + states.get(pr_url).cloned() + } + + /// Get all active states. + pub fn get_active_states(&self) -> Vec { + let states = self.states.read().unwrap(); + states.values().filter(|s| s.is_active).cloned().collect() + } + + /// Load states from storage. + pub fn load_states(&self, states_vec: Vec) { + let mut states = self.states.write().unwrap(); + for state in states_vec { + if state.is_active { + states.insert(state.pr_url.clone(), state); + } + } + } + + /// Get all states for persistence. + pub fn get_all_states(&self) -> Vec { + let states = self.states.read().unwrap(); + states.values().cloned().collect() + } + + /// Check all watched PRs for new reviews. + pub async fn check_for_reviews(&self) -> Result> { + if !self.github.is_enabled() { + return Ok(vec![]); + } + + let states_to_check: Vec = { + let states = self.states.read().unwrap(); + states.values().filter(|s| s.is_active).cloned().collect() + }; + + let mut events = Vec::new(); + + for state in states_to_check { + match self.check_pr_reviews(&state).await { + Ok(pr_events) => events.extend(pr_events), + Err(e) => { + tracing::warn!( + component = "review_watcher", + pr_url = %state.pr_url, + error = %e, + "Failed to check reviews" + ); + } + } + } + + Ok(events) + } + + /// Record a review to the database for analytics. + fn record_review_to_db(&self, state: &PrReviewState, review: &PrReview) { + if let Some(ref tracker) = self.tracker { + let mut record = PrReviewRecord::new(&state.pr_url); + record.reviewer = Some(review.user.login.clone()); + record.review_state = Some(review.state.clone()); + record.body = review.body.clone(); + + // Parse the submitted_at timestamp from GitHub's ISO 8601 format + if let Some(ref ts) = review.submitted_at { + if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(ts) { + record.submitted_at = Some(parsed.with_timezone(&chrono::Utc)); + } + } + + if let Err(e) = tracker.record_pr_review(&record) { + tracing::warn!( + component = "review_watcher", + pr_url = %state.pr_url, + error = %e, + "Failed to record review to database" + ); + } + + // Log activity event for the review + let message = format!( + "PR review from {}: {}", + review.user.login, + review.state + ); + let activity = crate::types::ActivityLogEntry::new("pr_review_received", &message) + .with_source("github".to_string()) + .with_metadata(serde_json::json!({ + "pr_url": state.pr_url, + "reviewer": review.user.login, + "review_state": review.state, + "repo": state.repo, + "pr_number": state.pr_number + })); + + if let Err(e) = tracker.record_activity(&activity) { + tracing::warn!( + component = "review_watcher", + error = %e, + "Failed to record review activity" + ); + } + } + } + + /// Check a single PR for new reviews. + async fn check_pr_reviews(&self, state: &PrReviewState) -> Result> { + let mut events = Vec::new(); + + // Check for new reviews + let reviews = self + .github + .get_new_reviews( + &state.repo, + state.pr_number, + state.last_review_time.as_deref(), + ) + .await?; + + for review in reviews { + // Skip reviews we've already processed + if let Some(last_id) = state.last_review_id { + if review.id <= last_id { + continue; + } + } + + // Skip bot reviews + if review.user.user_type.as_deref() == Some("Bot") { + continue; + } + + // Skip pending reviews (not yet submitted) + if review.state.to_uppercase() == "PENDING" { + continue; + } + + // Record the review to the database for analytics + self.record_review_to_db(state, &review); + + events.push(ReviewEvent::ReviewSubmitted { + pr_url: state.pr_url.clone(), + repo: state.repo.clone(), + pr_number: state.pr_number, + review: review.clone(), + }); + + // Update state + let mut states = self.states.write().unwrap(); + if let Some(s) = states.get_mut(&state.pr_url) { + s.last_review_id = Some(review.id); + s.last_review_time = review.submitted_at.clone(); + } + } + + // Check for new comments + let comments = self + .github + .get_new_review_comments( + &state.repo, + state.pr_number, + state.last_comment_time.as_deref(), + ) + .await?; + + // Filter out comments we've already processed + let new_comments: Vec<_> = comments + .into_iter() + .filter(|c| { + if let Some(last_id) = state.last_comment_id { + c.id > last_id + } else { + true + } + }) + .filter(|c| c.user.user_type.as_deref() != Some("Bot")) + .collect(); + + if !new_comments.is_empty() { + // Update state with latest comment + if let Some(latest) = new_comments.iter().max_by_key(|c| c.id) { + let mut states = self.states.write().unwrap(); + if let Some(s) = states.get_mut(&state.pr_url) { + s.last_comment_id = Some(latest.id); + s.last_comment_time = Some(latest.updated_at.clone()); + } + } + + events.push(ReviewEvent::CommentsAdded { + pr_url: state.pr_url.clone(), + repo: state.repo.clone(), + pr_number: state.pr_number, + comments: new_comments, + }); + } + + Ok(events) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + + /// Mock HTTP client for testing. + #[allow(clippy::type_complexity)] + pub struct MockHttpClient { + responses: Mutex>, + requests: Mutex)>>, + } + + impl MockHttpClient { + pub fn new() -> Self { + Self { + responses: Mutex::new(HashMap::new()), + requests: Mutex::new(Vec::new()), + } + } + + /// Add a mock response for a URL. + pub fn mock_response(&self, url: impl Into, status: u16, body: impl Into) { + let mut responses = self.responses.lock().unwrap(); + responses.insert( + url.into(), + HttpResponse { + status, + body: body.into(), + }, + ); + } + + /// Get recorded requests. + pub fn get_requests(&self) -> Vec<(String, Vec<(String, String)>)> { + self.requests.lock().unwrap().clone() + } + } + + #[async_trait] + impl HttpClient for MockHttpClient { + async fn get(&self, url: &str, headers: Vec<(&str, String)>) -> Result { + // Record the request + let owned_headers: Vec<(String, String)> = headers + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect(); + self.requests + .lock() + .unwrap() + .push((url.to_string(), owned_headers)); + + // Return mock response + let responses = self.responses.lock().unwrap(); + if let Some(response) = responses.get(url) { + Ok(HttpResponse { + status: response.status, + body: response.body.clone(), + }) + } else { + Ok(HttpResponse { + status: 404, + body: "Not found".to_string(), + }) + } + } + } + + #[test] + fn test_http_response_is_success() { + let response = HttpResponse { + status: 200, + body: "{}".to_string(), + }; + assert!(response.is_success()); + + let response = HttpResponse { + status: 201, + body: "{}".to_string(), + }; + assert!(response.is_success()); + + let response = HttpResponse { + status: 404, + body: "{}".to_string(), + }; + assert!(!response.is_success()); + + let response = HttpResponse { + status: 500, + body: "{}".to_string(), + }; + assert!(!response.is_success()); + } + + #[test] + fn test_http_response_is_not_found() { + let response = HttpResponse { + status: 404, + body: "{}".to_string(), + }; + assert!(response.is_not_found()); + + let response = HttpResponse { + status: 200, + body: "{}".to_string(), + }; + assert!(!response.is_not_found()); + } + + #[test] + fn test_http_response_json() { + let response = HttpResponse { + status: 200, + body: r#"{"name": "test"}"#.to_string(), + }; + let parsed: serde_json::Value = response.json().unwrap(); + assert_eq!(parsed["name"], "test"); + } + + #[test] + fn test_http_response_json_error() { + let response = HttpResponse { + status: 200, + body: "invalid json".to_string(), + }; + let result: Result = response.json(); + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_get_pr_status_open() { + let mock = MockHttpClient::new(); + mock.mock_response( + "https://api.github.com/repos/owner/repo/pulls/1", + 200, + r#"{"number": 1, "state": "open", "merged": false}"#, + ); + + let config = GitHubConfig { + token: Some("test_token".to_string()), + poll_interval_ms: 60000, + auto_resolve_on_merge: true, + }; + let client = GitHubClient::with_http_client(config, mock); + + let status = client.get_pr_status("owner/repo", 1).await.unwrap(); + assert_eq!(status, PrStatus::Open); + } + + #[tokio::test] + async fn test_get_pr_status_merged() { + let mock = MockHttpClient::new(); + mock.mock_response( + "https://api.github.com/repos/owner/repo/pulls/1", + 200, + r#"{"number": 1, "state": "closed", "merged": true}"#, + ); + + let config = GitHubConfig { + token: Some("test_token".to_string()), + poll_interval_ms: 60000, + auto_resolve_on_merge: true, + }; + let client = GitHubClient::with_http_client(config, mock); + + let status = client.get_pr_status("owner/repo", 1).await.unwrap(); + assert_eq!(status, PrStatus::Merged); + } + + #[tokio::test] + async fn test_get_pr_status_closed() { + let mock = MockHttpClient::new(); + mock.mock_response( + "https://api.github.com/repos/owner/repo/pulls/1", + 200, + r#"{"number": 1, "state": "closed", "merged": false}"#, + ); + + let config = GitHubConfig { + token: Some("test_token".to_string()), + poll_interval_ms: 60000, + auto_resolve_on_merge: true, + }; + let client = GitHubClient::with_http_client(config, mock); + + let status = client.get_pr_status("owner/repo", 1).await.unwrap(); + assert_eq!(status, PrStatus::Closed); + } + + #[tokio::test] + async fn test_get_pr_status_not_found() { + let mock = MockHttpClient::new(); + // No mock response means 404 + + let config = GitHubConfig { + token: Some("test_token".to_string()), + poll_interval_ms: 60000, + auto_resolve_on_merge: true, + }; + let client = GitHubClient::with_http_client(config, mock); + + let result = client.get_pr_status("owner/repo", 1).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("PR not found")); + } + + #[tokio::test] + async fn test_get_pr_status_api_error() { + let mock = MockHttpClient::new(); + mock.mock_response( + "https://api.github.com/repos/owner/repo/pulls/1", + 500, + "Internal Server Error", + ); + + let config = GitHubConfig { + token: Some("test_token".to_string()), + poll_interval_ms: 60000, + auto_resolve_on_merge: true, + }; + let client = GitHubClient::with_http_client(config, mock); + + let result = client.get_pr_status("owner/repo", 1).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("GitHub API error")); + } + + #[tokio::test] + async fn test_get_pr_status_no_token() { + let mock = MockHttpClient::new(); + let config = GitHubConfig::default(); // No token + let client = GitHubClient::with_http_client(config, mock); + + let result = client.get_pr_status("owner/repo", 1).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("token")); + } + + #[tokio::test] + async fn test_get_pr_reviews() { + let mock = MockHttpClient::new(); + mock.mock_response( + "https://api.github.com/repos/owner/repo/pulls/1/reviews", + 200, + r#"[ + {"id": 1, "state": "APPROVED", "body": "LGTM", "user": {"id": 123, "login": "reviewer"}, "submitted_at": "2024-01-01T00:00:00Z"} + ]"#, + ); + + let config = GitHubConfig { + token: Some("test_token".to_string()), + poll_interval_ms: 60000, + auto_resolve_on_merge: true, + }; + let client = GitHubClient::with_http_client(config, mock); + + let reviews = client.get_pr_reviews("owner/repo", 1).await.unwrap(); + assert_eq!(reviews.len(), 1); + assert_eq!(reviews[0].state, "APPROVED"); + } + + #[tokio::test] + async fn test_get_pr_reviews_api_error() { + let mock = MockHttpClient::new(); + mock.mock_response( + "https://api.github.com/repos/owner/repo/pulls/1/reviews", + 403, + "Forbidden", + ); + + let config = GitHubConfig { + token: Some("test_token".to_string()), + poll_interval_ms: 60000, + auto_resolve_on_merge: true, + }; + let client = GitHubClient::with_http_client(config, mock); + + let result = client.get_pr_reviews("owner/repo", 1).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_get_pr_review_comments() { + let mock = MockHttpClient::new(); + mock.mock_response( + "https://api.github.com/repos/owner/repo/pulls/1/comments", + 200, + r#"[ + { + "id": 1, + "path": "src/main.rs", + "body": "Please fix this", + "user": {"id": 123, "login": "reviewer"}, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z", + "html_url": "https://github.com/test" + } + ]"#, + ); + + let config = GitHubConfig { + token: Some("test_token".to_string()), + poll_interval_ms: 60000, + auto_resolve_on_merge: true, + }; + let client = GitHubClient::with_http_client(config, mock); + + let comments = client + .get_pr_review_comments("owner/repo", 1) + .await + .unwrap(); + assert_eq!(comments.len(), 1); + assert_eq!(comments[0].path, "src/main.rs"); + } + + #[tokio::test] + async fn test_get_new_reviews_filters_by_time() { + let mock = MockHttpClient::new(); + mock.mock_response( + "https://api.github.com/repos/owner/repo/pulls/1/reviews", + 200, + r#"[ + {"id": 1, "state": "APPROVED", "body": null, "user": {"id": 123, "login": "old"}, "submitted_at": "2024-01-01T00:00:00Z"}, + {"id": 2, "state": "CHANGES_REQUESTED", "body": null, "user": {"id": 124, "login": "new"}, "submitted_at": "2024-01-02T00:00:00Z"} + ]"#, + ); + + let config = GitHubConfig { + token: Some("test_token".to_string()), + poll_interval_ms: 60000, + auto_resolve_on_merge: true, + }; + let client = GitHubClient::with_http_client(config, mock); + + // Filter to only reviews after 2024-01-01T12:00:00Z + let reviews = client + .get_new_reviews("owner/repo", 1, Some("2024-01-01T12:00:00Z")) + .await + .unwrap(); + assert_eq!(reviews.len(), 1); + assert_eq!(reviews[0].user.login, "new"); + } + + #[tokio::test] + async fn test_get_new_reviews_no_filter() { + let mock = MockHttpClient::new(); + mock.mock_response( + "https://api.github.com/repos/owner/repo/pulls/1/reviews", + 200, + r#"[ + {"id": 1, "state": "APPROVED", "body": null, "user": {"id": 123, "login": "r1"}}, + {"id": 2, "state": "CHANGES_REQUESTED", "body": null, "user": {"id": 124, "login": "r2"}} + ]"#, + ); + + let config = GitHubConfig { + token: Some("test_token".to_string()), + poll_interval_ms: 60000, + auto_resolve_on_merge: true, + }; + let client = GitHubClient::with_http_client(config, mock); + + let reviews = client.get_new_reviews("owner/repo", 1, None).await.unwrap(); + assert_eq!(reviews.len(), 2); + } + + #[tokio::test] + async fn test_get_new_review_comments_filters_by_time() { + let mock = MockHttpClient::new(); + mock.mock_response( + "https://api.github.com/repos/owner/repo/pulls/1/comments", + 200, + r#"[ + {"id": 1, "path": "a.rs", "body": "old", "user": {"id": 1, "login": "u"}, "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", "html_url": "url"}, + {"id": 2, "path": "b.rs", "body": "new", "user": {"id": 1, "login": "u"}, "created_at": "2024-01-02T00:00:00Z", "updated_at": "2024-01-02T00:00:00Z", "html_url": "url"} + ]"#, + ); + + let config = GitHubConfig { + token: Some("test_token".to_string()), + poll_interval_ms: 60000, + auto_resolve_on_merge: true, + }; + let client = GitHubClient::with_http_client(config, mock); + + let comments = client + .get_new_review_comments("owner/repo", 1, Some("2024-01-01T12:00:00Z")) + .await + .unwrap(); + assert_eq!(comments.len(), 1); + assert_eq!(comments[0].path, "b.rs"); + } + + #[tokio::test] + async fn test_request_includes_auth_headers() { + let mock = MockHttpClient::new(); + mock.mock_response( + "https://api.github.com/repos/owner/repo/pulls/1", + 200, + r#"{"number": 1, "state": "open", "merged": false}"#, + ); + + let config = GitHubConfig { + token: Some("my_secret_token".to_string()), + poll_interval_ms: 60000, + auto_resolve_on_merge: true, + }; + let client = GitHubClient::with_http_client(config, mock); + + let _ = client.get_pr_status("owner/repo", 1).await; + + let http = &client.http; + let requests = http.get_requests(); + assert_eq!(requests.len(), 1); + + let (_, headers) = &requests[0]; + let auth_header = headers.iter().find(|(k, _)| k == "Authorization"); + assert!(auth_header.is_some()); + assert!(auth_header.unwrap().1.contains("my_secret_token")); + } + + #[tokio::test] + async fn test_review_watcher_check_for_reviews_with_mock() { + let mock = MockHttpClient::new(); + mock.mock_response( + "https://api.github.com/repos/owner/repo/pulls/1/reviews", + 200, + r#"[ + {"id": 1, "state": "CHANGES_REQUESTED", "body": "Fix this", "user": {"id": 123, "login": "reviewer", "type": "User"}, "submitted_at": "2024-01-01T00:00:00Z"} + ]"#, + ); + mock.mock_response( + "https://api.github.com/repos/owner/repo/pulls/1/comments", + 200, + "[]", + ); + + let config = GitHubConfig { + token: Some("test_token".to_string()), + poll_interval_ms: 60000, + auto_resolve_on_merge: true, + }; + let client = GitHubClient::with_http_client(config, mock); + let watcher = ReviewWatcher::with_http_client(client); + + let state = PrReviewState::new("url", "owner/repo", 1, "issue", "linear"); + watcher.watch_pr(state); + + let events = watcher.check_for_reviews().await.unwrap(); + assert_eq!(events.len(), 1); + match &events[0] { + ReviewEvent::ReviewSubmitted { review, .. } => { + assert_eq!(review.state, "CHANGES_REQUESTED"); + } + _ => panic!("Expected ReviewSubmitted event"), + } + } + + #[tokio::test] + async fn test_review_watcher_skips_bot_reviews() { + let mock = MockHttpClient::new(); + mock.mock_response( + "https://api.github.com/repos/owner/repo/pulls/1/reviews", + 200, + r#"[ + {"id": 1, "state": "APPROVED", "body": null, "user": {"id": 123, "login": "bot", "type": "Bot"}, "submitted_at": "2024-01-01T00:00:00Z"} + ]"#, + ); + mock.mock_response( + "https://api.github.com/repos/owner/repo/pulls/1/comments", + 200, + "[]", + ); + + let config = GitHubConfig { + token: Some("test_token".to_string()), + poll_interval_ms: 60000, + auto_resolve_on_merge: true, + }; + let client = GitHubClient::with_http_client(config, mock); + let watcher = ReviewWatcher::with_http_client(client); + + let state = PrReviewState::new("url", "owner/repo", 1, "issue", "linear"); + watcher.watch_pr(state); + + let events = watcher.check_for_reviews().await.unwrap(); + assert!(events.is_empty()); // Bot reviews are skipped + } + + #[tokio::test] + async fn test_review_watcher_skips_pending_reviews() { + let mock = MockHttpClient::new(); + mock.mock_response( + "https://api.github.com/repos/owner/repo/pulls/1/reviews", + 200, + r#"[ + {"id": 1, "state": "PENDING", "body": null, "user": {"id": 123, "login": "user", "type": "User"}, "submitted_at": "2024-01-01T00:00:00Z"} + ]"#, + ); + mock.mock_response( + "https://api.github.com/repos/owner/repo/pulls/1/comments", + 200, + "[]", + ); + + let config = GitHubConfig { + token: Some("test_token".to_string()), + poll_interval_ms: 60000, + auto_resolve_on_merge: true, + }; + let client = GitHubClient::with_http_client(config, mock); + let watcher = ReviewWatcher::with_http_client(client); + + let state = PrReviewState::new("url", "owner/repo", 1, "issue", "linear"); + watcher.watch_pr(state); + + let events = watcher.check_for_reviews().await.unwrap(); + assert!(events.is_empty()); // Pending reviews are skipped + } + + #[tokio::test] + async fn test_review_watcher_tracks_review_state() { + let mock = MockHttpClient::new(); + mock.mock_response( + "https://api.github.com/repos/owner/repo/pulls/1/reviews", + 200, + r#"[ + {"id": 5, "state": "APPROVED", "body": null, "user": {"id": 123, "login": "user", "type": "User"}, "submitted_at": "2024-01-01T00:00:00Z"} + ]"#, + ); + mock.mock_response( + "https://api.github.com/repos/owner/repo/pulls/1/comments", + 200, + "[]", + ); + + let config = GitHubConfig { + token: Some("test_token".to_string()), + poll_interval_ms: 60000, + auto_resolve_on_merge: true, + }; + let client = GitHubClient::with_http_client(config, mock); + let watcher = ReviewWatcher::with_http_client(client); + + let state = PrReviewState::new("url", "owner/repo", 1, "issue", "linear"); + watcher.watch_pr(state); + + let _ = watcher.check_for_reviews().await.unwrap(); + + // Check that the state was updated with the latest review ID + let updated_state = watcher.get_state("url").unwrap(); + assert_eq!(updated_state.last_review_id, Some(5)); + } + + #[tokio::test] + async fn test_review_watcher_comments_added_event() { + let mock = MockHttpClient::new(); + mock.mock_response( + "https://api.github.com/repos/owner/repo/pulls/1/reviews", + 200, + "[]", + ); + mock.mock_response( + "https://api.github.com/repos/owner/repo/pulls/1/comments", + 200, + r#"[ + {"id": 1, "path": "file.rs", "body": "Fix this", "user": {"id": 123, "login": "user", "type": "User"}, "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", "html_url": "url"} + ]"#, + ); + + let config = GitHubConfig { + token: Some("test_token".to_string()), + poll_interval_ms: 60000, + auto_resolve_on_merge: true, + }; + let client = GitHubClient::with_http_client(config, mock); + let watcher = ReviewWatcher::with_http_client(client); + + let state = PrReviewState::new("url", "owner/repo", 1, "issue", "linear"); + watcher.watch_pr(state); + + let events = watcher.check_for_reviews().await.unwrap(); + assert_eq!(events.len(), 1); + match &events[0] { + ReviewEvent::CommentsAdded { comments, .. } => { + assert_eq!(comments.len(), 1); + assert_eq!(comments[0].body, "Fix this"); + } + _ => panic!("Expected CommentsAdded event"), + } + } + + #[tokio::test] + async fn test_review_watcher_skips_already_processed_reviews() { + let mock = MockHttpClient::new(); + mock.mock_response( + "https://api.github.com/repos/owner/repo/pulls/1/reviews", + 200, + r#"[ + {"id": 1, "state": "APPROVED", "body": null, "user": {"id": 123, "login": "user", "type": "User"}, "submitted_at": "2024-01-01T00:00:00Z"} + ]"#, + ); + mock.mock_response( + "https://api.github.com/repos/owner/repo/pulls/1/comments", + 200, + "[]", + ); + + let config = GitHubConfig { + token: Some("test_token".to_string()), + poll_interval_ms: 60000, + auto_resolve_on_merge: true, + }; + let client = GitHubClient::with_http_client(config, mock); + let watcher = ReviewWatcher::with_http_client(client); + + // Pre-set the last_review_id to 1, so the review should be skipped + let mut state = PrReviewState::new("url", "owner/repo", 1, "issue", "linear"); + state.last_review_id = Some(1); + watcher.watch_pr(state); + + let events = watcher.check_for_reviews().await.unwrap(); + assert!(events.is_empty()); // Review was already processed + } + + #[test] + fn test_client_not_enabled_without_token() { + let config = GitHubConfig::default(); + let client = GitHubClient::new(config); + assert!(!client.is_enabled()); + } + + #[test] + fn test_client_enabled_with_token() { + let config = GitHubConfig { + token: Some("test_token".to_string()), + poll_interval_ms: 60000, + auto_resolve_on_merge: true, + }; + let client = GitHubClient::new(config); + assert!(client.is_enabled()); + } + + #[test] + fn test_pr_review_state_new() { + let state = PrReviewState::new( + "https://github.com/owner/repo/pull/1", + "owner/repo", + 1, + "issue-123", + "linear", + ); + + assert_eq!(state.pr_url, "https://github.com/owner/repo/pull/1"); + assert_eq!(state.repo, "owner/repo"); + assert_eq!(state.pr_number, 1); + assert!(state.is_active); + assert!(state.last_review_id.is_none()); + } + + #[test] + fn test_review_event_pr_url() { + let review = PrReview { + id: 1, + state: "APPROVED".to_string(), + body: Some("LGTM".to_string()), + user: GitHubUser { + id: 123, + login: "reviewer".to_string(), + user_type: Some("User".to_string()), + }, + submitted_at: Some("2024-01-01T00:00:00Z".to_string()), + html_url: Some("https://github.com/test".to_string()), + }; + + let event = ReviewEvent::ReviewSubmitted { + pr_url: "https://github.com/test/pull/1".to_string(), + repo: "test/test".to_string(), + pr_number: 1, + review, + }; + + assert_eq!(event.pr_url(), "https://github.com/test/pull/1"); + } + + #[test] + fn test_review_event_requires_action() { + let approved_review = PrReview { + id: 1, + state: "APPROVED".to_string(), + body: None, + user: GitHubUser { + id: 123, + login: "reviewer".to_string(), + user_type: Some("User".to_string()), + }, + submitted_at: None, + html_url: None, + }; + + let changes_requested = PrReview { + id: 2, + state: "CHANGES_REQUESTED".to_string(), + body: Some("Please fix this".to_string()), + user: GitHubUser { + id: 123, + login: "reviewer".to_string(), + user_type: Some("User".to_string()), + }, + submitted_at: None, + html_url: None, + }; + + let approved_event = ReviewEvent::ReviewSubmitted { + pr_url: "url".to_string(), + repo: "repo".to_string(), + pr_number: 1, + review: approved_review, + }; + + let changes_event = ReviewEvent::ReviewSubmitted { + pr_url: "url".to_string(), + repo: "repo".to_string(), + pr_number: 1, + review: changes_requested, + }; + + // Approved reviews don't require action + assert!(!approved_event.requires_action()); + // Changes requested do require action + assert!(changes_event.requires_action()); + } + + #[test] + fn test_review_event_feedback_summary() { + let review = PrReview { + id: 1, + state: "CHANGES_REQUESTED".to_string(), + body: Some("Please add tests".to_string()), + user: GitHubUser { + id: 123, + login: "reviewer".to_string(), + user_type: Some("User".to_string()), + }, + submitted_at: None, + html_url: None, + }; + + let event = ReviewEvent::ReviewSubmitted { + pr_url: "url".to_string(), + repo: "repo".to_string(), + pr_number: 1, + review, + }; + + let summary = event.get_feedback_summary(); + assert!(summary.contains("@reviewer")); + assert!(summary.contains("CHANGES_REQUESTED")); + assert!(summary.contains("Please add tests")); + } + + #[test] + fn test_review_event_comments_summary() { + let comments = vec![PrReviewComment { + id: 1, + path: "src/main.rs".to_string(), + position: None, + original_position: None, + body: "This should use a match statement".to_string(), + user: GitHubUser { + id: 123, + login: "reviewer".to_string(), + user_type: Some("User".to_string()), + }, + created_at: "2024-01-01T00:00:00Z".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + html_url: "https://github.com/test".to_string(), + pull_request_review_id: Some(1), + start_line: None, + line: Some(42), + side: Some("RIGHT".to_string()), + }]; + + let event = ReviewEvent::CommentsAdded { + pr_url: "url".to_string(), + repo: "repo".to_string(), + pr_number: 1, + comments, + }; + + let summary = event.get_feedback_summary(); + assert!(summary.contains("@reviewer")); + assert!(summary.contains("src/main.rs")); + assert!(summary.contains("line 42")); + assert!(summary.contains("match statement")); + } + + #[test] + fn test_review_watcher_watch_unwatch() { + let config = GitHubConfig { + token: Some("test".to_string()), + poll_interval_ms: 60000, + auto_resolve_on_merge: true, + }; + let client = GitHubClient::new(config); + let watcher = ReviewWatcher::new(client); + + let state = PrReviewState::new("url", "repo", 1, "issue", "linear"); + watcher.watch_pr(state); + + let retrieved = watcher.get_state("url"); + assert!(retrieved.is_some()); + assert!(retrieved.unwrap().is_active); + + watcher.unwatch_pr("url"); + let after_unwatch = watcher.get_state("url"); + assert!(after_unwatch.is_some()); + assert!(!after_unwatch.unwrap().is_active); + } + + #[test] + fn test_review_watcher_active_states() { + let config = GitHubConfig { + token: Some("test".to_string()), + poll_interval_ms: 60000, + auto_resolve_on_merge: true, + }; + let client = GitHubClient::new(config); + let watcher = ReviewWatcher::new(client); + + let state1 = PrReviewState::new("url1", "repo", 1, "issue1", "linear"); + let state2 = PrReviewState::new("url2", "repo", 2, "issue2", "linear"); + + watcher.watch_pr(state1); + watcher.watch_pr(state2); + watcher.unwatch_pr("url1"); + + let active = watcher.get_active_states(); + assert_eq!(active.len(), 1); + assert_eq!(active[0].pr_url, "url2"); + } + + #[test] + fn test_review_watcher_load_states() { + let config = GitHubConfig { + token: Some("test".to_string()), + poll_interval_ms: 60000, + auto_resolve_on_merge: true, + }; + let client = GitHubClient::new(config); + let watcher = ReviewWatcher::new(client); + + let mut inactive_state = PrReviewState::new("url1", "repo", 1, "issue1", "linear"); + inactive_state.is_active = false; + + let active_state = PrReviewState::new("url2", "repo", 2, "issue2", "linear"); + + watcher.load_states(vec![inactive_state, active_state]); + + // Only active states should be loaded + let all = watcher.get_all_states(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].pr_url, "url2"); + } + + #[test] + fn test_pr_status_equality() { + assert_eq!(PrStatus::Open, PrStatus::Open); + assert_eq!(PrStatus::Merged, PrStatus::Merged); + assert_eq!(PrStatus::Closed, PrStatus::Closed); + assert_ne!(PrStatus::Open, PrStatus::Merged); + } + + #[test] + fn test_client_token_accessor() { + let config = GitHubConfig { + token: Some("my_token".to_string()), + poll_interval_ms: 60000, + auto_resolve_on_merge: true, + }; + let client = GitHubClient::new(config); + assert_eq!(client.token(), Some("my_token")); + } + + #[test] + fn test_client_token_none() { + let config = GitHubConfig::default(); + let client = GitHubClient::new(config); + assert!(client.token().is_none()); + } + + #[test] + fn test_review_event_empty_comments() { + let event = ReviewEvent::CommentsAdded { + pr_url: "url".to_string(), + repo: "repo".to_string(), + pr_number: 1, + comments: vec![], + }; + assert!(!event.requires_action()); + } + + #[test] + fn test_review_event_commented_state() { + let review = PrReview { + id: 1, + state: "COMMENTED".to_string(), + body: Some("Some feedback".to_string()), + user: GitHubUser { + id: 123, + login: "reviewer".to_string(), + user_type: Some("User".to_string()), + }, + submitted_at: None, + html_url: None, + }; + + let event = ReviewEvent::ReviewSubmitted { + pr_url: "url".to_string(), + repo: "repo".to_string(), + pr_number: 1, + review, + }; + + assert!(event.requires_action()); + } + + #[test] + fn test_review_event_dismissed() { + let review = PrReview { + id: 1, + state: "DISMISSED".to_string(), + body: None, + user: GitHubUser { + id: 123, + login: "reviewer".to_string(), + user_type: Some("User".to_string()), + }, + submitted_at: None, + html_url: None, + }; + + let event = ReviewEvent::ReviewSubmitted { + pr_url: "url".to_string(), + repo: "repo".to_string(), + pr_number: 1, + review, + }; + + assert!(!event.requires_action()); + } + + #[test] + fn test_pr_review_state_update_review_tracking() { + let mut state = PrReviewState::new("url", "repo", 1, "issue", "linear"); + assert!(state.last_review_id.is_none()); + assert!(state.last_review_time.is_none()); + + state.last_review_id = Some(123); + state.last_review_time = Some("2024-01-01T00:00:00Z".to_string()); + + assert_eq!(state.last_review_id, Some(123)); + assert_eq!( + state.last_review_time, + Some("2024-01-01T00:00:00Z".to_string()) + ); + } + + #[test] + fn test_pr_review_state_update_comment_tracking() { + let mut state = PrReviewState::new("url", "repo", 1, "issue", "linear"); + assert!(state.last_comment_id.is_none()); + + state.last_comment_id = Some(456); + state.last_comment_time = Some("2024-01-01T01:00:00Z".to_string()); + + assert_eq!(state.last_comment_id, Some(456)); + } + + #[test] + fn test_pr_status_update_fields() { + let update = PrStatusUpdate { + source: "linear".to_string(), + issue_id: "issue-1".to_string(), + short_id: "LIN-1".to_string(), + pr_url: "https://github.com/org/repo/pull/1".to_string(), + new_status: PrStatus::Merged, + should_resolve: true, + }; + + assert_eq!(update.source, "linear"); + assert_eq!(update.new_status, PrStatus::Merged); + assert!(update.should_resolve); + } + + #[test] + fn test_github_user_serialization() { + let user = GitHubUser { + id: 123, + login: "testuser".to_string(), + user_type: Some("User".to_string()), + }; + let json = serde_json::to_string(&user).unwrap(); + assert!(json.contains("testuser")); + assert!(json.contains("123")); + } + + #[test] + fn test_pr_review_serialization() { + let review = PrReview { + id: 1, + state: "APPROVED".to_string(), + body: Some("LGTM".to_string()), + user: GitHubUser { + id: 123, + login: "reviewer".to_string(), + user_type: None, + }, + submitted_at: Some("2024-01-01T00:00:00Z".to_string()), + html_url: Some("https://github.com/review".to_string()), + }; + let json = serde_json::to_string(&review).unwrap(); + assert!(json.contains("APPROVED")); + assert!(json.contains("LGTM")); + } + + #[test] + fn test_pr_review_comment_serialization() { + let comment = PrReviewComment { + id: 1, + path: "src/main.rs".to_string(), + position: Some(10), + original_position: None, + body: "Please fix this".to_string(), + user: GitHubUser { + id: 123, + login: "commenter".to_string(), + user_type: Some("User".to_string()), + }, + created_at: "2024-01-01T00:00:00Z".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + html_url: "https://github.com/comment".to_string(), + pull_request_review_id: Some(5), + start_line: None, + line: Some(42), + side: Some("RIGHT".to_string()), + }; + let json = serde_json::to_string(&comment).unwrap(); + assert!(json.contains("src/main.rs")); + assert!(json.contains("Please fix this")); + } + + #[test] + fn test_review_watcher_is_enabled() { + let config_enabled = GitHubConfig { + token: Some("test".to_string()), + poll_interval_ms: 60000, + auto_resolve_on_merge: true, + }; + let client_enabled = GitHubClient::new(config_enabled); + let watcher_enabled = ReviewWatcher::new(client_enabled); + assert!(watcher_enabled.is_enabled()); + + let config_disabled = GitHubConfig::default(); + let client_disabled = GitHubClient::new(config_disabled); + let watcher_disabled = ReviewWatcher::new(client_disabled); + assert!(!watcher_disabled.is_enabled()); + } + + #[test] + fn test_review_event_feedback_summary_empty_body() { + let review = PrReview { + id: 1, + state: "CHANGES_REQUESTED".to_string(), + body: None, + user: GitHubUser { + id: 123, + login: "reviewer".to_string(), + user_type: Some("User".to_string()), + }, + submitted_at: None, + html_url: None, + }; + + let event = ReviewEvent::ReviewSubmitted { + pr_url: "url".to_string(), + repo: "repo".to_string(), + pr_number: 1, + review, + }; + + let summary = event.get_feedback_summary(); + assert!(summary.contains("@reviewer")); + assert!(summary.contains("CHANGES_REQUESTED")); + assert!(!summary.contains("Review comment:")); + } + + #[test] + fn test_review_event_comments_multiple() { + let comments = vec![ + PrReviewComment { + id: 1, + path: "file1.rs".to_string(), + position: None, + original_position: None, + body: "Comment 1".to_string(), + user: GitHubUser { + id: 1, + login: "user1".to_string(), + user_type: None, + }, + created_at: String::new(), + updated_at: String::new(), + html_url: String::new(), + pull_request_review_id: None, + start_line: None, + line: Some(10), + side: None, + }, + PrReviewComment { + id: 2, + path: "file2.rs".to_string(), + position: None, + original_position: None, + body: "Comment 2".to_string(), + user: GitHubUser { + id: 2, + login: "user2".to_string(), + user_type: None, + }, + created_at: String::new(), + updated_at: String::new(), + html_url: String::new(), + pull_request_review_id: None, + start_line: None, + line: None, + side: None, + }, + ]; + + let event = ReviewEvent::CommentsAdded { + pr_url: "url".to_string(), + repo: "repo".to_string(), + pr_number: 1, + comments, + }; + + let summary = event.get_feedback_summary(); + assert!(summary.contains("file1.rs")); + assert!(summary.contains("file2.rs")); + assert!(summary.contains("line 10")); + assert!(summary.contains("Comment 1")); + assert!(summary.contains("Comment 2")); + } + + #[test] + fn test_pr_review_state_serialization() { + let state = PrReviewState::new("url", "repo", 1, "issue", "linear"); + let json = serde_json::to_string(&state).unwrap(); + assert!(json.contains("url")); + assert!(json.contains("repo")); + assert!(json.contains("issue")); + } + + #[test] + fn test_pr_status_debug() { + let open = format!("{:?}", PrStatus::Open); + let merged = format!("{:?}", PrStatus::Merged); + let closed = format!("{:?}", PrStatus::Closed); + + assert_eq!(open, "Open"); + assert_eq!(merged, "Merged"); + assert_eq!(closed, "Closed"); + } + + #[test] + fn test_pr_status_copy_clone() { + let status = PrStatus::Open; + let copied = status; + let cloned = status; + assert_eq!(copied, status); + assert_eq!(cloned, status); + } + + #[test] + fn test_pr_status_update_clone() { + let update = PrStatusUpdate { + source: "linear".to_string(), + issue_id: "issue-1".to_string(), + short_id: "LIN-1".to_string(), + pr_url: "https://github.com/org/repo/pull/1".to_string(), + new_status: PrStatus::Merged, + should_resolve: true, + }; + + let cloned = update.clone(); + assert_eq!(cloned.source, "linear"); + assert_eq!(cloned.new_status, PrStatus::Merged); + } + + #[test] + fn test_pr_status_update_debug() { + let update = PrStatusUpdate { + source: "sentry".to_string(), + issue_id: "id".to_string(), + short_id: "short".to_string(), + pr_url: "url".to_string(), + new_status: PrStatus::Closed, + should_resolve: false, + }; + let debug = format!("{:?}", update); + assert!(debug.contains("sentry")); + assert!(debug.contains("Closed")); + } + + #[test] + fn test_pr_review_state_deserialization() { + let json = r#"{ + "pr_url": "https://github.com/test/test/pull/1", + "repo": "test/test", + "pr_number": 1, + "issue_id": "issue-123", + "source": "linear", + "last_review_id": null, + "last_review_time": null, + "last_comment_id": null, + "last_comment_time": null, + "is_active": true + }"#; + + let state: PrReviewState = serde_json::from_str(json).unwrap(); + assert_eq!(state.pr_url, "https://github.com/test/test/pull/1"); + assert_eq!(state.repo, "test/test"); + assert_eq!(state.pr_number, 1); + assert!(state.is_active); + } + + #[test] + fn test_review_watcher_get_all_states_empty() { + let config = GitHubConfig::default(); + let client = GitHubClient::new(config); + let watcher = ReviewWatcher::new(client); + + let all_states = watcher.get_all_states(); + assert!(all_states.is_empty()); + } + + #[test] + fn test_review_watcher_overwrite_state() { + let config = GitHubConfig { + token: Some("test".to_string()), + poll_interval_ms: 60000, + auto_resolve_on_merge: true, + }; + let client = GitHubClient::new(config); + let watcher = ReviewWatcher::new(client); + + let state1 = PrReviewState::new("url", "repo", 1, "issue1", "linear"); + let state2 = PrReviewState::new("url", "repo", 1, "issue2", "sentry"); + + watcher.watch_pr(state1); + watcher.watch_pr(state2); + + // Second state should overwrite first (same url) + let retrieved = watcher.get_state("url").unwrap(); + assert_eq!(retrieved.source, "sentry"); + } + + #[test] + fn test_github_user_clone() { + let user = GitHubUser { + id: 123, + login: "test".to_string(), + user_type: Some("User".to_string()), + }; + let cloned = user.clone(); + assert_eq!(cloned.id, 123); + assert_eq!(cloned.login, "test"); + } + + #[test] + fn test_pr_review_clone() { + let review = PrReview { + id: 1, + state: "APPROVED".to_string(), + body: Some("LGTM".to_string()), + user: GitHubUser { + id: 123, + login: "reviewer".to_string(), + user_type: None, + }, + submitted_at: Some("2024-01-01T00:00:00Z".to_string()), + html_url: Some("url".to_string()), + }; + let cloned = review.clone(); + assert_eq!(cloned.id, 1); + assert_eq!(cloned.state, "APPROVED"); + } + + #[test] + fn test_pr_review_comment_clone() { + let comment = PrReviewComment { + id: 1, + path: "file.rs".to_string(), + position: Some(10), + original_position: Some(5), + body: "Comment".to_string(), + user: GitHubUser { + id: 1, + login: "user".to_string(), + user_type: None, + }, + created_at: "2024-01-01T00:00:00Z".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + html_url: "url".to_string(), + pull_request_review_id: Some(5), + start_line: Some(1), + line: Some(10), + side: Some("RIGHT".to_string()), + }; + let cloned = comment.clone(); + assert_eq!(cloned.id, 1); + assert_eq!(cloned.path, "file.rs"); + assert_eq!(cloned.position, Some(10)); + } + + #[test] + fn test_review_event_clone() { + let review = PrReview { + id: 1, + state: "APPROVED".to_string(), + body: None, + user: GitHubUser { + id: 1, + login: "user".to_string(), + user_type: None, + }, + submitted_at: None, + html_url: None, + }; + + let event = ReviewEvent::ReviewSubmitted { + pr_url: "url".to_string(), + repo: "repo".to_string(), + pr_number: 1, + review, + }; + + let cloned = event.clone(); + assert_eq!(cloned.pr_url(), "url"); + } + + #[tokio::test] + async fn test_review_watcher_check_for_reviews_disabled() { + let config = GitHubConfig::default(); // No token = disabled + let client = GitHubClient::new(config); + let watcher = ReviewWatcher::new(client); + + // Should return empty when disabled + let events = watcher.check_for_reviews().await.unwrap(); + assert!(events.is_empty()); + } + + #[test] + fn test_unwatch_nonexistent_pr() { + let config = GitHubConfig { + token: Some("test".to_string()), + poll_interval_ms: 60000, + auto_resolve_on_merge: true, + }; + let client = GitHubClient::new(config); + let watcher = ReviewWatcher::new(client); + + // Should not panic when unwatching a PR that doesn't exist + watcher.unwatch_pr("nonexistent"); + + // Verify nothing was added + assert!(watcher.get_state("nonexistent").is_none()); + } + + #[test] + fn test_review_event_debug() { + let review = PrReview { + id: 1, + state: "APPROVED".to_string(), + body: None, + user: GitHubUser { + id: 1, + login: "user".to_string(), + user_type: None, + }, + submitted_at: None, + html_url: None, + }; + + let event = ReviewEvent::ReviewSubmitted { + pr_url: "url".to_string(), + repo: "repo".to_string(), + pr_number: 1, + review, + }; + + let debug = format!("{:?}", event); + assert!(debug.contains("ReviewSubmitted")); + } + + #[test] + fn test_pr_review_state_clone() { + let state = PrReviewState::new("url", "repo", 1, "issue", "linear"); + let cloned = state.clone(); + assert_eq!(cloned.pr_url, "url"); + assert_eq!(cloned.repo, "repo"); + assert_eq!(cloned.pr_number, 1); + } + + #[test] + fn test_github_client_new() { + let config = GitHubConfig { + token: Some("test_token".to_string()), + poll_interval_ms: 30000, + auto_resolve_on_merge: true, + }; + let client = GitHubClient::new(config); + assert!(client.is_enabled()); + assert_eq!(client.token(), Some("test_token")); + } + + #[test] + fn test_github_client_disabled() { + let config = GitHubConfig::default(); + let client = GitHubClient::new(config); + assert!(!client.is_enabled()); + assert!(client.token().is_none()); + } + + #[test] + fn test_pull_request_deserialization() { + let json = r#"{ + "number": 123, + "state": "open", + "merged": false, + "html_url": "https://github.com/test/repo/pull/123", + "title": "Test PR" + }"#; + let pr: PullRequest = serde_json::from_str(json).unwrap(); + assert_eq!(pr.state, "open"); + assert!(!pr.merged); + } + + #[test] + fn test_pull_request_merged() { + let json = r#"{ + "number": 456, + "state": "closed", + "merged": true, + "html_url": "https://github.com/test/repo/pull/456", + "title": "Merged PR" + }"#; + let pr: PullRequest = serde_json::from_str(json).unwrap(); + assert!(pr.merged); + assert_eq!(pr.state, "closed"); + } +} diff --git a/src/inference/context.rs b/src/inference/context.rs new file mode 100644 index 00000000..e2eb82f5 --- /dev/null +++ b/src/inference/context.rs @@ -0,0 +1,540 @@ +//! Issue context extraction for repository inference. +//! +//! Extracts searchable context (filenames, functions, keywords) from issues +//! to enable automated repository inference. + +use crate::types::Issue; +use regex_lite::Regex; +use std::collections::HashSet; + +/// Extracted context from an issue for repository inference. +#[derive(Debug, Clone, Default)] +pub struct IssueContext { + /// Extracted file paths or filenames. + pub filenames: Vec, + /// Extracted function names. + pub functions: Vec, + /// Other searchable keywords. + pub keywords: Vec, + /// Raw text used for extraction (for debugging). + pub raw_text: String, +} + +impl IssueContext { + /// Create a new empty context. + pub fn new() -> Self { + Self::default() + } + + /// Extract context from any issue based on its source. + pub fn from_issue(issue: &Issue) -> Self { + match issue.source.as_str() { + "sentry" => Self::from_sentry(issue), + "linear" => Self::from_linear(issue), + _ => Self::from_generic(issue), + } + } + + /// Extract context from a Sentry issue. + /// + /// Sentry issues have structured metadata: + /// - `metadata.filename` - direct file path + /// - `metadata.function` - function name + /// - `culprit` - often "file in function" format + pub fn from_sentry(issue: &Issue) -> Self { + let mut context = Self::new(); + + // Extract filename from metadata + if let Some(filename) = issue.metadata.get("filename").and_then(|v| v.as_str()) { + context.filenames.push(filename.to_string()); + } + + // Extract function from metadata + if let Some(function) = issue.metadata.get("function").and_then(|v| v.as_str()) { + context.functions.push(function.to_string()); + } + + // Extract culprit (often contains file and function info) + if let Some(culprit) = issue.metadata.get("culprit").and_then(|v| v.as_str()) { + // Culprit format is often "file in function" or just "file" + let parts: Vec<_> = culprit.split(" in ").collect(); + if parts.len() >= 2 { + context.filenames.push(parts[0].trim().to_string()); + context.functions.push(parts[1].trim().to_string()); + } else if !parts.is_empty() { + // Could be just a file path + if looks_like_path(parts[0]) { + context.filenames.push(parts[0].trim().to_string()); + } + } + } + + // Extract from stack trace if available + if let Some(stacktrace) = issue.metadata.get("stacktrace").and_then(|v| v.as_str()) { + extract_from_stacktrace(&mut context, stacktrace); + } + + // Extract from description + let description = issue.description.as_deref().unwrap_or(""); + let message = issue + .metadata + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let raw_text = format!("{}\n{}\n{}", issue.title, description, message); + extract_from_text(&mut context, &raw_text); + context.raw_text = raw_text; + + // Deduplicate + context.deduplicate(); + + context + } + + /// Extract context from a Linear issue. + /// + /// Linear issues have less structured metadata but may have: + /// - Code blocks in description + /// - File path references + /// - Stack traces pasted in + pub fn from_linear(issue: &Issue) -> Self { + let mut context = Self::new(); + + // Combine title and description for extraction + let description = issue.description.as_deref().unwrap_or(""); + let raw_text = format!("{}\n{}", issue.title, description); + + // Extract file paths from text + extract_from_text(&mut context, &raw_text); + context.raw_text = raw_text; + + // Extract from code blocks (markdown) + extract_from_code_blocks(&mut context, description); + + // Look for specific keywords in labels + if let Some(labels) = issue.metadata.get("labels") { + if let Some(labels_array) = labels.as_array() { + for label in labels_array { + if let Some(name) = label.as_str() { + context.keywords.push(name.to_lowercase()); + } + } + } + } + + // Deduplicate + context.deduplicate(); + + context + } + + /// Extract context from a generic issue (fallback). + pub fn from_generic(issue: &Issue) -> Self { + let mut context = Self::new(); + + let description = issue.description.as_deref().unwrap_or(""); + let raw_text = format!("{}\n{}", issue.title, description); + extract_from_text(&mut context, &raw_text); + context.raw_text = raw_text; + + context.deduplicate(); + context + } + + /// Remove duplicate entries. + fn deduplicate(&mut self) { + let filenames_set: HashSet<_> = self.filenames.drain(..).collect(); + self.filenames = filenames_set.into_iter().collect(); + + let functions_set: HashSet<_> = self.functions.drain(..).collect(); + self.functions = functions_set.into_iter().collect(); + + let keywords_set: HashSet<_> = self.keywords.drain(..).collect(); + self.keywords = keywords_set.into_iter().collect(); + } + + /// Check if context has any useful data. + pub fn is_empty(&self) -> bool { + self.filenames.is_empty() && self.functions.is_empty() && self.keywords.is_empty() + } +} + +/// Check if a string looks like a file path. +fn looks_like_path(s: &str) -> bool { + s.contains('/') || s.contains('\\') || s.contains('.') && !s.starts_with('.') +} + +/// Extract file paths and function names from stack trace text. +fn extract_from_stacktrace(context: &mut IssueContext, stacktrace: &str) { + // Common stack trace file patterns + // Python: File "path/to/file.py", line 123, in function_name + // Node.js: at function_name (path/to/file.js:123:45) + // PHP: #0 /path/to/file.php(123): function_name() + // Java: at package.Class.method(File.java:123) + + // Python style + let python_re = Regex::new(r#"File "([^"]+\.py)""#).ok(); + if let Some(re) = python_re { + for cap in re.captures_iter(stacktrace) { + if let Some(m) = cap.get(1) { + context.filenames.push(m.as_str().to_string()); + } + } + } + + // Node.js/JavaScript style + let node_re = Regex::new(r"\(([^)]+\.[jt]sx?):(\d+)").ok(); + if let Some(re) = node_re { + for cap in re.captures_iter(stacktrace) { + if let Some(m) = cap.get(1) { + context.filenames.push(m.as_str().to_string()); + } + } + } + + // PHP style + let php_re = Regex::new(r"(/[^(]+\.php)\((\d+)\)").ok(); + if let Some(re) = php_re { + for cap in re.captures_iter(stacktrace) { + if let Some(m) = cap.get(1) { + context.filenames.push(m.as_str().to_string()); + } + } + } + + // Java style + let java_re = Regex::new(r"\(([^)]+\.java):(\d+)\)").ok(); + if let Some(re) = java_re { + for cap in re.captures_iter(stacktrace) { + if let Some(m) = cap.get(1) { + context.filenames.push(m.as_str().to_string()); + } + } + } + + // Go style: /path/to/file.go:123 + let go_re = Regex::new(r"(/[^\s:]+\.go):(\d+)").ok(); + if let Some(re) = go_re { + for cap in re.captures_iter(stacktrace) { + if let Some(m) = cap.get(1) { + context.filenames.push(m.as_str().to_string()); + } + } + } + + // Rust style: path/to/file.rs:123:45 + let rust_re = Regex::new(r"([^\s]+\.rs):(\d+)").ok(); + if let Some(re) = rust_re { + for cap in re.captures_iter(stacktrace) { + if let Some(m) = cap.get(1) { + context.filenames.push(m.as_str().to_string()); + } + } + } + + // Extract function names from "at function" or "in function" patterns + let func_re = Regex::new(r"(?:at|in)\s+([a-zA-Z_][a-zA-Z0-9_.:]+)").ok(); + if let Some(re) = func_re { + for cap in re.captures_iter(stacktrace) { + if let Some(m) = cap.get(1) { + let func = m.as_str(); + // Skip common noise + if !func.starts_with("http") + && !func.starts_with("file") + && !func.contains("node_modules") + { + context.functions.push(func.to_string()); + } + } + } + } +} + +/// Extract file paths and keywords from general text. +fn extract_from_text(context: &mut IssueContext, text: &str) { + // Common file path patterns + // Unix paths: /path/to/file.ext or src/path/to/file.ext + // Relative paths with extensions + let path_re = Regex::new( + r#"(?:^|[\s"'`(])([a-zA-Z_./\\-]+\.(rs|js|ts|tsx|jsx|py|php|go|java|rb|swift|kt|c|cpp|h|hpp|cs|vue|svelte|html|css|scss|sass|yaml|yml|json|xml|md))\b"# + ).ok(); + + if let Some(re) = path_re { + for cap in re.captures_iter(text) { + if let Some(m) = cap.get(1) { + let path = m.as_str().trim_start_matches(['/', '\\']); + if !path.is_empty() { + context.filenames.push(path.to_string()); + } + } + } + } + + // Extract class names (PascalCase) + let class_re = Regex::new(r"\b([A-Z][a-zA-Z0-9]+(?:Controller|Service|Handler|Manager|Repository|Factory|Provider|Module|Component|Middleware))\b").ok(); + if let Some(re) = class_re { + for cap in re.captures_iter(text) { + if let Some(m) = cap.get(1) { + context.keywords.push(m.as_str().to_string()); + } + } + } + + // Extract error types + let error_re = Regex::new(r"\b([A-Z][a-zA-Z0-9]*(?:Error|Exception|Failure))\b").ok(); + if let Some(re) = error_re { + for cap in re.captures_iter(text) { + if let Some(m) = cap.get(1) { + context.keywords.push(m.as_str().to_string()); + } + } + } +} + +/// Extract file paths from markdown code blocks. +fn extract_from_code_blocks(context: &mut IssueContext, text: &str) { + // Match markdown code blocks with optional language + // ```language + // code + // ``` + let block_re = Regex::new(r"```(?:\w+)?\s*\n([\s\S]*?)```").ok(); + + if let Some(re) = block_re { + for cap in re.captures_iter(text) { + if let Some(m) = cap.get(1) { + let code = m.as_str(); + extract_from_text(context, code); + extract_from_stacktrace(context, code); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn create_test_issue(source: &str, title: &str, description: &str) -> Issue { + Issue { + id: "test-1".to_string(), + short_id: "TEST-1".to_string(), + source: source.to_string(), + title: title.to_string(), + description: if description.is_empty() { + None + } else { + Some(description.to_string()) + }, + url: "https://example.com/test".to_string(), + priority: crate::types::IssuePriority::Medium, + status: crate::types::IssueStatus::Open, + metadata: std::collections::HashMap::new(), + created_at: None, + updated_at: None, + } + } + + #[test] + fn test_context_from_sentry_with_filename() { + let mut issue = create_test_issue("sentry", "Error in handler", ""); + issue + .metadata + .insert("filename".to_string(), json!("src/handlers/auth.rs")); + issue + .metadata + .insert("function".to_string(), json!("authenticate")); + + let context = IssueContext::from_sentry(&issue); + + assert!(context.filenames.contains(&"src/handlers/auth.rs".to_string())); + assert!(context.functions.contains(&"authenticate".to_string())); + } + + #[test] + fn test_context_from_sentry_with_culprit() { + let mut issue = create_test_issue("sentry", "Error in handler", ""); + issue + .metadata + .insert("culprit".to_string(), json!("api/routes.ts in handleRequest")); + + let context = IssueContext::from_sentry(&issue); + + assert!(context.filenames.contains(&"api/routes.ts".to_string())); + assert!(context.functions.contains(&"handleRequest".to_string())); + } + + #[test] + fn test_context_from_sentry_with_stacktrace() { + let mut issue = create_test_issue("sentry", "Error in handler", ""); + issue.metadata.insert( + "stacktrace".to_string(), + json!(r#" + File "app/services/user_service.py", line 45, in create_user + raise ValueError("Invalid email") + File "app/controllers/user_controller.py", line 23, in handle + return service.create_user(data) + "#), + ); + + let context = IssueContext::from_sentry(&issue); + + assert!(context + .filenames + .iter() + .any(|f| f.contains("user_service.py"))); + assert!(context + .filenames + .iter() + .any(|f| f.contains("user_controller.py"))); + } + + #[test] + fn test_context_from_linear() { + let issue = create_test_issue( + "linear", + "Fix authentication bug", + "The bug is in `src/auth/session.rs` line 45.\n\n```rust\nfn validate_session() {\n // error here\n}\n```", + ); + + let context = IssueContext::from_linear(&issue); + + assert!(context + .filenames + .iter() + .any(|f| f.contains("session.rs"))); + } + + #[test] + fn test_context_from_linear_with_code_block() { + let issue = create_test_issue( + "linear", + "Error in Router", + "```\nat Router.handle (src/router/index.ts:123:45)\nat Server.listen (src/server.ts:67:12)\n```", + ); + + let context = IssueContext::from_linear(&issue); + + // Should extract from code blocks + assert!(!context.filenames.is_empty() || !context.functions.is_empty()); + } + + #[test] + fn test_context_from_issue_dispatch() { + let sentry_issue = create_test_issue("sentry", "Sentry Error", ""); + let linear_issue = create_test_issue("linear", "Linear Issue", ""); + let other_issue = create_test_issue("jira", "Jira Issue", ""); + + // All should work without errors + let _ = IssueContext::from_issue(&sentry_issue); + let _ = IssueContext::from_issue(&linear_issue); + let _ = IssueContext::from_issue(&other_issue); + } + + #[test] + fn test_extract_file_paths() { + let mut context = IssueContext::new(); + extract_from_text( + &mut context, + "The error is in src/main.rs and also affects lib/utils.py", + ); + + assert!(context.filenames.iter().any(|f| f.contains("main.rs"))); + assert!(context.filenames.iter().any(|f| f.contains("utils.py"))); + } + + #[test] + fn test_extract_class_names() { + let mut context = IssueContext::new(); + extract_from_text( + &mut context, + "Check the UserController and AuthService classes", + ); + + assert!(context.keywords.contains(&"UserController".to_string())); + assert!(context.keywords.contains(&"AuthService".to_string())); + } + + #[test] + fn test_extract_error_types() { + let mut context = IssueContext::new(); + extract_from_text( + &mut context, + "Got a ValidationError and AuthenticationFailure", + ); + + assert!(context.keywords.contains(&"ValidationError".to_string())); + assert!(context + .keywords + .contains(&"AuthenticationFailure".to_string())); + } + + #[test] + fn test_context_deduplication() { + let mut context = IssueContext::new(); + context.filenames.push("file.rs".to_string()); + context.filenames.push("file.rs".to_string()); + context.functions.push("func".to_string()); + context.functions.push("func".to_string()); + + context.deduplicate(); + + assert_eq!(context.filenames.len(), 1); + assert_eq!(context.functions.len(), 1); + } + + #[test] + fn test_context_is_empty() { + let empty = IssueContext::new(); + assert!(empty.is_empty()); + + let mut not_empty = IssueContext::new(); + not_empty.filenames.push("test.rs".to_string()); + assert!(!not_empty.is_empty()); + } + + #[test] + fn test_looks_like_path() { + assert!(looks_like_path("src/main.rs")); + assert!(looks_like_path("path/to/file.js")); + assert!(looks_like_path("file.py")); + assert!(!looks_like_path(".hidden")); + assert!(!looks_like_path("nopathhere")); + } + + #[test] + fn test_extract_python_stacktrace() { + let mut context = IssueContext::new(); + let stacktrace = r#" + Traceback (most recent call last): + File "/app/services/user.py", line 45, in create + raise ValueError("Invalid") + File "/app/api/routes.py", line 23, in handle + return service.create(data) + "#; + + extract_from_stacktrace(&mut context, stacktrace); + + assert!(context.filenames.iter().any(|f| f.contains("user.py"))); + assert!(context.filenames.iter().any(|f| f.contains("routes.py"))); + } + + #[test] + fn test_extract_nodejs_stacktrace() { + let mut context = IssueContext::new(); + let stacktrace = r#" + Error: Something went wrong + at processTicksAndRejections (internal/process/task_queues.js:95:5) + at Router.handle (/app/src/router.ts:45:12) + at Server.listen (/app/src/server.js:23:8) + "#; + + extract_from_stacktrace(&mut context, stacktrace); + + // Should extract the app files, not internal ones + assert!(context + .filenames + .iter() + .any(|f| f.contains("router.ts") || f.contains("server.js"))); + } +} diff --git a/src/inference/mod.rs b/src/inference/mod.rs new file mode 100644 index 00000000..dc9354bd --- /dev/null +++ b/src/inference/mod.rs @@ -0,0 +1,325 @@ +//! Repository inference engine. +//! +//! Automatically determines which repository an issue belongs to based on +//! file paths, stack traces, and other context extracted from issues. + +mod context; + +pub use context::IssueContext; + +use crate::repo::{IndexedRepo, RepoIndex}; +use crate::types::Issue; + +/// Confidence level for repository inference. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Confidence { + /// Direct file path match. + High, + /// Fuzzy/partial match. + Medium, + /// Content similarity only. + Low, + /// No match found. + None, +} + +impl std::fmt::Display for Confidence { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Confidence::High => write!(f, "high"), + Confidence::Medium => write!(f, "medium"), + Confidence::Low => write!(f, "low"), + Confidence::None => write!(f, "none"), + } + } +} + +/// Result of repository inference. +#[derive(Debug, Clone)] +pub struct InferredRepo { + /// The inferred repository. + pub repo: IndexedRepo, + /// Confidence level of the inference. + pub confidence: Confidence, + /// Reason for the inference. + pub reason: String, + /// File that matched (if applicable). + pub matched_file: Option, +} + +/// Repository inference engine. +/// +/// Uses a RepoIndex to determine which repository an issue belongs to +/// based on file paths and other context extracted from the issue. +#[derive(Clone)] +pub struct RepoInferrer { + index: RepoIndex, +} + +impl RepoInferrer { + /// Create a new inferrer with the given repository index. + pub fn new(index: RepoIndex) -> Self { + Self { index } + } + + /// Infer the target repository for an issue. + /// + /// Tries multiple strategies in order of confidence: + /// 1. Direct file path match + /// 2. Basename match + /// 3. Fuzzy file search + /// 4. Keyword matching (future) + pub fn infer(&self, issue: &Issue) -> Option { + let context = IssueContext::from_issue(issue); + + tracing::debug!( + issue_id = %issue.short_id, + filenames = ?context.filenames, + functions = ?context.functions, + "Extracted issue context" + ); + + // Strategy 1: Direct file path match + for filename in &context.filenames { + if let Some(repo) = self.index.find_by_file(filename) { + tracing::info!( + issue_id = %issue.short_id, + repo = %repo.name, + file = %filename, + "High confidence match: direct file path" + ); + return Some(InferredRepo { + repo: repo.clone(), + confidence: Confidence::High, + reason: format!("Direct file match: {}", filename), + matched_file: Some(filename.clone()), + }); + } + } + + // Strategy 2: Fuzzy file search (partial match) + for filename in &context.filenames { + let matches = self.index.search_files(filename); + + // If we have exactly one match, it's medium confidence + if matches.len() == 1 { + let (repo, matched_path) = matches[0]; + tracing::info!( + issue_id = %issue.short_id, + repo = %repo.name, + query = %filename, + matched = %matched_path, + "Medium confidence match: single fuzzy match" + ); + return Some(InferredRepo { + repo: repo.clone(), + confidence: Confidence::Medium, + reason: format!("Fuzzy match: {} -> {}", filename, matched_path), + matched_file: Some(matched_path.to_string()), + }); + } + + // If we have multiple matches in the same repo, still medium confidence + if !matches.is_empty() { + let first_repo = &matches[0].0.name; + let all_same_repo = matches.iter().all(|(r, _)| r.name == *first_repo); + + if all_same_repo { + let (repo, matched_path) = matches[0]; + tracing::info!( + issue_id = %issue.short_id, + repo = %repo.name, + matches = matches.len(), + "Medium confidence match: all matches in same repo" + ); + return Some(InferredRepo { + repo: repo.clone(), + confidence: Confidence::Medium, + reason: format!( + "Fuzzy match ({} files): {} -> {}", + matches.len(), + filename, + matched_path + ), + matched_file: Some(matched_path.to_string()), + }); + } + } + } + + // Strategy 3: Try just the basename of each filename + for filename in &context.filenames { + let basename = std::path::Path::new(filename) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| filename.clone()); + + let matches = self.index.search_files(&basename); + + if matches.len() == 1 { + let (repo, matched_path) = matches[0]; + tracing::info!( + issue_id = %issue.short_id, + repo = %repo.name, + basename = %basename, + matched = %matched_path, + "Low confidence match: basename match" + ); + return Some(InferredRepo { + repo: repo.clone(), + confidence: Confidence::Low, + reason: format!("Basename match: {} -> {}", basename, matched_path), + matched_file: Some(matched_path.to_string()), + }); + } + } + + // No match found + tracing::debug!( + issue_id = %issue.short_id, + "No repository match found" + ); + None + } + + /// Get the underlying repository index. + pub fn index(&self) -> &RepoIndex { + &self.index + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn create_test_index() -> RepoIndex { + let mut index = RepoIndex::new(); + + let mut repo1 = IndexedRepo::new("appwrite/console", "/path/console"); + repo1.files = vec![ + "src/routes/auth.ts".to_string(), + "src/components/Button.tsx".to_string(), + "src/lib/api/client.ts".to_string(), + ]; + index.add_repo(repo1); + + let mut repo2 = IndexedRepo::new("appwrite/sdk-for-php", "/path/sdk-php"); + repo2.files = vec![ + "src/Appwrite/Client.php".to_string(), + "src/Appwrite/Services/Account.php".to_string(), + ]; + index.add_repo(repo2); + + index + } + + fn create_test_issue(source: &str, title: &str, description: &str) -> Issue { + Issue { + id: "test-1".to_string(), + short_id: "TEST-1".to_string(), + source: source.to_string(), + title: title.to_string(), + description: if description.is_empty() { + None + } else { + Some(description.to_string()) + }, + url: "https://example.com/test".to_string(), + priority: crate::types::IssuePriority::Medium, + status: crate::types::IssueStatus::Open, + metadata: std::collections::HashMap::new(), + created_at: None, + updated_at: None, + } + } + + #[test] + fn test_infer_high_confidence() { + let index = create_test_index(); + let inferrer = RepoInferrer::new(index); + + let mut issue = create_test_issue("sentry", "Auth error", ""); + issue + .metadata + .insert("filename".to_string(), json!("src/routes/auth.ts")); + + let result = inferrer.infer(&issue); + + assert!(result.is_some()); + let inferred = result.unwrap(); + assert_eq!(inferred.repo.name, "appwrite/console"); + assert_eq!(inferred.confidence, Confidence::High); + } + + #[test] + fn test_infer_medium_confidence() { + let index = create_test_index(); + let inferrer = RepoInferrer::new(index); + + // Use a partial path that should fuzzy match + let mut issue = create_test_issue("sentry", "Client error", ""); + issue + .metadata + .insert("filename".to_string(), json!("Client.php")); + + let result = inferrer.infer(&issue); + + assert!(result.is_some()); + let inferred = result.unwrap(); + assert_eq!(inferred.repo.name, "appwrite/sdk-for-php"); + // Could be high or medium depending on exact matching logic + assert!( + inferred.confidence == Confidence::High + || inferred.confidence == Confidence::Medium + || inferred.confidence == Confidence::Low + ); + } + + #[test] + fn test_infer_no_match() { + let index = create_test_index(); + let inferrer = RepoInferrer::new(index); + + let issue = create_test_issue("sentry", "Unknown error", "No file paths here"); + + let result = inferrer.infer(&issue); + + assert!(result.is_none()); + } + + #[test] + fn test_infer_from_linear_issue() { + let index = create_test_index(); + let inferrer = RepoInferrer::new(index); + + let issue = create_test_issue( + "linear", + "Fix button styling", + "The issue is in src/components/Button.tsx", + ); + + let result = inferrer.infer(&issue); + + assert!(result.is_some()); + let inferred = result.unwrap(); + assert_eq!(inferred.repo.name, "appwrite/console"); + } + + #[test] + fn test_confidence_display() { + assert_eq!(format!("{}", Confidence::High), "high"); + assert_eq!(format!("{}", Confidence::Medium), "medium"); + assert_eq!(format!("{}", Confidence::Low), "low"); + assert_eq!(format!("{}", Confidence::None), "none"); + } + + #[test] + fn test_inferrer_index_access() { + let index = create_test_index(); + let inferrer = RepoInferrer::new(index); + + // Can access the index + assert_eq!(inferrer.index().len(), 2); + } +} diff --git a/src/ipc/client.rs b/src/ipc/client.rs new file mode 100644 index 00000000..905f087f --- /dev/null +++ b/src/ipc/client.rs @@ -0,0 +1,290 @@ +//! IPC client for communicating with the watcher daemon. + +use super::default_socket_path; +use super::protocol::{IpcCommand, IpcData, IpcResponse}; +use crate::error::{Error, Result}; + +use std::path::PathBuf; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixStream; +use tokio::time::timeout; + +/// Default timeout for IPC operations. +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + +/// Client for communicating with the watcher daemon via Unix socket. +pub struct IpcClient { + socket_path: PathBuf, + timeout: Duration, +} + +impl IpcClient { + /// Create a new IPC client with the default socket path. + pub fn new() -> Self { + Self { + socket_path: default_socket_path(), + timeout: DEFAULT_TIMEOUT, + } + } + + /// Create a client with a custom socket path. + pub fn with_socket_path(socket_path: PathBuf) -> Self { + Self { + socket_path, + timeout: DEFAULT_TIMEOUT, + } + } + + /// Set the timeout for operations. + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Check if the daemon is running. + pub fn is_daemon_running(&self) -> bool { + self.socket_path.exists() + && std::os::unix::net::UnixStream::connect(&self.socket_path).is_ok() + } + + /// Send a command and receive a response. + pub async fn send(&self, command: IpcCommand) -> Result { + let result = timeout(self.timeout, self.send_internal(command)).await; + + match result { + Ok(inner) => inner, + Err(_) => Err(Error::Other("IPC request timed out".to_string())), + } + } + + async fn send_internal(&self, command: IpcCommand) -> Result { + let stream = UnixStream::connect(&self.socket_path) + .await + .map_err(|e| Error::Other(format!("Failed to connect to daemon: {}", e)))?; + + let (reader, mut writer) = stream.into_split(); + let mut reader = BufReader::new(reader); + + // Send command + let json = serde_json::to_string(&command)? + "\n"; + writer.write_all(json.as_bytes()).await?; + + // Read response + let mut line = String::new(); + reader.read_line(&mut line).await?; + + let response: IpcResponse = serde_json::from_str(line.trim())?; + Ok(response) + } + + /// Ping the daemon. + pub async fn ping(&self) -> Result { + match self.send(IpcCommand::Ping).await { + Ok(IpcResponse::Ok(IpcData::Pong)) => Ok(true), + Ok(_) => Ok(false), + Err(_) => Ok(false), + } + } + + /// Get the daemon status. + pub async fn status(&self) -> Result { + self.send(IpcCommand::Status).await + } + + /// Pause the watcher. + pub async fn pause(&self) -> Result { + self.send(IpcCommand::Pause).await + } + + /// Resume the watcher. + pub async fn resume(&self) -> Result { + self.send(IpcCommand::Resume).await + } + + /// Trigger a fix for an issue. + pub async fn trigger(&self, source: &str, issue_id: &str) -> Result { + self.send(IpcCommand::Trigger { + source: source.to_string(), + issue_id: issue_id.to_string(), + }) + .await + } + + /// Reset a failed attempt. + pub async fn reset(&self, source: &str, issue_id: &str) -> Result { + self.send(IpcCommand::Reset { + source: source.to_string(), + issue_id: issue_id.to_string(), + }) + .await + } + + /// Get statistics. + pub async fn stats(&self) -> Result { + self.send(IpcCommand::Stats).await + } + + /// List pending PRs. + pub async fn list_prs(&self) -> Result { + self.send(IpcCommand::ListPrs).await + } + + /// List retryable issues. + pub async fn list_retries(&self) -> Result { + self.send(IpcCommand::ListRetries).await + } + + /// Process ready retries. + pub async fn process_retries(&self) -> Result { + self.send(IpcCommand::ProcessRetries).await + } + + /// Get recent activity. + pub async fn activity(&self, limit: usize) -> Result { + self.send(IpcCommand::Activity { limit }).await + } + + /// Request graceful shutdown. + pub async fn shutdown(&self) -> Result { + self.send(IpcCommand::Shutdown).await + } +} + +impl Default for IpcClient { + fn default() -> Self { + Self::new() + } +} + +/// Print an IPC response in a human-readable format. +pub fn print_response(response: &IpcResponse) { + match response { + IpcResponse::Ok(data) => match data { + IpcData::None => println!("OK"), + IpcData::Pong => println!("Pong!"), + IpcData::Message(msg) => println!("{}", msg), + IpcData::State(state) => { + println!("\nWatcher Status:"); + println!(" Running: {}", state.running); + println!(" Paused: {}", state.paused); + println!(" Mode: {}", state.mode); + println!(" Uptime: {}s", state.uptime_secs); + println!(" Issues processed: {}", state.issues_processed); + println!(" PRs created: {}", state.prs_created); + println!(" Sources: {}", state.sources.join(", ")); + if let Some(interval) = state.poll_interval_ms { + println!(" Poll interval: {}ms", interval); + } + if !state.processing.is_empty() { + println!(" Currently processing: {}", state.processing.join(", ")); + } + } + IpcData::Stats(stats) => { + println!("\nFix Attempt Statistics:"); + println!(" Total: {}", stats.total); + println!(" Pending: {}", stats.pending); + println!(" Success: {}", stats.success); + println!(" Merged: {}", stats.merged); + println!(" Closed: {}", stats.closed); + println!(" Failed: {}", stats.failed); + println!(" Cannot Fix: {}", stats.cannot_fix); + + if !stats.by_source.is_empty() { + println!("\nBy Source:"); + for (source, source_stats) in &stats.by_source { + println!( + " {}: {} total, {} merged, {} failed", + source, source_stats.total, source_stats.merged, source_stats.failed + ); + } + } + } + IpcData::Attempts(attempts) => { + if attempts.is_empty() { + println!("No attempts found."); + } else { + for attempt in attempts { + println!( + " [{}] {} - {:?} - {}", + attempt.source, + attempt.short_id, + attempt.status, + attempt.pr_url.as_deref().unwrap_or("N/A") + ); + } + } + } + IpcData::Triggered { + source, + issue_id, + pr_url, + } => { + println!("Triggered fix for {}:{}", source, issue_id); + if let Some(url) = pr_url { + println!(" PR: {}", url); + } + } + IpcData::Reset { source, issue_id } => { + println!("Reset {}:{}", source, issue_id); + } + IpcData::RetriesProcessed { count } => { + println!("Processed {} retries", count); + } + IpcData::Activity(entries) => { + if entries.is_empty() { + println!("No recent activity."); + } else { + println!("\nRecent Activity:"); + for entry in entries { + let source_info = entry + .source + .as_ref() + .map(|s| format!("[{}] ", s)) + .unwrap_or_default(); + let issue_info = entry + .issue_id + .as_ref() + .map(|id| format!("{}: ", id)) + .unwrap_or_default(); + println!( + " {} {}{}{:?}: {}", + entry.timestamp, + source_info, + issue_info, + entry.activity_type, + entry.message + ); + } + } + } + }, + IpcResponse::Error { message } => { + eprintln!("Error: {}", message); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_client_default() { + let client = IpcClient::default(); + assert!(!client.is_daemon_running()); // Assuming no daemon in tests + } + + #[test] + fn test_client_with_timeout() { + let client = IpcClient::new().with_timeout(Duration::from_secs(5)); + assert_eq!(client.timeout, Duration::from_secs(5)); + } + + #[test] + fn test_client_with_socket_path() { + let path = PathBuf::from("/tmp/test.sock"); + let client = IpcClient::with_socket_path(path.clone()); + assert_eq!(client.socket_path, path); + } +} diff --git a/src/ipc/mod.rs b/src/ipc/mod.rs new file mode 100644 index 00000000..8ea8df3e --- /dev/null +++ b/src/ipc/mod.rs @@ -0,0 +1,123 @@ +//! Inter-process communication via Unix socket. +//! +//! Enables the CLI to communicate with a running watcher daemon. + +mod client; +mod protocol; +mod server; + +pub use client::{print_response, IpcClient}; +pub use protocol::{IpcCommand, IpcData, IpcResponse, WatcherState}; +pub use server::IpcServer; + +use std::path::PathBuf; + +/// Default socket path for the IPC server. +pub fn default_socket_path() -> PathBuf { + if cfg!(target_os = "macos") { + // macOS: use /tmp or user's temp dir + std::env::temp_dir().join("claudear.sock") + } else { + // Linux: use XDG_RUNTIME_DIR if available, otherwise /tmp + std::env::var("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| std::env::temp_dir()) + .join("claudear.sock") + } +} + +/// Default PID file path. +pub fn default_pid_path() -> PathBuf { + if cfg!(target_os = "macos") { + std::env::temp_dir().join("claudear.pid") + } else { + std::env::var("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| std::env::temp_dir()) + .join("claudear.pid") + } +} + +/// Check if a watcher daemon is running. +pub fn is_daemon_running() -> bool { + let socket_path = default_socket_path(); + socket_path.exists() && std::os::unix::net::UnixStream::connect(&socket_path).is_ok() +} + +/// Get the PID of the running daemon, if any. +pub fn get_daemon_pid() -> Option { + let pid_path = default_pid_path(); + if pid_path.exists() { + std::fs::read_to_string(&pid_path) + .ok() + .and_then(|s| s.trim().parse().ok()) + } else { + None + } +} + +/// Write the current process PID to the pid file. +pub fn write_pid_file() -> std::io::Result<()> { + let pid_path = default_pid_path(); + std::fs::write(&pid_path, std::process::id().to_string()) +} + +/// Remove the PID file. +pub fn remove_pid_file() { + let pid_path = default_pid_path(); + let _ = std::fs::remove_file(&pid_path); +} + +/// Remove the socket file. +pub fn remove_socket_file() { + let socket_path = default_socket_path(); + let _ = std::fs::remove_file(&socket_path); +} + +/// Clean up stale socket/pid files from a previous crash. +pub fn cleanup_stale_files() { + if let Some(pid) = get_daemon_pid() { + // Check if process is still running by trying to read /proc/{pid} or using kill -0 + let is_running = is_process_running(pid); + if !is_running { + tracing::info!("Cleaning up stale files from previous run (PID {})", pid); + remove_pid_file(); + remove_socket_file(); + } + } else { + // No PID file but socket exists - stale + let socket_path = default_socket_path(); + if socket_path.exists() + && std::os::unix::net::UnixStream::connect(&socket_path).is_err() + { + tracing::info!("Cleaning up stale socket file"); + remove_socket_file(); + } + } +} + +/// Check if a process with the given PID is running. +fn is_process_running(pid: u32) -> bool { + // Try to check /proc on Linux + #[cfg(target_os = "linux")] + { + std::path::Path::new(&format!("/proc/{}", pid)).exists() + } + + // On macOS/BSD, use kill(pid, 0) to check if process exists + #[cfg(target_os = "macos")] + { + // SAFETY: kill with signal 0 doesn't actually send a signal, + // it just checks if the process exists and we have permission to signal it. + // Returns 0 if process exists, -1 if not (with errno set to ESRCH). + unsafe { libc::kill(pid as i32, 0) == 0 } + } + + // Fallback for other platforms + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = pid; // Suppress unused warning + // Assume running if we can't check + true + } +} diff --git a/src/ipc/protocol.rs b/src/ipc/protocol.rs new file mode 100644 index 00000000..c2f5a6a4 --- /dev/null +++ b/src/ipc/protocol.rs @@ -0,0 +1,352 @@ +//! IPC protocol definitions. + +use crate::types::{FixAttempt, FixAttemptStats}; +use serde::{Deserialize, Serialize}; + +/// Commands that can be sent to the watcher daemon. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "cmd", content = "args")] +pub enum IpcCommand { + /// Get the current watcher state. + Status, + + /// Pause the watcher (stop processing new issues). + Pause, + + /// Resume the watcher. + Resume, + + /// Trigger a fix for a specific issue. + Trigger { source: String, issue_id: String }, + + /// Reset a failed attempt. + Reset { source: String, issue_id: String }, + + /// Get statistics. + Stats, + + /// Get pending PRs. + ListPrs, + + /// Get retryable issues. + ListRetries, + + /// Process ready retries now. + ProcessRetries, + + /// Gracefully shutdown the daemon. + Shutdown, + + /// Ping (health check). + Ping, + + /// Get recent activity/logs. + Activity { limit: usize }, +} + +/// Response from the watcher daemon. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "status", content = "data")] +pub enum IpcResponse { + /// Success with optional data. + Ok(IpcData), + + /// Error with message. + Error { message: String }, +} + +/// Data returned in successful responses. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "value")] +pub enum IpcData { + /// No data (acknowledgment). + None, + + /// Pong response. + Pong, + + /// Watcher state. + State(WatcherState), + + /// Statistics. + Stats(FixAttemptStats), + + /// List of fix attempts. + Attempts(Vec), + + /// Triggered issue result. + Triggered { + source: String, + issue_id: String, + pr_url: Option, + }, + + /// Reset result. + Reset { source: String, issue_id: String }, + + /// Retry processing result. + RetriesProcessed { count: usize }, + + /// Recent activity entries. + Activity(Vec), + + /// Text message. + Message(String), +} + +/// Current state of the watcher daemon. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WatcherState { + /// Whether the watcher is running. + pub running: bool, + + /// Whether the watcher is paused. + pub paused: bool, + + /// Current mode (poll, webhook, dashboard). + pub mode: String, + + /// Uptime in seconds. + pub uptime_secs: u64, + + /// Number of issues processed since start. + pub issues_processed: usize, + + /// Number of PRs created since start. + pub prs_created: usize, + + /// Currently processing issues. + pub processing: Vec, + + /// Enabled sources. + pub sources: Vec, + + /// Poll interval (if in poll mode). + pub poll_interval_ms: Option, +} + +/// An activity log entry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActivityEntry { + /// Timestamp. + pub timestamp: String, + + /// Activity type. + pub activity_type: ActivityType, + + /// Short description. + pub message: String, + + /// Related issue ID. + pub issue_id: Option, + + /// Related source. + pub source: Option, +} + +/// Types of activity. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum ActivityType { + // === Issue Events === + /// Issue detected. + IssueDetected, + + /// Issue status changed externally. + IssueStatusChanged, + + /// Issue priority changed externally. + IssuePriorityChanged, + + /// Issue received a comment. + IssueCommented, + + /// Issue was resolved externally. + IssueResolved, + + /// Issue was cancelled externally. + IssueCancelled, + + /// Issue was escalated (e.g., Sentry event count spike). + IssueEscalated, + + // === Processing Events === + /// Processing started. + ProcessingStarted, + + /// Processing completed successfully. + ProcessingCompleted, + + /// Processing failed. + ProcessingFailed, + + /// Processing was skipped (already handled, not matching criteria, etc.). + ProcessingSkipped, + + /// A retry has been scheduled. + RetryScheduled, + + /// A retry is being executed. + RetryExecuted, + + // === PR Events === + /// PR created. + PrCreated, + + /// PR merged. + PrMerged, + + /// PR closed without merge. + PrClosed, + + /// PR review received. + PrReviewReceived, + + /// PR review was requested. + PrReviewRequested, + + /// PR received a comment. + PrCommented, + + /// PR status check passed. + PrStatusCheckPassed, + + /// PR status check failed. + PrStatusCheckFailed, + + /// PR was auto-closed because the source issue was resolved/cancelled. + PrAutoClosed, + + // === Claude Events === + /// Claude execution started. + ClaudeStarted, + + /// Claude execution completed successfully. + ClaudeCompleted, + + /// Claude execution timed out. + ClaudeTimedOut, + + /// Claude execution failed. + ClaudeFailed, + + // === Webhook Events === + /// Webhook received. + WebhookReceived, + + /// Webhook processed successfully. + WebhookProcessed, + + /// Webhook rejected (invalid signature, filtered out, etc.). + WebhookRejected, + + // === System Events === + /// Watcher started. + WatcherStarted, + + /// Watcher stopped. + WatcherStopped, + + /// Watcher paused. + WatcherPaused, + + /// Watcher resumed. + WatcherResumed, + + /// Rate limit hit on an external API. + RateLimitHit, + + /// Watcher state change (legacy, kept for compatibility). + StateChange, + + /// Error occurred. + Error, +} + +impl IpcResponse { + /// Create an OK response with no data. + pub fn ok() -> Self { + Self::Ok(IpcData::None) + } + + /// Create an OK response with data. + pub fn ok_with(data: IpcData) -> Self { + Self::Ok(data) + } + + /// Create an error response. + pub fn error(message: impl Into) -> Self { + Self::Error { + message: message.into(), + } + } + + /// Check if this is an OK response. + pub fn is_ok(&self) -> bool { + matches!(self, Self::Ok(_)) + } + + /// Get the error message if this is an error. + pub fn error_message(&self) -> Option<&str> { + match self { + Self::Error { message } => Some(message), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_command_serialization() { + let cmd = IpcCommand::Trigger { + source: "linear".to_string(), + issue_id: "LIN-123".to_string(), + }; + + let json = serde_json::to_string(&cmd).unwrap(); + assert!(json.contains("Trigger")); + assert!(json.contains("linear")); + + let parsed: IpcCommand = serde_json::from_str(&json).unwrap(); + assert!(matches!(parsed, IpcCommand::Trigger { .. })); + } + + #[test] + fn test_response_serialization() { + let resp = IpcResponse::ok_with(IpcData::Message("Hello".to_string())); + + let json = serde_json::to_string(&resp).unwrap(); + assert!(json.contains("Ok")); + assert!(json.contains("Hello")); + } + + #[test] + fn test_response_error() { + let resp = IpcResponse::error("Something went wrong"); + assert!(!resp.is_ok()); + assert_eq!(resp.error_message(), Some("Something went wrong")); + } + + #[test] + fn test_watcher_state() { + let state = WatcherState { + running: true, + paused: false, + mode: "poll".to_string(), + uptime_secs: 3600, + issues_processed: 10, + prs_created: 5, + processing: vec!["LIN-123".to_string()], + sources: vec!["linear".to_string(), "sentry".to_string()], + poll_interval_ms: Some(300000), + }; + + let json = serde_json::to_string(&state).unwrap(); + let parsed: WatcherState = serde_json::from_str(&json).unwrap(); + + assert!(parsed.running); + assert_eq!(parsed.uptime_secs, 3600); + } +} diff --git a/src/ipc/server.rs b/src/ipc/server.rs new file mode 100644 index 00000000..bf608931 --- /dev/null +++ b/src/ipc/server.rs @@ -0,0 +1,611 @@ +//! IPC server implementation using Unix sockets. + +use super::protocol::{ + ActivityEntry, ActivityType, IpcCommand, IpcData, IpcResponse, WatcherState, +}; +use super::{ + cleanup_stale_files, default_socket_path, remove_pid_file, remove_socket_file, write_pid_file, +}; +use crate::error::Result; +use crate::notifier::Notifier; +use crate::source::IssueSource; +use crate::storage::FixAttemptTracker; +use crate::types::{ActivityLogEntry, FixAttemptStatus}; +use crate::watcher::Watcher; + +use std::collections::VecDeque; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Instant; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{UnixListener, UnixStream}; +use tokio::sync::{broadcast, Mutex, RwLock}; + +/// Default maximum number of activity entries to keep (can be overridden via config). +const DEFAULT_MAX_ACTIVITY_ENTRIES: usize = 10_000; + +/// IPC server that listens on a Unix socket. +pub struct IpcServer { + socket_path: PathBuf, + tracker: Arc, + sources: Vec>, + notifier: Arc, + watcher: Option>, + state: Arc, + shutdown_tx: broadcast::Sender<()>, + /// Maximum number of activity entries to keep. + max_activity_entries: usize, +} + +/// Shared state for the IPC server. +struct ServerState { + /// Whether the watcher is paused. + paused: AtomicBool, + + /// Server start time. + start_time: Instant, + + /// Number of issues processed. + issues_processed: AtomicUsize, + + /// Number of PRs created. + prs_created: AtomicUsize, + + /// Currently processing issue IDs. + processing: RwLock>, + + /// Recent activity log. + activity: Mutex>, + + /// Mode (poll, webhook, etc.). + mode: RwLock, + + /// Poll interval if applicable. + poll_interval_ms: AtomicU64, + + /// Source names. + source_names: Vec, + + /// Maximum activity entries (configurable). + max_activity_entries: usize, +} + +impl IpcServer { + /// Create a new IPC server with default settings. + pub fn new( + tracker: Arc, + sources: Vec>, + notifier: Arc, + ) -> Self { + Self::builder(tracker, sources, notifier).build() + } + + /// Create a builder for configuring the IPC server. + pub fn builder( + tracker: Arc, + sources: Vec>, + notifier: Arc, + ) -> IpcServerBuilder { + IpcServerBuilder::new(tracker, sources, notifier) + } + + /// Set the watcher instance. + pub fn with_watcher(mut self, watcher: Arc) -> Self { + self.watcher = Some(watcher); + self + } + + /// Set the maximum activity entries. + pub fn with_max_activity_entries(mut self, max: usize) -> Self { + self.max_activity_entries = max; + self + } + + /// Set the mode. + pub async fn set_mode(&self, mode: &str) { + *self.state.mode.write().await = mode.to_string(); + } + + /// Set the poll interval. + pub fn set_poll_interval(&self, interval_ms: u64) { + self.state + .poll_interval_ms + .store(interval_ms, Ordering::SeqCst); + } + + /// Check if paused. + pub fn is_paused(&self) -> bool { + self.state.paused.load(Ordering::SeqCst) + } + + /// Log an activity entry. + /// + /// Persists to both in-memory cache (for fast CLI queries) and database (for long-term analytics). + pub async fn log_activity( + &self, + activity_type: ActivityType, + message: &str, + issue_id: Option<&str>, + source: Option<&str>, + ) { + let entry = ActivityEntry { + timestamp: chrono::Utc::now().to_rfc3339(), + activity_type: activity_type.clone(), + message: message.to_string(), + issue_id: issue_id.map(String::from), + source: source.map(String::from), + }; + + let db_activity_type = match activity_type { + // Issue Events + ActivityType::IssueDetected => "issue_detected", + ActivityType::IssueStatusChanged => "issue_status_changed", + ActivityType::IssuePriorityChanged => "issue_priority_changed", + ActivityType::IssueCommented => "issue_commented", + ActivityType::IssueResolved => "issue_resolved", + ActivityType::IssueCancelled => "issue_cancelled", + ActivityType::IssueEscalated => "issue_escalated", + // Processing Events + ActivityType::ProcessingStarted => "processing_started", + ActivityType::ProcessingCompleted => "processing_completed", + ActivityType::ProcessingFailed => "processing_failed", + ActivityType::ProcessingSkipped => "processing_skipped", + ActivityType::RetryScheduled => "retry_scheduled", + ActivityType::RetryExecuted => "retry_executed", + // PR Events + ActivityType::PrCreated => "pr_created", + ActivityType::PrMerged => "pr_merged", + ActivityType::PrClosed => "pr_closed", + ActivityType::PrReviewReceived => "pr_review_received", + ActivityType::PrReviewRequested => "pr_review_requested", + ActivityType::PrCommented => "pr_commented", + ActivityType::PrStatusCheckPassed => "pr_status_check_passed", + ActivityType::PrStatusCheckFailed => "pr_status_check_failed", + ActivityType::PrAutoClosed => "pr_auto_closed", + // Claude Events + ActivityType::ClaudeStarted => "claude_started", + ActivityType::ClaudeCompleted => "claude_completed", + ActivityType::ClaudeTimedOut => "claude_timed_out", + ActivityType::ClaudeFailed => "claude_failed", + // Webhook Events + ActivityType::WebhookReceived => "webhook_received", + ActivityType::WebhookProcessed => "webhook_processed", + ActivityType::WebhookRejected => "webhook_rejected", + // System Events + ActivityType::WatcherStarted => "watcher_started", + ActivityType::WatcherStopped => "watcher_stopped", + ActivityType::WatcherPaused => "watcher_paused", + ActivityType::WatcherResumed => "watcher_resumed", + ActivityType::RateLimitHit => "rate_limit_hit", + ActivityType::StateChange => "state_change", + ActivityType::Error => "error", + }; + + let db_entry = ActivityLogEntry::new(db_activity_type, message) + .with_source(source.unwrap_or("system").to_string()); + let db_entry = if let Some(id) = issue_id { + db_entry.with_issue(id.to_string(), id.to_string()) + } else { + db_entry + }; + + if let Err(e) = self.tracker.record_activity(&db_entry) { + tracing::warn!(error = %e, "Failed to persist activity to database"); + } + + let mut activity = self.state.activity.lock().await; + if activity.len() >= self.state.max_activity_entries { + activity.pop_front(); + } + activity.push_back(entry); + } + + /// Increment issues processed counter. + pub fn inc_issues_processed(&self) { + self.state.issues_processed.fetch_add(1, Ordering::SeqCst); + } + + /// Increment PRs created counter. + pub fn inc_prs_created(&self) { + self.state.prs_created.fetch_add(1, Ordering::SeqCst); + } + + /// Add a processing issue. + pub async fn add_processing(&self, issue_id: &str) { + self.state + .processing + .write() + .await + .push(issue_id.to_string()); + } + + /// Remove a processing issue. + pub async fn remove_processing(&self, issue_id: &str) { + self.state + .processing + .write() + .await + .retain(|id| id != issue_id); + } + + /// Get a shutdown receiver. + pub fn shutdown_receiver(&self) -> broadcast::Receiver<()> { + self.shutdown_tx.subscribe() + } + + /// Start the IPC server. + pub async fn start(&self) -> Result<()> { + // Clean up any stale files from previous runs + cleanup_stale_files(); + + // Remove existing socket file if present + if self.socket_path.exists() { + std::fs::remove_file(&self.socket_path)?; + } + + // Write PID file + write_pid_file()?; + + // Bind to socket + let listener = UnixListener::bind(&self.socket_path)?; + tracing::info!("IPC server listening on {:?}", self.socket_path); + + // Set permissions (owner only) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + std::fs::set_permissions(&self.socket_path, perms)?; + } + + let mut shutdown_rx = self.shutdown_tx.subscribe(); + + loop { + tokio::select! { + accept_result = listener.accept() => { + match accept_result { + Ok((stream, _)) => { + let tracker = self.tracker.clone(); + let sources = self.sources.clone(); + let notifier = self.notifier.clone(); + let watcher = self.watcher.clone(); + let state = self.state.clone(); + let shutdown_tx = self.shutdown_tx.clone(); + + tokio::spawn(async move { + if let Err(e) = handle_connection( + stream, tracker, sources, notifier, watcher, state, shutdown_tx + ).await { + tracing::error!("Error handling IPC connection: {}", e); + } + }); + } + Err(e) => { + tracing::error!("Failed to accept connection: {}", e); + } + } + } + _ = shutdown_rx.recv() => { + tracing::info!("IPC server shutting down"); + break; + } + } + } + + // Cleanup + remove_socket_file(); + remove_pid_file(); + + Ok(()) + } + + /// Trigger shutdown. + pub fn shutdown(&self) { + let _ = self.shutdown_tx.send(()); + } +} + +/// Handle a single IPC connection. +async fn handle_connection( + stream: UnixStream, + tracker: Arc, + sources: Vec>, + notifier: Arc, + watcher: Option>, + state: Arc, + shutdown_tx: broadcast::Sender<()>, +) -> Result<()> { + let (reader, mut writer) = stream.into_split(); + let mut reader = BufReader::new(reader); + let mut line = String::new(); + + while reader.read_line(&mut line).await? > 0 { + let command: IpcCommand = match serde_json::from_str(line.trim()) { + Ok(cmd) => cmd, + Err(e) => { + let response = IpcResponse::error(format!("Invalid command: {}", e)); + let json = serde_json::to_string(&response)? + "\n"; + writer.write_all(json.as_bytes()).await?; + line.clear(); + continue; + } + }; + + let response = handle_command( + command, + &tracker, + &sources, + ¬ifier, + &watcher, + &state, + &shutdown_tx, + ) + .await; + + let json = serde_json::to_string(&response)? + "\n"; + writer.write_all(json.as_bytes()).await?; + line.clear(); + } + + Ok(()) +} + +/// Handle a single IPC command. +async fn handle_command( + command: IpcCommand, + tracker: &Arc, + _sources: &[Arc], + _notifier: &Arc, + watcher: &Option>, + state: &Arc, + shutdown_tx: &broadcast::Sender<()>, +) -> IpcResponse { + match command { + IpcCommand::Ping => IpcResponse::ok_with(IpcData::Pong), + + IpcCommand::Status => { + let watcher_state = WatcherState { + running: true, + paused: state.paused.load(Ordering::SeqCst), + mode: state.mode.read().await.clone(), + uptime_secs: state.start_time.elapsed().as_secs(), + issues_processed: state.issues_processed.load(Ordering::SeqCst), + prs_created: state.prs_created.load(Ordering::SeqCst), + processing: state.processing.read().await.clone(), + sources: state.source_names.clone(), + poll_interval_ms: { + let interval = state.poll_interval_ms.load(Ordering::SeqCst); + if interval > 0 { + Some(interval) + } else { + None + } + }, + }; + IpcResponse::ok_with(IpcData::State(watcher_state)) + } + + IpcCommand::Pause => { + state.paused.store(true, Ordering::SeqCst); + + // Log watcher_paused activity + let activity = ActivityLogEntry::new("watcher_paused", "Watcher paused by user") + .with_source("system".to_string()); + tracker.record_activity(&activity).ok(); + + IpcResponse::ok_with(IpcData::Message("Watcher paused".to_string())) + } + + IpcCommand::Resume => { + state.paused.store(false, Ordering::SeqCst); + + // Log watcher_resumed activity + let activity = ActivityLogEntry::new("watcher_resumed", "Watcher resumed by user") + .with_source("system".to_string()); + tracker.record_activity(&activity).ok(); + + IpcResponse::ok_with(IpcData::Message("Watcher resumed".to_string())) + } + + IpcCommand::Stats => match tracker.get_stats() { + Ok(stats) => IpcResponse::ok_with(IpcData::Stats(stats)), + Err(e) => IpcResponse::error(format!("Failed to get stats: {}", e)), + }, + + IpcCommand::ListPrs => match tracker.get_pending_prs() { + Ok(attempts) => IpcResponse::ok_with(IpcData::Attempts(attempts)), + Err(e) => IpcResponse::error(format!("Failed to list PRs: {}", e)), + }, + + IpcCommand::ListRetries => { + // Use a reasonable default max_retries + match tracker.get_retryable_issues(3) { + Ok(attempts) => IpcResponse::ok_with(IpcData::Attempts(attempts)), + Err(e) => IpcResponse::error(format!("Failed to list retries: {}", e)), + } + } + + IpcCommand::Trigger { source, issue_id } => { + if let Some(watcher) = watcher { + match watcher.trigger_issue(&source, &issue_id).await { + Ok(()) => { + // Get the PR URL if available + let pr_url = tracker + .get_attempt(&source, &issue_id) + .ok() + .flatten() + .and_then(|a| a.pr_url); + + IpcResponse::ok_with(IpcData::Triggered { + source, + issue_id, + pr_url, + }) + } + Err(e) => IpcResponse::error(format!("Failed to trigger: {}", e)), + } + } else { + IpcResponse::error("Watcher not available") + } + } + + IpcCommand::Reset { source, issue_id } => { + if let Some(watcher) = watcher { + match watcher.reset_attempt(&source, &issue_id) { + Ok(()) => IpcResponse::ok_with(IpcData::Reset { source, issue_id }), + Err(e) => IpcResponse::error(format!("Failed to reset: {}", e)), + } + } else { + // Try direct tracker reset + match tracker.prepare_for_retry(&source, &issue_id) { + Ok(()) => IpcResponse::ok_with(IpcData::Reset { source, issue_id }), + Err(e) => IpcResponse::error(format!("Failed to reset: {}", e)), + } + } + } + + IpcCommand::ProcessRetries => { + if let Some(watcher) = watcher { + match tracker.get_retryable_issues(3) { + Ok(attempts) => { + let mut count = 0; + for attempt in attempts { + // Check if ready for retry (simplified check) + if attempt.status == FixAttemptStatus::Failed { + if let Err(e) = + tracker.prepare_for_retry(&attempt.source, &attempt.issue_id) + { + tracing::warn!( + "Failed to prepare retry for {}: {}", + attempt.short_id, + e + ); + continue; + } + + if let Err(e) = watcher + .trigger_issue(&attempt.source, &attempt.issue_id) + .await + { + tracing::warn!( + "Failed to trigger retry for {}: {}", + attempt.short_id, + e + ); + } else { + count += 1; + } + } + } + IpcResponse::ok_with(IpcData::RetriesProcessed { count }) + } + Err(e) => IpcResponse::error(format!("Failed to get retryable issues: {}", e)), + } + } else { + IpcResponse::error("Watcher not available for processing retries") + } + } + + IpcCommand::Activity { limit } => { + let activity = state.activity.lock().await; + let entries: Vec<_> = activity.iter().rev().take(limit).cloned().collect(); + IpcResponse::ok_with(IpcData::Activity(entries)) + } + + IpcCommand::Shutdown => { + let _ = shutdown_tx.send(()); + IpcResponse::ok_with(IpcData::Message("Shutdown initiated".to_string())) + } + } +} + +impl Drop for IpcServer { + fn drop(&mut self) { + // Best effort cleanup + remove_socket_file(); + remove_pid_file(); + } +} + +/// Builder for configuring an IpcServer. +pub struct IpcServerBuilder { + tracker: Arc, + sources: Vec>, + notifier: Arc, + max_activity_entries: usize, +} + +impl IpcServerBuilder { + /// Create a new IpcServer builder. + pub fn new( + tracker: Arc, + sources: Vec>, + notifier: Arc, + ) -> Self { + Self { + tracker, + sources, + notifier, + max_activity_entries: DEFAULT_MAX_ACTIVITY_ENTRIES, + } + } + + /// Set the maximum number of activity entries to keep. + pub fn max_activity_entries(mut self, max: usize) -> Self { + self.max_activity_entries = max; + self + } + + /// Build the IpcServer. + pub fn build(self) -> IpcServer { + let (shutdown_tx, _) = broadcast::channel(1); + let source_names = self.sources.iter().map(|s| s.name().to_string()).collect(); + + IpcServer { + socket_path: default_socket_path(), + tracker: self.tracker, + sources: self.sources, + notifier: self.notifier, + watcher: None, + state: Arc::new(ServerState { + paused: AtomicBool::new(false), + start_time: Instant::now(), + issues_processed: AtomicUsize::new(0), + prs_created: AtomicUsize::new(0), + processing: RwLock::new(Vec::new()), + activity: Mutex::new(VecDeque::with_capacity(self.max_activity_entries)), + mode: RwLock::new("initializing".to_string()), + poll_interval_ms: AtomicU64::new(0), + source_names, + max_activity_entries: self.max_activity_entries, + }), + shutdown_tx, + max_activity_entries: self.max_activity_entries, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_server_state_defaults() { + let state = ServerState { + paused: AtomicBool::new(false), + start_time: Instant::now(), + issues_processed: AtomicUsize::new(0), + prs_created: AtomicUsize::new(0), + processing: RwLock::new(Vec::new()), + activity: Mutex::new(VecDeque::new()), + mode: RwLock::new("test".to_string()), + poll_interval_ms: AtomicU64::new(0), + source_names: vec!["linear".to_string()], + max_activity_entries: DEFAULT_MAX_ACTIVITY_ENTRIES, + }; + + assert!(!state.paused.load(Ordering::SeqCst)); + assert_eq!(state.issues_processed.load(Ordering::SeqCst), 0); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 00000000..2a974dc0 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,77 @@ +//! # Claudear +//! +//! A unified watcher service that monitors issue trackers and error monitoring services, +//! automatically spawning Claude Code agents to fix issues and create pull requests. +//! +//! ## Features +//! +//! - **Multi-Source Support**: Monitor Linear issues and Sentry errors from a single service +//! - **Extensible Architecture**: Easy to add new sources (GitHub Issues, Jira, etc.) +//! - **Discord Notifications**: Get notified about fix attempts with PR links +//! - **SQLite Tracking**: Persistent tracking of fix attempts to avoid duplicates +//! - **Priority-Based Processing**: Urgent/escalating issues are processed first +//! - **Graceful Handling**: Proper error handling and retry support +//! +//! ## Usage +//! +//! ```bash +//! # First-time setup - mark existing issues as seen +//! claudear seed +//! +//! # Start polling for new issues +//! claudear poll +//! +//! # Start webhook server for real-time events +//! claudear webhook +//! ``` + +pub mod api; +pub mod config; +pub mod discord; +pub mod env_writer; +pub mod error; +pub mod feedback; +pub mod github; +pub mod inference; +pub mod ipc; +pub mod notifier; +pub mod repo; +pub mod reports; +pub mod retry; +pub mod runner; +pub mod source; +pub mod storage; +pub mod templates; +pub mod testutil; +pub mod types; +pub mod watcher; +pub mod webhook; + +pub use config::{Config, RetryConfig}; +pub use discord::{DiscordClient, ThreadManager, ThreadState}; +pub use error::{Error, Result}; +pub use feedback::{ + cosine_similarity, euclidean_distance, normalize, EmbeddingClient, EmbeddingConfig, + EmbeddingResult, FeedbackAnalyzer, FixOutcome, MemoryVectorStore, Outcome, PromptSuggestion, + SimilarIssue, +}; +pub use github::{ + GitHubClient, GitHubUser, PrMonitor, PrReview, PrReviewComment, PrReviewState, PrStatus, + PrStatusUpdate, ReviewEvent, ReviewWatcher, +}; +pub use inference::{Confidence, InferredRepo, IssueContext, RepoInferrer}; +pub use ipc::{ + default_socket_path, is_daemon_running, print_response, IpcClient, IpcCommand, IpcData, + IpcResponse, IpcServer, WatcherState, +}; +pub use repo::{ + DependencyDiscovery, DependencyGraph, DependencyType, DiscoveredDependency, IndexedRepo, + RepoIndex, RepoRelationships, Repository, +}; +pub use reports::{Report, ReportFrequency, ReportGenerator, ReportSchedule, ReportScheduler}; +pub use retry::{RetryDecision, RetryManager}; +pub use storage::{ + classify_error, compute_error_hash, AnalyticsService, FixAttemptTracker, SqliteTracker, + StoredDependency, StoredRepository, TimePeriod, TrendAnalysis, TrendDirection, +}; +pub use types::*; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 00000000..97f1cec7 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,1713 @@ +//! Claudear - Unified watcher for issue trackers and error services. + +use clap::{Parser, Subcommand}; +use claudear::{ + api::ApiServer, + config::Config, + github::{GitHubClient, PrMonitor, PrStatus}, + ipc::{default_socket_path, is_daemon_running, print_response, IpcClient, IpcServer}, + notifier::{ + CompositeNotifier, ConsoleNotifier, DiscordNotifier, EmailNotifier, Notifier, PushNotifier, + SmsNotifier, + }, + repo::{DependencyType, RepoIndex, RepoRelationships}, + reports::{ReportFrequency, ReportGenerator, ReportSchedule, ReportScheduler}, + retry::RetryManager, + source::{IssueSource, LinearSource, SentrySource}, + storage::{FixAttemptTracker, SqliteTracker}, + types::{ActivityLogEntry, FixAttemptStatus}, + watcher::{Watcher, WatcherOptions}, + webhook::{ + print_setup_result, LinearWebhookHandler, SentryWebhookHandler, WebhookConfigurator, + WebhookHandlerRegistry, WebhookServer, + }, +}; +use serde_json::json; +use std::sync::Arc; +use tokio::time::{interval, Duration}; +use tracing_subscriber::{fmt, EnvFilter}; + +#[derive(Parser)] +#[command(name = "claudear")] +#[command(about = "Unified watcher for issue trackers and error services")] +#[command(version)] +struct Cli { + /// Path to config file + #[arg(short, long, default_value = "claudear.yaml")] + config: String, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Start the watcher daemon with all configured services + Start { + /// HTTP port for dashboard API and webhooks + #[arg(long, default_value = "3100")] + port: u16, + + /// Enable background polling (in addition to webhooks) + #[arg(long)] + poll: bool, + + /// Polling interval in milliseconds (requires --poll) + #[arg(long, default_value = "300000")] + poll_interval: u64, + + /// Disable webhook server + #[arg(long)] + no_webhooks: bool, + + /// Disable dashboard API + #[arg(long)] + no_dashboard: bool, + }, + + /// Stop the running watcher daemon + Stop, + + /// Show the status of the running daemon + Status, + + /// Pause the running watcher (stops processing new issues) + Pause, + + /// Resume a paused watcher + Resume, + + /// Show recent activity from the daemon + Activity { + /// Number of entries to show + #[arg(default_value = "20")] + limit: usize, + }, + + /// Mark all existing issues as seen (run this first!) + Seed, + + /// Show what would be processed without running Claude + DryRun, + + /// Start polling all enabled sources (foreground, no daemon) + Poll { + /// Polling interval in milliseconds + #[arg(default_value = "300000")] + interval: u64, + }, + + /// Start webhook server for real-time events + Webhook { + /// Port to listen on + #[arg(default_value = "3100")] + port: u16, + + /// Auto-configure webhooks with Linear/Sentry APIs before starting + #[arg(long)] + setup_webhooks: bool, + + /// Public base URL where webhooks will be received (required with --setup-webhooks) + #[arg(long)] + base_url: Option, + + /// Path to .env file for saving webhook secrets + #[arg(long, default_value = ".env")] + env_file: String, + }, + + /// Manually trigger a fix for an issue + Trigger { + /// Source name (linear, sentry) + source: String, + /// Issue ID + issue_id: String, + }, + + /// Reset a failed attempt to allow retry + Reset { + /// Source name (linear, sentry) + source: String, + /// Issue ID + issue_id: String, + }, + + /// Show statistics about fix attempts + Stats, + + /// List configured sources + Sources, + + /// Start the dashboard API server + Dashboard { + /// Port to listen on + #[arg(default_value = "3100")] + port: u16, + + /// Path to built dashboard files (optional, serves static files) + #[arg(long)] + dashboard_dir: Option, + }, + + /// Repository management commands + #[command(subcommand)] + Repos(ReposCommands), + + /// Pull request management commands + #[command(subcommand)] + Prs(PrsCommands), + + /// Retry management commands + #[command(subcommand)] + Retries(RetriesCommands), + + /// Inference analytics and management + #[command(subcommand)] + Inference(InferenceCommands), + + /// Report generation and scheduling + #[command(subcommand)] + Report(ReportCommands), +} + +/// Repository management subcommands +#[derive(Subcommand)] +enum ReposCommands { + /// List all indexed repositories + List, + + /// Build/refresh the repository file index + Index { + /// Force full re-indexing even if already indexed + #[arg(long, default_value = "false")] + force: bool, + }, + + /// Search for files across all indexed repositories + Search { + /// Search query (file name or partial path) + query: String, + }, + + /// Show index statistics + Stats, + + /// Link two repositories (declare a dependency) + Link { + /// Upstream repository (the dependency) + upstream: String, + + /// Downstream repository (depends on upstream) + downstream: String, + + /// Dependency type (npm, composer, git_submodule, manual) + #[arg(long, default_value = "manual")] + dep_type: String, + }, + + /// Show the dependency graph + Graph { + /// Start from a specific repository + #[arg(long)] + root: Option, + }, + + /// Show what would cascade from a repository change + Cascade { + /// Repository that changed + repo: String, + }, + + /// Auto-discover dependencies by scanning directories + Discover { + /// Paths to scan (defaults to config's auto_discover_paths) + #[arg(long)] + paths: Vec, + + /// Save discovered dependencies to database + #[arg(long, default_value = "false")] + save: bool, + + /// Clear existing dependencies before saving + #[arg(long, default_value = "false")] + clear: bool, + }, + + /// [DEPRECATED] Add a repository (repos now auto-discovered via known_orgs) + #[command(hide = true)] + Add { + /// Repository name + name: String, + + /// Local filesystem path + #[arg(long)] + path: Option, + + /// GitHub URL (owner/repo format) + #[arg(long)] + github_url: Option, + }, + + /// [DEPRECATED] Sync (repos now auto-discovered via known_orgs) + #[command(hide = true)] + Sync, +} + +/// Inference analytics subcommands +#[derive(Subcommand)] +enum InferenceCommands { + /// Show inference statistics (success rates by confidence level) + Stats, + + /// List recent inference attempts + History { + /// Maximum number of entries to show + #[arg(long, default_value = "20")] + limit: usize, + }, + + /// Provide feedback on an inference attempt + Feedback { + /// Inference attempt ID + id: i64, + + /// Whether the inference was correct + #[arg(long)] + correct: bool, + + /// Actual repository name if inference was incorrect + #[arg(long)] + actual_repo: Option, + }, +} + +/// Pull request management subcommands +#[derive(Subcommand)] +enum PrsCommands { + /// List all PRs that are being tracked + List, + + /// Monitor PRs for merge status and auto-resolve issues + Monitor { + /// Run continuously with polling + #[arg(long, default_value = "false")] + continuous: bool, + }, +} + +/// Retry management subcommands +#[derive(Subcommand)] +enum RetriesCommands { + /// List issues that are eligible for retry + List, + + /// Process ready retries now + Process, +} + +/// Report subcommands +#[derive(Subcommand)] +enum ReportCommands { + /// Generate and show a report (preview without sending) + Preview { + /// Report frequency: daily, weekly, monthly + #[arg(default_value = "daily")] + frequency: String, + }, + + /// Generate and send a report immediately + Send { + /// Report frequency: daily, weekly, monthly + #[arg(default_value = "daily")] + frequency: String, + }, + + /// Start the report scheduler (runs in background) + Schedule { + /// Enable daily reports + #[arg(long, default_value = "true")] + daily: bool, + + /// Enable weekly reports (Monday) + #[arg(long, default_value = "false")] + weekly: bool, + + /// Hour to send reports (0-23 UTC) + #[arg(long, default_value = "9")] + hour: u32, + }, +} + +fn init_logging() { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + + fmt().with_env_filter(filter).with_target(false).init(); +} + +fn create_sources(config: &Config) -> Vec> { + let mut sources: Vec> = Vec::new(); + + if let Some(ref linear_config) = config.linear { + if linear_config.enabled { + sources.push(Arc::new(LinearSource::new(linear_config.clone()))); + tracing::info!("Linear source initialized"); + } + } + + if let Some(ref sentry_config) = config.sentry { + if sentry_config.enabled { + sources.push(Arc::new(SentrySource::new(sentry_config.clone()))); + tracing::info!("Sentry source initialized"); + } + } + + sources +} + +fn create_webhook_handlers(config: &Config) -> WebhookHandlerRegistry { + let mut registry = WebhookHandlerRegistry::new(); + + if let Some(ref linear_config) = config.linear { + if linear_config.enabled { + registry.register(Arc::new(LinearWebhookHandler::new(linear_config.clone()))); + tracing::info!("Linear webhook handler registered"); + } + } + + if let Some(ref sentry_config) = config.sentry { + if sentry_config.enabled { + registry.register(Arc::new(SentryWebhookHandler::new(sentry_config.clone()))); + tracing::info!("Sentry webhook handler registered"); + } + } + + registry +} + +fn create_notifier(config: &Config) -> Arc { + let mut composite = CompositeNotifier::new(); + + // Always add console notifier + composite.add(Arc::new(ConsoleNotifier::new())); + + // Add Discord if configured + if config.discord.webhook_url.is_some() { + composite.add(Arc::new(DiscordNotifier::new(config.discord.clone()))); + tracing::info!("Discord notifier enabled"); + } + + // Add Email if configured + if let Ok(email_notifier) = EmailNotifier::new(config.email.clone()) { + if email_notifier.is_enabled() { + composite.add(Arc::new(email_notifier)); + tracing::info!("Email notifier enabled"); + } + } + + // Add SMS if configured + let sms_notifier = SmsNotifier::new(config.sms.clone()); + if sms_notifier.is_enabled() { + composite.add(Arc::new(sms_notifier)); + tracing::info!("SMS notifier enabled"); + } + + // Add Push if configured + let push_notifier = PushNotifier::new(config.push.clone()); + if push_notifier.is_enabled() { + composite.add(Arc::new(push_notifier)); + tracing::info!("Push notifier enabled"); + } + + Arc::new(composite) +} + +fn create_tracker(config: &Config) -> Arc { + let tracker = SqliteTracker::new(&config.db_path).expect("Failed to initialize SQLite tracker"); + Arc::new(tracker) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + init_logging(); + + let cli = Cli::parse(); + let config_path = cli.config.clone(); + + // Load and validate config from YAML file + let config = Config::load(&config_path)?; + + // Handle daemon control commands early (don't need full config validation) + match &cli.command { + Commands::Stop => { + if !is_daemon_running() { + println!("No daemon is running."); + return Ok(()); + } + + let client = IpcClient::new(); + match client.shutdown().await { + Ok(response) => { + print_response(&response); + } + Err(e) => { + eprintln!("Failed to stop daemon: {}", e); + std::process::exit(1); + } + } + return Ok(()); + } + + Commands::Status => { + if !is_daemon_running() { + println!("No daemon is running."); + println!("Socket path: {:?}", default_socket_path()); + return Ok(()); + } + + let client = IpcClient::new(); + match client.status().await { + Ok(response) => { + print_response(&response); + } + Err(e) => { + eprintln!("Failed to get status: {}", e); + std::process::exit(1); + } + } + return Ok(()); + } + + Commands::Pause => { + if !is_daemon_running() { + anyhow::bail!("No daemon is running. Start one with 'claudear start'"); + } + + let client = IpcClient::new(); + match client.pause().await { + Ok(response) => { + print_response(&response); + } + Err(e) => { + eprintln!("Failed to pause: {}", e); + std::process::exit(1); + } + } + return Ok(()); + } + + Commands::Resume => { + if !is_daemon_running() { + anyhow::bail!("No daemon is running. Start one with 'claudear start'"); + } + + let client = IpcClient::new(); + match client.resume().await { + Ok(response) => { + print_response(&response); + } + Err(e) => { + eprintln!("Failed to resume: {}", e); + std::process::exit(1); + } + } + return Ok(()); + } + + Commands::Activity { limit } => { + if !is_daemon_running() { + anyhow::bail!("No daemon is running. Start one with 'claudear start'"); + } + + let client = IpcClient::new(); + match client.activity(*limit).await { + Ok(response) => { + print_response(&response); + } + Err(e) => { + eprintln!("Failed to get activity: {}", e); + std::process::exit(1); + } + } + return Ok(()); + } + + _ => {} + } + + // Handle sources command early (doesn't need full validation) + if matches!(cli.command, Commands::Sources) { + println!("\nConfigured Sources:"); + if config.is_linear_enabled() { + if let Some(ref linear) = config.linear { + println!(" Linear (labels: {})", linear.trigger_labels.join(", ")); + if linear.webhook_secret.is_some() { + println!(" Webhook secret: configured"); + } + } + } else { + println!(" Linear (not configured)"); + } + + if config.is_sentry_enabled() { + if let Some(ref sentry) = config.sentry { + println!(" Sentry (org: {})", sentry.org_slug); + if sentry.client_secret.is_some() { + println!(" Client secret: configured"); + } + } + } else { + println!(" Sentry (not configured)"); + } + + println!("\nRate Limiting:"); + println!(" Max issues per cycle: {}", config.max_issues_per_cycle); + println!(" Max concurrent: {}", config.max_concurrent); + println!(" Processing delay: {}ms", config.processing_delay_ms); + + println!("\nNotifiers:"); + println!(" Console: enabled"); + if config.discord.webhook_url.is_some() { + println!(" Discord: enabled"); + } + if config.email.smtp_host.is_some() { + println!(" Email: enabled"); + } + if config.sms.account_sid.is_some() { + println!(" SMS (Twilio): enabled"); + } + if config.push.api_token.is_some() { + println!(" Push (Pushover): enabled"); + } + + return Ok(()); + } + + // Handle Repos commands early (don't need sources or repository validation) + if let Commands::Repos(ref repos_cmd) = cli.command { + let db_tracker = SqliteTracker::new(&config.db_path)?; + + match repos_cmd { + ReposCommands::List => { + // First show indexed repos from the database + let indexed_repos = db_tracker.list_indexed_repos()?; + + if indexed_repos.is_empty() { + println!("\nNo indexed repositories."); + println!("Run 'claudear repos index' to build the repository index."); + } else { + println!("\nIndexed Repositories ({}):", indexed_repos.len()); + for repo in &indexed_repos { + println!(" {} - {} files [{}]", repo.name, repo.file_count, repo.path); + } + } + + // Show known orgs from config + if !config.known_orgs.is_empty() { + println!("\nKnown Organizations: {:?}", config.known_orgs); + } + + // Show auto-discover paths + if !config.auto_discover_paths.is_empty() { + println!("Auto-Discover Paths: {:?}", config.auto_discover_paths); + } + } + + ReposCommands::Index { force } => { + println!("\nBuilding repository index..."); + println!(" Known orgs: {:?}", config.known_orgs); + println!(" Scanning: {:?}", config.auto_discover_paths); + + if config.known_orgs.is_empty() || config.auto_discover_paths.is_empty() { + anyhow::bail!("known_orgs and auto_discover_paths must be configured in claudear.yaml"); + } + + let index = RepoIndex::build(&config.known_orgs, &config.auto_discover_paths)?; + + if index.is_empty() { + println!("\nNo repositories found from known orgs in the specified paths."); + return Ok(()); + } + + println!("\nDiscovered {} repositories:", index.len()); + + // Save to database + let mut saved_count = 0; + for repo in index.list() { + // Check if already indexed (skip unless force) + if !force { + if let Ok(Some(_)) = db_tracker.get_indexed_repo(&repo.name) { + println!(" {} - skipped (already indexed)", repo.name); + continue; + } + } + + // Save repo and its files + let repo_id = db_tracker.save_indexed_repo( + &repo.name, + &repo.path.to_string_lossy(), + Some(repo.github_url.as_str()), + &repo.default_branch, + repo.files.len(), + )?; + + // Convert files to (path, file_type) tuples for storage + let files_with_types: Vec<(String, Option)> = repo.files.iter() + .map(|f| { + let file_type = std::path::Path::new(f) + .extension() + .map(|e| e.to_string_lossy().to_string()); + (f.clone(), file_type) + }) + .collect(); + + // Save file index + db_tracker.save_repo_files(repo_id, &files_with_types)?; + + println!(" {} - {} files indexed", repo.name, repo.files.len()); + saved_count += 1; + } + + println!("\nIndexed {} repositories to {:?}", saved_count, config.db_path); + } + + ReposCommands::Search { query } => { + // Build index from config for searching + if config.known_orgs.is_empty() || config.auto_discover_paths.is_empty() { + anyhow::bail!("known_orgs and auto_discover_paths must be configured"); + } + + println!("\nSearching for '{}'...", query); + + let index = RepoIndex::build(&config.known_orgs, &config.auto_discover_paths)?; + let matches = index.search_files(query); + + if matches.is_empty() { + println!("No matches found."); + } else { + println!("\nMatches ({}):", matches.len()); + for (repo, file) in matches.iter().take(50) { + println!(" [{}] {}", repo.name, file); + } + if matches.len() > 50 { + println!(" ... and {} more", matches.len() - 50); + } + } + } + + ReposCommands::Stats => { + let stats = db_tracker.get_index_stats()?; + + println!("\nRepository Index Statistics:"); + println!(" Total repositories: {}", stats.repo_count); + println!(" Total files indexed: {}", stats.file_count); + + if stats.repo_count > 0 { + println!(" Average files per repo: {:.1}", stats.file_count as f64 / stats.repo_count as f64); + } + + if let Some(ref last_indexed) = stats.last_indexed_at { + println!(" Last indexed: {}", last_indexed); + } + + // Also show inference stats if available + if let Ok(inference_stats) = db_tracker.get_inference_stats() { + println!("\n Inference Statistics:"); + println!(" Total attempts: {}", inference_stats.total_attempts); + if inference_stats.total_attempts > 0 { + println!(" With feedback: {}", inference_stats.with_feedback); + if inference_stats.with_feedback > 0 { + println!(" Accuracy: {:.1}%", inference_stats.accuracy * 100.0); + } + } + } + } + + ReposCommands::Add { + name, + path, + github_url, + } => { + println!("\n=== Deprecated ===\n"); + println!("The 'repos add' command is deprecated."); + println!("Repositories are now auto-discovered from known_orgs config."); + println!("\nUse 'claudear repos index' instead."); + println!("\nIf you still want to manually track '{}', add the org to known_orgs", name); + let _ = (path, github_url); // Suppress unused warning + } + + ReposCommands::Link { + upstream, + downstream, + dep_type, + } => { + db_tracker.add_dependency(upstream, downstream, dep_type)?; + println!("Linked: {} depends on {} ({})", downstream, upstream, dep_type); + println!(" Saved to database at: {:?}", config.db_path); + } + + ReposCommands::Graph { root } => { + // Load dependencies from DB + let mut manager = RepoRelationships::with_defaults(); + + // Add DB dependencies to the manager + let db_deps = db_tracker.list_all_dependencies()?; + for dep in db_deps { + let dtype = DependencyType::parse(&dep.dep_type).unwrap_or(DependencyType::Manual); + manager.add_dependency(&dep.upstream, &dep.downstream, dtype, None).ok(); + } + + println!("\n=== Repository Dependency Graph ===\n"); + let tree = manager.print_tree(root.as_deref()); + print!("{}", tree); + + if root.is_none() { + println!("\nLegend: upstream -> downstream (changes in upstream may require updates in downstream)"); + } + } + + ReposCommands::Cascade { repo } => { + // Query DB directly for dependants + let direct = db_tracker.get_direct_dependants(repo)?; + let all = db_tracker.get_all_dependants(repo)?; + + println!("\n=== Cascading Changes from {} ===\n", repo); + + if direct.is_empty() { + println!(" No downstream repositories depend on {}", repo); + } else { + println!(" Changes in {} would trigger updates in:", repo); + for dep in &direct { + println!(" -> {} (VersionBump, {})", dep.downstream, dep.dep_type); + } + + // Show transitive dependants (depth > 1) + let indirect: Vec<_> = all.iter().filter(|(_, depth)| *depth > 1).collect(); + if !indirect.is_empty() { + println!("\n Transitively affected:"); + for (name, depth) in indirect { + println!(" -> {} (indirect, depth {})", name, depth); + } + } + } + } + + ReposCommands::Discover { paths, save, clear } => { + use claudear::DependencyDiscovery; + + // Use provided paths or fall back to config + let scan_paths = if paths.is_empty() { + config.auto_discover_paths.clone() + } else { + paths.clone() + }; + + if scan_paths.is_empty() { + anyhow::bail!( + "No paths to scan. Provide --paths or set auto_discover_paths in config.\n\ + Example: claudear repos discover --paths ~/Local" + ); + } + + println!("\n=== Auto-Discovering Dependencies ===\n"); + println!("Known orgs: {:?}", config.known_orgs); + println!("Scanning: {:?}\n", scan_paths); + + let discovery = DependencyDiscovery::new(config.known_orgs.clone()); + let discovered = discovery.scan_directories(&scan_paths)?; + + if discovered.is_empty() { + println!("No dependencies found from known organizations."); + return Ok(()); + } + + // Group by repo for display + let mut by_repo: std::collections::HashMap> = std::collections::HashMap::new(); + for dep in &discovered { + by_repo.entry(dep.repo.clone()).or_default().push(dep); + } + + println!("Discovered {} dependencies across {} repositories:\n", discovered.len(), by_repo.len()); + for (repo, deps) in &by_repo { + println!(" {}", repo); + for dep in deps { + println!(" -> {} ({})", dep.depends_on, dep.dep_type); + } + } + + if *save { + if *clear { + println!("\nClearing existing dependencies..."); + db_tracker.clear_repositories()?; + } + + println!("\nSaving to database..."); + for dep in &discovered { + // Add repo with its path + db_tracker.upsert_repository(&dep.repo, Some(&dep.repo_path), None)?; + // Add dependency + db_tracker.add_dependency(&dep.depends_on, &dep.repo, &dep.dep_type)?; + } + println!("Saved {} dependencies to {:?}", discovered.len(), config.db_path); + } else { + println!("\nRun with --save to persist to database."); + } + } + + ReposCommands::Sync => { + // Sync command is deprecated - repos and dependencies are now discovered automatically + println!("\n=== Note ===\n"); + println!("The 'repos sync' command is deprecated."); + println!("Repository configuration is now auto-discovered from known_orgs."); + println!("\nUse 'claudear repos discover --save' to discover and save repositories."); + } + } + + return Ok(()); + } + + // Handle Inference commands early + if let Commands::Inference(ref inference_cmd) = cli.command { + let db_tracker = SqliteTracker::new(&config.db_path)?; + + match inference_cmd { + InferenceCommands::Stats => { + let stats = db_tracker.get_inference_stats()?; + + println!("\nInference Statistics:"); + println!(" Total inference attempts: {}", stats.total_attempts); + println!(" Attempts with feedback: {}", stats.with_feedback); + + if stats.with_feedback > 0 { + println!(" Correct inferences: {} ({:.1}%)", + stats.correct, + stats.accuracy * 100.0); + } + + println!("\n By confidence level:"); + println!(" High: {} attempts", stats.by_confidence.high); + println!(" Medium: {} attempts", stats.by_confidence.medium); + println!(" Low: {} attempts", stats.by_confidence.low); + println!(" None: {} attempts", stats.by_confidence.none); + } + + InferenceCommands::History { limit } => { + // TODO: Add a method to fetch inference history from DB + // For now, just show the stats + println!("\nRecent Inference Attempts (last {}):", limit); + println!(" (History listing not yet implemented)"); + println!("\n Use 'claudear inference stats' to see aggregate statistics."); + } + + InferenceCommands::Feedback { id, correct, actual_repo } => { + let actual_repo_id = if let Some(ref repo_name) = actual_repo { + // Look up repo ID by name + if let Some(repo) = db_tracker.get_indexed_repo(repo_name)? { + Some(repo.id) + } else { + anyhow::bail!("Repository '{}' not found in index", repo_name); + } + } else { + None + }; + + db_tracker.record_inference_feedback( + *id, + *correct, + actual_repo_id, + "manual", + )?; + + println!("\nFeedback recorded for inference attempt {}:", id); + println!(" Correct: {}", correct); + if let Some(ref repo_name) = actual_repo { + println!(" Actual repo: {}", repo_name); + } + } + } + + return Ok(()); + } + + config.validate()?; + + // Initialize components + tracing::info!("Initializing..."); + let notifier = create_notifier(&config); + let tracker = create_tracker(&config); + + // Handle Start command (daemon mode with IPC - runs all services concurrently) + if let Commands::Start { + port, + poll, + poll_interval, + no_webhooks, + no_dashboard, + } = &cli.command + { + if is_daemon_running() { + anyhow::bail!("A daemon is already running. Stop it first with 'claudear stop'"); + } + + let sources = create_sources(&config); + if sources.is_empty() { + anyhow::bail!("No sources were initialized"); + } + + // Determine what services to run + let enable_webhooks = !no_webhooks; + let enable_dashboard = !no_dashboard; + let enable_polling = *poll; + + if !enable_webhooks && !enable_dashboard && !enable_polling { + anyhow::bail!("No services enabled. Remove --no-webhooks/--no-dashboard or add --poll"); + } + + // Build mode string for status + let mut modes = Vec::new(); + if enable_dashboard { + modes.push("dashboard"); + } + if enable_webhooks { + modes.push("webhooks"); + } + if enable_polling { + modes.push("polling"); + } + let mode_str = modes.join("+"); + + // Build repository inferrer for issue-to-repo mapping + let inferrer = Watcher::build_inferrer(&config)?; + if inferrer.is_some() { + tracing::info!("Repository inference enabled"); + } + + // Create watcher if polling is enabled + let watcher = if enable_polling { + Some(Arc::new(Watcher::new(WatcherOptions { + config: config.clone(), + sources: sources.clone(), + notifier: notifier.clone(), + tracker: tracker.clone(), + inferrer: inferrer.clone(), + dry_run: false, + }))) + } else { + None + }; + + // Create IPC server + let ipc_server = Arc::new(if let Some(ref w) = watcher { + IpcServer::new(tracker.clone(), sources.clone(), notifier.clone()) + .with_watcher(w.clone()) + } else { + IpcServer::new(tracker.clone(), sources.clone(), notifier.clone()) + }); + + ipc_server.set_mode(&mode_str).await; + if enable_polling { + ipc_server.set_poll_interval(*poll_interval); + } + + // Log watcher_started activity + let activity = ActivityLogEntry::new( + "watcher_started", + format!("Watcher daemon started in {} mode", mode_str), + ) + .with_source("system".to_string()) + .with_metadata(json!({ + "mode": mode_str, + "port": port, + "sources": sources.iter().map(|s| s.name()).collect::>() + })); + tracker.record_activity(&activity).ok(); + + // Log startup info + tracing::info!("Starting watcher daemon..."); + tracing::info!(" Mode: {}", mode_str); + tracing::info!(" Port: {}", port); + tracing::info!(" Socket: {:?}", default_socket_path()); + tracing::info!( + " Sources: {}", + sources + .iter() + .map(|s| s.name()) + .collect::>() + .join(", ") + ); + if enable_polling { + tracing::info!(" Poll interval: {}ms", poll_interval); + } + + // Shutdown signal handler with graceful drain + let watcher_for_shutdown = watcher.clone(); + let tracker_for_shutdown = tracker.clone(); + let mut shutdown_rx = ipc_server.shutdown_receiver(); + let shutdown = async move { + tokio::select! { + _ = tokio::signal::ctrl_c() => { + tracing::info!("\nReceived shutdown signal, initiating graceful shutdown..."); + } + _ = shutdown_rx.recv() => { + tracing::info!("\nReceived IPC shutdown command, initiating graceful shutdown..."); + } + } + if let Some(w) = watcher_for_shutdown { + // Use stop_and_drain for graceful shutdown that waits for active tasks + w.stop_and_drain().await; + } + + // Log watcher_stopped activity + let activity = ActivityLogEntry::new("watcher_stopped", "Watcher daemon stopped") + .with_source("system".to_string()); + tracker_for_shutdown.record_activity(&activity).ok(); + }; + + // Build the unified HTTP server with dashboard API + webhooks + let mut config = config.clone(); + config.webhook_port = *port; + + // Start all services concurrently + let ipc_future = ipc_server.start(); + + // Dashboard + Webhooks can share the same axum server + // For now, we'll run them on the same port with webhooks taking precedence + let inferrer_clone = inferrer.clone(); + let http_future = async move { + if enable_webhooks { + let handlers = create_webhook_handlers(&config); + if handlers.get_all().is_empty() && !enable_dashboard { + return Err(anyhow::anyhow!("No webhook handlers configured")); + } + + // Webhook server also serves health endpoint which dashboard uses + let server = + WebhookServer::new(config.clone(), handlers, notifier.clone(), tracker.clone(), inferrer_clone); + server.start().await?; + } else if enable_dashboard { + // Dashboard only (no webhooks) + let server = ApiServer::with_port(config.clone(), tracker.clone(), *port); + server.start().await?; + } + Ok::<(), anyhow::Error>(()) + }; + + let poll_future = async { + if let Some(w) = watcher { + w.start(Some(*poll_interval)).await?; + } else { + // Just wait forever if no polling + std::future::pending::<()>().await; + } + Ok::<(), anyhow::Error>(()) + }; + + // Run everything concurrently + tokio::select! { + result = ipc_future => { + if let Err(e) = result { + tracing::error!("IPC server error: {}", e); + } + } + result = http_future => { + if let Err(e) = result { + tracing::error!("HTTP server error: {}", e); + } + } + result = poll_future => { + if let Err(e) = result { + tracing::error!("Polling error: {}", e); + } + } + _ = shutdown => {} + } + + return Ok(()); + } + + // Handle PR commands early since they don't need sources + if let Commands::Prs(PrsCommands::Monitor { continuous }) = cli.command { + if !config.is_github_enabled() { + anyhow::bail!("GitHub token not configured. Set GITHUB_TOKEN environment variable."); + } + + let github_client = GitHubClient::new(config.github.clone()); + let sources = create_sources(&config); + let pr_monitor = PrMonitor::new( + github_client, + tracker.clone(), + config.github.auto_resolve_on_merge, + ); + + if continuous { + tracing::info!("Starting PR monitor (continuous mode)..."); + tracing::info!(" Poll interval: {}ms", config.github.poll_interval_ms); + tracing::info!( + " Auto-resolve on merge: {}", + config.github.auto_resolve_on_merge + ); + + let mut poll_timer = interval(Duration::from_millis(config.github.poll_interval_ms)); + + let shutdown = async { + tokio::signal::ctrl_c() + .await + .expect("Failed to install signal handler"); + tracing::info!("\nReceived shutdown signal..."); + }; + + tokio::select! { + _ = async { + loop { + poll_timer.tick().await; + match pr_monitor.check_pending_prs().await { + Ok(updates) => { + for update in updates { + match update.new_status { + PrStatus::Merged => { + tracing::info!(component = "pr_monitor", short_id = %update.short_id, pr_url = %update.pr_url, "PR merged"); + + // Auto-resolve on source if enabled + if update.should_resolve { + if let Some(source) = sources.iter().find(|s| s.name() == update.source) { + if let Err(e) = source.resolve_issue(&update.issue_id).await { + tracing::warn!(component = "pr_monitor", source = %update.source, error = %e, "Failed to resolve issue"); + } else { + tracker.mark_resolved(&update.source, &update.issue_id).ok(); + notifier.notify_merged(&claudear::types::Issue::new( + &update.issue_id, + &update.short_id, + "Issue resolved", + &update.pr_url, + &update.source, + ), &update.pr_url).await.ok(); + } + } + } + } + PrStatus::Closed => { + tracing::info!(component = "pr_monitor", short_id = %update.short_id, pr_url = %update.pr_url, "PR closed without merge"); + } + PrStatus::Open => {} + } + } + } + Err(e) => { + tracing::error!(component = "pr_monitor", error = %e, "Error checking PRs"); + } + } + } + } => {} + _ = shutdown => {} + } + } else { + // One-shot check + tracing::info!("Checking pending PRs..."); + let updates = pr_monitor.check_pending_prs().await?; + + if updates.is_empty() { + println!("\nNo PR status changes detected."); + } else { + println!("\nPR Status Updates:"); + for update in updates { + let status = match update.new_status { + PrStatus::Merged => "MERGED", + PrStatus::Closed => "CLOSED", + PrStatus::Open => "OPEN", + }; + println!(" [{}] {} - {}", status, update.short_id, update.pr_url); + + // Auto-resolve on source if merged + if update.should_resolve { + if let Some(source) = sources.iter().find(|s| s.name() == update.source) { + if let Err(e) = source.resolve_issue(&update.issue_id).await { + tracing::warn!( + "Failed to resolve issue on {}: {}", + update.source, + e + ); + } else { + tracker.mark_resolved(&update.source, &update.issue_id).ok(); + println!(" -> Issue resolved on {}", update.source); + } + } + } + } + } + } + return Ok(()); + } + + if let Commands::Prs(PrsCommands::List) = cli.command { + let pending_prs = tracker.get_pending_prs()?; + let merged = tracker.get_attempts_by_status(FixAttemptStatus::Merged)?; + let closed = tracker.get_attempts_by_status(FixAttemptStatus::Closed)?; + + println!("\n=== Pending PRs (awaiting merge) ==="); + if pending_prs.is_empty() { + println!(" No pending PRs"); + } else { + for attempt in &pending_prs { + println!( + " [{}] {} - {}", + attempt.source, + attempt.short_id, + attempt.pr_url.as_deref().unwrap_or("N/A") + ); + } + } + + println!("\n=== Merged PRs ==="); + if merged.is_empty() { + println!(" No merged PRs"); + } else { + for attempt in merged.iter().take(10) { + let resolved = if attempt.resolved_at.is_some() { + " (resolved)" + } else { + "" + }; + println!( + " [{}] {}{} - {}", + attempt.source, + attempt.short_id, + resolved, + attempt.pr_url.as_deref().unwrap_or("N/A") + ); + } + if merged.len() > 10 { + println!(" ... and {} more", merged.len() - 10); + } + } + + println!("\n=== Closed PRs (not merged) ==="); + if closed.is_empty() { + println!(" No closed PRs"); + } else { + for attempt in closed.iter().take(10) { + println!( + " [{}] {} - {}", + attempt.source, + attempt.short_id, + attempt.pr_url.as_deref().unwrap_or("N/A") + ); + } + if closed.len() > 10 { + println!(" ... and {} more", closed.len() - 10); + } + } + + return Ok(()); + } + + if let Commands::Retries(RetriesCommands::List) = cli.command { + let retry_manager = RetryManager::new(config.retry.clone(), tracker.clone()); + let retryable = tracker.get_retryable_issues(config.retry.max_retries)?; + let ready = retry_manager.get_ready_retries()?; + + println!( + "\n=== Retryable Issues (max_retries: {}) ===", + config.retry.max_retries + ); + if retryable.is_empty() { + println!(" No issues eligible for retry"); + } else { + for attempt in &retryable { + let next_retry = retry_manager.get_next_retry_time(attempt); + let ready_str = if ready.iter().any(|r| r.id == attempt.id) { + " [READY]" + } else { + "" + }; + let next_time = next_retry + .map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_else(|| "N/A".to_string()); + + println!( + " [{}] {} - retry {}/{} - next: {}{}", + attempt.source, + attempt.short_id, + attempt.retry_count, + config.retry.max_retries, + next_time, + ready_str + ); + if let Some(ref error) = attempt.error_message { + let truncated = if error.len() > 60 { + format!("{}...", &error[..60]) + } else { + error.clone() + }; + println!(" Error: {}", truncated); + } + } + } + + println!("\n=== Ready for Retry Now ==="); + if ready.is_empty() { + println!(" No issues ready for retry"); + } else { + println!(" {} issues ready", ready.len()); + for attempt in &ready { + println!(" - [{}] {}", attempt.source, attempt.short_id); + } + } + + return Ok(()); + } + + if let Commands::Retries(RetriesCommands::Process) = cli.command { + let retry_manager = RetryManager::new(config.retry.clone(), tracker.clone()); + let ready = retry_manager.get_ready_retries()?; + + if ready.is_empty() { + println!("\nNo issues ready for retry."); + return Ok(()); + } + + println!("\nProcessing {} retries...", ready.len()); + + // Need sources and watcher for processing + let sources = create_sources(&config); + if sources.is_empty() { + anyhow::bail!("No sources were initialized"); + } + + // Build inferrer for retry processing + let inferrer = Watcher::build_inferrer(&config)?; + + let watcher = Watcher::new(WatcherOptions { + config: config.clone(), + sources, + notifier, + tracker: tracker.clone(), + inferrer, + dry_run: false, + }); + + for attempt in ready { + println!("\n Retrying [{}] {}...", attempt.source, attempt.short_id); + + // Prepare for retry + retry_manager.prepare_retry(&attempt.source, &attempt.issue_id)?; + + // Trigger the fix + if let Err(e) = watcher + .trigger_issue(&attempt.source, &attempt.issue_id) + .await + { + tracing::error!("Failed to retry {}: {}", attempt.short_id, e); + } + } + + println!("\nRetry processing complete."); + return Ok(()); + } + + if let Commands::Dashboard { + port, + dashboard_dir, + } = cli.command + { + let server = if let Some(dir) = dashboard_dir { + ApiServer::with_dashboard(config, tracker, port, dir) + } else { + ApiServer::with_port(config, tracker, port) + }; + + // Handle shutdown signals + let shutdown = async { + tokio::signal::ctrl_c() + .await + .expect("Failed to install signal handler"); + tracing::info!("\nReceived shutdown signal..."); + }; + + tokio::select! { + result = server.start() => result?, + _ = shutdown => {} + } + + return Ok(()); + } + + if let Commands::Report(ReportCommands::Preview { frequency }) = cli.command { + let freq = ReportFrequency::parse(&frequency).ok_or_else(|| { + anyhow::anyhow!( + "Invalid frequency: {}. Use daily, weekly, or monthly", + frequency + ) + })?; + + let generator = ReportGenerator::new(tracker); + let report = match freq { + ReportFrequency::Daily => generator.generate_daily()?, + ReportFrequency::Weekly(_) => generator.generate_weekly()?, + ReportFrequency::Monthly => generator.generate_monthly()?, + }; + + println!("{}", report.format_text()); + return Ok(()); + } + + if let Commands::Report(ReportCommands::Send { frequency }) = cli.command { + let freq = ReportFrequency::parse(&frequency).ok_or_else(|| { + anyhow::anyhow!( + "Invalid frequency: {}. Use daily, weekly, or monthly", + frequency + ) + })?; + + let scheduler = ReportScheduler::new(tracker, notifier); + let report = scheduler.send_now(freq).await?; + + println!("Report sent successfully!"); + println!("{}", report.format_text()); + return Ok(()); + } + + if let Commands::Report(ReportCommands::Schedule { + daily, + weekly, + hour, + }) = cli.command + { + let mut scheduler = ReportScheduler::new(tracker, notifier); + + if daily { + scheduler.add_schedule(ReportSchedule::daily("daily-report", hour)); + tracing::info!("Daily reports scheduled at {:02}:00 UTC", hour); + } + + if weekly { + scheduler.add_schedule(ReportSchedule::weekly( + "weekly-report", + chrono::Weekday::Mon, + hour, + )); + tracing::info!("Weekly reports scheduled for Monday at {:02}:00 UTC", hour); + } + + if scheduler.schedules().is_empty() { + anyhow::bail!("No report schedules enabled. Use --daily or --weekly"); + } + + tracing::info!("Report scheduler started..."); + + // Run scheduler every hour to check for due reports + let mut check_timer = interval(Duration::from_secs(3600)); + + let shutdown = async { + tokio::signal::ctrl_c() + .await + .expect("Failed to install signal handler"); + tracing::info!("\nReceived shutdown signal..."); + }; + + tokio::select! { + _ = async { + loop { + check_timer.tick().await; + match scheduler.check_and_send().await { + Ok(sent) => { + for name in sent { + tracing::info!("Sent report: {}", name); + } + } + Err(e) => { + tracing::error!("Error checking schedules: {}", e); + } + } + } + } => {} + _ = shutdown => {} + } + + return Ok(()); + } + + match cli.command { + Commands::Webhook { + port, + setup_webhooks, + base_url, + env_file, + } => { + let mut config = config; + config.webhook_port = port; + + // Auto-configure webhooks if requested + if setup_webhooks { + let base_url = base_url.ok_or_else(|| { + anyhow::anyhow!( + "--base-url is required with --setup-webhooks. \ + Example: --base-url https://my-server.example.com:3100" + ) + })?; + + let env_path = std::path::PathBuf::from(&env_file); + let configurator = WebhookConfigurator::new(config.clone(), &env_path); + + match configurator.configure(&base_url).await { + Ok(result) => { + print_setup_result(&result); + + // Reload config to get the new secrets from env vars + // (webhook secrets are stored in env file, which overrides YAML config) + tracing::info!("Reloading configuration with new secrets..."); + config = Config::load(&config_path)?; + config.webhook_port = port; + } + Err(e) => { + anyhow::bail!("Webhook auto-configuration failed: {}", e); + } + } + } + + let handlers = create_webhook_handlers(&config); + + if handlers.get_all().is_empty() { + anyhow::bail!("No webhook handlers were registered"); + } + + // Build inferrer for repo inference + let inferrer = WebhookServer::build_inferrer(&config)?; + + let server = WebhookServer::new(config, handlers, notifier, tracker, inferrer); + + // Handle shutdown signals + let shutdown = async { + tokio::signal::ctrl_c() + .await + .expect("Failed to install signal handler"); + tracing::info!("\nReceived shutdown signal..."); + }; + + tokio::select! { + result = server.start() => result?, + _ = shutdown => {} + } + } + + _ => { + // Initialize sources for polling/seed/dry-run modes + tracing::info!("Initializing sources..."); + let sources = create_sources(&config); + + if sources.is_empty() { + anyhow::bail!("No sources were initialized"); + } + + let dry_run = matches!(cli.command, Commands::DryRun); + + // Build inferrer for repo inference + let inferrer = Watcher::build_inferrer(&config)?; + + let watcher = Watcher::new(WatcherOptions { + config: config.clone(), + sources, + notifier, + tracker, + inferrer, + dry_run, + }); + + // Handle shutdown signals + let watcher_ref = &watcher; + + match cli.command { + Commands::Seed => { + watcher.seed().await?; + } + + Commands::DryRun => { + let shutdown = async { + tokio::signal::ctrl_c() + .await + .expect("Failed to install signal handler"); + tracing::info!("\nReceived shutdown signal..."); + watcher_ref.stop(); + }; + + tokio::select! { + result = watcher.start(None) => result?, + _ = shutdown => {} + } + } + + Commands::Poll { interval } => { + let shutdown = async { + tokio::signal::ctrl_c() + .await + .expect("Failed to install signal handler"); + tracing::info!("\nReceived shutdown signal..."); + watcher_ref.stop(); + }; + + tokio::select! { + result = watcher.start(Some(interval)) => result?, + _ = shutdown => {} + } + } + + Commands::Trigger { source, issue_id } => { + watcher.trigger_issue(&source, &issue_id).await?; + } + + Commands::Reset { source, issue_id } => { + watcher.reset_attempt(&source, &issue_id)?; + } + + Commands::Stats => { + let stats = watcher.get_stats()?; + println!("\nFix Attempt Statistics:"); + println!(" Total: {}", stats.total); + println!(" Pending: {}", stats.pending); + println!(" Success: {} (PRs created)", stats.success); + println!( + " Merged: {} (PRs merged, issues resolved)", + stats.merged + ); + println!(" Closed: {} (PRs closed without merge)", stats.closed); + println!(" Failed: {}", stats.failed); + println!(" Cannot Fix: {} (max retries reached)", stats.cannot_fix); + + // Calculate success rate + let completed = stats.merged + stats.closed + stats.failed + stats.cannot_fix; + if completed > 0 { + let merge_rate = (stats.merged as f64 / completed as f64) * 100.0; + println!("\n Merge Rate: {:.1}%", merge_rate); + } + + if !stats.by_source.is_empty() { + println!("\nBy Source:"); + for (source, source_stats) in &stats.by_source { + println!(" {}:", source); + println!( + " Total: {}, Success: {}, Merged: {}, Closed: {}, Failed: {}, Cannot Fix: {}", + source_stats.total, source_stats.success, source_stats.merged, + source_stats.closed, source_stats.failed, source_stats.cannot_fix + ); + } + } + } + + Commands::Start { .. } + | Commands::Stop + | Commands::Status + | Commands::Pause + | Commands::Resume + | Commands::Activity { .. } + | Commands::Sources + | Commands::Webhook { .. } + | Commands::Prs(_) + | Commands::Retries(_) + | Commands::Dashboard { .. } + | Commands::Report(_) + | Commands::Repos(_) + | Commands::Inference(_) => unreachable!(), + } + } + } + + Ok(()) +} diff --git a/src/notifier/console.rs b/src/notifier/console.rs new file mode 100644 index 00000000..e7bcc6f3 --- /dev/null +++ b/src/notifier/console.rs @@ -0,0 +1,250 @@ +//! Console notifier for local development/debugging. + +use super::Notifier; +use crate::error::Result; +use crate::types::Issue; +use async_trait::async_trait; + +/// Console notifier that prints to stdout. +pub struct ConsoleNotifier; + +impl ConsoleNotifier { + pub fn new() -> Self { + Self + } +} + +impl Default for ConsoleNotifier { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Notifier for ConsoleNotifier { + fn name(&self) -> &str { + "console" + } + + fn is_enabled(&self) -> bool { + true + } + + async fn notify_start(&self, issue: &Issue) -> Result<()> { + println!( + "\n[{}] Processing: {} - {}", + issue.source, issue.short_id, issue.title + ); + Ok(()) + } + + async fn notify_success(&self, issue: &Issue, pr_url: &str) -> Result<()> { + println!( + "[{}] Success: {} - PR: {}", + issue.source, issue.short_id, pr_url + ); + Ok(()) + } + + async fn notify_completed(&self, issue: &Issue) -> Result<()> { + println!( + "[{}] Completed: {} (no PR URL found)", + issue.source, issue.short_id + ); + Ok(()) + } + + async fn notify_failed(&self, issue: &Issue, error: &str) -> Result<()> { + eprintln!("[{}] Failed: {} - {}", issue.source, issue.short_id, error); + Ok(()) + } + + async fn notify_status(&self, message: &str) -> Result<()> { + println!("{}", message); + Ok(()) + } + + async fn notify_urgent_issues(&self, issues: &[Issue]) -> Result<()> { + println!("\n{} urgent issue(s) detected:", issues.len()); + for issue in issues.iter().take(10) { + println!( + " - [{}] {}: {}", + issue.source, issue.short_id, issue.title + ); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new() { + let notifier = ConsoleNotifier::new(); + assert_eq!(notifier.name(), "console"); + } + + #[test] + fn test_default() { + let notifier = ConsoleNotifier; + assert_eq!(notifier.name(), "console"); + } + + #[test] + fn test_name() { + let notifier = ConsoleNotifier::new(); + assert_eq!(notifier.name(), "console"); + } + + #[test] + fn test_is_enabled_always_true() { + let notifier = ConsoleNotifier::new(); + assert!(notifier.is_enabled()); + } + + #[tokio::test] + async fn test_notify_start() { + let notifier = ConsoleNotifier::new(); + let issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://example.com", + "linear", + ); + + // Should not panic and return Ok + let result = notifier.notify_start(&issue).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_success() { + let notifier = ConsoleNotifier::new(); + let issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://example.com", + "linear", + ); + + let result = notifier + .notify_success(&issue, "https://github.com/org/repo/pull/1") + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_completed() { + let notifier = ConsoleNotifier::new(); + let issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://example.com", + "linear", + ); + + let result = notifier.notify_completed(&issue).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_failed() { + let notifier = ConsoleNotifier::new(); + let issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://example.com", + "linear", + ); + + let result = notifier.notify_failed(&issue, "Test error message").await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_status() { + let notifier = ConsoleNotifier::new(); + + let result = notifier.notify_status("Test status message").await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_urgent_issues_empty() { + let notifier = ConsoleNotifier::new(); + + let result = notifier.notify_urgent_issues(&[]).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_urgent_issues_single() { + let notifier = ConsoleNotifier::new(); + let issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://example.com", + "linear", + ); + + let result = notifier.notify_urgent_issues(&[issue]).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_urgent_issues_multiple() { + let notifier = ConsoleNotifier::new(); + let issues: Vec = (0..5) + .map(|i| { + Issue::new( + format!("{}", i), + format!("PROJ-{}", i), + format!("Issue {}", i), + "https://example.com", + "linear", + ) + }) + .collect(); + + let result = notifier.notify_urgent_issues(&issues).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_urgent_issues_truncated() { + let notifier = ConsoleNotifier::new(); + // More than 10 issues + let issues: Vec = (0..15) + .map(|i| { + Issue::new( + format!("{}", i), + format!("PROJ-{}", i), + format!("Issue {}", i), + "https://example.com", + "sentry", + ) + }) + .collect(); + + let result = notifier.notify_urgent_issues(&issues).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_start_different_sources() { + let notifier = ConsoleNotifier::new(); + + for source in ["linear", "sentry", "github", "jira"] { + let issue = Issue::new("123", "PROJ-123", "Test", "https://example.com", source); + let result = notifier.notify_start(&issue).await; + assert!(result.is_ok()); + } + } +} diff --git a/src/notifier/discord.rs b/src/notifier/discord.rs new file mode 100644 index 00000000..7b89e7e7 --- /dev/null +++ b/src/notifier/discord.rs @@ -0,0 +1,1109 @@ +//! Discord webhook notifier. + +use super::Notifier; +use crate::config::DiscordConfig; +use crate::error::{Error, Result}; +use crate::types::Issue; +use async_trait::async_trait; +use serde::Serialize; + +/// HTTP response for Discord webhook client. +pub struct HttpResponse { + pub status: u16, + pub body: String, +} + +/// Trait for HTTP client used by Discord notifier. +#[async_trait] +pub trait DiscordWebhookClient: Send + Sync { + async fn post_json(&self, url: &str, body: &serde_json::Value) -> Result; +} + +/// Real HTTP client using reqwest. +pub struct ReqwestDiscordWebhookClient { + client: reqwest::Client, +} + +impl ReqwestDiscordWebhookClient { + pub fn new() -> Self { + Self { + client: reqwest::Client::new(), + } + } +} + +impl Default for ReqwestDiscordWebhookClient { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl DiscordWebhookClient for ReqwestDiscordWebhookClient { + async fn post_json(&self, url: &str, body: &serde_json::Value) -> Result { + let response = self.client.post(url).json(body).send().await?; + + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + + Ok(HttpResponse { status, body }) + } +} + +/// Discord webhook notifier. +pub struct DiscordNotifier { + config: DiscordConfig, + http: H, +} + +#[derive(Debug, Serialize)] +struct DiscordMessage { + #[serde(skip_serializing_if = "Option::is_none")] + content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + embeds: Option>, +} + +#[derive(Debug, Serialize)] +struct DiscordEmbed { + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + fields: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + footer: Option, + #[serde(skip_serializing_if = "Option::is_none")] + timestamp: Option, +} + +#[derive(Debug, Serialize)] +struct DiscordField { + name: String, + value: String, + #[serde(skip_serializing_if = "Option::is_none")] + inline: Option, +} + +#[derive(Debug, Serialize)] +struct DiscordFooter { + text: String, +} + +impl DiscordNotifier { + pub fn new(config: DiscordConfig) -> Self { + Self { + config, + http: ReqwestDiscordWebhookClient::new(), + } + } +} + +/// Maximum lengths for user-controlled fields to prevent unbounded memory allocation. +const MAX_SHORT_ID_LENGTH: usize = 64; +const MAX_SOURCE_LENGTH: usize = 32; +const MAX_URL_LENGTH: usize = 2000; +const MAX_DESCRIPTION_LENGTH: usize = 2048; + +/// Truncate a string to the specified maximum length, adding "..." if truncated. +fn truncate_string(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else if max_len > 3 { + format!("{}...", &s[..max_len - 3]) + } else { + s[..max_len].to_string() + } +} + +impl DiscordNotifier { + /// Create a new Discord notifier with a custom HTTP client. + pub fn with_http_client(config: DiscordConfig, http: H) -> Self { + Self { config, http } + } + + async fn send(&self, message: DiscordMessage) -> Result<()> { + let webhook_url = match &self.config.webhook_url { + Some(url) => url, + None => return Ok(()), + }; + + let body = serde_json::to_value(&message)?; + let response = self.http.post_json(webhook_url, &body).await?; + + if response.status < 200 || response.status >= 300 { + return Err(Error::notifier( + "discord", + format!("Webhook error: {}", response.body), + )); + } + + Ok(()) + } + + fn get_user_mention(&self) -> Option { + self.config.user_id.as_ref().map(|id| format!("<@{}>", id)) + } + + fn get_source_emoji(source: &str) -> &'static str { + match source.to_lowercase().as_str() { + "linear" => "\u{1F4CB}", // clipboard + "sentry" => "\u{1F534}", // red circle + "github" => "\u{1F419}", // octopus + "jira" => "\u{1F3AB}", // ticket + _ => "\u{1F4CC}", // pushpin + } + } + + fn timestamp() -> String { + chrono::Utc::now().to_rfc3339() + } +} + +#[async_trait] +impl Notifier for DiscordNotifier { + fn name(&self) -> &str { + "discord" + } + + fn is_enabled(&self) -> bool { + self.config.webhook_url.is_some() + } + + async fn notify_start(&self, issue: &Issue) -> Result<()> { + let mention = self.get_user_mention(); + let emoji = Self::get_source_emoji(&issue.source); + let short_id = truncate_string(&issue.short_id, MAX_SHORT_ID_LENGTH); + let title = truncate_string(&issue.title, MAX_DESCRIPTION_LENGTH); + let url = truncate_string(&issue.url, MAX_URL_LENGTH); + let source = truncate_string(&issue.source, MAX_SOURCE_LENGTH); + + self.send(DiscordMessage { + content: mention.map(|m| format!("{} Processing issue...", m)), + embeds: Some(vec![DiscordEmbed { + title: Some(format!("{} Processing: {}", emoji, short_id)), + description: Some(title), + url: Some(url), + color: Some(0x3498db), // Blue + fields: Some(vec![ + DiscordField { + name: "Source".to_string(), + value: source, + inline: Some(true), + }, + DiscordField { + name: "Priority".to_string(), + value: issue.priority.to_string(), + inline: Some(true), + }, + DiscordField { + name: "Status".to_string(), + value: issue.status.to_string(), + inline: Some(true), + }, + ]), + footer: Some(DiscordFooter { + text: "Claude Watchers".to_string(), + }), + timestamp: Some(Self::timestamp()), + }]), + }) + .await + } + + async fn notify_success(&self, issue: &Issue, pr_url: &str) -> Result<()> { + let mention = self.get_user_mention(); + let emoji = Self::get_source_emoji(&issue.source); + let short_id = truncate_string(&issue.short_id, MAX_SHORT_ID_LENGTH); + let title = truncate_string(&issue.title, MAX_DESCRIPTION_LENGTH); + let issue_url = truncate_string(&issue.url, MAX_URL_LENGTH); + let pr_url_truncated = truncate_string(pr_url, MAX_URL_LENGTH); + let source = truncate_string(&issue.source, MAX_SOURCE_LENGTH); + + self.send(DiscordMessage { + content: mention.map(|m| format!("{} PR created!", m)), + embeds: Some(vec![DiscordEmbed { + title: Some(format!("\u{2705} PR Created: {}", short_id)), + description: Some(title), + url: Some(pr_url_truncated.clone()), + color: Some(0x2ecc71), // Green + fields: Some(vec![ + DiscordField { + name: "Source".to_string(), + value: format!("{} {}", emoji, source), + inline: Some(true), + }, + DiscordField { + name: "Issue".to_string(), + value: format!("[{}]({})", short_id, issue_url), + inline: Some(true), + }, + DiscordField { + name: "PR Link".to_string(), + value: format!("[View PR]({})", pr_url_truncated), + inline: Some(false), + }, + ]), + footer: Some(DiscordFooter { + text: "Claude Watchers".to_string(), + }), + timestamp: Some(Self::timestamp()), + }]), + }) + .await + } + + async fn notify_completed(&self, issue: &Issue) -> Result<()> { + let mention = self.get_user_mention(); + let emoji = Self::get_source_emoji(&issue.source); + let short_id = truncate_string(&issue.short_id, MAX_SHORT_ID_LENGTH); + let title = truncate_string(&issue.title, MAX_DESCRIPTION_LENGTH); + let url = truncate_string(&issue.url, MAX_URL_LENGTH); + let source = truncate_string(&issue.source, MAX_SOURCE_LENGTH); + + self.send(DiscordMessage { + content: mention.map(|m| format!("{} Issue processed (no PR URL found)", m)), + embeds: Some(vec![DiscordEmbed { + title: Some(format!("\u{2714}\u{FE0F} Completed: {}", short_id)), + description: Some(title), + url: Some(url), + color: Some(0x9b59b6), // Purple + fields: Some(vec![ + DiscordField { + name: "Source".to_string(), + value: format!("{} {}", emoji, source), + inline: Some(true), + }, + DiscordField { + name: "Note".to_string(), + value: "Claude completed but no PR URL was captured".to_string(), + inline: Some(false), + }, + ]), + footer: Some(DiscordFooter { + text: "Claude Watchers".to_string(), + }), + timestamp: Some(Self::timestamp()), + }]), + }) + .await + } + + async fn notify_failed(&self, issue: &Issue, error: &str) -> Result<()> { + let mention = self.get_user_mention(); + let emoji = Self::get_source_emoji(&issue.source); + let short_id = truncate_string(&issue.short_id, MAX_SHORT_ID_LENGTH); + let title = truncate_string(&issue.title, MAX_DESCRIPTION_LENGTH); + let url = truncate_string(&issue.url, MAX_URL_LENGTH); + let source = truncate_string(&issue.source, MAX_SOURCE_LENGTH); + + // Truncate error message if too long + let error_display = truncate_string(error, 1000); + + self.send(DiscordMessage { + content: mention.map(|m| format!("{} Fix attempt failed", m)), + embeds: Some(vec![DiscordEmbed { + title: Some(format!("\u{274C} Failed: {}", short_id)), + description: Some(title), + url: Some(url), + color: Some(0xe74c3c), // Red + fields: Some(vec![ + DiscordField { + name: "Source".to_string(), + value: format!("{} {}", emoji, source), + inline: Some(true), + }, + DiscordField { + name: "Error".to_string(), + value: error_display, + inline: Some(false), + }, + ]), + footer: Some(DiscordFooter { + text: "Claude Watchers".to_string(), + }), + timestamp: Some(Self::timestamp()), + }]), + }) + .await + } + + async fn notify_status(&self, message: &str) -> Result<()> { + let message_truncated = truncate_string(message, MAX_DESCRIPTION_LENGTH); + + self.send(DiscordMessage { + content: None, + embeds: Some(vec![DiscordEmbed { + title: None, + description: Some(message_truncated), + url: None, + color: Some(0x9b59b6), // Purple + fields: None, + footer: Some(DiscordFooter { + text: "Claude Watchers".to_string(), + }), + timestamp: Some(Self::timestamp()), + }]), + }) + .await + } + + async fn notify_urgent_issues(&self, issues: &[Issue]) -> Result<()> { + if issues.is_empty() { + return Ok(()); + } + + let mention = self.get_user_mention(); + + let fields: Vec = issues + .iter() + .take(10) + .map(|issue| { + let emoji = Self::get_source_emoji(&issue.source); + let short_id = truncate_string(&issue.short_id, MAX_SHORT_ID_LENGTH); + let title = truncate_string(&issue.title, 50); + let url = truncate_string(&issue.url, MAX_URL_LENGTH); + DiscordField { + name: format!("{} {}", emoji, short_id), + value: format!("[{}]({})", title, url), + inline: Some(true), + } + }) + .collect(); + + self.send(DiscordMessage { + content: mention.map(|m| format!("{} Urgent issues detected!", m)), + embeds: Some(vec![DiscordEmbed { + title: Some(format!( + "\u{1F6A8} {} Urgent Issue{} Detected", + issues.len(), + if issues.len() > 1 { "s" } else { "" } + )), + description: Some("The following issues require immediate attention:".to_string()), + url: None, + color: Some(0xf39c12), // Orange + fields: Some(fields), + footer: Some(DiscordFooter { + text: "Claude Watchers".to_string(), + }), + timestamp: Some(Self::timestamp()), + }]), + }) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_source_emoji() { + type TestNotifier = DiscordNotifier; + assert_eq!(TestNotifier::get_source_emoji("linear"), "\u{1F4CB}"); + assert_eq!(TestNotifier::get_source_emoji("sentry"), "\u{1F534}"); + assert_eq!(TestNotifier::get_source_emoji("github"), "\u{1F419}"); + assert_eq!(TestNotifier::get_source_emoji("unknown"), "\u{1F4CC}"); + } + + #[test] + fn test_user_mention() { + let config_with_id = DiscordConfig { + webhook_url: Some("https://example.com".to_string()), + user_id: Some("123456".to_string()), + }; + let notifier = DiscordNotifier::new(config_with_id); + assert_eq!(notifier.get_user_mention(), Some("<@123456>".to_string())); + + let config_without_id = DiscordConfig { + webhook_url: Some("https://example.com".to_string()), + user_id: None, + }; + let notifier = DiscordNotifier::new(config_without_id); + assert_eq!(notifier.get_user_mention(), None); + } + + #[test] + fn test_is_enabled() { + let enabled_config = DiscordConfig { + webhook_url: Some("https://example.com".to_string()), + user_id: None, + }; + let notifier = DiscordNotifier::new(enabled_config); + assert!(notifier.is_enabled()); + + let disabled_config = DiscordConfig { + webhook_url: None, + user_id: None, + }; + let notifier = DiscordNotifier::new(disabled_config); + assert!(!notifier.is_enabled()); + } + + #[test] + fn test_notifier_name() { + let config = DiscordConfig::default(); + let notifier = DiscordNotifier::new(config); + assert_eq!(notifier.name(), "discord"); + } + + #[test] + fn test_source_emoji_case_insensitive() { + type TestNotifier = DiscordNotifier; + assert_eq!(TestNotifier::get_source_emoji("LINEAR"), "\u{1F4CB}"); + assert_eq!(TestNotifier::get_source_emoji("Linear"), "\u{1F4CB}"); + assert_eq!(TestNotifier::get_source_emoji("SENTRY"), "\u{1F534}"); + assert_eq!(TestNotifier::get_source_emoji("GitHub"), "\u{1F419}"); + } + + #[test] + fn test_source_emoji_jira() { + type TestNotifier = DiscordNotifier; + assert_eq!(TestNotifier::get_source_emoji("jira"), "\u{1F3AB}"); + assert_eq!(TestNotifier::get_source_emoji("JIRA"), "\u{1F3AB}"); + } + + #[test] + fn test_timestamp_format() { + type TestNotifier = DiscordNotifier; + let timestamp = TestNotifier::timestamp(); + // Should be valid RFC3339 + assert!(timestamp.contains("T")); + assert!(timestamp.contains("+") || timestamp.contains("Z")); + } + + #[test] + fn test_discord_message_serialization() { + let message = DiscordMessage { + content: Some("Test message".to_string()), + embeds: None, + }; + let json = serde_json::to_string(&message).unwrap(); + assert!(json.contains("Test message")); + // embeds should be skipped because it's None + assert!(!json.contains("embeds")); + } + + #[test] + fn test_discord_embed_serialization() { + let embed = DiscordEmbed { + title: Some("Test Title".to_string()), + description: Some("Test Description".to_string()), + url: Some("https://example.com".to_string()), + color: Some(0xFF0000), + fields: None, + footer: None, + timestamp: None, + }; + let json = serde_json::to_string(&embed).unwrap(); + assert!(json.contains("Test Title")); + assert!(json.contains("Test Description")); + assert!(json.contains("https://example.com")); + // Optional fields should be skipped + assert!(!json.contains("fields")); + assert!(!json.contains("footer")); + assert!(!json.contains("timestamp")); + } + + #[test] + fn test_discord_field_serialization() { + let field = DiscordField { + name: "Field Name".to_string(), + value: "Field Value".to_string(), + inline: Some(true), + }; + let json = serde_json::to_string(&field).unwrap(); + assert!(json.contains("Field Name")); + assert!(json.contains("Field Value")); + assert!(json.contains("true")); + } + + #[test] + fn test_discord_field_serialization_no_inline() { + let field = DiscordField { + name: "Field Name".to_string(), + value: "Field Value".to_string(), + inline: None, + }; + let json = serde_json::to_string(&field).unwrap(); + assert!(!json.contains("inline")); + } + + #[test] + fn test_discord_footer_serialization() { + let footer = DiscordFooter { + text: "Footer Text".to_string(), + }; + let json = serde_json::to_string(&footer).unwrap(); + assert!(json.contains("Footer Text")); + } + + #[tokio::test] + async fn test_notify_status_disabled() { + let config = DiscordConfig { + webhook_url: None, // Disabled + user_id: None, + }; + let notifier = DiscordNotifier::new(config); + + // Should return Ok without actually sending + let result = notifier.notify_status("Test status").await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_start_disabled() { + let config = DiscordConfig { + webhook_url: None, + user_id: None, + }; + let notifier = DiscordNotifier::new(config); + + let issue = Issue::new( + "123", + "TEST-123", + "Test Issue", + "https://example.com", + "linear", + ); + let result = notifier.notify_start(&issue).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_success_disabled() { + let config = DiscordConfig { + webhook_url: None, + user_id: None, + }; + let notifier = DiscordNotifier::new(config); + + let issue = Issue::new( + "123", + "TEST-123", + "Test Issue", + "https://example.com", + "linear", + ); + let result = notifier + .notify_success(&issue, "https://github.com/org/repo/pull/1") + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_failed_disabled() { + let config = DiscordConfig { + webhook_url: None, + user_id: None, + }; + let notifier = DiscordNotifier::new(config); + + let issue = Issue::new( + "123", + "TEST-123", + "Test Issue", + "https://example.com", + "linear", + ); + let result = notifier.notify_failed(&issue, "Test error").await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_completed_disabled() { + let config = DiscordConfig { + webhook_url: None, + user_id: None, + }; + let notifier = DiscordNotifier::new(config); + + let issue = Issue::new( + "123", + "TEST-123", + "Test Issue", + "https://example.com", + "linear", + ); + let result = notifier.notify_completed(&issue).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_urgent_issues_empty() { + let config = DiscordConfig { + webhook_url: Some("https://example.com".to_string()), + user_id: None, + }; + let notifier = DiscordNotifier::new(config); + + // Empty list should return Ok without sending + let result = notifier.notify_urgent_issues(&[]).await; + assert!(result.is_ok()); + } + + // Mock-based tests for HTTP-dependent functionality + + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Mutex; + + /// Mock Discord webhook client for testing. + struct MockDiscordWebhookClient { + response_status: u16, + response_body: String, + call_count: AtomicUsize, + last_calls: Mutex>, + } + + impl MockDiscordWebhookClient { + fn new(status: u16, body: &str) -> Self { + Self { + response_status: status, + response_body: body.to_string(), + call_count: AtomicUsize::new(0), + last_calls: Mutex::new(Vec::new()), + } + } + + fn success() -> Self { + Self::new(204, "") + } + + fn error(status: u16, body: &str) -> Self { + Self::new(status, body) + } + + fn get_call_count(&self) -> usize { + self.call_count.load(Ordering::SeqCst) + } + + fn get_last_call(&self) -> Option<(String, serde_json::Value)> { + self.last_calls.lock().unwrap().last().cloned() + } + } + + #[async_trait] + impl DiscordWebhookClient for MockDiscordWebhookClient { + async fn post_json(&self, url: &str, body: &serde_json::Value) -> Result { + self.call_count.fetch_add(1, Ordering::SeqCst); + self.last_calls + .lock() + .unwrap() + .push((url.to_string(), body.clone())); + + Ok(HttpResponse { + status: self.response_status, + body: self.response_body.clone(), + }) + } + } + + fn enabled_config() -> DiscordConfig { + DiscordConfig { + webhook_url: Some("https://discord.com/api/webhooks/123/abc".to_string()), + user_id: None, + } + } + + fn enabled_config_with_user() -> DiscordConfig { + DiscordConfig { + webhook_url: Some("https://discord.com/api/webhooks/123/abc".to_string()), + user_id: Some("987654321".to_string()), + } + } + + #[tokio::test] + async fn test_send_webhook_success() { + let mock = MockDiscordWebhookClient::success(); + let notifier = DiscordNotifier::with_http_client(enabled_config(), mock); + let issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://example.com", + "linear", + ); + + let result = notifier.notify_start(&issue).await; + + assert!(result.is_ok()); + assert_eq!(notifier.http.get_call_count(), 1); + } + + #[tokio::test] + async fn test_send_webhook_sends_to_correct_url() { + let mock = MockDiscordWebhookClient::success(); + let notifier = DiscordNotifier::with_http_client(enabled_config(), mock); + let issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://example.com", + "linear", + ); + + notifier.notify_start(&issue).await.unwrap(); + + let (url, _) = notifier.http.get_last_call().unwrap(); + assert_eq!(url, "https://discord.com/api/webhooks/123/abc"); + } + + #[tokio::test] + async fn test_send_webhook_error_response() { + let mock = MockDiscordWebhookClient::error(400, "Bad Request"); + let notifier = DiscordNotifier::with_http_client(enabled_config(), mock); + let issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://example.com", + "linear", + ); + + let result = notifier.notify_start(&issue).await; + + assert!(result.is_err()); + let err_str = result.unwrap_err().to_string(); + assert!(err_str.contains("Webhook error")); + assert!(err_str.contains("Bad Request")); + } + + #[tokio::test] + async fn test_send_webhook_server_error() { + let mock = MockDiscordWebhookClient::error(500, "Internal Server Error"); + let notifier = DiscordNotifier::with_http_client(enabled_config(), mock); + + let result = notifier.notify_status("Test").await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_notify_start_sends_embed() { + let mock = MockDiscordWebhookClient::success(); + let notifier = DiscordNotifier::with_http_client(enabled_config(), mock); + let issue = Issue::new( + "123", + "PROJ-123", + "Test Issue Title", + "https://example.com", + "linear", + ); + + notifier.notify_start(&issue).await.unwrap(); + + let (_, body) = notifier.http.get_last_call().unwrap(); + assert!(body["embeds"].is_array()); + let embed = &body["embeds"][0]; + assert!(embed["title"].as_str().unwrap().contains("PROJ-123")); + assert_eq!(embed["description"], "Test Issue Title"); + } + + #[tokio::test] + async fn test_notify_start_with_user_mention() { + let mock = MockDiscordWebhookClient::success(); + let notifier = DiscordNotifier::with_http_client(enabled_config_with_user(), mock); + let issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://example.com", + "linear", + ); + + notifier.notify_start(&issue).await.unwrap(); + + let (_, body) = notifier.http.get_last_call().unwrap(); + let content = body["content"].as_str().unwrap(); + assert!(content.contains("<@987654321>")); + } + + #[tokio::test] + async fn test_notify_success_sends_correct_embed() { + let mock = MockDiscordWebhookClient::success(); + let notifier = DiscordNotifier::with_http_client(enabled_config(), mock); + let issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://example.com", + "linear", + ); + + notifier + .notify_success(&issue, "https://github.com/org/repo/pull/42") + .await + .unwrap(); + + let (_, body) = notifier.http.get_last_call().unwrap(); + let embed = &body["embeds"][0]; + assert!(embed["title"].as_str().unwrap().contains("PR Created")); + assert_eq!(embed["url"], "https://github.com/org/repo/pull/42"); + assert_eq!(embed["color"], 0x2ecc71); // Green + } + + #[tokio::test] + async fn test_notify_completed_sends_correct_embed() { + let mock = MockDiscordWebhookClient::success(); + let notifier = DiscordNotifier::with_http_client(enabled_config(), mock); + let issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://example.com", + "linear", + ); + + notifier.notify_completed(&issue).await.unwrap(); + + let (_, body) = notifier.http.get_last_call().unwrap(); + let embed = &body["embeds"][0]; + assert!(embed["title"].as_str().unwrap().contains("Completed")); + assert_eq!(embed["color"], 0x9b59b6); // Purple + } + + #[tokio::test] + async fn test_notify_failed_sends_correct_embed() { + let mock = MockDiscordWebhookClient::success(); + let notifier = DiscordNotifier::with_http_client(enabled_config(), mock); + let issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://example.com", + "linear", + ); + + notifier + .notify_failed(&issue, "Build failed with exit code 1") + .await + .unwrap(); + + let (_, body) = notifier.http.get_last_call().unwrap(); + let embed = &body["embeds"][0]; + assert!(embed["title"].as_str().unwrap().contains("Failed")); + assert_eq!(embed["color"], 0xe74c3c); // Red + // Check error field + let fields = embed["fields"].as_array().unwrap(); + let error_field = fields.iter().find(|f| f["name"] == "Error").unwrap(); + assert!(error_field["value"] + .as_str() + .unwrap() + .contains("Build failed")); + } + + #[tokio::test] + async fn test_notify_failed_truncates_long_error() { + let mock = MockDiscordWebhookClient::success(); + let notifier = DiscordNotifier::with_http_client(enabled_config(), mock); + let issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://example.com", + "linear", + ); + + let long_error = "x".repeat(2000); + notifier.notify_failed(&issue, &long_error).await.unwrap(); + + let (_, body) = notifier.http.get_last_call().unwrap(); + let fields = body["embeds"][0]["fields"].as_array().unwrap(); + let error_field = fields.iter().find(|f| f["name"] == "Error").unwrap(); + let error_value = error_field["value"].as_str().unwrap(); + assert!(error_value.len() <= 1010); + assert!(error_value.ends_with("...")); + } + + #[tokio::test] + async fn test_notify_status_sends_embed() { + let mock = MockDiscordWebhookClient::success(); + let notifier = DiscordNotifier::with_http_client(enabled_config(), mock); + + notifier.notify_status("System is healthy").await.unwrap(); + + let (_, body) = notifier.http.get_last_call().unwrap(); + let embed = &body["embeds"][0]; + assert_eq!(embed["description"], "System is healthy"); + } + + #[tokio::test] + async fn test_notify_urgent_issues_sends_embed() { + let mock = MockDiscordWebhookClient::success(); + let notifier = DiscordNotifier::with_http_client(enabled_config(), mock); + let issues = vec![ + Issue::new("1", "PROJ-1", "Issue 1", "https://example.com/1", "linear"), + Issue::new("2", "PROJ-2", "Issue 2", "https://example.com/2", "sentry"), + ]; + + notifier.notify_urgent_issues(&issues).await.unwrap(); + + let (_, body) = notifier.http.get_last_call().unwrap(); + let embed = &body["embeds"][0]; + assert!(embed["title"].as_str().unwrap().contains("2 Urgent Issues")); + assert_eq!(embed["color"], 0xf39c12); // Orange + } + + #[tokio::test] + async fn test_notify_urgent_issues_with_user_mention() { + let mock = MockDiscordWebhookClient::success(); + let notifier = DiscordNotifier::with_http_client(enabled_config_with_user(), mock); + let issues = vec![Issue::new( + "1", + "PROJ-1", + "Issue", + "https://example.com", + "linear", + )]; + + notifier.notify_urgent_issues(&issues).await.unwrap(); + + let (_, body) = notifier.http.get_last_call().unwrap(); + let content = body["content"].as_str().unwrap(); + assert!(content.contains("<@987654321>")); + } + + #[tokio::test] + async fn test_notify_urgent_issues_truncates_long_title() { + let mock = MockDiscordWebhookClient::success(); + let notifier = DiscordNotifier::with_http_client(enabled_config(), mock); + let long_title = "x".repeat(100); + let issues = vec![Issue::new( + "1", + "PROJ-1", + &long_title, + "https://example.com", + "linear", + )]; + + notifier.notify_urgent_issues(&issues).await.unwrap(); + + let (_, body) = notifier.http.get_last_call().unwrap(); + let fields = body["embeds"][0]["fields"].as_array().unwrap(); + let field_value = fields[0]["value"].as_str().unwrap(); + // Title should be truncated (47 chars + "...") + assert!(field_value.contains("...")); + } + + #[tokio::test] + async fn test_notify_urgent_issues_limits_to_ten() { + let mock = MockDiscordWebhookClient::success(); + let notifier = DiscordNotifier::with_http_client(enabled_config(), mock); + let issues: Vec = (1..=20) + .map(|i| { + Issue::new( + i.to_string(), + format!("PROJ-{}", i), + format!("Issue {}", i), + format!("https://example.com/{}", i), + "linear", + ) + }) + .collect(); + + notifier.notify_urgent_issues(&issues).await.unwrap(); + + let (_, body) = notifier.http.get_last_call().unwrap(); + let fields = body["embeds"][0]["fields"].as_array().unwrap(); + assert_eq!(fields.len(), 10); + } + + #[tokio::test] + async fn test_notify_urgent_issues_single_item_grammar() { + let mock = MockDiscordWebhookClient::success(); + let notifier = DiscordNotifier::with_http_client(enabled_config(), mock); + let issues = vec![Issue::new( + "1", + "PROJ-1", + "Issue", + "https://example.com", + "linear", + )]; + + notifier.notify_urgent_issues(&issues).await.unwrap(); + + let (_, body) = notifier.http.get_last_call().unwrap(); + let title = body["embeds"][0]["title"].as_str().unwrap(); + // Should use singular "Issue" not "Issues" + assert!(title.contains("1 Urgent Issue Detected")); + assert!(!title.contains("Issues")); + } + + #[test] + fn test_with_http_client() { + let mock = MockDiscordWebhookClient::success(); + let notifier = DiscordNotifier::with_http_client(enabled_config(), mock); + + assert!(notifier.is_enabled()); + assert_eq!(notifier.name(), "discord"); + } + + #[test] + fn test_reqwest_discord_webhook_client_default() { + let client = ReqwestDiscordWebhookClient::default(); + assert!(std::mem::size_of_val(&client) > 0); + } + + #[test] + fn test_http_response_fields() { + let response = HttpResponse { + status: 201, + body: "Created".to_string(), + }; + assert_eq!(response.status, 201); + assert_eq!(response.body, "Created"); + } + + #[tokio::test] + async fn test_source_specific_embeds() { + let mock = MockDiscordWebhookClient::success(); + let notifier = DiscordNotifier::with_http_client(enabled_config(), mock); + + // Test linear source + let linear_issue = Issue::new( + "1", + "LIN-1", + "Linear Issue", + "https://linear.app/1", + "linear", + ); + notifier.notify_start(&linear_issue).await.unwrap(); + + let (_, body) = notifier.http.get_last_call().unwrap(); + let fields = body["embeds"][0]["fields"].as_array().unwrap(); + let source_field = fields.iter().find(|f| f["name"] == "Source").unwrap(); + assert_eq!(source_field["value"], "linear"); + } + + #[tokio::test] + async fn test_embed_has_timestamp() { + let mock = MockDiscordWebhookClient::success(); + let notifier = DiscordNotifier::with_http_client(enabled_config(), mock); + let issue = Issue::new("1", "PROJ-1", "Test", "https://example.com", "linear"); + + notifier.notify_start(&issue).await.unwrap(); + + let (_, body) = notifier.http.get_last_call().unwrap(); + let timestamp = body["embeds"][0]["timestamp"].as_str().unwrap(); + // Should be RFC3339 format + assert!(timestamp.contains("T")); + } + + #[tokio::test] + async fn test_embed_has_footer() { + let mock = MockDiscordWebhookClient::success(); + let notifier = DiscordNotifier::with_http_client(enabled_config(), mock); + let issue = Issue::new("1", "PROJ-1", "Test", "https://example.com", "linear"); + + notifier.notify_start(&issue).await.unwrap(); + + let (_, body) = notifier.http.get_last_call().unwrap(); + let footer = body["embeds"][0]["footer"]["text"].as_str().unwrap(); + assert_eq!(footer, "Claude Watchers"); + } +} diff --git a/src/notifier/email.rs b/src/notifier/email.rs new file mode 100644 index 00000000..7464d326 --- /dev/null +++ b/src/notifier/email.rs @@ -0,0 +1,400 @@ +//! Email notifier via SMTP. + +use super::Notifier; +use crate::config::EmailConfig; +use crate::error::{Error, Result}; +use crate::types::Issue; +use async_trait::async_trait; +use lettre::{ + message::{header::ContentType, Mailbox}, + transport::smtp::authentication::Credentials, + AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, +}; + +/// Email notifier that sends notifications via SMTP. +pub struct EmailNotifier { + config: EmailConfig, + transport: Option>, +} + +impl EmailNotifier { + /// Create a new email notifier. + pub fn new(config: EmailConfig) -> Result { + let transport = if let (Some(host), Some(username), Some(password)) = ( + config.smtp_host.as_ref(), + config.smtp_username.as_ref(), + config.smtp_password.as_ref(), + ) { + let creds = Credentials::new(username.clone(), password.clone()); + + let builder = if config.use_tls { + AsyncSmtpTransport::::starttls_relay(host) + .map_err(|e| Error::notifier("email", format!("SMTP setup error: {}", e)))? + } else { + AsyncSmtpTransport::::builder_dangerous(host) + }; + + Some(builder.port(config.smtp_port).credentials(creds).build()) + } else { + None + }; + + Ok(Self { config, transport }) + } + + async fn send_email(&self, subject: &str, body: &str) -> Result<()> { + let transport = match &self.transport { + Some(t) => t, + None => return Ok(()), + }; + + let from_address = match &self.config.from_address { + Some(addr) => addr + .parse::() + .map_err(|e| Error::notifier("email", format!("Invalid from address: {}", e)))?, + None => return Ok(()), + }; + + for to_address in &self.config.to_addresses { + let to_mailbox = to_address + .parse::() + .map_err(|e| Error::notifier("email", format!("Invalid to address: {}", e)))?; + + let message = Message::builder() + .from(from_address.clone()) + .to(to_mailbox) + .subject(subject) + .header(ContentType::TEXT_PLAIN) + .body(body.to_string()) + .map_err(|e| Error::notifier("email", format!("Failed to build email: {}", e)))?; + + transport + .send(message) + .await + .map_err(|e| Error::notifier("email", format!("Failed to send email: {}", e)))?; + } + + Ok(()) + } + + fn format_issue_info(issue: &Issue) -> String { + format!( + "Issue: {} - {}\nSource: {}\nPriority: {}\nStatus: {}\nURL: {}", + issue.short_id, issue.title, issue.source, issue.priority, issue.status, issue.url + ) + } +} + +#[async_trait] +impl Notifier for EmailNotifier { + fn name(&self) -> &str { + "email" + } + + fn is_enabled(&self) -> bool { + self.transport.is_some() + && self.config.from_address.is_some() + && !self.config.to_addresses.is_empty() + } + + async fn notify_start(&self, issue: &Issue) -> Result<()> { + let subject = format!("[Claude Watchers] Processing: {}", issue.short_id); + let body = format!( + "Claude Watchers is now processing an issue.\n\n{}\n\nYou will receive another notification when processing completes.", + Self::format_issue_info(issue) + ); + self.send_email(&subject, &body).await + } + + async fn notify_success(&self, issue: &Issue, pr_url: &str) -> Result<()> { + let subject = format!("[Claude Watchers] PR Created: {}", issue.short_id); + let body = format!( + "Claude Watchers successfully created a PR!\n\n{}\n\nPR URL: {}", + Self::format_issue_info(issue), + pr_url + ); + self.send_email(&subject, &body).await + } + + async fn notify_completed(&self, issue: &Issue) -> Result<()> { + let subject = format!("[Claude Watchers] Completed: {}", issue.short_id); + let body = format!( + "Claude Watchers completed processing but no PR URL was captured.\n\n{}", + Self::format_issue_info(issue) + ); + self.send_email(&subject, &body).await + } + + async fn notify_failed(&self, issue: &Issue, error: &str) -> Result<()> { + let subject = format!("[Claude Watchers] Failed: {}", issue.short_id); + let body = format!( + "Claude Watchers failed to process an issue.\n\n{}\n\nError: {}", + Self::format_issue_info(issue), + error + ); + self.send_email(&subject, &body).await + } + + async fn notify_status(&self, message: &str) -> Result<()> { + let subject = "[Claude Watchers] Status Update".to_string(); + self.send_email(&subject, message).await + } + + async fn notify_urgent_issues(&self, issues: &[Issue]) -> Result<()> { + if issues.is_empty() { + return Ok(()); + } + + let subject = format!( + "[Claude Watchers] {} Urgent Issue{} Detected", + issues.len(), + if issues.len() > 1 { "s" } else { "" } + ); + + let mut body = "The following urgent issues require attention:\n\n".to_string(); + for issue in issues.iter().take(10) { + body.push_str(&format!( + "- [{}] {} - {}\n URL: {}\n\n", + issue.source, issue.short_id, issue.title, issue.url + )); + } + + self.send_email(&subject, &body).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{IssuePriority, IssueStatus}; + + fn disabled_config() -> EmailConfig { + EmailConfig { + smtp_host: None, + smtp_port: 587, + smtp_username: None, + smtp_password: None, + from_address: None, + to_addresses: vec![], + use_tls: true, + } + } + + fn partial_config() -> EmailConfig { + EmailConfig { + smtp_host: Some("smtp.example.com".to_string()), + smtp_port: 587, + smtp_username: Some("user".to_string()), + smtp_password: Some("pass".to_string()), + from_address: None, // Missing from address + to_addresses: vec!["test@example.com".to_string()], + use_tls: true, + } + } + + fn no_to_config() -> EmailConfig { + EmailConfig { + smtp_host: Some("smtp.example.com".to_string()), + smtp_port: 587, + smtp_username: Some("user".to_string()), + smtp_password: Some("pass".to_string()), + from_address: Some("from@example.com".to_string()), + to_addresses: vec![], // No recipients + use_tls: true, + } + } + + #[test] + fn test_new_disabled() { + let notifier = EmailNotifier::new(disabled_config()).unwrap(); + assert_eq!(notifier.name(), "email"); + assert!(!notifier.is_enabled()); + } + + #[test] + fn test_name() { + let notifier = EmailNotifier::new(disabled_config()).unwrap(); + assert_eq!(notifier.name(), "email"); + } + + #[test] + fn test_is_enabled_no_transport() { + let notifier = EmailNotifier::new(disabled_config()).unwrap(); + assert!(!notifier.is_enabled()); + } + + #[test] + fn test_is_enabled_no_from() { + let notifier = EmailNotifier::new(partial_config()).unwrap(); + // Has transport but no from address + assert!(!notifier.is_enabled()); + } + + #[test] + fn test_is_enabled_no_to() { + let notifier = EmailNotifier::new(no_to_config()).unwrap(); + // Has transport and from but no recipients + assert!(!notifier.is_enabled()); + } + + #[test] + fn test_format_issue_info() { + let mut issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://example.com", + "linear", + ); + issue.priority = IssuePriority::High; + issue.status = IssueStatus::Open; + + let info = EmailNotifier::format_issue_info(&issue); + + assert!(info.contains("PROJ-123")); + assert!(info.contains("Test Issue")); + assert!(info.contains("linear")); + assert!(info.contains("https://example.com")); + } + + #[test] + fn test_format_issue_info_all_priorities() { + for priority in [ + IssuePriority::Critical, + IssuePriority::High, + IssuePriority::Medium, + IssuePriority::Low, + IssuePriority::None, + ] { + let mut issue = Issue::new("123", "TEST-1", "Test", "https://example.com", "linear"); + issue.priority = priority; + + let info = EmailNotifier::format_issue_info(&issue); + assert!(!info.is_empty()); + } + } + + #[test] + fn test_format_issue_info_all_statuses() { + for status in [ + IssueStatus::Open, + IssueStatus::InProgress, + IssueStatus::Resolved, + IssueStatus::Ignored, + ] { + let mut issue = Issue::new("123", "TEST-1", "Test", "https://example.com", "sentry"); + issue.status = status; + + let info = EmailNotifier::format_issue_info(&issue); + assert!(!info.is_empty()); + } + } + + #[tokio::test] + async fn test_notify_start_disabled() { + let notifier = EmailNotifier::new(disabled_config()).unwrap(); + let issue = Issue::new("123", "PROJ-123", "Test", "https://example.com", "linear"); + + // Should return Ok even when disabled + let result = notifier.notify_start(&issue).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_success_disabled() { + let notifier = EmailNotifier::new(disabled_config()).unwrap(); + let issue = Issue::new("123", "PROJ-123", "Test", "https://example.com", "linear"); + + let result = notifier + .notify_success(&issue, "https://github.com/org/repo/pull/1") + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_completed_disabled() { + let notifier = EmailNotifier::new(disabled_config()).unwrap(); + let issue = Issue::new("123", "PROJ-123", "Test", "https://example.com", "linear"); + + let result = notifier.notify_completed(&issue).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_failed_disabled() { + let notifier = EmailNotifier::new(disabled_config()).unwrap(); + let issue = Issue::new("123", "PROJ-123", "Test", "https://example.com", "linear"); + + let result = notifier.notify_failed(&issue, "Error message").await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_status_disabled() { + let notifier = EmailNotifier::new(disabled_config()).unwrap(); + + let result = notifier.notify_status("Status update").await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_urgent_issues_empty() { + let notifier = EmailNotifier::new(disabled_config()).unwrap(); + + let result = notifier.notify_urgent_issues(&[]).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_urgent_issues_disabled() { + let notifier = EmailNotifier::new(disabled_config()).unwrap(); + let issues = vec![ + Issue::new("1", "PROJ-1", "Issue 1", "https://example.com", "linear"), + Issue::new("2", "PROJ-2", "Issue 2", "https://example.com", "linear"), + ]; + + let result = notifier.notify_urgent_issues(&issues).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_urgent_issues_single_vs_plural() { + let notifier = EmailNotifier::new(disabled_config()).unwrap(); + + // Single issue + let single = vec![Issue::new( + "1", + "PROJ-1", + "Issue", + "https://example.com", + "linear", + )]; + let result = notifier.notify_urgent_issues(&single).await; + assert!(result.is_ok()); + + // Multiple issues + let multiple = vec![ + Issue::new("1", "PROJ-1", "Issue 1", "https://example.com", "linear"), + Issue::new("2", "PROJ-2", "Issue 2", "https://example.com", "linear"), + ]; + let result = notifier.notify_urgent_issues(&multiple).await; + assert!(result.is_ok()); + } + + #[test] + fn test_new_with_non_tls() { + let config = EmailConfig { + smtp_host: Some("localhost".to_string()), + smtp_port: 25, + smtp_username: Some("user".to_string()), + smtp_password: Some("pass".to_string()), + from_address: Some("test@localhost".to_string()), + to_addresses: vec!["recipient@localhost".to_string()], + use_tls: false, + }; + + let notifier = EmailNotifier::new(config).unwrap(); + // Should create successfully with dangerous builder + assert_eq!(notifier.name(), "email"); + } +} diff --git a/src/notifier/mod.rs b/src/notifier/mod.rs new file mode 100644 index 00000000..eb99fc03 --- /dev/null +++ b/src/notifier/mod.rs @@ -0,0 +1,758 @@ +//! Notification implementations. +//! +//! The notifier system provides a flexible abstraction for sending notifications +//! to various channels. All notifiers implement the `Notifier` trait. +//! +//! ## Available Notifiers +//! +//! - `ConsoleNotifier` - Prints to stdout (always enabled) +//! - `DiscordNotifier` - Sends Discord webhook messages +//! - `EmailNotifier` - Sends email via SMTP +//! - `SmsNotifier` - Sends SMS via Twilio +//! - `PushNotifier` - Sends push notifications via Pushover +//! +//! ## Adding a New Notifier +//! +//! 1. Create a new module file (e.g., `slack.rs`) +//! 2. Implement the `Notifier` trait +//! 3. Export from this module +//! 4. Add configuration to `config.rs` +//! +//! Example: +//! +//! ```no_run +//! use async_trait::async_trait; +//! use claudear::notifier::Notifier; +//! use claudear::types::Issue; +//! use claudear::error::Result; +//! +//! pub struct SlackNotifier { +//! webhook_url: String, +//! } +//! +//! #[async_trait] +//! impl Notifier for SlackNotifier { +//! fn name(&self) -> &str { "slack" } +//! fn is_enabled(&self) -> bool { !self.webhook_url.is_empty() } +//! +//! async fn notify_start(&self, _issue: &Issue) -> Result<()> { Ok(()) } +//! async fn notify_success(&self, _issue: &Issue, _pr_url: &str) -> Result<()> { Ok(()) } +//! async fn notify_completed(&self, _issue: &Issue) -> Result<()> { Ok(()) } +//! async fn notify_failed(&self, _issue: &Issue, _error: &str) -> Result<()> { Ok(()) } +//! async fn notify_status(&self, _message: &str) -> Result<()> { Ok(()) } +//! async fn notify_urgent_issues(&self, _issues: &[Issue]) -> Result<()> { Ok(()) } +//! } +//! ``` + +mod console; +mod discord; +mod email; +mod push; +mod sms; + +pub use console::ConsoleNotifier; +pub use discord::DiscordNotifier; +pub use email::EmailNotifier; +pub use push::PushNotifier; +pub use sms::SmsNotifier; + +use crate::error::Result; +use crate::reports::Report; +use crate::types::Issue; +use async_trait::async_trait; +use std::sync::Arc; + +/// Trait for notification services. +#[async_trait] +pub trait Notifier: Send + Sync { + /// Unique name for this notifier. + fn name(&self) -> &str; + + /// Whether this notifier is currently enabled/configured. + fn is_enabled(&self) -> bool; + + /// Notify that processing has started for an issue. + async fn notify_start(&self, issue: &Issue) -> Result<()>; + + /// Notify that a fix was successful with PR link. + async fn notify_success(&self, issue: &Issue, pr_url: &str) -> Result<()>; + + /// Notify that processing completed but no PR was found. + async fn notify_completed(&self, issue: &Issue) -> Result<()>; + + /// Notify that processing failed. + async fn notify_failed(&self, issue: &Issue, error: &str) -> Result<()>; + + /// Send a general status message. + async fn notify_status(&self, message: &str) -> Result<()>; + + /// Notify about multiple urgent issues detected. + async fn notify_urgent_issues(&self, issues: &[Issue]) -> Result<()>; + + /// Notify that a PR was merged and issue was resolved. + async fn notify_merged(&self, issue: &Issue, pr_url: &str) -> Result<()> { + // Default implementation uses notify_status + self.notify_status(&format!( + "PR merged and issue resolved: {} - {}", + issue.short_id, pr_url + )) + .await + } + + /// Send a scheduled report. + async fn notify_report(&self, report: &Report) -> Result<()> { + // Default implementation formats as text and uses notify_status + self.notify_status(&report.format_text()).await + } +} + +/// Composite notifier that sends to multiple notifiers. +pub struct CompositeNotifier { + notifiers: Vec>, +} + +impl CompositeNotifier { + /// Create a new empty composite notifier. + pub fn new() -> Self { + Self { notifiers: vec![] } + } + + /// Add a notifier to the composite. + pub fn add(&mut self, notifier: Arc) { + if notifier.is_enabled() { + self.notifiers.push(notifier); + } + } + + /// Check if any notifiers are enabled. + pub fn is_enabled(&self) -> bool { + !self.notifiers.is_empty() + } + + async fn broadcast(&self, f: F) + where + F: Fn(Arc) -> Fut, + Fut: std::future::Future>, + { + let futures: Vec<_> = self + .notifiers + .iter() + .map(|n| { + let notifier = Arc::clone(n); + f(notifier) + }) + .collect(); + + for result in futures::future::join_all(futures).await { + if let Err(e) = result { + tracing::error!("Notification error: {}", e); + } + } + } +} + +impl Default for CompositeNotifier { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Notifier for CompositeNotifier { + fn name(&self) -> &str { + "composite" + } + + fn is_enabled(&self) -> bool { + !self.notifiers.is_empty() + } + + async fn notify_start(&self, issue: &Issue) -> Result<()> { + let issue = issue.clone(); + self.broadcast(|n| { + let issue = issue.clone(); + async move { n.notify_start(&issue).await } + }) + .await; + Ok(()) + } + + async fn notify_success(&self, issue: &Issue, pr_url: &str) -> Result<()> { + let issue = issue.clone(); + let pr_url = pr_url.to_string(); + self.broadcast(|n| { + let issue = issue.clone(); + let pr_url = pr_url.clone(); + async move { n.notify_success(&issue, &pr_url).await } + }) + .await; + Ok(()) + } + + async fn notify_completed(&self, issue: &Issue) -> Result<()> { + let issue = issue.clone(); + self.broadcast(|n| { + let issue = issue.clone(); + async move { n.notify_completed(&issue).await } + }) + .await; + Ok(()) + } + + async fn notify_failed(&self, issue: &Issue, error: &str) -> Result<()> { + let issue = issue.clone(); + let error = error.to_string(); + self.broadcast(|n| { + let issue = issue.clone(); + let error = error.clone(); + async move { n.notify_failed(&issue, &error).await } + }) + .await; + Ok(()) + } + + async fn notify_status(&self, message: &str) -> Result<()> { + let message = message.to_string(); + self.broadcast(|n| { + let message = message.clone(); + async move { n.notify_status(&message).await } + }) + .await; + Ok(()) + } + + async fn notify_urgent_issues(&self, issues: &[Issue]) -> Result<()> { + let issues: Vec = issues.to_vec(); + self.broadcast(|n| { + let issues = issues.clone(); + async move { n.notify_urgent_issues(&issues).await } + }) + .await; + Ok(()) + } + + async fn notify_merged(&self, issue: &Issue, pr_url: &str) -> Result<()> { + let issue = issue.clone(); + let pr_url = pr_url.to_string(); + self.broadcast(|n| { + let issue = issue.clone(); + let pr_url = pr_url.clone(); + async move { n.notify_merged(&issue, &pr_url).await } + }) + .await; + Ok(()) + } + + async fn notify_report(&self, report: &Report) -> Result<()> { + let report = report.clone(); + self.broadcast(|n| { + let report = report.clone(); + async move { n.notify_report(&report).await } + }) + .await; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct MockNotifier { + name: String, + enabled: bool, + call_count: AtomicUsize, + } + + impl MockNotifier { + fn new(name: &str, enabled: bool) -> Self { + Self { + name: name.to_string(), + enabled, + call_count: AtomicUsize::new(0), + } + } + + fn get_call_count(&self) -> usize { + self.call_count.load(Ordering::SeqCst) + } + } + + #[async_trait] + impl Notifier for MockNotifier { + fn name(&self) -> &str { + &self.name + } + + fn is_enabled(&self) -> bool { + self.enabled + } + + async fn notify_start(&self, _issue: &Issue) -> Result<()> { + self.call_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + async fn notify_success(&self, _issue: &Issue, _pr_url: &str) -> Result<()> { + self.call_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + async fn notify_completed(&self, _issue: &Issue) -> Result<()> { + self.call_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + async fn notify_failed(&self, _issue: &Issue, _error: &str) -> Result<()> { + self.call_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + async fn notify_status(&self, _message: &str) -> Result<()> { + self.call_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + async fn notify_urgent_issues(&self, _issues: &[Issue]) -> Result<()> { + self.call_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + fn test_issue() -> Issue { + Issue::new( + "123", + "TEST-123", + "Test Issue", + "https://example.com", + "linear", + ) + } + + #[test] + fn test_composite_notifier_new() { + let composite = CompositeNotifier::new(); + assert_eq!(composite.name(), "composite"); + assert!(!composite.is_enabled()); + } + + #[test] + fn test_composite_notifier_default() { + let composite = CompositeNotifier::default(); + assert!(!composite.is_enabled()); + } + + #[test] + fn test_composite_notifier_add_enabled() { + let mut composite = CompositeNotifier::new(); + let mock = Arc::new(MockNotifier::new("mock1", true)); + composite.add(mock); + assert!(composite.is_enabled()); + } + + #[test] + fn test_composite_notifier_add_disabled() { + let mut composite = CompositeNotifier::new(); + let mock = Arc::new(MockNotifier::new("mock1", false)); + composite.add(mock); + // Disabled notifiers shouldn't be added + assert!(!composite.is_enabled()); + } + + #[test] + fn test_composite_notifier_add_multiple() { + let mut composite = CompositeNotifier::new(); + composite.add(Arc::new(MockNotifier::new("mock1", true))); + composite.add(Arc::new(MockNotifier::new("mock2", true))); + composite.add(Arc::new(MockNotifier::new("mock3", false))); // disabled + assert!(composite.is_enabled()); + assert_eq!(composite.notifiers.len(), 2); + } + + #[tokio::test] + async fn test_composite_notify_start() { + let mut composite = CompositeNotifier::new(); + let mock1 = Arc::new(MockNotifier::new("mock1", true)); + let mock2 = Arc::new(MockNotifier::new("mock2", true)); + let mock1_clone = Arc::clone(&mock1); + let mock2_clone = Arc::clone(&mock2); + composite.add(mock1); + composite.add(mock2); + + let result = composite.notify_start(&test_issue()).await; + assert!(result.is_ok()); + assert_eq!(mock1_clone.get_call_count(), 1); + assert_eq!(mock2_clone.get_call_count(), 1); + } + + #[tokio::test] + async fn test_composite_notify_success() { + let mut composite = CompositeNotifier::new(); + let mock = Arc::new(MockNotifier::new("mock", true)); + let mock_clone = Arc::clone(&mock); + composite.add(mock); + + let result = composite + .notify_success(&test_issue(), "https://github.com/org/repo/pull/1") + .await; + assert!(result.is_ok()); + assert_eq!(mock_clone.get_call_count(), 1); + } + + #[tokio::test] + async fn test_composite_notify_completed() { + let mut composite = CompositeNotifier::new(); + let mock = Arc::new(MockNotifier::new("mock", true)); + let mock_clone = Arc::clone(&mock); + composite.add(mock); + + let result = composite.notify_completed(&test_issue()).await; + assert!(result.is_ok()); + assert_eq!(mock_clone.get_call_count(), 1); + } + + #[tokio::test] + async fn test_composite_notify_failed() { + let mut composite = CompositeNotifier::new(); + let mock = Arc::new(MockNotifier::new("mock", true)); + let mock_clone = Arc::clone(&mock); + composite.add(mock); + + let result = composite.notify_failed(&test_issue(), "Error").await; + assert!(result.is_ok()); + assert_eq!(mock_clone.get_call_count(), 1); + } + + #[tokio::test] + async fn test_composite_notify_status() { + let mut composite = CompositeNotifier::new(); + let mock = Arc::new(MockNotifier::new("mock", true)); + let mock_clone = Arc::clone(&mock); + composite.add(mock); + + let result = composite.notify_status("Status message").await; + assert!(result.is_ok()); + assert_eq!(mock_clone.get_call_count(), 1); + } + + #[tokio::test] + async fn test_composite_notify_urgent_issues() { + let mut composite = CompositeNotifier::new(); + let mock = Arc::new(MockNotifier::new("mock", true)); + let mock_clone = Arc::clone(&mock); + composite.add(mock); + + let issues = vec![test_issue(), test_issue()]; + let result = composite.notify_urgent_issues(&issues).await; + assert!(result.is_ok()); + assert_eq!(mock_clone.get_call_count(), 1); + } + + #[tokio::test] + async fn test_composite_notify_merged() { + let mut composite = CompositeNotifier::new(); + let mock = Arc::new(MockNotifier::new("mock", true)); + let mock_clone = Arc::clone(&mock); + composite.add(mock); + + let result = composite + .notify_merged(&test_issue(), "https://github.com/org/repo/pull/1") + .await; + assert!(result.is_ok()); + // notify_merged calls notify_status by default + assert_eq!(mock_clone.get_call_count(), 1); + } + + #[tokio::test] + async fn test_composite_empty_broadcasts() { + let composite = CompositeNotifier::new(); + + // All should succeed even with no notifiers + assert!(composite.notify_start(&test_issue()).await.is_ok()); + assert!(composite.notify_success(&test_issue(), "url").await.is_ok()); + assert!(composite.notify_completed(&test_issue()).await.is_ok()); + assert!(composite.notify_failed(&test_issue(), "err").await.is_ok()); + assert!(composite.notify_status("msg").await.is_ok()); + assert!(composite.notify_urgent_issues(&[]).await.is_ok()); + } + + #[tokio::test] + async fn test_composite_broadcast_all_notifiers() { + let mut composite = CompositeNotifier::new(); + let mocks: Vec> = (0..5) + .map(|i| Arc::new(MockNotifier::new(&format!("mock{}", i), true))) + .collect(); + + for mock in &mocks { + composite.add(Arc::clone(mock) as Arc); + } + + composite.notify_start(&test_issue()).await.unwrap(); + + for mock in &mocks { + assert_eq!( + mock.get_call_count(), + 1, + "Each notifier should be called once" + ); + } + } + + #[test] + fn test_notifier_trait_name() { + let mock = MockNotifier::new("test_notifier", true); + assert_eq!(mock.name(), "test_notifier"); + } + + #[test] + fn test_notifier_trait_is_enabled() { + let enabled = MockNotifier::new("enabled", true); + let disabled = MockNotifier::new("disabled", false); + assert!(enabled.is_enabled()); + assert!(!disabled.is_enabled()); + } + + #[tokio::test] + async fn test_composite_notify_report() { + let mut composite = CompositeNotifier::new(); + let mock = Arc::new(MockNotifier::new("mock", true)); + let mock_clone = Arc::clone(&mock); + composite.add(mock); + + let report = Report { + period: "2024-01-01".to_string(), + from: chrono::Utc::now(), + to: chrono::Utc::now(), + issues_attempted: 10, + issues_succeeded: 8, + issues_failed: 2, + issues_cannot_fix: 0, + success_rate: 80.0, + failure_rate: 20.0, + prs_created: 10, + prs_merged: 5, + prs_closed: 0, + by_source: std::collections::HashMap::new(), + pending_count: 0, + retryable_count: 0, + }; + + let result = composite.notify_report(&report).await; + assert!(result.is_ok()); + // notify_report default calls notify_status + assert_eq!(mock_clone.get_call_count(), 1); + } + + // Mock that returns errors to test error handling in broadcast + struct FailingNotifier { + name: String, + } + + impl FailingNotifier { + fn new(name: &str) -> Self { + Self { + name: name.to_string(), + } + } + } + + #[async_trait] + impl Notifier for FailingNotifier { + fn name(&self) -> &str { + &self.name + } + fn is_enabled(&self) -> bool { + true + } + async fn notify_start(&self, _issue: &Issue) -> Result<()> { + Err(crate::error::Error::config("Notification failed")) + } + async fn notify_success(&self, _issue: &Issue, _pr_url: &str) -> Result<()> { + Err(crate::error::Error::config("Notification failed")) + } + async fn notify_completed(&self, _issue: &Issue) -> Result<()> { + Err(crate::error::Error::config("Notification failed")) + } + async fn notify_failed(&self, _issue: &Issue, _error: &str) -> Result<()> { + Err(crate::error::Error::config("Notification failed")) + } + async fn notify_status(&self, _message: &str) -> Result<()> { + Err(crate::error::Error::config("Notification failed")) + } + async fn notify_urgent_issues(&self, _issues: &[Issue]) -> Result<()> { + Err(crate::error::Error::config("Notification failed")) + } + } + + #[tokio::test] + async fn test_composite_broadcast_handles_errors() { + let mut composite = CompositeNotifier::new(); + composite.add(Arc::new(FailingNotifier::new("failing"))); + composite.add(Arc::new(MockNotifier::new("working", true))); + + // Should not panic even when one notifier fails + let result = composite.notify_start(&test_issue()).await; + // The composite itself returns Ok even if some notifiers fail + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_composite_all_notifiers_fail() { + let mut composite = CompositeNotifier::new(); + composite.add(Arc::new(FailingNotifier::new("failing1"))); + composite.add(Arc::new(FailingNotifier::new("failing2"))); + + // Should not panic even when all notifiers fail + let result = composite.notify_start(&test_issue()).await; + // The composite returns Ok even if all notifiers fail + assert!(result.is_ok()); + } + + // Test the default trait implementation for notify_merged + #[tokio::test] + async fn test_default_notify_merged() { + // MockNotifier doesn't override notify_merged, so it uses the default + let notifier = MockNotifier::new("test", true); + let issue = test_issue(); + + // The default implementation calls notify_status + let result = notifier + .notify_merged(&issue, "https://github.com/org/repo/pull/1") + .await; + assert!(result.is_ok()); + assert_eq!(notifier.get_call_count(), 1); // notify_status was called + } + + // Test the default trait implementation for notify_report + #[tokio::test] + async fn test_default_notify_report() { + let notifier = MockNotifier::new("test", true); + let report = Report { + period: "2024-01-01".to_string(), + from: chrono::Utc::now(), + to: chrono::Utc::now(), + issues_attempted: 5, + issues_succeeded: 4, + issues_failed: 1, + issues_cannot_fix: 0, + success_rate: 80.0, + failure_rate: 20.0, + prs_created: 5, + prs_merged: 3, + prs_closed: 0, + by_source: std::collections::HashMap::new(), + pending_count: 0, + retryable_count: 0, + }; + + // The default implementation calls notify_status with formatted text + let result = notifier.notify_report(&report).await; + assert!(result.is_ok()); + assert_eq!(notifier.get_call_count(), 1); // notify_status was called + } + + #[tokio::test] + async fn test_composite_notify_merged_broadcasts() { + let mut composite = CompositeNotifier::new(); + let mock1 = Arc::new(MockNotifier::new("mock1", true)); + let mock2 = Arc::new(MockNotifier::new("mock2", true)); + let mock1_clone = Arc::clone(&mock1); + let mock2_clone = Arc::clone(&mock2); + composite.add(mock1); + composite.add(mock2); + + let result = composite + .notify_merged(&test_issue(), "https://github.com/test") + .await; + assert!(result.is_ok()); + // Each notifier's notify_merged (which calls notify_status) should be called + assert_eq!(mock1_clone.get_call_count(), 1); + assert_eq!(mock2_clone.get_call_count(), 1); + } + + #[tokio::test] + async fn test_composite_notify_report_broadcasts() { + let mut composite = CompositeNotifier::new(); + let mock1 = Arc::new(MockNotifier::new("mock1", true)); + let mock2 = Arc::new(MockNotifier::new("mock2", true)); + let mock1_clone = Arc::clone(&mock1); + let mock2_clone = Arc::clone(&mock2); + composite.add(mock1); + composite.add(mock2); + + let report = Report { + period: "2024-01-01".to_string(), + from: chrono::Utc::now(), + to: chrono::Utc::now(), + issues_attempted: 1, + issues_succeeded: 1, + issues_failed: 0, + issues_cannot_fix: 0, + success_rate: 100.0, + failure_rate: 0.0, + prs_created: 1, + prs_merged: 1, + prs_closed: 0, + by_source: std::collections::HashMap::new(), + pending_count: 0, + retryable_count: 0, + }; + + let result = composite.notify_report(&report).await; + assert!(result.is_ok()); + // Each notifier should be called + assert_eq!(mock1_clone.get_call_count(), 1); + assert_eq!(mock2_clone.get_call_count(), 1); + } + + #[test] + fn test_composite_notifier_name_is_composite() { + let composite = CompositeNotifier::new(); + assert_eq!(composite.name(), "composite"); + } + + #[tokio::test] + async fn test_composite_with_only_disabled_notifiers() { + let mut composite = CompositeNotifier::new(); + composite.add(Arc::new(MockNotifier::new("disabled1", false))); + composite.add(Arc::new(MockNotifier::new("disabled2", false))); + + // No notifiers should be added + assert!(!composite.is_enabled()); + assert!(composite.notifiers.is_empty()); + + // Operations should still succeed + assert!(composite.notify_start(&test_issue()).await.is_ok()); + } + + #[test] + fn test_report_format_text() { + let report = Report { + period: "2024-01-01".to_string(), + from: chrono::DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z") + .unwrap() + .with_timezone(&chrono::Utc), + to: chrono::DateTime::parse_from_rfc3339("2024-01-01T01:00:00Z") + .unwrap() + .with_timezone(&chrono::Utc), + issues_attempted: 10, + issues_succeeded: 8, + issues_failed: 2, + issues_cannot_fix: 0, + success_rate: 80.0, + failure_rate: 20.0, + prs_created: 10, + prs_merged: 5, + prs_closed: 0, + by_source: std::collections::HashMap::new(), + pending_count: 0, + retryable_count: 0, + }; + + let text = report.format_text(); + assert!(!text.is_empty()); + // Should contain some of the stats + assert!(text.contains("10") || text.contains("8") || text.contains("5")); + } +} diff --git a/src/notifier/push.rs b/src/notifier/push.rs new file mode 100644 index 00000000..c693d9fb --- /dev/null +++ b/src/notifier/push.rs @@ -0,0 +1,455 @@ +//! Push notification via Pushover. + +use super::Notifier; +use crate::config::PushConfig; +use crate::error::{Error, Result}; +use crate::types::Issue; +use async_trait::async_trait; +use serde::Serialize; + +/// Push notifier that sends notifications via Pushover. +pub struct PushNotifier { + config: PushConfig, + client: reqwest::Client, +} + +#[derive(Debug, Serialize)] +struct PushoverMessage { + token: String, + user: String, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + url_title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + priority: Option, + #[serde(skip_serializing_if = "Option::is_none")] + device: Option, + #[serde(skip_serializing_if = "Option::is_none")] + sound: Option, +} + +impl PushNotifier { + /// Create a new push notifier. + pub fn new(config: PushConfig) -> Self { + Self { + config, + client: reqwest::Client::new(), + } + } + + async fn send_push( + &self, + title: &str, + message: &str, + url: Option<&str>, + url_title: Option<&str>, + priority: Option, + ) -> Result<()> { + let (api_token, user_key) = match (&self.config.api_token, &self.config.user_key) { + (Some(token), Some(key)) => (token, key), + _ => return Ok(()), + }; + + // Truncate message if too long (Pushover limit is 1024 chars for message) + let truncated_message = if message.len() > 1000 { + format!("{}...", &message[..997]) + } else { + message.to_string() + }; + + let push_message = PushoverMessage { + token: api_token.clone(), + user: user_key.clone(), + message: truncated_message, + title: Some(title.to_string()), + url: url.map(|s| s.to_string()), + url_title: url_title.map(|s| s.to_string()), + priority: priority.or(self.config.priority), + device: self.config.device.clone(), + sound: None, + }; + + let response = self + .client + .post("https://api.pushover.net/1/messages.json") + .json(&push_message) + .send() + .await?; + + if !response.status().is_success() { + let text = response.text().await.unwrap_or_default(); + return Err(Error::notifier("push", format!("Pushover error: {}", text))); + } + + Ok(()) + } + + fn get_source_emoji(source: &str) -> &'static str { + match source.to_lowercase().as_str() { + "linear" => "\u{1F4CB}", + "sentry" => "\u{1F534}", + "github" => "\u{1F419}", + "jira" => "\u{1F3AB}", + _ => "\u{1F4CC}", + } + } +} + +#[async_trait] +impl Notifier for PushNotifier { + fn name(&self) -> &str { + "push" + } + + fn is_enabled(&self) -> bool { + self.config.api_token.is_some() && self.config.user_key.is_some() + } + + async fn notify_start(&self, issue: &Issue) -> Result<()> { + let emoji = Self::get_source_emoji(&issue.source); + let title = format!("{} Processing: {}", emoji, issue.short_id); + let message = format!( + "{}\n\nSource: {}\nPriority: {}\nStatus: {}", + issue.title, issue.source, issue.priority, issue.status + ); + + self.send_push( + &title, + &message, + Some(&issue.url), + Some("View Issue"), + Some(-1), + ) + .await + } + + async fn notify_success(&self, issue: &Issue, pr_url: &str) -> Result<()> { + let title = format!("\u{2705} PR Created: {}", issue.short_id); + let message = format!("{}\n\nPR URL: {}", issue.title, pr_url); + + self.send_push(&title, &message, Some(pr_url), Some("View PR"), Some(0)) + .await + } + + async fn notify_completed(&self, issue: &Issue) -> Result<()> { + let title = format!("\u{2714}\u{FE0F} Completed: {}", issue.short_id); + let message = format!("{}\n\nNo PR URL was captured.", issue.title); + + self.send_push( + &title, + &message, + Some(&issue.url), + Some("View Issue"), + Some(-1), + ) + .await + } + + async fn notify_failed(&self, issue: &Issue, error: &str) -> Result<()> { + let title = format!("\u{274C} Failed: {}", issue.short_id); + let message = format!("{}\n\nError: {}", issue.title, error); + + // Higher priority for failures + self.send_push( + &title, + &message, + Some(&issue.url), + Some("View Issue"), + Some(1), + ) + .await + } + + async fn notify_status(&self, message: &str) -> Result<()> { + self.send_push("Claude Watchers", message, None, None, Some(-1)) + .await + } + + async fn notify_urgent_issues(&self, issues: &[Issue]) -> Result<()> { + if issues.is_empty() { + return Ok(()); + } + + let title = format!( + "\u{1F6A8} {} Urgent Issue{}", + issues.len(), + if issues.len() > 1 { "s" } else { "" } + ); + + let message = issues + .iter() + .take(5) + .map(|i| { + let emoji = Self::get_source_emoji(&i.source); + format!("{} {} - {}", emoji, i.short_id, i.title) + }) + .collect::>() + .join("\n"); + + // High priority for urgent issues + self.send_push(&title, &message, None, None, Some(1)).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn disabled_config() -> PushConfig { + PushConfig { + api_token: None, + user_key: None, + device: None, + priority: None, + } + } + + fn partial_config_no_token() -> PushConfig { + PushConfig { + api_token: None, + user_key: Some("user_key".to_string()), + device: None, + priority: None, + } + } + + fn partial_config_no_user() -> PushConfig { + PushConfig { + api_token: Some("api_token".to_string()), + user_key: None, + device: None, + priority: None, + } + } + + #[test] + fn test_is_enabled() { + let enabled_config = PushConfig { + api_token: Some("test".to_string()), + user_key: Some("test".to_string()), + device: None, + priority: None, + }; + let notifier = PushNotifier::new(enabled_config); + assert!(notifier.is_enabled()); + + let disabled_config = PushConfig { + api_token: None, + user_key: None, + device: None, + priority: None, + }; + let notifier = PushNotifier::new(disabled_config); + assert!(!notifier.is_enabled()); + } + + #[test] + fn test_source_emoji() { + assert_eq!(PushNotifier::get_source_emoji("linear"), "\u{1F4CB}"); + assert_eq!(PushNotifier::get_source_emoji("sentry"), "\u{1F534}"); + } + + #[test] + fn test_name() { + let notifier = PushNotifier::new(disabled_config()); + assert_eq!(notifier.name(), "push"); + } + + #[test] + fn test_is_enabled_partial_configs() { + assert!(!PushNotifier::new(partial_config_no_token()).is_enabled()); + assert!(!PushNotifier::new(partial_config_no_user()).is_enabled()); + } + + #[test] + fn test_source_emoji_all_sources() { + assert_eq!(PushNotifier::get_source_emoji("linear"), "\u{1F4CB}"); + assert_eq!(PushNotifier::get_source_emoji("sentry"), "\u{1F534}"); + assert_eq!(PushNotifier::get_source_emoji("github"), "\u{1F419}"); + assert_eq!(PushNotifier::get_source_emoji("jira"), "\u{1F3AB}"); + assert_eq!(PushNotifier::get_source_emoji("unknown"), "\u{1F4CC}"); + } + + #[test] + fn test_source_emoji_case_insensitive() { + assert_eq!(PushNotifier::get_source_emoji("LINEAR"), "\u{1F4CB}"); + assert_eq!(PushNotifier::get_source_emoji("Sentry"), "\u{1F534}"); + assert_eq!(PushNotifier::get_source_emoji("GitHub"), "\u{1F419}"); + assert_eq!(PushNotifier::get_source_emoji("JIRA"), "\u{1F3AB}"); + } + + #[tokio::test] + async fn test_notify_start_disabled() { + let notifier = PushNotifier::new(disabled_config()); + let issue = Issue::new("123", "PROJ-123", "Test", "https://example.com", "linear"); + + let result = notifier.notify_start(&issue).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_success_disabled() { + let notifier = PushNotifier::new(disabled_config()); + let issue = Issue::new("123", "PROJ-123", "Test", "https://example.com", "linear"); + + let result = notifier + .notify_success(&issue, "https://github.com/org/repo/pull/1") + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_completed_disabled() { + let notifier = PushNotifier::new(disabled_config()); + let issue = Issue::new("123", "PROJ-123", "Test", "https://example.com", "linear"); + + let result = notifier.notify_completed(&issue).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_failed_disabled() { + let notifier = PushNotifier::new(disabled_config()); + let issue = Issue::new("123", "PROJ-123", "Test", "https://example.com", "linear"); + + let result = notifier.notify_failed(&issue, "Error message").await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_status_disabled() { + let notifier = PushNotifier::new(disabled_config()); + + let result = notifier.notify_status("Status update").await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_urgent_issues_empty() { + let notifier = PushNotifier::new(disabled_config()); + + let result = notifier.notify_urgent_issues(&[]).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_urgent_issues_disabled() { + let notifier = PushNotifier::new(disabled_config()); + let issues = vec![ + Issue::new("1", "PROJ-1", "Issue 1", "https://example.com", "linear"), + Issue::new("2", "PROJ-2", "Issue 2", "https://example.com", "linear"), + ]; + + let result = notifier.notify_urgent_issues(&issues).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_urgent_issues_single() { + let notifier = PushNotifier::new(disabled_config()); + let issues = vec![Issue::new( + "1", + "PROJ-1", + "Issue", + "https://example.com", + "sentry", + )]; + + let result = notifier.notify_urgent_issues(&issues).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_urgent_issues_truncated_to_five() { + let notifier = PushNotifier::new(disabled_config()); + let issues: Vec = (0..10) + .map(|i| { + Issue::new( + format!("{}", i), + format!("PROJ-{}", i), + format!("Issue {}", i), + "https://example.com", + "github", + ) + }) + .collect(); + + let result = notifier.notify_urgent_issues(&issues).await; + assert!(result.is_ok()); + } + + #[test] + fn test_pushover_message_serialization() { + let msg = PushoverMessage { + token: "token".to_string(), + user: "user".to_string(), + message: "Test message".to_string(), + title: Some("Title".to_string()), + url: Some("https://example.com".to_string()), + url_title: Some("Link".to_string()), + priority: Some(1), + device: Some("device".to_string()), + sound: Some("pushover".to_string()), + }; + + let json = serde_json::to_value(&msg).unwrap(); + assert_eq!(json["token"], "token"); + assert_eq!(json["user"], "user"); + assert_eq!(json["message"], "Test message"); + assert_eq!(json["title"], "Title"); + assert_eq!(json["url"], "https://example.com"); + assert_eq!(json["url_title"], "Link"); + assert_eq!(json["priority"], 1); + assert_eq!(json["device"], "device"); + assert_eq!(json["sound"], "pushover"); + } + + #[test] + fn test_pushover_message_optional_fields_skipped() { + let msg = PushoverMessage { + token: "token".to_string(), + user: "user".to_string(), + message: "Test message".to_string(), + title: None, + url: None, + url_title: None, + priority: None, + device: None, + sound: None, + }; + + let json = serde_json::to_value(&msg).unwrap(); + assert_eq!(json["token"], "token"); + assert!(json.get("title").is_none()); + assert!(json.get("url").is_none()); + assert!(json.get("priority").is_none()); + } + + #[test] + fn test_config_with_device_and_priority() { + let config = PushConfig { + api_token: Some("token".to_string()), + user_key: Some("user".to_string()), + device: Some("iphone".to_string()), + priority: Some(1), + }; + + let notifier = PushNotifier::new(config); + assert!(notifier.is_enabled()); + } + + #[tokio::test] + async fn test_notify_start_different_sources() { + let notifier = PushNotifier::new(disabled_config()); + + for source in ["linear", "sentry", "github", "jira", "unknown"] { + let issue = Issue::new("123", "PROJ-123", "Test", "https://example.com", source); + let result = notifier.notify_start(&issue).await; + assert!(result.is_ok()); + } + } +} diff --git a/src/notifier/sms.rs b/src/notifier/sms.rs new file mode 100644 index 00000000..9e8b3ec8 --- /dev/null +++ b/src/notifier/sms.rs @@ -0,0 +1,845 @@ +//! SMS notifier via Twilio. + +use super::Notifier; +use crate::config::SmsConfig; +use crate::error::{Error, Result}; +use crate::types::Issue; +use async_trait::async_trait; + +/// HTTP response for SMS client. +pub struct HttpResponse { + pub status: u16, + pub body: String, +} + +/// Trait for HTTP client used by SMS notifier. +#[async_trait] +pub trait SmsHttpClient: Send + Sync { + async fn post_form( + &self, + url: &str, + auth_user: &str, + auth_pass: &str, + params: &[(&str, &str)], + ) -> Result; +} + +/// Real HTTP client using reqwest. +pub struct ReqwestSmsClient { + client: reqwest::Client, +} + +impl ReqwestSmsClient { + pub fn new() -> Self { + Self { + client: reqwest::Client::new(), + } + } +} + +impl Default for ReqwestSmsClient { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl SmsHttpClient for ReqwestSmsClient { + async fn post_form( + &self, + url: &str, + auth_user: &str, + auth_pass: &str, + params: &[(&str, &str)], + ) -> Result { + let response = self + .client + .post(url) + .basic_auth(auth_user, Some(auth_pass)) + .form(params) + .send() + .await?; + + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + + Ok(HttpResponse { status, body }) + } +} + +/// SMS notifier that sends notifications via Twilio. +pub struct SmsNotifier { + config: SmsConfig, + http: H, +} + +impl SmsNotifier { + /// Create a new SMS notifier. + pub fn new(config: SmsConfig) -> Self { + Self { + config, + http: ReqwestSmsClient::new(), + } + } +} + +impl SmsNotifier { + /// Create a new SMS notifier with custom HTTP client. + pub fn with_http_client(config: SmsConfig, http: H) -> Self { + Self { config, http } + } + + async fn send_sms(&self, body: &str) -> Result<()> { + let (account_sid, auth_token, from_number) = match ( + &self.config.account_sid, + &self.config.auth_token, + &self.config.from_number, + ) { + (Some(sid), Some(token), Some(from)) => (sid, token, from), + _ => return Ok(()), + }; + + let url = format!( + "https://api.twilio.com/2010-04-01/Accounts/{}/Messages.json", + account_sid + ); + + // Truncate message to SMS limit (160 chars for basic SMS, 1600 for modern) + let truncated_body = if body.len() > 1500 { + format!("{}...", &body[..1497]) + } else { + body.to_string() + }; + + for to_number in &self.config.to_numbers { + let params = [ + ("From", from_number.as_str()), + ("To", to_number.as_str()), + ("Body", &truncated_body), + ]; + + let response = self + .http + .post_form(&url, account_sid, auth_token, ¶ms) + .await?; + + if response.status < 200 || response.status >= 300 { + return Err(Error::notifier( + "sms", + format!("Twilio error: {}", response.body), + )); + } + } + + Ok(()) + } +} + +#[async_trait] +impl Notifier for SmsNotifier { + fn name(&self) -> &str { + "sms" + } + + fn is_enabled(&self) -> bool { + self.config.account_sid.is_some() + && self.config.auth_token.is_some() + && self.config.from_number.is_some() + && !self.config.to_numbers.is_empty() + } + + async fn notify_start(&self, issue: &Issue) -> Result<()> { + let body = format!( + "[Claude Watchers] Processing {} from {} - {}", + issue.short_id, issue.source, issue.title + ); + self.send_sms(&body).await + } + + async fn notify_success(&self, issue: &Issue, pr_url: &str) -> Result<()> { + let body = format!( + "[Claude Watchers] PR Created for {}: {}", + issue.short_id, pr_url + ); + self.send_sms(&body).await + } + + async fn notify_completed(&self, issue: &Issue) -> Result<()> { + let body = format!("[Claude Watchers] Completed {} (no PR URL)", issue.short_id); + self.send_sms(&body).await + } + + async fn notify_failed(&self, issue: &Issue, error: &str) -> Result<()> { + // Truncate error for SMS + let short_error = if error.len() > 100 { + format!("{}...", &error[..97]) + } else { + error.to_string() + }; + + let body = format!( + "[Claude Watchers] FAILED {}: {}", + issue.short_id, short_error + ); + self.send_sms(&body).await + } + + async fn notify_status(&self, message: &str) -> Result<()> { + let body = format!("[Claude Watchers] {}", message); + self.send_sms(&body).await + } + + async fn notify_urgent_issues(&self, issues: &[Issue]) -> Result<()> { + if issues.is_empty() { + return Ok(()); + } + + let body = format!( + "[Claude Watchers] {} urgent issue(s): {}", + issues.len(), + issues + .iter() + .take(3) + .map(|i| i.short_id.to_string()) + .collect::>() + .join(", ") + ); + self.send_sms(&body).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Mutex; + + /// Mock SMS HTTP client for testing. + #[allow(clippy::type_complexity)] + struct MockSmsClient { + response_status: u16, + response_body: String, + call_count: AtomicUsize, + last_calls: Mutex)>>, + } + + impl MockSmsClient { + fn new(status: u16, body: &str) -> Self { + Self { + response_status: status, + response_body: body.to_string(), + call_count: AtomicUsize::new(0), + last_calls: Mutex::new(Vec::new()), + } + } + + fn success() -> Self { + Self::new(200, r#"{"sid": "SMxxx", "status": "queued"}"#) + } + + fn error(status: u16, body: &str) -> Self { + Self::new(status, body) + } + + fn get_call_count(&self) -> usize { + self.call_count.load(Ordering::SeqCst) + } + + #[allow(clippy::type_complexity)] + fn get_last_calls(&self) -> Vec<(String, String, String, Vec<(String, String)>)> { + self.last_calls.lock().unwrap().clone() + } + } + + #[async_trait] + impl SmsHttpClient for MockSmsClient { + async fn post_form( + &self, + url: &str, + auth_user: &str, + auth_pass: &str, + params: &[(&str, &str)], + ) -> Result { + self.call_count.fetch_add(1, Ordering::SeqCst); + let params_owned: Vec<(String, String)> = params + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + self.last_calls.lock().unwrap().push(( + url.to_string(), + auth_user.to_string(), + auth_pass.to_string(), + params_owned, + )); + + Ok(HttpResponse { + status: self.response_status, + body: self.response_body.clone(), + }) + } + } + + fn disabled_config() -> SmsConfig { + SmsConfig { + account_sid: None, + auth_token: None, + from_number: None, + to_numbers: vec![], + } + } + + fn enabled_config() -> SmsConfig { + SmsConfig { + account_sid: Some("AC123456".to_string()), + auth_token: Some("auth_token_xyz".to_string()), + from_number: Some("+15551234567".to_string()), + to_numbers: vec!["+15559876543".to_string()], + } + } + + fn multi_recipient_config() -> SmsConfig { + SmsConfig { + account_sid: Some("AC123456".to_string()), + auth_token: Some("auth_token_xyz".to_string()), + from_number: Some("+15551234567".to_string()), + to_numbers: vec![ + "+15551111111".to_string(), + "+15552222222".to_string(), + "+15553333333".to_string(), + ], + } + } + + fn partial_config_no_sid() -> SmsConfig { + SmsConfig { + account_sid: None, + auth_token: Some("token".to_string()), + from_number: Some("+1234567890".to_string()), + to_numbers: vec!["+0987654321".to_string()], + } + } + + fn partial_config_no_token() -> SmsConfig { + SmsConfig { + account_sid: Some("sid".to_string()), + auth_token: None, + from_number: Some("+1234567890".to_string()), + to_numbers: vec!["+0987654321".to_string()], + } + } + + fn partial_config_no_from() -> SmsConfig { + SmsConfig { + account_sid: Some("sid".to_string()), + auth_token: Some("token".to_string()), + from_number: None, + to_numbers: vec!["+0987654321".to_string()], + } + } + + fn partial_config_no_to() -> SmsConfig { + SmsConfig { + account_sid: Some("sid".to_string()), + auth_token: Some("token".to_string()), + from_number: Some("+1234567890".to_string()), + to_numbers: vec![], + } + } + + #[test] + fn test_is_enabled() { + let enabled_config = SmsConfig { + account_sid: Some("test".to_string()), + auth_token: Some("test".to_string()), + from_number: Some("+1234567890".to_string()), + to_numbers: vec!["+0987654321".to_string()], + }; + let notifier = SmsNotifier::new(enabled_config); + assert!(notifier.is_enabled()); + + let disabled_config = SmsConfig { + account_sid: None, + auth_token: None, + from_number: None, + to_numbers: vec![], + }; + let notifier = SmsNotifier::new(disabled_config); + assert!(!notifier.is_enabled()); + } + + #[test] + fn test_name() { + let notifier = SmsNotifier::new(disabled_config()); + assert_eq!(notifier.name(), "sms"); + } + + #[test] + fn test_is_enabled_partial_configs() { + assert!(!SmsNotifier::new(partial_config_no_sid()).is_enabled()); + assert!(!SmsNotifier::new(partial_config_no_token()).is_enabled()); + assert!(!SmsNotifier::new(partial_config_no_from()).is_enabled()); + assert!(!SmsNotifier::new(partial_config_no_to()).is_enabled()); + } + + #[tokio::test] + async fn test_notify_start_disabled() { + let notifier = SmsNotifier::new(disabled_config()); + let issue = Issue::new("123", "PROJ-123", "Test", "https://example.com", "linear"); + + let result = notifier.notify_start(&issue).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_success_disabled() { + let notifier = SmsNotifier::new(disabled_config()); + let issue = Issue::new("123", "PROJ-123", "Test", "https://example.com", "linear"); + + let result = notifier + .notify_success(&issue, "https://github.com/org/repo/pull/1") + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_completed_disabled() { + let notifier = SmsNotifier::new(disabled_config()); + let issue = Issue::new("123", "PROJ-123", "Test", "https://example.com", "linear"); + + let result = notifier.notify_completed(&issue).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_failed_disabled() { + let notifier = SmsNotifier::new(disabled_config()); + let issue = Issue::new("123", "PROJ-123", "Test", "https://example.com", "linear"); + + let result = notifier.notify_failed(&issue, "Error message").await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_failed_long_error() { + let notifier = SmsNotifier::new(disabled_config()); + let issue = Issue::new("123", "PROJ-123", "Test", "https://example.com", "linear"); + + // Error longer than 100 characters + let long_error = "x".repeat(200); + let result = notifier.notify_failed(&issue, &long_error).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_status_disabled() { + let notifier = SmsNotifier::new(disabled_config()); + + let result = notifier.notify_status("Status update").await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_urgent_issues_empty() { + let notifier = SmsNotifier::new(disabled_config()); + + let result = notifier.notify_urgent_issues(&[]).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_urgent_issues_disabled() { + let notifier = SmsNotifier::new(disabled_config()); + let issues = vec![ + Issue::new("1", "PROJ-1", "Issue 1", "https://example.com", "linear"), + Issue::new("2", "PROJ-2", "Issue 2", "https://example.com", "linear"), + ]; + + let result = notifier.notify_urgent_issues(&issues).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_notify_urgent_issues_truncated_to_three() { + let notifier = SmsNotifier::new(disabled_config()); + let issues: Vec = (0..10) + .map(|i| { + Issue::new( + format!("{}", i), + format!("PROJ-{}", i), + format!("Issue {}", i), + "https://example.com", + "linear", + ) + }) + .collect(); + + let result = notifier.notify_urgent_issues(&issues).await; + assert!(result.is_ok()); + } + + #[test] + fn test_new_multiple_recipients() { + let config = SmsConfig { + account_sid: Some("sid".to_string()), + auth_token: Some("token".to_string()), + from_number: Some("+1234567890".to_string()), + to_numbers: vec![ + "+1111111111".to_string(), + "+2222222222".to_string(), + "+3333333333".to_string(), + ], + }; + + let notifier = SmsNotifier::new(config); + assert!(notifier.is_enabled()); + } + + // Mock-based tests for HTTP-dependent functionality + + #[tokio::test] + async fn test_send_sms_success() { + let mock = MockSmsClient::success(); + let notifier = SmsNotifier::with_http_client(enabled_config(), mock); + let issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://example.com", + "linear", + ); + + let result = notifier.notify_start(&issue).await; + + assert!(result.is_ok()); + assert_eq!(notifier.http.get_call_count(), 1); + } + + #[tokio::test] + async fn test_send_sms_verifies_url_format() { + let mock = MockSmsClient::success(); + let notifier = SmsNotifier::with_http_client(enabled_config(), mock); + let issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://example.com", + "linear", + ); + + notifier.notify_start(&issue).await.unwrap(); + + let calls = notifier.http.get_last_calls(); + assert_eq!(calls.len(), 1); + assert!(calls[0].0.contains("api.twilio.com")); + assert!(calls[0].0.contains("AC123456")); // Account SID in URL + assert!(calls[0].0.contains("Messages.json")); + } + + #[tokio::test] + async fn test_send_sms_uses_basic_auth() { + let mock = MockSmsClient::success(); + let notifier = SmsNotifier::with_http_client(enabled_config(), mock); + let issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://example.com", + "linear", + ); + + notifier.notify_start(&issue).await.unwrap(); + + let calls = notifier.http.get_last_calls(); + assert_eq!(calls[0].1, "AC123456"); // auth_user + assert_eq!(calls[0].2, "auth_token_xyz"); // auth_pass + } + + #[tokio::test] + async fn test_send_sms_sends_correct_params() { + let mock = MockSmsClient::success(); + let notifier = SmsNotifier::with_http_client(enabled_config(), mock); + let issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://example.com", + "linear", + ); + + notifier.notify_start(&issue).await.unwrap(); + + let calls = notifier.http.get_last_calls(); + let params = &calls[0].3; + assert!(params + .iter() + .any(|(k, v)| k == "From" && v == "+15551234567")); + assert!(params.iter().any(|(k, v)| k == "To" && v == "+15559876543")); + assert!(params + .iter() + .any(|(k, v)| k == "Body" && v.contains("Processing"))); + } + + #[tokio::test] + async fn test_send_sms_multiple_recipients() { + let mock = MockSmsClient::success(); + let notifier = SmsNotifier::with_http_client(multi_recipient_config(), mock); + let issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://example.com", + "linear", + ); + + let result = notifier.notify_start(&issue).await; + + assert!(result.is_ok()); + assert_eq!(notifier.http.get_call_count(), 3); // One call per recipient + } + + #[tokio::test] + async fn test_send_sms_error_response() { + let mock = MockSmsClient::error(400, "Invalid phone number"); + let notifier = SmsNotifier::with_http_client(enabled_config(), mock); + let issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://example.com", + "linear", + ); + + let result = notifier.notify_start(&issue).await; + + assert!(result.is_err()); + let err_str = result.unwrap_err().to_string(); + assert!(err_str.contains("Twilio error")); + assert!(err_str.contains("Invalid phone number")); + } + + #[tokio::test] + async fn test_send_sms_server_error() { + let mock = MockSmsClient::error(500, "Internal server error"); + let notifier = SmsNotifier::with_http_client(enabled_config(), mock); + let issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://example.com", + "linear", + ); + + let result = notifier.notify_start(&issue).await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_send_sms_truncates_long_message() { + let mock = MockSmsClient::success(); + let notifier = SmsNotifier::with_http_client(enabled_config(), mock); + + // Create a message longer than 1500 chars + let long_message = "x".repeat(2000); + notifier.notify_status(&long_message).await.unwrap(); + + let calls = notifier.http.get_last_calls(); + let body_param = calls[0].3.iter().find(|(k, _)| k == "Body").unwrap(); + // Body should be truncated to 1500 chars + "..." + assert!(body_param.1.len() <= 1600); // "[Claude Watchers] " + truncated body + assert!(body_param.1.ends_with("...")); + } + + #[tokio::test] + async fn test_notify_success_message_format() { + let mock = MockSmsClient::success(); + let notifier = SmsNotifier::with_http_client(enabled_config(), mock); + let issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://example.com", + "linear", + ); + + notifier + .notify_success(&issue, "https://github.com/org/repo/pull/42") + .await + .unwrap(); + + let calls = notifier.http.get_last_calls(); + let body = &calls[0].3.iter().find(|(k, _)| k == "Body").unwrap().1; + assert!(body.contains("[Claude Watchers]")); + assert!(body.contains("PR Created")); + assert!(body.contains("PROJ-123")); + assert!(body.contains("https://github.com/org/repo/pull/42")); + } + + #[tokio::test] + async fn test_notify_completed_message_format() { + let mock = MockSmsClient::success(); + let notifier = SmsNotifier::with_http_client(enabled_config(), mock); + let issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://example.com", + "linear", + ); + + notifier.notify_completed(&issue).await.unwrap(); + + let calls = notifier.http.get_last_calls(); + let body = &calls[0].3.iter().find(|(k, _)| k == "Body").unwrap().1; + assert!(body.contains("Completed")); + assert!(body.contains("no PR URL")); + } + + #[tokio::test] + async fn test_notify_failed_message_format() { + let mock = MockSmsClient::success(); + let notifier = SmsNotifier::with_http_client(enabled_config(), mock); + let issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://example.com", + "linear", + ); + + notifier + .notify_failed(&issue, "Build failed with exit code 1") + .await + .unwrap(); + + let calls = notifier.http.get_last_calls(); + let body = &calls[0].3.iter().find(|(k, _)| k == "Body").unwrap().1; + assert!(body.contains("FAILED")); + assert!(body.contains("PROJ-123")); + assert!(body.contains("Build failed")); + } + + #[tokio::test] + async fn test_notify_failed_truncates_long_error() { + let mock = MockSmsClient::success(); + let notifier = SmsNotifier::with_http_client(enabled_config(), mock); + let issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://example.com", + "linear", + ); + + let long_error = "x".repeat(200); + notifier.notify_failed(&issue, &long_error).await.unwrap(); + + let calls = notifier.http.get_last_calls(); + let body = &calls[0].3.iter().find(|(k, _)| k == "Body").unwrap().1; + // Error should be truncated to 100 chars including "..." + assert!(body.contains("...")); + } + + #[tokio::test] + async fn test_notify_status_message_format() { + let mock = MockSmsClient::success(); + let notifier = SmsNotifier::with_http_client(enabled_config(), mock); + + notifier.notify_status("System is healthy").await.unwrap(); + + let calls = notifier.http.get_last_calls(); + let body = &calls[0].3.iter().find(|(k, _)| k == "Body").unwrap().1; + assert_eq!(body, "[Claude Watchers] System is healthy"); + } + + #[tokio::test] + async fn test_notify_urgent_issues_message_format() { + let mock = MockSmsClient::success(); + let notifier = SmsNotifier::with_http_client(enabled_config(), mock); + let issues = vec![ + Issue::new("1", "PROJ-1", "Issue 1", "https://example.com", "linear"), + Issue::new("2", "PROJ-2", "Issue 2", "https://example.com", "linear"), + ]; + + notifier.notify_urgent_issues(&issues).await.unwrap(); + + let calls = notifier.http.get_last_calls(); + let body = &calls[0].3.iter().find(|(k, _)| k == "Body").unwrap().1; + assert!(body.contains("2 urgent issue(s)")); + assert!(body.contains("PROJ-1")); + assert!(body.contains("PROJ-2")); + } + + #[tokio::test] + async fn test_notify_urgent_issues_truncates_to_three() { + let mock = MockSmsClient::success(); + let notifier = SmsNotifier::with_http_client(enabled_config(), mock); + let issues: Vec = (1..=10) + .map(|i| { + Issue::new( + i.to_string(), + format!("PROJ-{}", i), + format!("Issue {}", i), + "https://example.com", + "linear", + ) + }) + .collect(); + + notifier.notify_urgent_issues(&issues).await.unwrap(); + + let calls = notifier.http.get_last_calls(); + let body = &calls[0].3.iter().find(|(k, _)| k == "Body").unwrap().1; + assert!(body.contains("10 urgent issue(s)")); + // Only first 3 are listed + assert!(body.contains("PROJ-1")); + assert!(body.contains("PROJ-2")); + assert!(body.contains("PROJ-3")); + assert!(!body.contains("PROJ-4")); + } + + #[tokio::test] + async fn test_send_sms_stops_on_first_error() { + let mock = MockSmsClient::error(400, "Bad request"); + let notifier = SmsNotifier::with_http_client(multi_recipient_config(), mock); + let issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://example.com", + "linear", + ); + + let result = notifier.notify_start(&issue).await; + + assert!(result.is_err()); + // Should stop after first failure, not try all 3 recipients + assert_eq!(notifier.http.get_call_count(), 1); + } + + #[test] + fn test_with_http_client() { + let mock = MockSmsClient::success(); + let notifier = SmsNotifier::with_http_client(enabled_config(), mock); + + assert!(notifier.is_enabled()); + assert_eq!(notifier.name(), "sms"); + } + + #[test] + fn test_reqwest_sms_client_default() { + let client = ReqwestSmsClient::default(); + // Just verify it can be constructed + assert!(std::mem::size_of_val(&client) > 0); + } + + #[test] + fn test_http_response_fields() { + let response = HttpResponse { + status: 201, + body: "Created".to_string(), + }; + assert_eq!(response.status, 201); + assert_eq!(response.body, "Created"); + } +} diff --git a/src/repo/discovery.rs b/src/repo/discovery.rs new file mode 100644 index 00000000..840d2d2c --- /dev/null +++ b/src/repo/discovery.rs @@ -0,0 +1,267 @@ +//! Auto-discovery of repository dependencies from package manifests. + +use crate::error::Result; +use serde::Deserialize; +use std::collections::HashSet; +use std::fs; +use std::path::Path; + +/// Discovered dependency information. +#[derive(Debug, Clone)] +pub struct DiscoveredDependency { + /// The repository that has the dependency. + pub repo: String, + /// The dependency name (org/package format). + pub depends_on: String, + /// Type of dependency (composer, npm). + pub dep_type: String, + /// Local path where the repo was found. + pub repo_path: String, +} + +/// Scans directories for dependencies from known organizations. +pub struct DependencyDiscovery { + known_orgs: HashSet, +} + +impl DependencyDiscovery { + /// Create a new discovery scanner with the given known organizations. + pub fn new(known_orgs: Vec) -> Self { + Self { + known_orgs: known_orgs.into_iter().collect(), + } + } + + /// Check if a package name belongs to a known organization. + fn is_known_org(&self, package_name: &str) -> bool { + if let Some(org) = package_name.split('/').next() { + self.known_orgs.contains(org) + } else { + false + } + } + + /// Scan a single directory for dependencies. + pub fn scan_directory(&self, path: &Path) -> Result> { + let mut deps = Vec::new(); + + // Try to determine repo name from composer.json or package.json + let repo_name = self.get_repo_name(path); + + if let Some(ref name) = repo_name { + // Scan composer.json + let composer_path = path.join("composer.json"); + if composer_path.exists() { + if let Ok(composer_deps) = self.scan_composer(&composer_path, name) { + deps.extend(composer_deps); + } + } + + // Scan package.json + let package_path = path.join("package.json"); + if package_path.exists() { + if let Ok(npm_deps) = self.scan_package_json(&package_path, name) { + deps.extend(npm_deps); + } + } + } + + Ok(deps) + } + + /// Scan multiple directories and return all discovered dependencies. + pub fn scan_directories(&self, paths: &[String]) -> Result> { + let mut all_deps = Vec::new(); + + for path_str in paths { + let path = Path::new(path_str); + + // If path is a directory containing multiple repos (like ~/Local) + // scan each subdirectory + if path.is_dir() { + // First try scanning the path itself + if let Ok(deps) = self.scan_directory(path) { + if !deps.is_empty() { + all_deps.extend(deps); + continue; + } + } + + // Otherwise scan subdirectories + if let Ok(entries) = fs::read_dir(path) { + for entry in entries.flatten() { + let entry_path = entry.path(); + if entry_path.is_dir() { + if let Ok(deps) = self.scan_directory(&entry_path) { + all_deps.extend(deps); + } + } + } + } + } + } + + Ok(all_deps) + } + + /// Get the repository name from package manifests. + fn get_repo_name(&self, path: &Path) -> Option { + // Try composer.json first + let composer_path = path.join("composer.json"); + if composer_path.exists() { + if let Ok(content) = fs::read_to_string(&composer_path) { + if let Ok(composer) = serde_json::from_str::(&content) { + if let Some(name) = composer.name { + return Some(name); + } + } + } + } + + // Try package.json + let package_path = path.join("package.json"); + if package_path.exists() { + if let Ok(content) = fs::read_to_string(&package_path) { + if let Ok(package) = serde_json::from_str::(&content) { + if let Some(name) = package.name { + // npm packages might be @org/package, convert to org/package + return Some(name.trim_start_matches('@').to_string()); + } + } + } + } + + // Fall back to directory name + path.file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()) + } + + /// Scan composer.json for dependencies. + fn scan_composer(&self, path: &Path, repo_name: &str) -> Result> { + let content = fs::read_to_string(path)?; + let composer: ComposerJson = serde_json::from_str(&content)?; + + let mut deps = Vec::new(); + let repo_path = path + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + + // Process require + if let Some(require) = composer.require { + for (package, _version) in require { + if self.is_known_org(&package) { + deps.push(DiscoveredDependency { + repo: repo_name.to_string(), + depends_on: package, + dep_type: "composer".to_string(), + repo_path: repo_path.clone(), + }); + } + } + } + + // Process require-dev + if let Some(require_dev) = composer.require_dev { + for (package, _version) in require_dev { + if self.is_known_org(&package) { + deps.push(DiscoveredDependency { + repo: repo_name.to_string(), + depends_on: package, + dep_type: "composer".to_string(), + repo_path: repo_path.clone(), + }); + } + } + } + + Ok(deps) + } + + /// Scan package.json for dependencies. + fn scan_package_json(&self, path: &Path, repo_name: &str) -> Result> { + let content = fs::read_to_string(path)?; + let package: PackageJson = serde_json::from_str(&content)?; + + let mut deps = Vec::new(); + let repo_path = path + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + + // Process dependencies + if let Some(dependencies) = package.dependencies { + for (package_name, _version) in dependencies { + // npm scoped packages: @org/package -> org/package + let normalized = package_name.trim_start_matches('@').to_string(); + if self.is_known_org(&normalized) { + deps.push(DiscoveredDependency { + repo: repo_name.to_string(), + depends_on: normalized, + dep_type: "npm".to_string(), + repo_path: repo_path.clone(), + }); + } + } + } + + // Process devDependencies + if let Some(dev_dependencies) = package.dev_dependencies { + for (package_name, _version) in dev_dependencies { + let normalized = package_name.trim_start_matches('@').to_string(); + if self.is_known_org(&normalized) { + deps.push(DiscoveredDependency { + repo: repo_name.to_string(), + depends_on: normalized, + dep_type: "npm".to_string(), + repo_path: repo_path.clone(), + }); + } + } + } + + Ok(deps) + } +} + +#[derive(Deserialize)] +struct ComposerJson { + name: Option, + require: Option>, + #[serde(rename = "require-dev")] + require_dev: Option>, +} + +#[derive(Deserialize)] +struct PackageJson { + name: Option, + dependencies: Option>, + #[serde(rename = "devDependencies")] + dev_dependencies: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_known_org() { + let discovery = DependencyDiscovery::new(vec![ + "utopia-php".to_string(), + "appwrite".to_string(), + ]); + + assert!(discovery.is_known_org("utopia-php/database")); + assert!(discovery.is_known_org("appwrite/sdk")); + assert!(!discovery.is_known_org("symfony/console")); + assert!(!discovery.is_known_org("laravel/framework")); + } + + #[test] + fn test_normalize_npm_scope() { + let name = "@appwrite/sdk"; + let normalized = name.trim_start_matches('@').to_string(); + assert_eq!(normalized, "appwrite/sdk"); + } +} diff --git a/src/repo/git.rs b/src/repo/git.rs new file mode 100644 index 00000000..85b60cc0 --- /dev/null +++ b/src/repo/git.rs @@ -0,0 +1,160 @@ +//! Git operations for repository management. + +use crate::error::{Error, Result}; +use std::path::Path; +use std::process::Stdio; +use tokio::process::Command; + +/// Git operations for managing repositories. +pub struct GitOps; + +impl GitOps { + /// Ensure a repository is available and up to date. + /// + /// If the repository doesn't exist locally, it will be cloned. + /// If it exists, it will be pulled to update. + pub async fn ensure_repo_at_path( + repo_path: &Path, + github_url: &str, + default_branch: &str, + ) -> Result<()> { + if repo_path.exists() { + Self::pull(repo_path, default_branch).await + } else { + Self::clone(github_url, repo_path).await + } + } + + /// Clone a repository. + async fn clone(url: &str, target: &Path) -> Result<()> { + tracing::info!(url = %url, target = ?target, "Cloning repository"); + + // Create parent directory if it doesn't exist + if let Some(parent) = target.parent() { + tokio::fs::create_dir_all(parent).await.map_err(|e| { + Error::io(format!( + "Failed to create parent directory {:?}: {}", + parent, e + )) + })?; + } + + let output = Command::new("git") + .args(["clone", "--depth", "1", url]) + .arg(target) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| Error::io(format!("Failed to execute git clone: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Error::git(format!("git clone failed: {}", stderr))); + } + + tracing::info!(target = ?target, "Repository cloned successfully"); + Ok(()) + } + + /// Pull latest changes on a branch. + async fn pull(repo_path: &Path, branch: &str) -> Result<()> { + tracing::debug!(repo = ?repo_path, branch = %branch, "Pulling latest changes"); + + // First, fetch + let output = Command::new("git") + .args(["fetch", "origin", branch]) + .current_dir(repo_path) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| Error::io(format!("Failed to execute git fetch: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Error::git(format!("git fetch failed: {}", stderr))); + } + + // Checkout the branch + let output = Command::new("git") + .args(["checkout", branch]) + .current_dir(repo_path) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| Error::io(format!("Failed to execute git checkout: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Error::git(format!("git checkout failed: {}", stderr))); + } + + // Reset to origin/branch to ensure clean state + let output = Command::new("git") + .args(["reset", "--hard", &format!("origin/{}", branch)]) + .current_dir(repo_path) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| Error::io(format!("Failed to execute git reset: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Error::git(format!("git reset failed: {}", stderr))); + } + + tracing::debug!(repo = ?repo_path, "Repository updated successfully"); + Ok(()) + } + + /// Check if a path is a valid git repository. + pub fn is_git_repo(path: &Path) -> bool { + path.join(".git").is_dir() + } + + /// Get the current branch of a repository. + pub async fn current_branch(repo_path: &Path) -> Result { + let output = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(repo_path) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| Error::io(format!("Failed to execute git rev-parse: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Error::git(format!("git rev-parse failed: {}", stderr))); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_is_git_repo_true() { + let temp = TempDir::new().unwrap(); + std::fs::create_dir(temp.path().join(".git")).unwrap(); + assert!(GitOps::is_git_repo(temp.path())); + } + + #[test] + fn test_is_git_repo_false() { + let temp = TempDir::new().unwrap(); + assert!(!GitOps::is_git_repo(temp.path())); + } + + #[test] + fn test_is_git_repo_nonexistent() { + assert!(!GitOps::is_git_repo(Path::new("/nonexistent/path"))); + } +} diff --git a/src/repo/index.rs b/src/repo/index.rs new file mode 100644 index 00000000..5338d029 --- /dev/null +++ b/src/repo/index.rs @@ -0,0 +1,502 @@ +//! Repository index for file-based searching. +//! +//! Provides a searchable index of repositories discovered from known organizations. +//! This enables issue-to-repository inference by matching file paths and names. + +use crate::error::Result; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; + +/// An indexed repository with file information. +#[derive(Debug, Clone)] +pub struct IndexedRepo { + /// Repository name (e.g., "appwrite/cloud"). + pub name: String, + /// Local filesystem path. + pub path: PathBuf, + /// GitHub URL inferred from org + name. + pub github_url: String, + /// Relative file paths within the repository. + pub files: Vec, + /// Default branch name. + pub default_branch: String, +} + +impl IndexedRepo { + /// Create a new indexed repository. + pub fn new(name: impl Into, path: impl Into) -> Self { + let name = name.into(); + let github_url = format!("https://github.com/{}", name); + Self { + name, + path: path.into(), + github_url, + files: Vec::new(), + default_branch: "main".to_string(), + } + } + + /// Set the GitHub URL. + pub fn with_github_url(mut self, url: impl Into) -> Self { + self.github_url = url.into(); + self + } + + /// Set the default branch. + pub fn with_default_branch(mut self, branch: impl Into) -> Self { + self.default_branch = branch.into(); + self + } + + /// Check if this repo contains a file with the given name. + pub fn has_file(&self, filename: &str) -> bool { + let filename_lower = filename.to_lowercase(); + self.files.iter().any(|f| { + f.to_lowercase().ends_with(&filename_lower) + || f.to_lowercase().contains(&filename_lower) + }) + } + + /// Find all files matching a query. + pub fn find_files(&self, query: &str) -> Vec<&str> { + let query_lower = query.to_lowercase(); + self.files + .iter() + .filter(|f| f.to_lowercase().contains(&query_lower)) + .map(|s| s.as_str()) + .collect() + } +} + +/// Index of discovered repositories. +#[derive(Debug, Default, Clone)] +pub struct RepoIndex { + /// Indexed repositories keyed by name. + repos: HashMap, + /// File path to repository name mapping for fast lookups. + file_index: HashMap, +} + +impl RepoIndex { + /// Create a new empty index. + pub fn new() -> Self { + Self { + repos: HashMap::new(), + file_index: HashMap::new(), + } + } + + /// Build an index by scanning auto_discover_paths for repos from known_orgs. + /// + /// # Arguments + /// * `known_orgs` - GitHub organization names to look for + /// * `paths` - Directories to scan for repositories + /// + /// # Returns + /// A populated RepoIndex with discovered repositories and their files. + pub fn build(known_orgs: &[String], paths: &[String]) -> Result { + let mut index = Self::new(); + let orgs_set: HashSet<_> = known_orgs.iter().map(|s| s.to_lowercase()).collect(); + + for path_str in paths { + let path = expand_path(path_str); + if !path.exists() { + tracing::warn!(path = %path.display(), "Auto-discover path does not exist"); + continue; + } + + tracing::info!(path = %path.display(), "Scanning for repositories"); + + // Walk the directory to find git repositories + for entry in WalkDir::new(&path) + .max_depth(3) // Don't go too deep + .into_iter() + .filter_entry(|e| !is_hidden(e)) + { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + + let entry_path = entry.path(); + + // Check if this is a git repository + if entry_path.join(".git").is_dir() { + // Try to determine the repository name from git remote + if let Some(repo_name) = get_repo_name_from_git(entry_path) { + // Check if this repo belongs to a known org + let org = repo_name.split('/').next().unwrap_or(""); + if orgs_set.contains(&org.to_lowercase()) { + tracing::debug!( + repo = %repo_name, + path = %entry_path.display(), + "Found repository from known org" + ); + + let mut repo = IndexedRepo::new(&repo_name, entry_path); + repo = index_files(repo); + index.add_repo(repo); + } + } + } + } + } + + tracing::info!( + count = index.repos.len(), + files = index.file_index.len(), + "Repository index built" + ); + + Ok(index) + } + + /// Add a repository to the index. + pub fn add_repo(&mut self, repo: IndexedRepo) { + // Index all files for fast lookup + for file in &repo.files { + // Index by full path + self.file_index.insert(file.clone(), repo.name.clone()); + + // Index by filename only (for basename matching) + if let Some(filename) = Path::new(file).file_name() { + self.file_index + .insert(filename.to_string_lossy().to_string(), repo.name.clone()); + } + } + + self.repos.insert(repo.name.clone(), repo); + } + + /// Find a repository by exact file path match. + pub fn find_by_file(&self, filename: &str) -> Option<&IndexedRepo> { + // Try exact match first + if let Some(repo_name) = self.file_index.get(filename) { + return self.repos.get(repo_name); + } + + // Try basename match + let basename = Path::new(filename) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| filename.to_string()); + + if let Some(repo_name) = self.file_index.get(&basename) { + return self.repos.get(repo_name); + } + + None + } + + /// Search for files matching a query across all repositories. + /// + /// Returns tuples of (repo, matching_file_path). + pub fn search_files(&self, query: &str) -> Vec<(&IndexedRepo, &str)> { + let mut results = Vec::new(); + + for repo in self.repos.values() { + for file in repo.find_files(query) { + results.push((repo, file)); + } + } + + results + } + + /// Get a repository by name. + pub fn get(&self, name: &str) -> Option<&IndexedRepo> { + self.repos.get(name) + } + + /// Get all indexed repositories. + pub fn list(&self) -> Vec<&IndexedRepo> { + self.repos.values().collect() + } + + /// Get the number of indexed repositories. + pub fn len(&self) -> usize { + self.repos.len() + } + + /// Check if the index is empty. + pub fn is_empty(&self) -> bool { + self.repos.is_empty() + } + + /// Get total file count across all repositories. + pub fn total_files(&self) -> usize { + self.repos.values().map(|r| r.files.len()).sum() + } +} + +/// Expand ~ to home directory. +fn expand_path(path: &str) -> PathBuf { + if path.starts_with('~') { + if let Some(home) = dirs::home_dir() { + return home.join(path.strip_prefix("~/").unwrap_or(&path[1..])); + } + } + PathBuf::from(path) +} + +/// Check if a directory entry is hidden. +fn is_hidden(entry: &walkdir::DirEntry) -> bool { + entry + .file_name() + .to_str() + .map(|s| s.starts_with('.')) + .unwrap_or(false) +} + +/// Get repository name from git remote origin. +fn get_repo_name_from_git(path: &Path) -> Option { + let output = std::process::Command::new("git") + .args(["remote", "get-url", "origin"]) + .current_dir(path) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); + parse_repo_name_from_url(&url) +} + +/// Parse repository name (org/repo) from a git URL. +fn parse_repo_name_from_url(url: &str) -> Option { + // Handle SSH URLs: git@github.com:org/repo.git + if url.starts_with("git@") { + let parts: Vec<_> = url.split(':').collect(); + if parts.len() == 2 { + let repo_part = parts[1].trim_end_matches(".git"); + return Some(repo_part.to_string()); + } + } + + // Handle HTTPS URLs: https://github.com/org/repo.git + if url.contains("github.com") { + let url = url.trim_end_matches(".git"); + let parts: Vec<_> = url.split('/').collect(); + if parts.len() >= 2 { + let org = parts[parts.len() - 2]; + let repo = parts[parts.len() - 1]; + return Some(format!("{}/{}", org, repo)); + } + } + + None +} + +/// Index all files in a repository. +fn index_files(mut repo: IndexedRepo) -> IndexedRepo { + let mut files = Vec::new(); + + for entry in WalkDir::new(&repo.path) + .into_iter() + .filter_entry(|e| { + // Skip hidden directories and common non-source directories + let name = e.file_name().to_string_lossy(); + !name.starts_with('.') + && name != "node_modules" + && name != "vendor" + && name != "target" + && name != "build" + && name != "dist" + && name != "__pycache__" + }) + .filter_map(|e| e.ok()) + { + if entry.file_type().is_file() { + // Store relative path from repo root + if let Ok(rel_path) = entry.path().strip_prefix(&repo.path) { + files.push(rel_path.to_string_lossy().to_string()); + } + } + } + + repo.files = files; + repo +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_indexed_repo_new() { + let repo = IndexedRepo::new("appwrite/cloud", "/path/to/cloud"); + assert_eq!(repo.name, "appwrite/cloud"); + assert_eq!(repo.github_url, "https://github.com/appwrite/cloud"); + assert_eq!(repo.default_branch, "main"); + } + + #[test] + fn test_indexed_repo_with_github_url() { + let repo = + IndexedRepo::new("test/repo", "/path").with_github_url("https://gitlab.com/test/repo"); + assert_eq!(repo.github_url, "https://gitlab.com/test/repo"); + } + + #[test] + fn test_indexed_repo_has_file() { + let mut repo = IndexedRepo::new("test/repo", "/path"); + repo.files = vec![ + "src/main.rs".to_string(), + "src/lib.rs".to_string(), + "README.md".to_string(), + ]; + + assert!(repo.has_file("main.rs")); + assert!(repo.has_file("src/main.rs")); + assert!(repo.has_file("README.md")); + assert!(!repo.has_file("nonexistent.txt")); + } + + #[test] + fn test_indexed_repo_find_files() { + let mut repo = IndexedRepo::new("test/repo", "/path"); + repo.files = vec![ + "src/main.rs".to_string(), + "src/lib.rs".to_string(), + "src/utils/helper.rs".to_string(), + ]; + + let matches = repo.find_files(".rs"); + assert_eq!(matches.len(), 3); + + let matches = repo.find_files("main"); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0], "src/main.rs"); + } + + #[test] + fn test_repo_index_new() { + let index = RepoIndex::new(); + assert!(index.is_empty()); + assert_eq!(index.len(), 0); + } + + #[test] + fn test_repo_index_add_repo() { + let mut index = RepoIndex::new(); + let mut repo = IndexedRepo::new("test/repo", "/path"); + repo.files = vec!["src/main.rs".to_string()]; + + index.add_repo(repo); + + assert_eq!(index.len(), 1); + assert!(index.get("test/repo").is_some()); + } + + #[test] + fn test_repo_index_find_by_file() { + let mut index = RepoIndex::new(); + + let mut repo1 = IndexedRepo::new("org/repo1", "/path1"); + repo1.files = vec!["src/main.rs".to_string(), "src/app.rs".to_string()]; + index.add_repo(repo1); + + let mut repo2 = IndexedRepo::new("org/repo2", "/path2"); + repo2.files = vec!["lib/utils.rs".to_string()]; + index.add_repo(repo2); + + // Find by full path + let found = index.find_by_file("src/main.rs"); + assert!(found.is_some()); + assert_eq!(found.unwrap().name, "org/repo1"); + + // Find by basename + let found = index.find_by_file("utils.rs"); + assert!(found.is_some()); + assert_eq!(found.unwrap().name, "org/repo2"); + + // Not found + let found = index.find_by_file("nonexistent.rs"); + assert!(found.is_none()); + } + + #[test] + fn test_repo_index_search_files() { + let mut index = RepoIndex::new(); + + let mut repo1 = IndexedRepo::new("org/repo1", "/path1"); + repo1.files = vec!["src/main.rs".to_string(), "src/router.rs".to_string()]; + index.add_repo(repo1); + + let mut repo2 = IndexedRepo::new("org/repo2", "/path2"); + repo2.files = vec!["lib/router.rs".to_string()]; + index.add_repo(repo2); + + let results = index.search_files("router"); + assert_eq!(results.len(), 2); + } + + #[test] + fn test_expand_path_home() { + let expanded = expand_path("~/test"); + if let Some(home) = dirs::home_dir() { + assert_eq!(expanded, home.join("test")); + } + } + + #[test] + fn test_expand_path_absolute() { + let expanded = expand_path("/absolute/path"); + assert_eq!(expanded, PathBuf::from("/absolute/path")); + } + + #[test] + fn test_parse_repo_name_ssh() { + let name = parse_repo_name_from_url("git@github.com:appwrite/cloud.git"); + assert_eq!(name, Some("appwrite/cloud".to_string())); + } + + #[test] + fn test_parse_repo_name_https() { + let name = parse_repo_name_from_url("https://github.com/appwrite/cloud.git"); + assert_eq!(name, Some("appwrite/cloud".to_string())); + } + + #[test] + fn test_parse_repo_name_https_no_git() { + let name = parse_repo_name_from_url("https://github.com/appwrite/cloud"); + assert_eq!(name, Some("appwrite/cloud".to_string())); + } + + #[test] + fn test_is_hidden() { + use walkdir::WalkDir; + let temp = TempDir::new().unwrap(); + std::fs::create_dir(temp.path().join(".hidden")).unwrap(); + std::fs::create_dir(temp.path().join("visible")).unwrap(); + + for entry in WalkDir::new(temp.path()).max_depth(1) { + let entry = entry.unwrap(); + let name = entry.file_name().to_string_lossy(); + if name == ".hidden" { + assert!(is_hidden(&entry)); + } else if name == "visible" { + assert!(!is_hidden(&entry)); + } + } + } + + #[test] + fn test_total_files() { + let mut index = RepoIndex::new(); + + let mut repo1 = IndexedRepo::new("org/repo1", "/path1"); + repo1.files = vec!["a.rs".to_string(), "b.rs".to_string()]; + index.add_repo(repo1); + + let mut repo2 = IndexedRepo::new("org/repo2", "/path2"); + repo2.files = vec!["c.rs".to_string()]; + index.add_repo(repo2); + + assert_eq!(index.total_files(), 3); + } +} diff --git a/src/repo/mod.rs b/src/repo/mod.rs new file mode 100644 index 00000000..9c3d90fb --- /dev/null +++ b/src/repo/mod.rs @@ -0,0 +1,15 @@ +//! Multi-repository support module. +//! +//! Provides git operations, dependency tracking, cascading changes, and repository indexing. + +mod discovery; +mod git; +mod index; +mod relationships; + +pub use discovery::{DependencyDiscovery, DiscoveredDependency}; +pub use git::GitOps; +pub use index::{IndexedRepo, RepoIndex}; +pub use relationships::{ + CascadingChange, DependencyGraph, DependencyType, RepoRelationships, Repository, +}; diff --git a/src/repo/relationships.rs b/src/repo/relationships.rs new file mode 100644 index 00000000..c1088536 --- /dev/null +++ b/src/repo/relationships.rs @@ -0,0 +1,861 @@ +//! Repository dependency tracking and relationships. + +use crate::error::Result; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; + +/// Type of dependency between repositories. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum DependencyType { + /// NPM dependency + Npm, + /// Composer (PHP) dependency + Composer, + /// Git submodule + GitSubmodule, + /// Manually defined relationship + Manual, +} + +impl DependencyType { + /// Parse from string. + pub fn parse(s: &str) -> Option { + match s.to_lowercase().as_str() { + "npm" => Some(DependencyType::Npm), + "composer" => Some(DependencyType::Composer), + "git_submodule" | "submodule" => Some(DependencyType::GitSubmodule), + "manual" => Some(DependencyType::Manual), + _ => None, + } + } + + /// Convert to string. + pub fn as_str(&self) -> &'static str { + match self { + DependencyType::Npm => "npm", + DependencyType::Composer => "composer", + DependencyType::GitSubmodule => "git_submodule", + DependencyType::Manual => "manual", + } + } +} + +/// A repository in the dependency graph. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Repository { + /// Unique repository name/identifier. + pub name: String, + /// Local filesystem path (if available). + pub path: Option, + /// GitHub URL (owner/repo format or full URL). + pub github_url: Option, + /// When this repository was added. + pub created_at: DateTime, +} + +impl Repository { + /// Create a new repository. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + path: None, + github_url: None, + created_at: Utc::now(), + } + } + + /// Set the local path. + pub fn with_path(mut self, path: impl Into) -> Self { + self.path = Some(path.into()); + self + } + + /// Set the GitHub URL. + pub fn with_github_url(mut self, url: impl Into) -> Self { + self.github_url = Some(url.into()); + self + } +} + +/// A dependency relationship between two repositories. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Dependency { + /// The upstream repository name (depended upon). + pub upstream: String, + /// The downstream repository name (depends on upstream). + pub downstream: String, + /// Type of dependency. + pub dep_type: DependencyType, + /// Version pattern (e.g., "^1.0.0" for npm). + pub version_pattern: Option, + /// When this relationship was created. + pub created_at: DateTime, +} + +/// A cascading change triggered by a merged PR. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CascadingChange { + /// The repository that triggered this change. + pub trigger_repo: String, + /// The repository that needs to be updated. + pub target_repo: String, + /// Type of change needed. + pub change_type: CascadeChangeType, + /// Status of this cascading change. + pub status: CascadeStatus, + /// PR URL if created. + pub pr_url: Option, + /// Created timestamp. + pub created_at: DateTime, + /// Completed timestamp. + pub completed_at: Option>, +} + +/// Type of cascading change. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CascadeChangeType { + /// Bump the version dependency. + VersionBump, + /// Update code to match API changes. + CodeChange, + /// Update tests. + TestUpdate, +} + +/// Status of a cascading change. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CascadeStatus { + /// Change is pending. + Pending, + /// Change is in progress. + InProgress, + /// Change completed successfully. + Completed, + /// Change failed. + Failed, + /// Change was skipped. + Skipped, +} + +/// Dependency graph for repositories. +#[derive(Debug, Clone, Default)] +pub struct DependencyGraph { + /// All repositories. + repositories: HashMap, + /// Dependencies: upstream -> list of downstreams that depend on it. + downstream_deps: HashMap>, + /// Dependencies: downstream -> list of upstreams it depends on. + upstream_deps: HashMap>, +} + +impl DependencyGraph { + /// Create a new empty dependency graph. + pub fn new() -> Self { + Self::default() + } + + /// Add a repository to the graph. + pub fn add_repository(&mut self, repo: Repository) { + self.repositories.insert(repo.name.clone(), repo); + } + + /// Get a repository by name. + pub fn get_repository(&self, name: &str) -> Option<&Repository> { + self.repositories.get(name) + } + + /// Get all repositories. + pub fn repositories(&self) -> impl Iterator { + self.repositories.values() + } + + /// Add a dependency relationship. + pub fn add_dependency(&mut self, dep: Dependency) { + // Add to downstream deps (upstream -> downstreams) + self.downstream_deps + .entry(dep.upstream.clone()) + .or_default() + .push(dep.clone()); + + // Add to upstream deps (downstream -> upstreams) + self.upstream_deps + .entry(dep.downstream.clone()) + .or_default() + .push(dep); + } + + /// Get all repositories that depend on the given repository (direct dependants). + pub fn get_dependants(&self, repo: &str) -> Vec<&Repository> { + self.downstream_deps + .get(repo) + .map(|deps| { + deps.iter() + .filter_map(|d| self.repositories.get(&d.downstream)) + .collect() + }) + .unwrap_or_default() + } + + /// Get all repositories that the given repository depends on (direct dependencies). + pub fn get_dependencies(&self, repo: &str) -> Vec<&Repository> { + self.upstream_deps + .get(repo) + .map(|deps| { + deps.iter() + .filter_map(|d| self.repositories.get(&d.upstream)) + .collect() + }) + .unwrap_or_default() + } + + /// Get all downstream dependants transitively (BFS). + pub fn get_all_dependants(&self, repo: &str) -> Vec<&Repository> { + let mut visited = HashSet::new(); + let mut result = Vec::new(); + let mut queue = vec![repo.to_string()]; + + while let Some(current) = queue.pop() { + if visited.contains(¤t) { + continue; + } + visited.insert(current.clone()); + + if let Some(deps) = self.downstream_deps.get(¤t) { + for dep in deps { + if !visited.contains(&dep.downstream) { + if let Some(r) = self.repositories.get(&dep.downstream) { + result.push(r); + queue.push(dep.downstream.clone()); + } + } + } + } + } + + result + } + + /// Check if repo A depends on repo B (directly or transitively). + pub fn depends_on(&self, repo_a: &str, repo_b: &str) -> bool { + let mut visited = HashSet::new(); + let mut queue = vec![repo_a.to_string()]; + + while let Some(current) = queue.pop() { + if visited.contains(¤t) { + continue; + } + visited.insert(current.clone()); + + if let Some(deps) = self.upstream_deps.get(¤t) { + for dep in deps { + if dep.upstream == repo_b { + return true; + } + if !visited.contains(&dep.upstream) { + queue.push(dep.upstream.clone()); + } + } + } + } + + false + } + + /// Get the dependency graph as a printable string. + pub fn to_string_tree(&self, root: Option<&str>) -> String { + let mut output = String::new(); + + if let Some(root_name) = root { + self.print_subtree(root_name, 0, &mut output, &mut HashSet::new()); + } else { + // Find root nodes (repos with no upstream dependencies) + let roots: Vec<_> = self + .repositories + .keys() + .filter(|name| !self.upstream_deps.contains_key(*name)) + .collect(); + + for root in roots { + self.print_subtree(root, 0, &mut output, &mut HashSet::new()); + output.push('\n'); + } + } + + output + } + + fn print_subtree( + &self, + name: &str, + depth: usize, + output: &mut String, + visited: &mut HashSet, + ) { + if visited.contains(name) { + output.push_str(&format!("{}{} (cycle)\n", " ".repeat(depth), name)); + return; + } + visited.insert(name.to_string()); + + output.push_str(&format!("{}{}\n", " ".repeat(depth), name)); + + if let Some(deps) = self.downstream_deps.get(name) { + for dep in deps { + self.print_subtree(&dep.downstream, depth + 1, output, visited); + } + } + } +} + +/// Manages repository relationships and dependencies. +pub struct RepoRelationships { + graph: DependencyGraph, +} + +impl RepoRelationships { + /// Create a new repository relationships manager. + pub fn new() -> Self { + Self { + graph: DependencyGraph::new(), + } + } + + /// Create with default repository relationships. + /// + /// Dependencies are now discovered automatically from the repo index, + /// so this method simply returns a manager seeded with Appwrite defaults. + pub fn with_defaults() -> Self { + let mut manager = Self::new(); + manager.seed_appwrite_defaults(); + manager + } + + /// Create with default Appwrite relationships. + pub fn with_appwrite_defaults() -> Self { + let mut manager = Self::new(); + manager.seed_appwrite_defaults(); + manager + } + + /// Seed default Appwrite repository relationships. + pub fn seed_appwrite_defaults(&mut self) { + // Add repositories + self.graph + .add_repository(Repository::new("cloud").with_github_url("appwrite/cloud")); + self.graph + .add_repository(Repository::new("appwrite").with_github_url("appwrite/appwrite")); + self.graph + .add_repository(Repository::new("utopia-php").with_github_url("utopia-php/utopia-php")); + self.graph.add_repository( + Repository::new("utopia-database").with_github_url("utopia-php/database"), + ); + self.graph + .add_repository(Repository::new("utopia-http").with_github_url("utopia-php/http")); + self.graph + .add_repository(Repository::new("utopia-cache").with_github_url("utopia-php/cache")); + + // Add dependencies: upstream -> downstream (upstream is depended upon) + // cloud depends on appwrite (appwrite is upstream, cloud is downstream) + // appwrite depends on utopia-* (utopia-* are upstream, appwrite is downstream) + self.add_dependency("appwrite", "cloud", DependencyType::Composer, None) + .ok(); + self.add_dependency("utopia-php", "appwrite", DependencyType::Composer, None) + .ok(); + self.add_dependency( + "utopia-database", + "appwrite", + DependencyType::Composer, + None, + ) + .ok(); + self.add_dependency("utopia-http", "appwrite", DependencyType::Composer, None) + .ok(); + self.add_dependency("utopia-cache", "appwrite", DependencyType::Composer, None) + .ok(); + } + + /// Add a repository. + pub fn add_repository(&mut self, repo: Repository) { + self.graph.add_repository(repo); + } + + /// Get a repository by name. + pub fn get_repository(&self, name: &str) -> Option<&Repository> { + self.graph.get_repository(name) + } + + /// Get all repositories. + pub fn list_repositories(&self) -> Vec<&Repository> { + self.graph.repositories().collect() + } + + /// Add a dependency relationship. + /// + /// This also ensures both repositories exist in the graph. + pub fn add_dependency( + &mut self, + upstream: &str, + downstream: &str, + dep_type: DependencyType, + version_pattern: Option, + ) -> Result<()> { + // Ensure both repos exist in the graph + if self.graph.get_repository(upstream).is_none() { + self.graph.add_repository( + Repository::new(upstream).with_github_url(upstream), + ); + } + if self.graph.get_repository(downstream).is_none() { + self.graph.add_repository( + Repository::new(downstream).with_github_url(downstream), + ); + } + + let dep = Dependency { + upstream: upstream.to_string(), + downstream: downstream.to_string(), + dep_type, + version_pattern, + created_at: Utc::now(), + }; + self.graph.add_dependency(dep); + Ok(()) + } + + /// Get all repositories that depend on the given repository. + pub fn get_dependants(&self, repo: &str) -> Vec<&Repository> { + self.graph.get_dependants(repo) + } + + /// Get all repositories that the given repository depends on. + pub fn get_dependencies(&self, repo: &str) -> Vec<&Repository> { + self.graph.get_dependencies(repo) + } + + /// Get all dependants transitively. + pub fn get_all_dependants(&self, repo: &str) -> Vec<&Repository> { + self.graph.get_all_dependants(repo) + } + + /// Get the dependency graph. + pub fn get_graph(&self) -> &DependencyGraph { + &self.graph + } + + /// Print the dependency tree. + pub fn print_tree(&self, root: Option<&str>) -> String { + self.graph.to_string_tree(root) + } + + /// When a PR is merged in a repository, determine what downstream changes are needed. + pub fn get_cascade_changes(&self, repo: &str) -> Vec { + let dependants = self.get_dependants(repo); + + dependants + .into_iter() + .map(|target| CascadingChange { + trigger_repo: repo.to_string(), + target_repo: target.name.clone(), + change_type: CascadeChangeType::VersionBump, + status: CascadeStatus::Pending, + pr_url: None, + created_at: Utc::now(), + completed_at: None, + }) + .collect() + } +} + +impl Default for RepoRelationships { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dependency_type_parse() { + assert_eq!(DependencyType::parse("npm"), Some(DependencyType::Npm)); + assert_eq!( + DependencyType::parse("composer"), + Some(DependencyType::Composer) + ); + assert_eq!( + DependencyType::parse("manual"), + Some(DependencyType::Manual) + ); + assert_eq!(DependencyType::parse("invalid"), None); + } + + #[test] + fn test_add_repository() { + let mut manager = RepoRelationships::new(); + manager.add_repository(Repository::new("test-repo")); + + assert!(manager.get_repository("test-repo").is_some()); + assert!(manager.get_repository("nonexistent").is_none()); + } + + #[test] + fn test_add_dependency() { + let mut manager = RepoRelationships::new(); + manager.add_repository(Repository::new("upstream")); + manager.add_repository(Repository::new("downstream")); + + manager + .add_dependency("upstream", "downstream", DependencyType::Npm, None) + .unwrap(); + + let dependants = manager.get_dependants("upstream"); + assert_eq!(dependants.len(), 1); + assert_eq!(dependants[0].name, "downstream"); + + let dependencies = manager.get_dependencies("downstream"); + assert_eq!(dependencies.len(), 1); + assert_eq!(dependencies[0].name, "upstream"); + } + + #[test] + fn test_transitive_dependants() { + let mut manager = RepoRelationships::new(); + manager.add_repository(Repository::new("lib")); + manager.add_repository(Repository::new("core")); + manager.add_repository(Repository::new("app")); + + manager + .add_dependency("lib", "core", DependencyType::Npm, None) + .unwrap(); + manager + .add_dependency("core", "app", DependencyType::Npm, None) + .unwrap(); + + // lib -> core -> app + let all_dependants = manager.get_all_dependants("lib"); + assert_eq!(all_dependants.len(), 2); + + let names: Vec<_> = all_dependants.iter().map(|r| r.name.as_str()).collect(); + assert!(names.contains(&"core")); + assert!(names.contains(&"app")); + } + + #[test] + fn test_appwrite_defaults() { + let manager = RepoRelationships::with_appwrite_defaults(); + + // Check repositories exist + assert!(manager.get_repository("cloud").is_some()); + assert!(manager.get_repository("appwrite").is_some()); + assert!(manager.get_repository("utopia-php").is_some()); + + // cloud depends on appwrite + let cloud_deps = manager.get_dependencies("cloud"); + assert_eq!(cloud_deps.len(), 1); + assert_eq!(cloud_deps[0].name, "appwrite"); + + // appwrite is depended on by cloud + let appwrite_dependants = manager.get_dependants("appwrite"); + assert_eq!(appwrite_dependants.len(), 1); + assert_eq!(appwrite_dependants[0].name, "cloud"); + } + + #[test] + fn test_cascade_changes() { + let mut manager = RepoRelationships::new(); + manager.add_repository(Repository::new("lib")); + manager.add_repository(Repository::new("app1")); + manager.add_repository(Repository::new("app2")); + + manager + .add_dependency("lib", "app1", DependencyType::Npm, None) + .unwrap(); + manager + .add_dependency("lib", "app2", DependencyType::Npm, None) + .unwrap(); + + let changes = manager.get_cascade_changes("lib"); + assert_eq!(changes.len(), 2); + + let targets: Vec<_> = changes.iter().map(|c| c.target_repo.as_str()).collect(); + assert!(targets.contains(&"app1")); + assert!(targets.contains(&"app2")); + } + + #[test] + fn test_print_tree() { + let mut manager = RepoRelationships::new(); + manager.add_repository(Repository::new("root")); + manager.add_repository(Repository::new("child1")); + manager.add_repository(Repository::new("child2")); + + manager + .add_dependency("root", "child1", DependencyType::Manual, None) + .unwrap(); + manager + .add_dependency("root", "child2", DependencyType::Manual, None) + .unwrap(); + + let tree = manager.print_tree(Some("root")); + assert!(tree.contains("root")); + assert!(tree.contains("child1")); + assert!(tree.contains("child2")); + } + + #[test] + fn test_depends_on() { + let mut manager = RepoRelationships::new(); + manager.add_repository(Repository::new("a")); + manager.add_repository(Repository::new("b")); + manager.add_repository(Repository::new("c")); + + manager + .add_dependency("a", "b", DependencyType::Manual, None) + .unwrap(); + manager + .add_dependency("b", "c", DependencyType::Manual, None) + .unwrap(); + + // c depends on b (directly) + assert!(manager.get_graph().depends_on("c", "b")); + // c depends on a (transitively) + assert!(manager.get_graph().depends_on("c", "a")); + // a does not depend on c + assert!(!manager.get_graph().depends_on("a", "c")); + } + + #[test] + fn test_dependency_type_as_str() { + assert_eq!(DependencyType::Npm.as_str(), "npm"); + assert_eq!(DependencyType::Composer.as_str(), "composer"); + assert_eq!(DependencyType::GitSubmodule.as_str(), "git_submodule"); + assert_eq!(DependencyType::Manual.as_str(), "manual"); + } + + #[test] + fn test_dependency_type_parse_submodule() { + assert_eq!( + DependencyType::parse("git_submodule"), + Some(DependencyType::GitSubmodule) + ); + assert_eq!( + DependencyType::parse("submodule"), + Some(DependencyType::GitSubmodule) + ); + } + + #[test] + fn test_repository_with_path() { + let repo = Repository::new("my-repo").with_path("/path/to/repo"); + assert_eq!(repo.name, "my-repo"); + assert_eq!(repo.path, Some("/path/to/repo".to_string())); + } + + #[test] + fn test_repository_with_github_url() { + let repo = Repository::new("my-repo").with_github_url("https://github.com/org/repo"); + assert_eq!( + repo.github_url, + Some("https://github.com/org/repo".to_string()) + ); + } + + #[test] + fn test_repository_created_at() { + let repo = Repository::new("my-repo"); + // Should be recent (within last minute) + let diff = Utc::now() - repo.created_at; + assert!(diff.num_seconds() < 60); + } + + #[test] + fn test_dependency_graph_empty() { + let graph = DependencyGraph::new(); + assert_eq!(graph.repositories().count(), 0); + } + + #[test] + fn test_dependency_graph_get_nonexistent_repo() { + let graph = DependencyGraph::new(); + assert!(graph.get_repository("nonexistent").is_none()); + } + + #[test] + fn test_get_dependants_empty() { + let graph = DependencyGraph::new(); + let dependants = graph.get_dependants("nonexistent"); + assert!(dependants.is_empty()); + } + + #[test] + fn test_get_dependencies_empty() { + let graph = DependencyGraph::new(); + let dependencies = graph.get_dependencies("nonexistent"); + assert!(dependencies.is_empty()); + } + + #[test] + fn test_list_repositories() { + let mut manager = RepoRelationships::new(); + manager.add_repository(Repository::new("repo1")); + manager.add_repository(Repository::new("repo2")); + + let repos = manager.list_repositories(); + assert_eq!(repos.len(), 2); + } + + #[test] + fn test_dependency_with_version_pattern() { + let mut manager = RepoRelationships::new(); + manager.add_repository(Repository::new("lib")); + manager.add_repository(Repository::new("app")); + + manager + .add_dependency( + "lib", + "app", + DependencyType::Npm, + Some("^1.0.0".to_string()), + ) + .unwrap(); + + let deps = manager.get_dependencies("app"); + assert_eq!(deps.len(), 1); + } + + #[test] + fn test_cascade_change_fields() { + let change = CascadingChange { + trigger_repo: "upstream".to_string(), + target_repo: "downstream".to_string(), + change_type: CascadeChangeType::VersionBump, + status: CascadeStatus::Pending, + pr_url: None, + created_at: Utc::now(), + completed_at: None, + }; + + assert_eq!(change.trigger_repo, "upstream"); + assert_eq!(change.target_repo, "downstream"); + assert_eq!(change.change_type, CascadeChangeType::VersionBump); + assert_eq!(change.status, CascadeStatus::Pending); + } + + #[test] + fn test_cascade_change_types() { + assert_eq!( + CascadeChangeType::VersionBump, + CascadeChangeType::VersionBump + ); + assert_eq!(CascadeChangeType::CodeChange, CascadeChangeType::CodeChange); + assert_eq!(CascadeChangeType::TestUpdate, CascadeChangeType::TestUpdate); + assert_ne!( + CascadeChangeType::VersionBump, + CascadeChangeType::CodeChange + ); + } + + #[test] + fn test_cascade_status_variants() { + assert_eq!(CascadeStatus::Pending, CascadeStatus::Pending); + assert_eq!(CascadeStatus::InProgress, CascadeStatus::InProgress); + assert_eq!(CascadeStatus::Completed, CascadeStatus::Completed); + assert_eq!(CascadeStatus::Failed, CascadeStatus::Failed); + assert_eq!(CascadeStatus::Skipped, CascadeStatus::Skipped); + } + + #[test] + fn test_print_tree_root_only() { + let mut manager = RepoRelationships::new(); + manager.add_repository(Repository::new("root")); + + let tree = manager.print_tree(Some("root")); + assert!(tree.contains("root")); + } + + #[test] + fn test_dependency_graph_cycle_detection() { + let mut graph = DependencyGraph::new(); + graph.add_repository(Repository::new("a")); + graph.add_repository(Repository::new("b")); + + // Add dependencies that create a cycle (for tree printing) + graph.add_dependency(Dependency { + upstream: "a".to_string(), + downstream: "b".to_string(), + dep_type: DependencyType::Manual, + version_pattern: None, + created_at: Utc::now(), + }); + graph.add_dependency(Dependency { + upstream: "b".to_string(), + downstream: "a".to_string(), + dep_type: DependencyType::Manual, + version_pattern: None, + created_at: Utc::now(), + }); + + let tree = graph.to_string_tree(Some("a")); + // Should detect cycle + assert!(tree.contains("cycle") || tree.contains("a") || tree.contains("b")); + } + + #[test] + fn test_repo_relationships_default() { + let manager = RepoRelationships::default(); + assert!(manager.list_repositories().is_empty()); + } + + #[test] + fn test_get_cascade_changes_no_dependants() { + let mut manager = RepoRelationships::new(); + manager.add_repository(Repository::new("standalone")); + + let changes = manager.get_cascade_changes("standalone"); + assert!(changes.is_empty()); + } + + #[test] + fn test_dependency_serialization() { + let dep = Dependency { + upstream: "lib".to_string(), + downstream: "app".to_string(), + dep_type: DependencyType::Npm, + version_pattern: Some("^1.0.0".to_string()), + created_at: Utc::now(), + }; + + let json = serde_json::to_string(&dep).unwrap(); + assert!(json.contains("lib")); + assert!(json.contains("app")); + // Serde serializes the enum variant name as "Npm" + assert!(json.contains("Npm")); + } + + #[test] + fn test_repository_serialization() { + let repo = Repository::new("my-repo") + .with_path("/path") + .with_github_url("https://github.com/org/repo"); + + let json = serde_json::to_string(&repo).unwrap(); + assert!(json.contains("my-repo")); + assert!(json.contains("/path")); + } + + #[test] + fn test_appwrite_defaults_utopia_deps() { + let manager = RepoRelationships::with_appwrite_defaults(); + + // appwrite should depend on utopia-php + let appwrite_deps = manager.get_dependencies("appwrite"); + let dep_names: Vec<_> = appwrite_deps.iter().map(|r| r.name.as_str()).collect(); + + assert!(dep_names.contains(&"utopia-php")); + assert!(dep_names.contains(&"utopia-database")); + assert!(dep_names.contains(&"utopia-http")); + assert!(dep_names.contains(&"utopia-cache")); + } +} diff --git a/src/reports/generator.rs b/src/reports/generator.rs new file mode 100644 index 00000000..3c80c4b4 --- /dev/null +++ b/src/reports/generator.rs @@ -0,0 +1,570 @@ +//! Report generation logic. + +use crate::error::Result; +use crate::storage::FixAttemptTracker; +use crate::types::FixAttemptStatus; +use chrono::{DateTime, Duration, Utc}; +use serde::Serialize; +use std::collections::HashMap; +use std::sync::Arc; + +/// A recurring issue pattern. +#[derive(Debug, Clone, Serialize)] +pub struct RecurringIssue { + /// Pattern or category of the issue + pub pattern: String, + /// Number of occurrences + pub count: usize, + /// Sources where this pattern appears + pub sources: Vec, +} + +/// A generated report. +#[derive(Debug, Clone, Serialize)] +pub struct Report { + /// Human-readable period description + pub period: String, + /// Start of the report period + pub from: DateTime, + /// End of the report period + pub to: DateTime, + /// Total issues attempted + pub issues_attempted: usize, + /// Issues that succeeded (PR created or merged) + pub issues_succeeded: usize, + /// Issues that failed + pub issues_failed: usize, + /// Issues marked as cannot fix + pub issues_cannot_fix: usize, + /// Success rate as percentage + pub success_rate: f64, + /// Failure rate as percentage + pub failure_rate: f64, + /// PRs created + pub prs_created: usize, + /// PRs merged + pub prs_merged: usize, + /// PRs closed without merge + pub prs_closed: usize, + /// Breakdown by source + pub by_source: HashMap, + /// Issues currently pending + pub pending_count: usize, + /// Issues ready for retry + pub retryable_count: usize, +} + +/// Report statistics for a single source. +#[derive(Debug, Clone, Serialize)] +pub struct SourceReport { + pub name: String, + pub attempted: usize, + pub succeeded: usize, + pub failed: usize, + pub merged: usize, +} + +/// Generates reports from tracking data. +pub struct ReportGenerator { + tracker: Arc, +} + +impl ReportGenerator { + /// Create a new report generator. + pub fn new(tracker: Arc) -> Self { + Self { tracker } + } + + /// Generate a report for the specified time period. + pub fn generate(&self, from: DateTime, to: DateTime) -> Result { + let _stats = self.tracker.get_stats()?; + + // Get attempts by status + let pending = self + .tracker + .get_attempts_by_status(FixAttemptStatus::Pending)?; + let success = self + .tracker + .get_attempts_by_status(FixAttemptStatus::Success)?; + let failed = self + .tracker + .get_attempts_by_status(FixAttemptStatus::Failed)?; + let merged = self + .tracker + .get_attempts_by_status(FixAttemptStatus::Merged)?; + let closed = self + .tracker + .get_attempts_by_status(FixAttemptStatus::Closed)?; + let cannot_fix = self + .tracker + .get_attempts_by_status(FixAttemptStatus::CannotFix)?; + + // Filter to time period + let in_period = |attempt: &crate::types::FixAttempt| { + attempt.attempted_at >= from && attempt.attempted_at <= to + }; + + let period_success: Vec<_> = success.into_iter().filter(|a| in_period(a)).collect(); + let period_failed: Vec<_> = failed.into_iter().filter(|a| in_period(a)).collect(); + let period_merged: Vec<_> = merged.into_iter().filter(|a| in_period(a)).collect(); + let period_closed: Vec<_> = closed.into_iter().filter(|a| in_period(a)).collect(); + let period_cannot_fix: Vec<_> = cannot_fix.into_iter().filter(|a| in_period(a)).collect(); + + // Count by source + let mut by_source: HashMap = HashMap::new(); + + for attempt in &period_success { + let entry = by_source + .entry(attempt.source.clone()) + .or_insert_with(|| SourceReport { + name: attempt.source.clone(), + attempted: 0, + succeeded: 0, + failed: 0, + merged: 0, + }); + entry.attempted += 1; + entry.succeeded += 1; + } + + for attempt in &period_merged { + let entry = by_source + .entry(attempt.source.clone()) + .or_insert_with(|| SourceReport { + name: attempt.source.clone(), + attempted: 0, + succeeded: 0, + failed: 0, + merged: 0, + }); + entry.attempted += 1; + entry.succeeded += 1; + entry.merged += 1; + } + + for attempt in &period_failed { + let entry = by_source + .entry(attempt.source.clone()) + .or_insert_with(|| SourceReport { + name: attempt.source.clone(), + attempted: 0, + succeeded: 0, + failed: 0, + merged: 0, + }); + entry.attempted += 1; + entry.failed += 1; + } + + for attempt in &period_cannot_fix { + let entry = by_source + .entry(attempt.source.clone()) + .or_insert_with(|| SourceReport { + name: attempt.source.clone(), + attempted: 0, + succeeded: 0, + failed: 0, + merged: 0, + }); + entry.attempted += 1; + entry.failed += 1; + } + + for attempt in &period_closed { + let entry = by_source + .entry(attempt.source.clone()) + .or_insert_with(|| SourceReport { + name: attempt.source.clone(), + attempted: 0, + succeeded: 0, + failed: 0, + merged: 0, + }); + entry.attempted += 1; + } + + // Calculate totals + let issues_succeeded = period_success.len() + period_merged.len(); + let issues_failed = period_failed.len() + period_cannot_fix.len(); + let issues_attempted = issues_succeeded + issues_failed + period_closed.len(); + + let success_rate = if issues_attempted > 0 { + issues_succeeded as f64 / issues_attempted as f64 * 100.0 + } else { + 0.0 + }; + + let failure_rate = if issues_attempted > 0 { + issues_failed as f64 / issues_attempted as f64 * 100.0 + } else { + 0.0 + }; + + // Get retryable issues + let retryable = self.tracker.get_retryable_issues(2).unwrap_or_default(); + + // Generate period description + let period = Self::describe_period(from, to); + + Ok(Report { + period, + from, + to, + issues_attempted, + issues_succeeded, + issues_failed, + issues_cannot_fix: period_cannot_fix.len(), + success_rate, + failure_rate, + prs_created: period_success.len() + period_merged.len() + period_closed.len(), + prs_merged: period_merged.len(), + prs_closed: period_closed.len(), + by_source, + pending_count: pending.len(), + retryable_count: retryable.len(), + }) + } + + /// Generate a daily report (last 24 hours). + pub fn generate_daily(&self) -> Result { + let to = Utc::now(); + let from = to - Duration::days(1); + self.generate(from, to) + } + + /// Generate a weekly report (last 7 days). + pub fn generate_weekly(&self) -> Result { + let to = Utc::now(); + let from = to - Duration::days(7); + self.generate(from, to) + } + + /// Generate a monthly report (last 30 days). + pub fn generate_monthly(&self) -> Result { + let to = Utc::now(); + let from = to - Duration::days(30); + self.generate(from, to) + } + + /// Generate an all-time report. + pub fn generate_all_time(&self) -> Result { + let to = Utc::now(); + let from = DateTime::from_timestamp(0, 0).unwrap_or(to); + self.generate(from, to) + } + + /// Describe the time period in human-readable form. + fn describe_period(from: DateTime, to: DateTime) -> String { + let duration = to - from; + let days = duration.num_days(); + + if days <= 1 { + "Last 24 Hours".to_string() + } else if days <= 7 { + "Last 7 Days".to_string() + } else if days <= 30 { + "Last 30 Days".to_string() + } else { + format!("{} to {}", from.format("%Y-%m-%d"), to.format("%Y-%m-%d")) + } + } +} + +impl Report { + /// Format the report as a plain text summary. + pub fn format_text(&self) -> String { + let mut text = String::new(); + + text.push_str(&format!("# Report: {}\n", self.period)); + text.push_str(&format!( + "Period: {} to {}\n\n", + self.from.format("%Y-%m-%d %H:%M UTC"), + self.to.format("%Y-%m-%d %H:%M UTC") + )); + + text.push_str("## Summary\n"); + text.push_str(&format!("- Issues Attempted: {}\n", self.issues_attempted)); + text.push_str(&format!( + "- Succeeded: {} ({:.1}%)\n", + self.issues_succeeded, self.success_rate + )); + text.push_str(&format!( + "- Failed: {} ({:.1}%)\n", + self.issues_failed, self.failure_rate + )); + text.push_str(&format!("- Cannot Fix: {}\n", self.issues_cannot_fix)); + text.push('\n'); + + text.push_str("## Pull Requests\n"); + text.push_str(&format!("- Created: {}\n", self.prs_created)); + text.push_str(&format!("- Merged: {}\n", self.prs_merged)); + text.push_str(&format!("- Closed: {}\n", self.prs_closed)); + text.push('\n'); + + text.push_str("## Current Status\n"); + text.push_str(&format!("- Pending: {}\n", self.pending_count)); + text.push_str(&format!("- Retryable: {}\n", self.retryable_count)); + text.push('\n'); + + if !self.by_source.is_empty() { + text.push_str("## By Source\n"); + for (name, source) in &self.by_source { + text.push_str(&format!( + "- {}: {} attempted, {} succeeded, {} failed, {} merged\n", + name, source.attempted, source.succeeded, source.failed, source.merged + )); + } + } + + text + } + + /// Format the report as a brief summary for SMS/Push. + pub fn format_brief(&self) -> String { + format!( + "Report: {} | {} attempted, {:.0}% success, {} merged, {} pending", + self.period, + self.issues_attempted, + self.success_rate, + self.prs_merged, + self.pending_count + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::SqliteTracker; + use tempfile::TempDir; + + fn create_test_tracker() -> (TempDir, Arc) { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let tracker = SqliteTracker::new(db_path.to_str().unwrap()).unwrap(); + (temp_dir, Arc::new(tracker)) + } + + #[test] + fn test_generate_empty_report() { + let (_temp, tracker) = create_test_tracker(); + let generator = ReportGenerator::new(tracker); + + let to = Utc::now(); + let from = to - Duration::days(1); + let report = generator.generate(from, to).unwrap(); + + assert_eq!(report.issues_attempted, 0); + assert_eq!(report.issues_succeeded, 0); + assert_eq!(report.issues_failed, 0); + assert_eq!(report.success_rate, 0.0); + } + + #[test] + fn test_generate_daily_report() { + let (_temp, tracker) = create_test_tracker(); + let generator = ReportGenerator::new(tracker); + + let report = generator.generate_daily().unwrap(); + assert!(report.period.contains("24 Hours")); + } + + #[test] + fn test_generate_weekly_report() { + let (_temp, tracker) = create_test_tracker(); + let generator = ReportGenerator::new(tracker); + + let report = generator.generate_weekly().unwrap(); + assert!(report.period.contains("7 Days")); + } + + #[test] + fn test_format_text() { + let (_temp, tracker) = create_test_tracker(); + let generator = ReportGenerator::new(tracker); + + let report = generator.generate_daily().unwrap(); + let text = report.format_text(); + + assert!(text.contains("Report:")); + assert!(text.contains("Summary")); + assert!(text.contains("Pull Requests")); + } + + #[test] + fn test_format_brief() { + let (_temp, tracker) = create_test_tracker(); + let generator = ReportGenerator::new(tracker); + + let report = generator.generate_daily().unwrap(); + let brief = report.format_brief(); + + assert!(brief.contains("attempted")); + assert!(brief.contains("success")); + assert!(brief.contains("merged")); + } + + #[test] + fn test_generate_monthly_report() { + let (_temp, tracker) = create_test_tracker(); + let generator = ReportGenerator::new(tracker); + + let report = generator.generate_monthly().unwrap(); + assert!(report.period.contains("30 Days")); + } + + #[test] + fn test_generate_all_time_report() { + let (_temp, tracker) = create_test_tracker(); + let generator = ReportGenerator::new(tracker); + + let report = generator.generate_all_time().unwrap(); + // All-time should have a date range format + assert!(report.period.contains("to") || report.period.contains("Days")); + } + + #[test] + fn test_describe_period_24_hours() { + let now = Utc::now(); + let from = now - Duration::hours(12); + let period = ReportGenerator::describe_period(from, now); + assert!(period.contains("24 Hours")); + } + + #[test] + fn test_describe_period_7_days() { + let now = Utc::now(); + let from = now - Duration::days(5); + let period = ReportGenerator::describe_period(from, now); + assert!(period.contains("7 Days")); + } + + #[test] + fn test_describe_period_30_days() { + let now = Utc::now(); + let from = now - Duration::days(15); + let period = ReportGenerator::describe_period(from, now); + assert!(period.contains("30 Days")); + } + + #[test] + fn test_describe_period_custom_range() { + let now = Utc::now(); + let from = now - Duration::days(60); + let period = ReportGenerator::describe_period(from, now); + assert!(period.contains("to")); + } + + #[test] + fn test_report_fields() { + let (_temp, tracker) = create_test_tracker(); + let generator = ReportGenerator::new(tracker); + + let to = Utc::now(); + let from = to - Duration::days(1); + let report = generator.generate(from, to).unwrap(); + + assert_eq!(report.issues_attempted, 0); + assert_eq!(report.issues_succeeded, 0); + assert_eq!(report.issues_failed, 0); + assert_eq!(report.issues_cannot_fix, 0); + assert_eq!(report.success_rate, 0.0); + assert_eq!(report.failure_rate, 0.0); + assert_eq!(report.prs_created, 0); + assert_eq!(report.prs_merged, 0); + assert_eq!(report.prs_closed, 0); + } + + #[test] + fn test_format_text_sections() { + let (_temp, tracker) = create_test_tracker(); + let generator = ReportGenerator::new(tracker); + + let report = generator.generate_daily().unwrap(); + let text = report.format_text(); + + assert!(text.contains("# Report:")); + assert!(text.contains("## Summary")); + assert!(text.contains("Issues Attempted:")); + assert!(text.contains("Succeeded:")); + assert!(text.contains("Failed:")); + assert!(text.contains("Cannot Fix:")); + assert!(text.contains("## Pull Requests")); + assert!(text.contains("Created:")); + assert!(text.contains("Merged:")); + assert!(text.contains("Closed:")); + assert!(text.contains("## Current Status")); + assert!(text.contains("Pending:")); + assert!(text.contains("Retryable:")); + } + + #[test] + fn test_source_report_fields() { + let source_report = SourceReport { + name: "linear".to_string(), + attempted: 10, + succeeded: 7, + failed: 3, + merged: 5, + }; + + assert_eq!(source_report.name, "linear"); + assert_eq!(source_report.attempted, 10); + assert_eq!(source_report.succeeded, 7); + assert_eq!(source_report.failed, 3); + assert_eq!(source_report.merged, 5); + } + + #[test] + fn test_recurring_issue_fields() { + let issue = RecurringIssue { + pattern: "NullPointerException".to_string(), + count: 5, + sources: vec!["sentry".to_string(), "linear".to_string()], + }; + + assert_eq!(issue.pattern, "NullPointerException"); + assert_eq!(issue.count, 5); + assert_eq!(issue.sources.len(), 2); + } + + #[test] + fn test_report_serialization() { + let (_temp, tracker) = create_test_tracker(); + let generator = ReportGenerator::new(tracker); + + let report = generator.generate_daily().unwrap(); + let json = serde_json::to_string(&report).unwrap(); + + assert!(json.contains("period")); + assert!(json.contains("issues_attempted")); + assert!(json.contains("success_rate")); + } + + #[test] + fn test_source_report_serialization() { + let source_report = SourceReport { + name: "linear".to_string(), + attempted: 10, + succeeded: 7, + failed: 3, + merged: 5, + }; + + let json = serde_json::to_string(&source_report).unwrap(); + assert!(json.contains("linear")); + assert!(json.contains("10")); + } + + #[test] + fn test_report_date_format() { + let (_temp, tracker) = create_test_tracker(); + let generator = ReportGenerator::new(tracker); + + let report = generator.generate_daily().unwrap(); + let text = report.format_text(); + + // Should contain UTC date format + assert!(text.contains("UTC")); + } +} diff --git a/src/reports/mod.rs b/src/reports/mod.rs new file mode 100644 index 00000000..303af0e2 --- /dev/null +++ b/src/reports/mod.rs @@ -0,0 +1,9 @@ +//! Scheduled reports module. +//! +//! Provides automated daily/weekly reporting via notifications. + +mod generator; +mod scheduler; + +pub use generator::{RecurringIssue, Report, ReportGenerator}; +pub use scheduler::{ReportFrequency, ReportSchedule, ReportScheduler}; diff --git a/src/reports/scheduler.rs b/src/reports/scheduler.rs new file mode 100644 index 00000000..427d6c8f --- /dev/null +++ b/src/reports/scheduler.rs @@ -0,0 +1,570 @@ +//! Report scheduling logic. + +use super::generator::{Report, ReportGenerator}; +use crate::error::Result; +use crate::notifier::Notifier; +use crate::storage::FixAttemptTracker; +use chrono::{DateTime, Datelike, Duration, TimeZone, Timelike, Utc, Weekday}; +use std::sync::Arc; + +/// Report frequency. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReportFrequency { + /// Daily report + Daily, + /// Weekly report (specify day of week) + Weekly(Weekday), + /// Monthly report (on the 1st) + Monthly, +} + +impl ReportFrequency { + /// Parse frequency from string. + pub fn parse(s: &str) -> Option { + match s.to_lowercase().as_str() { + "daily" => Some(ReportFrequency::Daily), + "weekly" => Some(ReportFrequency::Weekly(Weekday::Mon)), + "weekly-mon" | "weekly-monday" => Some(ReportFrequency::Weekly(Weekday::Mon)), + "weekly-tue" | "weekly-tuesday" => Some(ReportFrequency::Weekly(Weekday::Tue)), + "weekly-wed" | "weekly-wednesday" => Some(ReportFrequency::Weekly(Weekday::Wed)), + "weekly-thu" | "weekly-thursday" => Some(ReportFrequency::Weekly(Weekday::Thu)), + "weekly-fri" | "weekly-friday" => Some(ReportFrequency::Weekly(Weekday::Fri)), + "weekly-sat" | "weekly-saturday" => Some(ReportFrequency::Weekly(Weekday::Sat)), + "weekly-sun" | "weekly-sunday" => Some(ReportFrequency::Weekly(Weekday::Sun)), + "monthly" => Some(ReportFrequency::Monthly), + _ => None, + } + } + + /// Convert to display string. + pub fn to_str(&self) -> &'static str { + match self { + ReportFrequency::Daily => "daily", + ReportFrequency::Weekly(Weekday::Mon) => "weekly-monday", + ReportFrequency::Weekly(Weekday::Tue) => "weekly-tuesday", + ReportFrequency::Weekly(Weekday::Wed) => "weekly-wednesday", + ReportFrequency::Weekly(Weekday::Thu) => "weekly-thursday", + ReportFrequency::Weekly(Weekday::Fri) => "weekly-friday", + ReportFrequency::Weekly(Weekday::Sat) => "weekly-saturday", + ReportFrequency::Weekly(Weekday::Sun) => "weekly-sunday", + ReportFrequency::Monthly => "monthly", + } + } +} + +/// A report schedule. +#[derive(Debug, Clone)] +pub struct ReportSchedule { + /// Schedule name + pub name: String, + /// How often to send + pub frequency: ReportFrequency, + /// Hour to send (0-23 UTC) + pub hour: u32, + /// Whether the schedule is enabled + pub enabled: bool, + /// Last time a report was sent + pub last_sent_at: Option>, +} + +impl ReportSchedule { + /// Create a new daily schedule. + pub fn daily(name: impl Into, hour: u32) -> Self { + Self { + name: name.into(), + frequency: ReportFrequency::Daily, + hour, + enabled: true, + last_sent_at: None, + } + } + + /// Create a new weekly schedule. + pub fn weekly(name: impl Into, day: Weekday, hour: u32) -> Self { + Self { + name: name.into(), + frequency: ReportFrequency::Weekly(day), + hour, + enabled: true, + last_sent_at: None, + } + } + + /// Create a new monthly schedule. + pub fn monthly(name: impl Into, hour: u32) -> Self { + Self { + name: name.into(), + frequency: ReportFrequency::Monthly, + hour, + enabled: true, + last_sent_at: None, + } + } + + /// Check if this schedule is due to run. + pub fn is_due(&self, now: DateTime) -> bool { + if !self.enabled { + return false; + } + + // Check if we're at the right hour + if now.hour() != self.hour { + return false; + } + + // Check frequency-specific conditions + match self.frequency { + ReportFrequency::Daily => { + // Daily: due if we haven't sent today + match self.last_sent_at { + None => true, + Some(last) => last.date_naive() < now.date_naive(), + } + } + ReportFrequency::Weekly(day) => { + // Weekly: due if it's the right day and we haven't sent this week + if now.weekday() != day { + return false; + } + match self.last_sent_at { + None => true, + Some(last) => { + // Check if last send was more than 6 days ago + (now - last).num_days() >= 6 + } + } + } + ReportFrequency::Monthly => { + // Monthly: due on the 1st if we haven't sent this month + if now.day() != 1 { + return false; + } + match self.last_sent_at { + None => true, + Some(last) => last.month() != now.month() || last.year() != now.year(), + } + } + } + } + + /// Get the next scheduled run time. + pub fn next_run(&self, now: DateTime) -> DateTime { + let target_hour = self.hour; + + match self.frequency { + ReportFrequency::Daily => { + let today_target = Utc + .with_ymd_and_hms(now.year(), now.month(), now.day(), target_hour, 0, 0) + .single() + .unwrap_or(now); + + if now < today_target { + today_target + } else { + today_target + Duration::days(1) + } + } + ReportFrequency::Weekly(target_day) => { + let current_day = now.weekday(); + let days_until = (target_day.num_days_from_monday() as i64 + - current_day.num_days_from_monday() as i64 + + 7) + % 7; + + let target_date = now + Duration::days(days_until); + let target = Utc + .with_ymd_and_hms( + target_date.year(), + target_date.month(), + target_date.day(), + target_hour, + 0, + 0, + ) + .single() + .unwrap_or(now); + + if now < target && days_until == 0 { + target + } else if days_until == 0 { + target + Duration::days(7) + } else { + target + } + } + ReportFrequency::Monthly => { + // Next 1st of the month + let (year, month) = if now.day() == 1 && now.hour() < target_hour { + (now.year(), now.month()) + } else if now.month() == 12 { + (now.year() + 1, 1) + } else { + (now.year(), now.month() + 1) + }; + + Utc.with_ymd_and_hms(year, month, 1, target_hour, 0, 0) + .single() + .unwrap_or(now) + } + } + } +} + +/// Manages report schedules and sends reports. +pub struct ReportScheduler { + generator: ReportGenerator, + notifier: Arc, + schedules: Vec, +} + +impl ReportScheduler { + /// Create a new scheduler. + pub fn new(tracker: Arc, notifier: Arc) -> Self { + Self { + generator: ReportGenerator::new(tracker), + notifier, + schedules: Vec::new(), + } + } + + /// Add a schedule. + pub fn add_schedule(&mut self, schedule: ReportSchedule) { + self.schedules.push(schedule); + } + + /// Get all schedules. + pub fn schedules(&self) -> &[ReportSchedule] { + &self.schedules + } + + /// Check and send any due reports. + pub async fn check_and_send(&mut self) -> Result> { + let now = Utc::now(); + let mut sent = Vec::new(); + + for schedule in &mut self.schedules { + if schedule.is_due(now) { + let report = match schedule.frequency { + ReportFrequency::Daily => self.generator.generate_daily()?, + ReportFrequency::Weekly(_) => self.generator.generate_weekly()?, + ReportFrequency::Monthly => self.generator.generate_monthly()?, + }; + + // Send via notifier + self.notifier.notify_report(&report).await?; + + schedule.last_sent_at = Some(now); + sent.push(schedule.name.clone()); + + tracing::info!("Sent scheduled report: {}", schedule.name); + } + } + + Ok(sent) + } + + /// Generate a report without sending (preview). + pub fn preview(&self, frequency: ReportFrequency) -> Result { + match frequency { + ReportFrequency::Daily => self.generator.generate_daily(), + ReportFrequency::Weekly(_) => self.generator.generate_weekly(), + ReportFrequency::Monthly => self.generator.generate_monthly(), + } + } + + /// Generate and send a report immediately. + pub async fn send_now(&self, frequency: ReportFrequency) -> Result { + let report = self.preview(frequency)?; + self.notifier.notify_report(&report).await?; + Ok(report) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_frequency_parse() { + assert_eq!( + ReportFrequency::parse("daily"), + Some(ReportFrequency::Daily) + ); + assert_eq!( + ReportFrequency::parse("weekly"), + Some(ReportFrequency::Weekly(Weekday::Mon)) + ); + assert_eq!( + ReportFrequency::parse("weekly-friday"), + Some(ReportFrequency::Weekly(Weekday::Fri)) + ); + assert_eq!( + ReportFrequency::parse("monthly"), + Some(ReportFrequency::Monthly) + ); + assert_eq!(ReportFrequency::parse("invalid"), None); + } + + #[test] + fn test_daily_schedule_is_due() { + let schedule = ReportSchedule::daily("test", 9); + + // At 9am, should be due + let now = Utc.with_ymd_and_hms(2024, 1, 15, 9, 0, 0).unwrap(); + assert!(schedule.is_due(now)); + + // At 10am, should not be due + let now = Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(); + assert!(!schedule.is_due(now)); + } + + #[test] + fn test_daily_schedule_already_sent() { + let mut schedule = ReportSchedule::daily("test", 9); + schedule.last_sent_at = Some(Utc.with_ymd_and_hms(2024, 1, 15, 9, 0, 0).unwrap()); + + // Same day, should not be due again + let now = Utc.with_ymd_and_hms(2024, 1, 15, 9, 30, 0).unwrap(); + assert!(!schedule.is_due(now)); + + // Next day, should be due + let now = Utc.with_ymd_and_hms(2024, 1, 16, 9, 0, 0).unwrap(); + assert!(schedule.is_due(now)); + } + + #[test] + fn test_weekly_schedule_is_due() { + let schedule = ReportSchedule::weekly("test", Weekday::Mon, 9); + + // Monday at 9am + let now = Utc.with_ymd_and_hms(2024, 1, 15, 9, 0, 0).unwrap(); // Monday + assert!(schedule.is_due(now)); + + // Tuesday at 9am + let now = Utc.with_ymd_and_hms(2024, 1, 16, 9, 0, 0).unwrap(); // Tuesday + assert!(!schedule.is_due(now)); + } + + #[test] + fn test_monthly_schedule_is_due() { + let schedule = ReportSchedule::monthly("test", 9); + + // 1st of month at 9am + let now = Utc.with_ymd_and_hms(2024, 2, 1, 9, 0, 0).unwrap(); + assert!(schedule.is_due(now)); + + // 2nd of month + let now = Utc.with_ymd_and_hms(2024, 2, 2, 9, 0, 0).unwrap(); + assert!(!schedule.is_due(now)); + } + + #[test] + fn test_disabled_schedule_not_due() { + let mut schedule = ReportSchedule::daily("test", 9); + schedule.enabled = false; + + let now = Utc.with_ymd_and_hms(2024, 1, 15, 9, 0, 0).unwrap(); + assert!(!schedule.is_due(now)); + } + + #[test] + fn test_next_run_daily() { + let schedule = ReportSchedule::daily("test", 9); + + // Before target hour + let now = Utc.with_ymd_and_hms(2024, 1, 15, 8, 0, 0).unwrap(); + let next = schedule.next_run(now); + assert_eq!(next.hour(), 9); + assert_eq!(next.day(), 15); + + // After target hour + let now = Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(); + let next = schedule.next_run(now); + assert_eq!(next.hour(), 9); + assert_eq!(next.day(), 16); + } + + #[test] + fn test_next_run_weekly() { + let schedule = ReportSchedule::weekly("test", Weekday::Fri, 9); + + // Monday + let now = Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(); + let next = schedule.next_run(now); + assert_eq!(next.weekday(), Weekday::Fri); + assert_eq!(next.hour(), 9); + } + + #[test] + fn test_frequency_to_str() { + assert_eq!(ReportFrequency::Daily.to_str(), "daily"); + assert_eq!(ReportFrequency::Monthly.to_str(), "monthly"); + assert_eq!( + ReportFrequency::Weekly(Weekday::Mon).to_str(), + "weekly-monday" + ); + assert_eq!( + ReportFrequency::Weekly(Weekday::Fri).to_str(), + "weekly-friday" + ); + assert_eq!( + ReportFrequency::Weekly(Weekday::Sun).to_str(), + "weekly-sunday" + ); + } + + #[test] + fn test_frequency_parse_variants() { + assert_eq!( + ReportFrequency::parse("DAILY"), + Some(ReportFrequency::Daily) + ); + assert_eq!( + ReportFrequency::parse("Daily"), + Some(ReportFrequency::Daily) + ); + assert_eq!( + ReportFrequency::parse("weekly-tue"), + Some(ReportFrequency::Weekly(Weekday::Tue)) + ); + assert_eq!( + ReportFrequency::parse("weekly-wednesday"), + Some(ReportFrequency::Weekly(Weekday::Wed)) + ); + assert_eq!( + ReportFrequency::parse("weekly-thursday"), + Some(ReportFrequency::Weekly(Weekday::Thu)) + ); + assert_eq!( + ReportFrequency::parse("weekly-saturday"), + Some(ReportFrequency::Weekly(Weekday::Sat)) + ); + } + + #[test] + fn test_daily_schedule_wrong_hour() { + let schedule = ReportSchedule::daily("test", 9); + let now = Utc.with_ymd_and_hms(2024, 1, 15, 8, 0, 0).unwrap(); + assert!(!schedule.is_due(now)); + } + + #[test] + fn test_weekly_schedule_wrong_day() { + let schedule = ReportSchedule::weekly("test", Weekday::Mon, 9); + // Friday at 9am + let now = Utc.with_ymd_and_hms(2024, 1, 19, 9, 0, 0).unwrap(); + assert!(!schedule.is_due(now)); + } + + #[test] + fn test_weekly_schedule_already_sent_this_week() { + let mut schedule = ReportSchedule::weekly("test", Weekday::Mon, 9); + // Set last sent to 3 days ago + schedule.last_sent_at = Some(Utc.with_ymd_and_hms(2024, 1, 12, 9, 0, 0).unwrap()); + + // Today is Monday Jan 15 + let now = Utc.with_ymd_and_hms(2024, 1, 15, 9, 0, 0).unwrap(); + assert!(!schedule.is_due(now)); + } + + #[test] + fn test_monthly_schedule_wrong_day() { + let schedule = ReportSchedule::monthly("test", 9); + // 15th of the month + let now = Utc.with_ymd_and_hms(2024, 1, 15, 9, 0, 0).unwrap(); + assert!(!schedule.is_due(now)); + } + + #[test] + fn test_monthly_schedule_already_sent_this_month() { + let mut schedule = ReportSchedule::monthly("test", 9); + schedule.last_sent_at = Some(Utc.with_ymd_and_hms(2024, 1, 1, 9, 0, 0).unwrap()); + + // Same month, 1st day + let now = Utc.with_ymd_and_hms(2024, 1, 1, 9, 30, 0).unwrap(); + assert!(!schedule.is_due(now)); + } + + #[test] + fn test_monthly_schedule_new_month() { + let mut schedule = ReportSchedule::monthly("test", 9); + schedule.last_sent_at = Some(Utc.with_ymd_and_hms(2024, 1, 1, 9, 0, 0).unwrap()); + + // February 1st + let now = Utc.with_ymd_and_hms(2024, 2, 1, 9, 0, 0).unwrap(); + assert!(schedule.is_due(now)); + } + + #[test] + fn test_next_run_daily_same_day() { + let schedule = ReportSchedule::daily("test", 18); + let now = Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(); + let next = schedule.next_run(now); + assert_eq!(next.day(), 15); // Same day + assert_eq!(next.hour(), 18); + } + + #[test] + fn test_next_run_monthly() { + let schedule = ReportSchedule::monthly("test", 9); + // Mid-month + let now = Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(); + let next = schedule.next_run(now); + assert_eq!(next.day(), 1); + assert_eq!(next.month(), 2); + } + + #[test] + fn test_next_run_monthly_year_rollover() { + let schedule = ReportSchedule::monthly("test", 9); + // December 15 + let now = Utc.with_ymd_and_hms(2024, 12, 15, 10, 0, 0).unwrap(); + let next = schedule.next_run(now); + assert_eq!(next.day(), 1); + assert_eq!(next.month(), 1); + assert_eq!(next.year(), 2025); + } + + #[test] + fn test_schedule_name_field() { + let schedule = ReportSchedule::daily("My Daily Report", 9); + assert_eq!(schedule.name, "My Daily Report"); + } + + #[test] + fn test_schedule_enabled_field() { + let mut schedule = ReportSchedule::daily("test", 9); + assert!(schedule.enabled); + schedule.enabled = false; + assert!(!schedule.enabled); + } + + #[test] + fn test_schedule_last_sent_at_field() { + let mut schedule = ReportSchedule::daily("test", 9); + assert!(schedule.last_sent_at.is_none()); + schedule.last_sent_at = Some(Utc::now()); + assert!(schedule.last_sent_at.is_some()); + } + + #[test] + fn test_report_frequency_eq() { + assert_eq!(ReportFrequency::Daily, ReportFrequency::Daily); + assert_eq!( + ReportFrequency::Weekly(Weekday::Mon), + ReportFrequency::Weekly(Weekday::Mon) + ); + assert_ne!( + ReportFrequency::Weekly(Weekday::Mon), + ReportFrequency::Weekly(Weekday::Tue) + ); + assert_ne!(ReportFrequency::Daily, ReportFrequency::Monthly); + } + + #[test] + fn test_report_frequency_copy() { + let freq = ReportFrequency::Daily; + let freq_copy = freq; + assert_eq!(freq, freq_copy); + } +} diff --git a/src/retry.rs b/src/retry.rs new file mode 100644 index 00000000..48fd1bd7 --- /dev/null +++ b/src/retry.rs @@ -0,0 +1,690 @@ +//! Retry logic with exponential backoff for fix attempts. + +use crate::config::RetryConfig; +use crate::error::Result; +use crate::storage::FixAttemptTracker; +use crate::types::{ActivityLogEntry, FixAttempt, FixAttemptStatus}; +use chrono::{DateTime, Duration as ChronoDuration, Utc}; +use serde_json::json; +use std::sync::Arc; +use tokio::time::Duration; + +/// Manages retry logic for fix attempts. +pub struct RetryManager { + config: RetryConfig, + tracker: Arc, +} + +impl RetryManager { + /// Create a new retry manager. + pub fn new(config: RetryConfig, tracker: Arc) -> Self { + Self { config, tracker } + } + + /// Check if an attempt should be retried. + pub fn should_retry(&self, attempt: &FixAttempt) -> bool { + // Only retry failed or closed attempts + if attempt.status != FixAttemptStatus::Failed && attempt.status != FixAttemptStatus::Closed + { + return false; + } + + // Check if we've exceeded max retries + if attempt.retry_count >= self.config.max_retries { + return false; + } + + true + } + + /// Check if enough time has passed since the last retry. + pub fn is_ready_for_retry(&self, attempt: &FixAttempt) -> bool { + if !self.should_retry(attempt) { + return false; + } + + let delay = self.get_delay(attempt.retry_count); + let min_retry_time = attempt + .last_retry_at + .or(Some(attempt.attempted_at)) + .map(|t| t + ChronoDuration::milliseconds(delay.as_millis() as i64)) + .unwrap_or(Utc::now()); + + Utc::now() >= min_retry_time + } + + /// Calculate the delay before the next retry using exponential backoff. + /// Formula: min(base_delay * 2^retry_count, max_delay) + pub fn get_delay(&self, retry_count: u32) -> Duration { + let multiplier = 2u64.saturating_pow(retry_count); + let delay_ms = self.config.base_delay_ms.saturating_mul(multiplier); + let capped_delay = delay_ms.min(self.config.max_delay_ms); + Duration::from_millis(capped_delay) + } + + /// Get the next retry time for an attempt. + pub fn get_next_retry_time(&self, attempt: &FixAttempt) -> Option> { + if !self.should_retry(attempt) { + return None; + } + + let delay = self.get_delay(attempt.retry_count); + let base_time = attempt.last_retry_at.unwrap_or(attempt.attempted_at); + Some(base_time + ChronoDuration::milliseconds(delay.as_millis() as i64)) + } + + /// Process a failed attempt - either schedule retry or mark as cannot fix. + pub fn handle_failure( + &self, + source: &str, + issue_id: &str, + error: &str, + ) -> Result { + let attempt = self.tracker.get_attempt(source, issue_id)?; + + match attempt { + Some(attempt) => { + let new_retry_count = attempt.retry_count + 1; + + if new_retry_count > self.config.max_retries { + // Max retries reached + self.tracker.mark_cannot_fix( + source, + issue_id, + &format!( + "Max retries ({}) reached. Last error: {}", + self.config.max_retries, error + ), + )?; + tracing::warn!( + component = "retry", + short_id = %attempt.short_id, + max_retries = self.config.max_retries, + "Reached max retries, marking as cannot_fix" + ); + Ok(RetryDecision::CannotFix) + } else { + // Schedule retry + self.tracker.increment_retry(source, issue_id)?; + self.tracker.mark_failed(source, issue_id, error)?; + + let delay = self.get_delay(new_retry_count); + let next_retry_time = Utc::now() + ChronoDuration::milliseconds(delay.as_millis() as i64); + + tracing::info!( + component = "retry", + short_id = %attempt.short_id, + retry_count = new_retry_count, + max_retries = self.config.max_retries, + delay = ?delay, + "Will retry" + ); + + // Log retry_scheduled activity + let activity = ActivityLogEntry::new( + "retry_scheduled", + format!("Retry scheduled for {} at {}", attempt.short_id, next_retry_time.format("%Y-%m-%dT%H:%M:%SZ")), + ) + .with_source(source.to_string()) + .with_issue(issue_id.to_string(), attempt.short_id.clone()) + .with_metadata(json!({ + "retry_count": new_retry_count, + "max_retries": self.config.max_retries, + "next_retry": next_retry_time.to_rfc3339(), + "delay_ms": delay.as_millis() as u64 + })); + self.tracker.record_activity(&activity).ok(); + + Ok(RetryDecision::Retry { + retry_count: new_retry_count, + delay, + }) + } + } + None => { + tracing::warn!(component = "retry", source = %source, issue_id = %issue_id, "Attempt not found"); + Ok(RetryDecision::NotFound) + } + } + } + + /// Handle a PR being closed (not merged) - triggers retry logic. + pub fn handle_pr_closed(&self, source: &str, issue_id: &str) -> Result { + self.handle_failure(source, issue_id, "PR was closed without merging") + } + + /// Get all attempts that are ready to be retried now. + pub fn get_ready_retries(&self) -> Result> { + let retryable = self.tracker.get_retryable_issues(self.config.max_retries)?; + + Ok(retryable + .into_iter() + .filter(|a| self.is_ready_for_retry(a)) + .collect()) + } + + /// Prepare an attempt for retry - resets status and clears PR info. + pub fn prepare_retry(&self, source: &str, issue_id: &str) -> Result<()> { + // Get attempt info before incrementing + let attempt = self.tracker.get_attempt(source, issue_id)?; + let (short_id, retry_count) = attempt + .map(|a| (a.short_id.clone(), a.retry_count + 1)) + .unwrap_or_else(|| (issue_id.to_string(), 1)); + + self.tracker.increment_retry(source, issue_id)?; + self.tracker.prepare_for_retry(source, issue_id)?; + + // Log retry_executed activity + let activity = ActivityLogEntry::new( + "retry_executed", + format!("Executing retry {} for {}", retry_count, short_id), + ) + .with_source(source.to_string()) + .with_issue(issue_id.to_string(), short_id) + .with_metadata(json!({ + "retry_count": retry_count + })); + self.tracker.record_activity(&activity).ok(); + + Ok(()) + } +} + +/// Decision made by retry logic. +#[derive(Debug, Clone)] +pub enum RetryDecision { + /// Will retry after the specified delay. + Retry { retry_count: u32, delay: Duration }, + /// Max retries reached, cannot fix automatically. + CannotFix, + /// Attempt not found. + NotFound, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::SqliteTracker; + + fn create_test_tracker() -> Arc { + Arc::new(SqliteTracker::in_memory().unwrap()) + } + + #[test] + fn test_exponential_backoff_delay() { + let config = RetryConfig { + max_retries: 3, + base_delay_ms: 1000, // 1 second + max_delay_ms: 10_000, // 10 seconds + }; + let manager = RetryManager::new(config, create_test_tracker()); + + // First retry: 1s * 2^0 = 1s + assert_eq!(manager.get_delay(0), Duration::from_millis(1000)); + + // Second retry: 1s * 2^1 = 2s + assert_eq!(manager.get_delay(1), Duration::from_millis(2000)); + + // Third retry: 1s * 2^2 = 4s + assert_eq!(manager.get_delay(2), Duration::from_millis(4000)); + + // Fourth retry: 1s * 2^3 = 8s + assert_eq!(manager.get_delay(3), Duration::from_millis(8000)); + + // Fifth retry: 1s * 2^4 = 16s, but capped at 10s + assert_eq!(manager.get_delay(4), Duration::from_millis(10_000)); + } + + #[test] + fn test_should_retry_status() { + let config = RetryConfig::default(); + let manager = RetryManager::new(config, create_test_tracker()); + + let failed = FixAttempt { + id: 1, + issue_id: "123".to_string(), + short_id: "PROJ-123".to_string(), + source: "linear".to_string(), + attempted_at: Utc::now(), + pr_url: None, + github_repo: None, + github_pr_number: None, + status: FixAttemptStatus::Failed, + error_message: Some("Error".to_string()), + merged_at: None, + resolved_at: None, + retry_count: 0, + last_retry_at: None, + }; + assert!(manager.should_retry(&failed)); + + let closed = FixAttempt { + status: FixAttemptStatus::Closed, + ..failed.clone() + }; + assert!(manager.should_retry(&closed)); + + let success = FixAttempt { + status: FixAttemptStatus::Success, + ..failed.clone() + }; + assert!(!manager.should_retry(&success)); + + let merged = FixAttempt { + status: FixAttemptStatus::Merged, + ..failed.clone() + }; + assert!(!manager.should_retry(&merged)); + + let cannot_fix = FixAttempt { + status: FixAttemptStatus::CannotFix, + ..failed.clone() + }; + assert!(!manager.should_retry(&cannot_fix)); + } + + #[test] + fn test_should_retry_max_retries() { + let config = RetryConfig { + max_retries: 2, + ..Default::default() + }; + let manager = RetryManager::new(config, create_test_tracker()); + + let attempt = FixAttempt { + id: 1, + issue_id: "123".to_string(), + short_id: "PROJ-123".to_string(), + source: "linear".to_string(), + attempted_at: Utc::now(), + pr_url: None, + github_repo: None, + github_pr_number: None, + status: FixAttemptStatus::Failed, + error_message: Some("Error".to_string()), + merged_at: None, + resolved_at: None, + retry_count: 0, + last_retry_at: None, + }; + assert!(manager.should_retry(&attempt)); + + let attempt_1_retry = FixAttempt { + retry_count: 1, + ..attempt.clone() + }; + assert!(manager.should_retry(&attempt_1_retry)); + + let attempt_2_retries = FixAttempt { + retry_count: 2, + ..attempt.clone() + }; + assert!(!manager.should_retry(&attempt_2_retries)); + + let attempt_3_retries = FixAttempt { + retry_count: 3, + ..attempt.clone() + }; + assert!(!manager.should_retry(&attempt_3_retries)); + } + + #[test] + fn test_handle_failure_increments_retry() { + let tracker = create_test_tracker(); + let config = RetryConfig { + max_retries: 2, + ..Default::default() + }; + let manager = RetryManager::new(config, tracker.clone()); + + // Record an attempt + tracker.record_attempt("linear", "123", "PROJ-123").unwrap(); + tracker + .mark_failed("linear", "123", "First failure") + .unwrap(); + + // Handle failure - should schedule retry + let decision = manager + .handle_failure("linear", "123", "Second failure") + .unwrap(); + match decision { + RetryDecision::Retry { retry_count, .. } => { + assert_eq!(retry_count, 1); + } + _ => panic!("Expected Retry decision"), + } + + // Check that retry count was incremented + let attempt = tracker.get_attempt("linear", "123").unwrap().unwrap(); + assert_eq!(attempt.retry_count, 1); + } + + #[test] + fn test_handle_failure_marks_cannot_fix() { + let tracker = create_test_tracker(); + let config = RetryConfig { + max_retries: 1, + ..Default::default() + }; + let manager = RetryManager::new(config, tracker.clone()); + + // Record an attempt with retry_count = 1 (at max) + tracker.record_attempt("linear", "123", "PROJ-123").unwrap(); + tracker + .mark_failed("linear", "123", "First failure") + .unwrap(); + tracker.increment_retry("linear", "123").unwrap(); + + // Handle failure - should mark as cannot_fix + let decision = manager + .handle_failure("linear", "123", "Final failure") + .unwrap(); + match decision { + RetryDecision::CannotFix => {} + _ => panic!("Expected CannotFix decision"), + } + + // Check that status is cannot_fix + let attempt = tracker.get_attempt("linear", "123").unwrap().unwrap(); + assert_eq!(attempt.status, FixAttemptStatus::CannotFix); + } + + #[test] + fn test_handle_failure_not_found() { + let tracker = create_test_tracker(); + let config = RetryConfig::default(); + let manager = RetryManager::new(config, tracker); + + let decision = manager + .handle_failure("linear", "nonexistent", "Error") + .unwrap(); + assert!(matches!(decision, RetryDecision::NotFound)); + } + + #[test] + fn test_handle_pr_closed() { + let tracker = create_test_tracker(); + let config = RetryConfig { + max_retries: 2, + ..Default::default() + }; + let manager = RetryManager::new(config, tracker.clone()); + + tracker.record_attempt("linear", "123", "PROJ-123").unwrap(); + tracker + .mark_success("linear", "123", "https://github.com/org/repo/pull/1") + .unwrap(); + tracker.mark_closed("linear", "123").unwrap(); + + let decision = manager.handle_pr_closed("linear", "123").unwrap(); + match decision { + RetryDecision::Retry { retry_count, .. } => { + assert_eq!(retry_count, 1); + } + _ => panic!("Expected Retry decision"), + } + } + + #[test] + fn test_get_next_retry_time_not_retryable() { + let config = RetryConfig::default(); + let manager = RetryManager::new(config, create_test_tracker()); + + let attempt = FixAttempt { + id: 1, + issue_id: "123".to_string(), + short_id: "PROJ-123".to_string(), + source: "linear".to_string(), + attempted_at: Utc::now(), + pr_url: None, + github_repo: None, + github_pr_number: None, + status: FixAttemptStatus::Success, // Not retryable + error_message: None, + merged_at: None, + resolved_at: None, + retry_count: 0, + last_retry_at: None, + }; + + assert!(manager.get_next_retry_time(&attempt).is_none()); + } + + #[test] + fn test_get_next_retry_time_with_last_retry() { + let config = RetryConfig { + max_retries: 5, + base_delay_ms: 60_000, // 1 minute + max_delay_ms: 3_600_000, + }; + let manager = RetryManager::new(config, create_test_tracker()); + + let base_time = Utc::now(); + let attempt = FixAttempt { + id: 1, + issue_id: "123".to_string(), + short_id: "PROJ-123".to_string(), + source: "linear".to_string(), + attempted_at: base_time - ChronoDuration::hours(1), + pr_url: None, + github_repo: None, + github_pr_number: None, + status: FixAttemptStatus::Failed, + error_message: Some("Error".to_string()), + merged_at: None, + resolved_at: None, + retry_count: 2, + last_retry_at: Some(base_time), + }; + + let next_retry = manager.get_next_retry_time(&attempt).unwrap(); + // With retry_count=2, delay = 60s * 2^2 = 240s = 4 minutes + let expected = base_time + ChronoDuration::milliseconds(240_000); + + // Allow some tolerance for test execution time + let diff = (next_retry - expected).num_seconds().abs(); + assert!( + diff < 2, + "Expected next retry time around {:?}, got {:?}", + expected, + next_retry + ); + } + + #[test] + fn test_is_ready_for_retry_not_retryable() { + let config = RetryConfig::default(); + let manager = RetryManager::new(config, create_test_tracker()); + + let attempt = FixAttempt { + id: 1, + issue_id: "123".to_string(), + short_id: "PROJ-123".to_string(), + source: "linear".to_string(), + attempted_at: Utc::now(), + pr_url: None, + github_repo: None, + github_pr_number: None, + status: FixAttemptStatus::Merged, // Not retryable + error_message: None, + merged_at: None, + resolved_at: None, + retry_count: 0, + last_retry_at: None, + }; + + assert!(!manager.is_ready_for_retry(&attempt)); + } + + #[test] + fn test_is_ready_for_retry_time_not_elapsed() { + let config = RetryConfig { + max_retries: 5, + base_delay_ms: 3_600_000, // 1 hour + max_delay_ms: 7_200_000, + }; + let manager = RetryManager::new(config, create_test_tracker()); + + // Attempted just now, shouldn't be ready for 1 hour + let attempt = FixAttempt { + id: 1, + issue_id: "123".to_string(), + short_id: "PROJ-123".to_string(), + source: "linear".to_string(), + attempted_at: Utc::now(), + pr_url: None, + github_repo: None, + github_pr_number: None, + status: FixAttemptStatus::Failed, + error_message: Some("Error".to_string()), + merged_at: None, + resolved_at: None, + retry_count: 0, + last_retry_at: None, + }; + + assert!(!manager.is_ready_for_retry(&attempt)); + } + + #[test] + fn test_is_ready_for_retry_time_elapsed() { + let config = RetryConfig { + max_retries: 5, + base_delay_ms: 1_000, // 1 second + max_delay_ms: 10_000, + }; + let manager = RetryManager::new(config, create_test_tracker()); + + // Attempted 1 hour ago, should be ready (delay is only 1 second) + let attempt = FixAttempt { + id: 1, + issue_id: "123".to_string(), + short_id: "PROJ-123".to_string(), + source: "linear".to_string(), + attempted_at: Utc::now() - ChronoDuration::hours(1), + pr_url: None, + github_repo: None, + github_pr_number: None, + status: FixAttemptStatus::Failed, + error_message: Some("Error".to_string()), + merged_at: None, + resolved_at: None, + retry_count: 0, + last_retry_at: None, + }; + + assert!(manager.is_ready_for_retry(&attempt)); + } + + #[test] + fn test_get_ready_retries() { + let tracker = create_test_tracker(); + let config = RetryConfig { + max_retries: 3, + base_delay_ms: 1_000, // 1 second + max_delay_ms: 10_000, + }; + let manager = RetryManager::new(config, tracker.clone()); + + // Create a failed attempt from 1 hour ago (ready to retry) + tracker.record_attempt("linear", "1", "PROJ-1").unwrap(); + tracker.mark_failed("linear", "1", "Error").unwrap(); + + // Manually update attempted_at to be in the past + // Note: This is a simplified test - in practice, the time check matters + + let ready = manager.get_ready_retries().unwrap(); + // May or may not be ready depending on timing, but should not panic + assert!(ready.len() <= 1); + } + + #[test] + fn test_prepare_retry() { + let tracker = create_test_tracker(); + let config = RetryConfig::default(); + let manager = RetryManager::new(config, tracker.clone()); + + tracker.record_attempt("linear", "123", "PROJ-123").unwrap(); + tracker + .mark_success("linear", "123", "https://github.com/org/repo/pull/1") + .unwrap(); + tracker.mark_closed("linear", "123").unwrap(); + + manager.prepare_retry("linear", "123").unwrap(); + + let attempt = tracker.get_attempt("linear", "123").unwrap().unwrap(); + assert_eq!(attempt.status, FixAttemptStatus::Pending); + assert!(attempt.pr_url.is_none()); + assert_eq!(attempt.retry_count, 1); + } + + #[test] + fn test_exponential_backoff_overflow() { + let config = RetryConfig { + max_retries: 100, + base_delay_ms: u64::MAX / 2, + max_delay_ms: u64::MAX, + }; + let manager = RetryManager::new(config, create_test_tracker()); + + // Should not panic with very large values + let delay = manager.get_delay(50); + assert!(delay.as_millis() <= u64::MAX as u128); + } + + #[test] + fn test_retry_decision_debug() { + let retry = RetryDecision::Retry { + retry_count: 2, + delay: Duration::from_secs(60), + }; + let debug_str = format!("{:?}", retry); + assert!(debug_str.contains("Retry")); + assert!(debug_str.contains("2")); + + let cannot_fix = RetryDecision::CannotFix; + let debug_str = format!("{:?}", cannot_fix); + assert!(debug_str.contains("CannotFix")); + + let not_found = RetryDecision::NotFound; + let debug_str = format!("{:?}", not_found); + assert!(debug_str.contains("NotFound")); + } + + #[test] + fn test_retry_decision_clone() { + let original = RetryDecision::Retry { + retry_count: 3, + delay: Duration::from_secs(120), + }; + let cloned = original.clone(); + + if let RetryDecision::Retry { retry_count, delay } = cloned { + assert_eq!(retry_count, 3); + assert_eq!(delay, Duration::from_secs(120)); + } else { + panic!("Clone failed"); + } + } + + #[test] + fn test_delay_capping() { + let config = RetryConfig { + max_retries: 10, + base_delay_ms: 1_000, + max_delay_ms: 5_000, // Cap at 5 seconds + }; + let manager = RetryManager::new(config, create_test_tracker()); + + // 1s * 2^0 = 1s + assert_eq!(manager.get_delay(0), Duration::from_millis(1_000)); + // 1s * 2^1 = 2s + assert_eq!(manager.get_delay(1), Duration::from_millis(2_000)); + // 1s * 2^2 = 4s + assert_eq!(manager.get_delay(2), Duration::from_millis(4_000)); + // 1s * 2^3 = 8s, capped at 5s + assert_eq!(manager.get_delay(3), Duration::from_millis(5_000)); + // All subsequent should be capped + assert_eq!(manager.get_delay(10), Duration::from_millis(5_000)); + } +} diff --git a/src/runner.rs b/src/runner.rs new file mode 100644 index 00000000..03646bd3 --- /dev/null +++ b/src/runner.rs @@ -0,0 +1,990 @@ +//! Claude CLI runner for executing fixes. + +use crate::error::{Error, Result}; +use crate::storage::FixAttemptTracker; +use crate::templates::{TemplateContext, TemplateLoader, TemplateRenderer}; +use crate::types::{ActivityLogEntry, ClaudeExecution, ClaudeResult, Issue}; +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::path::Path; +use std::process::Stdio; +use std::sync::Arc; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; + +/// Configuration for the Claude runner. +#[derive(Debug, Clone)] +pub struct ClaudeRunnerConfig { + /// Timeout for Claude process execution in seconds (default: 21600 = 6 hours). + pub timeout_secs: u64, +} + +impl Default for ClaudeRunnerConfig { + fn default() -> Self { + Self { + timeout_secs: 21600, // 6 hours default + } + } +} + +/// Runs Claude Code to fix issues. +pub struct ClaudeRunner { + config: ClaudeRunnerConfig, + template_renderer: TemplateRenderer, + tracker: Arc, +} + +impl ClaudeRunner { + /// Create a new Claude runner. + pub fn new(config: ClaudeRunnerConfig, tracker: Arc) -> Self { + let template_renderer = TemplateRenderer::new(); + Self { + config, + template_renderer, + tracker, + } + } + + /// Create a new Claude runner without template support (for testing). + pub fn new_simple(config: ClaudeRunnerConfig) -> Self { + use crate::storage::SqliteTracker; + // Use a temporary in-memory tracker + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + Self::new(config, tracker) + } + + /// Run Claude Code with a prompt to fix an issue in a specific repository. + pub async fn run_fix( + &self, + issue: &Issue, + context: &str, + project_dir: &Path, + ) -> Result { + let prompt = self.build_prompt(issue, context, project_dir); + self.execute(&prompt, Some(issue), project_dir).await + } + + /// Run Claude Code with the /issue skill (for Linear issues). + pub async fn run_issue_skill( + &self, + issue_identifier: &str, + issue_url: &str, + project_dir: &Path, + ) -> Result { + tracing::info!(component = "claude", issue_id = %issue_identifier, "Running /issue skill"); + + let mut env: HashMap = std::env::vars().collect(); + env.insert("LINEAR_ISSUE_ID".to_string(), issue_identifier.to_string()); + env.insert("LINEAR_ISSUE_URL".to_string(), issue_url.to_string()); + + self.execute_with_env( + &format!("/issue {}", issue_identifier), + issue_identifier, + env, + project_dir, + ) + .await + } + + /// Run Claude Code with a custom prompt. + pub async fn run_custom(&self, prompt: &str, project_dir: &Path) -> Result { + self.execute(prompt, None, project_dir).await + } + + fn build_prompt(&self, issue: &Issue, context: &str, project_dir: &Path) -> String { + // Try to use template system + let template_loader = TemplateLoader::new(project_dir); + if let Ok(template) = template_loader.get_template(issue) { + let agent_md = template_loader.load_agent_md(); + let template_context = + TemplateContext::new(issue.clone(), context.to_string()).with_agent_md(agent_md); + return self.template_renderer.render(&template, &template_context); + } + + // Fallback to simple format + format!( + r#"You are fixing an issue from {}. Here is the issue context: + +{} + +Your task: +1. Analyze the issue/error and any stack traces +2. Find the relevant code in this codebase +3. Implement a fix for the issue +4. Write or update tests if applicable +5. Create a PR with your changes + +The PR title should include the issue ID: {} + +After creating the PR, output the PR URL on a line by itself starting with "PR_URL: ". +"#, + issue.source, context, issue.short_id + ) + } + + /// Check if a project has an AGENT.md file. + pub fn has_agent_md(&self, project_dir: &Path) -> bool { + let template_loader = TemplateLoader::new(project_dir); + template_loader.has_agent_md() + } + + /// Get AGENT.md content if it exists. + pub fn get_agent_md(&self, project_dir: &Path) -> Option { + let template_loader = TemplateLoader::new(project_dir); + template_loader.load_agent_md() + } + + /// Build a prompt for the given issue and context. + /// + /// This is public to allow callers to get the prompt string for analytics/logging. + pub fn build_prompt_for_issue(&self, issue: &Issue, context: &str, project_dir: &Path) -> String { + self.build_prompt(issue, context, project_dir) + } + + async fn execute(&self, prompt: &str, issue: Option<&Issue>, project_dir: &Path) -> Result { + let mut env: HashMap = std::env::vars().collect(); + + if let Some(issue) = issue { + let source_upper = issue.source.to_uppercase(); + env.insert(format!("{}_ISSUE_ID", source_upper), issue.id.clone()); + env.insert( + format!("{}_ISSUE_SHORT_ID", source_upper), + issue.short_id.clone(), + ); + env.insert(format!("{}_ISSUE_URL", source_upper), issue.url.clone()); + } + + let label = issue.map(|i| i.short_id.as_str()).unwrap_or("custom"); + self.execute_with_env(prompt, label, env, project_dir).await + } + + async fn execute_with_env( + &self, + prompt: &str, + label: &str, + env: HashMap, + project_dir: &Path, + ) -> Result { + self.execute_with_env_and_attempt(prompt, label, env, None, project_dir).await + } + + /// Execute Claude with optional attempt ID for analytics tracking. + pub async fn execute_with_attempt( + &self, + prompt: &str, + issue: Option<&Issue>, + attempt_id: Option, + project_dir: &Path, + ) -> Result { + let mut env: HashMap = std::env::vars().collect(); + + if let Some(issue) = issue { + let source_upper = issue.source.to_uppercase(); + env.insert(format!("{}_ISSUE_ID", source_upper), issue.id.clone()); + env.insert( + format!("{}_ISSUE_SHORT_ID", source_upper), + issue.short_id.clone(), + ); + env.insert(format!("{}_ISSUE_URL", source_upper), issue.url.clone()); + } + + let label = issue.map(|i| i.short_id.as_str()).unwrap_or("custom"); + self.execute_with_env_and_attempt(prompt, label, env, attempt_id, project_dir).await + } + + async fn execute_with_env_and_attempt( + &self, + prompt: &str, + label: &str, + env: HashMap, + attempt_id: Option, + project_dir: &Path, + ) -> Result { + // Create execution record for analytics + let mut execution = ClaudeExecution::new(); + if let Some(id) = attempt_id { + execution = execution.with_attempt_id(id); + } + execution.prompt_used = Some(prompt.to_string()); + execution.prompt_hash = Some(Self::hash_prompt(prompt)); + execution.model_version = Some("claude-code".to_string()); + execution.working_directory = Some(project_dir.display().to_string()); + + tracing::info!( + component = "claude", + label = label, + timeout_secs = self.config.timeout_secs, + "Starting execution" + ); + + // Log claude_started activity + let activity = ActivityLogEntry::new( + "claude_started", + format!("Claude execution started for {}", label), + ) + .with_source("claude".to_string()) + .with_metadata(json!({ + "timeout_secs": self.config.timeout_secs, + "working_dir": project_dir.display().to_string(), + "label": label + })); + self.tracker.record_activity(&activity).ok(); + + let mut child = Command::new("claude") + .args(["--print", "--dangerously-skip-permissions", prompt]) + .current_dir(project_dir) + .envs(env) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| Error::runner(format!("Failed to spawn claude: {}", e)))?; + + let stdout = child + .stdout + .take() + .ok_or_else(|| Error::runner("Failed to capture stdout"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| Error::runner("Failed to capture stderr"))?; + + let label_stdout = label.to_string(); + let label_stderr = label.to_string(); + + // Stream stdout + let stdout_handle = tokio::spawn(async move { + let mut lines = BufReader::new(stdout).lines(); + let mut output = String::new(); + while let Ok(Some(line)) = lines.next_line().await { + if !line.trim().is_empty() { + tracing::info!( + component = "claude", + label = label_stdout.as_str(), + "{}", + line + ); + } + output.push_str(&line); + output.push('\n'); + } + output + }); + + // Stream stderr + let stderr_handle = tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + let mut output = String::new(); + while let Ok(Some(line)) = lines.next_line().await { + if !line.trim().is_empty() { + tracing::error!( + component = "claude", + label = label_stderr.as_str(), + "{}", + line + ); + } + output.push_str(&line); + output.push('\n'); + } + output + }); + + // Wait for process with timeout + let timeout_duration = std::time::Duration::from_secs(self.config.timeout_secs); + let wait_result = tokio::time::timeout(timeout_duration, child.wait()).await; + + let (status, timed_out) = match wait_result { + Ok(Ok(status)) => (status, false), + Ok(Err(e)) => { + return Err(Error::runner(format!("Failed to wait for claude: {}", e))); + } + Err(_) => { + // Timeout occurred - try to kill the process + tracing::error!( + component = "claude", + label = label, + timeout_secs = self.config.timeout_secs, + "Process timed out, attempting to kill" + ); + if let Err(e) = child.kill().await { + tracing::error!(component = "claude", error = %e, "Failed to kill timed-out process"); + } + + // Log claude_timed_out activity + let activity = ActivityLogEntry::new( + "claude_timed_out", + format!("Claude timed out for {}", label), + ) + .with_source("claude".to_string()) + .with_metadata(json!({ + "timeout_secs": self.config.timeout_secs, + "label": label + })); + self.tracker.record_activity(&activity).ok(); + + // Record the timed-out execution + execution.complete(None, true); + execution.stderr_preview = Some(format!( + "Process timed out after {} seconds", + self.config.timeout_secs + )); + if let Err(e) = self.tracker.record_execution(&execution) { + tracing::warn!(error = %e, "Failed to record timed-out execution to database"); + } + + // Return a result indicating timeout + return Ok(ClaudeResult { + success: false, + output: String::new(), + pr_url: None, + error: Some(format!( + "Process timed out after {} seconds", + self.config.timeout_secs + )), + }); + } + }; + + let stdout_output = stdout_handle.await.unwrap_or_default(); + let stderr_output = stderr_handle.await.unwrap_or_default(); + + let exit_code = status.code().unwrap_or(-1); + tracing::info!( + component = "claude", + label = label, + exit_code = exit_code, + timed_out = timed_out, + "Process completed" + ); + + let pr_url = Self::extract_pr_url(&stdout_output); + + if let Some(ref url) = pr_url { + tracing::info!( + component = "claude", + label = label, + pr_url = url, + "PR URL extracted" + ); + } + + // Complete and record the execution + execution.complete(status.code(), timed_out); + execution.stdout_preview = Some(Self::truncate(&stdout_output, 2000)); + execution.stderr_preview = if stderr_output.is_empty() { + None + } else { + Some(Self::truncate(&stderr_output, 2000)) + }; + + // Record the execution to the database (don't fail the main operation if this fails) + if let Err(e) = self.tracker.record_execution(&execution) { + tracing::warn!(error = %e, "Failed to record execution to database"); + } + + // Log completion activity + if status.success() { + let activity = ActivityLogEntry::new( + "claude_completed", + format!("Claude completed for {}", label), + ) + .with_source("claude".to_string()) + .with_metadata(json!({ + "duration_secs": execution.duration_secs, + "exit_code": exit_code, + "has_pr": pr_url.is_some(), + "label": label + })); + self.tracker.record_activity(&activity).ok(); + } else { + let error_msg = if stderr_output.is_empty() { + format!("Process exited with code {}", exit_code) + } else { + stderr_output.clone() + }; + let activity = ActivityLogEntry::new( + "claude_failed", + format!("Claude failed for {}: {}", label, Self::truncate(&error_msg, 100)), + ) + .with_source("claude".to_string()) + .with_metadata(json!({ + "duration_secs": execution.duration_secs, + "exit_code": exit_code, + "error": Self::truncate(&error_msg, 500), + "label": label + })); + self.tracker.record_activity(&activity).ok(); + } + + Ok(ClaudeResult { + success: status.success(), + output: stdout_output, + pr_url, + error: if status.success() { + None + } else { + Some(if stderr_output.is_empty() { + format!("Process exited with code {}", exit_code) + } else { + stderr_output + }) + }, + }) + } + + /// Compute a SHA256 hash of the prompt for grouping similar prompts. + fn hash_prompt(prompt: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(prompt.as_bytes()); + let result = hasher.finalize(); + format!("{:x}", result)[..16].to_string() // First 16 hex chars + } + + /// Truncate a string to max_len, adding "..." if truncated. + fn truncate(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else { + format!("{}...", &s[..max_len.saturating_sub(3)]) + } + } + + /// Extract PR URL from output. + fn extract_pr_url(output: &str) -> Option { + // Try explicit PR_URL format first + if let Some(captures) = regex_lite::Regex::new(r"PR_URL:\s*(https://[^\s]+)") + .ok() + .and_then(|re| re.captures(output)) + { + return captures.get(1).map(|m| m.as_str().to_string()); + } + + // Try GitHub PR URL pattern + if let Some(captures) = regex_lite::Regex::new(r"https://github\.com/[^\s]+/pull/\d+") + .ok() + .and_then(|re| re.find(output)) + { + return Some(captures.as_str().to_string()); + } + + // Try GitLab MR URL pattern + if let Some(captures) = + regex_lite::Regex::new(r"https://gitlab\.com/[^\s]+/-/merge_requests/\d+") + .ok() + .and_then(|re| re.find(output)) + { + return Some(captures.as_str().to_string()); + } + + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_pr_url_explicit() { + let output = "Some output\nPR_URL: https://github.com/org/repo/pull/123\nMore output"; + assert_eq!( + ClaudeRunner::extract_pr_url(output), + Some("https://github.com/org/repo/pull/123".to_string()) + ); + } + + #[test] + fn test_extract_pr_url_github() { + let output = "Created PR at https://github.com/myorg/myrepo/pull/456 successfully"; + assert_eq!( + ClaudeRunner::extract_pr_url(output), + Some("https://github.com/myorg/myrepo/pull/456".to_string()) + ); + } + + #[test] + fn test_extract_pr_url_gitlab() { + let output = "MR created: https://gitlab.com/group/project/-/merge_requests/789"; + assert_eq!( + ClaudeRunner::extract_pr_url(output), + Some("https://gitlab.com/group/project/-/merge_requests/789".to_string()) + ); + } + + #[test] + fn test_extract_pr_url_none() { + let output = "No PR URL in this output"; + assert_eq!(ClaudeRunner::extract_pr_url(output), None); + } + + #[test] + fn test_build_prompt() { + let runner = ClaudeRunner::new_simple(ClaudeRunnerConfig::default()); + + let issue = Issue::new( + "123", + "PROJ-123", + "Fix bug", + "https://example.com", + "linear", + ); + let context = "Issue description here"; + let project_dir = std::path::Path::new("/tmp"); + + let prompt = runner.build_prompt(&issue, context, project_dir); + assert!(prompt.contains("Linear")); + assert!(prompt.contains("Issue description here") || prompt.contains("context")); + assert!(prompt.contains("PROJ-123")); + assert!(prompt.contains("PR_URL")); + } + + #[test] + fn test_extract_pr_url_with_trailing_punctuation() { + let output = "Created PR: https://github.com/org/repo/pull/123."; + // The regex will stop at whitespace, so trailing period might be included + let url = ClaudeRunner::extract_pr_url(output); + assert!(url.is_some()); + let url = url.unwrap(); + assert!(url.starts_with("https://github.com/org/repo/pull/123")); + } + + #[test] + fn test_extract_pr_url_multiple_urls() { + let output = "First PR: https://github.com/org/repo1/pull/100\nSecond PR: https://github.com/org/repo2/pull/200"; + let url = ClaudeRunner::extract_pr_url(output); + assert!(url.is_some()); + // Should find the first one + assert!(url.unwrap().contains("/pull/")); + } + + #[test] + fn test_extract_pr_url_explicit_takes_precedence() { + let output = "Random text https://github.com/org/repo/pull/999\nPR_URL: https://github.com/org/main/pull/123"; + let url = ClaudeRunner::extract_pr_url(output); + // PR_URL should take precedence + assert_eq!( + url, + Some("https://github.com/org/main/pull/123".to_string()) + ); + } + + #[test] + fn test_extract_pr_url_with_query_params() { + // GitHub URLs might have query params, but our regex stops at whitespace + let output = "PR at https://github.com/org/repo/pull/123?diff=split created"; + let url = ClaudeRunner::extract_pr_url(output); + assert!(url.is_some()); + } + + #[test] + fn test_extract_pr_url_gitlab_nested_groups() { + let output = "MR: https://gitlab.com/group/subgroup/project/-/merge_requests/42"; + let url = ClaudeRunner::extract_pr_url(output); + assert_eq!( + url, + Some("https://gitlab.com/group/subgroup/project/-/merge_requests/42".to_string()) + ); + } + + #[test] + fn test_extract_pr_url_empty_string() { + assert_eq!(ClaudeRunner::extract_pr_url(""), None); + } + + #[test] + fn test_extract_pr_url_similar_but_not_valid() { + // Not a valid PR URL + let output = "See https://github.com/org/repo/issues/123"; + let url = ClaudeRunner::extract_pr_url(output); + assert!(url.is_none()); + } + + #[test] + fn test_build_prompt_sentry_source() { + let runner = ClaudeRunner::new_simple(ClaudeRunnerConfig::default()); + + let issue = Issue::new( + "456", + "SENTRY-456", + "TypeError in main.js", + "https://sentry.io/123", + "sentry", + ); + let context = "Stack trace here"; + + let project_dir = std::path::Path::new("/tmp"); + let prompt = runner.build_prompt(&issue, context, project_dir); + // Prompt should be non-empty and contain either source name or context + assert!(!prompt.is_empty()); + // Either templates are used (which have different content) or fallback + assert!( + prompt.contains("sentry") + || prompt.contains("Sentry") + || prompt.contains("Stack trace") + || prompt.contains("TypeError") + ); + } + + #[test] + fn test_claude_result_success() { + let result = ClaudeResult { + success: true, + output: "Success output".to_string(), + pr_url: Some("https://github.com/org/repo/pull/123".to_string()), + error: None, + }; + + assert!(result.success); + assert!(result.pr_url.is_some()); + assert!(result.error.is_none()); + } + + #[test] + fn test_claude_result_failure() { + let result = ClaudeResult { + success: false, + output: "Error occurred".to_string(), + pr_url: None, + error: Some("Error message".to_string()), + }; + + assert!(!result.success); + assert!(result.pr_url.is_none()); + assert!(result.error.is_some()); + } + + #[test] + fn test_runner_config() { + let config = ClaudeRunnerConfig::default(); + + let runner = ClaudeRunner::new_simple(config); + // Verify runner can be created + let issue = Issue::new("1", "TEST-1", "Test", "https://test.com", "linear"); + let project_dir = std::path::Path::new("/path/to/project"); + let prompt = runner.build_prompt(&issue, "context", project_dir); + assert!(!prompt.is_empty()); + } + + #[test] + fn test_claude_result_default_fields() { + let result = ClaudeResult { + success: false, + output: String::new(), + pr_url: None, + error: None, + }; + + assert!(!result.success); + assert!(result.output.is_empty()); + assert!(result.pr_url.is_none()); + assert!(result.error.is_none()); + } + + #[test] + fn test_claude_runner_config_debug() { + let config = ClaudeRunnerConfig { + timeout_secs: 3600, + }; + let debug = format!("{:?}", config); + assert!(debug.contains("timeout_secs")); + } + + #[test] + fn test_extract_pr_url_whitespace_only() { + assert_eq!(ClaudeRunner::extract_pr_url(" \n\t "), None); + } + + #[test] + fn test_extract_pr_url_github_enterprise() { + // GitHub enterprise URLs might have different domains + let output = "PR at https://github.mycompany.com/org/repo/pull/99"; + // This should NOT match since it's not github.com + let url = ClaudeRunner::extract_pr_url(output); + assert!(url.is_none()); + } + + #[test] + fn test_extract_pr_url_with_newlines() { + let output = "PR created\n\nhttps://github.com/org/repo/pull/42\n\nDone"; + let url = ClaudeRunner::extract_pr_url(output); + assert!(url.is_some()); + assert!(url.unwrap().contains("/pull/42")); + } + + #[test] + fn test_extract_pr_url_pr_url_colon_space() { + let output = "PR_URL: https://github.com/org/repo/pull/1 "; + let url = ClaudeRunner::extract_pr_url(output); + assert_eq!(url, Some("https://github.com/org/repo/pull/1".to_string())); + } + + #[test] + fn test_build_prompt_github_source() { + let runner = ClaudeRunner::new_simple(ClaudeRunnerConfig::default()); + + let issue = Issue::new( + "789", + "#789", + "Add feature", + "https://github.com/org/repo/issues/789", + "github", + ); + let context = "Feature description"; + let project_dir = std::path::Path::new("/tmp"); + + let prompt = runner.build_prompt(&issue, context, project_dir); + assert!(!prompt.is_empty()); + } + + #[test] + fn test_build_prompt_unknown_source() { + let runner = ClaudeRunner::new_simple(ClaudeRunnerConfig::default()); + + let issue = Issue::new( + "123", + "JIRA-123", + "Bug fix", + "https://jira.example.com/123", + "jira", + ); + let context = "Bug details"; + let project_dir = std::path::Path::new("/tmp"); + + let prompt = runner.build_prompt(&issue, context, project_dir); + // Should still produce a prompt even for unknown source + assert!(!prompt.is_empty()); + } + + #[test] + fn test_has_agent_md() { + let runner = ClaudeRunner::new_simple(ClaudeRunnerConfig::default()); + let project_dir = std::path::Path::new("/nonexistent/path"); + // No AGENT.md should exist at nonexistent path + assert!(!runner.has_agent_md(project_dir)); + } + + #[test] + fn test_get_agent_md_nonexistent() { + let runner = ClaudeRunner::new_simple(ClaudeRunnerConfig::default()); + let project_dir = std::path::Path::new("/nonexistent/path"); + // Should return None for nonexistent AGENT.md + assert!(runner.get_agent_md(project_dir).is_none()); + } + + #[test] + fn test_claude_result_with_long_output() { + let output = "x".repeat(10000); + let result = ClaudeResult { + success: true, + output, + pr_url: None, + error: None, + }; + + assert!(result.success); + assert_eq!(result.output.len(), 10000); + } + + #[test] + fn test_claude_result_with_empty_error() { + let result = ClaudeResult { + success: false, + output: "Output".to_string(), + pr_url: None, + error: Some(String::new()), + }; + + // Even empty error is Some("") + assert!(result.error.is_some()); + assert!(result.error.unwrap().is_empty()); + } + + #[test] + fn test_extract_pr_url_case_sensitivity() { + // PR_URL: pattern should be case sensitive, but GitHub URL pattern is separate + let output = "pr_url: https://github.com/org/repo/pull/1"; + let url = ClaudeRunner::extract_pr_url(output); + // The GitHub URL regex will match regardless of the pr_url: prefix + // This test verifies the behavior: PR_URL: must be uppercase to be the explicit format + // But the GitHub URL fallback will still match + assert!(url.is_some()); + } + + #[test] + fn test_extract_pr_url_gitlab_with_path() { + let output = "MR: https://gitlab.com/a/b/c/d/-/merge_requests/123"; + let url = ClaudeRunner::extract_pr_url(output); + assert_eq!( + url, + Some("https://gitlab.com/a/b/c/d/-/merge_requests/123".to_string()) + ); + } + + #[test] + fn test_claude_runner_config_clone() { + let config = ClaudeRunnerConfig { + timeout_secs: 3600, + }; + let cloned = config.clone(); + assert_eq!(cloned.timeout_secs, config.timeout_secs); + } + + #[test] + fn test_build_prompt_empty_context() { + let runner = ClaudeRunner::new_simple(ClaudeRunnerConfig::default()); + + let issue = Issue::new("1", "TEST-1", "Test", "https://example.com", "linear"); + let project_dir = std::path::Path::new("/tmp"); + let prompt = runner.build_prompt(&issue, "", project_dir); + // Should still produce a prompt even with empty context + assert!(!prompt.is_empty()); + assert!(prompt.contains("PROJ") || prompt.contains("TEST-1") || prompt.contains("Linear")); + } + + #[test] + fn test_build_prompt_special_characters() { + let runner = ClaudeRunner::new_simple(ClaudeRunnerConfig::default()); + + let issue = Issue::new( + "1", + "TEST-1", + "Fix \"quoted\" issue & ", + "https://example.com", + "linear", + ); + let context = "Context with special chars: <>&\"'"; + let project_dir = std::path::Path::new("/tmp"); + + let prompt = runner.build_prompt(&issue, context, project_dir); + assert!(!prompt.is_empty()); + } + + #[test] + fn test_extract_pr_url_no_scheme() { + let output = "github.com/org/repo/pull/123"; + let url = ClaudeRunner::extract_pr_url(output); + // Should not match without https:// + assert!(url.is_none()); + } + + #[test] + fn test_extract_pr_url_http_not_https() { + // HTTP URLs should not match (security) + let output = "PR at http://github.com/org/repo/pull/123"; + let url = ClaudeRunner::extract_pr_url(output); + assert!(url.is_none()); + } + + #[test] + fn test_extract_pr_url_multiline_pr_url() { + let output = "Creating PR...\nPR_URL: https://github.com/org/repo/pull/42\nDone!"; + let url = ClaudeRunner::extract_pr_url(output); + assert_eq!(url, Some("https://github.com/org/repo/pull/42".to_string())); + } + + #[test] + fn test_build_prompt_multiline_context() { + let runner = ClaudeRunner::new_simple(ClaudeRunnerConfig::default()); + + let issue = Issue::new("1", "TEST-1", "Bug", "https://example.com", "linear"); + let context = "Line 1\nLine 2\nLine 3\n"; + let project_dir = std::path::Path::new("/tmp"); + + let prompt = runner.build_prompt(&issue, context, project_dir); + assert!(!prompt.is_empty()); + } + + #[test] + fn test_extract_pr_url_gitlab_self_hosted() { + // Only gitlab.com should match, not self-hosted + let output = "MR: https://gitlab.mycompany.com/group/project/-/merge_requests/123"; + let url = ClaudeRunner::extract_pr_url(output); + assert!(url.is_none()); + } + + #[test] + fn test_extract_pr_url_github_pr_zero() { + // PR number 0 is technically invalid but should match regex + let output = "PR: https://github.com/org/repo/pull/0"; + let url = ClaudeRunner::extract_pr_url(output); + assert!(url.is_some()); + } + + #[test] + fn test_extract_pr_url_very_long_pr_number() { + let output = "PR: https://github.com/org/repo/pull/999999999999"; + let url = ClaudeRunner::extract_pr_url(output); + assert_eq!( + url, + Some("https://github.com/org/repo/pull/999999999999".to_string()) + ); + } + + #[test] + fn test_extract_pr_url_org_with_dashes() { + let output = "PR: https://github.com/my-org-name/my-repo-name/pull/123"; + let url = ClaudeRunner::extract_pr_url(output); + assert_eq!( + url, + Some("https://github.com/my-org-name/my-repo-name/pull/123".to_string()) + ); + } + + #[test] + fn test_extract_pr_url_org_with_dots() { + let output = "PR: https://github.com/org.name/repo.name/pull/123"; + let url = ClaudeRunner::extract_pr_url(output); + assert_eq!( + url, + Some("https://github.com/org.name/repo.name/pull/123".to_string()) + ); + } + + #[test] + fn test_claude_result_pr_url_with_trailing_slash() { + // Test PR URL doesn't capture trailing slash + let output = "PR_URL: https://github.com/org/repo/pull/123/ Done"; + let url = ClaudeRunner::extract_pr_url(output); + // The regex matches up to whitespace, so trailing slash would be captured + assert!(url.is_some()); + } + + #[test] + fn test_build_prompt_unicode_context() { + let runner = ClaudeRunner::new_simple(ClaudeRunnerConfig::default()); + + let issue = Issue::new( + "1", + "TEST-1", + "Fix 日本語 issue", + "https://example.com", + "linear", + ); + let context = "Context with emoji 🎉 and unicode ñ"; + let project_dir = std::path::Path::new("/tmp"); + + let prompt = runner.build_prompt(&issue, context, project_dir); + assert!(!prompt.is_empty()); + } + + #[test] + fn test_claude_runner_new_with_tracker() { + use crate::storage::SqliteTracker; + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + let config = ClaudeRunnerConfig::default(); + let runner = ClaudeRunner::new(config, tracker); + let project_dir = std::path::Path::new("/tmp"); + assert!(!runner.has_agent_md(project_dir)); + } + + #[test] + fn test_issue_creation_for_runner() { + let issue = Issue::new("id123", "SHORT-123", "Title", "https://url.com", "linear"); + assert_eq!(issue.id, "id123"); + assert_eq!(issue.short_id, "SHORT-123"); + assert_eq!(issue.title, "Title"); + assert_eq!(issue.url, "https://url.com"); + assert_eq!(issue.source, "linear"); + } +} diff --git a/src/source/linear.rs b/src/source/linear.rs new file mode 100644 index 00000000..1e5ca479 --- /dev/null +++ b/src/source/linear.rs @@ -0,0 +1,1789 @@ +//! Linear issue source adapter. + +use super::IssueSource; +use crate::config::LinearConfig; +use crate::error::{Error, Result}; +use crate::types::{Issue, IssuePriority, IssueStatus, MatchPriority, MatchResult}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +/// HTTP response abstraction for testability. +#[derive(Debug)] +pub struct HttpResponse { + pub status: u16, + pub body: String, +} + +impl HttpResponse { + /// Check if the status is successful (2xx). + pub fn is_success(&self) -> bool { + (200..300).contains(&self.status) + } + + /// Parse the body as JSON. + pub fn json(&self) -> Result { + serde_json::from_str(&self.body) + .map_err(|e| Error::Other(format!("JSON parse error: {}", e))) + } +} + +/// Trait for GraphQL client operations to enable testing. +#[async_trait] +pub trait LinearHttpClient: Send + Sync { + /// Perform a POST request with JSON body. + async fn post(&self, url: &str, api_key: &str, body: serde_json::Value) + -> Result; +} + +/// Default HTTP client using reqwest. +pub struct ReqwestLinearClient { + client: reqwest::Client, +} + +impl ReqwestLinearClient { + /// Create a new reqwest-based HTTP client. + pub fn new() -> Self { + Self { + client: reqwest::Client::new(), + } + } +} + +impl Default for ReqwestLinearClient { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl LinearHttpClient for ReqwestLinearClient { + async fn post( + &self, + url: &str, + api_key: &str, + body: serde_json::Value, + ) -> Result { + let response = self + .client + .post(url) + .header("Authorization", api_key) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await?; + let status = response.status().as_u16(); + let body_text = response.text().await.unwrap_or_default(); + Ok(HttpResponse { + status, + body: body_text, + }) + } +} + +/// Linear GraphQL API client. +pub struct LinearSource { + config: LinearConfig, + http: H, +} + +// GraphQL types +#[derive(Debug, Serialize)] +struct GraphQLRequest { + query: &'static str, + variables: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +struct GraphQLResponse { + data: Option, + errors: Option>, +} + +#[derive(Debug, Deserialize)] +struct GraphQLError { + message: String, +} + +#[derive(Debug, Deserialize)] +struct IssuesResponse { + issues: IssuesConnection, +} + +#[derive(Debug, Deserialize)] +struct IssuesConnection { + nodes: Vec, +} + +#[derive(Debug, Deserialize)] +struct IssueResponse { + issue: Option, +} + +#[derive(Debug, Deserialize)] +struct LinearIssue { + id: String, + identifier: String, + title: String, + description: Option, + url: String, + priority: i32, + #[serde(rename = "createdAt")] + created_at: String, + #[serde(rename = "updatedAt")] + updated_at: String, + state: Option, + labels: LabelsConnection, + team: Option, + project: Option, + assignee: Option, +} + +#[derive(Debug, Deserialize)] +struct LinearState { + name: String, + #[serde(rename = "type")] + state_type: String, +} + +#[derive(Debug, Deserialize)] +struct LabelsConnection { + nodes: Vec, +} + +#[derive(Debug, Deserialize)] +struct LinearLabel { + name: String, +} + +#[derive(Debug, Deserialize)] +struct LinearTeam { + id: String, + name: String, +} + +#[derive(Debug, Deserialize)] +struct LinearProject { + id: String, + name: String, +} + +#[derive(Debug, Deserialize)] +struct LinearUser { + name: String, +} + +const ISSUES_QUERY: &str = r#" +query Issues($filter: IssueFilter, $first: Int) { + issues(filter: $filter, first: $first, orderBy: updatedAt) { + nodes { + id + identifier + title + description + url + priority + createdAt + updatedAt + state { + name + type + } + labels { + nodes { + name + } + } + team { + id + name + } + project { + id + name + } + assignee { + name + } + } + } +} +"#; + +const ISSUE_QUERY: &str = r#" +query Issue($id: String!) { + issue(id: $id) { + id + identifier + title + description + url + priority + createdAt + updatedAt + state { + name + type + } + labels { + nodes { + name + } + } + team { + id + name + } + project { + id + name + } + assignee { + name + } + } +} +"#; + +impl LinearSource { + /// Create a new Linear source with the default HTTP client. + pub fn new(config: LinearConfig) -> Self { + Self { + config, + http: ReqwestLinearClient::new(), + } + } +} + +impl LinearSource { + /// Create a new Linear source with a custom HTTP client. + pub fn with_http_client(config: LinearConfig, http: H) -> Self { + Self { config, http } + } + + async fn graphql Deserialize<'de>>( + &self, + query: &'static str, + variables: serde_json::Value, + ) -> Result { + let request = GraphQLRequest { query, variables }; + let body = serde_json::to_value(&request) + .map_err(|e| Error::Other(format!("JSON error: {}", e)))?; + + let response = self + .http + .post("https://api.linear.app/graphql", &self.config.api_key, body) + .await?; + + if !response.is_success() { + return Err(Error::source( + "linear", + format!("API error: {}", response.body), + )); + } + + let gql_response: GraphQLResponse = response.json()?; + + if let Some(errors) = gql_response.errors { + let messages: Vec<_> = errors.iter().map(|e| e.message.as_str()).collect(); + return Err(Error::source("linear", messages.join(", "))); + } + + gql_response + .data + .ok_or_else(|| Error::source("linear", "No data in response")) + } + + fn map_issue(&self, issue: LinearIssue) -> Issue { + let labels: Vec = issue.labels.nodes.iter().map(|l| l.name.clone()).collect(); + + let mut mapped = Issue::new(issue.id, issue.identifier, issue.title, issue.url, "linear"); + + mapped.description = issue.description; + mapped.priority = Self::map_priority(issue.priority); + mapped.status = Self::map_status(issue.state.as_ref().map(|s| s.state_type.as_str())); + + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&issue.created_at) { + mapped.created_at = Some(dt.with_timezone(&chrono::Utc)); + } + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&issue.updated_at) { + mapped.updated_at = Some(dt.with_timezone(&chrono::Utc)); + } + + // Store metadata + if let Some(ref state) = issue.state { + mapped.set_metadata("state_name", &state.name); + mapped.set_metadata("state_type", &state.state_type); + } + mapped.set_metadata("labels", &labels); + if let Some(ref team) = issue.team { + mapped.set_metadata("team", &team.name); + mapped.set_metadata("team_id", &team.id); + } + if let Some(ref project) = issue.project { + mapped.set_metadata("project", &project.name); + mapped.set_metadata("project_id", &project.id); + } + if let Some(ref assignee) = issue.assignee { + mapped.set_metadata("assignee", &assignee.name); + } + + mapped + } + + fn map_priority(priority: i32) -> IssuePriority { + match priority { + 1 => IssuePriority::Critical, + 2 => IssuePriority::High, + 3 => IssuePriority::Medium, + 4 => IssuePriority::Low, + _ => IssuePriority::None, + } + } + + fn map_status(state_type: Option<&str>) -> IssueStatus { + match state_type { + Some("completed") | Some("canceled") => IssueStatus::Resolved, + Some("started") => IssueStatus::InProgress, + _ => IssueStatus::Open, + } + } + + /// Check if a Linear state type represents a terminal state. + /// Terminal states are those where the issue is considered "done" - no further action needed. + pub fn is_issue_terminal(state_type: &str) -> bool { + let s = state_type.to_lowercase(); + s == "completed" || s == "canceled" || s == "cancelled" + } +} + +#[async_trait] +impl IssueSource for LinearSource { + fn name(&self) -> &str { + "linear" + } + + fn display_name(&self) -> &str { + "Linear" + } + + async fn fetch_issues(&self) -> Result> { + let mut filter = serde_json::Map::new(); + + if let Some(ref team_id) = self.config.team_id { + filter.insert( + "team".to_string(), + serde_json::json!({ "id": { "eq": team_id } }), + ); + } + + if let Some(ref project_id) = self.config.project_id { + filter.insert( + "project".to_string(), + serde_json::json!({ "id": { "eq": project_id } }), + ); + } + + if !self.config.trigger_labels.is_empty() { + filter.insert( + "labels".to_string(), + serde_json::json!({ + "some": { + "name": { "in": self.config.trigger_labels } + } + }), + ); + } + + let variables = serde_json::json!({ + "filter": filter, + "first": 50 + }); + + let response: IssuesResponse = self.graphql(ISSUES_QUERY, variables).await?; + + Ok(response + .issues + .nodes + .into_iter() + .map(|i| self.map_issue(i)) + .collect()) + } + + fn matches_criteria(&self, issue: &Issue) -> MatchResult { + let state_name: Option = issue.get_metadata("state_name"); + let state_type: Option = issue.get_metadata("state_type"); + let labels: Vec = issue.get_metadata("labels").unwrap_or_default(); + + // Check state + if !self.config.trigger_states.is_empty() { + let state_name_lower = state_name.as_deref().unwrap_or("").to_lowercase(); + let state_type_lower = state_type.as_deref().unwrap_or("").to_lowercase(); + + let state_matches = self.config.trigger_states.iter().any(|s| { + let s_lower = s.to_lowercase(); + state_name_lower.contains(&s_lower) || state_type_lower.contains(&s_lower) + }); + + if !state_matches { + return MatchResult::not_matched(format!( + "State \"{}\" not in trigger states", + state_name.as_deref().unwrap_or("unknown") + )); + } + } + + // Check labels + if !self.config.trigger_labels.is_empty() { + let label_matches = self.config.trigger_labels.iter().any(|trigger| { + labels + .iter() + .any(|l| l.to_lowercase() == trigger.to_lowercase()) + }); + + if !label_matches { + return MatchResult::not_matched("No matching trigger labels"); + } + } + + // Determine priority + let priority = match issue.priority { + IssuePriority::Critical => MatchPriority::Urgent, + IssuePriority::High => MatchPriority::High, + IssuePriority::Low => MatchPriority::Low, + _ => MatchPriority::Normal, + }; + + MatchResult::matched("Matches state and label criteria", priority) + } + + async fn build_issue_context(&self, issue: &Issue) -> Result { + let mut context = format!("# Linear Issue: {}\n\n", issue.short_id); + context.push_str(&format!("**Title:** {}\n", issue.title)); + context.push_str(&format!("**URL:** {}\n", issue.url)); + context.push_str(&format!("**Priority:** {}\n", issue.priority)); + context.push_str(&format!("**Status:** {}\n\n", issue.status)); + + if let Some(ref description) = issue.description { + context.push_str(&format!("## Description\n{}\n\n", description)); + } + + if let Some(team) = issue.get_metadata::("team") { + context.push_str(&format!("**Team:** {}\n", team)); + } + if let Some(project) = issue.get_metadata::("project") { + context.push_str(&format!("**Project:** {}\n", project)); + } + if let Some(assignee) = issue.get_metadata::("assignee") { + context.push_str(&format!("**Assignee:** {}\n", assignee)); + } + + Ok(context) + } + + async fn get_issue(&self, issue_id: &str) -> Result { + let variables = serde_json::json!({ + "id": issue_id + }); + + let response: IssueResponse = self.graphql(ISSUE_QUERY, variables).await?; + + response + .issue + .map(|i| self.map_issue(i)) + .ok_or_else(|| Error::issue_not_found("linear", issue_id)) + } + + async fn resolve_issue(&self, issue_id: &str) -> Result<()> { + // First, get the "Done" state ID for this issue's team + let issue = self.get_issue(issue_id).await?; + let team_id = issue + .get_metadata::("team_id") + .ok_or_else(|| Error::source("linear", "Issue has no team_id"))?; + + // Query for team's workflow states to find "Done" state + #[derive(Debug, Deserialize)] + struct TeamStatesResponse { + team: Option, + } + #[derive(Debug, Deserialize)] + struct TeamWithStates { + states: StatesConnection, + } + #[derive(Debug, Deserialize)] + struct StatesConnection { + nodes: Vec, + } + #[derive(Debug, Deserialize)] + struct WorkflowState { + id: String, + name: String, + #[serde(rename = "type")] + state_type: String, + } + + const TEAM_STATES_QUERY: &str = r#" + query TeamStates($teamId: String!) { + team(id: $teamId) { + states { + nodes { + id + name + type + } + } + } + } + "#; + + let states_response: TeamStatesResponse = self + .graphql(TEAM_STATES_QUERY, serde_json::json!({ "teamId": team_id })) + .await?; + + let done_state = states_response + .team + .and_then(|t| { + t.states + .nodes + .into_iter() + .find(|s| s.state_type == "completed") + }) + .ok_or_else(|| Error::source("linear", "Could not find completed state for team"))?; + + // Update the issue state to Done + #[derive(Debug, Deserialize)] + struct IssueUpdateResponse { + #[serde(rename = "issueUpdate")] + issue_update: Option, + } + #[derive(Debug, Deserialize)] + struct IssueUpdatePayload { + success: bool, + } + + const ISSUE_UPDATE_MUTATION: &str = r#" + mutation UpdateIssue($id: String!, $stateId: String!) { + issueUpdate(id: $id, input: { stateId: $stateId }) { + success + } + } + "#; + + let update_response: IssueUpdateResponse = self + .graphql( + ISSUE_UPDATE_MUTATION, + serde_json::json!({ + "id": issue_id, + "stateId": done_state.id + }), + ) + .await?; + + if !update_response + .issue_update + .map(|u| u.success) + .unwrap_or(false) + { + return Err(Error::source("linear", "Failed to update issue state")); + } + + tracing::info!( + source = "linear", + issue_id = %issue_id, + state = %done_state.name, + "Resolved issue" + ); + Ok(()) + } + + async fn add_comment(&self, issue_id: &str, comment: &str) -> Result<()> { + #[derive(Debug, Deserialize)] + struct CommentCreateResponse { + #[serde(rename = "commentCreate")] + comment_create: Option, + } + #[derive(Debug, Deserialize)] + struct CommentCreatePayload { + success: bool, + } + + const COMMENT_CREATE_MUTATION: &str = r#" + mutation CreateComment($issueId: String!, $body: String!) { + commentCreate(input: { issueId: $issueId, body: $body }) { + success + } + } + "#; + + let response: CommentCreateResponse = self + .graphql( + COMMENT_CREATE_MUTATION, + serde_json::json!({ + "issueId": issue_id, + "body": comment + }), + ) + .await?; + + if !response.comment_create.map(|c| c.success).unwrap_or(false) { + return Err(Error::source("linear", "Failed to create comment")); + } + + tracing::info!(source = "linear", issue_id = %issue_id, "Added comment to issue"); + Ok(()) + } + + async fn get_issue_status(&self, issue_id: &str) -> Result { + let issue = self.get_issue(issue_id).await?; + let state_type: Option = issue.get_metadata("state_type"); + Ok(state_type.unwrap_or_else(|| "unknown".to_string())) + } + + fn is_terminal_status(&self, status: &str) -> bool { + Self::is_issue_terminal(status) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + + /// Mock HTTP client for testing. + pub struct MockLinearClient { + responses: Mutex>, + requests: Mutex>, + } + + impl MockLinearClient { + pub fn new() -> Self { + Self { + responses: Mutex::new(HashMap::new()), + requests: Mutex::new(Vec::new()), + } + } + + pub fn mock_response(&self, url: impl Into, status: u16, body: impl Into) { + let mut responses = self.responses.lock().unwrap(); + responses.insert( + url.into(), + HttpResponse { + status, + body: body.into(), + }, + ); + } + + #[allow(dead_code)] + pub fn get_requests(&self) -> Vec<(String, serde_json::Value)> { + self.requests.lock().unwrap().clone() + } + } + + #[async_trait] + impl LinearHttpClient for MockLinearClient { + async fn post( + &self, + url: &str, + _api_key: &str, + body: serde_json::Value, + ) -> Result { + self.requests.lock().unwrap().push((url.to_string(), body)); + let responses = self.responses.lock().unwrap(); + if let Some(response) = responses.get(url) { + Ok(HttpResponse { + status: response.status, + body: response.body.clone(), + }) + } else { + Ok(HttpResponse { + status: 404, + body: "Not found".to_string(), + }) + } + } + } + + #[test] + fn test_http_response_is_success() { + let response = HttpResponse { + status: 200, + body: "{}".to_string(), + }; + assert!(response.is_success()); + let response = HttpResponse { + status: 404, + body: "{}".to_string(), + }; + assert!(!response.is_success()); + } + + #[tokio::test] + async fn test_fetch_issues_success() { + let mock = MockLinearClient::new(); + mock.mock_response( + "https://api.linear.app/graphql", + 200, + r#"{ + "data": { + "issues": { + "nodes": [{ + "id": "123", + "identifier": "PROJ-123", + "title": "Test Issue", + "description": "Description", + "url": "https://linear.app/123", + "priority": 2, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-02T00:00:00Z", + "state": {"name": "Backlog", "type": "backlog"}, + "labels": {"nodes": [{"name": "auto-implement"}]}, + "team": {"id": "team1", "name": "Team"}, + "project": null, + "assignee": null + }] + } + } + }"#, + ); + + let config = test_config(); + let source = LinearSource::with_http_client(config, mock); + let issues = source.fetch_issues().await.unwrap(); + + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].short_id, "PROJ-123"); + assert_eq!(issues[0].title, "Test Issue"); + } + + #[tokio::test] + async fn test_fetch_issues_api_error() { + let mock = MockLinearClient::new(); + mock.mock_response( + "https://api.linear.app/graphql", + 500, + "Internal Server Error", + ); + + let config = test_config(); + let source = LinearSource::with_http_client(config, mock); + let result = source.fetch_issues().await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_fetch_issues_graphql_error() { + let mock = MockLinearClient::new(); + mock.mock_response( + "https://api.linear.app/graphql", + 200, + r#"{ + "errors": [{"message": "Invalid query"}] + }"#, + ); + + let config = test_config(); + let source = LinearSource::with_http_client(config, mock); + let result = source.fetch_issues().await; + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Invalid query")); + } + + #[tokio::test] + async fn test_fetch_issues_no_data() { + let mock = MockLinearClient::new(); + mock.mock_response("https://api.linear.app/graphql", 200, r#"{}"#); + + let config = test_config(); + let source = LinearSource::with_http_client(config, mock); + let result = source.fetch_issues().await; + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("No data")); + } + + #[tokio::test] + async fn test_get_issue_success() { + let mock = MockLinearClient::new(); + mock.mock_response( + "https://api.linear.app/graphql", + 200, + r#"{ + "data": { + "issue": { + "id": "456", + "identifier": "PROJ-456", + "title": "Single Issue", + "description": "Desc", + "url": "https://linear.app/456", + "priority": 1, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-02T00:00:00Z", + "state": {"name": "In Progress", "type": "started"}, + "labels": {"nodes": []}, + "team": null, + "project": null, + "assignee": {"name": "John Doe"} + } + } + }"#, + ); + + let config = test_config(); + let source = LinearSource::with_http_client(config, mock); + let issue = source.get_issue("456").await.unwrap(); + + assert_eq!(issue.id, "456"); + assert_eq!(issue.short_id, "PROJ-456"); + assert_eq!(issue.status, IssueStatus::InProgress); + } + + #[tokio::test] + async fn test_get_issue_not_found() { + let mock = MockLinearClient::new(); + mock.mock_response( + "https://api.linear.app/graphql", + 200, + r#"{ + "data": { + "issue": null + } + }"#, + ); + + let config = test_config(); + let source = LinearSource::with_http_client(config, mock); + let result = source.get_issue("nonexistent").await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_build_issue_context() { + let mock = MockLinearClient::new(); + // No HTTP call needed for build_issue_context - it just formats metadata + + let config = test_config(); + let source = LinearSource::with_http_client(config, mock); + + let mut issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://linear.app/123", + "linear", + ); + issue.description = Some("This is the description".to_string()); + issue.set_metadata("team", "Engineering"); + issue.set_metadata("project", "Project Alpha"); + issue.set_metadata("assignee", "John"); + + let context = source.build_issue_context(&issue).await.unwrap(); + + assert!(context.contains("PROJ-123")); + assert!(context.contains("Test Issue")); + assert!(context.contains("This is the description")); + assert!(context.contains("Engineering")); + assert!(context.contains("Project Alpha")); + assert!(context.contains("John")); + } + + #[tokio::test] + async fn test_fetch_issues_with_team_filter() { + let mock = MockLinearClient::new(); + mock.mock_response( + "https://api.linear.app/graphql", + 200, + r#"{ + "data": { + "issues": { + "nodes": [] + } + } + }"#, + ); + + let mut config = test_config(); + config.team_id = Some("team-123".to_string()); + let source = LinearSource::with_http_client(config, mock); + let issues = source.fetch_issues().await.unwrap(); + + assert!(issues.is_empty()); + } + + #[tokio::test] + async fn test_fetch_issues_with_project_filter() { + let mock = MockLinearClient::new(); + mock.mock_response( + "https://api.linear.app/graphql", + 200, + r#"{ + "data": { + "issues": { + "nodes": [] + } + } + }"#, + ); + + let mut config = test_config(); + config.project_id = Some("proj-123".to_string()); + let source = LinearSource::with_http_client(config, mock); + let issues = source.fetch_issues().await.unwrap(); + + assert!(issues.is_empty()); + } + + fn test_config() -> LinearConfig { + LinearConfig { + enabled: true, + api_key: "test_key".to_string(), + trigger_labels: vec!["auto-implement".to_string(), "claude".to_string()], + trigger_states: vec!["backlog".to_string(), "todo".to_string()], + team_id: None, + project_id: None, + webhook_secret: None, + } + } + + #[test] + fn test_map_priority() { + assert_eq!( + LinearSource::::map_priority(1), + IssuePriority::Critical + ); + assert_eq!( + LinearSource::::map_priority(2), + IssuePriority::High + ); + assert_eq!( + LinearSource::::map_priority(3), + IssuePriority::Medium + ); + assert_eq!( + LinearSource::::map_priority(4), + IssuePriority::Low + ); + assert_eq!( + LinearSource::::map_priority(0), + IssuePriority::None + ); + } + + #[test] + fn test_map_status() { + assert_eq!( + LinearSource::::map_status(Some("completed")), + IssueStatus::Resolved + ); + assert_eq!( + LinearSource::::map_status(Some("canceled")), + IssueStatus::Resolved + ); + assert_eq!( + LinearSource::::map_status(Some("started")), + IssueStatus::InProgress + ); + assert_eq!( + LinearSource::::map_status(Some("backlog")), + IssueStatus::Open + ); + assert_eq!( + LinearSource::::map_status(None), + IssueStatus::Open + ); + } + + #[test] + fn test_matches_criteria_labels() { + let source = LinearSource::new(test_config()); + + let mut issue = Issue::new( + "123", + "PROJ-123", + "Fix bug", + "https://example.com", + "linear", + ); + issue.set_metadata("labels", vec!["auto-implement".to_string()]); + issue.set_metadata("state_name", "Backlog"); + issue.set_metadata("state_type", "backlog"); + + let result = source.matches_criteria(&issue); + assert!(result.matches); + + // No matching labels + issue.set_metadata("labels", vec!["other-label".to_string()]); + let result = source.matches_criteria(&issue); + assert!(!result.matches); + } + + #[test] + fn test_matches_criteria_states() { + let source = LinearSource::new(test_config()); + + let mut issue = Issue::new( + "123", + "PROJ-123", + "Fix bug", + "https://example.com", + "linear", + ); + issue.set_metadata("labels", vec!["claude".to_string()]); + issue.set_metadata("state_name", "Todo"); + issue.set_metadata("state_type", "todo"); + + let result = source.matches_criteria(&issue); + assert!(result.matches); + + // Non-matching state + issue.set_metadata("state_name", "In Progress"); + issue.set_metadata("state_type", "started"); + let result = source.matches_criteria(&issue); + assert!(!result.matches); + } + + #[test] + fn test_matches_criteria_priority_mapping() { + let source = LinearSource::new(test_config()); + + let mut issue = Issue::new( + "123", + "PROJ-123", + "Fix bug", + "https://example.com", + "linear", + ); + issue.set_metadata("labels", vec!["claude".to_string()]); + issue.set_metadata("state_type", "backlog"); + issue.priority = IssuePriority::Critical; + + let result = source.matches_criteria(&issue); + assert!(result.matches); + assert_eq!(result.priority, MatchPriority::Urgent); + + issue.priority = IssuePriority::High; + let result = source.matches_criteria(&issue); + assert_eq!(result.priority, MatchPriority::High); + + issue.priority = IssuePriority::Low; + let result = source.matches_criteria(&issue); + assert_eq!(result.priority, MatchPriority::Low); + } + + #[test] + fn test_source_name() { + let source = LinearSource::new(test_config()); + assert_eq!(source.name(), "linear"); + assert_eq!(source.display_name(), "Linear"); + } + + #[test] + fn test_matches_criteria_empty_trigger_labels() { + let config = LinearConfig { + enabled: true, + api_key: "test_key".to_string(), + trigger_labels: vec![], // Empty - matches all + trigger_states: vec!["backlog".to_string()], + team_id: None, + project_id: None, + webhook_secret: None, + }; + let source = LinearSource::new(config); + + let mut issue = Issue::new( + "123", + "PROJ-123", + "Fix bug", + "https://example.com", + "linear", + ); + issue.set_metadata("labels", vec!["any-label".to_string()]); + issue.set_metadata("state_type", "backlog"); + + let result = source.matches_criteria(&issue); + assert!(result.matches); + } + + #[test] + fn test_matches_criteria_empty_trigger_states() { + let config = LinearConfig { + enabled: true, + api_key: "test_key".to_string(), + trigger_labels: vec!["auto-implement".to_string()], + trigger_states: vec![], // Empty - matches all + team_id: None, + project_id: None, + webhook_secret: None, + }; + let source = LinearSource::new(config); + + let mut issue = Issue::new( + "123", + "PROJ-123", + "Fix bug", + "https://example.com", + "linear", + ); + issue.set_metadata("labels", vec!["auto-implement".to_string()]); + issue.set_metadata("state_type", "any-state"); + + let result = source.matches_criteria(&issue); + assert!(result.matches); + } + + #[test] + fn test_matches_criteria_case_insensitive_labels() { + let source = LinearSource::new(test_config()); + + let mut issue = Issue::new( + "123", + "PROJ-123", + "Fix bug", + "https://example.com", + "linear", + ); + issue.set_metadata("labels", vec!["AUTO-IMPLEMENT".to_string()]); // Uppercase + issue.set_metadata("state_type", "backlog"); + + let result = source.matches_criteria(&issue); + assert!(result.matches); + } + + #[test] + fn test_matches_criteria_case_insensitive_states() { + let source = LinearSource::new(test_config()); + + let mut issue = Issue::new( + "123", + "PROJ-123", + "Fix bug", + "https://example.com", + "linear", + ); + issue.set_metadata("labels", vec!["claude".to_string()]); + issue.set_metadata("state_name", "BACKLOG"); // Uppercase + issue.set_metadata("state_type", "BACKLOG"); + + let result = source.matches_criteria(&issue); + assert!(result.matches); + } + + #[test] + fn test_matches_criteria_priority_medium() { + let source = LinearSource::new(test_config()); + + let mut issue = Issue::new( + "123", + "PROJ-123", + "Fix bug", + "https://example.com", + "linear", + ); + issue.set_metadata("labels", vec!["claude".to_string()]); + issue.set_metadata("state_type", "backlog"); + issue.priority = IssuePriority::Medium; + + let result = source.matches_criteria(&issue); + assert!(result.matches); + assert_eq!(result.priority, MatchPriority::Normal); + } + + #[test] + fn test_matches_criteria_priority_none() { + let source = LinearSource::new(test_config()); + + let mut issue = Issue::new( + "123", + "PROJ-123", + "Fix bug", + "https://example.com", + "linear", + ); + issue.set_metadata("labels", vec!["claude".to_string()]); + issue.set_metadata("state_type", "backlog"); + issue.priority = IssuePriority::None; + + let result = source.matches_criteria(&issue); + assert!(result.matches); + assert_eq!(result.priority, MatchPriority::Normal); + } + + #[test] + fn test_matches_criteria_no_metadata() { + let source = LinearSource::new(test_config()); + + // Issue with no metadata set + let issue = Issue::new( + "123", + "PROJ-123", + "Fix bug", + "https://example.com", + "linear", + ); + + let result = source.matches_criteria(&issue); + // Should not match because no labels or state + assert!(!result.matches); + } + + #[test] + fn test_matches_criteria_partial_state_match() { + let source = LinearSource::new(test_config()); + + let mut issue = Issue::new( + "123", + "PROJ-123", + "Fix bug", + "https://example.com", + "linear", + ); + issue.set_metadata("labels", vec!["claude".to_string()]); + issue.set_metadata("state_name", "In Backlog"); // Contains "backlog" + issue.set_metadata("state_type", "unstarted"); + + let result = source.matches_criteria(&issue); + // Should match because state_name contains "backlog" + assert!(result.matches); + } + + #[test] + fn test_map_priority_all_values() { + assert_eq!( + LinearSource::::map_priority(0), + IssuePriority::None + ); + assert_eq!( + LinearSource::::map_priority(1), + IssuePriority::Critical + ); + assert_eq!( + LinearSource::::map_priority(2), + IssuePriority::High + ); + assert_eq!( + LinearSource::::map_priority(3), + IssuePriority::Medium + ); + assert_eq!( + LinearSource::::map_priority(4), + IssuePriority::Low + ); + assert_eq!( + LinearSource::::map_priority(5), + IssuePriority::None + ); // Out of range + assert_eq!( + LinearSource::::map_priority(-1), + IssuePriority::None + ); // Negative + assert_eq!( + LinearSource::::map_priority(100), + IssuePriority::None + ); + } + + #[test] + fn test_map_status_all_values() { + assert_eq!( + LinearSource::::map_status(Some("completed")), + IssueStatus::Resolved + ); + assert_eq!( + LinearSource::::map_status(Some("canceled")), + IssueStatus::Resolved + ); + assert_eq!( + LinearSource::::map_status(Some("started")), + IssueStatus::InProgress + ); + assert_eq!( + LinearSource::::map_status(Some("backlog")), + IssueStatus::Open + ); + assert_eq!( + LinearSource::::map_status(Some("triage")), + IssueStatus::Open + ); + assert_eq!( + LinearSource::::map_status(Some("unstarted")), + IssueStatus::Open + ); + assert_eq!( + LinearSource::::map_status(Some("")), + IssueStatus::Open + ); + assert_eq!( + LinearSource::::map_status(None), + IssueStatus::Open + ); + } + + #[test] + fn test_config_with_filters() { + let config = LinearConfig { + enabled: true, + api_key: "test_key".to_string(), + trigger_labels: vec!["urgent".to_string()], + trigger_states: vec!["todo".to_string()], + team_id: Some("team_123".to_string()), + project_id: Some("project_456".to_string()), + webhook_secret: Some("secret".to_string()), + }; + let source = LinearSource::new(config); + + // Verify source was created (API calls would require mocking) + assert_eq!(source.name(), "linear"); + } + + #[test] + fn test_matches_criteria_reason_message() { + let source = LinearSource::new(test_config()); + + let mut issue = Issue::new( + "123", + "PROJ-123", + "Fix bug", + "https://example.com", + "linear", + ); + issue.set_metadata("labels", vec!["claude".to_string()]); + issue.set_metadata("state_name", "In Progress"); + issue.set_metadata("state_type", "started"); // Not in trigger_states + + let result = source.matches_criteria(&issue); + assert!(!result.matches); + assert!(!result.reason.is_empty()); + assert!(result.reason.contains("In Progress")); + } + + #[test] + fn test_matches_criteria_no_matching_labels_message() { + let source = LinearSource::new(test_config()); + + let mut issue = Issue::new( + "123", + "PROJ-123", + "Fix bug", + "https://example.com", + "linear", + ); + issue.set_metadata("labels", vec!["unrelated".to_string()]); + issue.set_metadata("state_type", "backlog"); + + let result = source.matches_criteria(&issue); + assert!(!result.matches); + assert!(!result.reason.is_empty()); + assert!(result.reason.contains("label")); + } + + fn create_linear_issue( + id: &str, + identifier: &str, + title: &str, + priority: i32, + state_type: &str, + state_name: &str, + labels: Vec<&str>, + ) -> LinearIssue { + LinearIssue { + id: id.to_string(), + identifier: identifier.to_string(), + title: title.to_string(), + description: Some("Test description".to_string()), + url: format!("https://linear.app/team/issue/{}", identifier), + priority, + created_at: "2024-01-01T00:00:00Z".to_string(), + updated_at: "2024-01-02T00:00:00Z".to_string(), + state: Some(LinearState { + name: state_name.to_string(), + state_type: state_type.to_string(), + }), + labels: LabelsConnection { + nodes: labels + .iter() + .map(|l| LinearLabel { + name: l.to_string(), + }) + .collect(), + }, + team: Some(LinearTeam { + id: "team_123".to_string(), + name: "Engineering".to_string(), + }), + project: Some(LinearProject { + id: "proj_456".to_string(), + name: "Backend".to_string(), + }), + assignee: Some(LinearUser { + name: "John Doe".to_string(), + }), + } + } + + #[test] + fn test_map_issue_full() { + let source = LinearSource::new(test_config()); + let linear_issue = create_linear_issue( + "issue_123", + "PROJ-123", + "Fix authentication bug", + 1, // Critical + "started", + "In Progress", + vec!["bug", "auth"], + ); + + let issue = source.map_issue(linear_issue); + + assert_eq!(issue.id, "issue_123"); + assert_eq!(issue.short_id, "PROJ-123"); + assert_eq!(issue.title, "Fix authentication bug"); + assert_eq!(issue.source, "linear"); + assert_eq!(issue.priority, IssuePriority::Critical); + assert_eq!(issue.status, IssueStatus::InProgress); + assert_eq!(issue.description, Some("Test description".to_string())); + assert!(issue.created_at.is_some()); + assert!(issue.updated_at.is_some()); + + // Check metadata + let team: Option = issue.get_metadata("team"); + assert_eq!(team, Some("Engineering".to_string())); + + let project: Option = issue.get_metadata("project"); + assert_eq!(project, Some("Backend".to_string())); + + let assignee: Option = issue.get_metadata("assignee"); + assert_eq!(assignee, Some("John Doe".to_string())); + + let labels: Vec = issue.get_metadata("labels").unwrap_or_default(); + assert_eq!(labels.len(), 2); + assert!(labels.contains(&"bug".to_string())); + } + + #[test] + fn test_map_issue_minimal() { + let source = LinearSource::new(test_config()); + let linear_issue = LinearIssue { + id: "123".to_string(), + identifier: "TEST-1".to_string(), + title: "Simple issue".to_string(), + description: None, + url: "https://linear.app/test/TEST-1".to_string(), + priority: 0, + created_at: "invalid-date".to_string(), + updated_at: "invalid-date".to_string(), + state: None, + labels: LabelsConnection { nodes: vec![] }, + team: None, + project: None, + assignee: None, + }; + + let issue = source.map_issue(linear_issue); + + assert_eq!(issue.id, "123"); + assert_eq!(issue.short_id, "TEST-1"); + assert_eq!(issue.priority, IssuePriority::None); + assert_eq!(issue.status, IssueStatus::Open); + assert!(issue.description.is_none()); + assert!(issue.created_at.is_none()); // Invalid date + assert!(issue.updated_at.is_none()); + } + + #[test] + fn test_map_issue_completed_state() { + let source = LinearSource::new(test_config()); + let linear_issue = create_linear_issue( + "123", + "TEST-1", + "Done issue", + 3, + "completed", + "Done", + vec![], + ); + + let issue = source.map_issue(linear_issue); + assert_eq!(issue.status, IssueStatus::Resolved); + } + + #[test] + fn test_map_issue_canceled_state() { + let source = LinearSource::new(test_config()); + let mut linear_issue = create_linear_issue( + "123", + "TEST-1", + "Canceled issue", + 4, + "canceled", + "Canceled", + vec![], + ); + linear_issue.state = Some(LinearState { + name: "Canceled".to_string(), + state_type: "canceled".to_string(), + }); + + let issue = source.map_issue(linear_issue); + assert_eq!(issue.status, IssueStatus::Resolved); + } + + #[test] + fn test_map_issue_all_priorities() { + let source = LinearSource::new(test_config()); + + for (priority_num, expected) in [ + (0, IssuePriority::None), + (1, IssuePriority::Critical), + (2, IssuePriority::High), + (3, IssuePriority::Medium), + (4, IssuePriority::Low), + (5, IssuePriority::None), + ] { + let linear_issue = create_linear_issue( + "123", + "TEST-1", + "Test", + priority_num, + "backlog", + "Backlog", + vec![], + ); + let issue = source.map_issue(linear_issue); + assert_eq!( + issue.priority, expected, + "Priority {} should map to {:?}", + priority_num, expected + ); + } + } + + #[test] + fn test_graphql_request_serialization() { + let request = GraphQLRequest { + query: "query { test }", + variables: serde_json::json!({"id": "123"}), + }; + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("query")); + assert!(json.contains("variables")); + assert!(json.contains("123")); + } + + #[test] + fn test_graphql_error_debug() { + let error = GraphQLError { + message: "Test error".to_string(), + }; + let debug = format!("{:?}", error); + assert!(debug.contains("Test error")); + } + + #[test] + fn test_linear_state_deserialization() { + let json = r#"{"name": "In Progress", "type": "started"}"#; + let state: LinearState = serde_json::from_str(json).unwrap(); + assert_eq!(state.name, "In Progress"); + assert_eq!(state.state_type, "started"); + } + + #[test] + fn test_linear_label_deserialization() { + let json = r#"{"name": "bug"}"#; + let label: LinearLabel = serde_json::from_str(json).unwrap(); + assert_eq!(label.name, "bug"); + } + + #[test] + fn test_linear_team_deserialization() { + let json = r#"{"id": "team_123", "name": "Engineering"}"#; + let team: LinearTeam = serde_json::from_str(json).unwrap(); + assert_eq!(team.id, "team_123"); + assert_eq!(team.name, "Engineering"); + } + + #[test] + fn test_linear_project_deserialization() { + let json = r#"{"id": "proj_456", "name": "Backend"}"#; + let project: LinearProject = serde_json::from_str(json).unwrap(); + assert_eq!(project.id, "proj_456"); + assert_eq!(project.name, "Backend"); + } + + #[test] + fn test_linear_user_deserialization() { + let json = r#"{"name": "John Doe"}"#; + let user: LinearUser = serde_json::from_str(json).unwrap(); + assert_eq!(user.name, "John Doe"); + } + + #[test] + fn test_linear_issue_full_deserialization() { + let json = r#"{ + "id": "issue_123", + "identifier": "PROJ-123", + "title": "Test issue", + "description": "Description here", + "url": "https://linear.app/test", + "priority": 2, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-02T00:00:00Z", + "state": {"name": "Todo", "type": "unstarted"}, + "labels": {"nodes": [{"name": "bug"}, {"name": "p1"}]}, + "team": {"id": "t1", "name": "Team"}, + "project": {"id": "p1", "name": "Project"}, + "assignee": {"name": "Jane"} + }"#; + let issue: LinearIssue = serde_json::from_str(json).unwrap(); + assert_eq!(issue.id, "issue_123"); + assert_eq!(issue.identifier, "PROJ-123"); + assert_eq!(issue.priority, 2); + assert_eq!(issue.labels.nodes.len(), 2); + assert!(issue.team.is_some()); + assert!(issue.project.is_some()); + assert!(issue.assignee.is_some()); + } + + #[test] + fn test_issues_response_deserialization() { + let json = r#"{ + "issues": { + "nodes": [ + { + "id": "1", + "identifier": "T-1", + "title": "Issue 1", + "description": null, + "url": "https://linear.app/1", + "priority": 3, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + "state": null, + "labels": {"nodes": []}, + "team": null, + "project": null, + "assignee": null + } + ] + } + }"#; + let response: IssuesResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.issues.nodes.len(), 1); + } + + #[test] + fn test_issue_response_deserialization() { + let json = r#"{ + "issue": { + "id": "1", + "identifier": "T-1", + "title": "Issue 1", + "description": null, + "url": "https://linear.app/1", + "priority": 3, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + "state": null, + "labels": {"nodes": []}, + "team": null, + "project": null, + "assignee": null + } + }"#; + let response: IssueResponse = serde_json::from_str(json).unwrap(); + assert!(response.issue.is_some()); + } + + #[test] + fn test_issue_response_null_deserialization() { + let json = r#"{"issue": null}"#; + let response: IssueResponse = serde_json::from_str(json).unwrap(); + assert!(response.issue.is_none()); + } + + #[test] + fn test_graphql_response_with_errors() { + let json = r#"{ + "data": null, + "errors": [{"message": "Error 1"}, {"message": "Error 2"}] + }"#; + let response: GraphQLResponse = serde_json::from_str(json).unwrap(); + assert!(response.data.is_none()); + assert!(response.errors.is_some()); + assert_eq!(response.errors.as_ref().unwrap().len(), 2); + } + + #[test] + fn test_graphql_response_with_data() { + let json = r#"{"data": {"test": "value"}, "errors": null}"#; + let response: GraphQLResponse = serde_json::from_str(json).unwrap(); + assert!(response.data.is_some()); + assert!(response.errors.is_none()); + } + + #[test] + fn test_map_issue_with_valid_dates() { + let source = LinearSource::new(test_config()); + let linear_issue = LinearIssue { + id: "123".to_string(), + identifier: "TEST-1".to_string(), + title: "Test".to_string(), + description: None, + url: "https://linear.app/test".to_string(), + priority: 0, + created_at: "2024-06-15T10:30:00.000Z".to_string(), + updated_at: "2024-06-16T14:45:00.000Z".to_string(), + state: None, + labels: LabelsConnection { nodes: vec![] }, + team: None, + project: None, + assignee: None, + }; + + let issue = source.map_issue(linear_issue); + assert!(issue.created_at.is_some()); + assert!(issue.updated_at.is_some()); + } + + #[test] + fn test_queries_constant() { + // Verify queries are valid strings + assert!(ISSUES_QUERY.contains("query Issues")); + assert!(ISSUES_QUERY.contains("nodes")); + assert!(ISSUE_QUERY.contains("query Issue")); + assert!(ISSUE_QUERY.contains("$id")); + } + + #[tokio::test] + async fn test_build_issue_context_with_all_metadata() { + let source = LinearSource::new(test_config()); + + let mut issue = Issue::new( + "123", + "PROJ-456", + "Complex Bug", + "https://linear.app/123", + "linear", + ); + issue.description = Some("A very complex bug description\nwith multiple lines".to_string()); + issue.priority = IssuePriority::Critical; + issue.status = IssueStatus::InProgress; + issue.set_metadata("team", "Platform"); + issue.set_metadata("project", "Infrastructure"); + issue.set_metadata("assignee", "Alice Smith"); + + let context = source.build_issue_context(&issue).await.unwrap(); + + assert!(context.contains("# Linear Issue: PROJ-456")); + assert!(context.contains("**Title:** Complex Bug")); + assert!(context.contains("https://linear.app/123")); + assert!(context.contains("Priority")); // Check priority line exists + assert!(context.contains("A very complex bug description")); + assert!(context.contains("Platform")); + assert!(context.contains("Infrastructure")); + assert!(context.contains("Alice Smith")); + } +} diff --git a/src/source/mod.rs b/src/source/mod.rs new file mode 100644 index 00000000..715beedd --- /dev/null +++ b/src/source/mod.rs @@ -0,0 +1,296 @@ +//! Issue source implementations. + +mod linear; +mod sentry; + +pub use linear::LinearSource; +pub use sentry::SentrySource; + +use crate::error::Result; +use crate::types::{Issue, MatchResult}; +use async_trait::async_trait; +use std::collections::HashMap; +use std::sync::Arc; + +/// Trait for issue sources (Linear, Sentry, GitHub, Jira, etc.) +#[async_trait] +pub trait IssueSource: Send + Sync { + /// Unique name for this source. + fn name(&self) -> &str; + + /// Human-readable display name. + fn display_name(&self) -> &str; + + /// Fetch issues that should be considered for processing. + async fn fetch_issues(&self) -> Result>; + + /// Check if a specific issue matches the processing criteria. + fn matches_criteria(&self, issue: &Issue) -> MatchResult; + + /// Build context string for Claude about an issue. + async fn build_issue_context(&self, issue: &Issue) -> Result; + + /// Fetch a single issue by ID. + async fn get_issue(&self, issue_id: &str) -> Result; + + /// Resolve/close an issue on the remote source. + /// Called when a PR is merged and auto-resolve is enabled. + async fn resolve_issue(&self, issue_id: &str) -> Result<()> { + // Default implementation does nothing - not all sources support this + let _ = issue_id; + Ok(()) + } + + /// Add a comment to an issue on the remote source. + async fn add_comment(&self, issue_id: &str, comment: &str) -> Result<()> { + // Default implementation does nothing - not all sources support this + let _ = (issue_id, comment); + Ok(()) + } + + /// Get the current status of an issue from the remote source. + /// Returns the raw status string from the source (e.g., "completed", "resolved", "ignored"). + async fn get_issue_status(&self, issue_id: &str) -> Result { + // Default implementation fetches the full issue and returns its status + let issue = self.get_issue(issue_id).await?; + Ok(format!("{:?}", issue.status)) + } + + /// Check if a status string represents a terminal state for this source. + /// Terminal states are those where no further action is needed (e.g., completed, cancelled, resolved, ignored). + fn is_terminal_status(&self, status: &str) -> bool { + // Default implementation treats common terminal status names + let s = status.to_lowercase(); + s == "completed" + || s == "resolved" + || s == "cancelled" + || s == "canceled" + || s == "ignored" + || s == "closed" + || s == "done" + } +} + +/// Registry for available sources. +pub struct SourceRegistry { + sources: HashMap>, +} + +impl SourceRegistry { + /// Create a new empty registry. + pub fn new() -> Self { + Self { + sources: HashMap::new(), + } + } + + /// Register a source. + pub fn register(&mut self, source: Arc) { + self.sources.insert(source.name().to_string(), source); + } + + /// Get a source by name. + pub fn get(&self, name: &str) -> Option<&Arc> { + self.sources.get(name) + } + + /// Get all registered sources. + pub fn get_all(&self) -> Vec<&Arc> { + self.sources.values().collect() + } + + /// Check if a source is registered. + pub fn has(&self, name: &str) -> bool { + self.sources.contains_key(name) + } + + /// Get all source names. + pub fn names(&self) -> Vec<&str> { + self.sources.keys().map(|s| s.as_str()).collect() + } +} + +impl Default for SourceRegistry { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{MatchPriority, MatchResult}; + + struct MockSource { + name: String, + display: String, + } + + impl MockSource { + fn new(name: &str, display: &str) -> Self { + Self { + name: name.to_string(), + display: display.to_string(), + } + } + } + + #[async_trait] + impl IssueSource for MockSource { + fn name(&self) -> &str { + &self.name + } + fn display_name(&self) -> &str { + &self.display + } + async fn fetch_issues(&self) -> Result> { + Ok(vec![]) + } + fn matches_criteria(&self, _issue: &Issue) -> MatchResult { + MatchResult::matched("test", MatchPriority::Normal) + } + async fn build_issue_context(&self, _issue: &Issue) -> Result { + Ok(String::new()) + } + async fn get_issue(&self, _id: &str) -> Result { + Err(crate::error::Error::issue_not_found(&self.name, "test")) + } + } + + #[test] + fn test_source_registry_new() { + let registry = SourceRegistry::new(); + assert!(registry.sources.is_empty()); + assert!(registry.get_all().is_empty()); + } + + #[test] + fn test_source_registry_default() { + let registry = SourceRegistry::default(); + assert!(registry.sources.is_empty()); + } + + #[test] + fn test_source_registry_register() { + let mut registry = SourceRegistry::new(); + let source = Arc::new(MockSource::new("test", "Test")); + registry.register(source); + assert!(registry.has("test")); + assert!(!registry.has("other")); + } + + #[test] + fn test_source_registry_get() { + let mut registry = SourceRegistry::new(); + let source = Arc::new(MockSource::new("linear", "Linear")); + registry.register(source); + + let retrieved = registry.get("linear"); + assert!(retrieved.is_some()); + assert_eq!(retrieved.unwrap().name(), "linear"); + assert!(registry.get("sentry").is_none()); + } + + #[test] + fn test_source_registry_get_all() { + let mut registry = SourceRegistry::new(); + registry.register(Arc::new(MockSource::new("linear", "Linear"))); + registry.register(Arc::new(MockSource::new("sentry", "Sentry"))); + + let all = registry.get_all(); + assert_eq!(all.len(), 2); + } + + #[test] + fn test_source_registry_has() { + let mut registry = SourceRegistry::new(); + registry.register(Arc::new(MockSource::new("test", "Test"))); + + assert!(registry.has("test")); + assert!(!registry.has("nonexistent")); + } + + #[test] + fn test_source_registry_names() { + let mut registry = SourceRegistry::new(); + registry.register(Arc::new(MockSource::new("a", "A"))); + registry.register(Arc::new(MockSource::new("b", "B"))); + registry.register(Arc::new(MockSource::new("c", "C"))); + + let names = registry.names(); + assert_eq!(names.len(), 3); + assert!(names.contains(&"a")); + assert!(names.contains(&"b")); + assert!(names.contains(&"c")); + } + + #[test] + fn test_source_registry_overwrite() { + let mut registry = SourceRegistry::new(); + registry.register(Arc::new(MockSource::new("test", "Test1"))); + registry.register(Arc::new(MockSource::new("test", "Test2"))); + + // Should overwrite, HashMap behavior + assert_eq!(registry.get_all().len(), 1); + assert_eq!(registry.get("test").unwrap().display_name(), "Test2"); + } + + #[test] + fn test_source_registry_empty_names() { + let registry = SourceRegistry::new(); + assert!(registry.names().is_empty()); + } + + #[tokio::test] + async fn test_mock_source_fetch_issues() { + let source = MockSource::new("test", "Test"); + let issues = source.fetch_issues().await.unwrap(); + assert!(issues.is_empty()); + } + + #[tokio::test] + async fn test_mock_source_get_issue() { + let source = MockSource::new("test", "Test"); + let result = source.get_issue("123").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_mock_source_build_context() { + let source = MockSource::new("test", "Test"); + let issue = Issue::new("1", "T-1", "Test", "http://test.com", "test"); + let context = source.build_issue_context(&issue).await.unwrap(); + assert!(context.is_empty()); + } + + #[test] + fn test_mock_source_matches_criteria() { + let source = MockSource::new("test", "Test"); + let issue = Issue::new("1", "T-1", "Test", "http://test.com", "test"); + let result = source.matches_criteria(&issue); + assert!(result.matches); + } + + #[tokio::test] + async fn test_default_resolve_issue() { + let source = MockSource::new("test", "Test"); + // Default implementation should return Ok(()) + let result = source.resolve_issue("123").await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_default_add_comment() { + let source = MockSource::new("test", "Test"); + // Default implementation should return Ok(()) + let result = source.add_comment("123", "Test comment").await; + assert!(result.is_ok()); + } + + #[test] + fn test_source_name_display() { + let source = MockSource::new("sentry", "Sentry Errors"); + assert_eq!(source.name(), "sentry"); + assert_eq!(source.display_name(), "Sentry Errors"); + } +} diff --git a/src/source/sentry.rs b/src/source/sentry.rs new file mode 100644 index 00000000..f3cb2020 --- /dev/null +++ b/src/source/sentry.rs @@ -0,0 +1,2321 @@ +//! Sentry issue source adapter. + +use super::IssueSource; +use crate::config::SentryConfig; +use crate::error::{Error, Result}; +use crate::types::{Issue, IssuePriority, IssueStatus, MatchPriority, MatchResult}; +use async_trait::async_trait; +use serde::Deserialize; +use std::collections::HashSet; + +/// HTTP response abstraction for testability. +#[derive(Debug)] +pub struct HttpResponse { + pub status: u16, + pub body: String, +} + +impl HttpResponse { + /// Check if the status is successful (2xx). + pub fn is_success(&self) -> bool { + (200..300).contains(&self.status) + } + + /// Parse the body as JSON. + pub fn json(&self) -> Result { + serde_json::from_str(&self.body) + .map_err(|e| Error::Other(format!("JSON parse error: {}", e))) + } +} + +/// Trait for HTTP client operations to enable testing. +#[async_trait] +pub trait SentryHttpClient: Send + Sync { + /// Perform a GET request with bearer auth. + async fn get(&self, url: &str, auth_token: &str) -> Result; + + /// Perform a PUT request with bearer auth and JSON body. + async fn put( + &self, + url: &str, + auth_token: &str, + body: serde_json::Value, + ) -> Result; +} + +/// Default HTTP client using reqwest. +pub struct ReqwestSentryClient { + client: reqwest::Client, +} + +impl ReqwestSentryClient { + /// Create a new reqwest-based HTTP client. + pub fn new() -> Self { + Self { + client: reqwest::Client::new(), + } + } +} + +impl Default for ReqwestSentryClient { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl SentryHttpClient for ReqwestSentryClient { + async fn get(&self, url: &str, auth_token: &str) -> Result { + let response = self + .client + .get(url) + .bearer_auth(auth_token) + .header("Content-Type", "application/json") + .send() + .await?; + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + Ok(HttpResponse { status, body }) + } + + async fn put( + &self, + url: &str, + auth_token: &str, + body: serde_json::Value, + ) -> Result { + let response = self + .client + .put(url) + .header("Authorization", format!("Bearer {}", auth_token)) + .json(&body) + .send() + .await?; + let status = response.status().as_u16(); + let body_text = response.text().await.unwrap_or_default(); + Ok(HttpResponse { + status, + body: body_text, + }) + } +} + +/// Sentry REST API client. +pub struct SentrySource { + config: SentryConfig, + http: H, + escalating_issue_ids: std::sync::RwLock>, +} + +#[derive(Debug, Deserialize)] +struct SentryApiIssue { + id: String, + #[serde(rename = "shortId")] + short_id: String, + title: String, + culprit: Option, + permalink: String, + #[serde(rename = "firstSeen")] + first_seen: String, + #[serde(rename = "lastSeen")] + last_seen: String, + count: String, + #[serde(rename = "userCount")] + user_count: Option, + project: SentryProject, + status: String, + level: String, + #[serde(rename = "isUnhandled")] + is_unhandled: Option, + metadata: Option, + stats: Option, +} + +#[derive(Debug, Deserialize)] +struct SentryProject { + name: String, + slug: String, +} + +#[derive(Debug, Deserialize)] +struct SentryMetadata { + #[serde(rename = "type")] + error_type: Option, + value: Option, + filename: Option, + function: Option, +} + +#[derive(Debug, Deserialize)] +struct SentryStats { + #[serde(rename = "24h")] + last_24h: Option>, +} + +#[derive(Debug, Deserialize)] +struct SentryEvent { + tags: Option>, + entries: Option>, +} + +#[derive(Debug, Deserialize)] +struct SentryTag { + key: String, + value: String, +} + +#[derive(Debug, Deserialize)] +struct SentryEntry { + #[serde(rename = "type")] + entry_type: String, + data: serde_json::Value, +} + +impl SentrySource { + /// Create a new Sentry source with the default HTTP client. + pub fn new(config: SentryConfig) -> Self { + Self { + config, + http: ReqwestSentryClient::new(), + escalating_issue_ids: std::sync::RwLock::new(HashSet::new()), + } + } +} + +impl SentrySource { + /// Create a new Sentry source with a custom HTTP client. + pub fn with_http_client(config: SentryConfig, http: H) -> Self { + Self { + config, + http, + escalating_issue_ids: std::sync::RwLock::new(HashSet::new()), + } + } + + async fn fetch Deserialize<'de>>(&self, endpoint: &str) -> Result { + let url = format!("https://sentry.io/api/0{}", endpoint); + let response = self.http.get(&url, &self.config.auth_token).await?; + + if !response.is_success() { + return Err(Error::source( + "sentry", + format!("API error ({}): {}", response.status, response.body), + )); + } + + response.json() + } + + async fn fetch_top_issues(&self) -> Result> { + let mut query_parts = vec!["is:unresolved".to_string()]; + + if !self.config.project_slugs.is_empty() { + let project_query = self + .config + .project_slugs + .iter() + .map(|p| format!("project:{}", p)) + .collect::>() + .join(" OR "); + query_parts.push(project_query); + } + + let query = query_parts.join(" "); + let endpoint = format!( + "/organizations/{}/issues/?query={}&sort=freq&limit={}&statsPeriod={}", + self.config.org_slug, + urlencoding::encode(&query), + self.config.top_issues_count, + self.config.top_issues_period.to_stats_period() + ); + + self.fetch(&endpoint).await + } + + async fn fetch_escalating_issues(&self) -> Result> { + let mut query_parts = vec!["is:unresolved".to_string(), "is:escalating".to_string()]; + + if !self.config.project_slugs.is_empty() { + let project_query = self + .config + .project_slugs + .iter() + .map(|p| format!("project:{}", p)) + .collect::>() + .join(" OR "); + query_parts.push(project_query); + } + + let query = query_parts.join(" "); + let endpoint = format!( + "/organizations/{}/issues/?query={}&sort=date&limit=100", + self.config.org_slug, + urlencoding::encode(&query) + ); + + self.fetch(&endpoint).await + } + + async fn fetch_latest_event(&self, issue_id: &str) -> Result { + let endpoint = format!("/issues/{}/events/latest/", issue_id); + self.fetch(&endpoint).await + } + + fn map_issue(&self, api_issue: SentryApiIssue) -> Issue { + let event_count: i64 = api_issue.count.parse().unwrap_or(0); + let escalation_rate = self.calculate_escalation_rate(&api_issue); + let is_escalating = self + .escalating_issue_ids + .read() + .map(|ids| ids.contains(&api_issue.id)) + .unwrap_or(false); + + let mut issue = Issue::new( + &api_issue.id, + &api_issue.short_id, + &api_issue.title, + &api_issue.permalink, + "sentry", + ); + + issue.description = api_issue.metadata.as_ref().and_then(|m| m.value.clone()); + issue.priority = Self::map_priority(&api_issue.level, event_count); + issue.status = Self::map_status(&api_issue.status); + + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&api_issue.first_seen) { + issue.created_at = Some(dt.with_timezone(&chrono::Utc)); + } + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&api_issue.last_seen) { + issue.updated_at = Some(dt.with_timezone(&chrono::Utc)); + } + + // Store metadata + issue.set_metadata("culprit", api_issue.culprit.as_deref().unwrap_or("")); + issue.set_metadata("level", &api_issue.level); + issue.set_metadata("project", &api_issue.project.name); + issue.set_metadata("project_slug", &api_issue.project.slug); + issue.set_metadata("event_count", event_count); + issue.set_metadata("user_count", api_issue.user_count.unwrap_or(0)); + issue.set_metadata("is_unhandled", api_issue.is_unhandled.unwrap_or(false)); + issue.set_metadata("is_escalating", is_escalating); + + if let Some(ref metadata) = api_issue.metadata { + if let Some(ref t) = metadata.error_type { + issue.set_metadata("error_type", t); + } + if let Some(ref v) = metadata.value { + issue.set_metadata("error_value", v); + } + if let Some(ref f) = metadata.filename { + issue.set_metadata("filename", f); + } + if let Some(ref f) = metadata.function { + issue.set_metadata("function", f); + } + } + + if let Some(rate) = escalation_rate { + issue.set_metadata("escalation_rate", rate); + } + + issue + } + + fn map_priority(level: &str, event_count: i64) -> IssuePriority { + if level == "fatal" || (level == "error" && event_count > 1000) { + IssuePriority::Critical + } else if level == "error" { + IssuePriority::High + } else if level == "warning" { + IssuePriority::Medium + } else { + IssuePriority::Low + } + } + + fn map_status(status: &str) -> IssueStatus { + match status { + "resolved" => IssueStatus::Resolved, + "ignored" => IssueStatus::Ignored, + _ => IssueStatus::Open, + } + } + + /// Check if a Sentry status represents a terminal/resolved state. + /// Terminal states are those where the issue is considered "done" - no further action needed. + pub fn is_issue_resolved(status: &str) -> bool { + let s = status.to_lowercase(); + s == "resolved" || s == "ignored" + } + + fn calculate_escalation_rate(&self, issue: &SentryApiIssue) -> Option { + let stats = issue.stats.as_ref()?.last_24h.as_ref()?; + + if stats.len() < 4 { + return None; + } + + let midpoint = stats.len() / 2; + let first_half: i64 = stats[..midpoint].iter().map(|(_, count)| count).sum(); + let second_half: i64 = stats[midpoint..].iter().map(|(_, count)| count).sum(); + + if first_half == 0 { + return Some(if second_half > 0 { 100.0 } else { 0.0 }); + } + + Some(((second_half - first_half) as f64 / first_half as f64) * 100.0) + } +} + +#[async_trait] +impl IssueSource for SentrySource { + fn name(&self) -> &str { + "sentry" + } + + fn display_name(&self) -> &str { + "Sentry" + } + + async fn fetch_issues(&self) -> Result> { + // Fetch both escalating and top issues + let (escalating_result, top_result) = + tokio::join!(self.fetch_escalating_issues(), self.fetch_top_issues()); + + let escalating_issues = escalating_result.unwrap_or_else(|e| { + tracing::warn!(source = "sentry", error = %e, "Failed to fetch escalating issues"); + vec![] + }); + + let top_issues = top_result?; + + // Track escalating IDs + { + let mut ids = self.escalating_issue_ids.write().unwrap(); + ids.clear(); + for issue in &escalating_issues { + ids.insert(issue.id.clone()); + } + } + + // Combine and dedupe + let mut seen = HashSet::new(); + let mut all_issues = Vec::new(); + + for issue in escalating_issues.into_iter().chain(top_issues) { + if !seen.contains(&issue.id) { + seen.insert(issue.id.clone()); + all_issues.push(self.map_issue(issue)); + } + } + + Ok(all_issues) + } + + fn matches_criteria(&self, issue: &Issue) -> MatchResult { + let event_count: i64 = issue.get_metadata("event_count").unwrap_or(0); + let is_escalating: bool = issue.get_metadata("is_escalating").unwrap_or(false); + let escalation_rate: Option = issue.get_metadata("escalation_rate"); + + // Check minimum event count + if event_count < self.config.min_event_count as i64 { + return MatchResult::not_matched(format!( + "Event count {} below threshold {}", + event_count, self.config.min_event_count + )); + } + + // Check if resolved + if issue.status == IssueStatus::Resolved { + return MatchResult::not_matched("Issue is already resolved"); + } + + // Determine priority and reason + let (priority, reason) = if is_escalating { + ( + MatchPriority::Urgent, + "Issue is escalating (flagged by Sentry)".to_string(), + ) + } else if let Some(rate) = escalation_rate { + if rate >= self.config.escalation_threshold_percent as f64 { + ( + MatchPriority::Urgent, + format!("Issue is escalating ({:.1}% increase)", rate), + ) + } else if issue.priority == IssuePriority::Critical + || issue.priority == IssuePriority::High + { + ( + MatchPriority::High, + format!("Top issue by frequency ({} events)", event_count), + ) + } else { + ( + MatchPriority::Normal, + format!("Top issue by frequency ({} events)", event_count), + ) + } + } else if issue.priority == IssuePriority::Critical || issue.priority == IssuePriority::High + { + ( + MatchPriority::High, + format!("Top issue by frequency ({} events)", event_count), + ) + } else { + ( + MatchPriority::Normal, + format!("Top issue by frequency ({} events)", event_count), + ) + }; + + MatchResult::matched(reason, priority) + } + + async fn build_issue_context(&self, issue: &Issue) -> Result { + let mut context = format!("# Sentry Issue: {}\n\n", issue.short_id); + context.push_str(&format!("**Title:** {}\n", issue.title)); + context.push_str(&format!("**URL:** {}\n", issue.url)); + + if let Some(level) = issue.get_metadata::("level") { + context.push_str(&format!("**Level:** {}\n", level)); + } + + context.push_str(&format!("**Status:** {}\n", issue.status)); + + if let Some(event_count) = issue.get_metadata::("event_count") { + context.push_str(&format!("**Event Count:** {}\n", event_count)); + } + if let Some(user_count) = issue.get_metadata::("user_count") { + context.push_str(&format!("**User Count:** {}\n", user_count)); + } + if let Some(project) = issue.get_metadata::("project") { + context.push_str(&format!("**Project:** {}\n\n", project)); + } + + if let Some(culprit) = issue.get_metadata::("culprit") { + if !culprit.is_empty() { + context.push_str(&format!("**Culprit:** {}\n\n", culprit)); + } + } + + // Error details + let error_type: Option = issue.get_metadata("error_type"); + let error_value: Option = issue.get_metadata("error_value"); + let filename: Option = issue.get_metadata("filename"); + let function: Option = issue.get_metadata("function"); + + if error_type.is_some() || error_value.is_some() { + context.push_str("## Error Details\n"); + if let Some(ref t) = error_type { + context.push_str(&format!("- **Type:** {}\n", t)); + } + if let Some(ref v) = error_value { + context.push_str(&format!("- **Value:** {}\n", v)); + } + if let Some(ref f) = filename { + context.push_str(&format!("- **File:** {}\n", f)); + } + if let Some(ref f) = function { + context.push_str(&format!("- **Function:** {}\n", f)); + } + context.push('\n'); + } + + // Try to get stack trace from latest event + match self.fetch_latest_event(&issue.id).await { + Ok(event) => { + if let Some(entries) = event.entries { + if let Some(exception_entry) = + entries.iter().find(|e| e.entry_type == "exception") + { + if let Some(values) = exception_entry.data.get("values") { + if let Some(arr) = values.as_array() { + context.push_str("## Stack Trace\n```\n"); + for exc in arr { + if let (Some(exc_type), Some(exc_value)) = + (exc.get("type"), exc.get("value")) + { + context.push_str(&format!( + "{}: {}\n", + exc_type.as_str().unwrap_or(""), + exc_value.as_str().unwrap_or("") + )); + } + if let Some(stacktrace) = exc.get("stacktrace") { + if let Some(frames) = stacktrace.get("frames") { + if let Some(frames_arr) = frames.as_array() { + for frame in frames_arr.iter().rev().take(10) { + let func = frame + .get("function") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let file = frame + .get("filename") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + let line = frame + .get("lineNo") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + let col = frame + .get("colNo") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + context.push_str(&format!( + " at {} ({}:{}:{})\n", + func, file, line, col + )); + } + } + } + } + } + context.push_str("```\n\n"); + } + } + } + } + + if let Some(tags) = event.tags { + context.push_str("## Tags\n"); + for tag in tags.iter().take(20) { + context.push_str(&format!("- **{}:** {}\n", tag.key, tag.value)); + } + context.push('\n'); + } + } + Err(e) => { + tracing::warn!( + source = "sentry", + short_id = %issue.short_id, + error = %e, + "Failed to fetch event details" + ); + } + } + + Ok(context) + } + + async fn get_issue(&self, issue_id: &str) -> Result { + let endpoint = format!("/issues/{}/", issue_id); + let api_issue: SentryApiIssue = self.fetch(&endpoint).await?; + Ok(self.map_issue(api_issue)) + } + + async fn resolve_issue(&self, issue_id: &str) -> Result<()> { + let url = format!("https://sentry.io/api/0/issues/{}/", issue_id); + + let response = self + .http + .put( + &url, + &self.config.auth_token, + serde_json::json!({ + "status": "resolved" + }), + ) + .await?; + + if !response.is_success() { + return Err(Error::source( + "sentry", + format!("Failed to resolve issue: {}", response.body), + )); + } + + tracing::info!(source = "sentry", issue_id = %issue_id, "Resolved issue"); + Ok(()) + } + + async fn get_issue_status(&self, issue_id: &str) -> Result { + let issue = self.get_issue(issue_id).await?; + // Return the raw status string (e.g., "resolved", "ignored", "unresolved") + Ok(format!("{:?}", issue.status).to_lowercase()) + } + + fn is_terminal_status(&self, status: &str) -> bool { + Self::is_issue_resolved(status) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::TopIssuesPeriod; + use std::collections::HashMap; + use std::sync::Mutex; + + /// Mock HTTP client for testing. + pub struct MockSentryClient { + get_responses: Mutex>, + put_responses: Mutex>, + requests: Mutex>, // (method, url) + } + + impl MockSentryClient { + pub fn new() -> Self { + Self { + get_responses: Mutex::new(HashMap::new()), + put_responses: Mutex::new(HashMap::new()), + requests: Mutex::new(Vec::new()), + } + } + + pub fn mock_get(&self, url: impl Into, status: u16, body: impl Into) { + let mut responses = self.get_responses.lock().unwrap(); + responses.insert( + url.into(), + HttpResponse { + status, + body: body.into(), + }, + ); + } + + pub fn mock_put(&self, url: impl Into, status: u16, body: impl Into) { + let mut responses = self.put_responses.lock().unwrap(); + responses.insert( + url.into(), + HttpResponse { + status, + body: body.into(), + }, + ); + } + + #[allow(dead_code)] + pub fn get_requests(&self) -> Vec<(String, String)> { + self.requests.lock().unwrap().clone() + } + } + + #[async_trait] + impl SentryHttpClient for MockSentryClient { + async fn get(&self, url: &str, _auth_token: &str) -> Result { + self.requests + .lock() + .unwrap() + .push(("GET".to_string(), url.to_string())); + let responses = self.get_responses.lock().unwrap(); + if let Some(response) = responses.get(url) { + Ok(HttpResponse { + status: response.status, + body: response.body.clone(), + }) + } else { + Ok(HttpResponse { + status: 404, + body: "Not found".to_string(), + }) + } + } + + async fn put( + &self, + url: &str, + _auth_token: &str, + _body: serde_json::Value, + ) -> Result { + self.requests + .lock() + .unwrap() + .push(("PUT".to_string(), url.to_string())); + let responses = self.put_responses.lock().unwrap(); + if let Some(response) = responses.get(url) { + Ok(HttpResponse { + status: response.status, + body: response.body.clone(), + }) + } else { + Ok(HttpResponse { + status: 404, + body: "Not found".to_string(), + }) + } + } + } + + #[test] + fn test_http_response_is_success() { + let response = HttpResponse { + status: 200, + body: "{}".to_string(), + }; + assert!(response.is_success()); + let response = HttpResponse { + status: 404, + body: "{}".to_string(), + }; + assert!(!response.is_success()); + } + + #[test] + fn test_http_response_json() { + let response = HttpResponse { + status: 200, + body: r#"{"id": "123"}"#.to_string(), + }; + let parsed: serde_json::Value = response.json().unwrap(); + assert_eq!(parsed["id"], "123"); + } + + #[tokio::test] + async fn test_fetch_issues_success() { + let mock = MockSentryClient::new(); + mock.mock_get( + "https://sentry.io/api/0/organizations/test-org/issues/?query=is%3Aunresolved&sort=date&limit=100", + 200, + "[]", + ); + mock.mock_get( + "https://sentry.io/api/0/organizations/test-org/issues/?query=is%3Aunresolved&sort=freq&limit=100&statsPeriod=24h", + 200, + r#"[{ + "id": "123", + "shortId": "SENTRY-123", + "title": "Test Error", + "culprit": "app.js", + "permalink": "https://sentry.io/issue/123", + "firstSeen": "2024-01-01T00:00:00Z", + "lastSeen": "2024-01-02T00:00:00Z", + "count": "100", + "userCount": 10, + "project": {"id": "1", "name": "Test Project", "slug": "test"}, + "status": "unresolved", + "level": "error" + }]"#, + ); + + let config = test_config(); + let source = SentrySource::with_http_client(config, mock); + let issues = source.fetch_issues().await.unwrap(); + + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].short_id, "SENTRY-123"); + assert_eq!(issues[0].title, "Test Error"); + } + + #[tokio::test] + async fn test_fetch_issues_api_error() { + let mock = MockSentryClient::new(); + mock.mock_get( + "https://sentry.io/api/0/organizations/test-org/issues/?query=is%3Aunresolved&sort=date&limit=100", + 200, + "[]", + ); + mock.mock_get( + "https://sentry.io/api/0/organizations/test-org/issues/?query=is%3Aunresolved&sort=freq&limit=100&statsPeriod=24h", + 500, + "Internal Server Error", + ); + + let config = test_config(); + let source = SentrySource::with_http_client(config, mock); + let result = source.fetch_issues().await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_get_issue_success() { + let mock = MockSentryClient::new(); + mock.mock_get( + "https://sentry.io/api/0/issues/123/", + 200, + r#"{ + "id": "123", + "shortId": "SENTRY-123", + "title": "Test Error", + "permalink": "https://sentry.io/issue/123", + "firstSeen": "2024-01-01T00:00:00Z", + "lastSeen": "2024-01-02T00:00:00Z", + "count": "50", + "project": {"id": "1", "name": "Test", "slug": "test"}, + "status": "unresolved", + "level": "warning" + }"#, + ); + + let config = test_config(); + let source = SentrySource::with_http_client(config, mock); + let issue = source.get_issue("123").await.unwrap(); + + assert_eq!(issue.id, "123"); + assert_eq!(issue.short_id, "SENTRY-123"); + } + + #[tokio::test] + async fn test_get_issue_not_found() { + let mock = MockSentryClient::new(); + // No mock response means 404 + + let config = test_config(); + let source = SentrySource::with_http_client(config, mock); + let result = source.get_issue("nonexistent").await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_resolve_issue_success() { + let mock = MockSentryClient::new(); + mock.mock_put( + "https://sentry.io/api/0/issues/123/", + 200, + r#"{"status": "resolved"}"#, + ); + + let config = test_config(); + let source = SentrySource::with_http_client(config, mock); + let result = source.resolve_issue("123").await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_resolve_issue_failure() { + let mock = MockSentryClient::new(); + mock.mock_put("https://sentry.io/api/0/issues/123/", 403, "Forbidden"); + + let config = test_config(); + let source = SentrySource::with_http_client(config, mock); + let result = source.resolve_issue("123").await; + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Failed to resolve")); + } + + #[tokio::test] + async fn test_build_issue_context() { + let mock = MockSentryClient::new(); + mock.mock_get( + "https://sentry.io/api/0/issues/123/events/latest/", + 200, + r#"{ + "eventID": "event-1", + "title": "Test Error", + "dateCreated": "2024-01-01T00:00:00Z", + "tags": [{"key": "environment", "value": "production"}], + "entries": [{ + "type": "exception", + "data": { + "values": [{ + "type": "TypeError", + "value": "Cannot read property", + "stacktrace": { + "frames": [{ + "function": "main", + "filename": "app.js", + "lineNo": 42, + "colNo": 10 + }] + } + }] + } + }] + }"#, + ); + + let config = test_config(); + let source = SentrySource::with_http_client(config, mock); + + let mut issue = Issue::new( + "123", + "SENTRY-123", + "Test Error", + "https://sentry.io/issue/123", + "sentry", + ); + issue.set_metadata("project", "Test Project"); + issue.set_metadata("culprit", "app.js:main"); + issue.set_metadata("error_type", "TypeError"); + issue.set_metadata("error_value", "Cannot read property"); + + let context = source.build_issue_context(&issue).await.unwrap(); + + assert!(context.contains("Test Project")); + assert!(context.contains("TypeError")); + assert!(context.contains("Stack Trace")); + assert!(context.contains("environment")); + } + + #[tokio::test] + async fn test_build_issue_context_empty_culprit() { + let mock = MockSentryClient::new(); + mock.mock_get( + "https://sentry.io/api/0/issues/456/events/latest/", + 200, + r#"{ + "eventID": "event-2", + "title": "Warning", + "dateCreated": "2024-01-01T00:00:00Z" + }"#, + ); + + let config = test_config(); + let source = SentrySource::with_http_client(config, mock); + + let mut issue = Issue::new( + "456", + "SENTRY-456", + "Warning", + "https://sentry.io/issue/456", + "sentry", + ); + issue.set_metadata("culprit", ""); // Empty culprit + + let context = source.build_issue_context(&issue).await.unwrap(); + + // Should not contain "Culprit:" for empty culprit + assert!(!context.contains("**Culprit:**")); + } + + #[tokio::test] + async fn test_build_issue_context_minimal() { + let mock = MockSentryClient::new(); + mock.mock_get( + "https://sentry.io/api/0/issues/789/events/latest/", + 200, + r#"{ + "eventID": "event-3", + "title": "Basic", + "dateCreated": "2024-01-01T00:00:00Z" + }"#, + ); + + let config = test_config(); + let source = SentrySource::with_http_client(config, mock); + + let issue = Issue::new( + "789", + "SENTRY-789", + "Basic Issue", + "https://sentry.io/issue/789", + "sentry", + ); + + let context = source.build_issue_context(&issue).await.unwrap(); + + assert!(context.contains("Basic Issue")); + assert!(context.contains("https://sentry.io/issue/789")); + } + + #[tokio::test] + async fn test_build_issue_context_with_all_metadata() { + let mock = MockSentryClient::new(); + mock.mock_get( + "https://sentry.io/api/0/issues/999/events/latest/", + 200, + r#"{ + "eventID": "event-4", + "title": "Full Error", + "dateCreated": "2024-01-01T00:00:00Z", + "tags": [ + {"key": "browser", "value": "Chrome"}, + {"key": "os", "value": "Windows"} + ] + }"#, + ); + + let config = test_config(); + let source = SentrySource::with_http_client(config, mock); + + let mut issue = Issue::new( + "999", + "SENTRY-999", + "Full Error", + "https://sentry.io/issue/999", + "sentry", + ); + issue.set_metadata("project", "Full Project"); + issue.set_metadata("culprit", "handler.js:processRequest"); + issue.set_metadata("error_type", "ReferenceError"); + issue.set_metadata("error_value", "x is not defined"); + issue.set_metadata("filename", "handler.js"); + issue.set_metadata("function", "processRequest"); + + let context = source.build_issue_context(&issue).await.unwrap(); + + assert!(context.contains("Full Project")); + assert!(context.contains("ReferenceError")); + assert!(context.contains("x is not defined")); + assert!(context.contains("handler.js")); + assert!(context.contains("processRequest")); + assert!(context.contains("Chrome")); + assert!(context.contains("Windows")); + } + + #[tokio::test] + async fn test_fetch_escalating_issues() { + let mock = MockSentryClient::new(); + mock.mock_get( + "https://sentry.io/api/0/organizations/test-org/issues/?query=is%3Aunresolved%20is%3Aescalating&sort=date&limit=100", + 200, + r#"[{ + "id": "escalating-1", + "shortId": "ESC-1", + "title": "Escalating Error", + "permalink": "https://sentry.io/issue/escalating-1", + "firstSeen": "2024-01-01T00:00:00Z", + "lastSeen": "2024-01-02T00:00:00Z", + "count": "500", + "project": {"id": "1", "name": "Test", "slug": "test"}, + "status": "unresolved", + "level": "error" + }]"#, + ); + mock.mock_get( + "https://sentry.io/api/0/organizations/test-org/issues/?query=is%3Aunresolved&sort=freq&limit=100&statsPeriod=24h", + 200, + "[]", + ); + + let config = test_config(); + let source = SentrySource::with_http_client(config, mock); + let issues = source.fetch_issues().await.unwrap(); + + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].short_id, "ESC-1"); + + // Check that escalating flag is set + let is_escalating: bool = issues[0].get_metadata("is_escalating").unwrap_or(false); + assert!(is_escalating); + } + + #[tokio::test] + async fn test_fetch_deduplicates_issues() { + let mock = MockSentryClient::new(); + // Same issue appears in both escalating and top issues + mock.mock_get( + "https://sentry.io/api/0/organizations/test-org/issues/?query=is%3Aunresolved%20is%3Aescalating&sort=date&limit=100", + 200, + r#"[{ + "id": "dupe-1", + "shortId": "DUPE-1", + "title": "Duplicate Issue", + "permalink": "https://sentry.io/issue/dupe-1", + "firstSeen": "2024-01-01T00:00:00Z", + "lastSeen": "2024-01-02T00:00:00Z", + "count": "100", + "project": {"id": "1", "name": "Test", "slug": "test"}, + "status": "unresolved", + "level": "error" + }]"#, + ); + mock.mock_get( + "https://sentry.io/api/0/organizations/test-org/issues/?query=is%3Aunresolved&sort=freq&limit=100&statsPeriod=24h", + 200, + r#"[{ + "id": "dupe-1", + "shortId": "DUPE-1", + "title": "Duplicate Issue", + "permalink": "https://sentry.io/issue/dupe-1", + "firstSeen": "2024-01-01T00:00:00Z", + "lastSeen": "2024-01-02T00:00:00Z", + "count": "100", + "project": {"id": "1", "name": "Test", "slug": "test"}, + "status": "unresolved", + "level": "error" + }]"#, + ); + + let config = test_config(); + let source = SentrySource::with_http_client(config, mock); + let issues = source.fetch_issues().await.unwrap(); + + // Should only have 1 issue (deduplicated) + assert_eq!(issues.len(), 1); + } + + #[test] + fn test_calculate_escalation_rate() { + let source = SentrySource::new(test_config()); + + // Test with escalating data (second half has more events) + let issue_escalating = SentryApiIssue { + id: "1".to_string(), + short_id: "TEST-1".to_string(), + title: "Test".to_string(), + culprit: None, + permalink: "url".to_string(), + first_seen: "2024-01-01T00:00:00Z".to_string(), + last_seen: "2024-01-02T00:00:00Z".to_string(), + count: "100".to_string(), + user_count: None, + project: SentryProject { + name: "Test".to_string(), + slug: "test".to_string(), + }, + status: "unresolved".to_string(), + level: "error".to_string(), + is_unhandled: None, + metadata: None, + stats: Some(SentryStats { + last_24h: Some(vec![(0, 10), (1, 10), (2, 30), (3, 40)]), + }), + }; + + let rate = source.calculate_escalation_rate(&issue_escalating); + assert!(rate.is_some()); + assert!(rate.unwrap() > 0.0); + + // Test with no stats + let issue_no_stats = SentryApiIssue { + id: "2".to_string(), + short_id: "TEST-2".to_string(), + title: "Test".to_string(), + culprit: None, + permalink: "url".to_string(), + first_seen: "2024-01-01T00:00:00Z".to_string(), + last_seen: "2024-01-02T00:00:00Z".to_string(), + count: "100".to_string(), + user_count: None, + project: SentryProject { + name: "Test".to_string(), + slug: "test".to_string(), + }, + status: "unresolved".to_string(), + level: "error".to_string(), + is_unhandled: None, + metadata: None, + stats: None, + }; + + let rate = source.calculate_escalation_rate(&issue_no_stats); + assert!(rate.is_none()); + + // Test with insufficient stats + let issue_few_stats = SentryApiIssue { + id: "3".to_string(), + short_id: "TEST-3".to_string(), + title: "Test".to_string(), + culprit: None, + permalink: "url".to_string(), + first_seen: "2024-01-01T00:00:00Z".to_string(), + last_seen: "2024-01-02T00:00:00Z".to_string(), + count: "100".to_string(), + user_count: None, + project: SentryProject { + name: "Test".to_string(), + slug: "test".to_string(), + }, + status: "unresolved".to_string(), + level: "error".to_string(), + is_unhandled: None, + metadata: None, + stats: Some(SentryStats { + last_24h: Some(vec![(0, 10)]), + }), + }; + + let rate = source.calculate_escalation_rate(&issue_few_stats); + assert!(rate.is_none()); + } + + #[test] + fn test_calculate_escalation_rate_zero_first_half() { + let source = SentrySource::new(test_config()); + + let issue = SentryApiIssue { + id: "1".to_string(), + short_id: "TEST-1".to_string(), + title: "Test".to_string(), + culprit: None, + permalink: "url".to_string(), + first_seen: "2024-01-01T00:00:00Z".to_string(), + last_seen: "2024-01-02T00:00:00Z".to_string(), + count: "100".to_string(), + user_count: None, + project: SentryProject { + name: "Test".to_string(), + slug: "test".to_string(), + }, + status: "unresolved".to_string(), + level: "error".to_string(), + is_unhandled: None, + metadata: None, + stats: Some(SentryStats { + last_24h: Some(vec![(0, 0), (1, 0), (2, 10), (3, 20)]), + }), + }; + + let rate = source.calculate_escalation_rate(&issue); + assert!(rate.is_some()); + assert_eq!(rate.unwrap(), 100.0); // When first half is 0, should return 100% + } + + fn test_config() -> SentryConfig { + SentryConfig { + enabled: true, + auth_token: "test_token".to_string(), + org_slug: "test-org".to_string(), + project_slugs: vec![], + top_issues_count: 100, + top_issues_period: TopIssuesPeriod::OneDay, + min_event_count: 10, + escalation_threshold_percent: 50, + client_secret: None, + } + } + + #[test] + fn test_map_priority() { + assert_eq!( + SentrySource::::map_priority("fatal", 0), + IssuePriority::Critical + ); + assert_eq!( + SentrySource::::map_priority("error", 1001), + IssuePriority::Critical + ); + assert_eq!( + SentrySource::::map_priority("error", 100), + IssuePriority::High + ); + assert_eq!( + SentrySource::::map_priority("warning", 100), + IssuePriority::Medium + ); + assert_eq!( + SentrySource::::map_priority("info", 100), + IssuePriority::Low + ); + } + + #[test] + fn test_map_status() { + assert_eq!( + SentrySource::::map_status("resolved"), + IssueStatus::Resolved + ); + assert_eq!( + SentrySource::::map_status("ignored"), + IssueStatus::Ignored + ); + assert_eq!( + SentrySource::::map_status("unresolved"), + IssueStatus::Open + ); + } + + #[test] + fn test_matches_criteria_min_event_count() { + let source = SentrySource::new(test_config()); + + let mut issue = Issue::new( + "123", + "SENTRY-123", + "TypeError", + "https://sentry.io/issue/123", + "sentry", + ); + issue.set_metadata("event_count", 5i64); + + let result = source.matches_criteria(&issue); + assert!(!result.matches); + assert!(result.reason.contains("below threshold")); + } + + #[test] + fn test_matches_criteria_resolved() { + let source = SentrySource::new(test_config()); + + let mut issue = Issue::new( + "123", + "SENTRY-123", + "TypeError", + "https://sentry.io/issue/123", + "sentry", + ); + issue.set_metadata("event_count", 100i64); + issue.status = IssueStatus::Resolved; + + let result = source.matches_criteria(&issue); + assert!(!result.matches); + assert!(result.reason.contains("resolved")); + } + + #[test] + fn test_matches_criteria_escalating() { + let source = SentrySource::new(test_config()); + + let mut issue = Issue::new( + "123", + "SENTRY-123", + "TypeError", + "https://sentry.io/issue/123", + "sentry", + ); + issue.set_metadata("event_count", 100i64); + issue.set_metadata("is_escalating", true); + + let result = source.matches_criteria(&issue); + assert!(result.matches); + assert_eq!(result.priority, MatchPriority::Urgent); + assert!(result.reason.contains("escalating")); + } + + #[test] + fn test_matches_criteria_high_escalation_rate() { + let source = SentrySource::new(test_config()); + + let mut issue = Issue::new( + "123", + "SENTRY-123", + "TypeError", + "https://sentry.io/issue/123", + "sentry", + ); + issue.set_metadata("event_count", 100i64); + issue.set_metadata("escalation_rate", 75.0); + + let result = source.matches_criteria(&issue); + assert!(result.matches); + assert_eq!(result.priority, MatchPriority::Urgent); + } + + #[test] + fn test_source_name() { + let source = SentrySource::new(test_config()); + assert_eq!(source.name(), "sentry"); + assert_eq!(source.display_name(), "Sentry"); + } + + #[test] + fn test_matches_criteria_normal_priority() { + let source = SentrySource::new(test_config()); + + let mut issue = Issue::new( + "123", + "SENTRY-123", + "Warning message", + "https://sentry.io/issue/123", + "sentry", + ); + issue.set_metadata("event_count", 50i64); + issue.priority = IssuePriority::Medium; + + let result = source.matches_criteria(&issue); + assert!(result.matches); + assert_eq!(result.priority, MatchPriority::Normal); + } + + #[test] + fn test_matches_criteria_high_priority_issue() { + let source = SentrySource::new(test_config()); + + let mut issue = Issue::new( + "123", + "SENTRY-123", + "Error message", + "https://sentry.io/issue/123", + "sentry", + ); + issue.set_metadata("event_count", 500i64); + issue.priority = IssuePriority::High; + + let result = source.matches_criteria(&issue); + assert!(result.matches); + assert_eq!(result.priority, MatchPriority::High); + } + + #[test] + fn test_matches_criteria_critical_priority() { + let source = SentrySource::new(test_config()); + + let mut issue = Issue::new( + "123", + "SENTRY-123", + "Fatal error", + "https://sentry.io/issue/123", + "sentry", + ); + issue.set_metadata("event_count", 1000i64); + issue.priority = IssuePriority::Critical; + + let result = source.matches_criteria(&issue); + assert!(result.matches); + assert_eq!(result.priority, MatchPriority::High); + } + + #[test] + fn test_matches_criteria_low_escalation_rate() { + let source = SentrySource::new(test_config()); + + let mut issue = Issue::new( + "123", + "SENTRY-123", + "TypeError", + "https://sentry.io/issue/123", + "sentry", + ); + issue.set_metadata("event_count", 100i64); + issue.set_metadata("escalation_rate", 25.0); // Below threshold of 50 + + let result = source.matches_criteria(&issue); + assert!(result.matches); + // Should not be Urgent because rate is below threshold + assert_ne!(result.priority, MatchPriority::Urgent); + } + + #[test] + fn test_map_priority_all_levels() { + assert_eq!( + SentrySource::::map_priority("fatal", 0), + IssuePriority::Critical + ); + assert_eq!( + SentrySource::::map_priority("fatal", 1), + IssuePriority::Critical + ); + assert_eq!( + SentrySource::::map_priority("error", 1001), + IssuePriority::Critical + ); + assert_eq!( + SentrySource::::map_priority("error", 1000), + IssuePriority::High + ); + assert_eq!( + SentrySource::::map_priority("error", 999), + IssuePriority::High + ); + assert_eq!( + SentrySource::::map_priority("error", 1), + IssuePriority::High + ); + assert_eq!( + SentrySource::::map_priority("warning", 0), + IssuePriority::Medium + ); + assert_eq!( + SentrySource::::map_priority("warning", 10000), + IssuePriority::Medium + ); + assert_eq!( + SentrySource::::map_priority("info", 0), + IssuePriority::Low + ); + assert_eq!( + SentrySource::::map_priority("debug", 0), + IssuePriority::Low + ); + assert_eq!( + SentrySource::::map_priority("unknown", 0), + IssuePriority::Low + ); + } + + #[test] + fn test_map_status_all_values() { + assert_eq!( + SentrySource::::map_status("resolved"), + IssueStatus::Resolved + ); + assert_eq!( + SentrySource::::map_status("ignored"), + IssueStatus::Ignored + ); + assert_eq!( + SentrySource::::map_status("unresolved"), + IssueStatus::Open + ); + assert_eq!( + SentrySource::::map_status("reprocessing"), + IssueStatus::Open + ); + assert_eq!( + SentrySource::::map_status(""), + IssueStatus::Open + ); + } + + #[test] + fn test_config_with_project_filters() { + let config = SentryConfig { + enabled: true, + auth_token: "test_token".to_string(), + org_slug: "test-org".to_string(), + project_slugs: vec!["frontend".to_string(), "backend".to_string()], + top_issues_count: 50, + top_issues_period: TopIssuesPeriod::OneDay, + min_event_count: 5, + escalation_threshold_percent: 25, + client_secret: Some("secret".to_string()), + }; + let source = SentrySource::new(config); + assert_eq!(source.name(), "sentry"); + } + + #[test] + fn test_matches_criteria_threshold_boundary() { + let config = SentryConfig { + enabled: true, + auth_token: "test_token".to_string(), + org_slug: "test-org".to_string(), + project_slugs: vec![], + top_issues_count: 100, + top_issues_period: TopIssuesPeriod::OneDay, + min_event_count: 10, + escalation_threshold_percent: 50, + client_secret: None, + }; + let source = SentrySource::new(config); + + let mut issue = Issue::new( + "123", + "SENTRY-123", + "Error", + "https://sentry.io/123", + "sentry", + ); + + // Exactly at threshold + issue.set_metadata("event_count", 10i64); + let result = source.matches_criteria(&issue); + assert!(result.matches); + + // One below threshold + issue.set_metadata("event_count", 9i64); + let result = source.matches_criteria(&issue); + assert!(!result.matches); + } + + #[test] + fn test_matches_criteria_exact_escalation_threshold() { + let config = SentryConfig { + enabled: true, + auth_token: "test_token".to_string(), + org_slug: "test-org".to_string(), + project_slugs: vec![], + top_issues_count: 100, + top_issues_period: TopIssuesPeriod::OneDay, + min_event_count: 10, + escalation_threshold_percent: 50, + client_secret: None, + }; + let source = SentrySource::new(config); + + let mut issue = Issue::new( + "123", + "SENTRY-123", + "Error", + "https://sentry.io/123", + "sentry", + ); + issue.set_metadata("event_count", 100i64); + + // Exactly at escalation threshold + issue.set_metadata("escalation_rate", 50.0); + let result = source.matches_criteria(&issue); + assert!(result.matches); + assert_eq!(result.priority, MatchPriority::Urgent); + + // Just below escalation threshold + issue.set_metadata("escalation_rate", 49.9); + let result = source.matches_criteria(&issue); + assert!(result.matches); + assert_ne!(result.priority, MatchPriority::Urgent); + } + + #[test] + fn test_matches_criteria_no_metadata() { + let source = SentrySource::new(test_config()); + + let issue = Issue::new( + "123", + "SENTRY-123", + "Error", + "https://sentry.io/123", + "sentry", + ); + + let result = source.matches_criteria(&issue); + // Should not match because event_count defaults to 0, which is below threshold + assert!(!result.matches); + } + + #[test] + fn test_matches_criteria_ignored_status() { + let source = SentrySource::new(test_config()); + + let mut issue = Issue::new( + "123", + "SENTRY-123", + "Error", + "https://sentry.io/123", + "sentry", + ); + issue.set_metadata("event_count", 100i64); + issue.status = IssueStatus::Ignored; + + // Ignored status is not resolved, so should match + let result = source.matches_criteria(&issue); + assert!(result.matches); + } + + #[test] + fn test_calculate_escalation_rate_increasing() { + let source = SentrySource::new(test_config()); + + // Stats showing increase over time (first half: 10, second half: 20 = 100% increase) + let stats = SentryStats { + last_24h: Some(vec![ + (0, 2), + (1, 2), + (2, 3), + (3, 3), // First half: 10 + (4, 5), + (5, 5), + (6, 5), + (7, 5), // Second half: 20 + ]), + }; + + let issue = SentryApiIssue { + id: "123".to_string(), + short_id: "SENTRY-123".to_string(), + title: "Error".to_string(), + culprit: None, + permalink: "https://sentry.io/123".to_string(), + first_seen: "2024-01-01T00:00:00Z".to_string(), + last_seen: "2024-01-01T12:00:00Z".to_string(), + count: "30".to_string(), + user_count: None, + project: SentryProject { + name: "Test".to_string(), + slug: "test".to_string(), + }, + status: "unresolved".to_string(), + level: "error".to_string(), + is_unhandled: None, + metadata: None, + stats: Some(stats), + }; + + let rate = source.calculate_escalation_rate(&issue).unwrap(); + assert!((rate - 100.0).abs() < 0.1); + } + + #[test] + fn test_calculate_escalation_rate_decreasing() { + let source = SentrySource::new(test_config()); + + // Stats showing decrease (first half: 20, second half: 10 = -50%) + let stats = SentryStats { + last_24h: Some(vec![ + (0, 5), + (1, 5), + (2, 5), + (3, 5), // First half: 20 + (4, 2), + (5, 3), + (6, 2), + (7, 3), // Second half: 10 + ]), + }; + + let issue = SentryApiIssue { + id: "123".to_string(), + short_id: "SENTRY-123".to_string(), + title: "Error".to_string(), + culprit: None, + permalink: "https://sentry.io/123".to_string(), + first_seen: "2024-01-01T00:00:00Z".to_string(), + last_seen: "2024-01-01T12:00:00Z".to_string(), + count: "30".to_string(), + user_count: None, + project: SentryProject { + name: "Test".to_string(), + slug: "test".to_string(), + }, + status: "unresolved".to_string(), + level: "error".to_string(), + is_unhandled: None, + metadata: None, + stats: Some(stats), + }; + + let rate = source.calculate_escalation_rate(&issue).unwrap(); + assert!((rate - (-50.0)).abs() < 0.1); + } + + #[test] + fn test_calculate_escalation_rate_first_half_zero() { + let source = SentrySource::new(test_config()); + + // First half is zero (issue just started) + let stats = SentryStats { + last_24h: Some(vec![ + (0, 0), + (1, 0), + (2, 0), + (3, 0), // First half: 0 + (4, 5), + (5, 5), + (6, 5), + (7, 5), // Second half: 20 + ]), + }; + + let issue = SentryApiIssue { + id: "123".to_string(), + short_id: "SENTRY-123".to_string(), + title: "Error".to_string(), + culprit: None, + permalink: "https://sentry.io/123".to_string(), + first_seen: "2024-01-01T00:00:00Z".to_string(), + last_seen: "2024-01-01T12:00:00Z".to_string(), + count: "20".to_string(), + user_count: None, + project: SentryProject { + name: "Test".to_string(), + slug: "test".to_string(), + }, + status: "unresolved".to_string(), + level: "error".to_string(), + is_unhandled: None, + metadata: None, + stats: Some(stats), + }; + + let rate = source.calculate_escalation_rate(&issue).unwrap(); + assert_eq!(rate, 100.0); // New issue = 100% + } + + #[test] + fn test_calculate_escalation_rate_no_stats() { + let source = SentrySource::new(test_config()); + + let issue = SentryApiIssue { + id: "123".to_string(), + short_id: "SENTRY-123".to_string(), + title: "Error".to_string(), + culprit: None, + permalink: "https://sentry.io/123".to_string(), + first_seen: "2024-01-01T00:00:00Z".to_string(), + last_seen: "2024-01-01T12:00:00Z".to_string(), + count: "20".to_string(), + user_count: None, + project: SentryProject { + name: "Test".to_string(), + slug: "test".to_string(), + }, + status: "unresolved".to_string(), + level: "error".to_string(), + is_unhandled: None, + metadata: None, + stats: None, + }; + + let rate = source.calculate_escalation_rate(&issue); + assert!(rate.is_none()); + } + + #[test] + fn test_calculate_escalation_rate_insufficient_data() { + let source = SentrySource::new(test_config()); + + // Less than 4 data points + let stats = SentryStats { + last_24h: Some(vec![(0, 5), (1, 10), (2, 15)]), + }; + + let issue = SentryApiIssue { + id: "123".to_string(), + short_id: "SENTRY-123".to_string(), + title: "Error".to_string(), + culprit: None, + permalink: "https://sentry.io/123".to_string(), + first_seen: "2024-01-01T00:00:00Z".to_string(), + last_seen: "2024-01-01T12:00:00Z".to_string(), + count: "30".to_string(), + user_count: None, + project: SentryProject { + name: "Test".to_string(), + slug: "test".to_string(), + }, + status: "unresolved".to_string(), + level: "error".to_string(), + is_unhandled: None, + metadata: None, + stats: Some(stats), + }; + + let rate = source.calculate_escalation_rate(&issue); + assert!(rate.is_none()); + } + + fn create_sentry_api_issue( + id: &str, + short_id: &str, + title: &str, + level: &str, + status: &str, + count: &str, + ) -> SentryApiIssue { + SentryApiIssue { + id: id.to_string(), + short_id: short_id.to_string(), + title: title.to_string(), + culprit: Some("src/app.js".to_string()), + permalink: format!("https://sentry.io/issues/{}", id), + first_seen: "2024-01-01T00:00:00Z".to_string(), + last_seen: "2024-01-02T00:00:00Z".to_string(), + count: count.to_string(), + user_count: Some(50), + project: SentryProject { + name: "Frontend".to_string(), + slug: "frontend".to_string(), + }, + status: status.to_string(), + level: level.to_string(), + is_unhandled: Some(true), + metadata: Some(SentryMetadata { + error_type: Some("TypeError".to_string()), + value: Some("Cannot read property 'x' of undefined".to_string()), + filename: Some("src/components/App.js".to_string()), + function: Some("handleClick".to_string()), + }), + stats: None, + } + } + + #[test] + fn test_map_issue_full() { + let source = SentrySource::new(test_config()); + let api_issue = create_sentry_api_issue( + "123456", + "FRONTEND-ABC", + "TypeError: Cannot read property", + "error", + "unresolved", + "500", + ); + + let issue = source.map_issue(api_issue); + + assert_eq!(issue.id, "123456"); + assert_eq!(issue.short_id, "FRONTEND-ABC"); + assert_eq!(issue.title, "TypeError: Cannot read property"); + assert_eq!(issue.source, "sentry"); + assert_eq!(issue.priority, IssuePriority::High); + assert_eq!(issue.status, IssueStatus::Open); + assert!(issue.created_at.is_some()); + assert!(issue.updated_at.is_some()); + + // Check metadata + let culprit: Option = issue.get_metadata("culprit"); + assert_eq!(culprit, Some("src/app.js".to_string())); + + let project: Option = issue.get_metadata("project"); + assert_eq!(project, Some("Frontend".to_string())); + + let event_count: i64 = issue.get_metadata("event_count").unwrap_or(0); + assert_eq!(event_count, 500); + + let user_count: i64 = issue.get_metadata("user_count").unwrap_or(0); + assert_eq!(user_count, 50); + + let is_unhandled: bool = issue.get_metadata("is_unhandled").unwrap_or(false); + assert!(is_unhandled); + + let error_type: Option = issue.get_metadata("error_type"); + assert_eq!(error_type, Some("TypeError".to_string())); + } + + #[test] + fn test_map_issue_minimal() { + let source = SentrySource::new(test_config()); + let api_issue = SentryApiIssue { + id: "123".to_string(), + short_id: "TEST-1".to_string(), + title: "Simple error".to_string(), + culprit: None, + permalink: "https://sentry.io/123".to_string(), + first_seen: "invalid-date".to_string(), + last_seen: "invalid-date".to_string(), + count: "invalid".to_string(), // Invalid count + user_count: None, + project: SentryProject { + name: "Test".to_string(), + slug: "test".to_string(), + }, + status: "unresolved".to_string(), + level: "info".to_string(), + is_unhandled: None, + metadata: None, + stats: None, + }; + + let issue = source.map_issue(api_issue); + + assert_eq!(issue.id, "123"); + assert_eq!(issue.priority, IssuePriority::Low); + assert!(issue.created_at.is_none()); // Invalid date + assert!(issue.updated_at.is_none()); + + let event_count: i64 = issue.get_metadata("event_count").unwrap_or(-1); + assert_eq!(event_count, 0); // Invalid count parsed to 0 + } + + #[test] + fn test_map_issue_resolved_status() { + let source = SentrySource::new(test_config()); + let api_issue = + create_sentry_api_issue("123", "TEST-1", "Error", "error", "resolved", "100"); + + let issue = source.map_issue(api_issue); + assert_eq!(issue.status, IssueStatus::Resolved); + } + + #[test] + fn test_map_issue_ignored_status() { + let source = SentrySource::new(test_config()); + let api_issue = + create_sentry_api_issue("123", "TEST-1", "Error", "error", "ignored", "100"); + + let issue = source.map_issue(api_issue); + assert_eq!(issue.status, IssueStatus::Ignored); + } + + #[test] + fn test_map_issue_fatal_level() { + let source = SentrySource::new(test_config()); + let api_issue = + create_sentry_api_issue("123", "TEST-1", "Fatal error", "fatal", "unresolved", "1"); + + let issue = source.map_issue(api_issue); + assert_eq!(issue.priority, IssuePriority::Critical); + } + + #[test] + fn test_map_issue_high_count_error() { + let source = SentrySource::new(test_config()); + let api_issue = + create_sentry_api_issue("123", "TEST-1", "Error", "error", "unresolved", "5000"); + + let issue = source.map_issue(api_issue); + assert_eq!(issue.priority, IssuePriority::Critical); + } + + #[test] + fn test_map_issue_warning_level() { + let source = SentrySource::new(test_config()); + let api_issue = + create_sentry_api_issue("123", "TEST-1", "Warning", "warning", "unresolved", "100"); + + let issue = source.map_issue(api_issue); + assert_eq!(issue.priority, IssuePriority::Medium); + } + + #[test] + fn test_map_issue_escalating() { + let source = SentrySource::new(test_config()); + + // Mark as escalating + { + let mut ids = source.escalating_issue_ids.write().unwrap(); + ids.insert("escalating_123".to_string()); + } + + let api_issue = create_sentry_api_issue( + "escalating_123", + "TEST-1", + "Error", + "error", + "unresolved", + "100", + ); + + let issue = source.map_issue(api_issue); + let is_escalating: bool = issue.get_metadata("is_escalating").unwrap_or(false); + assert!(is_escalating); + } + + #[test] + fn test_map_issue_with_escalation_rate() { + let source = SentrySource::new(test_config()); + + let mut api_issue = + create_sentry_api_issue("123", "TEST-1", "Error", "error", "unresolved", "100"); + api_issue.stats = Some(SentryStats { + last_24h: Some(vec![ + (0, 10), + (1, 10), + (2, 10), + (3, 10), // First half: 40 + (4, 20), + (5, 20), + (6, 20), + (7, 20), // Second half: 80 + ]), + }); + + let issue = source.map_issue(api_issue); + let rate: Option = issue.get_metadata("escalation_rate"); + assert!(rate.is_some()); + assert!((rate.unwrap() - 100.0).abs() < 0.1); + } + + #[test] + fn test_sentry_project_deserialization() { + let json = r#"{"id": "123", "name": "My Project", "slug": "my-project"}"#; + let project: SentryProject = serde_json::from_str(json).unwrap(); + assert_eq!(project.name, "My Project"); + assert_eq!(project.slug, "my-project"); + } + + #[test] + fn test_sentry_metadata_deserialization() { + let json = r#"{ + "type": "TypeError", + "value": "Cannot read property", + "filename": "app.js", + "function": "onClick" + }"#; + let metadata: SentryMetadata = serde_json::from_str(json).unwrap(); + assert_eq!(metadata.error_type, Some("TypeError".to_string())); + assert_eq!(metadata.value, Some("Cannot read property".to_string())); + assert_eq!(metadata.filename, Some("app.js".to_string())); + assert_eq!(metadata.function, Some("onClick".to_string())); + } + + #[test] + fn test_sentry_metadata_partial_deserialization() { + let json = r#"{"type": "Error"}"#; + let metadata: SentryMetadata = serde_json::from_str(json).unwrap(); + assert_eq!(metadata.error_type, Some("Error".to_string())); + assert!(metadata.value.is_none()); + assert!(metadata.filename.is_none()); + assert!(metadata.function.is_none()); + } + + #[test] + fn test_sentry_stats_deserialization() { + let json = r#"{"24h": [[1704067200, 10], [1704070800, 20], [1704074400, 30]]}"#; + let stats: SentryStats = serde_json::from_str(json).unwrap(); + assert!(stats.last_24h.is_some()); + assert_eq!(stats.last_24h.as_ref().unwrap().len(), 3); + } + + #[test] + fn test_sentry_tag_deserialization() { + let json = r#"{"key": "browser", "value": "Chrome"}"#; + let tag: SentryTag = serde_json::from_str(json).unwrap(); + assert_eq!(tag.key, "browser"); + assert_eq!(tag.value, "Chrome"); + } + + #[test] + fn test_sentry_entry_deserialization() { + let json = r#"{"type": "exception", "data": {"values": []}}"#; + let entry: SentryEntry = serde_json::from_str(json).unwrap(); + assert_eq!(entry.entry_type, "exception"); + assert!(entry.data.is_object()); + } + + #[test] + fn test_sentry_event_deserialization() { + let json = r#"{ + "eventID": "abc123", + "title": "TypeError", + "message": "Error message", + "dateCreated": "2024-01-01T00:00:00Z", + "tags": [{"key": "browser", "value": "Chrome"}], + "entries": [] + }"#; + let event: SentryEvent = serde_json::from_str(json).unwrap(); + assert!(event.tags.is_some()); + assert!(event.entries.is_some()); + } + + #[test] + fn test_sentry_api_issue_full_deserialization() { + let json = r#"{ + "id": "123", + "shortId": "PROJ-ABC", + "title": "TypeError", + "culprit": "app.js", + "permalink": "https://sentry.io/123", + "firstSeen": "2024-01-01T00:00:00Z", + "lastSeen": "2024-01-02T00:00:00Z", + "count": "1000", + "userCount": 50, + "project": {"id": "1", "name": "Test", "slug": "test"}, + "status": "unresolved", + "level": "error", + "isUnhandled": true, + "metadata": {"type": "TypeError", "value": "error message"}, + "stats": {"24h": [[0, 10], [1, 20]]} + }"#; + let issue: SentryApiIssue = serde_json::from_str(json).unwrap(); + assert_eq!(issue.id, "123"); + assert_eq!(issue.short_id, "PROJ-ABC"); + assert_eq!(issue.count, "1000"); + assert_eq!(issue.user_count, Some(50)); + assert!(issue.is_unhandled.unwrap()); + assert!(issue.metadata.is_some()); + assert!(issue.stats.is_some()); + } + + #[test] + fn test_calculate_escalation_rate_both_halves_zero() { + let source = SentrySource::new(test_config()); + + let stats = SentryStats { + last_24h: Some(vec![ + (0, 0), + (1, 0), + (2, 0), + (3, 0), + (4, 0), + (5, 0), + (6, 0), + (7, 0), + ]), + }; + + let issue = SentryApiIssue { + id: "123".to_string(), + short_id: "TEST-1".to_string(), + title: "Error".to_string(), + culprit: None, + permalink: "https://sentry.io/123".to_string(), + first_seen: "2024-01-01T00:00:00Z".to_string(), + last_seen: "2024-01-01T12:00:00Z".to_string(), + count: "0".to_string(), + user_count: None, + project: SentryProject { + name: "Test".to_string(), + slug: "test".to_string(), + }, + status: "unresolved".to_string(), + level: "error".to_string(), + is_unhandled: None, + metadata: None, + stats: Some(stats), + }; + + let rate = source.calculate_escalation_rate(&issue).unwrap(); + assert_eq!(rate, 0.0); + } + + #[test] + fn test_calculate_escalation_rate_exactly_four_points() { + let source = SentrySource::new(test_config()); + + let stats = SentryStats { + last_24h: Some(vec![ + (0, 5), + (1, 5), // First half: 10 + (2, 10), + (3, 10), // Second half: 20 + ]), + }; + + let issue = SentryApiIssue { + id: "123".to_string(), + short_id: "TEST-1".to_string(), + title: "Error".to_string(), + culprit: None, + permalink: "https://sentry.io/123".to_string(), + first_seen: "2024-01-01T00:00:00Z".to_string(), + last_seen: "2024-01-01T12:00:00Z".to_string(), + count: "30".to_string(), + user_count: None, + project: SentryProject { + name: "Test".to_string(), + slug: "test".to_string(), + }, + status: "unresolved".to_string(), + level: "error".to_string(), + is_unhandled: None, + metadata: None, + stats: Some(stats), + }; + + let rate = source.calculate_escalation_rate(&issue).unwrap(); + assert!((rate - 100.0).abs() < 0.1); + } + + #[test] + fn test_map_issue_with_valid_dates() { + let source = SentrySource::new(test_config()); + let mut api_issue = + create_sentry_api_issue("123", "TEST-1", "Error", "error", "unresolved", "100"); + api_issue.first_seen = "2024-06-15T10:30:00.000Z".to_string(); + api_issue.last_seen = "2024-06-16T14:45:00.000Z".to_string(); + + let issue = source.map_issue(api_issue); + assert!(issue.created_at.is_some()); + assert!(issue.updated_at.is_some()); + } + + #[test] + fn test_map_issue_metadata_all_fields() { + let source = SentrySource::new(test_config()); + let api_issue = + create_sentry_api_issue("123", "TEST-1", "Error", "error", "unresolved", "100"); + + let issue = source.map_issue(api_issue); + + // Verify all metadata fields + assert!(issue.get_metadata::("culprit").is_some()); + assert!(issue.get_metadata::("level").is_some()); + assert!(issue.get_metadata::("project").is_some()); + assert!(issue.get_metadata::("project_slug").is_some()); + assert!(issue.get_metadata::("event_count").is_some()); + assert!(issue.get_metadata::("user_count").is_some()); + assert!(issue.get_metadata::("is_unhandled").is_some()); + assert!(issue.get_metadata::("is_escalating").is_some()); + assert!(issue.get_metadata::("error_type").is_some()); + assert!(issue.get_metadata::("error_value").is_some()); + assert!(issue.get_metadata::("filename").is_some()); + assert!(issue.get_metadata::("function").is_some()); + } + + #[test] + fn test_matches_criteria_with_escalation_rate_high_priority() { + let source = SentrySource::new(test_config()); + + let mut issue = Issue::new( + "123", + "SENTRY-1", + "Error", + "https://sentry.io/123", + "sentry", + ); + issue.set_metadata("event_count", 100i64); + issue.set_metadata("escalation_rate", 30.0); // Below threshold + issue.priority = IssuePriority::High; + + let result = source.matches_criteria(&issue); + assert!(result.matches); + assert_eq!(result.priority, MatchPriority::High); + } + + #[test] + fn test_matches_criteria_with_escalation_rate_critical_priority() { + let source = SentrySource::new(test_config()); + + let mut issue = Issue::new( + "123", + "SENTRY-1", + "Error", + "https://sentry.io/123", + "sentry", + ); + issue.set_metadata("event_count", 100i64); + issue.set_metadata("escalation_rate", 30.0); // Below threshold + issue.priority = IssuePriority::Critical; + + let result = source.matches_criteria(&issue); + assert!(result.matches); + assert_eq!(result.priority, MatchPriority::High); + } +} diff --git a/src/storage/analytics.rs b/src/storage/analytics.rs new file mode 100644 index 00000000..9848ac91 --- /dev/null +++ b/src/storage/analytics.rs @@ -0,0 +1,392 @@ +//! Analytics-specific queries and aggregation functions. +//! +//! This module provides higher-level analytics functionality built on top of +//! the core storage layer, including trend analysis, success rate calculations, +//! and time-series data retrieval. + +use crate::error::Result; +use crate::types::{AnalyticsSummary, ErrorPattern, ProcessingMetric}; +use chrono::{DateTime, Duration, Utc}; +use std::collections::HashMap; + +use super::SqliteTracker; + +/// Time period for trend analysis. +#[derive(Debug, Clone, Copy)] +pub enum TimePeriod { + /// Last hour + Hour, + /// Last 24 hours + Day, + /// Last 7 days + Week, + /// Last 30 days + Month, +} + +impl TimePeriod { + /// Get the duration for this time period. + pub fn duration(&self) -> Duration { + match self { + TimePeriod::Hour => Duration::hours(1), + TimePeriod::Day => Duration::days(1), + TimePeriod::Week => Duration::days(7), + TimePeriod::Month => Duration::days(30), + } + } + + /// Get the start time for this period from now. + pub fn start_time(&self) -> DateTime { + Utc::now() - self.duration() + } +} + +/// Trend direction. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum TrendDirection { + Up, + Down, + Stable, +} + +/// Trend analysis result. +#[derive(Debug, Clone)] +pub struct TrendAnalysis { + /// Current value + pub current: f64, + /// Previous value (for comparison period) + pub previous: f64, + /// Percentage change + pub change_percent: f64, + /// Trend direction + pub direction: TrendDirection, +} + +impl TrendAnalysis { + /// Create a new trend analysis. + pub fn new(current: f64, previous: f64) -> Self { + let change_percent = if previous > 0.0 { + ((current - previous) / previous) * 100.0 + } else if current > 0.0 { + 100.0 + } else { + 0.0 + }; + + let direction = if change_percent > 5.0 { + TrendDirection::Up + } else if change_percent < -5.0 { + TrendDirection::Down + } else { + TrendDirection::Stable + }; + + Self { + current, + previous, + change_percent, + direction, + } + } +} + +/// Analytics service for querying and analyzing operational data. +pub struct AnalyticsService<'a> { + tracker: &'a SqliteTracker, +} + +impl<'a> AnalyticsService<'a> { + /// Create a new analytics service. + pub fn new(tracker: &'a SqliteTracker) -> Self { + Self { tracker } + } + + /// Get the overall analytics summary. + pub fn get_summary(&self) -> Result { + self.tracker.get_analytics_summary() + } + + /// Get the success rate over a time period. + pub fn get_success_rate(&self) -> Result { + self.tracker.get_success_rate() + } + + /// Get recent metrics for a given metric name. + pub fn get_recent_metrics( + &self, + metric_name: &str, + period: TimePeriod, + ) -> Result> { + let since = Some(period.start_time()); + self.tracker.get_metrics(metric_name, since, 1000) + } + + /// Get the most common error patterns. + pub fn get_top_errors(&self, limit: usize) -> Result> { + self.tracker.get_error_patterns(limit) + } + + /// Calculate the average metric value over a time period. + pub fn average_metric(&self, metric_name: &str, period: TimePeriod) -> Result> { + let metrics = self.get_recent_metrics(metric_name, period)?; + if metrics.is_empty() { + return Ok(None); + } + + let sum: f64 = metrics.iter().map(|m| m.metric_value).sum(); + Ok(Some(sum / metrics.len() as f64)) + } + + /// Analyze the trend for a metric by comparing two time periods. + pub fn analyze_metric_trend( + &self, + metric_name: &str, + period: TimePeriod, + ) -> Result> { + let now = Utc::now(); + let period_duration = period.duration(); + + // Current period + let current_start = now - period_duration; + let current_metrics = self.tracker.get_metrics( + metric_name, + Some(current_start), + 1000, + )?; + + // Previous period + let previous_start = current_start - period_duration; + let previous_metrics = self.tracker.get_metrics( + metric_name, + Some(previous_start), + 1000, + )?; + + // Filter previous metrics to only include those before current period + let previous_metrics: Vec<_> = previous_metrics + .into_iter() + .filter(|m| m.timestamp < current_start) + .collect(); + + if current_metrics.is_empty() && previous_metrics.is_empty() { + return Ok(None); + } + + let current_avg = if current_metrics.is_empty() { + 0.0 + } else { + current_metrics.iter().map(|m| m.metric_value).sum::() + / current_metrics.len() as f64 + }; + + let previous_avg = if previous_metrics.is_empty() { + 0.0 + } else { + previous_metrics.iter().map(|m| m.metric_value).sum::() + / previous_metrics.len() as f64 + }; + + Ok(Some(TrendAnalysis::new(current_avg, previous_avg))) + } + + /// Get metrics aggregated by source. + pub fn metrics_by_source( + &self, + metric_name: &str, + period: TimePeriod, + ) -> Result> { + let metrics = self.get_recent_metrics(metric_name, period)?; + + let mut by_source: HashMap> = HashMap::new(); + for metric in metrics { + if let Some(source) = metric.source { + by_source.entry(source).or_default().push(metric.metric_value); + } + } + + let mut result = HashMap::new(); + for (source, values) in by_source { + if !values.is_empty() { + let avg = values.iter().sum::() / values.len() as f64; + result.insert(source, avg); + } + } + + Ok(result) + } + + /// Calculate throughput (issues processed per hour) over a time period. + pub fn calculate_throughput(&self, period: TimePeriod) -> Result { + let activities = self + .tracker + .get_recent_activities(10000, None)?; + + let start_time = period.start_time(); + let processing_count = activities + .iter() + .filter(|a| { + a.timestamp >= start_time + && (a.activity_type == "processing_completed" + || a.activity_type == "pr_created") + }) + .count(); + + let hours = period.duration().num_hours() as f64; + if hours > 0.0 { + Ok(processing_count as f64 / hours) + } else { + Ok(0.0) + } + } + + /// Get error rate (errors per total processing) over a time period. + pub fn calculate_error_rate(&self, period: TimePeriod) -> Result { + let activities = self.tracker.get_recent_activities(10000, None)?; + let start_time = period.start_time(); + + let total = activities + .iter() + .filter(|a| { + a.timestamp >= start_time + && (a.activity_type == "processing_completed" + || a.activity_type == "error" + || a.activity_type == "pr_created") + }) + .count(); + + let errors = activities + .iter() + .filter(|a| a.timestamp >= start_time && a.activity_type == "error") + .count(); + + if total > 0 { + Ok(errors as f64 / total as f64) + } else { + Ok(0.0) + } + } +} + +/// Helper function to compute a hash for error message normalization. +pub fn compute_error_hash(error_message: &str) -> String { + use sha2::{Digest, Sha256}; + + // Normalize the error message by: + // 1. Converting to lowercase + // 2. Removing numbers (often variable) + // 3. Removing extra whitespace + let normalized: String = error_message + .to_lowercase() + .chars() + .filter(|c| !c.is_numeric()) + .collect::() + .split_whitespace() + .collect::>() + .join(" "); + + let mut hasher = Sha256::new(); + hasher.update(normalized.as_bytes()); + hex::encode(&hasher.finalize()[..16]) // Use first 16 bytes for shorter hash +} + +/// Classify an error message into an error type. +pub fn classify_error(error_message: &str) -> &'static str { + let lower = error_message.to_lowercase(); + + if lower.contains("timeout") || lower.contains("timed out") { + "timeout" + } else if lower.contains("build") || lower.contains("compile") || lower.contains("cargo") { + "build_failure" + } else if lower.contains("test") || lower.contains("assertion") { + "test_failure" + } else if lower.contains("claude") || lower.contains("api") || lower.contains("rate limit") { + "claude_error" + } else if lower.contains("git") || lower.contains("merge") || lower.contains("conflict") { + "git_error" + } else if lower.contains("permission") || lower.contains("access denied") { + "permission_error" + } else if lower.contains("network") || lower.contains("connection") { + "network_error" + } else { + "unknown" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_time_period_duration() { + assert_eq!(TimePeriod::Hour.duration().num_hours(), 1); + assert_eq!(TimePeriod::Day.duration().num_days(), 1); + assert_eq!(TimePeriod::Week.duration().num_days(), 7); + assert_eq!(TimePeriod::Month.duration().num_days(), 30); + } + + #[test] + fn test_trend_analysis_up() { + let trend = TrendAnalysis::new(120.0, 100.0); + assert_eq!(trend.direction, TrendDirection::Up); + assert!((trend.change_percent - 20.0).abs() < 0.01); + } + + #[test] + fn test_trend_analysis_down() { + let trend = TrendAnalysis::new(80.0, 100.0); + assert_eq!(trend.direction, TrendDirection::Down); + assert!((trend.change_percent - (-20.0)).abs() < 0.01); + } + + #[test] + fn test_trend_analysis_stable() { + let trend = TrendAnalysis::new(101.0, 100.0); + assert_eq!(trend.direction, TrendDirection::Stable); + } + + #[test] + fn test_trend_analysis_from_zero() { + let trend = TrendAnalysis::new(100.0, 0.0); + assert_eq!(trend.direction, TrendDirection::Up); + assert_eq!(trend.change_percent, 100.0); + } + + #[test] + fn test_compute_error_hash() { + let hash1 = compute_error_hash("Error at line 42: undefined variable"); + let hash2 = compute_error_hash("Error at line 100: undefined variable"); + // Should produce the same hash (numbers removed) + assert_eq!(hash1, hash2); + + let hash3 = compute_error_hash("Different error message"); + assert_ne!(hash1, hash3); + } + + #[test] + fn test_classify_error() { + assert_eq!(classify_error("Process timed out after 60 seconds"), "timeout"); + assert_eq!(classify_error("Build failed: cargo build error"), "build_failure"); + assert_eq!(classify_error("Test assertion failed"), "test_failure"); + assert_eq!(classify_error("Claude API rate limit exceeded"), "claude_error"); + assert_eq!(classify_error("Git merge conflict"), "git_error"); + assert_eq!(classify_error("Permission denied"), "permission_error"); + assert_eq!(classify_error("Network connection refused"), "network_error"); + assert_eq!(classify_error("Some random error"), "unknown"); + } + + #[test] + fn test_analytics_service_creation() { + let tracker = SqliteTracker::in_memory().unwrap(); + let _service = AnalyticsService::new(&tracker); + } + + #[test] + fn test_analytics_summary() { + let tracker = SqliteTracker::in_memory().unwrap(); + let service = AnalyticsService::new(&tracker); + + let summary = service.get_summary().unwrap(); + assert_eq!(summary.total_processed, 0); + assert_eq!(summary.success_rate, 0.0); + } +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs new file mode 100644 index 00000000..d71e2bcd --- /dev/null +++ b/src/storage/mod.rs @@ -0,0 +1,114 @@ +//! Storage implementations for tracking fix attempts, embeddings, and analytics. + +mod sqlite; +pub mod analytics; +pub mod vectorlite; + +pub use sqlite::{ + ConfidenceBreakdown, IndexStats, InferenceStats, SqliteTracker, StoredDependency, + StoredIndexedRepo, StoredRepository, +}; +pub use vectorlite::{is_vectorlite_available, try_load_vectorlite, VectorStoreConfig}; +pub use analytics::{ + AnalyticsService, TimePeriod, TrendAnalysis, TrendDirection, + classify_error, compute_error_hash, +}; + +use crate::error::Result; +use crate::types::{ + ActivityLogEntry, AnalyticsSummary, ClaudeExecution, ErrorPattern, FixAttempt, + FixAttemptStats, FixAttemptStatus, PrReviewRecord, ProcessingMetric, +}; +use std::collections::HashSet; + +/// Trait for tracking fix attempts. +pub trait FixAttemptTracker: Send + Sync { + /// Check if an issue has already been attempted. + fn has_attempted(&self, source: &str, issue_id: &str) -> bool; + + /// Get all attempted issue IDs for a source. + fn get_attempted_issue_ids(&self, source: &str) -> HashSet; + + /// Record a new fix attempt (pending status). + fn record_attempt(&self, source: &str, issue_id: &str, short_id: &str) -> Result<()>; + + /// Update a fix attempt with success and PR URL. + fn mark_success(&self, source: &str, issue_id: &str, pr_url: &str) -> Result<()>; + + /// Update a fix attempt with failure. + fn mark_failed(&self, source: &str, issue_id: &str, error_message: &str) -> Result<()>; + + /// Mark a fix attempt as merged (PR was merged). + fn mark_merged(&self, source: &str, issue_id: &str) -> Result<()>; + + /// Mark a fix attempt as closed (PR was closed without merging). + fn mark_closed(&self, source: &str, issue_id: &str) -> Result<()>; + + /// Mark the issue as resolved on the remote source. + fn mark_resolved(&self, source: &str, issue_id: &str) -> Result<()>; + + /// Get a specific fix attempt. + fn get_attempt(&self, source: &str, issue_id: &str) -> Result>; + + /// Get all fix attempts with a specific status. + fn get_attempts_by_status(&self, status: FixAttemptStatus) -> Result>; + + /// Get all successful attempts that haven't been merged yet (PRs to monitor). + fn get_pending_prs(&self) -> Result>; + + /// Get a fix attempt by PR URL. + fn get_attempt_by_pr_url(&self, pr_url: &str) -> Result>; + + /// Reset a failed attempt to allow retry. + fn reset_attempt(&self, source: &str, issue_id: &str) -> Result<()>; + + /// Get statistics about fix attempts. + fn get_stats(&self) -> Result; + + /// Increment retry count and set last retry timestamp. + fn increment_retry(&self, source: &str, issue_id: &str) -> Result<()>; + + /// Mark an issue as cannot be fixed (max retries reached). + fn mark_cannot_fix(&self, source: &str, issue_id: &str, reason: &str) -> Result<()>; + + /// Get issues that are eligible for retry (failed/closed with retry_count < max_retries). + fn get_retryable_issues(&self, max_retries: u32) -> Result>; + + /// Prepare an issue for retry (reset status to pending, clear PR info). + fn prepare_for_retry(&self, source: &str, issue_id: &str) -> Result<()>; + + /// Record an activity log entry. + fn record_activity(&self, _entry: &ActivityLogEntry) -> Result { + Ok(0) // Default no-op + } + + /// Get recent activity entries. + fn get_recent_activities(&self, _limit: usize) -> Result> { + Ok(Vec::new()) // Default no-op + } + + /// Record a Claude execution. + fn record_execution(&self, _execution: &ClaudeExecution) -> Result { + Ok(0) // Default no-op + } + + /// Record a PR review. + fn record_pr_review(&self, _review: &PrReviewRecord) -> Result { + Ok(0) // Default no-op + } + + /// Record an error pattern. + fn record_error_pattern(&self, _pattern: &ErrorPattern) -> Result { + Ok(0) // Default no-op + } + + /// Record a processing metric. + fn record_metric(&self, _metric: &ProcessingMetric) -> Result { + Ok(0) // Default no-op + } + + /// Get analytics summary. + fn get_analytics_summary(&self) -> Result { + Ok(AnalyticsSummary::default()) + } +} diff --git a/src/storage/sqlite.rs b/src/storage/sqlite.rs new file mode 100644 index 00000000..85d5e41d --- /dev/null +++ b/src/storage/sqlite.rs @@ -0,0 +1,2924 @@ +//! SQLite-based fix attempt tracker and analytics storage. + +use super::FixAttemptTracker; +use crate::error::Result; +use crate::types::{ + ActivityLogEntry, AnalyticsSummary, ClaudeExecution, ErrorPattern, FixAttempt, FixAttemptStats, + FixAttemptStatus, IssueEmbedding, ProcessingMetric, PromptExperiment, PrReviewRecord, + SimilarIssue, SourceStats, +}; +use chrono::{DateTime, Utc}; +use rusqlite::{params, Connection}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::sync::Mutex; + +/// SQLite-based fix attempt tracker for persistence. +/// +/// # Async Safety +/// +/// This implementation uses `std::sync::Mutex` which is appropriate for: +/// - Short-duration locks (SQLite in-process operations are typically fast) +/// - Operations that don't hold the lock across `.await` points +/// +/// All methods are synchronous and complete quickly, making this safe to call +/// from async contexts without risking thread starvation. The mutex is never +/// held across await points since all trait methods are synchronous. +pub struct SqliteTracker { + conn: Mutex, +} + +impl SqliteTracker { + /// Create a new SQLite tracker with the given database path. + pub fn new(db_path: impl AsRef) -> Result { + let conn = Connection::open(db_path)?; + let tracker = Self { + conn: Mutex::new(conn), + }; + tracker.init()?; + Ok(tracker) + } + + /// Create an in-memory SQLite tracker (for testing). + pub fn in_memory() -> Result { + let conn = Connection::open_in_memory()?; + let tracker = Self { + conn: Mutex::new(conn), + }; + tracker.init()?; + Ok(tracker) + } + + /// Acquire a lock on the database connection, handling poisoned mutex gracefully. + fn acquire_lock(&self) -> Result> { + self.conn.lock().map_err(|e| { + crate::error::Error::Storage(format!("Failed to acquire database lock: {}", e)) + }) + } + + fn init(&self) -> Result<()> { + let conn = self.acquire_lock()?; + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS fix_attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + issue_id TEXT NOT NULL, + short_id TEXT NOT NULL, + attempted_at TEXT NOT NULL DEFAULT (datetime('now')), + pr_url TEXT, + github_repo TEXT, + github_pr_number INTEGER, + status TEXT NOT NULL DEFAULT 'pending', + error_message TEXT, + merged_at TEXT, + resolved_at TEXT, + retry_count INTEGER NOT NULL DEFAULT 0, + UNIQUE(source, issue_id) + ); + + CREATE INDEX IF NOT EXISTS idx_fix_attempts_status ON fix_attempts(status); + CREATE INDEX IF NOT EXISTS idx_fix_attempts_source_issue ON fix_attempts(source, issue_id); + CREATE INDEX IF NOT EXISTS idx_fix_attempts_pr_url ON fix_attempts(pr_url); + CREATE INDEX IF NOT EXISTS idx_fix_attempts_retryable ON fix_attempts(status, retry_count, attempted_at); + + -- Feedback outcomes table for learning from past fixes + CREATE TABLE IF NOT EXISTS feedback_outcomes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + attempt_id INTEGER REFERENCES fix_attempts(id), + source TEXT NOT NULL, + issue_id TEXT NOT NULL, + issue_text TEXT NOT NULL, + prompt_used TEXT NOT NULL, + outcome TEXT NOT NULL, + error_type TEXT, + learnings TEXT, + keywords TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_feedback_outcomes_source ON feedback_outcomes(source); + CREATE INDEX IF NOT EXISTS idx_feedback_outcomes_outcome ON feedback_outcomes(outcome); + CREATE INDEX IF NOT EXISTS idx_feedback_outcomes_attempt ON feedback_outcomes(attempt_id); + CREATE INDEX IF NOT EXISTS idx_feedback_source_issue ON feedback_outcomes(source, issue_id); + + -- Discord threads table + CREATE TABLE IF NOT EXISTS discord_threads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + thread_id TEXT NOT NULL UNIQUE, + thread_name TEXT NOT NULL, + channel_id TEXT NOT NULL, + pr_url TEXT NOT NULL, + issue_id TEXT NOT NULL, + source TEXT NOT NULL, + is_active INTEGER DEFAULT 1, + last_message_id TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_discord_threads_pr ON discord_threads(pr_url); + CREATE INDEX IF NOT EXISTS idx_discord_threads_active ON discord_threads(is_active); + + -- PR review states table + CREATE TABLE IF NOT EXISTS pr_review_states ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pr_url TEXT NOT NULL UNIQUE, + repo TEXT NOT NULL, + pr_number INTEGER NOT NULL, + issue_id TEXT NOT NULL, + source TEXT NOT NULL, + last_review_id INTEGER, + last_review_time TEXT, + last_comment_id INTEGER, + last_comment_time TEXT, + is_active INTEGER DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_pr_review_states_active ON pr_review_states(is_active); + + -- Repositories table + CREATE TABLE IF NOT EXISTS repositories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + path TEXT NOT NULL, + github_url TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- Repository dependencies table + CREATE TABLE IF NOT EXISTS repository_dependencies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + upstream_id INTEGER REFERENCES repositories(id), + downstream_id INTEGER REFERENCES repositories(id), + dependency_type TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(upstream_id, downstream_id) + ); + + -- ============================================================ + -- Analytics Tables + -- ============================================================ + + -- Activity log - persistent activity tracking (replaces in-memory) + CREATE TABLE IF NOT EXISTS activity_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (datetime('now')), + activity_type TEXT NOT NULL, + source TEXT, + issue_id TEXT, + short_id TEXT, + message TEXT NOT NULL, + metadata TEXT + ); + CREATE INDEX IF NOT EXISTS idx_activity_timestamp ON activity_log(timestamp DESC); + CREATE INDEX IF NOT EXISTS idx_activity_source ON activity_log(source); + CREATE INDEX IF NOT EXISTS idx_activity_issue ON activity_log(issue_id); + CREATE INDEX IF NOT EXISTS idx_activity_source_issue ON activity_log(source, issue_id, timestamp DESC); + + -- Claude executions - detailed execution metrics + CREATE TABLE IF NOT EXISTS claude_executions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + attempt_id INTEGER REFERENCES fix_attempts(id), + started_at TEXT NOT NULL, + completed_at TEXT, + duration_secs REAL, + exit_code INTEGER, + timed_out INTEGER DEFAULT 0, + stdout_preview TEXT, + stderr_preview TEXT, + prompt_used TEXT, + prompt_hash TEXT, + model_version TEXT, + working_directory TEXT, + git_branch TEXT, + git_commit_before TEXT, + git_commit_after TEXT, + files_changed INTEGER, + lines_added INTEGER, + lines_removed INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_executions_attempt ON claude_executions(attempt_id); + CREATE INDEX IF NOT EXISTS idx_executions_prompt_hash ON claude_executions(prompt_hash); + + -- PR reviews - PR review feedback for learning + CREATE TABLE IF NOT EXISTS pr_reviews ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + attempt_id INTEGER REFERENCES fix_attempts(id), + pr_url TEXT NOT NULL, + reviewer TEXT, + review_state TEXT, + submitted_at TEXT, + body TEXT, + sentiment TEXT, + actionable_feedback TEXT + ); + CREATE INDEX IF NOT EXISTS idx_pr_reviews_attempt ON pr_reviews(attempt_id); + + -- Issue embeddings - vector embeddings for similarity + CREATE TABLE IF NOT EXISTS issue_embeddings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + issue_id TEXT NOT NULL, + short_id TEXT, + title TEXT, + embedding BLOB NOT NULL, + embedding_model TEXT, + created_at TEXT DEFAULT (datetime('now')), + UNIQUE(source, issue_id) + ); + CREATE INDEX IF NOT EXISTS idx_embeddings_source ON issue_embeddings(source); + + -- Error patterns - recurring error analysis + CREATE TABLE IF NOT EXISTS error_patterns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pattern_hash TEXT UNIQUE, + error_type TEXT, + error_message TEXT, + first_seen TEXT, + last_seen TEXT, + occurrence_count INTEGER DEFAULT 1, + sources TEXT, + example_issue_ids TEXT, + resolution_hints TEXT + ); + CREATE INDEX IF NOT EXISTS idx_error_patterns_type ON error_patterns(error_type); + CREATE INDEX IF NOT EXISTS idx_error_patterns_count ON error_patterns(occurrence_count DESC); + + -- Processing metrics - time-series operational metrics + CREATE TABLE IF NOT EXISTS processing_metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (datetime('now')), + metric_name TEXT NOT NULL, + metric_value REAL NOT NULL, + source TEXT, + tags TEXT + ); + CREATE INDEX IF NOT EXISTS idx_metrics_name_time ON processing_metrics(metric_name, timestamp DESC); + + -- Prompt experiments - A/B testing prompts + CREATE TABLE IF NOT EXISTS prompt_experiments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + experiment_name TEXT NOT NULL, + variant TEXT NOT NULL, + prompt_template TEXT NOT NULL, + prompt_hash TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + active INTEGER DEFAULT 1, + success_count INTEGER DEFAULT 0, + failure_count INTEGER DEFAULT 0, + avg_time_to_merge REAL, + avg_review_score REAL + ); + CREATE INDEX IF NOT EXISTS idx_experiments_active ON prompt_experiments(active, experiment_name); + + -- Similar issues - cached similarity matches + CREATE TABLE IF NOT EXISTS similar_issues ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_issue_id TEXT NOT NULL, + similar_issue_id TEXT NOT NULL, + similarity_score REAL NOT NULL, + computed_at TEXT DEFAULT (datetime('now')), + UNIQUE(source_issue_id, similar_issue_id) + ); + CREATE INDEX IF NOT EXISTS idx_similar_source ON similar_issues(source_issue_id); + CREATE INDEX IF NOT EXISTS idx_similar_score ON similar_issues(similarity_score DESC); + + -- ============================================================ + -- Repository Index Tables + -- ============================================================ + + -- Indexed repositories - repos discovered from known_orgs + CREATE TABLE IF NOT EXISTS indexed_repos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + path TEXT NOT NULL, + github_url TEXT, + default_branch TEXT DEFAULT 'main', + file_count INTEGER NOT NULL DEFAULT 0, + last_indexed_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_indexed_repos_name ON indexed_repos(name); + + -- File index for searching - files within indexed repos + CREATE TABLE IF NOT EXISTS repo_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER NOT NULL REFERENCES indexed_repos(id) ON DELETE CASCADE, + file_path TEXT NOT NULL, + file_type TEXT, + last_modified TEXT, + UNIQUE(repo_id, file_path) + ); + CREATE INDEX IF NOT EXISTS idx_repo_files_path ON repo_files(file_path); + CREATE INDEX IF NOT EXISTS idx_repo_files_type ON repo_files(file_type); + CREATE INDEX IF NOT EXISTS idx_repo_files_repo ON repo_files(repo_id); + + -- ============================================================ + -- Inference Tracking Tables + -- ============================================================ + + -- Track every inference attempt for learning and analytics + CREATE TABLE IF NOT EXISTS inference_attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + issue_id TEXT NOT NULL, + issue_source TEXT NOT NULL, + + -- Input context + extracted_filenames TEXT, + extracted_functions TEXT, + extracted_keywords TEXT, + raw_context TEXT, + + -- Inference result + inferred_repo_id INTEGER REFERENCES indexed_repos(id), + confidence TEXT, + inference_reason TEXT, + match_details TEXT, + + -- Outcome tracking (updated later) + was_correct INTEGER, + actual_repo_id INTEGER REFERENCES indexed_repos(id), + feedback_source TEXT, + + -- Timing + inference_duration_ms INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + feedback_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_inference_issue ON inference_attempts(issue_id); + CREATE INDEX IF NOT EXISTS idx_inference_confidence ON inference_attempts(confidence); + CREATE INDEX IF NOT EXISTS idx_inference_correct ON inference_attempts(was_correct); + CREATE INDEX IF NOT EXISTS idx_inference_created ON inference_attempts(created_at DESC); + "#, + )?; + + // Add new columns if they don't exist (migration for existing DBs) + // Note: These will fail with "duplicate column name" if column already exists, + // which is expected and safe to ignore. Other errors are logged. + let migrations = [ + ( + "github_repo", + "ALTER TABLE fix_attempts ADD COLUMN github_repo TEXT", + ), + ( + "github_pr_number", + "ALTER TABLE fix_attempts ADD COLUMN github_pr_number INTEGER", + ), + ( + "merged_at", + "ALTER TABLE fix_attempts ADD COLUMN merged_at TEXT", + ), + ( + "resolved_at", + "ALTER TABLE fix_attempts ADD COLUMN resolved_at TEXT", + ), + ( + "retry_count", + "ALTER TABLE fix_attempts ADD COLUMN retry_count INTEGER DEFAULT 0", + ), + ( + "last_retry_at", + "ALTER TABLE fix_attempts ADD COLUMN last_retry_at TEXT", + ), + ]; + + for (column_name, sql) in migrations { + if let Err(e) = conn.execute(sql, []) { + // "duplicate column name" is expected if column already exists + if !e.to_string().contains("duplicate column name") { + tracing::error!( + column = column_name, + error = %e, + "Failed to run migration" + ); + } + } + } + + Ok(()) + } + + fn parse_datetime(s: &str) -> DateTime { + DateTime::parse_from_rfc3339(s) + .map(|dt| dt.with_timezone(&Utc)) + .or_else(|_| { + chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").map(|dt| dt.and_utc()) + }) + .unwrap_or_else(|_| Utc::now()) + } + + fn parse_optional_datetime(s: Option) -> Option> { + s.map(|s| Self::parse_datetime(&s)) + } + + /// Parse a GitHub PR URL to extract repo and PR number. + /// Supports: https://github.com/owner/repo/pull/123 + fn parse_pr_url(url: &str) -> Option<(String, i64)> { + let regex = regex_lite::Regex::new(r"github\.com/([^/]+/[^/]+)/pull/(\d+)").ok()?; + let caps = regex.captures(url)?; + let repo = caps.get(1)?.as_str().to_string(); + let pr_number: i64 = caps.get(2)?.as_str().parse().ok()?; + Some((repo, pr_number)) + } +} + +impl FixAttemptTracker for SqliteTracker { + fn has_attempted(&self, source: &str, issue_id: &str) -> bool { + let conn = match self.conn.lock() { + Ok(c) => c, + Err(e) => { + tracing::error!(error = %e, "Failed to acquire database lock in has_attempted"); + return false; + } + }; + let mut stmt = + match conn.prepare("SELECT 1 FROM fix_attempts WHERE source = ? AND issue_id = ?") { + Ok(s) => s, + Err(e) => { + tracing::error!(error = %e, "Failed to prepare statement in has_attempted"); + return false; + } + }; + stmt.exists(params![source, issue_id]).unwrap_or(false) + } + + fn get_attempted_issue_ids(&self, source: &str) -> HashSet { + let conn = match self.conn.lock() { + Ok(c) => c, + Err(e) => { + tracing::error!(error = %e, "Failed to acquire database lock in get_attempted_issue_ids"); + return HashSet::new(); + } + }; + let mut stmt = match conn.prepare("SELECT issue_id FROM fix_attempts WHERE source = ?") { + Ok(s) => s, + Err(e) => { + tracing::error!(error = %e, "Failed to prepare statement in get_attempted_issue_ids"); + return HashSet::new(); + } + }; + + // Collect results immediately to avoid borrow lifetime issues + let query_result = stmt.query_map(params![source], |row| row.get::<_, String>(0)); + let mut result = HashSet::new(); + + match query_result { + Ok(rows) => { + for row in rows { + match row { + Ok(id) => { + result.insert(id); + } + Err(e) => { + tracing::warn!(error = %e, "Failed to read issue_id row"); + } + } + } + } + Err(e) => { + tracing::error!(error = %e, "Failed to query issue IDs"); + } + } + result + } + + fn record_attempt(&self, source: &str, issue_id: &str, short_id: &str) -> Result<()> { + let conn = self.conn.lock().map_err(|e| { + crate::error::Error::Storage(format!("Failed to acquire database lock: {}", e)) + })?; + conn.execute( + r#" + INSERT INTO fix_attempts (source, issue_id, short_id, status, attempted_at) + VALUES (?, ?, ?, 'pending', datetime('now')) + ON CONFLICT(source, issue_id) DO UPDATE SET + short_id = excluded.short_id, + attempted_at = datetime('now') + "#, + params![source, issue_id, short_id], + )?; + Ok(()) + } + + fn mark_success(&self, source: &str, issue_id: &str, pr_url: &str) -> Result<()> { + let conn = self.acquire_lock()?; + + // Parse PR URL to extract GitHub repo and PR number + let (github_repo, github_pr_number) = Self::parse_pr_url(pr_url) + .map(|(r, n)| (Some(r), Some(n))) + .unwrap_or((None, None)); + + conn.execute( + r#" + UPDATE fix_attempts + SET status = 'success', pr_url = ?, github_repo = ?, github_pr_number = ? + WHERE source = ? AND issue_id = ? + "#, + params![pr_url, github_repo, github_pr_number, source, issue_id], + )?; + Ok(()) + } + + fn mark_merged(&self, source: &str, issue_id: &str) -> Result<()> { + let conn = self.acquire_lock()?; + conn.execute( + r#" + UPDATE fix_attempts + SET status = 'merged', merged_at = datetime('now') + WHERE source = ? AND issue_id = ? + "#, + params![source, issue_id], + )?; + Ok(()) + } + + fn mark_closed(&self, source: &str, issue_id: &str) -> Result<()> { + let conn = self.acquire_lock()?; + conn.execute( + r#" + UPDATE fix_attempts + SET status = 'closed' + WHERE source = ? AND issue_id = ? + "#, + params![source, issue_id], + )?; + Ok(()) + } + + fn mark_resolved(&self, source: &str, issue_id: &str) -> Result<()> { + let conn = self.acquire_lock()?; + conn.execute( + r#" + UPDATE fix_attempts + SET resolved_at = datetime('now') + WHERE source = ? AND issue_id = ? + "#, + params![source, issue_id], + )?; + Ok(()) + } + + fn mark_failed(&self, source: &str, issue_id: &str, error_message: &str) -> Result<()> { + let conn = self.acquire_lock()?; + conn.execute( + r#" + UPDATE fix_attempts + SET status = 'failed', error_message = ? + WHERE source = ? AND issue_id = ? + "#, + params![error_message, source, issue_id], + )?; + Ok(()) + } + + fn get_attempt(&self, source: &str, issue_id: &str) -> Result> { + let conn = self.acquire_lock()?; + let mut stmt = conn.prepare( + r#" + SELECT id, source, issue_id, short_id, attempted_at, pr_url, github_repo, + github_pr_number, status, error_message, merged_at, resolved_at, + retry_count, last_retry_at + FROM fix_attempts + WHERE source = ? AND issue_id = ? + "#, + )?; + + let result = stmt + .query_row(params![source, issue_id], |row| { + Ok(FixAttempt { + id: row.get(0)?, + source: row.get(1)?, + issue_id: row.get(2)?, + short_id: row.get(3)?, + attempted_at: Self::parse_datetime(&row.get::<_, String>(4)?), + pr_url: row.get(5)?, + github_repo: row.get(6)?, + github_pr_number: row.get(7)?, + status: row + .get::<_, String>(8)? + .parse() + .unwrap_or(FixAttemptStatus::Pending), + error_message: row.get(9)?, + merged_at: Self::parse_optional_datetime(row.get(10)?), + resolved_at: Self::parse_optional_datetime(row.get(11)?), + retry_count: row.get::<_, Option>(12)?.unwrap_or(0), + last_retry_at: Self::parse_optional_datetime(row.get(13)?), + }) + }) + .ok(); + + Ok(result) + } + + fn get_attempts_by_status(&self, status: FixAttemptStatus) -> Result> { + let conn = self.acquire_lock()?; + let mut stmt = conn.prepare( + r#" + SELECT id, source, issue_id, short_id, attempted_at, pr_url, github_repo, + github_pr_number, status, error_message, merged_at, resolved_at, + retry_count, last_retry_at + FROM fix_attempts + WHERE status = ? + ORDER BY attempted_at DESC + "#, + )?; + + let status_str = status.to_string(); + let rows = stmt.query_map(params![status_str], |row| { + Ok(FixAttempt { + id: row.get(0)?, + source: row.get(1)?, + issue_id: row.get(2)?, + short_id: row.get(3)?, + attempted_at: Self::parse_datetime(&row.get::<_, String>(4)?), + pr_url: row.get(5)?, + github_repo: row.get(6)?, + github_pr_number: row.get(7)?, + status: row + .get::<_, String>(8)? + .parse() + .unwrap_or(FixAttemptStatus::Pending), + error_message: row.get(9)?, + merged_at: Self::parse_optional_datetime(row.get(10)?), + resolved_at: Self::parse_optional_datetime(row.get(11)?), + retry_count: row.get::<_, Option>(12)?.unwrap_or(0), + last_retry_at: Self::parse_optional_datetime(row.get(13)?), + }) + })?; + + let mut results = Vec::new(); + for row in rows { + match row { + Ok(attempt) => results.push(attempt), + Err(e) => { + tracing::warn!(error = %e, "Failed to read fix attempt row"); + } + } + } + Ok(results) + } + + fn get_pending_prs(&self) -> Result> { + let conn = self.acquire_lock()?; + let mut stmt = conn.prepare( + r#" + SELECT id, source, issue_id, short_id, attempted_at, pr_url, github_repo, + github_pr_number, status, error_message, merged_at, resolved_at, + retry_count, last_retry_at + FROM fix_attempts + WHERE status = 'success' AND pr_url IS NOT NULL AND github_repo IS NOT NULL + ORDER BY attempted_at DESC + "#, + )?; + + let rows = stmt.query_map([], |row| { + Ok(FixAttempt { + id: row.get(0)?, + source: row.get(1)?, + issue_id: row.get(2)?, + short_id: row.get(3)?, + attempted_at: Self::parse_datetime(&row.get::<_, String>(4)?), + pr_url: row.get(5)?, + github_repo: row.get(6)?, + github_pr_number: row.get(7)?, + status: row + .get::<_, String>(8)? + .parse() + .unwrap_or(FixAttemptStatus::Success), + error_message: row.get(9)?, + merged_at: Self::parse_optional_datetime(row.get(10)?), + resolved_at: Self::parse_optional_datetime(row.get(11)?), + retry_count: row.get::<_, Option>(12)?.unwrap_or(0), + last_retry_at: Self::parse_optional_datetime(row.get(13)?), + }) + })?; + + let mut results = Vec::new(); + for row in rows { + match row { + Ok(attempt) => results.push(attempt), + Err(e) => { + tracing::warn!(error = %e, "Failed to read pending PR row"); + } + } + } + Ok(results) + } + + fn get_attempt_by_pr_url(&self, pr_url: &str) -> Result> { + let conn = self.acquire_lock()?; + let mut stmt = conn.prepare( + r#" + SELECT id, source, issue_id, short_id, attempted_at, pr_url, github_repo, + github_pr_number, status, error_message, merged_at, resolved_at, + retry_count, last_retry_at + FROM fix_attempts + WHERE pr_url = ? + "#, + )?; + + let result = stmt + .query_row(params![pr_url], |row| { + Ok(FixAttempt { + id: row.get(0)?, + source: row.get(1)?, + issue_id: row.get(2)?, + short_id: row.get(3)?, + attempted_at: Self::parse_datetime(&row.get::<_, String>(4)?), + pr_url: row.get(5)?, + github_repo: row.get(6)?, + github_pr_number: row.get(7)?, + status: row + .get::<_, String>(8)? + .parse() + .unwrap_or(FixAttemptStatus::Pending), + error_message: row.get(9)?, + merged_at: Self::parse_optional_datetime(row.get(10)?), + resolved_at: Self::parse_optional_datetime(row.get(11)?), + retry_count: row.get::<_, Option>(12)?.unwrap_or(0), + last_retry_at: Self::parse_optional_datetime(row.get(13)?), + }) + }) + .ok(); + + Ok(result) + } + + fn reset_attempt(&self, source: &str, issue_id: &str) -> Result<()> { + let conn = self.acquire_lock()?; + conn.execute( + "DELETE FROM fix_attempts WHERE source = ? AND issue_id = ?", + params![source, issue_id], + )?; + Ok(()) + } + + fn increment_retry(&self, source: &str, issue_id: &str) -> Result<()> { + let conn = self.acquire_lock()?; + conn.execute( + r#" + UPDATE fix_attempts + SET retry_count = COALESCE(retry_count, 0) + 1, + last_retry_at = datetime('now') + WHERE source = ? AND issue_id = ? + "#, + params![source, issue_id], + )?; + Ok(()) + } + + fn mark_cannot_fix(&self, source: &str, issue_id: &str, reason: &str) -> Result<()> { + let conn = self.acquire_lock()?; + conn.execute( + r#" + UPDATE fix_attempts + SET status = 'cannot_fix', error_message = ? + WHERE source = ? AND issue_id = ? + "#, + params![reason, source, issue_id], + )?; + Ok(()) + } + + fn get_retryable_issues(&self, max_retries: u32) -> Result> { + let conn = self.acquire_lock()?; + let mut stmt = conn.prepare( + r#" + SELECT id, source, issue_id, short_id, attempted_at, pr_url, github_repo, + github_pr_number, status, error_message, merged_at, resolved_at, + retry_count, last_retry_at + FROM fix_attempts + WHERE (status = 'failed' OR status = 'closed') + AND COALESCE(retry_count, 0) < ? + ORDER BY attempted_at ASC + "#, + )?; + + let rows = stmt.query_map(params![max_retries], |row| { + Ok(FixAttempt { + id: row.get(0)?, + source: row.get(1)?, + issue_id: row.get(2)?, + short_id: row.get(3)?, + attempted_at: Self::parse_datetime(&row.get::<_, String>(4)?), + pr_url: row.get(5)?, + github_repo: row.get(6)?, + github_pr_number: row.get(7)?, + status: row + .get::<_, String>(8)? + .parse() + .unwrap_or(FixAttemptStatus::Failed), + error_message: row.get(9)?, + merged_at: Self::parse_optional_datetime(row.get(10)?), + resolved_at: Self::parse_optional_datetime(row.get(11)?), + retry_count: row.get::<_, Option>(12)?.unwrap_or(0), + last_retry_at: Self::parse_optional_datetime(row.get(13)?), + }) + })?; + + let mut results = Vec::new(); + for row in rows { + match row { + Ok(attempt) => results.push(attempt), + Err(e) => { + tracing::warn!(error = %e, "Failed to read retryable issue row"); + } + } + } + Ok(results) + } + + fn prepare_for_retry(&self, source: &str, issue_id: &str) -> Result<()> { + let conn = self.acquire_lock()?; + conn.execute( + r#" + UPDATE fix_attempts + SET status = 'pending', + pr_url = NULL, + github_repo = NULL, + github_pr_number = NULL, + error_message = NULL, + attempted_at = datetime('now') + WHERE source = ? AND issue_id = ? + "#, + params![source, issue_id], + )?; + Ok(()) + } + + fn get_stats(&self) -> Result { + let conn = self.acquire_lock()?; + let mut stats = FixAttemptStats::default(); + + // Overall stats + let mut stmt = conn.prepare( + r#" + SELECT status, COUNT(*) as count + FROM fix_attempts + GROUP BY status + "#, + )?; + + let rows = stmt.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?)) + })?; + + for row in rows { + match row { + Ok((status, count)) => { + stats.total += count; + match status.as_str() { + "pending" => stats.pending = count, + "success" => stats.success = count, + "failed" => stats.failed = count, + "merged" => stats.merged = count, + "closed" => stats.closed = count, + "cannot_fix" => stats.cannot_fix = count, + _ => {} + } + } + Err(e) => { + tracing::warn!(error = %e, "Failed to read stats row"); + } + } + } + + // Stats by source + let mut stmt = conn.prepare( + r#" + SELECT source, status, COUNT(*) as count + FROM fix_attempts + GROUP BY source, status + "#, + )?; + + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, usize>(2)?, + )) + })?; + + let mut by_source: HashMap = HashMap::new(); + + for row in rows { + match row { + Ok((source, status, count)) => { + let entry = by_source.entry(source).or_default(); + entry.total += count; + match status.as_str() { + "success" => entry.success = count, + "failed" => entry.failed = count, + "merged" => entry.merged = count, + "closed" => entry.closed = count, + "cannot_fix" => entry.cannot_fix = count, + _ => {} + } + } + Err(e) => { + tracing::warn!(error = %e, "Failed to read source stats row"); + } + } + } + + stats.by_source = by_source; + + Ok(stats) + } + + fn record_activity(&self, entry: &ActivityLogEntry) -> Result { + SqliteTracker::record_activity(self, entry) + } + + fn get_recent_activities(&self, limit: usize) -> Result> { + SqliteTracker::get_recent_activities(self, limit, None) + } + + fn record_execution(&self, execution: &ClaudeExecution) -> Result { + SqliteTracker::record_execution(self, execution) + } + + fn record_pr_review(&self, review: &PrReviewRecord) -> Result { + SqliteTracker::record_pr_review(self, review) + } + + fn record_error_pattern(&self, pattern: &ErrorPattern) -> Result { + SqliteTracker::record_error_pattern(self, pattern) + } + + fn record_metric(&self, metric: &ProcessingMetric) -> Result { + SqliteTracker::record_metric(self, metric) + } + + fn get_analytics_summary(&self) -> Result { + SqliteTracker::get_analytics_summary(self) + } +} + +impl SqliteTracker { + /// Record an activity to the activity log. + pub fn record_activity(&self, entry: &ActivityLogEntry) -> Result { + let conn = self.acquire_lock()?; + let metadata_json = entry.metadata.as_ref().map(|m| m.to_string()); + + conn.execute( + r#" + INSERT INTO activity_log (timestamp, activity_type, source, issue_id, short_id, message, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?) + "#, + params![ + entry.timestamp.format("%Y-%m-%d %H:%M:%S").to_string(), + entry.activity_type, + entry.source, + entry.issue_id, + entry.short_id, + entry.message, + metadata_json, + ], + )?; + Ok(conn.last_insert_rowid()) + } + + /// Get recent activities, optionally filtered by source. + pub fn get_recent_activities( + &self, + limit: usize, + source_filter: Option<&str>, + ) -> Result> { + let conn = self.acquire_lock()?; + + let mut entries = Vec::new(); + if let Some(source) = source_filter { + let mut stmt = conn.prepare( + r#" + SELECT id, timestamp, activity_type, source, issue_id, short_id, message, metadata + FROM activity_log + WHERE source = ? + ORDER BY timestamp DESC + LIMIT ? + "#, + )?; + + let rows = stmt.query_map(params![source, limit as i64], |row| { + Ok(Self::row_to_activity_entry(row)) + })?; + + for row in rows.flatten() { + entries.push(row); + } + } else { + let mut stmt = conn.prepare( + r#" + SELECT id, timestamp, activity_type, source, issue_id, short_id, message, metadata + FROM activity_log + ORDER BY timestamp DESC + LIMIT ? + "#, + )?; + + let rows = stmt.query_map(params![limit as i64], |row| { + Ok(Self::row_to_activity_entry(row)) + })?; + + for row in rows.flatten() { + entries.push(row); + } + } + + Ok(entries) + } + + /// Get activities for a specific issue. + pub fn get_activities_for_issue( + &self, + source: &str, + issue_id: &str, + ) -> Result> { + let conn = self.acquire_lock()?; + let mut stmt = conn.prepare( + r#" + SELECT id, timestamp, activity_type, source, issue_id, short_id, message, metadata + FROM activity_log + WHERE source = ? AND issue_id = ? + ORDER BY timestamp DESC + "#, + )?; + + let mut entries = Vec::new(); + let rows = stmt.query_map(params![source, issue_id], |row| { + Ok(Self::row_to_activity_entry(row)) + })?; + + for row in rows.flatten() { + entries.push(row); + } + + Ok(entries) + } + + fn row_to_activity_entry(row: &rusqlite::Row<'_>) -> ActivityLogEntry { + let metadata_str: Option = row.get(7).ok(); + let metadata = metadata_str.and_then(|s| serde_json::from_str(&s).ok()); + + ActivityLogEntry { + id: row.get(0).unwrap_or(0), + timestamp: Self::parse_datetime(&row.get::<_, String>(1).unwrap_or_default()), + activity_type: row.get(2).unwrap_or_default(), + source: row.get(3).ok(), + issue_id: row.get(4).ok(), + short_id: row.get(5).ok(), + message: row.get(6).unwrap_or_default(), + metadata, + } + } + + /// Record a Claude execution. + pub fn record_execution(&self, execution: &ClaudeExecution) -> Result { + let conn = self.acquire_lock()?; + + conn.execute( + r#" + INSERT INTO claude_executions ( + attempt_id, started_at, completed_at, duration_secs, exit_code, timed_out, + stdout_preview, stderr_preview, prompt_used, prompt_hash, model_version, + working_directory, git_branch, git_commit_before, git_commit_after, + files_changed, lines_added, lines_removed + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + params![ + execution.attempt_id, + execution.started_at.format("%Y-%m-%d %H:%M:%S").to_string(), + execution.completed_at.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()), + execution.duration_secs, + execution.exit_code, + execution.timed_out as i32, + execution.stdout_preview, + execution.stderr_preview, + execution.prompt_used, + execution.prompt_hash, + execution.model_version, + execution.working_directory, + execution.git_branch, + execution.git_commit_before, + execution.git_commit_after, + execution.files_changed, + execution.lines_added, + execution.lines_removed, + ], + )?; + Ok(conn.last_insert_rowid()) + } + + /// Get executions for a specific attempt. + pub fn get_executions_for_attempt(&self, attempt_id: i64) -> Result> { + let conn = self.acquire_lock()?; + let mut stmt = conn.prepare( + r#" + SELECT id, attempt_id, started_at, completed_at, duration_secs, exit_code, timed_out, + stdout_preview, stderr_preview, prompt_used, prompt_hash, model_version, + working_directory, git_branch, git_commit_before, git_commit_after, + files_changed, lines_added, lines_removed + FROM claude_executions + WHERE attempt_id = ? + ORDER BY started_at DESC + "#, + )?; + + let mut executions = Vec::new(); + let rows = stmt.query_map(params![attempt_id], |row| { + Ok(ClaudeExecution { + id: row.get(0)?, + attempt_id: row.get(1)?, + started_at: Self::parse_datetime(&row.get::<_, String>(2)?), + completed_at: Self::parse_optional_datetime(row.get(3)?), + duration_secs: row.get(4)?, + exit_code: row.get(5)?, + timed_out: row.get::<_, i32>(6).unwrap_or(0) != 0, + stdout_preview: row.get(7)?, + stderr_preview: row.get(8)?, + prompt_used: row.get(9)?, + prompt_hash: row.get(10)?, + model_version: row.get(11)?, + working_directory: row.get(12)?, + git_branch: row.get(13)?, + git_commit_before: row.get(14)?, + git_commit_after: row.get(15)?, + files_changed: row.get(16)?, + lines_added: row.get(17)?, + lines_removed: row.get(18)?, + }) + })?; + + for row in rows.flatten() { + executions.push(row); + } + + Ok(executions) + } + + /// Record a PR review. + pub fn record_pr_review(&self, review: &PrReviewRecord) -> Result { + let conn = self.acquire_lock()?; + + conn.execute( + r#" + INSERT INTO pr_reviews (attempt_id, pr_url, reviewer, review_state, submitted_at, body, sentiment, actionable_feedback) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + "#, + params![ + review.attempt_id, + review.pr_url, + review.reviewer, + review.review_state, + review.submitted_at.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()), + review.body, + review.sentiment, + review.actionable_feedback, + ], + )?; + Ok(conn.last_insert_rowid()) + } + + /// Get reviews for a specific attempt. + pub fn get_reviews_for_attempt(&self, attempt_id: i64) -> Result> { + let conn = self.acquire_lock()?; + let mut stmt = conn.prepare( + r#" + SELECT id, attempt_id, pr_url, reviewer, review_state, submitted_at, body, sentiment, actionable_feedback + FROM pr_reviews + WHERE attempt_id = ? + ORDER BY submitted_at DESC + "#, + )?; + + let mut reviews = Vec::new(); + let rows = stmt.query_map(params![attempt_id], |row| { + Ok(PrReviewRecord { + id: row.get(0)?, + attempt_id: row.get(1)?, + pr_url: row.get(2)?, + reviewer: row.get(3)?, + review_state: row.get(4)?, + submitted_at: Self::parse_optional_datetime(row.get(5)?), + body: row.get(6)?, + sentiment: row.get(7)?, + actionable_feedback: row.get(8)?, + }) + })?; + + for row in rows.flatten() { + reviews.push(row); + } + + Ok(reviews) + } + + /// Store an issue embedding. + pub fn store_embedding(&self, embedding: &IssueEmbedding) -> Result { + let conn = self.acquire_lock()?; + + // Serialize the embedding vector to bytes + let embedding_bytes: Vec = embedding + .embedding + .iter() + .flat_map(|f| f.to_le_bytes()) + .collect(); + + conn.execute( + r#" + INSERT INTO issue_embeddings (source, issue_id, short_id, title, embedding, embedding_model, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(source, issue_id) DO UPDATE SET + embedding = excluded.embedding, + embedding_model = excluded.embedding_model, + created_at = excluded.created_at + "#, + params![ + embedding.source, + embedding.issue_id, + embedding.short_id, + embedding.title, + embedding_bytes, + embedding.embedding_model, + embedding.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), + ], + )?; + Ok(conn.last_insert_rowid()) + } + + /// Get an embedding by source and issue ID. + pub fn get_embedding(&self, source: &str, issue_id: &str) -> Result> { + let conn = self.acquire_lock()?; + let mut stmt = conn.prepare( + r#" + SELECT id, source, issue_id, short_id, title, embedding, embedding_model, created_at + FROM issue_embeddings + WHERE source = ? AND issue_id = ? + "#, + )?; + + let result = stmt + .query_row(params![source, issue_id], |row| { + let embedding_bytes: Vec = row.get(5)?; + let embedding: Vec = embedding_bytes + .chunks(4) + .map(|chunk| { + let arr: [u8; 4] = chunk.try_into().unwrap_or([0; 4]); + f32::from_le_bytes(arr) + }) + .collect(); + + Ok(IssueEmbedding { + id: row.get(0)?, + source: row.get(1)?, + issue_id: row.get(2)?, + short_id: row.get(3)?, + title: row.get(4)?, + embedding, + embedding_model: row.get(6)?, + created_at: Self::parse_datetime(&row.get::<_, String>(7)?), + }) + }) + .ok(); + + Ok(result) + } + + /// Record or update an error pattern. + pub fn record_error_pattern(&self, pattern: &ErrorPattern) -> Result { + let conn = self.acquire_lock()?; + + let sources_json = pattern.sources.as_ref().and_then(|s| serde_json::to_string(s).ok()); + let example_ids_json = pattern.example_issue_ids.as_ref().and_then(|s| serde_json::to_string(s).ok()); + + conn.execute( + r#" + INSERT INTO error_patterns (pattern_hash, error_type, error_message, first_seen, last_seen, occurrence_count, sources, example_issue_ids, resolution_hints) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(pattern_hash) DO UPDATE SET + last_seen = excluded.last_seen, + occurrence_count = occurrence_count + 1, + sources = excluded.sources, + example_issue_ids = excluded.example_issue_ids + "#, + params![ + pattern.pattern_hash, + pattern.error_type, + pattern.error_message, + pattern.first_seen.format("%Y-%m-%d %H:%M:%S").to_string(), + pattern.last_seen.format("%Y-%m-%d %H:%M:%S").to_string(), + pattern.occurrence_count, + sources_json, + example_ids_json, + pattern.resolution_hints, + ], + )?; + Ok(conn.last_insert_rowid()) + } + + /// Get the most common error patterns. + pub fn get_error_patterns(&self, limit: usize) -> Result> { + let conn = self.acquire_lock()?; + let mut stmt = conn.prepare( + r#" + SELECT id, pattern_hash, error_type, error_message, first_seen, last_seen, occurrence_count, sources, example_issue_ids, resolution_hints + FROM error_patterns + ORDER BY occurrence_count DESC + LIMIT ? + "#, + )?; + + let mut patterns = Vec::new(); + let rows = stmt.query_map(params![limit as i64], |row| { + let sources_str: Option = row.get(7)?; + let example_ids_str: Option = row.get(8)?; + + Ok(ErrorPattern { + id: row.get(0)?, + pattern_hash: row.get(1)?, + error_type: row.get(2)?, + error_message: row.get(3)?, + first_seen: Self::parse_datetime(&row.get::<_, String>(4)?), + last_seen: Self::parse_datetime(&row.get::<_, String>(5)?), + occurrence_count: row.get(6)?, + sources: sources_str.and_then(|s| serde_json::from_str(&s).ok()), + example_issue_ids: example_ids_str.and_then(|s| serde_json::from_str(&s).ok()), + resolution_hints: row.get(9)?, + }) + })?; + + for row in rows.flatten() { + patterns.push(row); + } + + Ok(patterns) + } + + /// Record a processing metric. + pub fn record_metric(&self, metric: &ProcessingMetric) -> Result { + let conn = self.acquire_lock()?; + + let tags_json = metric.tags.as_ref().map(|t| t.to_string()); + + conn.execute( + r#" + INSERT INTO processing_metrics (timestamp, metric_name, metric_value, source, tags) + VALUES (?, ?, ?, ?, ?) + "#, + params![ + metric.timestamp.format("%Y-%m-%d %H:%M:%S").to_string(), + metric.metric_name, + metric.metric_value, + metric.source, + tags_json, + ], + )?; + Ok(conn.last_insert_rowid()) + } + + /// Get metrics by name within a time range. + pub fn get_metrics( + &self, + metric_name: &str, + since: Option>, + limit: usize, + ) -> Result> { + let conn = self.acquire_lock()?; + + let mut metrics = Vec::new(); + if let Some(since_time) = since { + let mut stmt = conn.prepare( + r#" + SELECT id, timestamp, metric_name, metric_value, source, tags + FROM processing_metrics + WHERE metric_name = ? AND timestamp >= ? + ORDER BY timestamp DESC + LIMIT ? + "#, + )?; + + let rows = stmt.query_map( + params![metric_name, since_time.format("%Y-%m-%d %H:%M:%S").to_string(), limit as i64], + Self::row_to_metric, + )?; + + for row in rows.flatten() { + metrics.push(row); + } + } else { + let mut stmt = conn.prepare( + r#" + SELECT id, timestamp, metric_name, metric_value, source, tags + FROM processing_metrics + WHERE metric_name = ? + ORDER BY timestamp DESC + LIMIT ? + "#, + )?; + + let rows = stmt.query_map(params![metric_name, limit as i64], |row| { + Self::row_to_metric(row) + })?; + + for row in rows.flatten() { + metrics.push(row); + } + } + + Ok(metrics) + } + + fn row_to_metric(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let tags_str: Option = row.get(5)?; + let tags = tags_str.and_then(|s| serde_json::from_str(&s).ok()); + + Ok(ProcessingMetric { + id: row.get(0)?, + timestamp: Self::parse_datetime(&row.get::<_, String>(1)?), + metric_name: row.get(2)?, + metric_value: row.get(3)?, + source: row.get(4)?, + tags, + }) + } + + /// Create or update a prompt experiment. + pub fn save_experiment(&self, experiment: &PromptExperiment) -> Result { + let conn = self.acquire_lock()?; + + conn.execute( + r#" + INSERT INTO prompt_experiments (experiment_name, variant, prompt_template, prompt_hash, created_at, active, success_count, failure_count, avg_time_to_merge, avg_review_score) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + params![ + experiment.experiment_name, + experiment.variant, + experiment.prompt_template, + experiment.prompt_hash, + experiment.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), + experiment.active as i32, + experiment.success_count, + experiment.failure_count, + experiment.avg_time_to_merge, + experiment.avg_review_score, + ], + )?; + Ok(conn.last_insert_rowid()) + } + + /// Get active experiments. + pub fn get_active_experiments(&self) -> Result> { + let conn = self.acquire_lock()?; + let mut stmt = conn.prepare( + r#" + SELECT id, experiment_name, variant, prompt_template, prompt_hash, created_at, active, success_count, failure_count, avg_time_to_merge, avg_review_score + FROM prompt_experiments + WHERE active = 1 + ORDER BY experiment_name, variant + "#, + )?; + + let mut experiments = Vec::new(); + let rows = stmt.query_map([], |row| { + Ok(PromptExperiment { + id: row.get(0)?, + experiment_name: row.get(1)?, + variant: row.get(2)?, + prompt_template: row.get(3)?, + prompt_hash: row.get(4)?, + created_at: Self::parse_datetime(&row.get::<_, String>(5)?), + active: row.get::<_, i32>(6)? != 0, + success_count: row.get(7)?, + failure_count: row.get(8)?, + avg_time_to_merge: row.get(9)?, + avg_review_score: row.get(10)?, + }) + })?; + + for row in rows.flatten() { + experiments.push(row); + } + + Ok(experiments) + } + + /// Update experiment statistics. + pub fn update_experiment_stats( + &self, + experiment_id: i64, + success: bool, + time_to_merge: Option, + ) -> Result<()> { + let conn = self.acquire_lock()?; + + if success { + conn.execute( + r#" + UPDATE prompt_experiments + SET success_count = success_count + 1 + WHERE id = ? + "#, + params![experiment_id], + )?; + } else { + conn.execute( + r#" + UPDATE prompt_experiments + SET failure_count = failure_count + 1 + WHERE id = ? + "#, + params![experiment_id], + )?; + } + + if let Some(ttm) = time_to_merge { + // Update rolling average of time to merge + conn.execute( + r#" + UPDATE prompt_experiments + SET avg_time_to_merge = CASE + WHEN avg_time_to_merge IS NULL THEN ? + ELSE (avg_time_to_merge * success_count + ?) / (success_count + 1) + END + WHERE id = ? + "#, + params![ttm, ttm, experiment_id], + )?; + } + + Ok(()) + } + + /// Store a similar issue relationship. + pub fn store_similar_issue(&self, similar: &SimilarIssue) -> Result { + let conn = self.acquire_lock()?; + + conn.execute( + r#" + INSERT INTO similar_issues (source_issue_id, similar_issue_id, similarity_score, computed_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(source_issue_id, similar_issue_id) DO UPDATE SET + similarity_score = excluded.similarity_score, + computed_at = excluded.computed_at + "#, + params![ + similar.source_issue_id, + similar.similar_issue_id, + similar.similarity_score, + similar.computed_at.format("%Y-%m-%d %H:%M:%S").to_string(), + ], + )?; + Ok(conn.last_insert_rowid()) + } + + /// Find similar issues for a given issue. + pub fn find_similar_issues( + &self, + issue_id: &str, + min_score: f64, + limit: usize, + ) -> Result> { + let conn = self.acquire_lock()?; + let mut stmt = conn.prepare( + r#" + SELECT id, source_issue_id, similar_issue_id, similarity_score, computed_at + FROM similar_issues + WHERE source_issue_id = ? AND similarity_score >= ? + ORDER BY similarity_score DESC + LIMIT ? + "#, + )?; + + let mut results = Vec::new(); + let rows = stmt.query_map(params![issue_id, min_score, limit as i64], |row| { + Ok(SimilarIssue { + id: row.get(0)?, + source_issue_id: row.get(1)?, + similar_issue_id: row.get(2)?, + similarity_score: row.get(3)?, + computed_at: Self::parse_datetime(&row.get::<_, String>(4)?), + }) + })?; + + for row in rows.flatten() { + results.push(row); + } + + Ok(results) + } + + /// Get the overall success rate. + pub fn get_success_rate(&self) -> Result { + let conn = self.acquire_lock()?; + let mut stmt = conn.prepare( + r#" + SELECT + CAST(SUM(CASE WHEN status IN ('success', 'merged') THEN 1 ELSE 0 END) AS REAL) / + NULLIF(CAST(COUNT(*) AS REAL), 0) + FROM fix_attempts + "#, + )?; + + let rate: f64 = stmt.query_row([], |row| row.get(0)).unwrap_or(0.0); + Ok(rate) + } + + /// Get a comprehensive analytics summary. + pub fn get_analytics_summary(&self) -> Result { + let conn = self.acquire_lock()?; + + // Get basic stats + let mut stmt = conn.prepare( + r#" + SELECT + COUNT(*) as total, + SUM(CASE WHEN status IN ('success', 'merged') THEN 1 ELSE 0 END) as successful, + SUM(CASE WHEN status = 'merged' THEN 1 ELSE 0 END) as merged + FROM fix_attempts + "#, + )?; + + let (total, successful, merged): (i64, i64, i64) = stmt + .query_row([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) + .unwrap_or((0, 0, 0)); + + let success_rate = if total > 0 { + successful as f64 / total as f64 + } else { + 0.0 + }; + + // Get average processing time + let mut stmt = conn.prepare( + r#" + SELECT AVG(duration_secs) FROM claude_executions WHERE duration_secs IS NOT NULL + "#, + )?; + let avg_processing_time: Option = stmt.query_row([], |row| row.get(0)).ok(); + + // Get most common error + let mut stmt = conn.prepare( + r#" + SELECT error_type FROM error_patterns ORDER BY occurrence_count DESC LIMIT 1 + "#, + )?; + let most_common_error: Option = stmt.query_row([], |row| row.get(0)).ok(); + + // Get success rate by source + let mut stmt = conn.prepare( + r#" + SELECT source, + CAST(SUM(CASE WHEN status IN ('success', 'merged') THEN 1 ELSE 0 END) AS REAL) / + NULLIF(CAST(COUNT(*) AS REAL), 0) as rate + FROM fix_attempts + GROUP BY source + "#, + )?; + + let mut success_rate_by_source = HashMap::new(); + let rows = stmt.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?)) + })?; + + for row in rows.flatten() { + success_rate_by_source.insert(row.0, row.1); + } + + Ok(AnalyticsSummary { + success_rate, + total_processed: total, + total_successful: successful, + total_merged: merged, + avg_processing_time_secs: avg_processing_time, + avg_time_to_merge_hours: None, // Would need more complex calculation + most_common_error, + success_rate_by_source, + }) + } + + /// Prune old activity logs to prevent unbounded growth. + pub fn prune_old_activities(&self, days_to_keep: i64) -> Result { + let conn = self.acquire_lock()?; + + let deleted = conn.execute( + r#" + DELETE FROM activity_log + WHERE timestamp < datetime('now', ? || ' days') + "#, + params![format!("-{}", days_to_keep)], + )?; + + Ok(deleted) + } + + /// Prune old metrics to prevent unbounded growth. + pub fn prune_old_metrics(&self, days_to_keep: i64) -> Result { + let conn = self.acquire_lock()?; + + let deleted = conn.execute( + r#" + DELETE FROM processing_metrics + WHERE timestamp < datetime('now', ? || ' days') + "#, + params![format!("-{}", days_to_keep)], + )?; + + Ok(deleted) + } + + /// Add or update a repository in the database. + pub fn upsert_repository( + &self, + name: &str, + path: Option<&str>, + github_url: Option<&str>, + ) -> Result { + let conn = self.acquire_lock()?; + + // Use name as github_url if not provided + let github_url = github_url.unwrap_or(name); + let path = path.unwrap_or(""); + + conn.execute( + r#" + INSERT INTO repositories (name, path, github_url) + VALUES (?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + path = CASE WHEN excluded.path != '' THEN excluded.path ELSE repositories.path END, + github_url = excluded.github_url + "#, + params![name, path, github_url], + )?; + + // Get the id + let id: i64 = conn.query_row( + "SELECT id FROM repositories WHERE name = ?", + params![name], + |row| row.get(0), + )?; + + Ok(id) + } + + /// Get a repository by name. + pub fn get_repository(&self, name: &str) -> Result> { + let conn = self.acquire_lock()?; + + let result = conn.query_row( + r#" + SELECT id, name, path, github_url, created_at + FROM repositories WHERE name = ? + "#, + params![name], + |row| { + Ok(StoredRepository { + id: row.get(0)?, + name: row.get(1)?, + path: row.get::<_, String>(2).ok().filter(|s| !s.is_empty()), + github_url: row.get(3)?, + created_at: row.get(4)?, + }) + }, + ); + + match result { + Ok(repo) => Ok(Some(repo)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + /// List all repositories. + pub fn list_repositories(&self) -> Result> { + let conn = self.acquire_lock()?; + let mut stmt = conn.prepare( + r#" + SELECT id, name, path, github_url, created_at + FROM repositories ORDER BY name + "#, + )?; + + let mut repos = Vec::new(); + let rows = stmt.query_map([], |row| { + Ok(StoredRepository { + id: row.get(0)?, + name: row.get(1)?, + path: row.get::<_, String>(2).ok().filter(|s| !s.is_empty()), + github_url: row.get(3)?, + created_at: row.get(4)?, + }) + })?; + + for row in rows.flatten() { + repos.push(row); + } + + Ok(repos) + } + + /// Add a dependency between two repositories. + /// Creates the repos if they don't exist. + pub fn add_dependency( + &self, + upstream: &str, + downstream: &str, + dep_type: &str, + ) -> Result<()> { + // Ensure both repos exist + let upstream_id = self.upsert_repository(upstream, None, None)?; + let downstream_id = self.upsert_repository(downstream, None, None)?; + + let conn = self.acquire_lock()?; + conn.execute( + r#" + INSERT INTO repository_dependencies (upstream_id, downstream_id, dependency_type) + VALUES (?, ?, ?) + ON CONFLICT(upstream_id, downstream_id) DO UPDATE SET + dependency_type = excluded.dependency_type + "#, + params![upstream_id, downstream_id, dep_type], + )?; + + Ok(()) + } + + /// Get all dependencies for a repository (what it depends on). + pub fn get_dependencies(&self, repo_name: &str) -> Result> { + let conn = self.acquire_lock()?; + let mut stmt = conn.prepare( + r#" + SELECT rd.id, u.name, d.name, rd.dependency_type, rd.created_at + FROM repository_dependencies rd + JOIN repositories u ON rd.upstream_id = u.id + JOIN repositories d ON rd.downstream_id = d.id + WHERE d.name = ? + "#, + )?; + + let mut deps = Vec::new(); + let rows = stmt.query_map(params![repo_name], |row| { + Ok(StoredDependency { + id: row.get(0)?, + upstream: row.get(1)?, + downstream: row.get(2)?, + dep_type: row.get(3)?, + created_at: row.get(4)?, + }) + })?; + + for row in rows.flatten() { + deps.push(row); + } + + Ok(deps) + } + + /// Get all dependents of a repository (what depends on it). + pub fn get_dependents(&self, repo_name: &str) -> Result> { + let conn = self.acquire_lock()?; + let mut stmt = conn.prepare( + r#" + SELECT rd.id, u.name, d.name, rd.dependency_type, rd.created_at + FROM repository_dependencies rd + JOIN repositories u ON rd.upstream_id = u.id + JOIN repositories d ON rd.downstream_id = d.id + WHERE u.name = ? + "#, + )?; + + let mut deps = Vec::new(); + let rows = stmt.query_map(params![repo_name], |row| { + Ok(StoredDependency { + id: row.get(0)?, + upstream: row.get(1)?, + downstream: row.get(2)?, + dep_type: row.get(3)?, + created_at: row.get(4)?, + }) + })?; + + for row in rows.flatten() { + deps.push(row); + } + + Ok(deps) + } + + /// Get all dependencies in the database. + pub fn list_all_dependencies(&self) -> Result> { + let conn = self.acquire_lock()?; + let mut stmt = conn.prepare( + r#" + SELECT rd.id, u.name, d.name, rd.dependency_type, rd.created_at + FROM repository_dependencies rd + JOIN repositories u ON rd.upstream_id = u.id + JOIN repositories d ON rd.downstream_id = d.id + ORDER BY d.name, u.name + "#, + )?; + + let mut deps = Vec::new(); + let rows = stmt.query_map([], |row| { + Ok(StoredDependency { + id: row.get(0)?, + upstream: row.get(1)?, + downstream: row.get(2)?, + dep_type: row.get(3)?, + created_at: row.get(4)?, + }) + })?; + + for row in rows.flatten() { + deps.push(row); + } + + Ok(deps) + } + + /// Clear all repositories and dependencies from the database. + pub fn clear_repositories(&self) -> Result<()> { + let conn = self.acquire_lock()?; + conn.execute_batch( + r#" + DELETE FROM repository_dependencies; + DELETE FROM repositories; + "#, + )?; + Ok(()) + } + + /// Get repositories that directly depend on the given repository. + /// + /// Returns repos where the given repo is an upstream dependency. + pub fn get_direct_dependants(&self, repo: &str) -> Result> { + let conn = self.acquire_lock()?; + let mut stmt = conn.prepare( + r#" + SELECT rd.id, u.name, d.name, rd.dependency_type, rd.created_at + FROM repository_dependencies rd + JOIN repositories u ON rd.upstream_id = u.id + JOIN repositories d ON rd.downstream_id = d.id + WHERE u.name = ? + ORDER BY d.name + "#, + )?; + + let mut deps = Vec::new(); + let rows = stmt.query_map(params![repo], |row| { + Ok(StoredDependency { + id: row.get(0)?, + upstream: row.get(1)?, + downstream: row.get(2)?, + dep_type: row.get(3)?, + created_at: row.get(4)?, + }) + })?; + + for row in rows.flatten() { + deps.push(row); + } + + Ok(deps) + } + + /// Get all repositories that depend on the given repository, transitively. + /// + /// Uses a recursive CTE to traverse the dependency graph. + /// Returns (repo_name, depth) pairs where depth indicates how many hops from the source. + pub fn get_all_dependants(&self, repo: &str) -> Result> { + let conn = self.acquire_lock()?; + let mut stmt = conn.prepare( + r#" + WITH RECURSIVE dependants AS ( + -- Base case: direct dependants + SELECT d.id, d.name, 1 as depth + FROM repository_dependencies rd + JOIN repositories u ON rd.upstream_id = u.id + JOIN repositories d ON rd.downstream_id = d.id + WHERE u.name = ? + + UNION + + -- Recursive case: dependants of dependants + SELECT d.id, d.name, dep.depth + 1 + FROM dependants dep + JOIN repository_dependencies rd ON rd.upstream_id = dep.id + JOIN repositories d ON rd.downstream_id = d.id + WHERE dep.depth < 10 -- Prevent infinite loops + ) + SELECT DISTINCT name, MIN(depth) as depth + FROM dependants + GROUP BY name + ORDER BY depth, name + "#, + )?; + + let mut results = Vec::new(); + let rows = stmt.query_map(params![repo], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)?)) + })?; + + for row in rows.flatten() { + results.push(row); + } + + Ok(results) + } + + // ================================================================ + // Indexed Repository Methods + // ================================================================ + + /// Save an indexed repository to the database. + pub fn save_indexed_repo( + &self, + name: &str, + path: &str, + github_url: Option<&str>, + default_branch: &str, + file_count: usize, + ) -> Result { + let conn = self.acquire_lock()?; + conn.execute( + r#" + INSERT INTO indexed_repos (name, path, github_url, default_branch, file_count, last_indexed_at) + VALUES (?1, ?2, ?3, ?4, ?5, datetime('now')) + ON CONFLICT(name) DO UPDATE SET + path = excluded.path, + github_url = excluded.github_url, + default_branch = excluded.default_branch, + file_count = excluded.file_count, + last_indexed_at = datetime('now') + "#, + params![name, path, github_url, default_branch, file_count as i64], + )?; + + let id = conn.last_insert_rowid(); + Ok(id) + } + + /// Save a file to the repo files index. + pub fn save_repo_file(&self, repo_id: i64, file_path: &str, file_type: Option<&str>) -> Result<()> { + let conn = self.acquire_lock()?; + conn.execute( + r#" + INSERT INTO repo_files (repo_id, file_path, file_type) + VALUES (?1, ?2, ?3) + ON CONFLICT(repo_id, file_path) DO UPDATE SET + file_type = excluded.file_type + "#, + params![repo_id, file_path, file_type], + )?; + Ok(()) + } + + /// Save multiple files to the repo files index efficiently. + pub fn save_repo_files(&self, repo_id: i64, files: &[(String, Option)]) -> Result<()> { + let conn = self.acquire_lock()?; + let mut stmt = conn.prepare( + r#" + INSERT INTO repo_files (repo_id, file_path, file_type) + VALUES (?1, ?2, ?3) + ON CONFLICT(repo_id, file_path) DO UPDATE SET + file_type = excluded.file_type + "#, + )?; + + for (file_path, file_type) in files { + stmt.execute(params![repo_id, file_path, file_type.as_deref()])?; + } + + Ok(()) + } + + /// Clear files for a repository (before re-indexing). + pub fn clear_repo_files(&self, repo_id: i64) -> Result<()> { + let conn = self.acquire_lock()?; + conn.execute("DELETE FROM repo_files WHERE repo_id = ?", params![repo_id])?; + Ok(()) + } + + /// Get an indexed repository by name. + pub fn get_indexed_repo(&self, name: &str) -> Result> { + let conn = self.acquire_lock()?; + let mut stmt = conn.prepare( + r#" + SELECT id, name, path, github_url, default_branch, file_count, last_indexed_at, created_at + FROM indexed_repos WHERE name = ? + "#, + )?; + + let result = stmt.query_row(params![name], |row| { + Ok(StoredIndexedRepo { + id: row.get(0)?, + name: row.get(1)?, + path: row.get(2)?, + github_url: row.get(3)?, + default_branch: row.get(4)?, + file_count: row.get(5)?, + last_indexed_at: row.get(6)?, + created_at: row.get(7)?, + }) + }); + + match result { + Ok(repo) => Ok(Some(repo)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + /// List all indexed repositories. + pub fn list_indexed_repos(&self) -> Result> { + let conn = self.acquire_lock()?; + let mut stmt = conn.prepare( + r#" + SELECT id, name, path, github_url, default_branch, file_count, last_indexed_at, created_at + FROM indexed_repos ORDER BY name + "#, + )?; + + let mut repos = Vec::new(); + let rows = stmt.query_map([], |row| { + Ok(StoredIndexedRepo { + id: row.get(0)?, + name: row.get(1)?, + path: row.get(2)?, + github_url: row.get(3)?, + default_branch: row.get(4)?, + file_count: row.get(5)?, + last_indexed_at: row.get(6)?, + created_at: row.get(7)?, + }) + })?; + + for row in rows.flatten() { + repos.push(row); + } + + Ok(repos) + } + + /// Get index statistics. + pub fn get_index_stats(&self) -> Result { + let conn = self.acquire_lock()?; + + let repo_count: i64 = conn.query_row("SELECT COUNT(*) FROM indexed_repos", [], |row| row.get(0))?; + let file_count: i64 = conn.query_row("SELECT COUNT(*) FROM repo_files", [], |row| row.get(0))?; + let last_indexed: Option = conn.query_row( + "SELECT MAX(last_indexed_at) FROM indexed_repos", + [], + |row| row.get(0), + ).ok(); + + Ok(IndexStats { + repo_count: repo_count as usize, + file_count: file_count as usize, + last_indexed_at: last_indexed, + }) + } + + // ================================================================ + // Inference Tracking Methods + // ================================================================ + + /// Record an inference attempt. + pub fn record_inference_attempt( + &self, + issue_id: &str, + issue_source: &str, + extracted_filenames: &[String], + extracted_functions: &[String], + extracted_keywords: &[String], + inferred_repo_id: Option, + confidence: &str, + inference_reason: &str, + duration_ms: Option, + ) -> Result { + let conn = self.acquire_lock()?; + + let filenames_json = serde_json::to_string(extracted_filenames).unwrap_or_default(); + let functions_json = serde_json::to_string(extracted_functions).unwrap_or_default(); + let keywords_json = serde_json::to_string(extracted_keywords).unwrap_or_default(); + + conn.execute( + r#" + INSERT INTO inference_attempts ( + issue_id, issue_source, extracted_filenames, extracted_functions, + extracted_keywords, inferred_repo_id, confidence, inference_reason, + inference_duration_ms + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + "#, + params![ + issue_id, + issue_source, + filenames_json, + functions_json, + keywords_json, + inferred_repo_id, + confidence, + inference_reason, + duration_ms.map(|d| d as i64), + ], + )?; + + Ok(conn.last_insert_rowid()) + } + + /// Record feedback on an inference attempt (was it correct?). + pub fn record_inference_feedback( + &self, + inference_id: i64, + was_correct: bool, + actual_repo_id: Option, + feedback_source: &str, + ) -> Result<()> { + let conn = self.acquire_lock()?; + conn.execute( + r#" + UPDATE inference_attempts + SET was_correct = ?1, actual_repo_id = ?2, feedback_source = ?3, feedback_at = datetime('now') + WHERE id = ?4 + "#, + params![was_correct, actual_repo_id, feedback_source, inference_id], + )?; + Ok(()) + } + + /// Get inference statistics. + pub fn get_inference_stats(&self) -> Result { + let conn = self.acquire_lock()?; + + let total: i64 = conn.query_row("SELECT COUNT(*) FROM inference_attempts", [], |row| row.get(0))?; + let with_feedback: i64 = conn.query_row( + "SELECT COUNT(*) FROM inference_attempts WHERE was_correct IS NOT NULL", + [], + |row| row.get(0), + )?; + let correct: i64 = conn.query_row( + "SELECT COUNT(*) FROM inference_attempts WHERE was_correct = 1", + [], + |row| row.get(0), + )?; + + let high_confidence: i64 = conn.query_row( + "SELECT COUNT(*) FROM inference_attempts WHERE confidence = 'high'", + [], + |row| row.get(0), + )?; + let medium_confidence: i64 = conn.query_row( + "SELECT COUNT(*) FROM inference_attempts WHERE confidence = 'medium'", + [], + |row| row.get(0), + )?; + let low_confidence: i64 = conn.query_row( + "SELECT COUNT(*) FROM inference_attempts WHERE confidence = 'low'", + [], + |row| row.get(0), + )?; + let no_match: i64 = conn.query_row( + "SELECT COUNT(*) FROM inference_attempts WHERE inferred_repo_id IS NULL", + [], + |row| row.get(0), + )?; + + Ok(InferenceStats { + total_attempts: total as usize, + with_feedback: with_feedback as usize, + correct: correct as usize, + accuracy: if with_feedback > 0 { + (correct as f64 / with_feedback as f64) * 100.0 + } else { + 0.0 + }, + by_confidence: ConfidenceBreakdown { + high: high_confidence as usize, + medium: medium_confidence as usize, + low: low_confidence as usize, + none: no_match as usize, + }, + }) + } +} + +/// An indexed repository stored in the database. +#[derive(Debug, Clone)] +pub struct StoredIndexedRepo { + pub id: i64, + pub name: String, + pub path: String, + pub github_url: Option, + pub default_branch: String, + pub file_count: i64, + pub last_indexed_at: String, + pub created_at: String, +} + +/// Index statistics. +#[derive(Debug, Clone)] +pub struct IndexStats { + pub repo_count: usize, + pub file_count: usize, + pub last_indexed_at: Option, +} + +/// Inference statistics. +#[derive(Debug, Clone)] +pub struct InferenceStats { + pub total_attempts: usize, + pub with_feedback: usize, + pub correct: usize, + pub accuracy: f64, + pub by_confidence: ConfidenceBreakdown, +} + +/// Breakdown by confidence level. +#[derive(Debug, Clone)] +pub struct ConfidenceBreakdown { + pub high: usize, + pub medium: usize, + pub low: usize, + pub none: usize, +} + +/// A repository stored in the database. +#[derive(Debug, Clone)] +pub struct StoredRepository { + pub id: i64, + pub name: String, + pub path: Option, + pub github_url: String, + pub created_at: String, +} + +/// A dependency relationship stored in the database. +#[derive(Debug, Clone)] +pub struct StoredDependency { + pub id: i64, + pub upstream: String, + pub downstream: String, + pub dep_type: String, + pub created_at: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{Datelike, Timelike}; + + #[test] + fn test_record_and_retrieve_attempt() { + let tracker = SqliteTracker::in_memory().unwrap(); + + tracker.record_attempt("linear", "123", "PROJ-123").unwrap(); + + assert!(tracker.has_attempted("linear", "123")); + assert!(!tracker.has_attempted("linear", "456")); + assert!(!tracker.has_attempted("sentry", "123")); + + let attempt = tracker.get_attempt("linear", "123").unwrap().unwrap(); + assert_eq!(attempt.issue_id, "123"); + assert_eq!(attempt.short_id, "PROJ-123"); + assert_eq!(attempt.source, "linear"); + assert_eq!(attempt.status, FixAttemptStatus::Pending); + } + + #[test] + fn test_mark_success() { + let tracker = SqliteTracker::in_memory().unwrap(); + + tracker.record_attempt("linear", "123", "PROJ-123").unwrap(); + tracker + .mark_success("linear", "123", "https://github.com/org/repo/pull/42") + .unwrap(); + + let attempt = tracker.get_attempt("linear", "123").unwrap().unwrap(); + assert_eq!(attempt.status, FixAttemptStatus::Success); + assert_eq!( + attempt.pr_url, + Some("https://github.com/org/repo/pull/42".to_string()) + ); + // Check that GitHub info was extracted + assert_eq!(attempt.github_repo, Some("org/repo".to_string())); + assert_eq!(attempt.github_pr_number, Some(42)); + } + + #[test] + fn test_mark_merged() { + let tracker = SqliteTracker::in_memory().unwrap(); + + tracker.record_attempt("linear", "123", "PROJ-123").unwrap(); + tracker + .mark_success("linear", "123", "https://github.com/org/repo/pull/42") + .unwrap(); + tracker.mark_merged("linear", "123").unwrap(); + + let attempt = tracker.get_attempt("linear", "123").unwrap().unwrap(); + assert_eq!(attempt.status, FixAttemptStatus::Merged); + assert!(attempt.merged_at.is_some()); + } + + #[test] + fn test_mark_closed() { + let tracker = SqliteTracker::in_memory().unwrap(); + + tracker.record_attempt("linear", "123", "PROJ-123").unwrap(); + tracker + .mark_success("linear", "123", "https://github.com/org/repo/pull/42") + .unwrap(); + tracker.mark_closed("linear", "123").unwrap(); + + let attempt = tracker.get_attempt("linear", "123").unwrap().unwrap(); + assert_eq!(attempt.status, FixAttemptStatus::Closed); + } + + #[test] + fn test_get_pending_prs() { + let tracker = SqliteTracker::in_memory().unwrap(); + + // Create a successful attempt with a PR + tracker.record_attempt("linear", "123", "PROJ-123").unwrap(); + tracker + .mark_success("linear", "123", "https://github.com/org/repo/pull/42") + .unwrap(); + + // Create a merged attempt (should not be in pending PRs) + tracker.record_attempt("linear", "456", "PROJ-456").unwrap(); + tracker + .mark_success("linear", "456", "https://github.com/org/repo/pull/43") + .unwrap(); + tracker.mark_merged("linear", "456").unwrap(); + + let pending_prs = tracker.get_pending_prs().unwrap(); + assert_eq!(pending_prs.len(), 1); + assert_eq!(pending_prs[0].issue_id, "123"); + } + + #[test] + fn test_get_attempt_by_pr_url() { + let tracker = SqliteTracker::in_memory().unwrap(); + + tracker.record_attempt("linear", "123", "PROJ-123").unwrap(); + tracker + .mark_success("linear", "123", "https://github.com/org/repo/pull/42") + .unwrap(); + + let attempt = tracker + .get_attempt_by_pr_url("https://github.com/org/repo/pull/42") + .unwrap() + .unwrap(); + assert_eq!(attempt.issue_id, "123"); + assert_eq!(attempt.source, "linear"); + } + + #[test] + fn test_parse_pr_url() { + let (repo, pr) = + SqliteTracker::parse_pr_url("https://github.com/owner/repo/pull/123").unwrap(); + assert_eq!(repo, "owner/repo"); + assert_eq!(pr, 123); + + // Non-GitHub URL should return None + assert!( + SqliteTracker::parse_pr_url("https://gitlab.com/owner/repo/merge_requests/123") + .is_none() + ); + } + + #[test] + fn test_mark_failed() { + let tracker = SqliteTracker::in_memory().unwrap(); + + tracker.record_attempt("linear", "123", "PROJ-123").unwrap(); + tracker + .mark_failed("linear", "123", "Something went wrong") + .unwrap(); + + let attempt = tracker.get_attempt("linear", "123").unwrap().unwrap(); + assert_eq!(attempt.status, FixAttemptStatus::Failed); + assert_eq!( + attempt.error_message, + Some("Something went wrong".to_string()) + ); + } + + #[test] + fn test_reset_attempt() { + let tracker = SqliteTracker::in_memory().unwrap(); + + tracker.record_attempt("linear", "123", "PROJ-123").unwrap(); + assert!(tracker.has_attempted("linear", "123")); + + tracker.reset_attempt("linear", "123").unwrap(); + assert!(!tracker.has_attempted("linear", "123")); + } + + #[test] + fn test_get_attempted_issue_ids() { + let tracker = SqliteTracker::in_memory().unwrap(); + + tracker.record_attempt("linear", "123", "PROJ-123").unwrap(); + tracker.record_attempt("linear", "456", "PROJ-456").unwrap(); + tracker + .record_attempt("sentry", "789", "SENTRY-789") + .unwrap(); + + let linear_ids = tracker.get_attempted_issue_ids("linear"); + assert_eq!(linear_ids.len(), 2); + assert!(linear_ids.contains("123")); + assert!(linear_ids.contains("456")); + + let sentry_ids = tracker.get_attempted_issue_ids("sentry"); + assert_eq!(sentry_ids.len(), 1); + assert!(sentry_ids.contains("789")); + } + + #[test] + fn test_get_attempts_by_status() { + let tracker = SqliteTracker::in_memory().unwrap(); + + tracker.record_attempt("linear", "123", "PROJ-123").unwrap(); + tracker.record_attempt("linear", "456", "PROJ-456").unwrap(); + tracker + .mark_success("linear", "123", "https://example.com/pr/1") + .unwrap(); + + let pending = tracker + .get_attempts_by_status(FixAttemptStatus::Pending) + .unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].issue_id, "456"); + + let success = tracker + .get_attempts_by_status(FixAttemptStatus::Success) + .unwrap(); + assert_eq!(success.len(), 1); + assert_eq!(success[0].issue_id, "123"); + } + + #[test] + fn test_get_stats() { + let tracker = SqliteTracker::in_memory().unwrap(); + + tracker.record_attempt("linear", "1", "PROJ-1").unwrap(); + tracker.record_attempt("linear", "2", "PROJ-2").unwrap(); + tracker.record_attempt("sentry", "3", "SENTRY-3").unwrap(); + + tracker + .mark_success("linear", "1", "https://example.com/pr/1") + .unwrap(); + tracker.mark_failed("linear", "2", "Error").unwrap(); + + let stats = tracker.get_stats().unwrap(); + assert_eq!(stats.total, 3); + assert_eq!(stats.pending, 1); + assert_eq!(stats.success, 1); + assert_eq!(stats.failed, 1); + + let linear_stats = stats.by_source.get("linear").unwrap(); + assert_eq!(linear_stats.total, 2); + assert_eq!(linear_stats.success, 1); + assert_eq!(linear_stats.failed, 1); + + let sentry_stats = stats.by_source.get("sentry").unwrap(); + assert_eq!(sentry_stats.total, 1); + } + + #[test] + fn test_mark_resolved() { + let tracker = SqliteTracker::in_memory().unwrap(); + + tracker.record_attempt("linear", "123", "PROJ-123").unwrap(); + tracker + .mark_success("linear", "123", "https://github.com/org/repo/pull/42") + .unwrap(); + tracker.mark_resolved("linear", "123").unwrap(); + + let attempt = tracker.get_attempt("linear", "123").unwrap().unwrap(); + assert!(attempt.resolved_at.is_some()); + } + + #[test] + fn test_increment_retry() { + let tracker = SqliteTracker::in_memory().unwrap(); + + tracker.record_attempt("linear", "123", "PROJ-123").unwrap(); + tracker.mark_failed("linear", "123", "Error").unwrap(); + + let attempt = tracker.get_attempt("linear", "123").unwrap().unwrap(); + assert_eq!(attempt.retry_count, 0); + + tracker.increment_retry("linear", "123").unwrap(); + let attempt = tracker.get_attempt("linear", "123").unwrap().unwrap(); + assert_eq!(attempt.retry_count, 1); + assert!(attempt.last_retry_at.is_some()); + + tracker.increment_retry("linear", "123").unwrap(); + let attempt = tracker.get_attempt("linear", "123").unwrap().unwrap(); + assert_eq!(attempt.retry_count, 2); + } + + #[test] + fn test_mark_cannot_fix() { + let tracker = SqliteTracker::in_memory().unwrap(); + + tracker.record_attempt("linear", "123", "PROJ-123").unwrap(); + tracker + .mark_cannot_fix("linear", "123", "Max retries exceeded") + .unwrap(); + + let attempt = tracker.get_attempt("linear", "123").unwrap().unwrap(); + assert_eq!(attempt.status, FixAttemptStatus::CannotFix); + assert_eq!( + attempt.error_message, + Some("Max retries exceeded".to_string()) + ); + } + + #[test] + fn test_get_retryable_issues() { + let tracker = SqliteTracker::in_memory().unwrap(); + + // Failed with 0 retries - retryable + tracker.record_attempt("linear", "1", "PROJ-1").unwrap(); + tracker.mark_failed("linear", "1", "Error").unwrap(); + + // Failed with 2 retries - still retryable if max is 3 + tracker.record_attempt("linear", "2", "PROJ-2").unwrap(); + tracker.mark_failed("linear", "2", "Error").unwrap(); + tracker.increment_retry("linear", "2").unwrap(); + tracker.increment_retry("linear", "2").unwrap(); + + // Closed - retryable + tracker.record_attempt("linear", "3", "PROJ-3").unwrap(); + tracker + .mark_success("linear", "3", "https://github.com/org/repo/pull/1") + .unwrap(); + tracker.mark_closed("linear", "3").unwrap(); + + // Success - not retryable + tracker.record_attempt("linear", "4", "PROJ-4").unwrap(); + tracker + .mark_success("linear", "4", "https://github.com/org/repo/pull/2") + .unwrap(); + + let retryable = tracker.get_retryable_issues(3).unwrap(); + assert_eq!(retryable.len(), 3); + + // With max 2 retries, issue 2 should be excluded + let retryable = tracker.get_retryable_issues(2).unwrap(); + assert_eq!(retryable.len(), 2); + } + + #[test] + fn test_prepare_for_retry() { + let tracker = SqliteTracker::in_memory().unwrap(); + + tracker.record_attempt("linear", "123", "PROJ-123").unwrap(); + tracker + .mark_success("linear", "123", "https://github.com/org/repo/pull/42") + .unwrap(); + tracker.mark_closed("linear", "123").unwrap(); + + // Prepare for retry should reset to pending + tracker.prepare_for_retry("linear", "123").unwrap(); + + let attempt = tracker.get_attempt("linear", "123").unwrap().unwrap(); + assert_eq!(attempt.status, FixAttemptStatus::Pending); + assert!(attempt.pr_url.is_none()); + assert!(attempt.github_repo.is_none()); + assert!(attempt.github_pr_number.is_none()); + assert!(attempt.error_message.is_none()); + } + + #[test] + fn test_parse_pr_url_various_formats() { + // Standard format + let (repo, pr) = + SqliteTracker::parse_pr_url("https://github.com/owner/repo/pull/123").unwrap(); + assert_eq!(repo, "owner/repo"); + assert_eq!(pr, 123); + + // With trailing slash + let (repo, pr) = SqliteTracker::parse_pr_url("https://github.com/owner/repo/pull/456/") + .unwrap_or(("".into(), 0)); + // Regex doesn't match trailing slash, this is expected behavior + assert_eq!(repo, "owner/repo"); + assert_eq!(pr, 456); + + // HTTP instead of HTTPS (should still work as regex doesn't require https) + let result = SqliteTracker::parse_pr_url("http://github.com/owner/repo/pull/789"); + assert!(result.is_some()); + + // Invalid URL + assert!(SqliteTracker::parse_pr_url("not a url").is_none()); + + // Empty string + assert!(SqliteTracker::parse_pr_url("").is_none()); + } + + #[test] + fn test_get_attempt_not_found() { + let tracker = SqliteTracker::in_memory().unwrap(); + + let attempt = tracker.get_attempt("linear", "nonexistent").unwrap(); + assert!(attempt.is_none()); + } + + #[test] + fn test_get_attempt_by_pr_url_not_found() { + let tracker = SqliteTracker::in_memory().unwrap(); + + let attempt = tracker + .get_attempt_by_pr_url("https://github.com/org/repo/pull/999") + .unwrap(); + assert!(attempt.is_none()); + } + + #[test] + fn test_get_attempts_by_status_empty() { + let tracker = SqliteTracker::in_memory().unwrap(); + + let attempts = tracker + .get_attempts_by_status(FixAttemptStatus::Merged) + .unwrap(); + assert!(attempts.is_empty()); + } + + #[test] + fn test_stats_empty_database() { + let tracker = SqliteTracker::in_memory().unwrap(); + + let stats = tracker.get_stats().unwrap(); + assert_eq!(stats.total, 0); + assert_eq!(stats.pending, 0); + assert_eq!(stats.success, 0); + assert_eq!(stats.failed, 0); + assert!(stats.by_source.is_empty()); + } + + #[test] + fn test_stats_all_statuses() { + let tracker = SqliteTracker::in_memory().unwrap(); + + // Pending + tracker.record_attempt("linear", "1", "PROJ-1").unwrap(); + + // Success + tracker.record_attempt("linear", "2", "PROJ-2").unwrap(); + tracker + .mark_success("linear", "2", "https://github.com/org/repo/pull/1") + .unwrap(); + + // Failed + tracker.record_attempt("linear", "3", "PROJ-3").unwrap(); + tracker.mark_failed("linear", "3", "Error").unwrap(); + + // Merged + tracker.record_attempt("linear", "4", "PROJ-4").unwrap(); + tracker + .mark_success("linear", "4", "https://github.com/org/repo/pull/2") + .unwrap(); + tracker.mark_merged("linear", "4").unwrap(); + + // Closed + tracker.record_attempt("linear", "5", "PROJ-5").unwrap(); + tracker + .mark_success("linear", "5", "https://github.com/org/repo/pull/3") + .unwrap(); + tracker.mark_closed("linear", "5").unwrap(); + + // Cannot fix + tracker.record_attempt("linear", "6", "PROJ-6").unwrap(); + tracker + .mark_cannot_fix("linear", "6", "Max retries") + .unwrap(); + + let stats = tracker.get_stats().unwrap(); + assert_eq!(stats.total, 6); + assert_eq!(stats.pending, 1); + assert_eq!(stats.success, 1); + assert_eq!(stats.failed, 1); + assert_eq!(stats.merged, 1); + assert_eq!(stats.closed, 1); + assert_eq!(stats.cannot_fix, 1); + } + + #[test] + fn test_record_attempt_upsert_preserves_data() { + let tracker = SqliteTracker::in_memory().unwrap(); + + // Record initial attempt + tracker.record_attempt("linear", "123", "PROJ-123").unwrap(); + tracker + .mark_success("linear", "123", "https://github.com/org/repo/pull/1") + .unwrap(); + + // Record again - using ON CONFLICT DO UPDATE, this should only update + // short_id and attempted_at, preserving status and pr_url + tracker + .record_attempt("linear", "123", "PROJ-123-v2") + .unwrap(); + + let attempt = tracker.get_attempt("linear", "123").unwrap().unwrap(); + // short_id should be updated + assert_eq!(attempt.short_id, "PROJ-123-v2"); + // status and pr_url should be preserved (not reset) + assert_eq!(attempt.status, FixAttemptStatus::Success); + assert_eq!( + attempt.pr_url, + Some("https://github.com/org/repo/pull/1".to_string()) + ); + } + + #[test] + fn test_multiple_sources_isolation() { + let tracker = SqliteTracker::in_memory().unwrap(); + + // Same issue_id in different sources + tracker + .record_attempt("linear", "123", "LINEAR-123") + .unwrap(); + tracker + .record_attempt("sentry", "123", "SENTRY-123") + .unwrap(); + + assert!(tracker.has_attempted("linear", "123")); + assert!(tracker.has_attempted("sentry", "123")); + + tracker + .mark_success("linear", "123", "https://github.com/org/repo/pull/1") + .unwrap(); + + let linear_attempt = tracker.get_attempt("linear", "123").unwrap().unwrap(); + let sentry_attempt = tracker.get_attempt("sentry", "123").unwrap().unwrap(); + + assert_eq!(linear_attempt.status, FixAttemptStatus::Success); + assert_eq!(sentry_attempt.status, FixAttemptStatus::Pending); + } + + #[test] + fn test_parse_datetime_rfc3339() { + let dt = SqliteTracker::parse_datetime("2024-01-15T10:30:00Z"); + assert_eq!(dt.year(), 2024); + assert_eq!(dt.month(), 1); + assert_eq!(dt.day(), 15); + assert_eq!(dt.hour(), 10); + assert_eq!(dt.minute(), 30); + } + + #[test] + fn test_parse_datetime_sqlite_format() { + let dt = SqliteTracker::parse_datetime("2024-01-15 10:30:00"); + assert_eq!(dt.year(), 2024); + assert_eq!(dt.month(), 1); + assert_eq!(dt.day(), 15); + } + + #[test] + fn test_parse_datetime_invalid() { + // Should return current time for invalid format + let dt = SqliteTracker::parse_datetime("not a date"); + // Just verify it doesn't panic and returns a valid datetime + assert!(dt.year() >= 2024); + } + + #[test] + fn test_parse_optional_datetime() { + let none_result = SqliteTracker::parse_optional_datetime(None); + assert!(none_result.is_none()); + + let some_result = + SqliteTracker::parse_optional_datetime(Some("2024-01-15T10:30:00Z".to_string())); + assert!(some_result.is_some()); + } + + #[test] + fn test_get_pending_prs_empty() { + let tracker = SqliteTracker::in_memory().unwrap(); + let pending_prs = tracker.get_pending_prs().unwrap(); + assert!(pending_prs.is_empty()); + } + + #[test] + fn test_get_pending_prs_no_github_info() { + let tracker = SqliteTracker::in_memory().unwrap(); + + // Create attempt with non-GitHub PR URL + tracker.record_attempt("linear", "123", "PROJ-123").unwrap(); + tracker + .mark_success( + "linear", + "123", + "https://gitlab.com/org/repo/-/merge_requests/42", + ) + .unwrap(); + + // Should not be included because github_repo is None + let pending_prs = tracker.get_pending_prs().unwrap(); + assert!(pending_prs.is_empty()); + } +} diff --git a/src/storage/vectorlite.rs b/src/storage/vectorlite.rs new file mode 100644 index 00000000..a4b73726 --- /dev/null +++ b/src/storage/vectorlite.rs @@ -0,0 +1,260 @@ +//! Vectorlite integration for HNSW-based similarity search. +//! +//! Uses the vectorlite SQLite extension for efficient approximate nearest neighbor search. + +use crate::error::{Error, Result}; +use rusqlite::{params, Connection}; +use std::path::Path; + +/// Configuration for the vector store. +#[derive(Debug, Clone)] +pub struct VectorStoreConfig { + /// Embedding dimension (768 for nomic-embed-text). + pub dimension: usize, + /// HNSW max elements (capacity). + pub max_elements: usize, + /// HNSW ef_construction parameter (higher = better quality, slower build). + pub ef_construction: usize, + /// HNSW M parameter (connections per node). + pub m: usize, + /// Distance type: "l2", "cosine", or "ip". + pub distance_type: String, +} + +impl Default for VectorStoreConfig { + fn default() -> Self { + Self { + dimension: 768, // nomic-embed-text + max_elements: 10000, + ef_construction: 200, + m: 16, + distance_type: "cosine".to_string(), + } + } +} + +/// Load the vectorlite extension into a SQLite connection. +/// +/// # Safety +/// This enables extension loading which can execute arbitrary code. +/// Only load trusted extensions. +pub fn load_vectorlite_extension(conn: &Connection, extension_path: &Path) -> Result<()> { + // Enable extension loading + unsafe { + conn.load_extension_enable()?; + } + + // Load vectorlite + // SAFETY: We only load the vectorlite extension from trusted paths + unsafe { + conn.load_extension(extension_path, None) + .map_err(|e| Error::Other(format!("Failed to load vectorlite extension: {}", e)))?; + } + + // Disable extension loading for safety + conn.load_extension_disable()?; + + Ok(()) +} + +/// Try to load vectorlite from common paths. +pub fn try_load_vectorlite(conn: &Connection) -> Result { + let common_paths = [ + // Docker/Linux paths + "/usr/local/lib/vectorlite.so", + "/usr/lib/vectorlite.so", + "/app/lib/vectorlite.so", + // macOS paths + "/usr/local/lib/vectorlite.dylib", + "/opt/homebrew/lib/vectorlite.dylib", + // Local development + "./vectorlite.so", + "./vectorlite.dylib", + ]; + + for path in common_paths { + let path = Path::new(path); + if path.exists() { + match load_vectorlite_extension(conn, path) { + Ok(()) => { + tracing::info!("Loaded vectorlite from {:?}", path); + return Ok(true); + } + Err(e) => { + tracing::warn!("Failed to load vectorlite from {:?}: {}", path, e); + } + } + } + } + + // Also try from VECTORLITE_PATH env var + if let Ok(path) = std::env::var("VECTORLITE_PATH") { + let path = Path::new(&path); + if path.exists() { + load_vectorlite_extension(conn, path)?; + tracing::info!("Loaded vectorlite from VECTORLITE_PATH: {:?}", path); + return Ok(true); + } + } + + tracing::warn!("Vectorlite extension not found, vector search will be disabled"); + Ok(false) +} + +/// Create the vector virtual table for outcome embeddings. +pub fn create_vector_table(conn: &Connection, config: &VectorStoreConfig) -> Result<()> { + let sql = format!( + r#" + CREATE VIRTUAL TABLE IF NOT EXISTS outcome_embeddings USING vectorlite( + embedding float32[{dimension}] {distance_type}, + hnsw(max_elements={max_elements}, ef_construction={ef_construction}, M={m}) + ) + "#, + dimension = config.dimension, + distance_type = config.distance_type, + max_elements = config.max_elements, + ef_construction = config.ef_construction, + m = config.m, + ); + + conn.execute_batch(&sql)?; + Ok(()) +} + +/// Insert an embedding into the vector store. +pub fn insert_embedding(conn: &Connection, rowid: i64, embedding: &[f32]) -> Result<()> { + let blob = embedding_to_blob(embedding); + conn.execute( + "INSERT INTO outcome_embeddings(rowid, embedding) VALUES (?, ?)", + params![rowid, blob], + )?; + Ok(()) +} + +/// Update an embedding in the vector store. +pub fn update_embedding(conn: &Connection, rowid: i64, embedding: &[f32]) -> Result<()> { + // Vectorlite doesn't support UPDATE, so delete and reinsert + conn.execute( + "DELETE FROM outcome_embeddings WHERE rowid = ?", + params![rowid], + )?; + insert_embedding(conn, rowid, embedding) +} + +/// Delete an embedding from the vector store. +pub fn delete_embedding(conn: &Connection, rowid: i64) -> Result<()> { + conn.execute( + "DELETE FROM outcome_embeddings WHERE rowid = ?", + params![rowid], + )?; + Ok(()) +} + +/// Search for similar embeddings using HNSW. +/// +/// Returns (rowid, distance) pairs sorted by distance. +pub fn search_similar( + conn: &Connection, + query: &[f32], + k: usize, + ef: usize, +) -> Result> { + let blob = embedding_to_blob(query); + + let mut stmt = conn.prepare( + "SELECT rowid, distance FROM outcome_embeddings WHERE knn_search(embedding, knn_param(?, ?, ?))" + )?; + + let results = stmt + .query_map(params![blob, k as i64, ef as i64], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, f32>(1)?)) + })? + .filter_map(|r| r.ok()) + .collect(); + + Ok(results) +} + +/// Search for similar embeddings with rowid filtering. +pub fn search_similar_filtered( + conn: &Connection, + query: &[f32], + k: usize, + ef: usize, + rowid_filter: &[i64], +) -> Result> { + if rowid_filter.is_empty() { + return search_similar(conn, query, k, ef); + } + + let blob = embedding_to_blob(query); + let placeholders: Vec = rowid_filter.iter().map(|_| "?".to_string()).collect(); + let filter_sql = placeholders.join(", "); + + let sql = format!( + "SELECT rowid, distance FROM outcome_embeddings WHERE knn_search(embedding, knn_param(?, ?, ?)) AND rowid IN ({})", + filter_sql + ); + + let mut stmt = conn.prepare(&sql)?; + + // Build params: blob, k, ef, then all rowids + let mut params: Vec> = + vec![Box::new(blob), Box::new(k as i64), Box::new(ef as i64)]; + for id in rowid_filter { + params.push(Box::new(*id)); + } + + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let results = stmt + .query_map(param_refs.as_slice(), |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, f32>(1)?)) + })? + .filter_map(|r| r.ok()) + .collect(); + + Ok(results) +} + +/// Convert f32 slice to blob for SQLite storage. +fn embedding_to_blob(embedding: &[f32]) -> Vec { + embedding.iter().flat_map(|f| f.to_le_bytes()).collect() +} + +/// Convert blob back to f32 vector. +#[allow(dead_code)] +fn blob_to_embedding(blob: &[u8]) -> Vec { + blob.chunks_exact(4) + .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])) + .collect() +} + +/// Check if vectorlite is available in this connection. +pub fn is_vectorlite_available(conn: &Connection) -> bool { + conn.execute_batch("SELECT vectorlite_info()").is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_embedding_to_blob_roundtrip() { + let embedding = vec![1.0f32, 2.5, -3.7, 0.0, 100.123]; + let blob = embedding_to_blob(&embedding); + let restored = blob_to_embedding(&blob); + + assert_eq!(embedding.len(), restored.len()); + for (a, b) in embedding.iter().zip(restored.iter()) { + assert!((a - b).abs() < 1e-6); + } + } + + #[test] + fn test_vector_store_config_default() { + let config = VectorStoreConfig::default(); + assert_eq!(config.dimension, 768); + assert_eq!(config.distance_type, "cosine"); + } +} diff --git a/src/templates/loader.rs b/src/templates/loader.rs new file mode 100644 index 00000000..bb338582 --- /dev/null +++ b/src/templates/loader.rs @@ -0,0 +1,323 @@ +//! Template loading from files and database. + +use crate::error::Result; +use crate::types::Issue; +use std::path::{Path, PathBuf}; + +/// Loads templates from various sources. +pub struct TemplateLoader { + project_dir: PathBuf, + agent_md_cache: std::sync::RwLock>>, +} + +impl TemplateLoader { + /// Create a new template loader. + pub fn new(project_dir: impl Into) -> Self { + Self { + project_dir: project_dir.into(), + agent_md_cache: std::sync::RwLock::new(None), + } + } + + /// Check if the project has an AGENT.md file. + pub fn has_agent_md(&self) -> bool { + self.get_agent_md_path().exists() + } + + /// Get the path to AGENT.md. + fn get_agent_md_path(&self) -> PathBuf { + self.project_dir.join("AGENT.md") + } + + /// Load AGENT.md content if it exists. + pub fn load_agent_md(&self) -> Option { + // Check cache first + { + let cache = self.agent_md_cache.read().unwrap(); + if let Some(ref cached) = *cache { + return cached.clone(); + } + } + + // Load from file + let result = self.load_agent_md_uncached(); + + // Cache the result + { + let mut cache = self.agent_md_cache.write().unwrap(); + *cache = Some(result.clone()); + } + + result + } + + fn load_agent_md_uncached(&self) -> Option { + let path = self.get_agent_md_path(); + if path.exists() { + match std::fs::read_to_string(&path) { + Ok(content) => { + tracing::debug!(component = "templates", path = ?path, "Loaded AGENT.md"); + Some(content) + } + Err(e) => { + tracing::warn!(component = "templates", error = %e, "Failed to read AGENT.md"); + None + } + } + } else { + tracing::debug!(component = "templates", path = ?path, "No AGENT.md found"); + None + } + } + + /// Clear the AGENT.md cache (useful for testing or hot-reload). + pub fn clear_cache(&self) { + let mut cache = self.agent_md_cache.write().unwrap(); + *cache = None; + } + + /// Get the appropriate template for an issue. + /// Priority: + /// 1. AGENT.md content (prepended to default template) + /// 2. Database template (future) + /// 3. Default template based on source + pub fn get_template(&self, issue: &Issue) -> Result { + let agent_md = self.load_agent_md(); + let base_template = self.get_default_template(&issue.source); + + Ok(if let Some(ref md) = agent_md { + format!("{}\n\n---\n\n{}", md.trim(), base_template) + } else { + base_template.to_string() + }) + } + + /// Get the default template for a source type. + fn get_default_template(&self, source: &str) -> &'static str { + match source.to_lowercase().as_str() { + "linear" => super::DEFAULT_LINEAR_TEMPLATE, + "sentry" => super::DEFAULT_SENTRY_TEMPLATE, + _ => super::DEFAULT_FIX_TEMPLATE, + } + } + + /// Check if a custom template file exists at a path. + pub fn template_exists_at(&self, path: impl AsRef) -> bool { + path.as_ref().exists() + } + + /// Load a template from a specific file path. + pub fn load_from_file(&self, path: impl AsRef) -> Result { + let content = std::fs::read_to_string(path.as_ref()) + .map_err(|e| crate::error::Error::config(format!("Failed to load template: {}", e)))?; + Ok(content) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn create_test_loader() -> (TemplateLoader, TempDir) { + let temp_dir = TempDir::new().unwrap(); + let loader = TemplateLoader::new(temp_dir.path()); + (loader, temp_dir) + } + + #[test] + fn test_has_agent_md_false() { + let (loader, _temp) = create_test_loader(); + assert!(!loader.has_agent_md()); + } + + #[test] + fn test_has_agent_md_true() { + let (loader, temp) = create_test_loader(); + std::fs::write(temp.path().join("AGENT.md"), "# Agent Instructions").unwrap(); + assert!(loader.has_agent_md()); + } + + #[test] + fn test_load_agent_md() { + let (loader, temp) = create_test_loader(); + std::fs::write(temp.path().join("AGENT.md"), "Custom instructions").unwrap(); + + let content = loader.load_agent_md(); + assert_eq!(content, Some("Custom instructions".to_string())); + } + + #[test] + fn test_load_agent_md_cached() { + let (loader, temp) = create_test_loader(); + std::fs::write(temp.path().join("AGENT.md"), "Original").unwrap(); + + // First load + let content1 = loader.load_agent_md(); + assert_eq!(content1, Some("Original".to_string())); + + // Modify file + std::fs::write(temp.path().join("AGENT.md"), "Modified").unwrap(); + + // Should still return cached + let content2 = loader.load_agent_md(); + assert_eq!(content2, Some("Original".to_string())); + + // Clear cache and reload + loader.clear_cache(); + let content3 = loader.load_agent_md(); + assert_eq!(content3, Some("Modified".to_string())); + } + + #[test] + fn test_get_template_with_agent_md() { + let (loader, temp) = create_test_loader(); + std::fs::write(temp.path().join("AGENT.md"), "# Custom Agent Rules").unwrap(); + + let issue = Issue::new( + "123", + "PROJ-123", + "Fix bug", + "https://example.com", + "linear", + ); + let template = loader.get_template(&issue).unwrap(); + + assert!(template.contains("# Custom Agent Rules")); + assert!(template.contains("---")); // Separator + } + + #[test] + fn test_get_template_without_agent_md() { + let (loader, _temp) = create_test_loader(); + + let issue = Issue::new( + "123", + "PROJ-123", + "Fix bug", + "https://example.com", + "linear", + ); + let template = loader.get_template(&issue).unwrap(); + + // Should use default template + assert!(template.contains("Linear issue")); + } + + #[test] + fn test_get_default_template_linear() { + let (loader, _temp) = create_test_loader(); + let template = loader.get_default_template("linear"); + assert!(template.contains("Linear")); + } + + #[test] + fn test_get_default_template_sentry() { + let (loader, _temp) = create_test_loader(); + let template = loader.get_default_template("sentry"); + assert!(template.contains("Sentry")); + } + + #[test] + fn test_get_default_template_github() { + let (loader, _temp) = create_test_loader(); + let template = loader.get_default_template("github"); + // GitHub might use a generic template + assert!(!template.is_empty()); + } + + #[test] + fn test_get_default_template_unknown() { + let (loader, _temp) = create_test_loader(); + let template = loader.get_default_template("unknown"); + // Should return generic template + assert!(!template.is_empty()); + } + + #[test] + fn test_load_agent_md_empty_file() { + let (loader, temp) = create_test_loader(); + std::fs::write(temp.path().join("AGENT.md"), "").unwrap(); + + let content = loader.load_agent_md(); + assert_eq!(content, Some("".to_string())); + } + + #[test] + fn test_clear_cache() { + let (loader, _temp) = create_test_loader(); + + // Access something to populate cache (internally) + let _ = loader.load_agent_md(); + + // Clear should not panic + loader.clear_cache(); + } + + #[test] + fn test_get_template_for_sentry_issue() { + let (loader, _temp) = create_test_loader(); + + let issue = Issue::new( + "456", + "SENTRY-456", + "TypeError", + "https://sentry.io/123", + "sentry", + ); + let template = loader.get_template(&issue).unwrap(); + + assert!(template.contains("Sentry")); + } + + #[test] + fn test_get_template_for_github_issue() { + let (loader, _temp) = create_test_loader(); + + let issue = Issue::new( + "789", + "#789", + "Bug fix", + "https://github.com/org/repo/issues/789", + "github", + ); + let template = loader.get_template(&issue).unwrap(); + + // GitHub uses a generic template + assert!(!template.is_empty()); + } + + #[test] + fn test_custom_template_directory_creation() { + let (loader, temp) = create_test_loader(); + let template_dir = temp.path().join(".claudear"); + std::fs::create_dir_all(&template_dir).unwrap(); + + // Verify directory was created + assert!(template_dir.exists()); + + let issue = Issue::new("123", "LIN-123", "Test", "https://linear.app", "linear"); + // Template should still load (falls back to default) + let template = loader.get_template(&issue).unwrap(); + assert!(!template.is_empty()); + } + + #[test] + fn test_template_context_provided() { + let (loader, _temp) = create_test_loader(); + + let issue = Issue::new( + "123", + "PROJ-123", + "Fix something", + "https://example.com", + "linear", + ); + let template = loader.get_template(&issue).unwrap(); + + // Default template should mention the issue + assert!( + template.contains("{{") || template.contains("PROJ-123") || template.contains("Linear") + ); + } +} diff --git a/src/templates/mod.rs b/src/templates/mod.rs new file mode 100644 index 00000000..c7b8cb1e --- /dev/null +++ b/src/templates/mod.rs @@ -0,0 +1,75 @@ +//! Custom prompt templates with AGENT.md support. +//! +//! This module provides template loading and rendering for Claude prompts. +//! Templates can come from: +//! 1. The project's AGENT.md file (highest priority) +//! 2. Database-stored templates +//! 3. Built-in default templates + +mod loader; +mod renderer; + +pub use loader::TemplateLoader; +pub use renderer::{TemplateContext, TemplateRenderer}; + +/// Default template for issue fixing when no custom template is found. +pub const DEFAULT_FIX_TEMPLATE: &str = r#"You are fixing an issue from {{source}}. Here is the issue context: + +{{context}} + +Your task: +1. Analyze the issue/error and any stack traces +2. Find the relevant code in this codebase +3. Implement a fix for the issue +4. Write or update tests if applicable +5. Create a PR with your changes + +The PR title should include the issue ID: {{short_id}} + +After creating the PR, output the PR URL on a line by itself starting with "PR_URL: ". +"#; + +/// Default template for Linear issues (uses /issue skill). +pub const DEFAULT_LINEAR_TEMPLATE: &str = r#"{{#if has_agent_md}} +{{agent_md}} + +--- +{{/if}} +Fix the following Linear issue: + +Issue: {{short_id}} - {{title}} +URL: {{url}} +Priority: {{priority}} + +{{#if description}} +Description: +{{description}} +{{/if}} + +{{context}} + +Create a PR that fixes this issue. Include "{{short_id}}" in the PR title. +After creating the PR, output the PR URL on a line by itself starting with "PR_URL: ". +"#; + +/// Default template for Sentry errors. +pub const DEFAULT_SENTRY_TEMPLATE: &str = r#"{{#if has_agent_md}} +{{agent_md}} + +--- +{{/if}} +Fix the following error from Sentry: + +Error: {{title}} +URL: {{url}} +Event count: {{event_count}} + +{{context}} + +Analyze the stack trace and error context to identify the root cause. +Implement a fix that prevents this error from occurring. +Write tests to verify the fix if applicable. + +Create a PR that fixes this error. +After creating the PR, output the PR URL on a line by itself starting with "PR_URL: ". +"#; diff --git a/src/templates/renderer.rs b/src/templates/renderer.rs new file mode 100644 index 00000000..e0c8b99a --- /dev/null +++ b/src/templates/renderer.rs @@ -0,0 +1,704 @@ +//! Template rendering with variable substitution. + +use crate::types::Issue; +use serde_json::Value as JsonValue; +use std::collections::HashMap; + +/// Context for template rendering. +#[derive(Debug, Clone)] +pub struct TemplateContext { + /// The issue being fixed. + pub issue: Issue, + /// Additional context from the source (stack traces, etc.). + pub source_context: String, + /// Content from AGENT.md if present. + pub agent_md: Option, + /// Additional custom variables. + pub variables: HashMap, + /// Array variables for loop support. + pub arrays: HashMap>>, +} + +impl TemplateContext { + /// Create a new template context for an issue. + pub fn new(issue: Issue, source_context: String) -> Self { + Self { + issue, + source_context, + agent_md: None, + variables: HashMap::new(), + arrays: HashMap::new(), + } + } + + /// Set the AGENT.md content. + pub fn with_agent_md(mut self, content: Option) -> Self { + self.agent_md = content; + self + } + + /// Add a custom variable. + pub fn with_variable(mut self, key: impl Into, value: impl Into) -> Self { + self.variables.insert(key.into(), value.into()); + self + } + + /// Add an array variable for loop iteration. + pub fn with_array( + mut self, + key: impl Into, + items: Vec>, + ) -> Self { + self.arrays.insert(key.into(), items); + self + } + + /// Add an array from JSON value. + pub fn with_json_array(mut self, key: impl Into, value: &JsonValue) -> Self { + if let Some(arr) = value.as_array() { + let items: Vec> = arr + .iter() + .filter_map(|item| { + if let Some(obj) = item.as_object() { + let map: HashMap = obj + .iter() + .map(|(k, v)| { + let value_str = match v { + JsonValue::String(s) => s.clone(), + JsonValue::Number(n) => n.to_string(), + JsonValue::Bool(b) => b.to_string(), + _ => v.to_string(), + }; + (k.clone(), value_str) + }) + .collect(); + Some(map) + } else if let Some(s) = item.as_str() { + // Simple string array - use "item" as the key + let mut map = HashMap::new(); + map.insert("item".to_string(), s.to_string()); + Some(map) + } else { + None + } + }) + .collect(); + self.arrays.insert(key.into(), items); + } + self + } +} + +/// Renders templates with variable substitution. +pub struct TemplateRenderer; + +impl TemplateRenderer { + /// Create a new template renderer. + pub fn new() -> Self { + Self + } + + /// Render a template with the given context. + /// Uses simple {{variable}} substitution. + pub fn render(&self, template: &str, context: &TemplateContext) -> String { + let mut result = template.to_string(); + + // Process loops first (before variable substitution) + result = self.process_loops(&result, context); + + // Issue fields + result = result.replace("{{id}}", &context.issue.id); + result = result.replace("{{short_id}}", &context.issue.short_id); + result = result.replace("{{title}}", &context.issue.title); + result = result.replace("{{url}}", &context.issue.url); + result = result.replace("{{source}}", &context.issue.source); + result = result.replace("{{priority}}", &context.issue.priority.to_string()); + result = result.replace("{{status}}", &context.issue.status.to_string()); + + // Description with fallback + let description = context.issue.description.as_deref().unwrap_or(""); + result = result.replace("{{description}}", description); + + // Source context + result = result.replace("{{context}}", &context.source_context); + + // AGENT.md content + let has_agent_md = context.agent_md.is_some(); + result = result.replace( + "{{has_agent_md}}", + if has_agent_md { "true" } else { "false" }, + ); + result = result.replace("{{agent_md}}", context.agent_md.as_deref().unwrap_or("")); + + // Metadata fields + if let Some(event_count) = context.issue.get_metadata::("event_count") { + result = result.replace("{{event_count}}", &event_count.to_string()); + } else { + result = result.replace("{{event_count}}", "N/A"); + } + + // Custom variables + for (key, value) in &context.variables { + result = result.replace(&format!("{{{{{}}}}}", key), value); + } + + // Handle simple conditionals: {{#if field}}content{{/if}} + result = self.process_conditionals(&result, context); + + result + } + + /// Process {{#each array}}...{{/each}} loops. + fn process_loops(&self, template: &str, context: &TemplateContext) -> String { + let mut result = template.to_string(); + + // Process {{#each array}}...{{/each}} blocks + // Supports accessing item properties via {{property}} or {{this.property}} + // For simple arrays, use {{item}} or {{this}} + let each_regex = + regex_lite::Regex::new(r"\{\{#each\s+(\w+)\}\}([\s\S]*?)\{\{/each\}\}").unwrap(); + + result = each_regex + .replace_all(&result, |caps: ®ex_lite::Captures| { + let array_name = &caps[1]; + let loop_body = &caps[2]; + + if let Some(items) = context.arrays.get(array_name) { + items + .iter() + .enumerate() + .map(|(index, item)| { + let mut iteration = loop_body.to_string(); + + // Replace {{@index}} with the current index + iteration = iteration.replace("{{@index}}", &index.to_string()); + + // Replace {{@first}} with true/false + iteration = iteration + .replace("{{@first}}", if index == 0 { "true" } else { "false" }); + + // Replace {{@last}} with true/false + iteration = iteration.replace( + "{{@last}}", + if index == items.len() - 1 { + "true" + } else { + "false" + }, + ); + + // Replace {{this}} with the item value (for simple string arrays) + if let Some(value) = item.get("item") { + iteration = iteration.replace("{{this}}", value); + } + + // Replace {{property}} and {{this.property}} with item values + for (key, value) in item { + iteration = iteration.replace(&format!("{{{{{}}}}}", key), value); + iteration = + iteration.replace(&format!("{{{{this.{}}}}}", key), value); + } + + iteration + }) + .collect::>() + .join("") + } else { + // Array not found, return empty string + String::new() + } + }) + .to_string(); + + result + } + + /// Process simple if/endif conditionals. + fn process_conditionals(&self, template: &str, context: &TemplateContext) -> String { + let mut result = template.to_string(); + + // Process {{#if field}}...{{/if}} blocks + let if_regex = regex_lite::Regex::new(r"\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{/if\}\}").unwrap(); + + result = if_regex + .replace_all(&result, |caps: ®ex_lite::Captures| { + let field = &caps[1]; + let content = &caps[2]; + + let should_include = match field { + "has_agent_md" => context.agent_md.is_some(), + "description" => context.issue.description.is_some(), + // Check if array exists and is not empty + _ if context.arrays.contains_key(field) => context + .arrays + .get(field) + .map(|a| !a.is_empty()) + .unwrap_or(false), + _ => context + .variables + .get(field) + .map(|v| !v.is_empty()) + .unwrap_or(false), + }; + + if should_include { + content.to_string() + } else { + String::new() + } + }) + .to_string(); + + result + } +} + +impl Default for TemplateRenderer { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_issue() -> Issue { + let mut issue = Issue::new( + "123", + "PROJ-123", + "Fix the bug", + "https://example.com/issue/123", + "linear", + ); + issue.description = Some("This is a bug description".to_string()); + issue + } + + #[test] + fn test_basic_variable_substitution() { + let renderer = TemplateRenderer::new(); + let context = TemplateContext::new(create_test_issue(), "Stack trace here".to_string()); + + let template = "Fix {{short_id}}: {{title}}"; + let result = renderer.render(template, &context); + + assert_eq!(result, "Fix PROJ-123: Fix the bug"); + } + + #[test] + fn test_context_substitution() { + let renderer = TemplateRenderer::new(); + let context = TemplateContext::new(create_test_issue(), "Error at line 42".to_string()); + + let template = "Context:\n{{context}}"; + let result = renderer.render(template, &context); + + assert_eq!(result, "Context:\nError at line 42"); + } + + #[test] + fn test_agent_md_substitution() { + let renderer = TemplateRenderer::new(); + let context = TemplateContext::new(create_test_issue(), "".to_string()) + .with_agent_md(Some("Custom instructions".to_string())); + + let template = "{{#if has_agent_md}}{{agent_md}}\n---\n{{/if}}Fix the issue"; + let result = renderer.render(template, &context); + + assert!(result.contains("Custom instructions")); + assert!(result.contains("---")); + } + + #[test] + fn test_conditional_without_agent_md() { + let renderer = TemplateRenderer::new(); + let context = TemplateContext::new(create_test_issue(), "".to_string()); + + let template = "{{#if has_agent_md}}AGENT.md present\n{{/if}}Main content"; + let result = renderer.render(template, &context); + + assert!(!result.contains("AGENT.md present")); + assert!(result.contains("Main content")); + } + + #[test] + fn test_conditional_with_description() { + let renderer = TemplateRenderer::new(); + let context = TemplateContext::new(create_test_issue(), "".to_string()); + + let template = "{{#if description}}Description: {{description}}{{/if}}"; + let result = renderer.render(template, &context); + + assert!(result.contains("Description: This is a bug description")); + } + + #[test] + fn test_custom_variables() { + let renderer = TemplateRenderer::new(); + let context = TemplateContext::new(create_test_issue(), "".to_string()) + .with_variable("branch", "fix/bug-123"); + + let template = "Branch: {{branch}}"; + let result = renderer.render(template, &context); + + assert_eq!(result, "Branch: fix/bug-123"); + } + + #[test] + fn test_full_template() { + let renderer = TemplateRenderer::new(); + let context = TemplateContext::new( + create_test_issue(), + "Error: NullPointerException".to_string(), + ) + .with_agent_md(Some("Follow coding standards".to_string())); + + let template = r#"{{#if has_agent_md}} +{{agent_md}} + +--- +{{/if}} +Fix issue {{short_id}}: {{title}} +Source: {{source}} +URL: {{url}} + +{{#if description}} +Description: +{{description}} +{{/if}} + +Context: +{{context}}"#; + + let result = renderer.render(template, &context); + + assert!(result.contains("Follow coding standards")); + assert!(result.contains("Fix issue PROJ-123: Fix the bug")); + assert!(result.contains("Source: linear")); + assert!(result.contains("Description:")); + assert!(result.contains("This is a bug description")); + assert!(result.contains("Error: NullPointerException")); + } + + #[test] + fn test_each_basic_array() { + let renderer = TemplateRenderer::new(); + let items = vec![ + HashMap::from([ + ("name".to_string(), "Alice".to_string()), + ("role".to_string(), "Developer".to_string()), + ]), + HashMap::from([ + ("name".to_string(), "Bob".to_string()), + ("role".to_string(), "Designer".to_string()), + ]), + ]; + + let context = + TemplateContext::new(create_test_issue(), "".to_string()).with_array("users", items); + + let template = "Users:{{#each users}}\n- {{name}} ({{role}}){{/each}}"; + let result = renderer.render(template, &context); + + assert!(result.contains("- Alice (Developer)")); + assert!(result.contains("- Bob (Designer)")); + } + + #[test] + fn test_each_with_this_property() { + let renderer = TemplateRenderer::new(); + let items = vec![ + HashMap::from([("name".to_string(), "Item1".to_string())]), + HashMap::from([("name".to_string(), "Item2".to_string())]), + ]; + + let context = + TemplateContext::new(create_test_issue(), "".to_string()).with_array("items", items); + + let template = "{{#each items}}{{this.name}} {{/each}}"; + let result = renderer.render(template, &context); + + assert_eq!(result, "Item1 Item2 "); + } + + #[test] + fn test_each_with_index() { + let renderer = TemplateRenderer::new(); + let items = vec![ + HashMap::from([("name".to_string(), "First".to_string())]), + HashMap::from([("name".to_string(), "Second".to_string())]), + HashMap::from([("name".to_string(), "Third".to_string())]), + ]; + + let context = + TemplateContext::new(create_test_issue(), "".to_string()).with_array("items", items); + + let template = "{{#each items}}{{@index}}: {{name}}\n{{/each}}"; + let result = renderer.render(template, &context); + + assert!(result.contains("0: First")); + assert!(result.contains("1: Second")); + assert!(result.contains("2: Third")); + } + + #[test] + fn test_each_with_first_last() { + let renderer = TemplateRenderer::new(); + let items = vec![ + HashMap::from([("name".to_string(), "A".to_string())]), + HashMap::from([("name".to_string(), "B".to_string())]), + HashMap::from([("name".to_string(), "C".to_string())]), + ]; + + let context = + TemplateContext::new(create_test_issue(), "".to_string()).with_array("items", items); + + let template = "{{#each items}}{{name}}(first={{@first}},last={{@last}}) {{/each}}"; + let result = renderer.render(template, &context); + + assert!(result.contains("A(first=true,last=false)")); + assert!(result.contains("B(first=false,last=false)")); + assert!(result.contains("C(first=false,last=true)")); + } + + #[test] + fn test_each_empty_array() { + let renderer = TemplateRenderer::new(); + let context = + TemplateContext::new(create_test_issue(), "".to_string()).with_array("items", vec![]); + + let template = "Before{{#each items}}\n- {{name}}{{/each}}After"; + let result = renderer.render(template, &context); + + assert_eq!(result, "BeforeAfter"); + } + + #[test] + fn test_each_nonexistent_array() { + let renderer = TemplateRenderer::new(); + let context = TemplateContext::new(create_test_issue(), "".to_string()); + + let template = "Before{{#each missing}}ITEM{{/each}}After"; + let result = renderer.render(template, &context); + + assert_eq!(result, "BeforeAfter"); + } + + #[test] + fn test_each_simple_string_array() { + let renderer = TemplateRenderer::new(); + let items = vec![ + HashMap::from([("item".to_string(), "apple".to_string())]), + HashMap::from([("item".to_string(), "banana".to_string())]), + HashMap::from([("item".to_string(), "cherry".to_string())]), + ]; + + let context = + TemplateContext::new(create_test_issue(), "".to_string()).with_array("fruits", items); + + let template = "Fruits: {{#each fruits}}{{this}}, {{/each}}"; + let result = renderer.render(template, &context); + + assert!(result.contains("apple")); + assert!(result.contains("banana")); + assert!(result.contains("cherry")); + } + + #[test] + fn test_each_with_json_array() { + let renderer = TemplateRenderer::new(); + let json: serde_json::Value = serde_json::json!([ + {"file": "src/main.rs", "line": 42}, + {"file": "src/lib.rs", "line": 100} + ]); + + let context = TemplateContext::new(create_test_issue(), "".to_string()) + .with_json_array("stack_frames", &json); + + let template = "Stack:{{#each stack_frames}}\n {{file}}:{{line}}{{/each}}"; + let result = renderer.render(template, &context); + + assert!(result.contains("src/main.rs:42")); + assert!(result.contains("src/lib.rs:100")); + } + + #[test] + fn test_each_with_json_string_array() { + let renderer = TemplateRenderer::new(); + let json: serde_json::Value = serde_json::json!(["tag1", "tag2", "tag3"]); + + let context = TemplateContext::new(create_test_issue(), "".to_string()) + .with_json_array("tags", &json); + + let template = "Tags: {{#each tags}}#{{this}} {{/each}}"; + let result = renderer.render(template, &context); + + assert!(result.contains("#tag1")); + assert!(result.contains("#tag2")); + assert!(result.contains("#tag3")); + } + + #[test] + fn test_each_multiple_loops() { + let renderer = TemplateRenderer::new(); + let users = vec![ + HashMap::from([("name".to_string(), "Alice".to_string())]), + HashMap::from([("name".to_string(), "Bob".to_string())]), + ]; + let tasks = vec![ + HashMap::from([("task".to_string(), "Fix bug".to_string())]), + HashMap::from([("task".to_string(), "Add feature".to_string())]), + ]; + + let context = TemplateContext::new(create_test_issue(), "".to_string()) + .with_array("users", users) + .with_array("tasks", tasks); + + let template = + "Users:{{#each users}} {{name}}{{/each}}\nTasks:{{#each tasks}} {{task}}{{/each}}"; + let result = renderer.render(template, &context); + + assert!(result.contains("Users: Alice Bob")); + assert!(result.contains("Tasks: Fix bug Add feature")); + } + + #[test] + fn test_each_with_special_characters() { + let renderer = TemplateRenderer::new(); + let items = vec![ + HashMap::from([("text".to_string(), "Hello ".to_string())]), + HashMap::from([("text".to_string(), "Test & Debug".to_string())]), + ]; + + let context = + TemplateContext::new(create_test_issue(), "".to_string()).with_array("items", items); + + let template = "{{#each items}}{{text}}\n{{/each}}"; + let result = renderer.render(template, &context); + + assert!(result.contains("Hello ")); + assert!(result.contains("Test & Debug")); + } + + #[test] + fn test_if_array_exists() { + let renderer = TemplateRenderer::new(); + let items = vec![HashMap::from([("name".to_string(), "Item".to_string())])]; + + let context = + TemplateContext::new(create_test_issue(), "".to_string()).with_array("items", items); + + let template = "{{#if items}}Has items{{/if}}"; + let result = renderer.render(template, &context); + + assert_eq!(result, "Has items"); + } + + #[test] + fn test_if_empty_array_not_shown() { + let renderer = TemplateRenderer::new(); + let context = + TemplateContext::new(create_test_issue(), "".to_string()).with_array("items", vec![]); + + let template = "{{#if items}}Has items{{/if}}Empty"; + let result = renderer.render(template, &context); + + assert_eq!(result, "Empty"); + } + + #[test] + fn test_each_with_multiline_content() { + let renderer = TemplateRenderer::new(); + let items = vec![ + HashMap::from([ + ("title".to_string(), "Issue 1".to_string()), + ("desc".to_string(), "Description 1".to_string()), + ]), + HashMap::from([ + ("title".to_string(), "Issue 2".to_string()), + ("desc".to_string(), "Description 2".to_string()), + ]), + ]; + + let context = + TemplateContext::new(create_test_issue(), "".to_string()).with_array("issues", items); + + let template = r#"Issues: +{{#each issues}} +## {{title}} +{{desc}} + +{{/each}}"#; + let result = renderer.render(template, &context); + + assert!(result.contains("## Issue 1\nDescription 1")); + assert!(result.contains("## Issue 2\nDescription 2")); + } + + #[test] + fn test_each_single_item() { + let renderer = TemplateRenderer::new(); + let items = vec![HashMap::from([("name".to_string(), "Only".to_string())])]; + + let context = + TemplateContext::new(create_test_issue(), "".to_string()).with_array("items", items); + + let template = "{{#each items}}{{@first}}-{{@last}}-{{name}}{{/each}}"; + let result = renderer.render(template, &context); + + // Single item should be both first and last + assert_eq!(result, "true-true-Only"); + } + + #[test] + fn test_each_combined_with_if() { + let renderer = TemplateRenderer::new(); + let items = vec![HashMap::from([("name".to_string(), "Test".to_string())])]; + + let context = TemplateContext::new(create_test_issue(), "".to_string()) + .with_array("items", items) + .with_agent_md(Some("Guidelines".to_string())); + + let template = r#"{{#if has_agent_md}}{{agent_md}}{{/if}} +Items:{{#each items}} {{name}}{{/each}}"#; + let result = renderer.render(template, &context); + + assert!(result.contains("Guidelines")); + assert!(result.contains("Items: Test")); + } + + #[test] + fn test_each_with_numeric_values() { + let renderer = TemplateRenderer::new(); + let json: serde_json::Value = serde_json::json!([ + {"count": 10, "name": "errors"}, + {"count": 5, "name": "warnings"} + ]); + + let context = TemplateContext::new(create_test_issue(), "".to_string()) + .with_json_array("metrics", &json); + + let template = "{{#each metrics}}{{name}}: {{count}}\n{{/each}}"; + let result = renderer.render(template, &context); + + assert!(result.contains("errors: 10")); + assert!(result.contains("warnings: 5")); + } + + #[test] + fn test_each_preserves_surrounding_template() { + let renderer = TemplateRenderer::new(); + let items = vec![HashMap::from([("x".to_string(), "A".to_string())])]; + + let context = + TemplateContext::new(create_test_issue(), "ctx".to_string()).with_array("items", items); + + let template = "Issue: {{short_id}}\n{{#each items}}{{x}}{{/each}}\nContext: {{context}}"; + let result = renderer.render(template, &context); + + assert!(result.contains("Issue: PROJ-123")); + assert!(result.contains("A")); + assert!(result.contains("Context: ctx")); + } +} diff --git a/src/testutil.rs b/src/testutil.rs new file mode 100644 index 00000000..9318562e --- /dev/null +++ b/src/testutil.rs @@ -0,0 +1,1292 @@ +//! Test utilities and builders for creating test fixtures. +//! +//! This module provides builder patterns and helper functions for creating +//! test instances of core types. These utilities reduce boilerplate in tests +//! and ensure consistent test data across the codebase. +//! +//! # Example +//! +//! ```rust +//! use claudear::testutil::{IssueBuilder, FixAttemptBuilder}; +//! +//! // Create a test issue with custom fields +//! let issue = IssueBuilder::new() +//! .id("TEST-123") +//! .title("Fix authentication bug") +//! .priority(claudear::IssuePriority::High) +//! .build(); +//! +//! // Create a test fix attempt +//! let attempt = FixAttemptBuilder::new() +//! .issue_id("TEST-123") +//! .status(claudear::FixAttemptStatus::Success) +//! .pr_url("https://github.com/org/repo/pull/42") +//! .build(); +//! ``` + +use crate::types::{ + ActivityLogEntry, AnalyticsSummary, ClaudeExecution, ClaudeResult, ErrorPattern, FixAttempt, + FixAttemptStats, FixAttemptStatus, Issue, IssuePriority, IssueStatus, MatchPriority, + MatchResult, ProcessingMetric, PromptExperiment, SourceStats, +}; +use chrono::{DateTime, Utc}; +use std::collections::HashMap; + +/// Builder for creating test [`Issue`] instances. +/// +/// Provides sensible defaults for all fields while allowing customization +/// of specific fields as needed for individual tests. +#[derive(Debug, Clone)] +pub struct IssueBuilder { + id: String, + short_id: String, + title: String, + description: Option, + url: String, + source: String, + priority: IssuePriority, + status: IssueStatus, + metadata: HashMap, + created_at: Option>, + updated_at: Option>, +} + +impl Default for IssueBuilder { + fn default() -> Self { + Self::new() + } +} + +impl IssueBuilder { + /// Create a new builder with default test values. + pub fn new() -> Self { + Self { + id: "test-id-001".to_string(), + short_id: "TEST-1".to_string(), + title: "Test Issue Title".to_string(), + description: None, + url: "https://example.com/issues/test-1".to_string(), + source: "test".to_string(), + priority: IssuePriority::None, + status: IssueStatus::Open, + metadata: HashMap::new(), + created_at: None, + updated_at: None, + } + } + + /// Create a builder pre-configured for Linear issues. + pub fn linear() -> Self { + Self::new() + .source("linear") + .short_id("LIN-123") + .url("https://linear.app/team/issue/LIN-123") + } + + /// Create a builder pre-configured for Sentry issues. + pub fn sentry() -> Self { + Self::new() + .source("sentry") + .short_id("SENTRY-456") + .url("https://sentry.io/issues/456") + } + + /// Set the issue ID. + pub fn id(mut self, id: impl Into) -> Self { + self.id = id.into(); + self + } + + /// Set the short (human-readable) ID. + pub fn short_id(mut self, short_id: impl Into) -> Self { + self.short_id = short_id.into(); + self + } + + /// Set the issue title. + pub fn title(mut self, title: impl Into) -> Self { + self.title = title.into(); + self + } + + /// Set the issue description. + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + /// Set the issue URL. + pub fn url(mut self, url: impl Into) -> Self { + self.url = url.into(); + self + } + + /// Set the source service name. + pub fn source(mut self, source: impl Into) -> Self { + self.source = source.into(); + self + } + + /// Set the priority level. + pub fn priority(mut self, priority: IssuePriority) -> Self { + self.priority = priority; + self + } + + /// Set the issue status. + pub fn status(mut self, status: IssueStatus) -> Self { + self.status = status; + self + } + + /// Add a metadata entry. + pub fn with_metadata(mut self, key: impl Into, value: impl serde::Serialize) -> Self { + if let Ok(v) = serde_json::to_value(value) { + self.metadata.insert(key.into(), v); + } + self + } + + /// Set the created_at timestamp. + pub fn created_at(mut self, created_at: DateTime) -> Self { + self.created_at = Some(created_at); + self + } + + /// Set the updated_at timestamp. + pub fn updated_at(mut self, updated_at: DateTime) -> Self { + self.updated_at = Some(updated_at); + self + } + + /// Build the [`Issue`] instance. + pub fn build(self) -> Issue { + Issue { + id: self.id, + short_id: self.short_id, + title: self.title, + description: self.description, + url: self.url, + source: self.source, + priority: self.priority, + status: self.status, + metadata: self.metadata, + created_at: self.created_at, + updated_at: self.updated_at, + } + } +} + +/// Builder for creating test [`FixAttempt`] instances. +#[derive(Debug, Clone)] +pub struct FixAttemptBuilder { + id: i64, + issue_id: String, + short_id: String, + source: String, + attempted_at: DateTime, + pr_url: Option, + github_repo: Option, + github_pr_number: Option, + status: FixAttemptStatus, + error_message: Option, + merged_at: Option>, + resolved_at: Option>, + retry_count: u32, + last_retry_at: Option>, +} + +impl Default for FixAttemptBuilder { + fn default() -> Self { + Self::new() + } +} + +impl FixAttemptBuilder { + /// Create a new builder with default test values. + pub fn new() -> Self { + Self { + id: 1, + issue_id: "test-id-001".to_string(), + short_id: "TEST-1".to_string(), + source: "test".to_string(), + attempted_at: Utc::now(), + pr_url: None, + github_repo: None, + github_pr_number: None, + status: FixAttemptStatus::Pending, + error_message: None, + merged_at: None, + resolved_at: None, + retry_count: 0, + last_retry_at: None, + } + } + + /// Create a builder configured as a successful attempt with a PR. + pub fn successful() -> Self { + Self::new() + .status(FixAttemptStatus::Success) + .pr_url("https://github.com/org/repo/pull/1") + .github_repo("org/repo") + .github_pr_number(1) + } + + /// Create a builder configured as a failed attempt. + pub fn failed() -> Self { + Self::new() + .status(FixAttemptStatus::Failed) + .error_message("Build failed") + } + + /// Create a builder configured as a merged attempt. + pub fn merged() -> Self { + Self::successful() + .status(FixAttemptStatus::Merged) + .merged_at(Utc::now()) + .resolved_at(Utc::now()) + } + + /// Set the database ID. + pub fn id(mut self, id: i64) -> Self { + self.id = id; + self + } + + /// Set the issue ID. + pub fn issue_id(mut self, issue_id: impl Into) -> Self { + self.issue_id = issue_id.into(); + self + } + + /// Set the short (human-readable) ID. + pub fn short_id(mut self, short_id: impl Into) -> Self { + self.short_id = short_id.into(); + self + } + + /// Set the source service name. + pub fn source(mut self, source: impl Into) -> Self { + self.source = source.into(); + self + } + + /// Set the attempted_at timestamp. + pub fn attempted_at(mut self, attempted_at: DateTime) -> Self { + self.attempted_at = attempted_at; + self + } + + /// Set the PR URL. + pub fn pr_url(mut self, pr_url: impl Into) -> Self { + self.pr_url = Some(pr_url.into()); + self + } + + /// Set the GitHub repository (owner/repo format). + pub fn github_repo(mut self, repo: impl Into) -> Self { + self.github_repo = Some(repo.into()); + self + } + + /// Set the GitHub PR number. + pub fn github_pr_number(mut self, pr_number: i64) -> Self { + self.github_pr_number = Some(pr_number); + self + } + + /// Set the fix attempt status. + pub fn status(mut self, status: FixAttemptStatus) -> Self { + self.status = status; + self + } + + /// Set the error message. + pub fn error_message(mut self, error_message: impl Into) -> Self { + self.error_message = Some(error_message.into()); + self + } + + /// Set the merged_at timestamp. + pub fn merged_at(mut self, merged_at: DateTime) -> Self { + self.merged_at = Some(merged_at); + self + } + + /// Set the resolved_at timestamp. + pub fn resolved_at(mut self, resolved_at: DateTime) -> Self { + self.resolved_at = Some(resolved_at); + self + } + + /// Set the retry count. + pub fn retry_count(mut self, retry_count: u32) -> Self { + self.retry_count = retry_count; + self + } + + /// Set the last_retry_at timestamp. + pub fn last_retry_at(mut self, last_retry_at: DateTime) -> Self { + self.last_retry_at = Some(last_retry_at); + self + } + + /// Build the [`FixAttempt`] instance. + pub fn build(self) -> FixAttempt { + FixAttempt { + id: self.id, + issue_id: self.issue_id, + short_id: self.short_id, + source: self.source, + attempted_at: self.attempted_at, + pr_url: self.pr_url, + github_repo: self.github_repo, + github_pr_number: self.github_pr_number, + status: self.status, + error_message: self.error_message, + merged_at: self.merged_at, + resolved_at: self.resolved_at, + retry_count: self.retry_count, + last_retry_at: self.last_retry_at, + } + } +} + +/// Builder for creating test [`ClaudeResult`] instances. +#[derive(Debug, Clone)] +pub struct ClaudeResultBuilder { + success: bool, + output: String, + pr_url: Option, + error: Option, +} + +impl Default for ClaudeResultBuilder { + fn default() -> Self { + Self::new() + } +} + +impl ClaudeResultBuilder { + /// Create a new builder with default (pending) values. + pub fn new() -> Self { + Self { + success: false, + output: String::new(), + pr_url: None, + error: None, + } + } + + /// Create a builder configured as a successful result. + pub fn successful() -> Self { + Self { + success: true, + output: "PR created successfully".to_string(), + pr_url: Some("https://github.com/org/repo/pull/1".to_string()), + error: None, + } + } + + /// Create a builder configured as a failed result. + pub fn failed() -> Self { + Self { + success: false, + output: String::new(), + pr_url: None, + error: Some("Build failed".to_string()), + } + } + + /// Set whether the result is successful. + pub fn success(mut self, success: bool) -> Self { + self.success = success; + self + } + + /// Set the output text. + pub fn output(mut self, output: impl Into) -> Self { + self.output = output.into(); + self + } + + /// Set the PR URL. + pub fn pr_url(mut self, pr_url: impl Into) -> Self { + self.pr_url = Some(pr_url.into()); + self + } + + /// Set the error message. + pub fn error(mut self, error: impl Into) -> Self { + self.error = Some(error.into()); + self + } + + /// Build the [`ClaudeResult`] instance. + pub fn build(self) -> ClaudeResult { + ClaudeResult { + success: self.success, + output: self.output, + pr_url: self.pr_url, + error: self.error, + } + } +} + +/// Builder for creating test [`MatchResult`] instances. +#[derive(Debug, Clone)] +pub struct MatchResultBuilder { + matches: bool, + reason: String, + priority: MatchPriority, +} + +impl Default for MatchResultBuilder { + fn default() -> Self { + Self::new() + } +} + +impl MatchResultBuilder { + /// Create a new builder with default values (not matched). + pub fn new() -> Self { + Self { + matches: false, + reason: "No match".to_string(), + priority: MatchPriority::Normal, + } + } + + /// Create a builder configured as a matching result. + pub fn matched() -> Self { + Self { + matches: true, + reason: "Matches criteria".to_string(), + priority: MatchPriority::Normal, + } + } + + /// Set whether it matches. + pub fn matches(mut self, matches: bool) -> Self { + self.matches = matches; + self + } + + /// Set the reason. + pub fn reason(mut self, reason: impl Into) -> Self { + self.reason = reason.into(); + self + } + + /// Set the priority. + pub fn priority(mut self, priority: MatchPriority) -> Self { + self.priority = priority; + self + } + + /// Build the [`MatchResult`] instance. + pub fn build(self) -> MatchResult { + MatchResult { + matches: self.matches, + reason: self.reason, + priority: self.priority, + } + } +} + +/// Builder for creating test [`ActivityLogEntry`] instances. +#[derive(Debug, Clone)] +pub struct ActivityLogEntryBuilder { + id: i64, + timestamp: DateTime, + activity_type: String, + source: Option, + issue_id: Option, + short_id: Option, + message: String, + metadata: Option, +} + +impl Default for ActivityLogEntryBuilder { + fn default() -> Self { + Self::new() + } +} + +impl ActivityLogEntryBuilder { + /// Create a new builder with default values. + pub fn new() -> Self { + Self { + id: 1, + timestamp: Utc::now(), + activity_type: "test_activity".to_string(), + source: None, + issue_id: None, + short_id: None, + message: "Test activity message".to_string(), + metadata: None, + } + } + + /// Set the database ID. + pub fn id(mut self, id: i64) -> Self { + self.id = id; + self + } + + /// Set the timestamp. + pub fn timestamp(mut self, timestamp: DateTime) -> Self { + self.timestamp = timestamp; + self + } + + /// Set the activity type. + pub fn activity_type(mut self, activity_type: impl Into) -> Self { + self.activity_type = activity_type.into(); + self + } + + /// Set the source. + pub fn source(mut self, source: impl Into) -> Self { + self.source = Some(source.into()); + self + } + + /// Set the issue ID. + pub fn issue_id(mut self, issue_id: impl Into) -> Self { + self.issue_id = Some(issue_id.into()); + self + } + + /// Set the short ID. + pub fn short_id(mut self, short_id: impl Into) -> Self { + self.short_id = Some(short_id.into()); + self + } + + /// Set the message. + pub fn message(mut self, message: impl Into) -> Self { + self.message = message.into(); + self + } + + /// Set the metadata. + pub fn metadata(mut self, metadata: serde_json::Value) -> Self { + self.metadata = Some(metadata); + self + } + + /// Build the [`ActivityLogEntry`] instance. + pub fn build(self) -> ActivityLogEntry { + ActivityLogEntry { + id: self.id, + timestamp: self.timestamp, + activity_type: self.activity_type, + source: self.source, + issue_id: self.issue_id, + short_id: self.short_id, + message: self.message, + metadata: self.metadata, + } + } +} + +/// Builder for creating test [`ClaudeExecution`] instances. +#[derive(Debug, Clone)] +pub struct ClaudeExecutionBuilder { + inner: ClaudeExecution, +} + +impl Default for ClaudeExecutionBuilder { + fn default() -> Self { + Self::new() + } +} + +impl ClaudeExecutionBuilder { + /// Create a new builder with default values. + pub fn new() -> Self { + Self { + inner: ClaudeExecution::new(), + } + } + + /// Create a builder configured as a completed execution. + pub fn completed() -> Self { + let mut builder = Self::new(); + builder.inner.completed_at = Some(Utc::now()); + builder.inner.duration_secs = Some(120.5); + builder.inner.exit_code = Some(0); + builder + } + + /// Create a builder configured as a timed out execution. + pub fn timed_out() -> Self { + let mut builder = Self::new(); + builder.inner.timed_out = true; + builder.inner.completed_at = Some(Utc::now()); + builder.inner.duration_secs = Some(21600.0); // 6 hours + builder + } + + /// Set the database ID. + pub fn id(mut self, id: i64) -> Self { + self.inner.id = id; + self + } + + /// Set the attempt ID. + pub fn attempt_id(mut self, attempt_id: i64) -> Self { + self.inner.attempt_id = Some(attempt_id); + self + } + + /// Set the exit code. + pub fn exit_code(mut self, exit_code: i32) -> Self { + self.inner.exit_code = Some(exit_code); + self + } + + /// Set the stdout preview. + pub fn stdout_preview(mut self, stdout: impl Into) -> Self { + self.inner.stdout_preview = Some(stdout.into()); + self + } + + /// Set the stderr preview. + pub fn stderr_preview(mut self, stderr: impl Into) -> Self { + self.inner.stderr_preview = Some(stderr.into()); + self + } + + /// Set the working directory. + pub fn working_directory(mut self, dir: impl Into) -> Self { + self.inner.working_directory = Some(dir.into()); + self + } + + /// Set the git branch. + pub fn git_branch(mut self, branch: impl Into) -> Self { + self.inner.git_branch = Some(branch.into()); + self + } + + /// Set the files changed count. + pub fn files_changed(mut self, count: i32) -> Self { + self.inner.files_changed = Some(count); + self + } + + /// Set the lines added count. + pub fn lines_added(mut self, count: i32) -> Self { + self.inner.lines_added = Some(count); + self + } + + /// Set the lines removed count. + pub fn lines_removed(mut self, count: i32) -> Self { + self.inner.lines_removed = Some(count); + self + } + + /// Build the [`ClaudeExecution`] instance. + pub fn build(self) -> ClaudeExecution { + self.inner + } +} + +/// Builder for creating test [`ErrorPattern`] instances. +#[derive(Debug, Clone)] +pub struct ErrorPatternBuilder { + inner: ErrorPattern, +} + +impl Default for ErrorPatternBuilder { + fn default() -> Self { + Self::new() + } +} + +impl ErrorPatternBuilder { + /// Create a new builder with default values. + pub fn new() -> Self { + Self { + inner: ErrorPattern::new("test-pattern-hash"), + } + } + + /// Set the pattern hash. + pub fn pattern_hash(mut self, hash: impl Into) -> Self { + self.inner.pattern_hash = hash.into(); + self + } + + /// Set the error type. + pub fn error_type(mut self, error_type: impl Into) -> Self { + self.inner.error_type = Some(error_type.into()); + self + } + + /// Set the error message. + pub fn error_message(mut self, message: impl Into) -> Self { + self.inner.error_message = Some(message.into()); + self + } + + /// Set the occurrence count. + pub fn occurrence_count(mut self, count: i32) -> Self { + self.inner.occurrence_count = count; + self + } + + /// Set resolution hints. + pub fn resolution_hints(mut self, hints: impl Into) -> Self { + self.inner.resolution_hints = Some(hints.into()); + self + } + + /// Build the [`ErrorPattern`] instance. + pub fn build(self) -> ErrorPattern { + self.inner + } +} + +/// Builder for creating test [`ProcessingMetric`] instances. +#[derive(Debug, Clone)] +pub struct ProcessingMetricBuilder { + inner: ProcessingMetric, +} + +impl Default for ProcessingMetricBuilder { + fn default() -> Self { + Self::new() + } +} + +impl ProcessingMetricBuilder { + /// Create a new builder with default values. + pub fn new() -> Self { + Self { + inner: ProcessingMetric::new("test_metric", 0.0), + } + } + + /// Set the metric name. + pub fn metric_name(mut self, name: impl Into) -> Self { + self.inner.metric_name = name.into(); + self + } + + /// Set the metric value. + pub fn metric_value(mut self, value: f64) -> Self { + self.inner.metric_value = value; + self + } + + /// Set the source. + pub fn source(mut self, source: impl Into) -> Self { + self.inner.source = Some(source.into()); + self + } + + /// Set the tags. + pub fn tags(mut self, tags: serde_json::Value) -> Self { + self.inner.tags = Some(tags); + self + } + + /// Build the [`ProcessingMetric`] instance. + pub fn build(self) -> ProcessingMetric { + self.inner + } +} + +/// Builder for creating test [`PromptExperiment`] instances. +#[derive(Debug, Clone)] +pub struct PromptExperimentBuilder { + inner: PromptExperiment, +} + +impl Default for PromptExperimentBuilder { + fn default() -> Self { + Self::new() + } +} + +impl PromptExperimentBuilder { + /// Create a new builder with default values. + pub fn new() -> Self { + Self { + inner: PromptExperiment::new( + "test_experiment", + "control", + "Test prompt template", + "test-hash", + ), + } + } + + /// Set the experiment name. + pub fn experiment_name(mut self, name: impl Into) -> Self { + self.inner.experiment_name = name.into(); + self + } + + /// Set the variant. + pub fn variant(mut self, variant: impl Into) -> Self { + self.inner.variant = variant.into(); + self + } + + /// Set the prompt template. + pub fn prompt_template(mut self, template: impl Into) -> Self { + self.inner.prompt_template = template.into(); + self + } + + /// Set whether the experiment is active. + pub fn active(mut self, active: bool) -> Self { + self.inner.active = active; + self + } + + /// Set the success count. + pub fn success_count(mut self, count: i32) -> Self { + self.inner.success_count = count; + self + } + + /// Set the failure count. + pub fn failure_count(mut self, count: i32) -> Self { + self.inner.failure_count = count; + self + } + + /// Build the [`PromptExperiment`] instance. + pub fn build(self) -> PromptExperiment { + self.inner + } +} + +/// Builder for creating test [`FixAttemptStats`] instances. +#[derive(Debug, Clone, Default)] +pub struct FixAttemptStatsBuilder { + inner: FixAttemptStats, +} + +impl FixAttemptStatsBuilder { + /// Create a new builder with default (zero) values. + pub fn new() -> Self { + Self::default() + } + + /// Set the total count. + pub fn total(mut self, total: usize) -> Self { + self.inner.total = total; + self + } + + /// Set the pending count. + pub fn pending(mut self, pending: usize) -> Self { + self.inner.pending = pending; + self + } + + /// Set the success count. + pub fn success(mut self, success: usize) -> Self { + self.inner.success = success; + self + } + + /// Set the failed count. + pub fn failed(mut self, failed: usize) -> Self { + self.inner.failed = failed; + self + } + + /// Set the merged count. + pub fn merged(mut self, merged: usize) -> Self { + self.inner.merged = merged; + self + } + + /// Set the closed count. + pub fn closed(mut self, closed: usize) -> Self { + self.inner.closed = closed; + self + } + + /// Set the cannot_fix count. + pub fn cannot_fix(mut self, cannot_fix: usize) -> Self { + self.inner.cannot_fix = cannot_fix; + self + } + + /// Add source-specific stats. + pub fn with_source_stats(mut self, source: impl Into, stats: SourceStats) -> Self { + self.inner.by_source.insert(source.into(), stats); + self + } + + /// Build the [`FixAttemptStats`] instance. + pub fn build(self) -> FixAttemptStats { + self.inner + } +} + +/// Builder for creating test [`SourceStats`] instances. +#[derive(Debug, Clone, Default)] +pub struct SourceStatsBuilder { + inner: SourceStats, +} + +impl SourceStatsBuilder { + /// Create a new builder with default (zero) values. + pub fn new() -> Self { + Self::default() + } + + /// Set the total count. + pub fn total(mut self, total: usize) -> Self { + self.inner.total = total; + self + } + + /// Set the success count. + pub fn success(mut self, success: usize) -> Self { + self.inner.success = success; + self + } + + /// Set the failed count. + pub fn failed(mut self, failed: usize) -> Self { + self.inner.failed = failed; + self + } + + /// Set the merged count. + pub fn merged(mut self, merged: usize) -> Self { + self.inner.merged = merged; + self + } + + /// Set the closed count. + pub fn closed(mut self, closed: usize) -> Self { + self.inner.closed = closed; + self + } + + /// Set the cannot_fix count. + pub fn cannot_fix(mut self, cannot_fix: usize) -> Self { + self.inner.cannot_fix = cannot_fix; + self + } + + /// Build the [`SourceStats`] instance. + pub fn build(self) -> SourceStats { + self.inner + } +} + +/// Builder for creating test [`AnalyticsSummary`] instances. +#[derive(Debug, Clone, Default)] +pub struct AnalyticsSummaryBuilder { + inner: AnalyticsSummary, +} + +impl AnalyticsSummaryBuilder { + /// Create a new builder with default values. + pub fn new() -> Self { + Self::default() + } + + /// Set the success rate. + pub fn success_rate(mut self, rate: f64) -> Self { + self.inner.success_rate = rate; + self + } + + /// Set the total processed count. + pub fn total_processed(mut self, count: i64) -> Self { + self.inner.total_processed = count; + self + } + + /// Set the total successful count. + pub fn total_successful(mut self, count: i64) -> Self { + self.inner.total_successful = count; + self + } + + /// Set the total merged count. + pub fn total_merged(mut self, count: i64) -> Self { + self.inner.total_merged = count; + self + } + + /// Set the average processing time. + pub fn avg_processing_time_secs(mut self, secs: f64) -> Self { + self.inner.avg_processing_time_secs = Some(secs); + self + } + + /// Set the average time to merge. + pub fn avg_time_to_merge_hours(mut self, hours: f64) -> Self { + self.inner.avg_time_to_merge_hours = Some(hours); + self + } + + /// Set the most common error. + pub fn most_common_error(mut self, error: impl Into) -> Self { + self.inner.most_common_error = Some(error.into()); + self + } + + /// Add a source success rate. + pub fn with_source_success_rate(mut self, source: impl Into, rate: f64) -> Self { + self.inner.success_rate_by_source.insert(source.into(), rate); + self + } + + /// Build the [`AnalyticsSummary`] instance. + pub fn build(self) -> AnalyticsSummary { + self.inner + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_issue_builder_defaults() { + let issue = IssueBuilder::new().build(); + assert_eq!(issue.id, "test-id-001"); + assert_eq!(issue.short_id, "TEST-1"); + assert_eq!(issue.source, "test"); + assert_eq!(issue.priority, IssuePriority::None); + assert_eq!(issue.status, IssueStatus::Open); + } + + #[test] + fn test_issue_builder_linear() { + let issue = IssueBuilder::linear().build(); + assert_eq!(issue.source, "linear"); + assert!(issue.url.contains("linear.app")); + } + + #[test] + fn test_issue_builder_sentry() { + let issue = IssueBuilder::sentry().build(); + assert_eq!(issue.source, "sentry"); + assert!(issue.url.contains("sentry.io")); + } + + #[test] + fn test_issue_builder_custom_fields() { + let issue = IssueBuilder::new() + .id("custom-id") + .title("Custom Title") + .description("Custom description") + .priority(IssuePriority::Critical) + .status(IssueStatus::InProgress) + .with_metadata("key", "value") + .build(); + + assert_eq!(issue.id, "custom-id"); + assert_eq!(issue.title, "Custom Title"); + assert_eq!(issue.description.as_deref(), Some("Custom description")); + assert_eq!(issue.priority, IssuePriority::Critical); + assert_eq!(issue.status, IssueStatus::InProgress); + assert!(issue.metadata.contains_key("key")); + } + + #[test] + fn test_fix_attempt_builder_defaults() { + let attempt = FixAttemptBuilder::new().build(); + assert_eq!(attempt.status, FixAttemptStatus::Pending); + assert!(attempt.pr_url.is_none()); + assert_eq!(attempt.retry_count, 0); + } + + #[test] + fn test_fix_attempt_builder_successful() { + let attempt = FixAttemptBuilder::successful().build(); + assert_eq!(attempt.status, FixAttemptStatus::Success); + assert!(attempt.pr_url.is_some()); + assert!(attempt.github_repo.is_some()); + assert!(attempt.github_pr_number.is_some()); + } + + #[test] + fn test_fix_attempt_builder_failed() { + let attempt = FixAttemptBuilder::failed().build(); + assert_eq!(attempt.status, FixAttemptStatus::Failed); + assert!(attempt.error_message.is_some()); + } + + #[test] + fn test_fix_attempt_builder_merged() { + let attempt = FixAttemptBuilder::merged().build(); + assert_eq!(attempt.status, FixAttemptStatus::Merged); + assert!(attempt.merged_at.is_some()); + assert!(attempt.resolved_at.is_some()); + } + + #[test] + fn test_claude_result_builder_successful() { + let result = ClaudeResultBuilder::successful().build(); + assert!(result.success); + assert!(result.pr_url.is_some()); + assert!(result.error.is_none()); + } + + #[test] + fn test_claude_result_builder_failed() { + let result = ClaudeResultBuilder::failed().build(); + assert!(!result.success); + assert!(result.pr_url.is_none()); + assert!(result.error.is_some()); + } + + #[test] + fn test_match_result_builder() { + let result = MatchResultBuilder::matched() + .priority(MatchPriority::Urgent) + .reason("High priority issue") + .build(); + + assert!(result.matches); + assert_eq!(result.priority, MatchPriority::Urgent); + assert_eq!(result.reason, "High priority issue"); + } + + #[test] + fn test_activity_log_entry_builder() { + let entry = ActivityLogEntryBuilder::new() + .activity_type("issue_received") + .source("linear") + .issue_id("123") + .short_id("LIN-123") + .message("New issue received") + .build(); + + assert_eq!(entry.activity_type, "issue_received"); + assert_eq!(entry.source.as_deref(), Some("linear")); + assert_eq!(entry.issue_id.as_deref(), Some("123")); + } + + #[test] + fn test_claude_execution_builder() { + let execution = ClaudeExecutionBuilder::completed() + .files_changed(5) + .lines_added(100) + .lines_removed(20) + .build(); + + assert!(execution.completed_at.is_some()); + assert_eq!(execution.files_changed, Some(5)); + assert_eq!(execution.lines_added, Some(100)); + assert_eq!(execution.lines_removed, Some(20)); + } + + #[test] + fn test_claude_execution_builder_timed_out() { + let execution = ClaudeExecutionBuilder::timed_out().build(); + assert!(execution.timed_out); + assert!(execution.completed_at.is_some()); + } + + #[test] + fn test_error_pattern_builder() { + let pattern = ErrorPatternBuilder::new() + .error_type("build_failure") + .error_message("Compilation error") + .occurrence_count(5) + .build(); + + assert_eq!(pattern.error_type.as_deref(), Some("build_failure")); + assert_eq!(pattern.occurrence_count, 5); + } + + #[test] + fn test_processing_metric_builder() { + let metric = ProcessingMetricBuilder::new() + .metric_name("queue_depth") + .metric_value(42.0) + .source("linear") + .build(); + + assert_eq!(metric.metric_name, "queue_depth"); + assert_eq!(metric.metric_value, 42.0); + assert_eq!(metric.source.as_deref(), Some("linear")); + } + + #[test] + fn test_prompt_experiment_builder() { + let experiment = PromptExperimentBuilder::new() + .experiment_name("prompt_v2") + .variant("variant_a") + .success_count(10) + .failure_count(2) + .build(); + + assert_eq!(experiment.experiment_name, "prompt_v2"); + assert_eq!(experiment.variant, "variant_a"); + assert_eq!(experiment.success_count, 10); + assert_eq!(experiment.failure_count, 2); + } + + #[test] + fn test_fix_attempt_stats_builder() { + let source_stats = SourceStatsBuilder::new() + .total(50) + .success(40) + .failed(10) + .build(); + + let stats = FixAttemptStatsBuilder::new() + .total(100) + .success(80) + .failed(20) + .with_source_stats("linear", source_stats) + .build(); + + assert_eq!(stats.total, 100); + assert_eq!(stats.success, 80); + assert!(stats.by_source.contains_key("linear")); + } + + #[test] + fn test_analytics_summary_builder() { + let summary = AnalyticsSummaryBuilder::new() + .success_rate(0.85) + .total_processed(100) + .total_successful(85) + .total_merged(70) + .avg_processing_time_secs(120.5) + .with_source_success_rate("linear", 0.90) + .build(); + + assert_eq!(summary.success_rate, 0.85); + assert_eq!(summary.total_processed, 100); + assert!(summary.success_rate_by_source.contains_key("linear")); + } + + #[test] + fn test_builder_default_trait() { + // Verify Default trait works for all builders + let _issue = IssueBuilder::default().build(); + let _attempt = FixAttemptBuilder::default().build(); + let _result = ClaudeResultBuilder::default().build(); + let _match_result = MatchResultBuilder::default().build(); + let _activity = ActivityLogEntryBuilder::default().build(); + let _execution = ClaudeExecutionBuilder::default().build(); + let _pattern = ErrorPatternBuilder::default().build(); + let _metric = ProcessingMetricBuilder::default().build(); + let _experiment = PromptExperimentBuilder::default().build(); + let _stats = FixAttemptStatsBuilder::default().build(); + let _source_stats = SourceStatsBuilder::default().build(); + let _summary = AnalyticsSummaryBuilder::default().build(); + } +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 00000000..73f3ca19 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,1425 @@ +//! Core types shared across the application. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Maximum allowed length for issue IDs to prevent DoS attacks. +pub const MAX_ISSUE_ID_LENGTH: usize = 100; + +/// Validate an issue ID for safety and sanity. +/// +/// Returns `Ok(())` if the issue ID is valid, or `Err` with a description of the problem. +/// +/// # Validation Rules +/// - Must not be empty +/// - Must not exceed `MAX_ISSUE_ID_LENGTH` (100) characters +/// - Must not contain path traversal sequences (`..`) +/// - Must not contain forward slashes (`/`) +/// - Must not contain backslashes (`\`) +/// - Must not contain null bytes +/// +/// # Examples +/// ``` +/// use claudear::types::validate_issue_id; +/// +/// assert!(validate_issue_id("PROJ-123").is_ok()); +/// assert!(validate_issue_id("abc123").is_ok()); +/// assert!(validate_issue_id("").is_err()); +/// assert!(validate_issue_id("../etc/passwd").is_err()); +/// assert!(validate_issue_id("a/b").is_err()); +/// ``` +pub fn validate_issue_id(issue_id: &str) -> Result<(), String> { + if issue_id.is_empty() { + return Err("Issue ID cannot be empty".to_string()); + } + + if issue_id.len() > MAX_ISSUE_ID_LENGTH { + return Err(format!( + "Issue ID exceeds maximum length of {} characters", + MAX_ISSUE_ID_LENGTH + )); + } + + if issue_id.contains("..") { + return Err("Issue ID cannot contain path traversal sequences (..)".to_string()); + } + + if issue_id.contains('/') { + return Err("Issue ID cannot contain forward slashes (/)".to_string()); + } + + if issue_id.contains('\\') { + return Err("Issue ID cannot contain backslashes (\\)".to_string()); + } + + if issue_id.contains('\0') { + return Err("Issue ID cannot contain null bytes".to_string()); + } + + Ok(()) +} + +/// Priority levels for issues. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum IssuePriority { + #[default] + None, + Low, + Medium, + High, + Critical, +} + +impl std::fmt::Display for IssuePriority { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::None => write!(f, "none"), + Self::Low => write!(f, "low"), + Self::Medium => write!(f, "medium"), + Self::High => write!(f, "high"), + Self::Critical => write!(f, "critical"), + } + } +} + +/// Status of an issue. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum IssueStatus { + #[default] + Open, + InProgress, + Resolved, + Ignored, +} + +impl std::fmt::Display for IssueStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Open => write!(f, "open"), + Self::InProgress => write!(f, "in_progress"), + Self::Resolved => write!(f, "resolved"), + Self::Ignored => write!(f, "ignored"), + } + } +} + +/// Unified issue representation across all sources. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Issue { + /// Unique identifier from the source. + pub id: String, + /// Human-readable identifier (e.g., "PROJ-123", "SENTRY-ABC"). + pub short_id: String, + /// Issue title. + pub title: String, + /// Issue description or error message. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// URL to view the issue in its source. + pub url: String, + /// Source service name. + pub source: String, + /// Priority level. + pub priority: IssuePriority, + /// Current status. + pub status: IssueStatus, + /// Additional metadata specific to the source. + #[serde(default)] + pub metadata: HashMap, + /// When the issue was first seen/created. + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option>, + /// When the issue was last updated. + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option>, +} + +impl Issue { + /// Create a new issue with required fields. + pub fn new( + id: impl Into, + short_id: impl Into, + title: impl Into, + url: impl Into, + source: impl Into, + ) -> Self { + Self { + id: id.into(), + short_id: short_id.into(), + title: title.into(), + description: None, + url: url.into(), + source: source.into(), + priority: IssuePriority::default(), + status: IssueStatus::default(), + metadata: HashMap::new(), + created_at: None, + updated_at: None, + } + } + + /// Get a metadata value as a specific type. + pub fn get_metadata Deserialize<'de>>(&self, key: &str) -> Option { + self.metadata + .get(key) + .and_then(|v| serde_json::from_value(v.clone()).ok()) + } + + /// Set a metadata value. + pub fn set_metadata(&mut self, key: impl Into, value: impl Serialize) { + if let Ok(v) = serde_json::to_value(value) { + self.metadata.insert(key.into(), v); + } + } +} + +/// Priority for processing order. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum MatchPriority { + Low, + #[default] + Normal, + High, + Urgent, +} + +/// Result of matching an issue against criteria. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MatchResult { + /// Whether the issue matches. + pub matches: bool, + /// Human-readable reason for the match result. + pub reason: String, + /// Priority classification for processing order. + pub priority: MatchPriority, +} + +impl MatchResult { + /// Create a matching result. + pub fn matched(reason: impl Into, priority: MatchPriority) -> Self { + Self { + matches: true, + reason: reason.into(), + priority, + } + } + + /// Create a non-matching result. + pub fn not_matched(reason: impl Into) -> Self { + Self { + matches: false, + reason: reason.into(), + priority: MatchPriority::Normal, + } + } +} + +/// Result of a Claude fix attempt. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClaudeResult { + /// Whether the fix was successful. + pub success: bool, + /// Raw output from Claude. + pub output: String, + /// Extracted PR URL if available. + #[serde(skip_serializing_if = "Option::is_none")] + pub pr_url: Option, + /// Error message if failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Status of a fix attempt. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum FixAttemptStatus { + Pending, + Success, + Failed, + /// PR was merged and issue was resolved. + Merged, + /// PR was closed without merging. + Closed, + /// Max retries reached, issue cannot be automatically fixed. + CannotFix, +} + +impl std::fmt::Display for FixAttemptStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Pending => write!(f, "pending"), + Self::Success => write!(f, "success"), + Self::Failed => write!(f, "failed"), + Self::Merged => write!(f, "merged"), + Self::Closed => write!(f, "closed"), + Self::CannotFix => write!(f, "cannot_fix"), + } + } +} + +impl std::str::FromStr for FixAttemptStatus { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "pending" => Ok(Self::Pending), + "success" => Ok(Self::Success), + "failed" => Ok(Self::Failed), + "merged" => Ok(Self::Merged), + "closed" => Ok(Self::Closed), + "cannot_fix" => Ok(Self::CannotFix), + _ => Err(format!("Unknown status: {}", s)), + } + } +} + +/// Record of a fix attempt. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FixAttempt { + pub id: i64, + /// Issue ID from the source. + pub issue_id: String, + /// Human-readable issue ID. + pub short_id: String, + /// Source service name. + pub source: String, + /// When the attempt was made. + pub attempted_at: DateTime, + /// PR URL if successful. + #[serde(skip_serializing_if = "Option::is_none")] + pub pr_url: Option, + /// GitHub repository (owner/repo format). + #[serde(skip_serializing_if = "Option::is_none")] + pub github_repo: Option, + /// GitHub PR number. + #[serde(skip_serializing_if = "Option::is_none")] + pub github_pr_number: Option, + /// Current status. + pub status: FixAttemptStatus, + /// Error message if failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub error_message: Option, + /// When the PR was merged (if merged). + #[serde(skip_serializing_if = "Option::is_none")] + pub merged_at: Option>, + /// When the issue was resolved on the source. + #[serde(skip_serializing_if = "Option::is_none")] + pub resolved_at: Option>, + /// Number of retry attempts made. + #[serde(default)] + pub retry_count: u32, + /// When the last retry was attempted. + #[serde(skip_serializing_if = "Option::is_none")] + pub last_retry_at: Option>, +} + +/// Statistics about fix attempts. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FixAttemptStats { + pub total: usize, + pub pending: usize, + pub success: usize, + pub failed: usize, + /// PRs that were merged successfully. + pub merged: usize, + /// PRs that were closed without merging. + pub closed: usize, + /// Issues that reached max retries and cannot be fixed. + pub cannot_fix: usize, + pub by_source: HashMap, +} + +/// Per-source statistics. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SourceStats { + pub total: usize, + pub success: usize, + pub failed: usize, + pub merged: usize, + pub closed: usize, + pub cannot_fix: usize, +} + +/// Activity log entry for tracking operational events. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActivityLogEntry { + /// Database ID. + pub id: i64, + /// When the activity occurred. + pub timestamp: DateTime, + /// Type of activity (e.g., 'issue_received', 'processing_started', 'pr_created', 'error'). + pub activity_type: String, + /// Source service (e.g., 'linear', 'sentry'). + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, + /// Issue ID from the source. + #[serde(skip_serializing_if = "Option::is_none")] + pub issue_id: Option, + /// Human-readable issue ID. + #[serde(skip_serializing_if = "Option::is_none")] + pub short_id: Option, + /// Human-readable message describing the activity. + pub message: String, + /// Additional context as JSON. + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +impl ActivityLogEntry { + /// Create a new activity log entry. + pub fn new(activity_type: impl Into, message: impl Into) -> Self { + Self { + id: 0, + timestamp: Utc::now(), + activity_type: activity_type.into(), + source: None, + issue_id: None, + short_id: None, + message: message.into(), + metadata: None, + } + } + + /// Set the source for this activity. + pub fn with_source(mut self, source: impl Into) -> Self { + self.source = Some(source.into()); + self + } + + /// Set the issue ID for this activity. + pub fn with_issue(mut self, issue_id: impl Into, short_id: impl Into) -> Self { + self.issue_id = Some(issue_id.into()); + self.short_id = Some(short_id.into()); + self + } + + /// Set metadata for this activity. + pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self { + self.metadata = Some(metadata); + self + } +} + +/// Claude execution record with detailed metrics. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClaudeExecution { + /// Database ID. + pub id: i64, + /// Reference to fix_attempts table. + #[serde(skip_serializing_if = "Option::is_none")] + pub attempt_id: Option, + /// When execution started. + pub started_at: DateTime, + /// When execution completed. + #[serde(skip_serializing_if = "Option::is_none")] + pub completed_at: Option>, + /// Duration in seconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration_secs: Option, + /// Process exit code. + #[serde(skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + /// Whether the process timed out. + pub timed_out: bool, + /// Preview of stdout (first/last N chars). + #[serde(skip_serializing_if = "Option::is_none")] + pub stdout_preview: Option, + /// Preview of stderr. + #[serde(skip_serializing_if = "Option::is_none")] + pub stderr_preview: Option, + /// The prompt sent to Claude. + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_used: Option, + /// Hash of the prompt for grouping. + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_hash: Option, + /// Claude model version used. + #[serde(skip_serializing_if = "Option::is_none")] + pub model_version: Option, + /// Working directory path. + #[serde(skip_serializing_if = "Option::is_none")] + pub working_directory: Option, + /// Git branch name. + #[serde(skip_serializing_if = "Option::is_none")] + pub git_branch: Option, + /// Git commit hash before execution. + #[serde(skip_serializing_if = "Option::is_none")] + pub git_commit_before: Option, + /// Git commit hash after execution. + #[serde(skip_serializing_if = "Option::is_none")] + pub git_commit_after: Option, + /// Number of files changed. + #[serde(skip_serializing_if = "Option::is_none")] + pub files_changed: Option, + /// Lines added. + #[serde(skip_serializing_if = "Option::is_none")] + pub lines_added: Option, + /// Lines removed. + #[serde(skip_serializing_if = "Option::is_none")] + pub lines_removed: Option, +} + +impl ClaudeExecution { + /// Create a new execution record with the start time set to now. + pub fn new() -> Self { + Self { + id: 0, + attempt_id: None, + started_at: Utc::now(), + completed_at: None, + duration_secs: None, + exit_code: None, + timed_out: false, + stdout_preview: None, + stderr_preview: None, + prompt_used: None, + prompt_hash: None, + model_version: None, + working_directory: None, + git_branch: None, + git_commit_before: None, + git_commit_after: None, + files_changed: None, + lines_added: None, + lines_removed: None, + } + } + + /// Set the attempt ID. + pub fn with_attempt_id(mut self, attempt_id: i64) -> Self { + self.attempt_id = Some(attempt_id); + self + } + + /// Mark the execution as complete. + pub fn complete(&mut self, exit_code: Option, timed_out: bool) { + let now = Utc::now(); + self.completed_at = Some(now); + self.duration_secs = Some((now - self.started_at).num_milliseconds() as f64 / 1000.0); + self.exit_code = exit_code; + self.timed_out = timed_out; + } +} + +impl Default for ClaudeExecution { + fn default() -> Self { + Self::new() + } +} + +/// PR review feedback for learning. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrReviewRecord { + /// Database ID. + pub id: i64, + /// Reference to fix_attempts table. + #[serde(skip_serializing_if = "Option::is_none")] + pub attempt_id: Option, + /// PR URL. + pub pr_url: String, + /// Reviewer username. + #[serde(skip_serializing_if = "Option::is_none")] + pub reviewer: Option, + /// Review state (approved, changes_requested, commented). + #[serde(skip_serializing_if = "Option::is_none")] + pub review_state: Option, + /// When the review was submitted. + #[serde(skip_serializing_if = "Option::is_none")] + pub submitted_at: Option>, + /// Review body/comment. + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + /// Computed sentiment (positive, negative, neutral). + #[serde(skip_serializing_if = "Option::is_none")] + pub sentiment: Option, + /// Extracted improvement suggestions. + #[serde(skip_serializing_if = "Option::is_none")] + pub actionable_feedback: Option, +} + +impl PrReviewRecord { + /// Create a new PR review record. + pub fn new(pr_url: impl Into) -> Self { + Self { + id: 0, + attempt_id: None, + pr_url: pr_url.into(), + reviewer: None, + review_state: None, + submitted_at: None, + body: None, + sentiment: None, + actionable_feedback: None, + } + } +} + +/// Issue embedding for similarity search. +#[derive(Debug, Clone)] +pub struct IssueEmbedding { + /// Database ID. + pub id: i64, + /// Source service (e.g., 'linear', 'sentry'). + pub source: String, + /// Issue ID from the source. + pub issue_id: String, + /// Human-readable issue ID. + pub short_id: Option, + /// Issue title. + pub title: Option, + /// The embedding vector (serialized float32). + pub embedding: Vec, + /// Model used to generate the embedding. + pub embedding_model: Option, + /// When the embedding was created. + pub created_at: DateTime, +} + +impl IssueEmbedding { + /// Create a new issue embedding. + pub fn new(source: impl Into, issue_id: impl Into, embedding: Vec) -> Self { + Self { + id: 0, + source: source.into(), + issue_id: issue_id.into(), + short_id: None, + title: None, + embedding, + embedding_model: None, + created_at: Utc::now(), + } + } +} + +/// Error pattern for recurring error analysis. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorPattern { + /// Database ID. + pub id: i64, + /// Hash of the normalized error (for deduplication). + pub pattern_hash: String, + /// Error type (build_failure, test_failure, timeout, claude_error). + #[serde(skip_serializing_if = "Option::is_none")] + pub error_type: Option, + /// The error message. + #[serde(skip_serializing_if = "Option::is_none")] + pub error_message: Option, + /// When first seen. + pub first_seen: DateTime, + /// When last seen. + pub last_seen: DateTime, + /// How many times this pattern occurred. + pub occurrence_count: i32, + /// JSON array of sources that hit this error. + #[serde(skip_serializing_if = "Option::is_none")] + pub sources: Option>, + /// JSON array of example issue IDs. + #[serde(skip_serializing_if = "Option::is_none")] + pub example_issue_ids: Option>, + /// Learned hints for fixing this error. + #[serde(skip_serializing_if = "Option::is_none")] + pub resolution_hints: Option, +} + +impl ErrorPattern { + /// Create a new error pattern. + pub fn new(pattern_hash: impl Into) -> Self { + let now = Utc::now(); + Self { + id: 0, + pattern_hash: pattern_hash.into(), + error_type: None, + error_message: None, + first_seen: now, + last_seen: now, + occurrence_count: 1, + sources: None, + example_issue_ids: None, + resolution_hints: None, + } + } +} + +/// Processing metric for time-series data. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProcessingMetric { + /// Database ID. + pub id: i64, + /// When the metric was recorded. + pub timestamp: DateTime, + /// Metric name (queue_depth, processing_time, success_rate, etc.). + pub metric_name: String, + /// Metric value. + pub metric_value: f64, + /// Optional source for per-source metrics. + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, + /// Additional dimensions as JSON. + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option, +} + +impl ProcessingMetric { + /// Create a new processing metric. + pub fn new(metric_name: impl Into, metric_value: f64) -> Self { + Self { + id: 0, + timestamp: Utc::now(), + metric_name: metric_name.into(), + metric_value, + source: None, + tags: None, + } + } + + /// Set the source for this metric. + pub fn with_source(mut self, source: impl Into) -> Self { + self.source = Some(source.into()); + self + } + + /// Set tags for this metric. + pub fn with_tags(mut self, tags: serde_json::Value) -> Self { + self.tags = Some(tags); + self + } +} + +/// Prompt experiment for A/B testing. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PromptExperiment { + /// Database ID. + pub id: i64, + /// Experiment name. + pub experiment_name: String, + /// Variant (control, variant_a, etc.). + pub variant: String, + /// The prompt template. + pub prompt_template: String, + /// Hash of the prompt. + pub prompt_hash: String, + /// When the experiment was created. + pub created_at: DateTime, + /// Whether this variant is active. + pub active: bool, + /// Number of successful outcomes. + pub success_count: i32, + /// Number of failed outcomes. + pub failure_count: i32, + /// Average time to merge (in hours). + #[serde(skip_serializing_if = "Option::is_none")] + pub avg_time_to_merge: Option, + /// Average review score. + #[serde(skip_serializing_if = "Option::is_none")] + pub avg_review_score: Option, +} + +impl PromptExperiment { + /// Create a new prompt experiment. + pub fn new( + experiment_name: impl Into, + variant: impl Into, + prompt_template: impl Into, + prompt_hash: impl Into, + ) -> Self { + Self { + id: 0, + experiment_name: experiment_name.into(), + variant: variant.into(), + prompt_template: prompt_template.into(), + prompt_hash: prompt_hash.into(), + created_at: Utc::now(), + active: true, + success_count: 0, + failure_count: 0, + avg_time_to_merge: None, + avg_review_score: None, + } + } +} + +/// Similar issue match. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimilarIssue { + /// Database ID. + pub id: i64, + /// The source issue ID. + pub source_issue_id: String, + /// The similar issue ID. + pub similar_issue_id: String, + /// Similarity score (0.0 to 1.0). + pub similarity_score: f64, + /// When the similarity was computed. + pub computed_at: DateTime, +} + +impl SimilarIssue { + /// Create a new similar issue record. + pub fn new( + source_issue_id: impl Into, + similar_issue_id: impl Into, + similarity_score: f64, + ) -> Self { + Self { + id: 0, + source_issue_id: source_issue_id.into(), + similar_issue_id: similar_issue_id.into(), + similarity_score, + computed_at: Utc::now(), + } + } +} + +/// Analytics summary statistics. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AnalyticsSummary { + /// Overall success rate (0.0 to 1.0). + pub success_rate: f64, + /// Total issues processed. + pub total_processed: i64, + /// Total successful fixes. + pub total_successful: i64, + /// Total merged PRs. + pub total_merged: i64, + /// Average processing time in seconds. + pub avg_processing_time_secs: Option, + /// Average time to merge in hours. + pub avg_time_to_merge_hours: Option, + /// Most common error type. + pub most_common_error: Option, + /// Success rate by source. + pub success_rate_by_source: HashMap, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_issue_creation() { + let issue = Issue::new( + "123", + "PROJ-123", + "Fix bug", + "https://example.com", + "linear", + ); + assert_eq!(issue.id, "123"); + assert_eq!(issue.short_id, "PROJ-123"); + assert_eq!(issue.source, "linear"); + assert_eq!(issue.priority, IssuePriority::None); + assert_eq!(issue.status, IssueStatus::Open); + } + + #[test] + fn test_issue_metadata() { + let mut issue = Issue::new( + "123", + "PROJ-123", + "Fix bug", + "https://example.com", + "linear", + ); + issue.set_metadata("count", 42i64); + assert_eq!(issue.get_metadata::("count"), Some(42)); + } + + #[test] + fn test_match_result() { + let matched = MatchResult::matched("Matches criteria", MatchPriority::High); + assert!(matched.matches); + assert_eq!(matched.priority, MatchPriority::High); + + let not_matched = MatchResult::not_matched("Does not match"); + assert!(!not_matched.matches); + } + + #[test] + fn test_issue_serialization() { + let issue = Issue::new( + "123", + "PROJ-123", + "Fix bug", + "https://example.com", + "linear", + ); + let json = serde_json::to_string(&issue).unwrap(); + let deserialized: Issue = serde_json::from_str(&json).unwrap(); + assert_eq!(issue.id, deserialized.id); + assert_eq!(issue.short_id, deserialized.short_id); + } + + #[test] + fn test_priority_ordering() { + assert!(IssuePriority::Critical > IssuePriority::High); + assert!(IssuePriority::High > IssuePriority::Medium); + assert!(IssuePriority::Medium > IssuePriority::Low); + assert!(IssuePriority::Low > IssuePriority::None); + } + + #[test] + fn test_fix_attempt_status_parsing() { + assert_eq!( + "pending".parse::().unwrap(), + FixAttemptStatus::Pending + ); + assert_eq!( + "SUCCESS".parse::().unwrap(), + FixAttemptStatus::Success + ); + assert_eq!( + "Failed".parse::().unwrap(), + FixAttemptStatus::Failed + ); + assert_eq!( + "merged".parse::().unwrap(), + FixAttemptStatus::Merged + ); + assert_eq!( + "CLOSED".parse::().unwrap(), + FixAttemptStatus::Closed + ); + assert_eq!( + "cannot_fix".parse::().unwrap(), + FixAttemptStatus::CannotFix + ); + } + + #[test] + fn test_fix_attempt_status_parsing_invalid() { + assert!("invalid".parse::().is_err()); + assert!("".parse::().is_err()); + } + + #[test] + fn test_fix_attempt_status_display() { + assert_eq!(FixAttemptStatus::Pending.to_string(), "pending"); + assert_eq!(FixAttemptStatus::Success.to_string(), "success"); + assert_eq!(FixAttemptStatus::Failed.to_string(), "failed"); + assert_eq!(FixAttemptStatus::Merged.to_string(), "merged"); + assert_eq!(FixAttemptStatus::Closed.to_string(), "closed"); + assert_eq!(FixAttemptStatus::CannotFix.to_string(), "cannot_fix"); + } + + #[test] + fn test_issue_priority_display() { + assert_eq!(IssuePriority::None.to_string(), "none"); + assert_eq!(IssuePriority::Low.to_string(), "low"); + assert_eq!(IssuePriority::Medium.to_string(), "medium"); + assert_eq!(IssuePriority::High.to_string(), "high"); + assert_eq!(IssuePriority::Critical.to_string(), "critical"); + } + + #[test] + fn test_issue_status_display() { + assert_eq!(IssueStatus::Open.to_string(), "open"); + assert_eq!(IssueStatus::InProgress.to_string(), "in_progress"); + assert_eq!(IssueStatus::Resolved.to_string(), "resolved"); + assert_eq!(IssueStatus::Ignored.to_string(), "ignored"); + } + + #[test] + fn test_issue_priority_default() { + assert_eq!(IssuePriority::default(), IssuePriority::None); + } + + #[test] + fn test_issue_status_default() { + assert_eq!(IssueStatus::default(), IssueStatus::Open); + } + + #[test] + fn test_match_priority_default() { + assert_eq!(MatchPriority::default(), MatchPriority::Normal); + } + + #[test] + fn test_match_priority_ordering() { + assert!(MatchPriority::Urgent > MatchPriority::High); + assert!(MatchPriority::High > MatchPriority::Normal); + assert!(MatchPriority::Normal > MatchPriority::Low); + } + + #[test] + fn test_issue_with_description() { + let mut issue = Issue::new("1", "T-1", "Title", "url", "src"); + issue.description = Some("Description text".to_string()); + assert_eq!(issue.description.as_deref(), Some("Description text")); + } + + #[test] + fn test_issue_metadata_string() { + let mut issue = Issue::new("1", "T-1", "Title", "url", "src"); + issue.set_metadata("key", "value"); + assert_eq!( + issue.get_metadata::("key"), + Some("value".to_string()) + ); + } + + #[test] + fn test_issue_metadata_bool() { + let mut issue = Issue::new("1", "T-1", "Title", "url", "src"); + issue.set_metadata("flag", true); + assert_eq!(issue.get_metadata::("flag"), Some(true)); + } + + #[test] + fn test_issue_metadata_vec() { + let mut issue = Issue::new("1", "T-1", "Title", "url", "src"); + issue.set_metadata("tags", vec!["a", "b", "c"]); + assert_eq!( + issue.get_metadata::>("tags"), + Some(vec!["a".to_string(), "b".to_string(), "c".to_string()]) + ); + } + + #[test] + fn test_issue_metadata_missing_key() { + let issue = Issue::new("1", "T-1", "Title", "url", "src"); + assert_eq!(issue.get_metadata::("nonexistent"), None); + } + + #[test] + fn test_issue_metadata_wrong_type() { + let mut issue = Issue::new("1", "T-1", "Title", "url", "src"); + issue.set_metadata("number", 42i64); + // Trying to get a number as a string should fail + assert_eq!(issue.get_metadata::("number"), None); + } + + #[test] + fn test_claude_result_success() { + let result = ClaudeResult { + success: true, + output: "PR created".to_string(), + pr_url: Some("https://github.com/test/pr/1".to_string()), + error: None, + }; + assert!(result.success); + assert!(result.pr_url.is_some()); + assert!(result.error.is_none()); + } + + #[test] + fn test_claude_result_failure() { + let result = ClaudeResult { + success: false, + output: "".to_string(), + pr_url: None, + error: Some("Build failed".to_string()), + }; + assert!(!result.success); + assert!(result.pr_url.is_none()); + assert!(result.error.is_some()); + } + + #[test] + fn test_fix_attempt_stats_default() { + let stats = FixAttemptStats::default(); + assert_eq!(stats.total, 0); + assert_eq!(stats.pending, 0); + assert_eq!(stats.success, 0); + assert_eq!(stats.failed, 0); + assert_eq!(stats.merged, 0); + assert_eq!(stats.closed, 0); + assert_eq!(stats.cannot_fix, 0); + assert!(stats.by_source.is_empty()); + } + + #[test] + fn test_source_stats_default() { + let stats = SourceStats::default(); + assert_eq!(stats.total, 0); + assert_eq!(stats.success, 0); + assert_eq!(stats.failed, 0); + assert_eq!(stats.merged, 0); + assert_eq!(stats.closed, 0); + assert_eq!(stats.cannot_fix, 0); + } + + #[test] + fn test_issue_priority_serde() { + let priority = IssuePriority::High; + let json = serde_json::to_string(&priority).unwrap(); + assert_eq!(json, "\"high\""); + let parsed: IssuePriority = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, IssuePriority::High); + } + + #[test] + fn test_issue_status_serde() { + let status = IssueStatus::InProgress; + let json = serde_json::to_string(&status).unwrap(); + assert_eq!(json, "\"in_progress\""); + let parsed: IssueStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, IssueStatus::InProgress); + } + + #[test] + fn test_fix_attempt_status_serde() { + let status = FixAttemptStatus::CannotFix; + let json = serde_json::to_string(&status).unwrap(); + // Note: rename_all = "lowercase" makes this "cannotfix" + assert_eq!(json, "\"cannotfix\""); + let parsed: FixAttemptStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, FixAttemptStatus::CannotFix); + } + + #[test] + fn test_match_result_serde() { + let result = MatchResult::matched("test", MatchPriority::High); + let json = serde_json::to_string(&result).unwrap(); + let parsed: MatchResult = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.matches, result.matches); + assert_eq!(parsed.reason, result.reason); + assert_eq!(parsed.priority, result.priority); + } + + #[test] + fn test_issue_full_serde() { + let mut issue = Issue::new("id", "short", "title", "url", "source"); + issue.description = Some("desc".to_string()); + issue.priority = IssuePriority::Critical; + issue.status = IssueStatus::InProgress; + issue.set_metadata("key", "value"); + issue.created_at = Some(chrono::Utc::now()); + + let json = serde_json::to_string(&issue).unwrap(); + let parsed: Issue = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed.id, issue.id); + assert_eq!(parsed.short_id, issue.short_id); + assert_eq!(parsed.title, issue.title); + assert_eq!(parsed.description, issue.description); + assert_eq!(parsed.url, issue.url); + assert_eq!(parsed.source, issue.source); + assert_eq!(parsed.priority, issue.priority); + assert_eq!(parsed.status, issue.status); + } + + #[test] + fn test_fix_attempt_created_time() { + let attempt = FixAttempt { + id: 1, + source: "linear".to_string(), + issue_id: "123".to_string(), + short_id: "LIN-123".to_string(), + status: FixAttemptStatus::Pending, + pr_url: None, + github_repo: None, + github_pr_number: None, + error_message: None, + attempted_at: chrono::Utc::now(), + resolved_at: None, + merged_at: None, + retry_count: 0, + last_retry_at: None, + }; + + // Verify fields + assert_eq!(attempt.id, 1); + assert_eq!(attempt.source, "linear"); + assert_eq!(attempt.retry_count, 0); + assert!(attempt.resolved_at.is_none()); + } + + #[test] + fn test_fix_attempt_with_pr() { + let attempt = FixAttempt { + id: 1, + source: "linear".to_string(), + issue_id: "123".to_string(), + short_id: "LIN-123".to_string(), + status: FixAttemptStatus::Success, + pr_url: Some("https://github.com/org/repo/pull/42".to_string()), + github_repo: Some("org/repo".to_string()), + github_pr_number: Some(42), + error_message: None, + attempted_at: chrono::Utc::now(), + resolved_at: None, + merged_at: None, + retry_count: 0, + last_retry_at: None, + }; + + assert_eq!( + attempt.pr_url, + Some("https://github.com/org/repo/pull/42".to_string()) + ); + assert_eq!(attempt.github_repo, Some("org/repo".to_string())); + assert_eq!(attempt.github_pr_number, Some(42)); + } + + #[test] + fn test_fix_attempt_status_all_variants() { + assert_eq!(FixAttemptStatus::Pending.to_string(), "pending"); + assert_eq!(FixAttemptStatus::Success.to_string(), "success"); + assert_eq!(FixAttemptStatus::Failed.to_string(), "failed"); + assert_eq!(FixAttemptStatus::Merged.to_string(), "merged"); + assert_eq!(FixAttemptStatus::Closed.to_string(), "closed"); + assert_eq!(FixAttemptStatus::CannotFix.to_string(), "cannot_fix"); + } + + #[test] + fn test_fix_attempt_status_serde_all_variants() { + let statuses = vec![ + FixAttemptStatus::Pending, + FixAttemptStatus::Success, + FixAttemptStatus::Failed, + FixAttemptStatus::Merged, + FixAttemptStatus::Closed, + FixAttemptStatus::CannotFix, + ]; + + for status in statuses { + let json = serde_json::to_string(&status).unwrap(); + let parsed: FixAttemptStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, status); + } + } + + #[test] + fn test_issue_priority_serde_all_variants() { + let priorities = vec![ + IssuePriority::None, + IssuePriority::Low, + IssuePriority::Medium, + IssuePriority::High, + IssuePriority::Critical, + ]; + + for priority in priorities { + let json = serde_json::to_string(&priority).unwrap(); + let parsed: IssuePriority = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, priority); + } + } + + #[test] + fn test_issue_status_serde_all_variants() { + let statuses = vec![ + IssueStatus::Open, + IssueStatus::InProgress, + IssueStatus::Resolved, + IssueStatus::Ignored, + ]; + + for status in statuses { + let json = serde_json::to_string(&status).unwrap(); + let parsed: IssueStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, status); + } + } + + #[test] + fn test_match_priority_serde_all_variants() { + let priorities = vec![ + MatchPriority::Low, + MatchPriority::Normal, + MatchPriority::High, + MatchPriority::Urgent, + ]; + + for priority in priorities { + let json = serde_json::to_string(&priority).unwrap(); + let parsed: MatchPriority = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, priority); + } + } + + #[test] + fn test_issue_metadata_number() { + let mut issue = Issue::new("1", "T-1", "Title", "url", "src"); + issue.set_metadata("count", 42i64); + assert_eq!(issue.get_metadata::("count"), Some(42)); + } + + #[test] + fn test_issue_metadata_nested_object() { + let mut issue = Issue::new("1", "T-1", "Title", "url", "src"); + issue.set_metadata("nested", serde_json::json!({"a": 1, "b": "test"})); + + let nested: serde_json::Value = issue.get_metadata("nested").unwrap(); + assert_eq!(nested["a"], 1); + assert_eq!(nested["b"], "test"); + } + + #[test] + fn test_issue_clone() { + let mut original = Issue::new("id", "short", "title", "url", "source"); + original.description = Some("desc".to_string()); + original.priority = IssuePriority::High; + original.set_metadata("key", "value"); + + let cloned = original.clone(); + + assert_eq!(cloned.id, original.id); + assert_eq!(cloned.description, original.description); + assert_eq!(cloned.priority, original.priority); + } + + #[test] + fn test_match_result_with_empty_reason() { + let result = MatchResult::not_matched(""); + assert!(!result.matches); + assert!(result.reason.is_empty()); + } + + #[test] + #[allow(clippy::field_reassign_with_default)] + fn test_fix_attempt_stats_with_data() { + let mut stats = FixAttemptStats::default(); + stats.total = 100; + stats.pending = 10; + stats.success = 50; + stats.failed = 20; + stats.merged = 15; + stats.closed = 3; + stats.cannot_fix = 2; + + assert_eq!(stats.total, 100); + assert_eq!( + stats.pending + + stats.success + + stats.failed + + stats.merged + + stats.closed + + stats.cannot_fix, + 100 + ); + } + + #[test] + #[allow(clippy::field_reassign_with_default)] + fn test_source_stats_with_data() { + let mut stats = SourceStats::default(); + stats.total = 50; + stats.success = 30; + stats.failed = 10; + stats.merged = 8; + stats.closed = 2; + stats.cannot_fix = 0; + + assert_eq!(stats.total, 50); + } + + #[test] + fn test_claude_result_empty_output() { + let result = ClaudeResult { + success: false, + output: "".to_string(), + pr_url: None, + error: Some("No output".to_string()), + }; + + assert!(result.output.is_empty()); + assert!(result.error.is_some()); + } + + #[test] + fn test_issue_with_updated_at() { + let mut issue = Issue::new("1", "T-1", "Title", "url", "src"); + assert!(issue.updated_at.is_none()); + + issue.updated_at = Some(chrono::Utc::now()); + assert!(issue.updated_at.is_some()); + } + + #[test] + fn test_match_result_debug_format() { + let result = MatchResult::matched("Test reason", MatchPriority::High); + let debug_str = format!("{:?}", result); + assert!(debug_str.contains("matches")); + assert!(debug_str.contains("priority")); + } + + #[test] + fn test_issue_debug_format() { + let issue = Issue::new("1", "T-1", "Title", "url", "src"); + let debug_str = format!("{:?}", issue); + assert!(debug_str.contains("Issue")); + } + + // Tests for validate_issue_id + + #[test] + fn test_validate_issue_id_valid() { + assert!(validate_issue_id("PROJ-123").is_ok()); + assert!(validate_issue_id("abc123").is_ok()); + assert!(validate_issue_id("simple_id").is_ok()); + assert!(validate_issue_id("ID-WITH-DASHES").is_ok()); + assert!(validate_issue_id("123456").is_ok()); + assert!(validate_issue_id("a").is_ok()); + } + + #[test] + fn test_validate_issue_id_empty() { + let result = validate_issue_id(""); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("empty")); + } + + #[test] + fn test_validate_issue_id_too_long() { + let long_id = "x".repeat(MAX_ISSUE_ID_LENGTH + 1); + let result = validate_issue_id(&long_id); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("maximum length")); + } + + #[test] + fn test_validate_issue_id_at_max_length() { + let max_id = "x".repeat(MAX_ISSUE_ID_LENGTH); + assert!(validate_issue_id(&max_id).is_ok()); + } + + #[test] + fn test_validate_issue_id_path_traversal() { + assert!(validate_issue_id("..").is_err()); + assert!(validate_issue_id("../etc/passwd").is_err()); + assert!(validate_issue_id("foo..bar").is_err()); + assert!(validate_issue_id("a/../b").is_err()); + } + + #[test] + fn test_validate_issue_id_forward_slash() { + assert!(validate_issue_id("a/b").is_err()); + assert!(validate_issue_id("/leading").is_err()); + assert!(validate_issue_id("trailing/").is_err()); + assert!(validate_issue_id("path/to/something").is_err()); + } + + #[test] + fn test_validate_issue_id_backslash() { + assert!(validate_issue_id("a\\b").is_err()); + assert!(validate_issue_id("\\leading").is_err()); + assert!(validate_issue_id("windows\\path").is_err()); + } + + #[test] + fn test_validate_issue_id_null_byte() { + assert!(validate_issue_id("foo\0bar").is_err()); + assert!(validate_issue_id("\0").is_err()); + } + + #[test] + fn test_validate_issue_id_unicode() { + // Unicode should be allowed (for international issue IDs) + assert!(validate_issue_id("项目-123").is_ok()); + assert!(validate_issue_id("задача-456").is_ok()); + } + + #[test] + fn test_validate_issue_id_special_chars() { + // These special chars should be allowed + assert!(validate_issue_id("ID_with_underscore").is_ok()); + assert!(validate_issue_id("ID-with-dash").is_ok()); + assert!(validate_issue_id("ID.with.dot").is_ok()); + assert!(validate_issue_id("ID:with:colon").is_ok()); + } +} diff --git a/src/watcher.rs b/src/watcher.rs new file mode 100644 index 00000000..abf163bb --- /dev/null +++ b/src/watcher.rs @@ -0,0 +1,2017 @@ +//! Main watcher that coordinates sources, Claude, and notifications. + +use crate::config::Config; +use crate::error::Result; +use crate::inference::{IssueContext, RepoInferrer}; +use crate::notifier::Notifier; +use crate::repo::RepoIndex; +use crate::runner::{ClaudeRunner, ClaudeRunnerConfig}; +use crate::source::IssueSource; +use crate::storage::{classify_error, compute_error_hash, FixAttemptTracker, SqliteTracker}; +use crate::types::{ActivityLogEntry, ErrorPattern, FixAttemptStats, Issue, MatchPriority, MatchResult, ProcessingMetric}; +use serde_json::json; +use std::collections::HashSet; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::RwLock; +use tokio::time::{interval, Duration}; + +/// Options for creating a watcher. +pub struct WatcherOptions { + pub config: Config, + pub sources: Vec>, + pub notifier: Arc, + pub tracker: Arc, + pub inferrer: Option, + pub dry_run: bool, +} + +/// Main watcher that coordinates sources, Claude, and notifications. +pub struct Watcher { + config: Config, + sources: Vec>, + notifier: Arc, + tracker: Arc, + inferrer: Option, + claude: ClaudeRunner, + dry_run: bool, + is_running: AtomicBool, + processing: RwLock>, + active_processing: AtomicUsize, +} + +impl Watcher { + /// Create a new watcher. + pub fn new(options: WatcherOptions) -> Self { + Self { + claude: ClaudeRunner::new( + ClaudeRunnerConfig { + timeout_secs: options.config.claude_timeout_secs, + }, + options.tracker.clone(), + ), + config: options.config, + sources: options.sources, + notifier: options.notifier, + tracker: options.tracker, + inferrer: options.inferrer, + dry_run: options.dry_run, + is_running: AtomicBool::new(false), + processing: RwLock::new(HashSet::new()), + active_processing: AtomicUsize::new(0), + } + } + + /// Build a repository inferrer from config. + pub fn build_inferrer(config: &Config) -> Result> { + if config.known_orgs.is_empty() || config.auto_discover_paths.is_empty() { + tracing::info!("No known_orgs or auto_discover_paths configured, inference disabled"); + return Ok(None); + } + + let index = RepoIndex::build(&config.known_orgs, &config.auto_discover_paths)?; + if index.is_empty() { + tracing::warn!("Repository index is empty, no repos discovered"); + return Ok(None); + } + + tracing::info!( + repos = index.len(), + files = index.total_files(), + "Repository index built for inference" + ); + + Ok(Some(RepoInferrer::new(index))) + } + + // Repository resolution is now handled by the inference engine (RepoInferrer). + // See src/inference/mod.rs for the new implementation. + + /// Start the watcher with polling. + pub async fn start(&self, interval_ms: Option) -> Result<()> { + let poll_interval = interval_ms.unwrap_or(self.config.poll_interval_ms); + + tracing::info!(""); + tracing::info!( + "Starting Claude Watcher{}", + if self.dry_run { " (DRY RUN)" } else { "" } + ); + tracing::info!(" Work dir: {:?}", self.config.work_dir); + tracing::info!(" Known orgs: {}", self.config.known_orgs.len()); + tracing::info!(" Poll interval: {}ms", poll_interval); + tracing::info!( + " Max issues per cycle: {}", + self.config.max_issues_per_cycle + ); + tracing::info!(" Max concurrent: {}", self.config.max_concurrent); + tracing::info!(" Processing delay: {}ms", self.config.processing_delay_ms); + tracing::info!( + " Sources: {}", + self.sources + .iter() + .map(|s| s.display_name()) + .collect::>() + .join(", ") + ); + + if self.dry_run { + tracing::warn!(""); + tracing::warn!(" DRY RUN MODE - No issues will be processed"); + } + + tracing::info!(""); + + self.is_running.store(true, Ordering::SeqCst); + + // Initial poll + self.poll().await?; + + // Set up interval + let mut poll_timer = interval(Duration::from_millis(poll_interval)); + poll_timer.tick().await; // Skip immediate first tick + + while self.is_running.load(Ordering::SeqCst) { + poll_timer.tick().await; + if self.is_running.load(Ordering::SeqCst) { + // Check for PRs to auto-close due to issue state changes + if let Err(e) = self.check_and_auto_close_prs().await { + tracing::debug!(error = %e, "Error checking for auto-close PRs"); + } + + // Poll for new issues + if let Err(e) = self.poll().await { + tracing::error!(component = "watcher", error = %e, "Poll error"); + } + } + } + + Ok(()) + } + + /// Stop the watcher. + /// + /// This sets the running flag to false, which will cause the polling loop to exit + /// after the current cycle completes. The poll() method already waits for active + /// processing to complete before returning. + pub fn stop(&self) { + tracing::info!( + active_count = self.active_processing.load(Ordering::SeqCst), + "Stopping Claude Watcher, waiting for active tasks to complete..." + ); + self.is_running.store(false, Ordering::SeqCst); + } + + /// Stop the watcher and wait for all active processing to drain. + /// + /// This is useful for graceful shutdown scenarios where you want to ensure + /// all in-progress work completes before the application exits. + pub async fn stop_and_drain(&self) { + self.stop(); + + // Wait for any active processing to complete (up to 5 minutes) + let max_wait = std::time::Duration::from_secs(300); + let start = std::time::Instant::now(); + + while self.active_processing.load(Ordering::SeqCst) > 0 { + if start.elapsed() > max_wait { + tracing::warn!( + remaining = self.active_processing.load(Ordering::SeqCst), + "Graceful shutdown timeout reached, some tasks may not have completed" + ); + break; + } + tracing::info!( + active_count = self.active_processing.load(Ordering::SeqCst), + "Waiting for active tasks to complete..." + ); + tokio::time::sleep(Duration::from_secs(2)).await; + } + + tracing::info!("Claude Watcher stopped gracefully"); + } + + /// Check if the watcher is currently running. + pub fn is_running(&self) -> bool { + self.is_running.load(Ordering::SeqCst) + } + + /// Get the count of currently active processing tasks. + pub fn active_count(&self) -> usize { + self.active_processing.load(Ordering::SeqCst) + } + + /// Seed the tracker with existing issues. + pub async fn seed(&self) -> Result { + tracing::info!(""); + tracing::info!("Seeding tracker with existing issues..."); + + let mut results = SeedResult::default(); + + for source in &self.sources { + match source.fetch_issues().await { + Ok(issues) => { + let mut seeded = 0; + for issue in issues { + if !self.tracker.has_attempted(source.name(), &issue.id) { + self.tracker.record_attempt( + source.name(), + &issue.id, + &issue.short_id, + )?; + self.tracker.mark_failed( + source.name(), + &issue.id, + "SEEDED: Marked as seen during initial seed", + )?; + seeded += 1; + } + } + results.by_source.insert(source.name().to_string(), seeded); + results.total += seeded; + tracing::info!(source = source.name(), count = seeded, "Seeded issues"); + } + Err(e) => { + tracing::error!(source = source.name(), error = %e, "Error seeding"); + } + } + } + + tracing::info!(""); + tracing::info!( + "Seeding complete. Total: {} issues marked as seen.", + results.total + ); + tracing::info!("New issues created after this will be processed normally."); + tracing::info!(""); + + Ok(results) + } + + /// Run a single poll cycle. + async fn poll(&self) -> Result<()> { + tracing::info!(""); + tracing::info!( + "[{}] Polling...", + chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S") + ); + + for source in &self.sources { + if let Err(e) = self.poll_source(source).await { + tracing::error!(component = "watcher", source = source.name(), error = %e, "Error polling"); + } + } + + Ok(()) + } + + /// Poll a single source. + async fn poll_source(&self, source: &Arc) -> Result<()> { + tracing::info!(source = source.name(), "Fetching issues..."); + + let issues = source.fetch_issues().await?; + tracing::info!(source = source.name(), count = issues.len(), "Found issues"); + + // Get already attempted issue IDs + let attempted_ids = self.tracker.get_attempted_issue_ids(source.name()); + tracing::info!( + source = source.name(), + count = attempted_ids.len(), + "Already attempted issues" + ); + + // Filter and match criteria + let mut candidates: Vec<(Issue, MatchResult)> = Vec::new(); + + let processing = self.processing.read().await; + for issue in issues { + // Skip if already attempted + if attempted_ids.contains(&issue.id) { + continue; + } + + // Skip if currently processing + let processing_key = format!("{}:{}", source.name(), issue.id); + if processing.contains(&processing_key) { + continue; + } + + let match_result = source.matches_criteria(&issue); + if match_result.matches { + candidates.push((issue, match_result)); + } + } + drop(processing); + + if candidates.is_empty() { + tracing::info!(source = source.name(), "No new issues to process"); + return Ok(()); + } + + // Sort by priority + self.sort_by_priority(&mut candidates); + + // Apply max issues per cycle limit + let to_process: Vec<_> = candidates + .into_iter() + .take(self.config.max_issues_per_cycle) + .collect(); + + let to_process_count = to_process.len(); + let skipped = to_process + .len() + .saturating_sub(self.config.max_issues_per_cycle); + if skipped > 0 { + tracing::info!( + source = source.name(), + count = to_process.len(), + deferred = skipped, + "Will process issues" + ); + } else { + tracing::info!( + source = source.name(), + count = to_process.len(), + "Will process issues" + ); + } + + // In dry-run mode, just show what would be processed + if self.dry_run { + tracing::info!(""); + tracing::info!("[DRY RUN] Would process the following issues:"); + for (issue, match_result) in &to_process { + tracing::info!(" - [{}] {}", issue.short_id, issue.title); + tracing::info!( + " Priority: {:?}, Reason: {}", + match_result.priority, + match_result.reason + ); + tracing::info!(" URL: {}", issue.url); + } + return Ok(()); + } + + // Notify about urgent issues + let urgent_issues: Vec = to_process + .iter() + .filter(|(_, m)| m.priority == MatchPriority::Urgent) + .map(|(i, _)| i.clone()) + .collect(); + + if !urgent_issues.is_empty() { + self.notifier.notify_urgent_issues(&urgent_issues).await?; + } + + // Process issues with rate limiting + for (i, (issue, match_result)) in to_process.into_iter().enumerate() { + if !self.is_running.load(Ordering::SeqCst) { + break; + } + + // Wait for concurrency slot + while self.active_processing.load(Ordering::SeqCst) >= self.config.max_concurrent { + if !self.is_running.load(Ordering::SeqCst) { + return Ok(()); + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + + // Process the issue + let source_clone = Arc::clone(source); + let this = self; + this.process_issue(source_clone, issue, match_result).await; + + // Add delay between starting new issues + if i < self.config.max_issues_per_cycle - 1 && self.config.processing_delay_ms > 0 { + tokio::time::sleep(Duration::from_millis(self.config.processing_delay_ms)).await; + } + } + + // Wait for all processing to complete + while self.active_processing.load(Ordering::SeqCst) > 0 { + tokio::time::sleep(Duration::from_secs(1)).await; + } + + // Record processing metrics (don't fail main operation if this fails) + let metric = ProcessingMetric::new("batch_processed", to_process_count as f64) + .with_source(source.name().to_string()); + if let Err(e) = self.tracker.record_metric(&metric) { + tracing::warn!(error = %e, "Failed to record batch processing metric"); + } + + Ok(()) + } + + /// Process a single issue. + /// + /// Uses the RepoInferrer engine to determine which repository to use + /// for fixing the issue. + async fn process_issue( + &self, + source: Arc, + issue: Issue, + match_result: MatchResult, + ) { + let processing_key = format!("{}:{}", source.name(), issue.id); + + tracing::info!(""); + tracing::info!(short_id = %issue.short_id, title = %issue.title, "Processing issue"); + tracing::info!(short_id = %issue.short_id, reason = %match_result.reason, "Match reason"); + tracing::info!(short_id = %issue.short_id, priority = ?match_result.priority, "Match priority"); + + // Infer the target repository + let inference_start = Instant::now(); + let context = IssueContext::from_issue(&issue); + + let (project_dir, inferred_repo_id) = match &self.inferrer { + Some(inferrer) => { + match inferrer.infer(&issue) { + Some(inferred) => { + let duration_ms = inference_start.elapsed().as_millis() as i64; + tracing::info!( + short_id = %issue.short_id, + repo = %inferred.repo.name, + confidence = %inferred.confidence, + reason = %inferred.reason, + duration_ms = duration_ms, + "Repository inferred" + ); + + // Record inference attempt for analytics + let repo_id = self.record_inference_attempt( + &issue, + &context, + Some(&inferred), + duration_ms, + ); + + let activity = ActivityLogEntry::new( + "repo_inferred", + format!("Inferred repo {} for {}", inferred.repo.name, issue.short_id), + ) + .with_source(issue.source.clone()) + .with_issue(issue.id.clone(), issue.short_id.clone()) + .with_metadata(json!({ + "repo": inferred.repo.name, + "confidence": inferred.confidence.to_string(), + "reason": inferred.reason, + "matched_file": inferred.matched_file + })); + self.tracker.record_activity(&activity).ok(); + + (inferred.repo.path.clone(), repo_id) + } + None => { + let duration_ms = inference_start.elapsed().as_millis() as i64; + tracing::warn!( + short_id = %issue.short_id, + source = %issue.source, + "Could not infer repository for issue" + ); + + // Record failed inference attempt + self.record_inference_attempt(&issue, &context, None, duration_ms); + + let activity = ActivityLogEntry::new( + "inference_failed", + format!("Could not infer repository for {}", issue.short_id), + ) + .with_source(issue.source.clone()) + .with_issue(issue.id.clone(), issue.short_id.clone()) + .with_metadata(json!({ + "extracted_filenames": context.filenames, + "extracted_functions": context.functions + })); + self.tracker.record_activity(&activity).ok(); + + // Fall back to work_dir + (self.config.work_dir.clone(), None) + } + } + } + None => { + tracing::warn!( + short_id = %issue.short_id, + "No inferrer configured, using work_dir" + ); + let activity = ActivityLogEntry::new( + "processing_skipped", + format!("No inferrer configured for {}", issue.short_id), + ) + .with_source(issue.source.clone()) + .with_issue(issue.id.clone(), issue.short_id.clone()) + .with_metadata(json!({ + "reason": "no_inferrer_configured" + })); + self.tracker.record_activity(&activity).ok(); + + (self.config.work_dir.clone(), None) + } + }; + let _ = inferred_repo_id; // Used for analytics tracking + + // Mark as processing + { + let mut processing = self.processing.write().await; + processing.insert(processing_key.clone()); + } + self.active_processing.fetch_add(1, Ordering::SeqCst); + + if let Err(e) = self + .tracker + .record_attempt(source.name(), &issue.id, &issue.short_id) + { + tracing::error!(short_id = %issue.short_id, error = %e, "Failed to record attempt"); + } + + // Get the attempt ID for analytics tracking + let attempt_id = self.tracker + .get_attempt(source.name(), &issue.id) + .ok() + .flatten() + .map(|a| a.id); + + let result = async { + // Notify start + self.notifier.notify_start(&issue).await?; + + // Build context and run Claude with attempt ID for analytics + let context = source.build_issue_context(&issue).await?; + let prompt = self.claude.build_prompt_for_issue(&issue, &context, &project_dir); + let claude_result = self.claude.execute_with_attempt(&prompt, Some(&issue), attempt_id, &project_dir).await?; + + if claude_result.success { + if let Some(ref pr_url) = claude_result.pr_url { + tracing::info!(short_id = %issue.short_id, pr_url = %pr_url, "Success! PR created"); + self.tracker + .mark_success(source.name(), &issue.id, pr_url)?; + self.notifier.notify_success(&issue, pr_url).await?; + + // Record metric for PR creation + let metric = ProcessingMetric::new("pr_created", 1.0) + .with_source(source.name().to_string()); + if let Err(e) = self.tracker.record_metric(&metric) { + tracing::warn!(error = %e, "Failed to record pr_created metric"); + } + + // Log processing_completed activity + let activity = ActivityLogEntry::new( + "processing_completed", + format!("Processing completed for {}", issue.short_id), + ) + .with_source(issue.source.clone()) + .with_issue(issue.id.clone(), issue.short_id.clone()) + .with_metadata(json!({ + "has_pr": true, + "pr_url": pr_url + })); + self.tracker.record_activity(&activity).ok(); + } else { + tracing::info!(short_id = %issue.short_id, "Completed but no PR URL found"); + self.tracker.mark_failed( + source.name(), + &issue.id, + "No PR URL found in output", + )?; + self.notifier.notify_completed(&issue).await?; + + // Log processing_completed activity without PR + let activity = ActivityLogEntry::new( + "processing_completed", + format!("Processing completed for {} (no PR)", issue.short_id), + ) + .with_source(issue.source.clone()) + .with_issue(issue.id.clone(), issue.short_id.clone()) + .with_metadata(json!({ + "has_pr": false, + "pr_url": Option::::None + })); + self.tracker.record_activity(&activity).ok(); + } + } else { + let error = claude_result.error.as_deref().unwrap_or("Unknown error"); + tracing::error!(short_id = %issue.short_id, error = %error, "Failed"); + self.tracker.mark_failed(source.name(), &issue.id, error)?; + self.notifier.notify_failed(&issue, error).await?; + + // Record error pattern for analytics + self.record_error_pattern(source.name(), &issue.id, error); + } + + Ok::<_, crate::error::Error>(()) + } + .await; + + if let Err(ref e) = result { + tracing::error!(short_id = %issue.short_id, error = %e, "Error processing issue"); + let error_str = e.to_string(); + let _ = self + .tracker + .mark_failed(source.name(), &issue.id, &error_str); + let _ = self.notifier.notify_failed(&issue, &error_str).await; + + // Record error pattern for analytics + self.record_error_pattern(source.name(), &issue.id, &error_str); + } + + // Cleanup + { + let mut processing = self.processing.write().await; + processing.remove(&processing_key); + } + self.active_processing.fetch_sub(1, Ordering::SeqCst); + } + + /// Record an error pattern to the analytics database. + fn record_error_pattern(&self, source: &str, issue_id: &str, error_msg: &str) { + let error_type = classify_error(error_msg); + let pattern_hash = compute_error_hash(error_msg); + + let mut pattern = ErrorPattern::new(pattern_hash); + pattern.error_type = Some(error_type.to_string()); + pattern.error_message = Some(error_msg.to_string()); + pattern.sources = Some(vec![source.to_string()]); + pattern.example_issue_ids = Some(vec![issue_id.to_string()]); + + if let Err(e) = self.tracker.record_error_pattern(&pattern) { + tracing::warn!(error = %e, "Failed to record error pattern"); + } + } + + /// Record an inference attempt for analytics. + fn record_inference_attempt( + &self, + issue: &Issue, + context: &IssueContext, + inferred: Option<&crate::inference::InferredRepo>, + duration_ms: i64, + ) -> Option { + // Try to downcast tracker to SqliteTracker for inference recording + // This is a bit awkward but allows us to keep FixAttemptTracker trait simple + let tracker_any = &self.tracker as &dyn std::any::Any; + if let Some(sqlite_tracker) = tracker_any.downcast_ref::>() { + let (confidence, reason, repo_id) = match inferred { + Some(inf) => ( + inf.confidence.to_string(), + inf.reason.clone(), + sqlite_tracker.get_indexed_repo(&inf.repo.name).ok().flatten().map(|r| r.id), + ), + None => ("none".to_string(), "No match found".to_string(), None), + }; + + match sqlite_tracker.record_inference_attempt( + &issue.id, + &issue.source, + &context.filenames, + &context.functions, + &context.keywords, + repo_id, + &confidence, + &reason, + Some(duration_ms as u64), + ) { + Ok(id) => return Some(id), + Err(e) => { + tracing::warn!(error = %e, "Failed to record inference attempt"); + } + } + } + None + } + + /// Manually trigger processing for a specific issue. + pub async fn trigger_issue(&self, source_name: &str, issue_id: &str) -> Result<()> { + let source = self + .sources + .iter() + .find(|s| s.name() == source_name) + .ok_or_else(|| crate::error::Error::source(source_name, "Unknown source"))?; + + tracing::info!( + component = "watcher", + source = source_name, + issue_id = issue_id, + "Manually triggering issue" + ); + + let issue = source.get_issue(issue_id).await?; + let match_result = MatchResult::matched("Manual trigger", MatchPriority::Urgent); + + self.process_issue(Arc::clone(source), issue, match_result) + .await; + + Ok(()) + } + + /// Reset a failed attempt to allow retry. + pub fn reset_attempt(&self, source_name: &str, issue_id: &str) -> Result<()> { + self.tracker.reset_attempt(source_name, issue_id)?; + tracing::info!( + component = "watcher", + source = source_name, + issue_id = issue_id, + "Reset attempt" + ); + Ok(()) + } + + /// Get statistics. + pub fn get_stats(&self) -> Result { + self.tracker.get_stats() + } + + /// Check for PRs that should be auto-closed due to issue state changes. + /// + /// This checks all pending PRs and closes any whose source issue has been + /// resolved, cancelled, or otherwise moved to a terminal state. + pub async fn check_and_auto_close_prs(&self) -> Result> { + let pending_prs = self.tracker.get_pending_prs()?; + let mut auto_closed = Vec::new(); + + for attempt in pending_prs { + // Find the source for this attempt + if let Some(source) = self.sources.iter().find(|s| s.name() == attempt.source) { + // Check if issue is still active + match source.get_issue_status(&attempt.issue_id).await { + Ok(status) if source.is_terminal_status(&status) => { + tracing::info!( + source = %attempt.source, + issue_id = %attempt.issue_id, + short_id = %attempt.short_id, + status = %status, + "Auto-closing PR: issue reached terminal state" + ); + + // Log activity + let activity = ActivityLogEntry::new( + "pr_auto_closed", + format!( + "PR auto-closed: issue {} is now {}", + attempt.short_id, status + ), + ) + .with_source(attempt.source.clone()) + .with_issue(attempt.issue_id.clone(), attempt.short_id.clone()) + .with_metadata(json!({ + "pr_url": attempt.pr_url, + "issue_status": status, + "reason": "issue_terminal_state" + })); + let _ = self.tracker.record_activity(&activity); + + // Mark as closed in tracker + if let Err(e) = + self.tracker.mark_closed(&attempt.source, &attempt.issue_id) + { + tracing::warn!( + error = %e, + "Failed to mark attempt as closed" + ); + } + + // Notify about the auto-close + let issue = Issue::new( + &attempt.issue_id, + &attempt.short_id, + format!("Issue {} (auto-closed)", attempt.short_id), + attempt.pr_url.clone().unwrap_or_default(), + &attempt.source, + ); + let _ = self + .notifier + .notify_failed( + &issue, + &format!("PR auto-closed: source issue is now {}", status), + ) + .await; + + if let Some(ref url) = attempt.pr_url { + auto_closed.push(url.clone()); + } + } + Ok(_) => {} // Issue still active + Err(e) => { + tracing::debug!( + source = %attempt.source, + issue_id = %attempt.issue_id, + error = %e, + "Failed to check issue status for auto-close" + ); + } + } + } + } + + if !auto_closed.is_empty() { + tracing::info!(count = auto_closed.len(), "Auto-closed PRs due to issue state changes"); + } + + Ok(auto_closed) + } + + /// Sort issues by priority for processing order. + fn sort_by_priority(&self, issues: &mut [(Issue, MatchResult)]) { + issues.sort_by(|a, b| { + // Sort by match priority first + let priority_cmp = priority_order(&a.1.priority).cmp(&priority_order(&b.1.priority)); + if priority_cmp != std::cmp::Ordering::Equal { + return priority_cmp; + } + + // Then by issue priority + issue_priority_order(&b.0.priority).cmp(&issue_priority_order(&a.0.priority)) + }); + } +} + +fn priority_order(p: &MatchPriority) -> u8 { + match p { + MatchPriority::Urgent => 0, + MatchPriority::High => 1, + MatchPriority::Normal => 2, + MatchPriority::Low => 3, + } +} + +fn issue_priority_order(p: &crate::types::IssuePriority) -> u8 { + match p { + crate::types::IssuePriority::Critical => 4, + crate::types::IssuePriority::High => 3, + crate::types::IssuePriority::Medium => 2, + crate::types::IssuePriority::Low => 1, + crate::types::IssuePriority::None => 0, + } +} + +/// Result of seeding operation. +#[derive(Debug, Default)] +pub struct SeedResult { + pub total: usize, + pub by_source: std::collections::HashMap, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::notifier::Notifier; + use crate::reports::Report; + use crate::source::IssueSource; + use crate::storage::SqliteTracker; + use crate::types::IssuePriority; + use async_trait::async_trait; + use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; + + // Mock notifier for testing + struct MockNotifier { + enabled: bool, + call_count: AtomicUsize, + } + + impl MockNotifier { + fn new(enabled: bool) -> Self { + Self { + enabled, + call_count: AtomicUsize::new(0), + } + } + + fn get_call_count(&self) -> usize { + self.call_count.load(AtomicOrdering::SeqCst) + } + } + + #[async_trait] + impl Notifier for MockNotifier { + fn name(&self) -> &str { + "mock" + } + fn is_enabled(&self) -> bool { + self.enabled + } + async fn notify_start(&self, _issue: &Issue) -> Result<()> { + self.call_count.fetch_add(1, AtomicOrdering::SeqCst); + Ok(()) + } + async fn notify_success(&self, _issue: &Issue, _pr_url: &str) -> Result<()> { + self.call_count.fetch_add(1, AtomicOrdering::SeqCst); + Ok(()) + } + async fn notify_completed(&self, _issue: &Issue) -> Result<()> { + self.call_count.fetch_add(1, AtomicOrdering::SeqCst); + Ok(()) + } + async fn notify_failed(&self, _issue: &Issue, _error: &str) -> Result<()> { + self.call_count.fetch_add(1, AtomicOrdering::SeqCst); + Ok(()) + } + async fn notify_status(&self, _message: &str) -> Result<()> { + self.call_count.fetch_add(1, AtomicOrdering::SeqCst); + Ok(()) + } + async fn notify_urgent_issues(&self, _issues: &[Issue]) -> Result<()> { + self.call_count.fetch_add(1, AtomicOrdering::SeqCst); + Ok(()) + } + async fn notify_merged(&self, _issue: &Issue, _pr_url: &str) -> Result<()> { + self.call_count.fetch_add(1, AtomicOrdering::SeqCst); + Ok(()) + } + async fn notify_report(&self, _report: &Report) -> Result<()> { + self.call_count.fetch_add(1, AtomicOrdering::SeqCst); + Ok(()) + } + } + + // Mock source for testing + struct MockSource { + name: String, + issues: Vec, + } + + impl MockSource { + fn new(name: &str) -> Self { + Self { + name: name.to_string(), + issues: vec![], + } + } + + fn with_issues(name: &str, issues: Vec) -> Self { + Self { + name: name.to_string(), + issues, + } + } + } + + #[async_trait] + impl IssueSource for MockSource { + fn name(&self) -> &str { + &self.name + } + fn display_name(&self) -> &str { + &self.name + } + async fn fetch_issues(&self) -> Result> { + Ok(self.issues.clone()) + } + fn matches_criteria(&self, _issue: &Issue) -> MatchResult { + MatchResult::matched("Mock match", MatchPriority::Normal) + } + async fn build_issue_context(&self, issue: &Issue) -> Result { + Ok(format!("Context for {}", issue.short_id)) + } + async fn get_issue(&self, id: &str) -> Result { + self.issues + .iter() + .find(|i| i.id == id) + .cloned() + .ok_or_else(|| crate::error::Error::source(&self.name, "Issue not found")) + } + } + + fn test_issue() -> Issue { + Issue::new( + "123", + "TEST-123", + "Test Issue", + "https://example.com", + "test", + ) + } + + fn test_issue_with_priority(id: &str, priority: IssuePriority) -> Issue { + let mut issue = Issue::new( + id, + format!("TEST-{}", id), + "Test", + "https://example.com", + "test", + ); + issue.priority = priority; + issue + } + + fn test_config() -> Config { + Config { + work_dir: std::path::PathBuf::from("/tmp/repos"), + known_orgs: vec!["test-org".to_string()], + auto_discover_paths: vec![], + poll_interval_ms: 60000, + webhook_port: 8080, + db_path: std::path::PathBuf::from(":memory:"), + max_issues_per_cycle: 5, + max_concurrent: 2, + processing_delay_ms: 1000, + max_activity_entries: 100, + ipc_timeout_secs: 30, + claude_timeout_secs: 21600, + discord: crate::config::DiscordConfig::default(), + email: crate::config::EmailConfig::default(), + sms: crate::config::SmsConfig::default(), + push: crate::config::PushConfig::default(), + github: crate::config::GitHubConfig::default(), + retry: crate::config::RetryConfig::default(), + linear: None, + sentry: None, + } + } + + fn create_test_watcher( + notifier: Arc, + tracker: Arc, + sources: Vec>, + dry_run: bool, + ) -> Watcher { + Watcher::new(WatcherOptions { + config: test_config(), + sources, + notifier, + tracker, + inferrer: None, // Tests don't need inference + dry_run, + }) + } + + #[test] + fn test_watcher_new() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + let sources: Vec> = vec![Arc::new(MockSource::new("test"))]; + + let watcher = create_test_watcher(notifier, tracker, sources, false); + + assert!(!watcher.dry_run); + assert!(!watcher.is_running.load(Ordering::SeqCst)); + assert_eq!(watcher.active_processing.load(Ordering::SeqCst), 0); + } + + #[test] + fn test_watcher_new_dry_run() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + let sources: Vec> = vec![]; + + let watcher = create_test_watcher(notifier, tracker, sources, true); + + assert!(watcher.dry_run); + } + + #[test] + fn test_watcher_stop() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + let sources: Vec> = vec![]; + + let watcher = create_test_watcher(notifier, tracker, sources, false); + watcher.is_running.store(true, Ordering::SeqCst); + + assert!(watcher.is_running.load(Ordering::SeqCst)); + watcher.stop(); + assert!(!watcher.is_running.load(Ordering::SeqCst)); + } + + #[test] + fn test_watcher_get_stats() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + let sources: Vec> = vec![]; + + let watcher = create_test_watcher(notifier, tracker, sources, false); + + let stats = watcher.get_stats().unwrap(); + assert_eq!(stats.total, 0); + assert_eq!(stats.success, 0); + } + + #[test] + fn test_watcher_reset_attempt() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + let sources: Vec> = vec![]; + + // Record an attempt first + tracker.record_attempt("test", "123", "TEST-123").unwrap(); + + let watcher = create_test_watcher(notifier, tracker.clone(), sources, false); + + assert!(tracker.has_attempted("test", "123")); + watcher.reset_attempt("test", "123").unwrap(); + assert!(!tracker.has_attempted("test", "123")); + } + + #[test] + fn test_watcher_sort_by_priority() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + let sources: Vec> = vec![]; + + let watcher = create_test_watcher(notifier, tracker, sources, false); + + let mut issues = vec![ + ( + test_issue(), + MatchResult::matched("Low", MatchPriority::Low), + ), + ( + test_issue(), + MatchResult::matched("Urgent", MatchPriority::Urgent), + ), + ( + test_issue(), + MatchResult::matched("High", MatchPriority::High), + ), + ( + test_issue(), + MatchResult::matched("Normal", MatchPriority::Normal), + ), + ]; + + watcher.sort_by_priority(&mut issues); + + assert_eq!(issues[0].1.priority, MatchPriority::Urgent); + assert_eq!(issues[1].1.priority, MatchPriority::High); + assert_eq!(issues[2].1.priority, MatchPriority::Normal); + assert_eq!(issues[3].1.priority, MatchPriority::Low); + } + + #[test] + fn test_watcher_sort_by_priority_with_issue_priority() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + let sources: Vec> = vec![]; + + let watcher = create_test_watcher(notifier, tracker, sources, false); + + // All same match priority, different issue priorities + let mut issues = vec![ + ( + test_issue_with_priority("1", IssuePriority::Low), + MatchResult::matched("Same", MatchPriority::Normal), + ), + ( + test_issue_with_priority("2", IssuePriority::Critical), + MatchResult::matched("Same", MatchPriority::Normal), + ), + ( + test_issue_with_priority("3", IssuePriority::Medium), + MatchResult::matched("Same", MatchPriority::Normal), + ), + ]; + + watcher.sort_by_priority(&mut issues); + + // Should be sorted by issue priority (Critical first) + assert_eq!(issues[0].0.priority, IssuePriority::Critical); + assert_eq!(issues[1].0.priority, IssuePriority::Medium); + assert_eq!(issues[2].0.priority, IssuePriority::Low); + } + + #[test] + fn test_watcher_sort_empty_list() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + let sources: Vec> = vec![]; + + let watcher = create_test_watcher(notifier, tracker, sources, false); + + let mut issues: Vec<(Issue, MatchResult)> = vec![]; + watcher.sort_by_priority(&mut issues); + assert!(issues.is_empty()); + } + + #[test] + fn test_watcher_sort_single_item() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + let sources: Vec> = vec![]; + + let watcher = create_test_watcher(notifier, tracker, sources, false); + + let mut issues = vec![( + test_issue(), + MatchResult::matched("Single", MatchPriority::High), + )]; + watcher.sort_by_priority(&mut issues); + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].1.priority, MatchPriority::High); + } + + #[tokio::test] + async fn test_watcher_seed_empty_sources() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + let sources: Vec> = vec![]; + + let watcher = create_test_watcher(notifier, tracker, sources, false); + + let result = watcher.seed().await.unwrap(); + assert_eq!(result.total, 0); + assert!(result.by_source.is_empty()); + } + + #[tokio::test] + async fn test_watcher_seed_with_issues() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + + let issues = vec![ + Issue::new("1", "T-1", "Issue 1", "http://example.com/1", "mock"), + Issue::new("2", "T-2", "Issue 2", "http://example.com/2", "mock"), + ]; + let source = Arc::new(MockSource::with_issues("mock", issues)) as Arc; + let sources = vec![source]; + + let watcher = create_test_watcher(notifier, tracker.clone(), sources, false); + + let result = watcher.seed().await.unwrap(); + assert_eq!(result.total, 2); + assert_eq!(*result.by_source.get("mock").unwrap(), 2); + + // Verify issues are marked as seen + assert!(tracker.has_attempted("mock", "1")); + assert!(tracker.has_attempted("mock", "2")); + } + + #[tokio::test] + async fn test_watcher_seed_skips_already_seeded() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + + // Pre-seed one issue + tracker.record_attempt("mock", "1", "T-1").unwrap(); + + let issues = vec![ + Issue::new("1", "T-1", "Issue 1", "http://example.com/1", "mock"), + Issue::new("2", "T-2", "Issue 2", "http://example.com/2", "mock"), + ]; + let source = Arc::new(MockSource::with_issues("mock", issues)) as Arc; + let sources = vec![source]; + + let watcher = create_test_watcher(notifier, tracker.clone(), sources, false); + + let result = watcher.seed().await.unwrap(); + // Only 1 new issue should be seeded + assert_eq!(result.total, 1); + assert_eq!(*result.by_source.get("mock").unwrap(), 1); + } + + #[tokio::test] + async fn test_watcher_trigger_issue_unknown_source() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + let sources: Vec> = vec![]; + + let watcher = create_test_watcher(notifier, tracker, sources, false); + + let result = watcher.trigger_issue("nonexistent", "123").await; + assert!(result.is_err()); + } + + #[test] + fn test_seed_result_default() { + let result = SeedResult::default(); + assert_eq!(result.total, 0); + assert!(result.by_source.is_empty()); + } + + #[test] + fn test_seed_result_debug() { + let result = SeedResult { + total: 5, + by_source: [("test".to_string(), 5)].into_iter().collect(), + }; + let debug_str = format!("{:?}", result); + assert!(debug_str.contains("total: 5")); + assert!(debug_str.contains("test")); + } + + #[test] + fn test_watcher_options_struct_fields() { + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + let notifier = Arc::new(MockNotifier::new(true)); + let sources: Vec> = vec![]; + + let options = WatcherOptions { + config: test_config(), + sources: sources.clone(), + notifier: notifier.clone(), + tracker: tracker.clone(), + inferrer: None, + dry_run: true, + }; + + assert!(options.dry_run); + assert!(options.sources.is_empty()); + assert!(options.inferrer.is_none()); + } + + #[test] + fn test_priority_ordering() { + assert!(priority_order(&MatchPriority::Urgent) < priority_order(&MatchPriority::High)); + assert!(priority_order(&MatchPriority::High) < priority_order(&MatchPriority::Normal)); + assert!(priority_order(&MatchPriority::Normal) < priority_order(&MatchPriority::Low)); + } + + #[test] + fn test_issue_priority_ordering() { + use crate::types::IssuePriority; + + assert!( + issue_priority_order(&IssuePriority::Critical) + > issue_priority_order(&IssuePriority::High) + ); + assert!( + issue_priority_order(&IssuePriority::High) + > issue_priority_order(&IssuePriority::Medium) + ); + assert!( + issue_priority_order(&IssuePriority::Medium) + > issue_priority_order(&IssuePriority::Low) + ); + assert!( + issue_priority_order(&IssuePriority::Low) > issue_priority_order(&IssuePriority::None) + ); + } + + #[test] + fn test_priority_order_values() { + assert_eq!(priority_order(&MatchPriority::Urgent), 0); + assert_eq!(priority_order(&MatchPriority::High), 1); + assert_eq!(priority_order(&MatchPriority::Normal), 2); + assert_eq!(priority_order(&MatchPriority::Low), 3); + } + + #[test] + fn test_issue_priority_order_values() { + use crate::types::IssuePriority; + + assert_eq!(issue_priority_order(&IssuePriority::Critical), 4); + assert_eq!(issue_priority_order(&IssuePriority::High), 3); + assert_eq!(issue_priority_order(&IssuePriority::Medium), 2); + assert_eq!(issue_priority_order(&IssuePriority::Low), 1); + assert_eq!(issue_priority_order(&IssuePriority::None), 0); + } + + #[test] + fn test_match_priority_sorting() { + // Verify that sorting by priority_order puts Urgent first + let mut priorities = [ + MatchPriority::Low, + MatchPriority::Urgent, + MatchPriority::Normal, + MatchPriority::High, + ]; + + priorities.sort_by_key(priority_order); + + assert_eq!(priorities[0], MatchPriority::Urgent); + assert_eq!(priorities[1], MatchPriority::High); + assert_eq!(priorities[2], MatchPriority::Normal); + assert_eq!(priorities[3], MatchPriority::Low); + } + + #[test] + fn test_issue_priority_sorting() { + use crate::types::IssuePriority; + + let mut priorities = [ + IssuePriority::None, + IssuePriority::Critical, + IssuePriority::Low, + IssuePriority::High, + IssuePriority::Medium, + ]; + + priorities.sort_by_key(|p| std::cmp::Reverse(issue_priority_order(p))); + + assert_eq!(priorities[0], IssuePriority::Critical); + assert_eq!(priorities[1], IssuePriority::High); + assert_eq!(priorities[2], IssuePriority::Medium); + assert_eq!(priorities[3], IssuePriority::Low); + assert_eq!(priorities[4], IssuePriority::None); + } + + #[test] + fn test_watcher_options_struct() { + use crate::storage::SqliteTracker; + + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + // Verify tracker can be created + assert!(tracker.get_stats().is_ok()); + } + + #[test] + fn test_match_result_matched() { + let result = MatchResult::matched("Reason", MatchPriority::High); + assert!(result.matches); + assert_eq!(result.priority, MatchPriority::High); + assert_eq!(result.reason, "Reason"); + } + + #[test] + fn test_match_result_not_matched() { + let result = MatchResult::not_matched("Not matching reason"); + assert!(!result.matches); + assert_eq!(result.priority, MatchPriority::Normal); + assert_eq!(result.reason, "Not matching reason"); + } + + #[test] + fn test_fix_attempt_stats_default() { + let stats = FixAttemptStats::default(); + assert_eq!(stats.total, 0); + assert_eq!(stats.success, 0); + assert_eq!(stats.failed, 0); + assert_eq!(stats.pending, 0); + } + + #[test] + fn test_match_priority_variants() { + // Test all MatchPriority variants exist and can be compared + let urgent = MatchPriority::Urgent; + let high = MatchPriority::High; + let normal = MatchPriority::Normal; + let low = MatchPriority::Low; + + assert_ne!(urgent, high); + assert_ne!(high, normal); + assert_ne!(normal, low); + } + + #[test] + fn test_priority_order_all_priorities() { + // Ensure all priorities have unique order values + let orders: Vec = vec![ + priority_order(&MatchPriority::Urgent), + priority_order(&MatchPriority::High), + priority_order(&MatchPriority::Normal), + priority_order(&MatchPriority::Low), + ]; + + // All unique + let unique: HashSet<_> = orders.iter().collect(); + assert_eq!(unique.len(), 4); + + // Urgent is lowest (highest priority) + assert_eq!( + *orders.iter().min().unwrap(), + priority_order(&MatchPriority::Urgent) + ); + } + + #[test] + fn test_issue_priority_order_all_priorities() { + use crate::types::IssuePriority; + + let orders: Vec = vec![ + issue_priority_order(&IssuePriority::Critical), + issue_priority_order(&IssuePriority::High), + issue_priority_order(&IssuePriority::Medium), + issue_priority_order(&IssuePriority::Low), + issue_priority_order(&IssuePriority::None), + ]; + + // All unique + let unique: HashSet<_> = orders.iter().collect(); + assert_eq!(unique.len(), 5); + + // Critical is highest + assert_eq!( + *orders.iter().max().unwrap(), + issue_priority_order(&IssuePriority::Critical) + ); + // None is lowest + assert_eq!( + *orders.iter().min().unwrap(), + issue_priority_order(&IssuePriority::None) + ); + } + + #[test] + fn test_match_result_default_priority() { + let result = MatchResult::matched("Test", MatchPriority::Normal); + assert_eq!(result.priority, MatchPriority::Normal); + } + + #[test] + fn test_fix_attempt_stats_with_values() { + let stats = FixAttemptStats { + total: 100, + success: 75, + failed: 20, + pending: 5, + merged: 50, + closed: 10, + cannot_fix: 5, + by_source: std::collections::HashMap::new(), + }; + + assert_eq!(stats.total, 100); + assert_eq!(stats.success, 75); + assert_eq!(stats.failed, 20); + assert_eq!(stats.pending, 5); + assert_eq!(stats.merged, 50); + assert_eq!(stats.closed, 10); + assert_eq!(stats.cannot_fix, 5); + } + + #[test] + fn test_match_result_urgent_priority() { + let result = MatchResult::matched("Urgent issue", MatchPriority::Urgent); + assert!(result.matches); + assert_eq!(result.priority, MatchPriority::Urgent); + } + + #[test] + fn test_match_result_low_priority() { + let result = MatchResult::matched("Low priority", MatchPriority::Low); + assert!(result.matches); + assert_eq!(result.priority, MatchPriority::Low); + } + + #[test] + fn test_empty_priority_sorting() { + let priorities: Vec = vec![]; + let sorted: Vec<_> = priorities.to_vec(); + + assert!(sorted.is_empty()); + } + + #[test] + fn test_single_priority_sorting() { + let mut priorities = [MatchPriority::High]; + priorities.sort_by_key(priority_order); + assert_eq!(priorities[0], MatchPriority::High); + } + + #[test] + fn test_duplicate_priorities_sorting() { + let mut priorities = [ + MatchPriority::Normal, + MatchPriority::Urgent, + MatchPriority::Normal, + MatchPriority::Urgent, + ]; + priorities.sort_by_key(priority_order); + + // First two should be Urgent + assert_eq!(priorities[0], MatchPriority::Urgent); + assert_eq!(priorities[1], MatchPriority::Urgent); + // Last two should be Normal + assert_eq!(priorities[2], MatchPriority::Normal); + assert_eq!(priorities[3], MatchPriority::Normal); + } + + #[tokio::test] + async fn test_watcher_poll_with_no_sources() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + let sources: Vec> = vec![]; + + let watcher = create_test_watcher(notifier, tracker, sources, false); + + // Poll should succeed even with no sources + let result = watcher.poll().await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_watcher_poll_dry_run() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + + let issues = vec![Issue::new( + "1", + "T-1", + "Issue 1", + "http://example.com/1", + "mock", + )]; + let source = Arc::new(MockSource::with_issues("mock", issues)) as Arc; + let sources = vec![source]; + + let watcher = create_test_watcher(notifier.clone(), tracker.clone(), sources, true); + + // Poll in dry run mode - should succeed + let result = watcher.poll().await; + assert!(result.is_ok()); + + // In dry run mode, issues are NOT marked as attempted (just logged) + assert!(!tracker.has_attempted("mock", "1")); + } + + #[tokio::test] + async fn test_watcher_poll_with_multiple_sources() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + + let source1 = Arc::new(MockSource::with_issues( + "source1", + vec![Issue::new( + "1", + "S1-1", + "Issue 1", + "http://example.com/1", + "source1", + )], + )) as Arc; + + let source2 = Arc::new(MockSource::with_issues( + "source2", + vec![Issue::new( + "2", + "S2-1", + "Issue 2", + "http://example.com/2", + "source2", + )], + )) as Arc; + + let sources = vec![source1, source2]; + let watcher = create_test_watcher(notifier, tracker.clone(), sources, true); + + let result = watcher.poll().await; + assert!(result.is_ok()); + } + + #[test] + fn test_watcher_is_running_flag() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + let sources: Vec> = vec![]; + + let watcher = create_test_watcher(notifier, tracker, sources, false); + + // Initially not running + assert!(!watcher.is_running.load(Ordering::SeqCst)); + + // Set running + watcher.is_running.store(true, Ordering::SeqCst); + assert!(watcher.is_running.load(Ordering::SeqCst)); + + // Stop should clear flag + watcher.stop(); + assert!(!watcher.is_running.load(Ordering::SeqCst)); + } + + #[test] + fn test_watcher_active_processing_counter() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + let sources: Vec> = vec![]; + + let watcher = create_test_watcher(notifier, tracker, sources, false); + + // Initially 0 + assert_eq!(watcher.active_processing.load(Ordering::SeqCst), 0); + + // Increment + watcher.active_processing.fetch_add(1, Ordering::SeqCst); + assert_eq!(watcher.active_processing.load(Ordering::SeqCst), 1); + + // Decrement + watcher.active_processing.fetch_sub(1, Ordering::SeqCst); + assert_eq!(watcher.active_processing.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn test_watcher_poll_source_with_empty_issues() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + let source = Arc::new(MockSource::new("empty")) as Arc; + let sources = vec![source.clone()]; + + let watcher = create_test_watcher(notifier, tracker, sources, false); + + // poll_source returns Result<()>, not Vec + let result = watcher.poll_source(&source).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_watcher_poll_source_with_issues() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + + let issues = vec![ + Issue::new("1", "T-1", "Issue 1", "http://example.com/1", "test"), + Issue::new("2", "T-2", "Issue 2", "http://example.com/2", "test"), + ]; + let source = Arc::new(MockSource::with_issues("test", issues)) as Arc; + let sources = vec![source.clone()]; + + let watcher = create_test_watcher(notifier, tracker.clone(), sources, true); // dry run + + // poll_source returns Result<()> + let result = watcher.poll_source(&source).await; + assert!(result.is_ok()); + // In dry run mode, issues are NOT recorded (just logged) + assert!(!tracker.has_attempted("test", "1")); + assert!(!tracker.has_attempted("test", "2")); + } + + #[tokio::test] + async fn test_watcher_poll_source_skips_attempted() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + + // Pre-mark one issue as attempted + tracker.record_attempt("test", "1", "T-1").unwrap(); + + let issues = vec![ + Issue::new( + "1", + "T-1", + "Already Attempted", + "http://example.com/1", + "test", + ), + Issue::new("2", "T-2", "New Issue", "http://example.com/2", "test"), + ]; + let source = Arc::new(MockSource::with_issues("test", issues)) as Arc; + let sources = vec![source.clone()]; + + let watcher = create_test_watcher(notifier, tracker.clone(), sources, true); // dry run + + // poll_source returns Result<()> + let result = watcher.poll_source(&source).await; + assert!(result.is_ok()); + // Only the pre-existing one should be in tracker (dry run doesn't add new ones) + assert!(tracker.has_attempted("test", "1")); + assert!(!tracker.has_attempted("test", "2")); // Not recorded in dry run + } + + #[tokio::test] + async fn test_watcher_trigger_issue_with_known_source() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + + let issues = vec![Issue::new( + "123", + "T-123", + "Test Issue", + "http://example.com/123", + "mock", + )]; + let source = Arc::new(MockSource::with_issues("mock", issues)) as Arc; + let sources = vec![source]; + + let watcher = create_test_watcher(notifier, tracker, sources, true); // dry run + + let result = watcher.trigger_issue("mock", "123").await; + // Should succeed in dry run (doesn't actually process) + assert!(result.is_ok()); + } + + #[test] + fn test_watcher_processing_set() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + let sources: Vec> = vec![]; + + let watcher = create_test_watcher(notifier, tracker, sources, false); + + // Use tokio runtime for the async lock + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + // Initially empty + { + let processing = watcher.processing.read().await; + assert!(processing.is_empty()); + } + + // Add item + { + let mut processing = watcher.processing.write().await; + processing.insert("test:123".to_string()); + } + + // Verify added + { + let processing = watcher.processing.read().await; + assert!(processing.contains("test:123")); + } + + // Remove item + { + let mut processing = watcher.processing.write().await; + processing.remove("test:123"); + } + + // Verify removed + { + let processing = watcher.processing.read().await; + assert!(!processing.contains("test:123")); + } + }); + } + + #[test] + fn test_watcher_config_values() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + let sources: Vec> = vec![]; + + let mut config = test_config(); + config.max_issues_per_cycle = 10; + config.max_concurrent = 3; + config.processing_delay_ms = 500; + + let watcher = Watcher::new(WatcherOptions { + config, + sources, + notifier, + tracker, + inferrer: None, + dry_run: false, + }); + + assert_eq!(watcher.config.max_issues_per_cycle, 10); + assert_eq!(watcher.config.max_concurrent, 3); + assert_eq!(watcher.config.processing_delay_ms, 500); + } + + #[tokio::test] + async fn test_watcher_seed_with_multiple_sources() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + + let source1 = Arc::new(MockSource::with_issues( + "source1", + vec![Issue::new( + "1", + "S1-1", + "Issue 1", + "http://example.com/1", + "source1", + )], + )) as Arc; + + let source2 = Arc::new(MockSource::with_issues( + "source2", + vec![Issue::new( + "2", + "S2-1", + "Issue 2", + "http://example.com/2", + "source2", + )], + )) as Arc; + + let sources = vec![source1, source2]; + let watcher = create_test_watcher(notifier, tracker.clone(), sources, false); + + let result = watcher.seed().await.unwrap(); + assert_eq!(result.total, 2); + assert_eq!(*result.by_source.get("source1").unwrap(), 1); + assert_eq!(*result.by_source.get("source2").unwrap(), 1); + } + + #[tokio::test] + async fn test_watcher_poll_respects_max_issues() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + + // Create more issues than max_issues_per_cycle + let issues: Vec = (1..=10) + .map(|i| { + Issue::new( + format!("{}", i), + format!("T-{}", i), + format!("Issue {}", i), + format!("http://example.com/{}", i), + "test", + ) + }) + .collect(); + + let source = Arc::new(MockSource::with_issues("test", issues)) as Arc; + + let mut config = test_config(); + config.max_issues_per_cycle = 5; + + let watcher = Watcher::new(WatcherOptions { + config, + sources: vec![source.clone()], + notifier, + tracker, + inferrer: None, + dry_run: true, + }); + + // Poll should complete successfully + let result = watcher.poll().await; + assert!(result.is_ok()); + } + + #[test] + fn test_mock_notifier_call_tracking() { + let notifier = MockNotifier::new(true); + assert_eq!(notifier.get_call_count(), 0); + assert!(notifier.is_enabled()); + assert_eq!(notifier.name(), "mock"); + } + + #[test] + fn test_mock_notifier_disabled() { + let notifier = MockNotifier::new(false); + assert!(!notifier.is_enabled()); + } + + #[tokio::test] + async fn test_mock_source_get_issue_not_found() { + let source = MockSource::new("test"); + let result = source.get_issue("nonexistent").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_mock_source_get_issue_found() { + let issues = vec![Issue::new( + "123", + "T-123", + "Test", + "http://example.com", + "test", + )]; + let source = MockSource::with_issues("test", issues); + let result = source.get_issue("123").await; + assert!(result.is_ok()); + assert_eq!(result.unwrap().id, "123"); + } + + #[tokio::test] + async fn test_mock_source_build_issue_context() { + let source = MockSource::new("test"); + let issue = test_issue(); + let context = source.build_issue_context(&issue).await.unwrap(); + assert!(context.contains("TEST-123")); + } + + #[test] + fn test_mock_source_matches_criteria() { + let source = MockSource::new("test"); + let issue = test_issue(); + let result = source.matches_criteria(&issue); + assert!(result.matches); + assert_eq!(result.priority, MatchPriority::Normal); + assert!(result.reason.contains("Mock")); + } + + #[test] + fn test_watcher_reset_attempt_success() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + let sources: Vec> = vec![]; + + // Record an attempt + tracker.record_attempt("test", "123", "T-123").unwrap(); + assert!(tracker.has_attempted("test", "123")); + + let watcher = create_test_watcher(notifier, tracker.clone(), sources, false); + + // Reset should succeed + let result = watcher.reset_attempt("test", "123"); + assert!(result.is_ok()); + assert!(!tracker.has_attempted("test", "123")); + } + + #[test] + fn test_watcher_get_stats_empty() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + let sources: Vec> = vec![]; + + let watcher = create_test_watcher(notifier, tracker, sources, false); + + let stats = watcher.get_stats().unwrap(); + assert_eq!(stats.total, 0); + assert_eq!(stats.success, 0); + assert_eq!(stats.failed, 0); + assert_eq!(stats.pending, 0); + } + + #[test] + fn test_watcher_get_stats_after_attempts() { + let notifier = Arc::new(MockNotifier::new(true)); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + let sources: Vec> = vec![]; + + // Record some attempts + tracker.record_attempt("test", "1", "T-1").unwrap(); + tracker.record_attempt("test", "2", "T-2").unwrap(); + tracker + .mark_success("test", "1", "http://github.com/pr/1") + .unwrap(); + tracker.mark_failed("test", "2", "Error").unwrap(); + + let watcher = create_test_watcher(notifier, tracker, sources, false); + + let stats = watcher.get_stats().unwrap(); + assert_eq!(stats.total, 2); + assert_eq!(stats.success, 1); + assert_eq!(stats.failed, 1); + } +} diff --git a/src/webhook/configurator.rs b/src/webhook/configurator.rs new file mode 100644 index 00000000..5405b1cb --- /dev/null +++ b/src/webhook/configurator.rs @@ -0,0 +1,348 @@ +//! Webhook auto-configuration orchestrator. + +use crate::config::{Config, LinearConfig, SentryConfig}; +use crate::env_writer::update_env_file; +use crate::error::{Error, Result}; +use crate::webhook::linear_api::LinearApiClient; +use crate::webhook::sentry_api::SentryApiClient; +use std::collections::HashMap; + +/// Result of webhook auto-configuration. +#[derive(Debug, Default)] +pub struct WebhookSetupResult { + /// Linear webhook was configured. + pub linear_configured: bool, + /// Linear webhook ID. + pub linear_webhook_id: Option, + /// Linear webhook secret (for display only - also written to .env). + pub linear_secret: Option, + /// Sentry webhooks were configured (one per project). + pub sentry_configured: bool, + /// Number of Sentry projects configured. + pub sentry_project_count: usize, + /// Sentry webhook secret (for display only - also written to .env). + /// Note: If multiple projects, they may have different secrets. We use the first one. + pub sentry_secret: Option, + /// Errors encountered during setup (non-fatal). + pub warnings: Vec, +} + +/// Orchestrates webhook auto-configuration for Linear and Sentry. +pub struct WebhookConfigurator { + config: Config, + env_path: std::path::PathBuf, +} + +impl WebhookConfigurator { + /// Create a new webhook configurator. + /// + /// # Arguments + /// * `config` - The application configuration + /// * `env_path` - Path to the .env file for writing secrets + pub fn new(config: Config, env_path: impl Into) -> Self { + Self { + config, + env_path: env_path.into(), + } + } + + /// Run the webhook auto-configuration. + /// + /// This will: + /// 1. Create webhooks for enabled sources (Linear, Sentry) + /// 2. Write the returned secrets to the .env file + /// + /// # Arguments + /// * `base_url` - The public URL where webhooks will be received + pub async fn configure(&self, base_url: &str) -> Result { + tracing::info!("Starting webhook auto-configuration..."); + tracing::info!("Base URL: {}", base_url); + + let mut result = WebhookSetupResult::default(); + let mut env_updates: HashMap = HashMap::new(); + + // Configure Linear webhook + if let Some(ref linear_config) = self.config.linear { + if linear_config.enabled { + match self.configure_linear(linear_config, base_url).await { + Ok((webhook_id, secret)) => { + result.linear_configured = true; + result.linear_webhook_id = Some(webhook_id); + result.linear_secret = Some(secret.clone()); + env_updates.insert("LINEAR_WEBHOOK_SECRET".to_string(), secret); + tracing::info!("Linear webhook configured successfully"); + } + Err(e) => { + let warning = format!("Failed to configure Linear webhook: {}", e); + tracing::warn!("{}", warning); + result.warnings.push(warning); + } + } + } + } + + // Configure Sentry webhooks + if let Some(ref sentry_config) = self.config.sentry { + if sentry_config.enabled { + match self.configure_sentry(sentry_config, base_url).await { + Ok((count, secret)) => { + result.sentry_configured = true; + result.sentry_project_count = count; + if let Some(s) = secret { + result.sentry_secret = Some(s.clone()); + env_updates.insert("SENTRY_CLIENT_SECRET".to_string(), s); + } + tracing::info!("Sentry webhooks configured for {} project(s)", count); + } + Err(e) => { + let warning = format!("Failed to configure Sentry webhooks: {}", e); + tracing::warn!("{}", warning); + result.warnings.push(warning); + } + } + } + } + + // Write secrets to .env file + if !env_updates.is_empty() { + tracing::info!("Writing secrets to {}", self.env_path.display()); + update_env_file(&self.env_path, &env_updates)?; + } + + if !result.linear_configured && !result.sentry_configured { + if result.warnings.is_empty() { + return Err(Error::config( + "No webhook sources are enabled. Enable Linear or Sentry in your configuration." + )); + } else { + return Err(Error::config(format!( + "Failed to configure any webhooks: {}", + result.warnings.join("; ") + ))); + } + } + + Ok(result) + } + + async fn configure_linear( + &self, + config: &LinearConfig, + base_url: &str, + ) -> Result<(String, String)> { + let client = LinearApiClient::from_config(config); + let webhook_url = format!("{}/webhook/linear", base_url.trim_end_matches('/')); + + // Check if webhook already exists + if client.webhook_exists(&webhook_url).await? { + return Err(Error::api(format!( + "A webhook with URL {} already exists in Linear. \ + Delete it manually if you want to reconfigure.", + webhook_url + ))); + } + + tracing::info!("Creating Linear webhook: {}", webhook_url); + + let registration = client + .create_webhook(&webhook_url, config.team_id.as_deref(), &["Issue"]) + .await?; + + Ok((registration.id, registration.secret)) + } + + async fn configure_sentry( + &self, + config: &SentryConfig, + base_url: &str, + ) -> Result<(usize, Option)> { + let client = SentryApiClient::from_config(config); + let webhook_url = format!("{}/webhook/sentry", base_url.trim_end_matches('/')); + + tracing::info!("Creating Sentry webhooks: {}", webhook_url); + + let registrations = client + .create_webhooks_for_projects( + &config.project_slugs, + &webhook_url, + &["event.created", "event.alert"], + ) + .await?; + + let count = registrations.len(); + let secret = registrations.first().map(|r| r.secret.clone()); + + // Log warning if multiple projects have different secrets + if registrations.len() > 1 { + let first_secret = ®istrations[0].secret; + let different = registrations + .iter() + .skip(1) + .any(|r| &r.secret != first_secret); + if different { + tracing::warn!( + "Sentry webhooks have different secrets for different projects. \ + Only the first secret will be saved to .env. \ + You may need to handle this manually." + ); + } + } + + Ok((count, secret)) + } + + /// Check if webhooks need to be configured. + pub fn needs_configuration(&self) -> bool { + let linear_needs = self + .config + .linear + .as_ref() + .is_some_and(|c| c.enabled && c.webhook_secret.is_none()); + + let sentry_needs = self + .config + .sentry + .as_ref() + .is_some_and(|c| c.enabled && c.client_secret.is_none()); + + linear_needs || sentry_needs + } +} + +/// Print the result of webhook configuration to the console. +pub fn print_setup_result(result: &WebhookSetupResult) { + println!("\n=== Webhook Configuration Complete ===\n"); + + if result.linear_configured { + println!("Linear:"); + println!(" Status: Configured"); + if let Some(ref id) = result.linear_webhook_id { + println!(" Webhook ID: {}", id); + } + if let Some(ref secret) = result.linear_secret { + println!(" Secret: {} (saved to .env)", mask_secret(secret)); + } + println!(); + } + + if result.sentry_configured { + println!("Sentry:"); + println!(" Status: Configured"); + println!(" Projects: {}", result.sentry_project_count); + if let Some(ref secret) = result.sentry_secret { + println!(" Secret: {} (saved to .env)", mask_secret(secret)); + } + println!(); + } + + if !result.warnings.is_empty() { + println!("Warnings:"); + for warning in &result.warnings { + println!(" - {}", warning); + } + println!(); + } + + println!("Webhook secrets have been saved to your .env file."); + println!("The webhook server will now start and begin receiving events."); +} + +fn mask_secret(secret: &str) -> String { + if secret.len() <= 8 { + "*".repeat(secret.len()) + } else { + format!("{}...{}", &secret[..4], &secret[secret.len() - 4..]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_config() -> Config { + Config { + work_dir: "/tmp/repos".into(), + known_orgs: vec!["test-org".to_string()], + auto_discover_paths: vec![], + poll_interval_ms: 300_000, + webhook_port: 3100, + db_path: "/tmp/test.db".into(), + max_issues_per_cycle: 5, + max_concurrent: 1, + processing_delay_ms: 5000, + max_activity_entries: 100, + ipc_timeout_secs: 30, + claude_timeout_secs: 21600, + discord: crate::config::DiscordConfig::default(), + email: crate::config::EmailConfig::default(), + sms: crate::config::SmsConfig::default(), + push: crate::config::PushConfig::default(), + github: crate::config::GitHubConfig::default(), + retry: crate::config::RetryConfig::default(), + linear: None, + sentry: None, + } + } + + #[test] + fn test_mask_secret_long() { + assert_eq!(mask_secret("1234567890abcdef"), "1234...cdef"); + } + + #[test] + fn test_mask_secret_short() { + assert_eq!(mask_secret("1234"), "****"); + } + + #[test] + fn test_mask_secret_exactly_8() { + assert_eq!(mask_secret("12345678"), "********"); + } + + #[test] + fn test_webhook_setup_result_default() { + let result = WebhookSetupResult::default(); + assert!(!result.linear_configured); + assert!(!result.sentry_configured); + assert!(result.warnings.is_empty()); + } + + #[test] + fn test_needs_configuration_no_sources() { + let config = test_config(); + let configurator = WebhookConfigurator::new(config, "/tmp/.env"); + assert!(!configurator.needs_configuration()); + } + + #[test] + fn test_needs_configuration_linear_no_secret() { + let mut config = test_config(); + config.linear = Some(crate::config::LinearConfig { + enabled: true, + api_key: "test".to_string(), + trigger_labels: vec![], + trigger_states: vec![], + team_id: None, + project_id: None, + webhook_secret: None, // No secret + }); + let configurator = WebhookConfigurator::new(config, "/tmp/.env"); + assert!(configurator.needs_configuration()); + } + + #[test] + fn test_needs_configuration_linear_with_secret() { + let mut config = test_config(); + config.linear = Some(crate::config::LinearConfig { + enabled: true, + api_key: "test".to_string(), + trigger_labels: vec![], + trigger_states: vec![], + team_id: None, + project_id: None, + webhook_secret: Some("secret".to_string()), // Has secret + }); + let configurator = WebhookConfigurator::new(config, "/tmp/.env"); + assert!(!configurator.needs_configuration()); + } +} diff --git a/src/webhook/linear.rs b/src/webhook/linear.rs new file mode 100644 index 00000000..79580364 --- /dev/null +++ b/src/webhook/linear.rs @@ -0,0 +1,774 @@ +//! Linear webhook handler. + +use super::WebhookHandler; +use crate::config::LinearConfig; +use crate::error::Result; +use crate::types::{Issue, IssuePriority, IssueStatus, MatchPriority, MatchResult}; +use async_trait::async_trait; +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use std::collections::HashMap; +use subtle::ConstantTimeEq; + +/// Webhook handler for Linear. +pub struct LinearWebhookHandler { + config: LinearConfig, +} + +impl LinearWebhookHandler { + /// Create a new Linear webhook handler. + pub fn new(config: LinearConfig) -> Self { + Self { config } + } + + fn map_priority(priority: Option) -> IssuePriority { + match priority { + Some(1) => IssuePriority::Critical, + Some(2) => IssuePriority::High, + Some(3) => IssuePriority::Medium, + Some(4) => IssuePriority::Low, + _ => IssuePriority::None, + } + } + + fn map_status(state_type: Option<&str>) -> IssueStatus { + match state_type { + Some("completed") | Some("canceled") => IssueStatus::Resolved, + Some("started") => IssueStatus::InProgress, + _ => IssueStatus::Open, + } + } +} + +#[async_trait] +impl WebhookHandler for LinearWebhookHandler { + fn source_name(&self) -> &str { + "linear" + } + + fn verify_signature(&self, payload: &[u8], headers: &HashMap) -> bool { + let secret = match &self.config.webhook_secret { + Some(s) => s, + None => { + tracing::error!( + source = "linear", + "No webhook secret configured - rejecting request for security" + ); + return false; + } + }; + + let signature = match headers.get("linear-signature") { + Some(s) => s, + None => return false, + }; + + let mut mac = match Hmac::::new_from_slice(secret.as_bytes()) { + Ok(m) => m, + Err(_) => return false, + }; + + mac.update(payload); + let expected = mac.finalize().into_bytes(); + let expected_hex = hex::encode(expected); + + signature.as_bytes().ct_eq(expected_hex.as_bytes()).into() + } + + async fn parse_payload(&self, payload: &serde_json::Value) -> Result> { + // Only process Issue events + let event_type = payload.get("type").and_then(|v| v.as_str()).unwrap_or(""); + + if event_type != "Issue" { + return Ok(None); + } + + // Only process create and update actions + let action = payload.get("action").and_then(|v| v.as_str()).unwrap_or(""); + + if action != "create" && action != "update" { + return Ok(None); + } + + let data = match payload.get("data") { + Some(d) => d, + None => return Ok(None), + }; + + let id = data + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + + let identifier = data + .get("identifier") + .and_then(|v| v.as_str()) + .unwrap_or(&id) + .to_string(); + + let title = data + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("Untitled") + .to_string(); + + let url = data + .get("url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("https://linear.app/issue/{}", identifier)); + + let description = data + .get("description") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let priority = data.get("priority").and_then(|v| v.as_i64()); + + let state = data.get("state"); + let state_name = state.and_then(|s| s.get("name")).and_then(|v| v.as_str()); + let state_type = state.and_then(|s| s.get("type")).and_then(|v| v.as_str()); + + let labels: Vec = data + .get("labels") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|l| { + l.get("name") + .and_then(|n| n.as_str()) + .map(|s| s.to_string()) + }) + .collect() + }) + .unwrap_or_default(); + + let team = data.get("team"); + let team_id = team + .and_then(|t| t.get("id")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let team_name = team + .and_then(|t| t.get("name")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let project = data.get("project"); + let project_id = project + .and_then(|p| p.get("id")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let project_name = project + .and_then(|p| p.get("name")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let assignee_name = data + .get("assignee") + .and_then(|a| a.get("name")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let mut issue = Issue::new(&id, &identifier, &title, &url, "linear"); + issue.description = description; + issue.priority = Self::map_priority(priority); + issue.status = Self::map_status(state_type); + + // Store metadata + if let Some(name) = state_name { + issue.set_metadata("state_name", name); + } + if let Some(st) = state_type { + issue.set_metadata("state_type", st); + } + issue.set_metadata("labels", &labels); + if let Some(id) = team_id { + issue.set_metadata("team_id", &id); + } + if let Some(name) = team_name { + issue.set_metadata("team", &name); + } + if let Some(id) = project_id { + issue.set_metadata("project_id", &id); + } + if let Some(name) = project_name { + issue.set_metadata("project", &name); + } + if let Some(name) = assignee_name { + issue.set_metadata("assignee", &name); + } + + Ok(Some(issue)) + } + + fn matches_criteria(&self, issue: &Issue) -> MatchResult { + let state_name: Option = issue.get_metadata("state_name"); + let state_type: Option = issue.get_metadata("state_type"); + let labels: Vec = issue.get_metadata("labels").unwrap_or_default(); + let team_id: Option = issue.get_metadata("team_id"); + let project_id: Option = issue.get_metadata("project_id"); + + // Check team filter + if let Some(ref filter_team) = self.config.team_id { + if team_id.as_ref() != Some(filter_team) { + return MatchResult::not_matched("Team does not match filter"); + } + } + + // Check project filter + if let Some(ref filter_project) = self.config.project_id { + if project_id.as_ref() != Some(filter_project) { + return MatchResult::not_matched("Project does not match filter"); + } + } + + // Check state + if !self.config.trigger_states.is_empty() { + let state_name_lower = state_name.as_deref().unwrap_or("").to_lowercase(); + let state_type_lower = state_type.as_deref().unwrap_or("").to_lowercase(); + + let state_matches = self.config.trigger_states.iter().any(|s| { + let s_lower = s.to_lowercase(); + state_name_lower.contains(&s_lower) || state_type_lower.contains(&s_lower) + }); + + if !state_matches { + return MatchResult::not_matched(format!( + "State \"{}\" not in trigger states", + state_name.as_deref().unwrap_or("unknown") + )); + } + } + + // Check labels + if !self.config.trigger_labels.is_empty() { + let label_matches = self.config.trigger_labels.iter().any(|trigger| { + labels + .iter() + .any(|l| l.to_lowercase() == trigger.to_lowercase()) + }); + + if !label_matches { + return MatchResult::not_matched("No matching trigger labels"); + } + } + + let priority = match issue.priority { + IssuePriority::Critical => MatchPriority::Urgent, + IssuePriority::High => MatchPriority::High, + IssuePriority::Low => MatchPriority::Low, + _ => MatchPriority::Normal, + }; + + MatchResult::matched("Matches state and label criteria", priority) + } + + async fn build_issue_context(&self, issue: &Issue) -> Result { + let mut context = format!("# Linear Issue: {}\n\n", issue.short_id); + context.push_str(&format!("**Title:** {}\n", issue.title)); + context.push_str(&format!("**URL:** {}\n", issue.url)); + context.push_str(&format!("**Priority:** {}\n", issue.priority)); + context.push_str(&format!("**Status:** {}\n\n", issue.status)); + + if let Some(ref description) = issue.description { + context.push_str(&format!("## Description\n{}\n\n", description)); + } + + if let Some(team) = issue.get_metadata::("team") { + context.push_str(&format!("**Team:** {}\n", team)); + } + if let Some(project) = issue.get_metadata::("project") { + context.push_str(&format!("**Project:** {}\n", project)); + } + if let Some(assignee) = issue.get_metadata::("assignee") { + context.push_str(&format!("**Assignee:** {}\n", assignee)); + } + + Ok(context) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_config() -> LinearConfig { + LinearConfig { + enabled: true, + api_key: "test".to_string(), + trigger_labels: vec!["auto-implement".to_string()], + trigger_states: vec!["backlog".to_string(), "todo".to_string()], + team_id: None, + project_id: None, + webhook_secret: Some("test_secret".to_string()), + } + } + + #[test] + fn test_verify_signature_with_secret() { + let handler = LinearWebhookHandler::new(test_config()); + let payload = b"test payload"; + + // Calculate expected signature + let mut mac = Hmac::::new_from_slice(b"test_secret").unwrap(); + mac.update(payload); + let expected = hex::encode(mac.finalize().into_bytes()); + + let mut headers = HashMap::new(); + headers.insert("linear-signature".to_string(), expected); + + assert!(handler.verify_signature(payload, &headers)); + } + + #[test] + fn test_verify_signature_no_header() { + let handler = LinearWebhookHandler::new(test_config()); + let headers = HashMap::new(); + assert!(!handler.verify_signature(b"test", &headers)); + } + + #[test] + fn test_verify_signature_no_secret() { + let mut config = test_config(); + config.webhook_secret = None; + let handler = LinearWebhookHandler::new(config); + + let headers = HashMap::new(); + // Should return false when no secret configured (fail closed for security) + assert!(!handler.verify_signature(b"test", &headers)); + } + + #[tokio::test] + async fn test_parse_payload_issue_create() { + let handler = LinearWebhookHandler::new(test_config()); + + let payload = serde_json::json!({ + "type": "Issue", + "action": "create", + "data": { + "id": "123", + "identifier": "PROJ-123", + "title": "Fix bug", + "url": "https://linear.app/team/issue/PROJ-123", + "priority": 2, + "state": { + "name": "Backlog", + "type": "backlog" + }, + "labels": [ + { "name": "auto-implement" } + ], + "team": { + "id": "team-1", + "name": "Engineering" + } + } + }); + + let issue = handler.parse_payload(&payload).await.unwrap().unwrap(); + assert_eq!(issue.id, "123"); + assert_eq!(issue.short_id, "PROJ-123"); + assert_eq!(issue.title, "Fix bug"); + assert_eq!(issue.priority, IssuePriority::High); + } + + #[tokio::test] + async fn test_parse_payload_non_issue() { + let handler = LinearWebhookHandler::new(test_config()); + + let payload = serde_json::json!({ + "type": "Comment", + "action": "create", + "data": {} + }); + + let result = handler.parse_payload(&payload).await.unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_source_name() { + let handler = LinearWebhookHandler::new(test_config()); + assert_eq!(handler.source_name(), "linear"); + } + + #[test] + fn test_map_priority() { + assert_eq!( + LinearWebhookHandler::map_priority(Some(1)), + IssuePriority::Critical + ); + assert_eq!( + LinearWebhookHandler::map_priority(Some(2)), + IssuePriority::High + ); + assert_eq!( + LinearWebhookHandler::map_priority(Some(3)), + IssuePriority::Medium + ); + assert_eq!( + LinearWebhookHandler::map_priority(Some(4)), + IssuePriority::Low + ); + assert_eq!( + LinearWebhookHandler::map_priority(Some(0)), + IssuePriority::None + ); + assert_eq!( + LinearWebhookHandler::map_priority(Some(5)), + IssuePriority::None + ); + assert_eq!( + LinearWebhookHandler::map_priority(None), + IssuePriority::None + ); + } + + #[test] + fn test_map_status() { + assert_eq!( + LinearWebhookHandler::map_status(Some("completed")), + IssueStatus::Resolved + ); + assert_eq!( + LinearWebhookHandler::map_status(Some("canceled")), + IssueStatus::Resolved + ); + assert_eq!( + LinearWebhookHandler::map_status(Some("started")), + IssueStatus::InProgress + ); + assert_eq!( + LinearWebhookHandler::map_status(Some("backlog")), + IssueStatus::Open + ); + assert_eq!( + LinearWebhookHandler::map_status(Some("triage")), + IssueStatus::Open + ); + assert_eq!(LinearWebhookHandler::map_status(None), IssueStatus::Open); + } + + #[test] + fn test_matches_criteria_with_labels() { + let handler = LinearWebhookHandler::new(test_config()); + + let mut issue = Issue::new( + "123", + "PROJ-123", + "Fix bug", + "https://example.com", + "linear", + ); + issue.set_metadata("labels", vec!["auto-implement".to_string()]); + issue.set_metadata("state_type", "backlog"); + + let result = handler.matches_criteria(&issue); + assert!(result.matches); + } + + #[test] + fn test_matches_criteria_no_matching_labels() { + let handler = LinearWebhookHandler::new(test_config()); + + let mut issue = Issue::new( + "123", + "PROJ-123", + "Fix bug", + "https://example.com", + "linear", + ); + issue.set_metadata("labels", vec!["other-label".to_string()]); + issue.set_metadata("state_type", "backlog"); + + let result = handler.matches_criteria(&issue); + assert!(!result.matches); + assert!(result.reason.contains("label")); + } + + #[test] + fn test_matches_criteria_with_team_filter() { + let mut config = test_config(); + config.team_id = Some("team-1".to_string()); + let handler = LinearWebhookHandler::new(config); + + let mut issue = Issue::new( + "123", + "PROJ-123", + "Fix bug", + "https://example.com", + "linear", + ); + issue.set_metadata("team_id", "team-1"); + issue.set_metadata("labels", vec!["auto-implement".to_string()]); + issue.set_metadata("state_type", "backlog"); + + let result = handler.matches_criteria(&issue); + assert!(result.matches); + + // Different team should not match + issue.set_metadata("team_id", "team-2"); + let result = handler.matches_criteria(&issue); + assert!(!result.matches); + assert!(result.reason.contains("Team")); + } + + #[test] + fn test_matches_criteria_with_project_filter() { + let mut config = test_config(); + config.project_id = Some("project-1".to_string()); + let handler = LinearWebhookHandler::new(config); + + let mut issue = Issue::new( + "123", + "PROJ-123", + "Fix bug", + "https://example.com", + "linear", + ); + issue.set_metadata("project_id", "project-1"); + issue.set_metadata("labels", vec!["auto-implement".to_string()]); + issue.set_metadata("state_type", "backlog"); + + let result = handler.matches_criteria(&issue); + assert!(result.matches); + + // Different project should not match + issue.set_metadata("project_id", "project-2"); + let result = handler.matches_criteria(&issue); + assert!(!result.matches); + assert!(result.reason.contains("Project")); + } + + #[test] + fn test_matches_criteria_state_not_matching() { + let handler = LinearWebhookHandler::new(test_config()); + + let mut issue = Issue::new( + "123", + "PROJ-123", + "Fix bug", + "https://example.com", + "linear", + ); + issue.set_metadata("labels", vec!["auto-implement".to_string()]); + issue.set_metadata("state_name", "In Progress"); + issue.set_metadata("state_type", "started"); + + let result = handler.matches_criteria(&issue); + assert!(!result.matches); + assert!(result.reason.contains("State")); + } + + #[test] + fn test_matches_criteria_priority_mapping() { + let handler = LinearWebhookHandler::new(test_config()); + + let mut issue = Issue::new( + "123", + "PROJ-123", + "Fix bug", + "https://example.com", + "linear", + ); + issue.set_metadata("labels", vec!["auto-implement".to_string()]); + issue.set_metadata("state_type", "backlog"); + + issue.priority = IssuePriority::Critical; + let result = handler.matches_criteria(&issue); + assert_eq!(result.priority, MatchPriority::Urgent); + + issue.priority = IssuePriority::High; + let result = handler.matches_criteria(&issue); + assert_eq!(result.priority, MatchPriority::High); + + issue.priority = IssuePriority::Medium; + let result = handler.matches_criteria(&issue); + assert_eq!(result.priority, MatchPriority::Normal); + + issue.priority = IssuePriority::Low; + let result = handler.matches_criteria(&issue); + assert_eq!(result.priority, MatchPriority::Low); + } + + #[tokio::test] + async fn test_parse_payload_update_action() { + let handler = LinearWebhookHandler::new(test_config()); + + let payload = serde_json::json!({ + "type": "Issue", + "action": "update", + "data": { + "id": "456", + "identifier": "PROJ-456", + "title": "Updated bug", + "url": "https://linear.app/team/issue/PROJ-456", + "priority": 1 + } + }); + + let issue = handler.parse_payload(&payload).await.unwrap().unwrap(); + assert_eq!(issue.id, "456"); + assert_eq!(issue.short_id, "PROJ-456"); + assert_eq!(issue.priority, IssuePriority::Critical); + } + + #[tokio::test] + async fn test_parse_payload_delete_action() { + let handler = LinearWebhookHandler::new(test_config()); + + let payload = serde_json::json!({ + "type": "Issue", + "action": "delete", + "data": { "id": "789" } + }); + + let result = handler.parse_payload(&payload).await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_parse_payload_minimal() { + let handler = LinearWebhookHandler::new(test_config()); + + let payload = serde_json::json!({ + "type": "Issue", + "action": "create", + "data": { + "id": "minimal-123" + } + }); + + let issue = handler.parse_payload(&payload).await.unwrap().unwrap(); + assert_eq!(issue.id, "minimal-123"); + assert_eq!(issue.short_id, "minimal-123"); // Falls back to id + assert_eq!(issue.title, "Untitled"); + } + + #[tokio::test] + async fn test_parse_payload_with_all_fields() { + let handler = LinearWebhookHandler::new(test_config()); + + let payload = serde_json::json!({ + "type": "Issue", + "action": "create", + "data": { + "id": "full-123", + "identifier": "PROJ-999", + "title": "Complete issue", + "url": "https://linear.app/team/issue/PROJ-999", + "description": "Full description here", + "priority": 3, + "state": { + "name": "Todo", + "type": "unstarted" + }, + "labels": [ + { "name": "auto-implement" }, + { "name": "priority" } + ], + "team": { + "id": "team-abc", + "name": "Platform Team" + }, + "project": { + "id": "proj-xyz", + "name": "Q1 Goals" + }, + "assignee": { + "name": "John Doe" + } + } + }); + + let issue = handler.parse_payload(&payload).await.unwrap().unwrap(); + assert_eq!(issue.id, "full-123"); + assert_eq!(issue.short_id, "PROJ-999"); + assert_eq!(issue.title, "Complete issue"); + assert_eq!(issue.description, Some("Full description here".to_string())); + assert_eq!(issue.priority, IssuePriority::Medium); + + let labels: Vec = issue.get_metadata("labels").unwrap(); + assert_eq!(labels.len(), 2); + assert!(labels.contains(&"auto-implement".to_string())); + + let team: String = issue.get_metadata("team").unwrap(); + assert_eq!(team, "Platform Team"); + + let assignee: String = issue.get_metadata("assignee").unwrap(); + assert_eq!(assignee, "John Doe"); + } + + #[tokio::test] + async fn test_build_issue_context() { + let handler = LinearWebhookHandler::new(test_config()); + + let mut issue = Issue::new( + "123", + "PROJ-123", + "Test Issue", + "https://linear.app/123", + "linear", + ); + issue.description = Some("Test description".to_string()); + issue.set_metadata("team", "Engineering"); + issue.set_metadata("project", "Q1 Goals"); + issue.set_metadata("assignee", "Jane Doe"); + + let context = handler.build_issue_context(&issue).await.unwrap(); + + assert!(context.contains("PROJ-123")); + assert!(context.contains("Test Issue")); + assert!(context.contains("Test description")); + assert!(context.contains("Engineering")); + assert!(context.contains("Q1 Goals")); + assert!(context.contains("Jane Doe")); + } + + #[test] + fn test_verify_signature_invalid() { + let handler = LinearWebhookHandler::new(test_config()); + + let mut headers = HashMap::new(); + headers.insert( + "linear-signature".to_string(), + "invalid_signature".to_string(), + ); + + assert!(!handler.verify_signature(b"test payload", &headers)); + } + + #[test] + fn test_matches_criteria_empty_filters() { + let mut config = test_config(); + config.trigger_labels = vec![]; + config.trigger_states = vec![]; + let handler = LinearWebhookHandler::new(config); + + let issue = Issue::new( + "123", + "PROJ-123", + "Fix bug", + "https://example.com", + "linear", + ); + + let result = handler.matches_criteria(&issue); + assert!(result.matches); + } + + #[tokio::test] + async fn test_parse_payload_no_data() { + let handler = LinearWebhookHandler::new(test_config()); + + let payload = serde_json::json!({ + "type": "Issue", + "action": "create" + // Missing data field + }); + + let result = handler.parse_payload(&payload).await.unwrap(); + assert!(result.is_none()); + } +} diff --git a/src/webhook/linear_api.rs b/src/webhook/linear_api.rs new file mode 100644 index 00000000..e9791c87 --- /dev/null +++ b/src/webhook/linear_api.rs @@ -0,0 +1,392 @@ +//! Linear API client for webhook management. + +use crate::config::LinearConfig; +use crate::error::{Error, Result}; +use serde::{Deserialize, Serialize}; + +/// Client for Linear GraphQL API webhook operations. +pub struct LinearApiClient { + api_key: String, + client: reqwest::Client, +} + +/// Result of a webhook registration. +#[derive(Debug, Clone)] +pub struct WebhookRegistration { + /// The webhook ID. + pub id: String, + /// The webhook URL. + pub url: String, + /// Whether the webhook is enabled. + pub enabled: bool, + /// The signing secret for HMAC verification. + pub secret: String, +} + +#[derive(Debug, Serialize)] +struct GraphQLRequest { + query: String, + variables: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +struct GraphQLResponse { + data: Option, + errors: Option>, +} + +#[derive(Debug, Deserialize)] +struct GraphQLError { + message: String, +} + +#[derive(Debug, Deserialize)] +struct WebhookCreateResponse { + #[serde(rename = "webhookCreate")] + webhook_create: WebhookCreateResult, +} + +#[derive(Debug, Deserialize)] +struct WebhookCreateResult { + success: bool, + webhook: Option, +} + +#[derive(Debug, Deserialize)] +struct WebhookData { + id: String, + url: String, + enabled: bool, + secret: String, +} + +#[derive(Debug, Deserialize)] +struct WebhooksResponse { + webhooks: WebhooksConnection, +} + +#[derive(Debug, Deserialize)] +struct WebhooksConnection { + nodes: Vec, +} + +#[derive(Debug, Deserialize)] +struct WebhookNode { + id: String, + url: String, + enabled: bool, +} + +impl LinearApiClient { + const API_URL: &'static str = "https://api.linear.app/graphql"; + + /// Create a new Linear API client. + pub fn new(api_key: String) -> Self { + Self { + api_key, + client: reqwest::Client::new(), + } + } + + /// Create from a LinearConfig. + pub fn from_config(config: &LinearConfig) -> Self { + Self::new(config.api_key.clone()) + } + + /// Register a new webhook with Linear. + /// + /// # Arguments + /// * `url` - The URL where Linear will send webhook events + /// * `team_id` - Optional team ID to filter events (if None, receives all public team events) + /// * `resource_types` - List of resource types to subscribe to (e.g., ["Issue"]) + /// + /// # Returns + /// The webhook registration result including the signing secret. + pub async fn create_webhook( + &self, + url: &str, + team_id: Option<&str>, + resource_types: &[&str], + ) -> Result { + let mutation = r#" + mutation WebhookCreate($input: WebhookCreateInput!) { + webhookCreate(input: $input) { + success + webhook { + id + url + enabled + secret + } + } + } + "#; + + let resource_types_json: Vec = resource_types + .iter() + .map(|r| serde_json::Value::String(r.to_string())) + .collect(); + + let mut input = serde_json::json!({ + "url": url, + "resourceTypes": resource_types_json, + }); + + if let Some(tid) = team_id { + input["teamId"] = serde_json::Value::String(tid.to_string()); + } else { + input["allPublicTeams"] = serde_json::Value::Bool(true); + } + + let request = GraphQLRequest { + query: mutation.to_string(), + variables: serde_json::json!({ "input": input }), + }; + + let response = self + .client + .post(Self::API_URL) + .header("Authorization", &self.api_key) + .header("Content-Type", "application/json") + .json(&request) + .send() + .await + .map_err(|e| Error::api(format!("Failed to send request to Linear API: {}", e)))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(Error::api(format!( + "Linear API returned status {}: {}", + status, body + ))); + } + + let result: GraphQLResponse = response + .json() + .await + .map_err(|e| Error::api(format!("Failed to parse Linear API response: {}", e)))?; + + if let Some(errors) = result.errors { + let error_messages: Vec = errors.iter().map(|e| e.message.clone()).collect(); + return Err(Error::api(format!( + "Linear API errors: {}", + error_messages.join(", ") + ))); + } + + let data = result + .data + .ok_or_else(|| Error::api("No data in Linear API response"))?; + + if !data.webhook_create.success { + return Err(Error::api("Webhook creation failed")); + } + + let webhook = data + .webhook_create + .webhook + .ok_or_else(|| Error::api("No webhook data in response"))?; + + Ok(WebhookRegistration { + id: webhook.id, + url: webhook.url, + enabled: webhook.enabled, + secret: webhook.secret, + }) + } + + /// List existing webhooks for the organization. + pub async fn list_webhooks(&self) -> Result> { + let query = r#" + query { + webhooks { + nodes { + id + url + enabled + } + } + } + "#; + + let request = GraphQLRequest { + query: query.to_string(), + variables: serde_json::json!({}), + }; + + let response = self + .client + .post(Self::API_URL) + .header("Authorization", &self.api_key) + .header("Content-Type", "application/json") + .json(&request) + .send() + .await + .map_err(|e| Error::api(format!("Failed to send request to Linear API: {}", e)))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(Error::api(format!( + "Linear API returned status {}: {}", + status, body + ))); + } + + let result: GraphQLResponse = response + .json() + .await + .map_err(|e| Error::api(format!("Failed to parse Linear API response: {}", e)))?; + + if let Some(errors) = result.errors { + let error_messages: Vec = errors.iter().map(|e| e.message.clone()).collect(); + return Err(Error::api(format!( + "Linear API errors: {}", + error_messages.join(", ") + ))); + } + + let data = result + .data + .ok_or_else(|| Error::api("No data in Linear API response"))?; + + Ok(data + .webhooks + .nodes + .into_iter() + .map(|w| (w.id, w.url, w.enabled)) + .collect()) + } + + /// Check if a webhook with the given URL already exists. + pub async fn webhook_exists(&self, url: &str) -> Result { + let webhooks = self.list_webhooks().await?; + Ok(webhooks.iter().any(|(_, wh_url, _)| wh_url == url)) + } + + /// Delete a webhook by ID. + pub async fn delete_webhook(&self, webhook_id: &str) -> Result { + let mutation = r#" + mutation WebhookDelete($id: String!) { + webhookDelete(id: $id) { + success + } + } + "#; + + let request = GraphQLRequest { + query: mutation.to_string(), + variables: serde_json::json!({ "id": webhook_id }), + }; + + let response = self + .client + .post(Self::API_URL) + .header("Authorization", &self.api_key) + .header("Content-Type", "application/json") + .json(&request) + .send() + .await + .map_err(|e| Error::api(format!("Failed to send request to Linear API: {}", e)))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(Error::api(format!( + "Linear API returned status {}: {}", + status, body + ))); + } + + #[derive(Debug, Deserialize)] + struct DeleteResponse { + #[serde(rename = "webhookDelete")] + webhook_delete: DeleteResult, + } + + #[derive(Debug, Deserialize)] + struct DeleteResult { + success: bool, + } + + let result: GraphQLResponse = response + .json() + .await + .map_err(|e| Error::api(format!("Failed to parse Linear API response: {}", e)))?; + + if let Some(errors) = result.errors { + let error_messages: Vec = errors.iter().map(|e| e.message.clone()).collect(); + return Err(Error::api(format!( + "Linear API errors: {}", + error_messages.join(", ") + ))); + } + + let data = result + .data + .ok_or_else(|| Error::api("No data in Linear API response"))?; + + Ok(data.webhook_delete.success) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_linear_api_client_new() { + let client = LinearApiClient::new("lin_api_key".to_string()); + assert_eq!(client.api_key, "lin_api_key"); + } + + #[test] + fn test_linear_api_client_from_config() { + let config = LinearConfig { + enabled: true, + api_key: "lin_config_key".to_string(), + trigger_labels: vec![], + trigger_states: vec![], + team_id: None, + project_id: None, + webhook_secret: None, + }; + let client = LinearApiClient::from_config(&config); + assert_eq!(client.api_key, "lin_config_key"); + } + + #[test] + fn test_webhook_registration_fields() { + let registration = WebhookRegistration { + id: "wh_123".to_string(), + url: "https://example.com/webhook".to_string(), + enabled: true, + secret: "secret_abc".to_string(), + }; + assert_eq!(registration.id, "wh_123"); + assert_eq!(registration.url, "https://example.com/webhook"); + assert!(registration.enabled); + assert_eq!(registration.secret, "secret_abc"); + } + + #[test] + fn test_graphql_request_serialization() { + let request = GraphQLRequest { + query: "query { test }".to_string(), + variables: serde_json::json!({"key": "value"}), + }; + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("query")); + assert!(json.contains("variables")); + } +} diff --git a/src/webhook/mod.rs b/src/webhook/mod.rs new file mode 100644 index 00000000..ce2c5a99 --- /dev/null +++ b/src/webhook/mod.rs @@ -0,0 +1,82 @@ +//! Webhook handlers and HTTP server. + +mod configurator; +mod linear; +mod linear_api; +mod sentry; +mod sentry_api; +mod server; + +pub use configurator::{print_setup_result, WebhookConfigurator, WebhookSetupResult}; +pub use linear::LinearWebhookHandler; +pub use linear_api::{LinearApiClient, WebhookRegistration}; +pub use sentry::SentryWebhookHandler; +pub use sentry_api::{SentryApiClient, SentryWebhookRegistration}; +pub use server::WebhookServer; + +use crate::error::Result; +use crate::types::{Issue, MatchResult}; +use async_trait::async_trait; +use std::collections::HashMap; +use std::sync::Arc; + +/// Trait for webhook handlers. +#[async_trait] +pub trait WebhookHandler: Send + Sync { + /// Source name this handler is for. + fn source_name(&self) -> &str; + + /// Verify the webhook signature/authenticity. + fn verify_signature(&self, payload: &[u8], headers: &HashMap) -> bool; + + /// Parse the webhook payload into an Issue. + /// Returns None if this webhook should be ignored. + async fn parse_payload(&self, payload: &serde_json::Value) -> Result>; + + /// Check if the parsed issue matches processing criteria. + fn matches_criteria(&self, issue: &Issue) -> MatchResult; + + /// Build context for Claude from the issue. + async fn build_issue_context(&self, issue: &Issue) -> Result; +} + +/// Registry for webhook handlers. +pub struct WebhookHandlerRegistry { + handlers: HashMap>, +} + +impl WebhookHandlerRegistry { + /// Create a new empty registry. + pub fn new() -> Self { + Self { + handlers: HashMap::new(), + } + } + + /// Register a handler. + pub fn register(&mut self, handler: Arc) { + self.handlers + .insert(handler.source_name().to_string(), handler); + } + + /// Get a handler by source name. + pub fn get(&self, source_name: &str) -> Option<&Arc> { + self.handlers.get(source_name) + } + + /// Get all registered handlers. + pub fn get_all(&self) -> Vec<&Arc> { + self.handlers.values().collect() + } + + /// Check if a handler is registered. + pub fn has(&self, source_name: &str) -> bool { + self.handlers.contains_key(source_name) + } +} + +impl Default for WebhookHandlerRegistry { + fn default() -> Self { + Self::new() + } +} diff --git a/src/webhook/sentry.rs b/src/webhook/sentry.rs new file mode 100644 index 00000000..fcb38674 --- /dev/null +++ b/src/webhook/sentry.rs @@ -0,0 +1,691 @@ +//! Sentry webhook handler. + +use super::WebhookHandler; +use crate::config::SentryConfig; +use crate::error::Result; +use crate::types::{Issue, IssuePriority, IssueStatus, MatchPriority, MatchResult}; +use async_trait::async_trait; +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use std::collections::HashMap; +use subtle::ConstantTimeEq; + +/// Webhook handler for Sentry. +pub struct SentryWebhookHandler { + config: SentryConfig, +} + +impl SentryWebhookHandler { + /// Create a new Sentry webhook handler. + pub fn new(config: SentryConfig) -> Self { + Self { config } + } + + fn map_priority(level: &str, event_count: i64) -> IssuePriority { + if level == "fatal" || (level == "error" && event_count > 1000) { + IssuePriority::Critical + } else if level == "error" { + IssuePriority::High + } else if level == "warning" { + IssuePriority::Medium + } else { + IssuePriority::Low + } + } + + fn map_status(status: &str) -> IssueStatus { + match status { + "resolved" => IssueStatus::Resolved, + "ignored" => IssueStatus::Ignored, + _ => IssueStatus::Open, + } + } +} + +#[async_trait] +impl WebhookHandler for SentryWebhookHandler { + fn source_name(&self) -> &str { + "sentry" + } + + fn verify_signature(&self, payload: &[u8], headers: &HashMap) -> bool { + let secret = match &self.config.client_secret { + Some(s) => s, + None => { + tracing::error!( + source = "sentry", + "No client secret configured - rejecting request for security" + ); + return false; + } + }; + + let signature = match headers.get("sentry-hook-signature") { + Some(s) => s, + None => return false, + }; + + let mut mac = match Hmac::::new_from_slice(secret.as_bytes()) { + Ok(m) => m, + Err(_) => return false, + }; + + mac.update(payload); + let expected = mac.finalize().into_bytes(); + let expected_hex = hex::encode(expected); + + signature.as_bytes().ct_eq(expected_hex.as_bytes()).into() + } + + async fn parse_payload(&self, payload: &serde_json::Value) -> Result> { + // Only process issue events with created action + let action = payload.get("action").and_then(|v| v.as_str()).unwrap_or(""); + + if action != "created" { + return Ok(None); + } + + let issue_data = match payload.get("data").and_then(|d| d.get("issue")) { + Some(i) => i, + None => return Ok(None), + }; + + let project = match issue_data.get("project") { + Some(p) => p, + None => return Ok(None), + }; + + let project_slug = project + .get("slug") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + + // Check project filter + if !self.config.project_slugs.is_empty() + && !self + .config + .project_slugs + .contains(&project_slug.to_string()) + { + return Ok(None); + } + + let id = issue_data + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + + let short_id = issue_data + .get("shortId") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + + let title = issue_data + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + + let url = format!( + "https://sentry.io/organizations/{}/issues/{}/", + self.config.org_slug, id + ); + + let status = issue_data + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("unresolved"); + + let level = issue_data + .get("level") + .and_then(|v| v.as_str()) + .unwrap_or("error"); + + let count = issue_data + .get("count") + .and_then(|v| v.as_i64()) + .unwrap_or(1); + + let user_count = issue_data + .get("userCount") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + let culprit = issue_data + .get("culprit") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let metadata = issue_data.get("metadata"); + let error_type = metadata + .and_then(|m| m.get("type")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let error_value = metadata + .and_then(|m| m.get("value")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let filename = metadata + .and_then(|m| m.get("filename")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let function = metadata + .and_then(|m| m.get("function")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let project_name = project + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + + let first_seen = issue_data + .get("firstSeen") + .and_then(|v| v.as_str()) + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&chrono::Utc)); + + let last_seen = issue_data + .get("lastSeen") + .and_then(|v| v.as_str()) + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&chrono::Utc)); + + let mut issue = Issue::new(&id, &short_id, &title, &url, "sentry"); + issue.description = error_value.clone(); + issue.priority = Self::map_priority(level, count); + issue.status = Self::map_status(status); + issue.created_at = first_seen; + issue.updated_at = last_seen; + + // Store metadata + if let Some(c) = culprit { + issue.set_metadata("culprit", &c); + } + issue.set_metadata("level", level); + issue.set_metadata("project", project_name); + issue.set_metadata("project_slug", project_slug); + issue.set_metadata("event_count", count); + issue.set_metadata("user_count", user_count); + if let Some(t) = error_type { + issue.set_metadata("error_type", &t); + } + if let Some(v) = error_value { + issue.set_metadata("error_value", &v); + } + if let Some(f) = filename { + issue.set_metadata("filename", &f); + } + if let Some(f) = function { + issue.set_metadata("function", &f); + } + + Ok(Some(issue)) + } + + fn matches_criteria(&self, issue: &Issue) -> MatchResult { + let event_count: i64 = issue.get_metadata("event_count").unwrap_or(0); + let level: String = issue.get_metadata("level").unwrap_or_default(); + + // Check minimum event count + if event_count < self.config.min_event_count as i64 { + return MatchResult::not_matched(format!( + "Event count {} below threshold {}", + event_count, self.config.min_event_count + )); + } + + // Check if resolved + if issue.status == IssueStatus::Resolved { + return MatchResult::not_matched("Issue is already resolved"); + } + + // Determine priority + let (priority, reason) = if level == "fatal" || event_count > 1000 { + ( + MatchPriority::Urgent, + format!("New {} with {} events", level, event_count), + ) + } else if level == "error" && event_count > 100 { + ( + MatchPriority::High, + format!("New {} with {} events", level, event_count), + ) + } else { + ( + MatchPriority::Normal, + format!("New {} with {} events", level, event_count), + ) + }; + + MatchResult::matched(reason, priority) + } + + async fn build_issue_context(&self, issue: &Issue) -> Result { + let mut context = format!("# Sentry Issue: {}\n\n", issue.short_id); + context.push_str(&format!("**Title:** {}\n", issue.title)); + context.push_str(&format!("**URL:** {}\n", issue.url)); + + if let Some(level) = issue.get_metadata::("level") { + context.push_str(&format!("**Level:** {}\n", level)); + } + context.push_str(&format!("**Status:** {}\n", issue.status)); + + if let Some(event_count) = issue.get_metadata::("event_count") { + context.push_str(&format!("**Event Count:** {}\n", event_count)); + } + if let Some(user_count) = issue.get_metadata::("user_count") { + context.push_str(&format!("**User Count:** {}\n", user_count)); + } + if let Some(project) = issue.get_metadata::("project") { + context.push_str(&format!("**Project:** {}\n\n", project)); + } + + if let Some(culprit) = issue.get_metadata::("culprit") { + if !culprit.is_empty() { + context.push_str(&format!("**Culprit:** {}\n\n", culprit)); + } + } + + // Error details + let error_type: Option = issue.get_metadata("error_type"); + let error_value: Option = issue.get_metadata("error_value"); + let filename: Option = issue.get_metadata("filename"); + let function: Option = issue.get_metadata("function"); + + if error_type.is_some() || error_value.is_some() { + context.push_str("## Error Details\n"); + if let Some(ref t) = error_type { + context.push_str(&format!("- **Type:** {}\n", t)); + } + if let Some(ref v) = error_value { + context.push_str(&format!("- **Value:** {}\n", v)); + } + if let Some(ref f) = filename { + context.push_str(&format!("- **File:** {}\n", f)); + } + if let Some(ref f) = function { + context.push_str(&format!("- **Function:** {}\n", f)); + } + context.push('\n'); + } + + context.push_str("\n**Note:** This is from a webhook event. For full stack trace, check the Sentry dashboard.\n"); + + Ok(context) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::TopIssuesPeriod; + + fn test_config() -> SentryConfig { + SentryConfig { + enabled: true, + auth_token: "test".to_string(), + org_slug: "test-org".to_string(), + project_slugs: vec![], + top_issues_count: 100, + top_issues_period: TopIssuesPeriod::OneDay, + min_event_count: 10, + escalation_threshold_percent: 50, + client_secret: Some("test_secret".to_string()), + } + } + + #[test] + fn test_verify_signature() { + let handler = SentryWebhookHandler::new(test_config()); + let payload = b"test payload"; + + let mut mac = Hmac::::new_from_slice(b"test_secret").unwrap(); + mac.update(payload); + let expected = hex::encode(mac.finalize().into_bytes()); + + let mut headers = HashMap::new(); + headers.insert("sentry-hook-signature".to_string(), expected); + + assert!(handler.verify_signature(payload, &headers)); + } + + #[tokio::test] + async fn test_parse_payload_issue_created() { + let handler = SentryWebhookHandler::new(test_config()); + + let payload = serde_json::json!({ + "action": "created", + "data": { + "issue": { + "id": "123", + "shortId": "PROJ-ABC", + "title": "TypeError: Cannot read property", + "status": "unresolved", + "level": "error", + "count": 42, + "userCount": 5, + "firstSeen": "2024-01-01T00:00:00Z", + "lastSeen": "2024-01-01T01:00:00Z", + "project": { + "id": "1", + "name": "My Project", + "slug": "my-project" + }, + "metadata": { + "type": "TypeError", + "value": "Cannot read property 'x' of undefined" + } + } + } + }); + + let issue = handler.parse_payload(&payload).await.unwrap().unwrap(); + assert_eq!(issue.id, "123"); + assert_eq!(issue.short_id, "PROJ-ABC"); + assert_eq!(issue.priority, IssuePriority::High); + } + + #[tokio::test] + async fn test_parse_payload_non_created() { + let handler = SentryWebhookHandler::new(test_config()); + + let payload = serde_json::json!({ + "action": "resolved", + "data": {} + }); + + let result = handler.parse_payload(&payload).await.unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_matches_criteria_min_event_count() { + let handler = SentryWebhookHandler::new(test_config()); + + let mut issue = Issue::new("123", "PROJ-123", "Error", "https://sentry.io", "sentry"); + issue.set_metadata("event_count", 5i64); + issue.set_metadata("level", "error"); + + let result = handler.matches_criteria(&issue); + assert!(!result.matches); + } + + #[test] + fn test_matches_criteria_success() { + let handler = SentryWebhookHandler::new(test_config()); + + let mut issue = Issue::new("123", "PROJ-123", "Error", "https://sentry.io", "sentry"); + issue.set_metadata("event_count", 100i64); + issue.set_metadata("level", "error"); + + let result = handler.matches_criteria(&issue); + assert!(result.matches); + } + + #[test] + fn test_source_name() { + let handler = SentryWebhookHandler::new(test_config()); + assert_eq!(handler.source_name(), "sentry"); + } + + #[test] + fn test_verify_signature_missing_header() { + let handler = SentryWebhookHandler::new(test_config()); + let payload = b"test"; + let headers = HashMap::new(); + assert!(!handler.verify_signature(payload, &headers)); + } + + #[test] + fn test_verify_signature_no_secret() { + let mut config = test_config(); + config.client_secret = None; + let handler = SentryWebhookHandler::new(config); + let payload = b"test"; + let headers = HashMap::new(); + // Should return false when no secret configured (fail closed for security) + assert!(!handler.verify_signature(payload, &headers)); + } + + #[test] + fn test_verify_signature_invalid() { + let handler = SentryWebhookHandler::new(test_config()); + let payload = b"test payload"; + let mut headers = HashMap::new(); + headers.insert( + "sentry-hook-signature".to_string(), + "invalid_signature".to_string(), + ); + assert!(!handler.verify_signature(payload, &headers)); + } + + #[test] + fn test_map_priority_fatal() { + let priority = SentryWebhookHandler::map_priority("fatal", 1); + assert_eq!(priority, IssuePriority::Critical); + } + + #[test] + fn test_map_priority_error_high_count() { + let priority = SentryWebhookHandler::map_priority("error", 1001); + assert_eq!(priority, IssuePriority::Critical); + } + + #[test] + fn test_map_priority_error_normal() { + let priority = SentryWebhookHandler::map_priority("error", 100); + assert_eq!(priority, IssuePriority::High); + } + + #[test] + fn test_map_priority_warning() { + let priority = SentryWebhookHandler::map_priority("warning", 50); + assert_eq!(priority, IssuePriority::Medium); + } + + #[test] + fn test_map_priority_info() { + let priority = SentryWebhookHandler::map_priority("info", 10); + assert_eq!(priority, IssuePriority::Low); + } + + #[test] + fn test_map_status_resolved() { + let status = SentryWebhookHandler::map_status("resolved"); + assert_eq!(status, IssueStatus::Resolved); + } + + #[test] + fn test_map_status_ignored() { + let status = SentryWebhookHandler::map_status("ignored"); + assert_eq!(status, IssueStatus::Ignored); + } + + #[test] + fn test_map_status_unresolved() { + let status = SentryWebhookHandler::map_status("unresolved"); + assert_eq!(status, IssueStatus::Open); + } + + #[tokio::test] + async fn test_parse_payload_missing_data() { + let handler = SentryWebhookHandler::new(test_config()); + let payload = serde_json::json!({ + "action": "created" + }); + let result = handler.parse_payload(&payload).await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_parse_payload_missing_issue() { + let handler = SentryWebhookHandler::new(test_config()); + let payload = serde_json::json!({ + "action": "created", + "data": {} + }); + let result = handler.parse_payload(&payload).await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_parse_payload_missing_project() { + let handler = SentryWebhookHandler::new(test_config()); + let payload = serde_json::json!({ + "action": "created", + "data": { + "issue": { + "id": "123" + } + } + }); + let result = handler.parse_payload(&payload).await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_parse_payload_project_filter() { + let mut config = test_config(); + config.project_slugs = vec!["allowed-project".to_string()]; + let handler = SentryWebhookHandler::new(config); + + let payload = serde_json::json!({ + "action": "created", + "data": { + "issue": { + "id": "123", + "shortId": "PROJ-123", + "title": "Error", + "status": "unresolved", + "level": "error", + "count": 10, + "project": { + "slug": "disallowed-project", + "name": "Disallowed Project" + } + } + } + }); + + let result = handler.parse_payload(&payload).await.unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_matches_criteria_resolved_issue() { + let handler = SentryWebhookHandler::new(test_config()); + + let mut issue = Issue::new("123", "PROJ-123", "Error", "https://sentry.io", "sentry"); + issue.set_metadata("event_count", 100i64); + issue.set_metadata("level", "error"); + issue.status = IssueStatus::Resolved; + + let result = handler.matches_criteria(&issue); + assert!(!result.matches); + } + + #[test] + fn test_matches_criteria_urgent_priority() { + let handler = SentryWebhookHandler::new(test_config()); + + let mut issue = Issue::new("123", "PROJ-123", "Error", "https://sentry.io", "sentry"); + issue.set_metadata("event_count", 1500i64); + issue.set_metadata("level", "error"); + + let result = handler.matches_criteria(&issue); + assert!(result.matches); + assert_eq!(result.priority, MatchPriority::Urgent); + } + + #[test] + fn test_matches_criteria_high_priority() { + let handler = SentryWebhookHandler::new(test_config()); + + let mut issue = Issue::new("123", "PROJ-123", "Error", "https://sentry.io", "sentry"); + issue.set_metadata("event_count", 150i64); + issue.set_metadata("level", "error"); + + let result = handler.matches_criteria(&issue); + assert!(result.matches); + assert_eq!(result.priority, MatchPriority::High); + } + + #[tokio::test] + async fn test_build_issue_context() { + let handler = SentryWebhookHandler::new(test_config()); + + let mut issue = Issue::new( + "123", + "PROJ-123", + "TypeError", + "https://sentry.io/123", + "sentry", + ); + issue.set_metadata("level", "error"); + issue.set_metadata("project", "my-project"); + issue.set_metadata("event_count", 42i64); + issue.set_metadata("user_count", 5i64); + issue.set_metadata("culprit", "src/main.js in function"); + issue.set_metadata("error_type", "TypeError"); + issue.set_metadata("error_value", "Cannot read property"); + issue.set_metadata("filename", "src/main.js"); + issue.set_metadata("function", "handleClick"); + + let context = handler.build_issue_context(&issue).await.unwrap(); + assert!(context.contains("PROJ-123")); + assert!(context.contains("TypeError")); + assert!(context.contains("error")); + assert!(context.contains("my-project")); + assert!(context.contains("42")); + assert!(context.contains("src/main.js")); + assert!(context.contains("handleClick")); + } + + #[tokio::test] + async fn test_parse_payload_with_full_metadata() { + let handler = SentryWebhookHandler::new(test_config()); + + let payload = serde_json::json!({ + "action": "created", + "data": { + "issue": { + "id": "123", + "shortId": "PROJ-ABC", + "title": "TypeError in handler", + "status": "unresolved", + "level": "error", + "count": 42, + "userCount": 10, + "culprit": "src/handler.ts", + "firstSeen": "2024-01-01T00:00:00Z", + "lastSeen": "2024-01-01T01:00:00Z", + "project": { + "id": "1", + "name": "My Project", + "slug": "my-project" + }, + "metadata": { + "type": "TypeError", + "value": "undefined is not a function", + "filename": "src/handler.ts", + "function": "processRequest" + } + } + } + }); + + let issue = handler.parse_payload(&payload).await.unwrap().unwrap(); + assert_eq!(issue.id, "123"); + assert_eq!(issue.short_id, "PROJ-ABC"); + assert_eq!(issue.priority, IssuePriority::High); + + let event_count: i64 = issue.get_metadata("event_count").unwrap(); + assert_eq!(event_count, 42); + + let culprit: String = issue.get_metadata("culprit").unwrap(); + assert_eq!(culprit, "src/handler.ts"); + } +} diff --git a/src/webhook/sentry_api.rs b/src/webhook/sentry_api.rs new file mode 100644 index 00000000..a031c6ad --- /dev/null +++ b/src/webhook/sentry_api.rs @@ -0,0 +1,413 @@ +//! Sentry API client for webhook management. + +use crate::config::SentryConfig; +use crate::error::{Error, Result}; +use serde::{Deserialize, Serialize}; + +/// Client for Sentry REST API webhook operations. +pub struct SentryApiClient { + auth_token: String, + org_slug: String, + client: reqwest::Client, +} + +/// Result of a webhook registration. +#[derive(Debug, Clone)] +pub struct SentryWebhookRegistration { + /// The webhook/hook ID. + pub id: String, + /// The webhook URL. + pub url: String, + /// The signing secret for HMAC verification. + pub secret: String, + /// The events subscribed to. + pub events: Vec, + /// The project slug this hook is for. + pub project_slug: String, +} + +#[derive(Debug, Serialize)] +struct CreateHookRequest { + url: String, + events: Vec, +} + +#[derive(Debug, Deserialize)] +struct HookResponse { + id: String, + url: String, + secret: String, + events: Vec, +} + +#[derive(Debug, Deserialize)] +struct HookListItem { + id: String, + url: String, + events: Vec, +} + +#[derive(Debug, Deserialize)] +struct SentryErrorResponse { + detail: Option, +} + +#[derive(Debug, Deserialize)] +struct ProjectResponse { + slug: String, +} + +impl SentryApiClient { + const BASE_URL: &'static str = "https://sentry.io/api/0"; + + /// Create a new Sentry API client. + pub fn new(auth_token: String, org_slug: String) -> Self { + Self { + auth_token, + org_slug, + client: reqwest::Client::new(), + } + } + + /// Create from a SentryConfig. + pub fn from_config(config: &SentryConfig) -> Self { + Self::new(config.auth_token.clone(), config.org_slug.clone()) + } + + /// Get the organization slug. + pub fn org_slug(&self) -> &str { + &self.org_slug + } + + /// List all projects in the organization. + pub async fn list_projects(&self) -> Result> { + let url = format!( + "{}/organizations/{}/projects/", + Self::BASE_URL, + self.org_slug + ); + + let response = self + .client + .get(&url) + .header("Authorization", format!("Bearer {}", self.auth_token)) + .send() + .await + .map_err(|e| Error::api(format!("Failed to send request to Sentry API: {}", e)))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(Error::api(format!( + "Sentry API returned status {}: {}", + status, body + ))); + } + + let projects: Vec = response + .json() + .await + .map_err(|e| Error::api(format!("Failed to parse Sentry API response: {}", e)))?; + + Ok(projects.into_iter().map(|p| p.slug).collect()) + } + + /// Register a new webhook (service hook) for a project. + /// + /// # Arguments + /// * `project_slug` - The project slug to register the hook for + /// * `url` - The URL where Sentry will send webhook events + /// * `events` - List of events to subscribe to (e.g., ["event.created", "event.alert"]) + /// + /// # Returns + /// The webhook registration result including the signing secret. + pub async fn create_webhook( + &self, + project_slug: &str, + url: &str, + events: &[&str], + ) -> Result { + let api_url = format!( + "{}/projects/{}/{}/hooks/", + Self::BASE_URL, + self.org_slug, + project_slug + ); + + let request_body = CreateHookRequest { + url: url.to_string(), + events: events.iter().map(|s| s.to_string()).collect(), + }; + + let response = self + .client + .post(&api_url) + .header("Authorization", format!("Bearer {}", self.auth_token)) + .header("Content-Type", "application/json") + .json(&request_body) + .send() + .await + .map_err(|e| Error::api(format!("Failed to send request to Sentry API: {}", e)))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + + // Try to parse the error message + if let Ok(error_resp) = serde_json::from_str::(&body) { + if let Some(detail) = error_resp.detail { + return Err(Error::api(format!( + "Sentry API error ({}): {}", + status, detail + ))); + } + } + + return Err(Error::api(format!( + "Sentry API returned status {}: {}", + status, body + ))); + } + + let hook: HookResponse = response + .json() + .await + .map_err(|e| Error::api(format!("Failed to parse Sentry API response: {}", e)))?; + + Ok(SentryWebhookRegistration { + id: hook.id, + url: hook.url, + secret: hook.secret, + events: hook.events, + project_slug: project_slug.to_string(), + }) + } + + /// List existing webhooks for a project. + pub async fn list_webhooks( + &self, + project_slug: &str, + ) -> Result)>> { + let url = format!( + "{}/projects/{}/{}/hooks/", + Self::BASE_URL, + self.org_slug, + project_slug + ); + + let response = self + .client + .get(&url) + .header("Authorization", format!("Bearer {}", self.auth_token)) + .send() + .await + .map_err(|e| Error::api(format!("Failed to send request to Sentry API: {}", e)))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(Error::api(format!( + "Sentry API returned status {}: {}", + status, body + ))); + } + + let hooks: Vec = response + .json() + .await + .map_err(|e| Error::api(format!("Failed to parse Sentry API response: {}", e)))?; + + Ok(hooks.into_iter().map(|h| (h.id, h.url, h.events)).collect()) + } + + /// Check if a webhook with the given URL already exists for a project. + pub async fn webhook_exists(&self, project_slug: &str, url: &str) -> Result { + let webhooks = self.list_webhooks(project_slug).await?; + Ok(webhooks.iter().any(|(_, wh_url, _)| wh_url == url)) + } + + /// Delete a webhook by ID for a project. + pub async fn delete_webhook(&self, project_slug: &str, hook_id: &str) -> Result<()> { + let url = format!( + "{}/projects/{}/{}/hooks/{}/", + Self::BASE_URL, + self.org_slug, + project_slug, + hook_id + ); + + let response = self + .client + .delete(&url) + .header("Authorization", format!("Bearer {}", self.auth_token)) + .send() + .await + .map_err(|e| Error::api(format!("Failed to send request to Sentry API: {}", e)))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(Error::api(format!( + "Sentry API returned status {}: {}", + status, body + ))); + } + + Ok(()) + } + + /// Register webhooks for multiple projects. + /// + /// If `project_slugs` is empty, attempts to register for all projects in the org. + /// Returns a list of registration results (one per project). + pub async fn create_webhooks_for_projects( + &self, + project_slugs: &[String], + url: &str, + events: &[&str], + ) -> Result> { + let slugs = if project_slugs.is_empty() { + self.list_projects().await? + } else { + project_slugs.to_vec() + }; + + let mut registrations = Vec::new(); + let mut errors = Vec::new(); + + for slug in &slugs { + match self.create_webhook(slug, url, events).await { + Ok(reg) => registrations.push(reg), + Err(e) => { + // Log but continue with other projects + tracing::warn!("Failed to create webhook for project {}: {}", slug, e); + errors.push(format!("{}: {}", slug, e)); + } + } + } + + if registrations.is_empty() && !errors.is_empty() { + return Err(Error::api(format!( + "Failed to create webhooks for any project: {}", + errors.join("; ") + ))); + } + + Ok(registrations) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::TopIssuesPeriod; + + #[test] + fn test_sentry_api_client_new() { + let client = SentryApiClient::new("token".to_string(), "my-org".to_string()); + assert_eq!(client.auth_token, "token"); + assert_eq!(client.org_slug, "my-org"); + } + + #[test] + fn test_sentry_api_client_from_config() { + let config = SentryConfig { + enabled: true, + auth_token: "sentry_token".to_string(), + org_slug: "test-org".to_string(), + project_slugs: vec!["proj1".to_string()], + top_issues_count: 100, + top_issues_period: TopIssuesPeriod::OneDay, + min_event_count: 10, + escalation_threshold_percent: 50, + client_secret: None, + }; + let client = SentryApiClient::from_config(&config); + assert_eq!(client.auth_token, "sentry_token"); + assert_eq!(client.org_slug, "test-org"); + } + + #[test] + fn test_sentry_api_client_org_slug() { + let client = SentryApiClient::new("token".to_string(), "my-org".to_string()); + assert_eq!(client.org_slug(), "my-org"); + } + + #[test] + fn test_sentry_webhook_registration_fields() { + let registration = SentryWebhookRegistration { + id: "hook_123".to_string(), + url: "https://example.com/webhook".to_string(), + secret: "secret_abc".to_string(), + events: vec!["event.created".to_string()], + project_slug: "my-project".to_string(), + }; + assert_eq!(registration.id, "hook_123"); + assert_eq!(registration.url, "https://example.com/webhook"); + assert_eq!(registration.secret, "secret_abc"); + assert_eq!(registration.events, vec!["event.created"]); + assert_eq!(registration.project_slug, "my-project"); + } + + #[test] + fn test_create_hook_request_serialization() { + let request = CreateHookRequest { + url: "https://example.com/hook".to_string(), + events: vec!["event.created".to_string(), "event.alert".to_string()], + }; + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("url")); + assert!(json.contains("events")); + assert!(json.contains("event.created")); + } + + #[test] + fn test_hook_response_deserialization() { + let json = r#"{ + "id": "123", + "url": "https://example.com/hook", + "secret": "abc123", + "events": ["event.created"], + "status": "active", + "dateCreated": "2024-01-01T00:00:00Z" + }"#; + let response: HookResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.id, "123"); + assert_eq!(response.url, "https://example.com/hook"); + assert_eq!(response.secret, "abc123"); + } + + #[test] + fn test_hook_list_item_deserialization() { + let json = r#"[ + { + "id": "1", + "url": "https://example.com/hook1", + "events": ["event.created"], + "status": "active" + }, + { + "id": "2", + "url": "https://example.com/hook2", + "events": ["event.alert"], + "status": "disabled" + } + ]"#; + let hooks: Vec = serde_json::from_str(json).unwrap(); + assert_eq!(hooks.len(), 2); + assert_eq!(hooks[0].id, "1"); + assert_eq!(hooks[1].url, "https://example.com/hook2"); + } +} diff --git a/src/webhook/server.rs b/src/webhook/server.rs new file mode 100644 index 00000000..fda093cf --- /dev/null +++ b/src/webhook/server.rs @@ -0,0 +1,1563 @@ +//! HTTP server for webhooks. + +use super::{WebhookHandler, WebhookHandlerRegistry}; +use crate::config::Config; +use crate::error::Result; +use crate::inference::{IssueContext, RepoInferrer}; +use crate::notifier::Notifier; +use crate::repo::RepoIndex; +use crate::runner::{ClaudeRunner, ClaudeRunnerConfig}; +use crate::storage::{FixAttemptTracker, SqliteTracker}; +use crate::types::{validate_issue_id, ActivityLogEntry, Issue}; +use axum::{ + body::Bytes, + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::Json, + routing::{get, post}, + Router, +}; +use serde_json::json; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::RwLock; +use tower::limit::ConcurrencyLimitLayer; + +/// State shared across handlers. +struct AppState { + config: Config, + handlers: WebhookHandlerRegistry, + notifier: Arc, + tracker: Arc, + inferrer: Option, + claude: ClaudeRunner, + processing: RwLock>, +} + +/// HTTP server for webhooks. +pub struct WebhookServer { + config: Config, + handlers: WebhookHandlerRegistry, + notifier: Arc, + tracker: Arc, + inferrer: Option, + port: u16, +} + +impl WebhookServer { + /// Create a new webhook server. + pub fn new( + config: Config, + handlers: WebhookHandlerRegistry, + notifier: Arc, + tracker: Arc, + inferrer: Option, + ) -> Self { + let port = config.webhook_port; + Self { + config, + handlers, + notifier, + tracker, + inferrer, + port, + } + } + + /// Build a repository inferrer from config. + pub fn build_inferrer(config: &Config) -> Result> { + if config.known_orgs.is_empty() || config.auto_discover_paths.is_empty() { + tracing::info!("No known_orgs or auto_discover_paths configured, inference disabled"); + return Ok(None); + } + + let index = RepoIndex::build(&config.known_orgs, &config.auto_discover_paths)?; + if index.is_empty() { + tracing::warn!("Repository index is empty, no repos discovered"); + return Ok(None); + } + + tracing::info!( + repos = index.len(), + files = index.total_files(), + "Repository index built for inference" + ); + + Ok(Some(RepoInferrer::new(index))) + } + + /// Start the server. + pub async fn start(self) -> Result<()> { + let state = Arc::new(AppState { + claude: ClaudeRunner::new( + ClaudeRunnerConfig { + timeout_secs: self.config.claude_timeout_secs, + }, + self.tracker.clone(), + ), + config: self.config, + handlers: self.handlers, + notifier: self.notifier, + tracker: self.tracker, + inferrer: self.inferrer, + processing: RwLock::new(HashSet::new()), + }); + + // Concurrency limit: max 10 concurrent webhook processing + // This prevents overwhelming the system with too many simultaneous fix attempts + // Combined with the processing set, this provides effective rate control + let concurrency_layer = ConcurrencyLimitLayer::new(10); + + let app = Router::new() + .route("/health", get(health_handler)) + .route( + "/webhook/:source", + post(webhook_handler).layer(concurrency_layer), + ) + .with_state(state.clone()); + + let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", self.port)).await?; + + tracing::info!(port = self.port, "Webhook server listening"); + tracing::info!( + work_dir = ?state.config.work_dir, + known_orgs = state.config.known_orgs.len(), + "Repository configuration" + ); + tracing::info!("Concurrency limit: 10 concurrent webhook requests maximum"); + tracing::info!( + "Handlers: {}", + state + .handlers + .get_all() + .iter() + .map(|h| h.source_name()) + .collect::>() + .join(", ") + ); + tracing::info!(""); + tracing::info!("Endpoints:"); + tracing::info!(" GET http://localhost:{}/health", self.port); + for handler in state.handlers.get_all() { + tracing::info!( + " POST http://localhost:{}/webhook/{}", + self.port, + handler.source_name() + ); + } + + axum::serve(listener, app).await?; + + Ok(()) + } +} + +async fn health_handler(State(state): State>) -> Json { + let processing_count = state.processing.read().await.len(); + let handlers: Vec<&str> = state + .handlers + .get_all() + .iter() + .map(|h| h.source_name()) + .collect(); + + Json(json!({ + "status": "ok", + "processing_count": processing_count, + "handlers": handlers + })) +} + +async fn webhook_handler( + State(state): State>, + Path(source_name): Path, + headers: HeaderMap, + body: Bytes, +) -> (StatusCode, Json) { + let handler = match state.handlers.get(&source_name) { + Some(h) => h, + None => { + return ( + StatusCode::NOT_FOUND, + Json(json!({ "error": format!("Unknown source: {}", source_name) })), + ); + } + }; + + // Convert headers to HashMap + let header_map: HashMap = headers + .iter() + .filter_map(|(k, v)| { + v.to_str() + .ok() + .map(|val| (k.as_str().to_lowercase(), val.to_string())) + }) + .collect(); + + // Log webhook received + let has_signature = header_map.contains_key("x-signature") + || header_map.contains_key("sentry-hook-signature") + || header_map.contains_key("linear-signature"); + let activity = ActivityLogEntry::new( + "webhook_received", + format!("Webhook received from {}", source_name), + ) + .with_source(source_name.clone()) + .with_metadata(json!({ + "content_length": body.len(), + "has_signature": has_signature + })); + state.tracker.record_activity(&activity).ok(); + + // Verify signature + if !handler.verify_signature(&body, &header_map) { + tracing::error!(source = source_name.as_str(), "Invalid webhook signature"); + + // Log webhook rejected + let activity = ActivityLogEntry::new( + "webhook_rejected", + format!("Webhook rejected: invalid signature from {}", source_name), + ) + .with_source(source_name.clone()); + state.tracker.record_activity(&activity).ok(); + + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "Invalid signature" })), + ); + } + + // Parse JSON + let payload: serde_json::Value = match serde_json::from_slice(&body) { + Ok(p) => p, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "Invalid JSON" })), + ); + } + }; + + // Parse into issue + let issue = match handler.parse_payload(&payload).await { + Ok(Some(issue)) => issue, + Ok(None) => { + return ( + StatusCode::OK, + Json(json!({ "status": "ignored", "reason": "Event not applicable" })), + ); + } + Err(e) => { + tracing::error!( + source = source_name.as_str(), + error = %e, + "Error parsing payload" + ); + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "Failed to parse payload" })), + ); + } + }; + + // Validate issue ID to prevent path traversal and other security issues + if let Err(validation_error) = validate_issue_id(&issue.id) { + tracing::warn!( + source = source_name.as_str(), + issue_id = issue.id.as_str(), + error = validation_error.as_str(), + "Invalid issue ID rejected" + ); + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": format!("Invalid issue ID: {}", validation_error) })), + ); + } + + // Check criteria + let match_result = handler.matches_criteria(&issue); + if !match_result.matches { + tracing::info!( + source = source_name.as_str(), + issue_id = issue.short_id.as_str(), + reason = match_result.reason.as_str(), + "Issue does not match criteria" + ); + return ( + StatusCode::OK, + Json(json!({ "status": "ignored", "reason": match_result.reason })), + ); + } + + // Check if already attempted + if state.tracker.has_attempted(&source_name, &issue.id) { + return ( + StatusCode::OK, + Json(json!({ "status": "ignored", "reason": "Already attempted" })), + ); + } + + // Check if currently processing + let processing_key = format!("{}:{}", source_name, issue.id); + { + let processing = state.processing.read().await; + if processing.contains(&processing_key) { + return ( + StatusCode::OK, + Json(json!({ "status": "ignored", "reason": "Already processing" })), + ); + } + } + + // Accept and process in background + let short_id = issue.short_id.clone(); + let state_clone = Arc::clone(&state); + let handler_clone = Arc::clone(handler); + + tokio::spawn(async move { + if let Err(e) = process_issue(state_clone, handler_clone, issue, match_result).await { + tracing::error!(source = source_name.as_str(), error = %e, "Error processing webhook"); + } + }); + + ( + StatusCode::ACCEPTED, + Json(json!({ "status": "accepted", "issue": short_id })), + ) +} + +// Repository resolution is now handled by the inference engine (RepoInferrer). +// See src/inference/mod.rs for the new implementation. + +async fn process_issue( + state: Arc, + handler: Arc, + issue: Issue, + match_result: crate::types::MatchResult, +) -> Result<()> { + let source_name = handler.source_name(); + let processing_key = format!("{}:{}", source_name, issue.id); + + tracing::info!(short_id = %issue.short_id, title = %issue.title, "Processing webhook issue"); + tracing::info!(short_id = %issue.short_id, reason = %match_result.reason, "Match reason"); + + // Infer the target repository + let inference_start = Instant::now(); + let context = IssueContext::from_issue(&issue); + + let project_dir = match &state.inferrer { + Some(inferrer) => { + match inferrer.infer(&issue) { + Some(inferred) => { + let duration_ms = inference_start.elapsed().as_millis() as i64; + tracing::info!( + short_id = %issue.short_id, + repo = %inferred.repo.name, + confidence = %inferred.confidence, + reason = %inferred.reason, + duration_ms = duration_ms, + "Repository inferred" + ); + + // Record inference attempt for analytics + record_inference_attempt(&state, &issue, &context, Some(&inferred), duration_ms); + + let activity = ActivityLogEntry::new( + "repo_inferred", + format!("Inferred repo {} for {}", inferred.repo.name, issue.short_id), + ) + .with_source(issue.source.clone()) + .with_issue(issue.id.clone(), issue.short_id.clone()) + .with_metadata(json!({ + "repo": inferred.repo.name, + "confidence": inferred.confidence.to_string(), + "reason": inferred.reason, + "matched_file": inferred.matched_file + })); + state.tracker.record_activity(&activity).ok(); + + inferred.repo.path.clone() + } + None => { + let duration_ms = inference_start.elapsed().as_millis() as i64; + tracing::warn!( + short_id = %issue.short_id, + source = %issue.source, + "Could not infer repository for issue" + ); + + // Record failed inference attempt + record_inference_attempt(&state, &issue, &context, None, duration_ms); + + let activity = ActivityLogEntry::new( + "inference_failed", + format!("Could not infer repository for {}", issue.short_id), + ) + .with_source(issue.source.clone()) + .with_issue(issue.id.clone(), issue.short_id.clone()) + .with_metadata(json!({ + "extracted_filenames": context.filenames, + "extracted_functions": context.functions + })); + state.tracker.record_activity(&activity).ok(); + + // Fall back to work_dir + state.config.work_dir.clone() + } + } + } + None => { + tracing::warn!( + short_id = %issue.short_id, + "No inferrer configured, using work_dir" + ); + let activity = ActivityLogEntry::new( + "webhook_processing", + format!("No inferrer configured for {}", issue.short_id), + ) + .with_source(issue.source.clone()) + .with_issue(issue.id.clone(), issue.short_id.clone()) + .with_metadata(json!({ + "reason": "no_inferrer_configured" + })); + state.tracker.record_activity(&activity).ok(); + + state.config.work_dir.clone() + } + }; + + // Mark as processing + { + let mut processing = state.processing.write().await; + processing.insert(processing_key.clone()); + } + + // Record attempt + state + .tracker + .record_attempt(source_name, &issue.id, &issue.short_id)?; + + let result = async { + // Notify start + state.notifier.notify_start(&issue).await?; + + // Build context and run Claude + let context = handler.build_issue_context(&issue).await?; + let claude_result = state.claude.run_fix(&issue, &context, &project_dir).await?; + + if claude_result.success { + if let Some(pr_url) = claude_result.pr_url { + tracing::info!(short_id = %issue.short_id, pr_url = %pr_url, "Success! PR created"); + state + .tracker + .mark_success(source_name, &issue.id, &pr_url)?; + state.notifier.notify_success(&issue, &pr_url).await?; + + // Log webhook processed successfully with PR + let activity = ActivityLogEntry::new( + "webhook_processed", + format!("Webhook processed: {} - PR created", issue.short_id), + ) + .with_source(source_name.to_string()) + .with_issue(issue.id.clone(), issue.short_id.clone()) + .with_metadata(json!({ + "pr_url": pr_url, + "success": true + })); + state.tracker.record_activity(&activity).ok(); + } else { + tracing::info!(short_id = %issue.short_id, "Completed but no PR URL found"); + state + .tracker + .mark_failed(source_name, &issue.id, "No PR URL found")?; + state.notifier.notify_completed(&issue).await?; + + // Log webhook processed without PR + let activity = ActivityLogEntry::new( + "webhook_processed", + format!("Webhook processed: {} - no PR created", issue.short_id), + ) + .with_source(source_name.to_string()) + .with_issue(issue.id.clone(), issue.short_id.clone()) + .with_metadata(json!({ + "success": false, + "reason": "No PR URL found" + })); + state.tracker.record_activity(&activity).ok(); + } + } else { + let error = claude_result.error.as_deref().unwrap_or("Unknown error"); + tracing::error!(short_id = %issue.short_id, error = %error, "Failed"); + state.tracker.mark_failed(source_name, &issue.id, error)?; + state.notifier.notify_failed(&issue, error).await?; + + // Log webhook processing failed + let activity = ActivityLogEntry::new( + "webhook_processed", + format!("Webhook processed: {} - failed", issue.short_id), + ) + .with_source(source_name.to_string()) + .with_issue(issue.id.clone(), issue.short_id.clone()) + .with_metadata(json!({ + "success": false, + "error": error + })); + state.tracker.record_activity(&activity).ok(); + } + + Ok::<_, crate::error::Error>(()) + } + .await; + + // Remove from processing + { + let mut processing = state.processing.write().await; + processing.remove(&processing_key); + } + + result +} + +/// Record an inference attempt for analytics. +fn record_inference_attempt( + state: &AppState, + issue: &Issue, + context: &IssueContext, + inferred: Option<&crate::inference::InferredRepo>, + duration_ms: i64, +) { + // Try to downcast tracker to SqliteTracker for inference recording + let tracker_any = &state.tracker as &dyn std::any::Any; + if let Some(sqlite_tracker) = tracker_any.downcast_ref::>() { + let (confidence, reason, repo_id) = match inferred { + Some(inf) => ( + inf.confidence.to_string(), + inf.reason.clone(), + sqlite_tracker.get_indexed_repo(&inf.repo.name).ok().flatten().map(|r| r.id), + ), + None => ("none".to_string(), "No match found".to_string(), None), + }; + + if let Err(e) = sqlite_tracker.record_inference_attempt( + &issue.id, + &issue.source, + &context.filenames, + &context.functions, + &context.keywords, + repo_id, + &confidence, + &reason, + Some(duration_ms as u64), + ) { + tracing::warn!(error = %e, "Failed to record inference attempt"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{ + DiscordConfig, EmailConfig, GitHubConfig, PushConfig, RetryConfig, SmsConfig, + }; + use crate::notifier::Notifier; + use crate::reports::Report; + use crate::storage::SqliteTracker; + use crate::types::{Issue, MatchPriority, MatchResult}; + use async_trait::async_trait; + use std::sync::atomic::{AtomicUsize, Ordering}; + + // Mock notifier for testing + struct MockNotifier { + call_count: AtomicUsize, + } + + impl MockNotifier { + fn new() -> Self { + Self { + call_count: AtomicUsize::new(0), + } + } + } + + #[async_trait] + impl Notifier for MockNotifier { + fn name(&self) -> &str { + "mock" + } + fn is_enabled(&self) -> bool { + true + } + async fn notify_start(&self, _issue: &Issue) -> crate::error::Result<()> { + self.call_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + async fn notify_success(&self, _issue: &Issue, _pr_url: &str) -> crate::error::Result<()> { + self.call_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + async fn notify_completed(&self, _issue: &Issue) -> crate::error::Result<()> { + self.call_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + async fn notify_failed(&self, _issue: &Issue, _error: &str) -> crate::error::Result<()> { + self.call_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + async fn notify_status(&self, _message: &str) -> crate::error::Result<()> { + self.call_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + async fn notify_urgent_issues(&self, _issues: &[Issue]) -> crate::error::Result<()> { + self.call_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + async fn notify_merged(&self, _issue: &Issue, _pr_url: &str) -> crate::error::Result<()> { + self.call_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + async fn notify_report(&self, _report: &Report) -> crate::error::Result<()> { + self.call_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + // Mock webhook handler for testing + struct MockWebhookHandler { + name: String, + } + + impl MockWebhookHandler { + fn new(name: &str) -> Self { + Self { + name: name.to_string(), + } + } + } + + #[async_trait] + impl WebhookHandler for MockWebhookHandler { + fn source_name(&self) -> &str { + &self.name + } + fn verify_signature(&self, _body: &[u8], _headers: &HashMap) -> bool { + true + } + async fn parse_payload( + &self, + _payload: &serde_json::Value, + ) -> crate::error::Result> { + Ok(Some(Issue::new( + "1", + "TEST-1", + "Test", + "https://test.com", + &self.name, + ))) + } + fn matches_criteria(&self, _issue: &Issue) -> MatchResult { + MatchResult::matched("Test", MatchPriority::Normal) + } + async fn build_issue_context(&self, issue: &Issue) -> crate::error::Result { + Ok(format!("Context for {}", issue.short_id)) + } + } + + fn test_config() -> Config { + Config { + work_dir: std::path::PathBuf::from("/tmp/repos"), + known_orgs: vec!["test-org".to_string()], + auto_discover_paths: vec![], + poll_interval_ms: 60000, + webhook_port: 8080, + db_path: std::path::PathBuf::from(":memory:"), + max_issues_per_cycle: 5, + max_concurrent: 2, + processing_delay_ms: 1000, + max_activity_entries: 100, + ipc_timeout_secs: 30, + claude_timeout_secs: 21600, + discord: DiscordConfig::default(), + email: EmailConfig::default(), + sms: SmsConfig::default(), + push: PushConfig::default(), + github: GitHubConfig::default(), + retry: RetryConfig::default(), + linear: None, + sentry: None, + } + } + + #[test] + fn test_webhook_server_new() { + let config = test_config(); + let handlers = WebhookHandlerRegistry::new(); + let notifier = Arc::new(MockNotifier::new()); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + + let server = WebhookServer::new(config, handlers, notifier, tracker, None); + + assert_eq!(server.port, 8080); + } + + #[test] + fn test_webhook_server_with_custom_port() { + let mut config = test_config(); + config.webhook_port = 3000; + let handlers = WebhookHandlerRegistry::new(); + let notifier = Arc::new(MockNotifier::new()); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + + let server = WebhookServer::new(config, handlers, notifier, tracker, None); + + assert_eq!(server.port, 3000); + } + + #[test] + fn test_webhook_handler_registry_new() { + let registry = WebhookHandlerRegistry::new(); + assert!(registry.get_all().is_empty()); + } + + #[test] + fn test_webhook_handler_registry_register() { + let mut registry = WebhookHandlerRegistry::new(); + let handler = Arc::new(MockWebhookHandler::new("test")); + + registry.register(handler); + + assert_eq!(registry.get_all().len(), 1); + } + + #[test] + fn test_webhook_handler_registry_get() { + let mut registry = WebhookHandlerRegistry::new(); + let handler = Arc::new(MockWebhookHandler::new("linear")); + + registry.register(handler); + + assert!(registry.get("linear").is_some()); + assert!(registry.get("sentry").is_none()); + } + + #[test] + fn test_webhook_handler_registry_multiple_handlers() { + let mut registry = WebhookHandlerRegistry::new(); + registry.register(Arc::new(MockWebhookHandler::new("linear"))); + registry.register(Arc::new(MockWebhookHandler::new("sentry"))); + registry.register(Arc::new(MockWebhookHandler::new("github"))); + + assert_eq!(registry.get_all().len(), 3); + assert!(registry.get("linear").is_some()); + assert!(registry.get("sentry").is_some()); + assert!(registry.get("github").is_some()); + } + + #[test] + fn test_mock_webhook_handler_source_name() { + let handler = MockWebhookHandler::new("test-source"); + assert_eq!(handler.source_name(), "test-source"); + } + + #[test] + fn test_mock_webhook_handler_verify_signature() { + let handler = MockWebhookHandler::new("test"); + let headers: HashMap = HashMap::new(); + assert!(handler.verify_signature(b"body", &headers)); + } + + #[tokio::test] + async fn test_mock_webhook_handler_parse_payload() { + let handler = MockWebhookHandler::new("test"); + let payload = serde_json::json!({}); + + let result = handler.parse_payload(&payload).await.unwrap(); + + assert!(result.is_some()); + let issue = result.unwrap(); + assert_eq!(issue.short_id, "TEST-1"); + } + + #[test] + fn test_mock_webhook_handler_matches_criteria() { + let handler = MockWebhookHandler::new("test"); + let issue = Issue::new("1", "TEST-1", "Test", "https://test.com", "test"); + + let result = handler.matches_criteria(&issue); + + assert!(result.matches); + assert_eq!(result.priority, MatchPriority::Normal); + } + + #[tokio::test] + async fn test_mock_webhook_handler_build_issue_context() { + let handler = MockWebhookHandler::new("test"); + let issue = Issue::new("1", "TEST-1", "Test", "https://test.com", "test"); + + let context = handler.build_issue_context(&issue).await.unwrap(); + + assert!(context.contains("TEST-1")); + } + + #[test] + fn test_processing_key_format() { + // Verify the format of processing keys + let source_name = "linear"; + let issue_id = "abc123"; + let key = format!("{}:{}", source_name, issue_id); + + assert_eq!(key, "linear:abc123"); + assert!(key.contains(':')); + } + + #[test] + fn test_webhook_handler_registry_overwrite() { + let mut registry = WebhookHandlerRegistry::new(); + let handler1 = Arc::new(MockWebhookHandler::new("test")); + let handler2 = Arc::new(MockWebhookHandler::new("test")); + + registry.register(handler1); + registry.register(handler2); + + // Both have same name, registry should handle this + // (depends on implementation - may have 1 or 2 entries) + assert!(registry.get("test").is_some()); + } + + #[test] + fn test_mock_notifier_enabled() { + let notifier = MockNotifier::new(); + assert!(notifier.is_enabled()); + assert_eq!(notifier.name(), "mock"); + } + + #[test] + fn test_app_state_processing_set_is_hashset() { + // Verify that processing uses HashSet semantics + let mut set = HashSet::new(); + set.insert("key1".to_string()); + set.insert("key1".to_string()); // duplicate + + // HashSet should only contain one entry + assert_eq!(set.len(), 1); + } + + #[test] + fn test_json_response_structure() { + // Test the expected JSON response structures + let accepted = json!({ "status": "accepted", "issue": "TEST-1" }); + assert_eq!(accepted["status"], "accepted"); + assert_eq!(accepted["issue"], "TEST-1"); + + let ignored = json!({ "status": "ignored", "reason": "test reason" }); + assert_eq!(ignored["status"], "ignored"); + assert_eq!(ignored["reason"], "test reason"); + + let error = json!({ "error": "error message" }); + assert!(error["error"].is_string()); + } + + #[test] + fn test_health_response_structure() { + // Test expected health response structure + let health = json!({ + "status": "ok", + "processing_count": 0, + "handlers": ["linear", "sentry"] + }); + + assert_eq!(health["status"], "ok"); + assert_eq!(health["processing_count"], 0); + assert!(health["handlers"].is_array()); + } + + #[test] + fn test_header_lowercasing() { + // Test that headers are lowercased correctly + let original = "X-Hub-Signature-256"; + let lowercased = original.to_lowercase(); + assert_eq!(lowercased, "x-hub-signature-256"); + } + + #[tokio::test] + async fn test_rwlock_processing_set() { + // Test RwLock behavior for concurrent access + let processing: RwLock> = RwLock::new(HashSet::new()); + + // Write + { + let mut write_guard = processing.write().await; + write_guard.insert("test".to_string()); + } + + // Read + { + let read_guard = processing.read().await; + assert!(read_guard.contains("test")); + assert_eq!(read_guard.len(), 1); + } + + // Remove + { + let mut write_guard = processing.write().await; + write_guard.remove("test"); + } + + // Verify removed + { + let read_guard = processing.read().await; + assert!(!read_guard.contains("test")); + } + } + + // Tests for health_handler + #[tokio::test] + async fn test_health_handler() { + use axum::extract::State; + + let config = test_config(); + let mut handlers = WebhookHandlerRegistry::new(); + handlers.register(Arc::new(MockWebhookHandler::new("linear"))); + handlers.register(Arc::new(MockWebhookHandler::new("sentry"))); + let notifier = Arc::new(MockNotifier::new()); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + + let state = Arc::new(AppState { + claude: ClaudeRunner::new( + ClaudeRunnerConfig { + timeout_secs: config.claude_timeout_secs, + }, + tracker.clone(), + ), + config, + handlers, + notifier, + tracker, + inferrer: None, + processing: RwLock::new(HashSet::new()), + }); + + let Json(response) = health_handler(State(state)).await; + + assert_eq!(response["status"], "ok"); + assert_eq!(response["processing_count"], 0); + } + + #[tokio::test] + async fn test_health_handler_with_processing() { + use axum::extract::State; + + let config = test_config(); + let handlers = WebhookHandlerRegistry::new(); + let notifier = Arc::new(MockNotifier::new()); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + + let mut processing_set = HashSet::new(); + processing_set.insert("linear:issue1".to_string()); + processing_set.insert("sentry:issue2".to_string()); + + let state = Arc::new(AppState { + claude: ClaudeRunner::new( + ClaudeRunnerConfig { + timeout_secs: config.claude_timeout_secs, + }, + tracker.clone(), + ), + config, + handlers, + notifier, + tracker, + inferrer: None, + processing: RwLock::new(processing_set), + }); + + let Json(response) = health_handler(State(state)).await; + + assert_eq!(response["status"], "ok"); + assert_eq!(response["processing_count"], 2); + } + + // Tests for webhook_handler + #[tokio::test] + async fn test_webhook_handler_unknown_source() { + use axum::extract::{Path, State}; + use axum::http::HeaderMap; + + let config = test_config(); + let handlers = WebhookHandlerRegistry::new(); + let notifier = Arc::new(MockNotifier::new()); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + + let state = Arc::new(AppState { + claude: ClaudeRunner::new( + ClaudeRunnerConfig { + timeout_secs: config.claude_timeout_secs, + }, + tracker.clone(), + ), + config, + handlers, + notifier, + tracker, + inferrer: None, + processing: RwLock::new(HashSet::new()), + }); + + let (status, Json(response)) = webhook_handler( + State(state), + Path("unknown".to_string()), + HeaderMap::new(), + Bytes::from_static(b"{}"), + ) + .await; + + assert_eq!(status, StatusCode::NOT_FOUND); + assert!(response["error"] + .as_str() + .unwrap() + .contains("Unknown source")); + } + + // Mock handler that rejects signatures + struct RejectingSignatureHandler; + + #[async_trait] + impl WebhookHandler for RejectingSignatureHandler { + fn source_name(&self) -> &str { + "rejecting" + } + fn verify_signature(&self, _body: &[u8], _headers: &HashMap) -> bool { + false + } + async fn parse_payload( + &self, + _payload: &serde_json::Value, + ) -> crate::error::Result> { + Ok(None) + } + fn matches_criteria(&self, _issue: &Issue) -> MatchResult { + MatchResult::matched("Test", MatchPriority::Normal) + } + async fn build_issue_context(&self, _issue: &Issue) -> crate::error::Result { + Ok(String::new()) + } + } + + #[tokio::test] + async fn test_webhook_handler_invalid_signature() { + use axum::extract::{Path, State}; + use axum::http::HeaderMap; + + let config = test_config(); + let mut handlers = WebhookHandlerRegistry::new(); + handlers.register(Arc::new(RejectingSignatureHandler)); + let notifier = Arc::new(MockNotifier::new()); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + + let state = Arc::new(AppState { + claude: ClaudeRunner::new( + ClaudeRunnerConfig { + timeout_secs: config.claude_timeout_secs, + }, + tracker.clone(), + ), + config, + handlers, + notifier, + tracker, + inferrer: None, + processing: RwLock::new(HashSet::new()), + }); + + let (status, Json(response)) = webhook_handler( + State(state), + Path("rejecting".to_string()), + HeaderMap::new(), + Bytes::from_static(b"{}"), + ) + .await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert!(response["error"] + .as_str() + .unwrap() + .contains("Invalid signature")); + } + + #[tokio::test] + async fn test_webhook_handler_invalid_json() { + use axum::extract::{Path, State}; + use axum::http::HeaderMap; + + let config = test_config(); + let mut handlers = WebhookHandlerRegistry::new(); + handlers.register(Arc::new(MockWebhookHandler::new("test"))); + let notifier = Arc::new(MockNotifier::new()); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + + let state = Arc::new(AppState { + claude: ClaudeRunner::new( + ClaudeRunnerConfig { + timeout_secs: config.claude_timeout_secs, + }, + tracker.clone(), + ), + config, + handlers, + notifier, + tracker, + inferrer: None, + processing: RwLock::new(HashSet::new()), + }); + + let (status, Json(response)) = webhook_handler( + State(state), + Path("test".to_string()), + HeaderMap::new(), + Bytes::from_static(b"not valid json{"), + ) + .await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(response["error"].as_str().unwrap().contains("Invalid JSON")); + } + + // Mock handler that returns None from parse + struct IgnoringHandler; + + #[async_trait] + impl WebhookHandler for IgnoringHandler { + fn source_name(&self) -> &str { + "ignoring" + } + fn verify_signature(&self, _body: &[u8], _headers: &HashMap) -> bool { + true + } + async fn parse_payload( + &self, + _payload: &serde_json::Value, + ) -> crate::error::Result> { + Ok(None) + } + fn matches_criteria(&self, _issue: &Issue) -> MatchResult { + MatchResult::matched("Test", MatchPriority::Normal) + } + async fn build_issue_context(&self, _issue: &Issue) -> crate::error::Result { + Ok(String::new()) + } + } + + #[tokio::test] + async fn test_webhook_handler_event_ignored() { + use axum::extract::{Path, State}; + use axum::http::HeaderMap; + + let config = test_config(); + let mut handlers = WebhookHandlerRegistry::new(); + handlers.register(Arc::new(IgnoringHandler)); + let notifier = Arc::new(MockNotifier::new()); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + + let state = Arc::new(AppState { + claude: ClaudeRunner::new( + ClaudeRunnerConfig { + timeout_secs: config.claude_timeout_secs, + }, + tracker.clone(), + ), + config, + handlers, + notifier, + tracker, + inferrer: None, + processing: RwLock::new(HashSet::new()), + }); + + let (status, Json(response)) = webhook_handler( + State(state), + Path("ignoring".to_string()), + HeaderMap::new(), + Bytes::from_static(b"{}"), + ) + .await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(response["status"], "ignored"); + assert!(response["reason"] + .as_str() + .unwrap() + .contains("not applicable")); + } + + // Mock handler that fails criteria + struct NonMatchingHandler; + + #[async_trait] + impl WebhookHandler for NonMatchingHandler { + fn source_name(&self) -> &str { + "nonmatching" + } + fn verify_signature(&self, _body: &[u8], _headers: &HashMap) -> bool { + true + } + async fn parse_payload( + &self, + _payload: &serde_json::Value, + ) -> crate::error::Result> { + Ok(Some(Issue::new( + "1", + "TEST-1", + "Test", + "https://test.com", + "nonmatching", + ))) + } + fn matches_criteria(&self, _issue: &Issue) -> MatchResult { + MatchResult::not_matched("Does not match criteria") + } + async fn build_issue_context(&self, _issue: &Issue) -> crate::error::Result { + Ok(String::new()) + } + } + + #[tokio::test] + async fn test_webhook_handler_criteria_not_matched() { + use axum::extract::{Path, State}; + use axum::http::HeaderMap; + + let config = test_config(); + let mut handlers = WebhookHandlerRegistry::new(); + handlers.register(Arc::new(NonMatchingHandler)); + let notifier = Arc::new(MockNotifier::new()); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + + let state = Arc::new(AppState { + claude: ClaudeRunner::new( + ClaudeRunnerConfig { + timeout_secs: config.claude_timeout_secs, + }, + tracker.clone(), + ), + config, + handlers, + notifier, + tracker, + inferrer: None, + processing: RwLock::new(HashSet::new()), + }); + + let (status, Json(response)) = webhook_handler( + State(state), + Path("nonmatching".to_string()), + HeaderMap::new(), + Bytes::from_static(b"{}"), + ) + .await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(response["status"], "ignored"); + assert!(response["reason"] + .as_str() + .unwrap() + .contains("Does not match")); + } + + #[tokio::test] + async fn test_webhook_handler_already_attempted() { + use axum::extract::{Path, State}; + use axum::http::HeaderMap; + + let config = test_config(); + let mut handlers = WebhookHandlerRegistry::new(); + handlers.register(Arc::new(MockWebhookHandler::new("test"))); + let notifier = Arc::new(MockNotifier::new()); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + + // Mark the issue as already attempted + tracker.record_attempt("test", "1", "TEST-1").unwrap(); + + let state = Arc::new(AppState { + claude: ClaudeRunner::new( + ClaudeRunnerConfig { + timeout_secs: config.claude_timeout_secs, + }, + tracker.clone(), + ), + config, + handlers, + notifier, + tracker, + inferrer: None, + processing: RwLock::new(HashSet::new()), + }); + + let (status, Json(response)) = webhook_handler( + State(state), + Path("test".to_string()), + HeaderMap::new(), + Bytes::from_static(b"{}"), + ) + .await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(response["status"], "ignored"); + assert!(response["reason"] + .as_str() + .unwrap() + .contains("Already attempted")); + } + + #[tokio::test] + async fn test_webhook_handler_already_processing() { + use axum::extract::{Path, State}; + use axum::http::HeaderMap; + + let config = test_config(); + let mut handlers = WebhookHandlerRegistry::new(); + handlers.register(Arc::new(MockWebhookHandler::new("test"))); + let notifier = Arc::new(MockNotifier::new()); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + + // Mark issue as being processed + let mut processing = HashSet::new(); + processing.insert("test:1".to_string()); + + let state = Arc::new(AppState { + claude: ClaudeRunner::new( + ClaudeRunnerConfig { + timeout_secs: config.claude_timeout_secs, + }, + tracker.clone(), + ), + config, + handlers, + notifier, + tracker, + inferrer: None, + processing: RwLock::new(processing), + }); + + let (status, Json(response)) = webhook_handler( + State(state), + Path("test".to_string()), + HeaderMap::new(), + Bytes::from_static(b"{}"), + ) + .await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(response["status"], "ignored"); + assert!(response["reason"] + .as_str() + .unwrap() + .contains("Already processing")); + } + + #[tokio::test] + async fn test_webhook_handler_accepted() { + use axum::extract::{Path, State}; + use axum::http::HeaderMap; + + let config = test_config(); + let mut handlers = WebhookHandlerRegistry::new(); + handlers.register(Arc::new(MockWebhookHandler::new("test"))); + let notifier = Arc::new(MockNotifier::new()); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + + let state = Arc::new(AppState { + claude: ClaudeRunner::new( + ClaudeRunnerConfig { + timeout_secs: config.claude_timeout_secs, + }, + tracker.clone(), + ), + config, + handlers, + notifier, + tracker, + inferrer: None, + processing: RwLock::new(HashSet::new()), + }); + + let (status, Json(response)) = webhook_handler( + State(state), + Path("test".to_string()), + HeaderMap::new(), + Bytes::from_static(b"{}"), + ) + .await; + + assert_eq!(status, StatusCode::ACCEPTED); + assert_eq!(response["status"], "accepted"); + assert_eq!(response["issue"], "TEST-1"); + } + + // Mock handler that fails to parse + struct FailingParseHandler; + + #[async_trait] + impl WebhookHandler for FailingParseHandler { + fn source_name(&self) -> &str { + "failing" + } + fn verify_signature(&self, _body: &[u8], _headers: &HashMap) -> bool { + true + } + async fn parse_payload( + &self, + _payload: &serde_json::Value, + ) -> crate::error::Result> { + Err(crate::error::Error::config("Parse failed")) + } + fn matches_criteria(&self, _issue: &Issue) -> MatchResult { + MatchResult::matched("Test", MatchPriority::Normal) + } + async fn build_issue_context(&self, _issue: &Issue) -> crate::error::Result { + Ok(String::new()) + } + } + + #[tokio::test] + async fn test_webhook_handler_parse_error() { + use axum::extract::{Path, State}; + use axum::http::HeaderMap; + + let config = test_config(); + let mut handlers = WebhookHandlerRegistry::new(); + handlers.register(Arc::new(FailingParseHandler)); + let notifier = Arc::new(MockNotifier::new()); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + + let state = Arc::new(AppState { + claude: ClaudeRunner::new( + ClaudeRunnerConfig { + timeout_secs: config.claude_timeout_secs, + }, + tracker.clone(), + ), + config, + handlers, + notifier, + tracker, + inferrer: None, + processing: RwLock::new(HashSet::new()), + }); + + let (status, Json(response)) = webhook_handler( + State(state), + Path("failing".to_string()), + HeaderMap::new(), + Bytes::from_static(b"{}"), + ) + .await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(response["error"] + .as_str() + .unwrap() + .contains("Failed to parse")); + } + + #[test] + fn test_header_conversion_to_hashmap() { + use axum::http::{HeaderMap, HeaderName, HeaderValue}; + + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_static("x-hub-signature-256"), + HeaderValue::from_static("sha256=abc123"), + ); + headers.insert( + HeaderName::from_static("content-type"), + HeaderValue::from_static("application/json"), + ); + + let header_map: HashMap = headers + .iter() + .filter_map(|(k, v)| { + v.to_str() + .ok() + .map(|val| (k.as_str().to_lowercase(), val.to_string())) + }) + .collect(); + + assert_eq!(header_map.len(), 2); + assert_eq!( + header_map.get("x-hub-signature-256"), + Some(&"sha256=abc123".to_string()) + ); + assert_eq!( + header_map.get("content-type"), + Some(&"application/json".to_string()) + ); + } + + #[tokio::test] + async fn test_mock_notifier_all_methods() { + let notifier = MockNotifier::new(); + let issue = Issue::new("1", "TEST-1", "Test", "https://test.com", "test"); + + notifier.notify_start(&issue).await.unwrap(); + notifier + .notify_success(&issue, "https://github.com/pr") + .await + .unwrap(); + notifier.notify_completed(&issue).await.unwrap(); + notifier.notify_failed(&issue, "error").await.unwrap(); + notifier.notify_status("status").await.unwrap(); + notifier + .notify_urgent_issues(std::slice::from_ref(&issue)) + .await + .unwrap(); + notifier + .notify_merged(&issue, "https://github.com/pr") + .await + .unwrap(); + + assert_eq!(notifier.call_count.load(Ordering::SeqCst), 7); + } + + #[test] + fn test_webhook_server_fields() { + let config = test_config(); + let handlers = WebhookHandlerRegistry::new(); + let notifier = Arc::new(MockNotifier::new()); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + + let server = WebhookServer::new(config.clone(), handlers, notifier, tracker, None); + + assert_eq!(server.port, config.webhook_port); + assert_eq!(server.config.work_dir, config.work_dir); + } + + #[tokio::test] + async fn test_health_handler_no_handlers() { + use axum::extract::State; + + let config = test_config(); + let handlers = WebhookHandlerRegistry::new(); + let notifier = Arc::new(MockNotifier::new()); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + + let state = Arc::new(AppState { + claude: ClaudeRunner::new( + ClaudeRunnerConfig { + timeout_secs: config.claude_timeout_secs, + }, + tracker.clone(), + ), + config, + handlers, + notifier, + tracker, + inferrer: None, + processing: RwLock::new(HashSet::new()), + }); + + let Json(response) = health_handler(State(state)).await; + + assert_eq!(response["status"], "ok"); + assert!(response["handlers"].as_array().unwrap().is_empty()); + } +} From 8aaccdb39910df3e8504728831b2233faea4c6f6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 24 Jan 2026 03:45:09 +0000 Subject: [PATCH 2/2] chore(deps): bump rusqlite from 0.32.1 to 0.38.0 Bumps [rusqlite](https://github.com/rusqlite/rusqlite) from 0.32.1 to 0.38.0. - [Release notes](https://github.com/rusqlite/rusqlite/releases) - [Changelog](https://github.com/rusqlite/rusqlite/blob/master/Changelog.md) - [Commits](https://github.com/rusqlite/rusqlite/compare/v0.32.1...v0.38.0) --- updated-dependencies: - dependency-name: rusqlite dependency-version: 0.38.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Cargo.lock | 46 +++++++++++++++++++++++++++++++++++++++------- Cargo.toml | 2 +- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8555020c..1ebb5be1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -981,6 +981,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1266,14 +1272,17 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] [[package]] name = "hashlink" -version = "0.9.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" dependencies = [ - "hashbrown 0.14.5", + "hashbrown 0.16.1", ] [[package]] @@ -1846,9 +1855,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" dependencies = [ "cc", "pkg-config", @@ -2811,11 +2820,21 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + [[package]] name = "rusqlite" -version = "0.32.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ "bitflags 2.10.0", "fallible-iterator", @@ -2823,6 +2842,7 @@ dependencies = [ "hashlink", "libsqlite3-sys", "smallvec", + "sqlite-wasm-rs", ] [[package]] @@ -3126,6 +3146,18 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 404cd962..67a9b2d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ tower = { version = "0.5", features = ["limit"] } tower-http = { version = "0.6", features = ["trace", "cors", "fs", "limit"] } # Database -rusqlite = { version = "0.32", features = ["bundled", "load_extension"] } +rusqlite = { version = "0.38", features = ["bundled", "load_extension"] } # Embeddings fastembed = "4"