diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d325e2a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + pull_request: + push: + branches: [master] + workflow_dispatch: + +jobs: + test: + name: Run Tests & Build + runs-on: ubuntu-latest + + services: + mongodb: + image: mongo:6.0 + ports: + - 27017:27017 + options: >- + --health-cmd="mongosh --eval 'db.adminCommand(\"ping\")'" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + env: + MONGO_URL: mongodb://127.0.0.1:27017/worlddriven_test + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm install + + - name: Auto format with Prettier + run: npm run format + + - name: Run tests + run: npm test + + - name: Build project + run: npm run build diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 218475d..1fe69c3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,12 +8,12 @@ jobs: deploy: name: Deploy to Production runs-on: ubuntu-latest - + steps: - name: Deploy to Dokku run: | echo "Triggering deployment for commit ${{ github.sha }}" - + response=$(curl -s -w "%{http_code}" -o response.json \ -X POST https://sumalyze.tooangel.com/deploy/worlddriven \ -H "Authorization: Bearer ${{ secrets.DEPLOY_WEBHOOK_SECRET }}" \ @@ -23,32 +23,32 @@ jobs: "commit": "${{ github.sha }}", "branch": "${{ github.ref_name }}" }') - + http_code="${response: -3}" - + echo "HTTP Status Code: $http_code" echo "Response:" cat response.json - + if [ "$http_code" -ne 200 ]; then echo "Deployment failed with HTTP status: $http_code" exit 1 fi - + echo "Deployment triggered successfully!" - name: Verify Deployment run: | echo "Waiting 30 seconds for deployment to complete..." sleep 30 - + echo "Checking if worlddriven service is responding..." response=$(curl -s -w "%{http_code}" -o health.json https://www.worlddriven.org/ || echo "000") http_code="${response: -3}" - + if [ "$http_code" = "200" ]; then echo "✅ Deployment successful - worlddriven is responding" else echo "⚠️ Warning: worlddriven may still be starting (HTTP $http_code)" echo "This doesn't necessarily mean deployment failed." - fi \ No newline at end of file + fi diff --git a/GITHUB_APP_MIGRATION.md b/GITHUB_APP_MIGRATION.md new file mode 100644 index 0000000..a00c794 --- /dev/null +++ b/GITHUB_APP_MIGRATION.md @@ -0,0 +1,180 @@ +# GitHub App Migration Guide + +This document outlines the migration from Personal Access Token (PAT) authentication to GitHub App authentication in World Driven. + +## Overview + +World Driven now supports both Personal Access Token (PAT) and GitHub App authentication methods. This hybrid approach allows for gradual migration while maintaining backward compatibility. + +## Benefits of GitHub App Authentication + +1. **Multi-owner resilience** - Survives individual user departures +2. **Better security** - Fine-grained permissions per repository +3. **Automatic token management** - No expired tokens to manage +4. **Organization support** - Works seamlessly with GitHub organizations +5. **Official GitHub recommendation** - Future-proof approach + +## Architecture Changes + +### Database Schema + +The `repositories` collection now includes an `installationId` field: + +```javascript +{ + _id: ObjectId(), + owner: "TooAngel", + repo: "screeps", + userId: ObjectId() || null, // Legacy PAT authentication + installationId: 12345678 || null, // GitHub App authentication + configured: true, + createdAt: Date, + updatedAt: Date +} +``` + +### Authentication Flow + +The system automatically detects the authentication method for each repository: + +1. **GitHub App**: If `installationId` is present, uses GitHub App authentication +2. **PAT (Legacy)**: If `userId` is present, uses Personal Access Token authentication +3. **Error**: If neither is present, logs an error and skips the repository + +### Hybrid API Functions + +All GitHub API functions in `src/helpers/github.js` now accept either: +- A user object (for PAT authentication) +- An installation ID number (for GitHub App authentication) + +Example: +```javascript +// PAT authentication +await getPullRequests(userObject, owner, repo); + +// GitHub App authentication +await getPullRequests(installationId, owner, repo); +``` + +## Environment Variables + +Add these new environment variables for GitHub App support: + +```bash +# GitHub App Configuration +GITHUB_APP_ID=your_app_id +GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----..." +GITHUB_WEBHOOK_SECRET=your_webhook_secret +GITHUB_APP_NAME=world-driven # Optional, for installation URL + +# Keep existing OAuth for backward compatibility +GITHUB_CLIENT_ID=existing_oauth_client_id +GITHUB_CLIENT_SECRET=existing_oauth_secret +``` + +## Installation and Usage + +### For GitHub App (Recommended) + +1. Visit `/install-app` to install the World Driven GitHub App +2. Select repositories to enable World Driven on +3. Repositories will be automatically configured with GitHub App authentication + +### For PAT (Legacy) + +1. Visit `/login` to authenticate with Personal Access Token +2. Use the dashboard to enable World Driven on repositories +3. Repositories will use PAT authentication + +## Migration Process + +### Automatic Migration + +When the GitHub App is installed on repositories that already exist in the database (from PAT setup), the system automatically: + +1. Updates the repository record to include `installationId` +2. Keeps the existing `userId` for backward compatibility +3. Switches to GitHub App authentication for all operations + +### Manual Migration + +To migrate existing PAT repositories to GitHub App: + +1. Install the GitHub App on your account/organization +2. Select the repositories you want to migrate +3. The webhook handlers will automatically update the database + +### Database Migration + +Run the migration script to add the `installationId` field to existing repositories: + +```bash +node scripts/add-installation-field.js +``` + +## Webhook Events + +The GitHub App handles additional webhook events: + +- `installation` - App installed/uninstalled +- `installation_repositories` - Repositories added/removed from installation +- `pull_request` - Pull request events (existing) +- `pull_request_review` - Review events (existing) +- `push` - Push events (existing) + +## Monitoring + +The application logs authentication method for each repository during processing: + +``` +Using GitHub App authentication (installation: 12345678) +Using PAT authentication (user: 507f1f77bcf86cd799439011) +``` + +## Backward Compatibility + +- Existing PAT-authenticated repositories continue to work unchanged +- OAuth login flow remains available at `/login` +- All existing API endpoints continue to function +- No breaking changes to existing functionality + +## Future Migration + +Once all repositories are migrated to GitHub App authentication: + +1. The `userId` field can be removed from the database schema +2. PAT authentication code can be removed +3. OAuth login flow can be deprecated +4. The `users` collection can be removed + +## Troubleshooting + +### Repository Not Processing + +Check the authentication method in logs: +- If "No authentication method configured", add either `installationId` or `userId` +- If "No user found", the referenced user doesn't exist in the database +- If "HTTP 401: Unauthorized", the token/installation has insufficient permissions + +### GitHub App Installation Issues + +1. Verify the app is installed on the repository owner's account +2. Check that the repository is included in the installation +3. Ensure webhook URL is correctly configured +4. Verify environment variables are set correctly + +### Token Issues + +- GitHub App tokens are automatically managed and refresh +- PAT tokens may expire and need manual renewal +- Check GitHub App permissions if operations fail + +## Testing + +Test both authentication methods: + +1. Install GitHub App on a test repository +2. Verify webhook events are received and processed +3. Check that pull requests are processed correctly +4. Ensure existing PAT repositories continue working +5. Test migration from PAT to GitHub App authentication \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 5425f34..973d0a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "version": "1.0.0", "license": "AGPL", "dependencies": { + "@octokit/auth-app": "^8.1.0", + "@octokit/rest": "^22.0.0", + "@octokit/webhooks": "^14.1.3", "connect-mongo": "5.1.0", "express": "4.21.2", "express-session": "1.18.2", @@ -808,6 +811,279 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@octokit/auth-app": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-app/-/auth-app-8.1.0.tgz", + "integrity": "sha512-6bWhyvLXqCSfHiqlwzn9pScLZ+Qnvh/681GR/UEEPCMIVwfpRDBw0cCzy3/t2Dq8B7W2X/8pBgmw6MOiyE0DXQ==", + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-app": "^9.0.1", + "@octokit/auth-oauth-user": "^6.0.0", + "@octokit/request": "^10.0.2", + "@octokit/request-error": "^7.0.0", + "@octokit/types": "^14.0.0", + "toad-cache": "^3.7.0", + "universal-github-app-jwt": "^2.2.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-oauth-app": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-app/-/auth-oauth-app-9.0.1.tgz", + "integrity": "sha512-TthWzYxuHKLAbmxdFZwFlmwVyvynpyPmjwc+2/cI3cvbT7mHtsAW9b1LvQaNnAuWL+pFnqtxdmrU8QpF633i1g==", + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-device": "^8.0.1", + "@octokit/auth-oauth-user": "^6.0.0", + "@octokit/request": "^10.0.2", + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-oauth-device": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-device/-/auth-oauth-device-8.0.1.tgz", + "integrity": "sha512-TOqId/+am5yk9zor0RGibmlqn4V0h8vzjxlw/wYr3qzkQxl8aBPur384D1EyHtqvfz0syeXji4OUvKkHvxk/Gw==", + "license": "MIT", + "dependencies": { + "@octokit/oauth-methods": "^6.0.0", + "@octokit/request": "^10.0.2", + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-oauth-user": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-user/-/auth-oauth-user-6.0.0.tgz", + "integrity": "sha512-GV9IW134PHsLhtUad21WIeP9mlJ+QNpFd6V9vuPWmaiN25HEJeEQUcS4y5oRuqCm9iWDLtfIs+9K8uczBXKr6A==", + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-device": "^8.0.1", + "@octokit/oauth-methods": "^6.0.0", + "@octokit/request": "^10.0.2", + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.3.tgz", + "integrity": "sha512-oNXsh2ywth5aowwIa7RKtawnkdH6LgU1ztfP9AIUCQCvzysB+WeU8o2kyyosDPwBZutPpjZDKPQGIzzrfTWweQ==", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.1", + "@octokit/request": "^10.0.2", + "@octokit/request-error": "^7.0.0", + "@octokit/types": "^14.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.0.tgz", + "integrity": "sha512-hoYicJZaqISMAI3JfaDr1qMNi48OctWuOih1m80bkYow/ayPw6Jj52tqWJ6GEoFTk1gBqfanSoI1iY99Z5+ekQ==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.1.tgz", + "integrity": "sha512-j1nQNU1ZxNFx2ZtKmL4sMrs4egy5h65OMDmSbVyuCzjOcwsHq6EaYjOTGXPQxgfiN8dJ4CriYHk6zF050WEULg==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.2", + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/oauth-authorization-url": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@octokit/oauth-authorization-url/-/oauth-authorization-url-8.0.0.tgz", + "integrity": "sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/oauth-methods": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/oauth-methods/-/oauth-methods-6.0.0.tgz", + "integrity": "sha512-Q8nFIagNLIZgM2odAraelMcDssapc+lF+y3OlcIPxyAU+knefO8KmozGqfnma1xegRDP4z5M73ABsamn72bOcA==", + "license": "MIT", + "dependencies": { + "@octokit/oauth-authorization-url": "^8.0.0", + "@octokit/request": "^10.0.2", + "@octokit/request-error": "^7.0.0", + "@octokit/types": "^14.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "license": "MIT" + }, + "node_modules/@octokit/openapi-webhooks-types": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@octokit/openapi-webhooks-types/-/openapi-webhooks-types-12.0.3.tgz", + "integrity": "sha512-90MF5LVHjBedwoHyJsgmaFhEN1uzXyBDRLEBe7jlTYx/fEhPAk3P3DAJsfZwC54m8hAIryosJOL+UuZHB3K3yA==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-13.1.1.tgz", + "integrity": "sha512-q9iQGlZlxAVNRN2jDNskJW/Cafy7/XE52wjZ5TTvyhyOD904Cvx//DNyoO3J/MXJ0ve3rPoNWKEg5iZrisQSuw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.1.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-16.0.0.tgz", + "integrity": "sha512-kJVUQk6/dx/gRNLWUnAWKFs1kVPn5O5CYZyssyEoNYaFedqZxsfYs7DwI3d67hGz4qOwaJ1dpm07hOAD1BXx6g==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.1.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.3.tgz", + "integrity": "sha512-V6jhKokg35vk098iBqp2FBKunk3kMTXlmq+PtbV9Gl3TfskWlebSofU9uunVKhUN7xl+0+i5vt0TGTG8/p/7HA==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.0", + "@octokit/request-error": "^7.0.0", + "@octokit/types": "^14.0.0", + "fast-content-type-parse": "^3.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.0.0.tgz", + "integrity": "sha512-KRA7VTGdVyJlh0cP5Tf94hTiYVVqmt2f3I6mnimmaVz4UG3gQV/k4mDJlJv3X67iX6rmN7gSHCF8ssqeMnmhZg==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.0.tgz", + "integrity": "sha512-z6tmTu9BTnw51jYGulxrlernpsQYXpui1RK21vmXn8yF5bp6iX16yfTtJYGK5Mh1qDkvDOmp2n8sRMcQmR8jiA==", + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.2", + "@octokit/plugin-paginate-rest": "^13.0.1", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^25.1.0" + } + }, + "node_modules/@octokit/webhooks": { + "version": "14.1.3", + "resolved": "https://registry.npmjs.org/@octokit/webhooks/-/webhooks-14.1.3.tgz", + "integrity": "sha512-gcK4FNaROM9NjA0mvyfXl0KPusk7a1BeA8ITlYEZVQCXF5gcETTd4yhAU0Kjzd8mXwYHppzJBWgdBVpIR9wUcQ==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-webhooks-types": "12.0.3", + "@octokit/request-error": "^7.0.0", + "@octokit/webhooks-methods": "^6.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/webhooks-methods": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/webhooks-methods/-/webhooks-methods-6.0.0.tgz", + "integrity": "sha512-MFlzzoDJVw/GcbfzVC1RLR36QqkTLUf79vLVO3D+xn7r0QgxnFoLZgtrzxiQErAjFUOdH6fas2KeQJ1yr/qaXQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.50.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.0.tgz", @@ -1248,6 +1524,12 @@ "dev": true, "license": "MIT" }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "license": "Apache-2.0" + }, "node_modules/bn.js": { "version": "4.12.2", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", @@ -2678,6 +2960,22 @@ "tmp": "^0.0.29" } }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5848,6 +6146,15 @@ "node": ">=0.4.0" } }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -6045,6 +6352,18 @@ "dev": true, "license": "MIT" }, + "node_modules/universal-github-app-jwt": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/universal-github-app-jwt/-/universal-github-app-jwt-2.2.2.tgz", + "integrity": "sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw==", + "license": "MIT" + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index b800abc..c0b49b4 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,13 @@ "lint": "eslint . --report-unused-disable-directives --max-warnings 0", "lint:frontend": "eslint static/js --config frontend.eslint.config.cjs --report-unused-disable-directives --max-warnings 0", "start": "node src/index.js", - "test": "npm run check && sort-package-json package.json --check && mdspell --en-us --ignore-numbers --report \"*.md\" && write-good --no-adverb README.md CONTRIBUTING.md" + "test": "npm run check && sort-package-json package.json --check && mdspell --en-us --ignore-numbers --report \"*.md\" && write-good --no-adverb README.md CONTRIBUTING.md", + "format": "prettier --write ." }, "dependencies": { + "@octokit/auth-app": "^8.1.0", + "@octokit/rest": "^22.0.0", + "@octokit/webhooks": "^14.1.3", "connect-mongo": "5.1.0", "express": "4.21.2", "express-session": "1.18.2", diff --git a/scripts/add-installation-field.js b/scripts/add-installation-field.js new file mode 100644 index 0000000..7d3f4c9 --- /dev/null +++ b/scripts/add-installation-field.js @@ -0,0 +1,44 @@ +#!/usr/bin/env node + +import { database, client } from '../src/database/database.js'; + +async function migrateDatabase() { + try { + console.log('Starting database migration: Adding installationId field...'); + + // Add installationId field to all existing repositories + const result = await database.repositories.updateMany( + { installationId: { $exists: false } }, + { $set: { installationId: null } } + ); + + console.log( + `Migration complete: Updated ${result.modifiedCount} repositories with installationId field` + ); + + // Show current repository status + const repos = await database.repositories.find({}).toArray(); + console.log('\nCurrent repository authentication status:'); + for (const repo of repos) { + const authType = repo.installationId + ? 'GitHub App' + : repo.userId + ? 'PAT' + : 'None'; + console.log( + `- ${repo.owner}/${repo.repo}: ${authType} (configured: ${repo.configured})` + ); + } + } catch (error) { + console.error('Migration failed:', error); + process.exit(1); + } finally { + await client.close(); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + migrateDatabase(); +} + +export { migrateDatabase }; diff --git a/src/database/models.js b/src/database/models.js index c48b180..8e885c4 100644 --- a/src/database/models.js +++ b/src/database/models.js @@ -74,16 +74,19 @@ export const Repository = { * @param {string} repoData.owner * @param {string} repoData.repo * @param {boolean} repoData.configured - * @param {string|ObjectId} repoData.userId + * @param {string|ObjectId} [repoData.userId] - Legacy PAT authentication + * @param {number} [repoData.installationId] - GitHub App authentication * @returns {Promise} */ async create(repoData) { const repository = { ...repoData, - userId: - typeof repoData.userId === 'string' + userId: repoData.userId + ? typeof repoData.userId === 'string' ? new ObjectId(repoData.userId) - : repoData.userId, + : repoData.userId + : null, + installationId: repoData.installationId || null, createdAt: new Date(), updatedAt: new Date(), }; @@ -129,6 +132,15 @@ export const Repository = { return await database.repositories.find({ userId: objectId }).toArray(); }, + /** + * Find repositories by installation ID + * @param {number} installationId + * @returns {Promise} + */ + async findByInstallationId(installationId) { + return await database.repositories.find({ installationId }).toArray(); + }, + /** * Find all repositories * @returns {Promise} @@ -155,6 +167,9 @@ export const Repository = { ? new ObjectId(updates.userId) : updates.userId; } + if (updates.installationId !== undefined) { + updateData.installationId = updates.installationId; + } await database.repositories.updateOne( { _id: objectId }, { $set: updateData } diff --git a/src/helpers/github.js b/src/helpers/github.js index 4c6af8c..a2505ce 100644 --- a/src/helpers/github.js +++ b/src/helpers/github.js @@ -1,14 +1,37 @@ // Using native fetch API +import { + getPullRequestsApp, + mergePullRequestApp, + setCommitStatusApp, + getLatestCommitShaApp, + createIssueCommentApp, + createWebhookApp, + deleteWebhookApp, +} from './githubApp.js'; /** - * getPullRequests + * getPullRequests - Hybrid authentication (GitHub App or PAT) * - * @param {object} user + * @param {object|number} userOrInstallationId - User object with githubAccessToken or installationId number * @param {string} owner * @param {string} repo * @return {void} */ -export async function getPullRequests(user, owner, repo) { +export async function getPullRequests(userOrInstallationId, owner, repo) { + // If it's a number, treat as installationId (GitHub App) + if ( + typeof userOrInstallationId === 'number' || + (typeof userOrInstallationId === 'string' && !isNaN(userOrInstallationId)) + ) { + return await getPullRequestsApp( + parseInt(userOrInstallationId), + owner, + repo + ); + } + + // Otherwise, use existing PAT logic + const user = userOrInstallationId; const url = `https://api.github.com/repos/${owner}/${repo}/pulls?state=open`; try { @@ -41,15 +64,35 @@ export async function getPullRequests(user, owner, repo) { } /** - * mergePullRequest + * mergePullRequest - Hybrid authentication (GitHub App or PAT) * - * @param {object} user + * @param {object|number} userOrInstallationId - User object with githubAccessToken or installationId number * @param {string} owner * @param {string} repo * @param {number} number * @return {void} */ -export async function mergePullRequest(user, owner, repo, number) { +export async function mergePullRequest( + userOrInstallationId, + owner, + repo, + number +) { + // If it's a number, treat as installationId (GitHub App) + if ( + typeof userOrInstallationId === 'number' || + (typeof userOrInstallationId === 'string' && !isNaN(userOrInstallationId)) + ) { + return await mergePullRequestApp( + parseInt(userOrInstallationId), + owner, + repo, + number + ); + } + + // Otherwise, use existing PAT logic + const user = userOrInstallationId; const url = `https://api.github.com/repos/${owner}/${repo}/pulls/${number}/merge`; try { @@ -77,16 +120,38 @@ export async function mergePullRequest(user, owner, repo, number) { } /** - * createIssueComment + * createIssueComment - Hybrid authentication (GitHub App or PAT) * - * @param {object} user + * @param {object|number} userOrInstallationId - User object with githubAccessToken or installationId number * @param {string} owner * @param {string} repo * @param {number} number * @param {string} comment * @return {void} */ -export async function createIssueComment(user, owner, repo, number, comment) { +export async function createIssueComment( + userOrInstallationId, + owner, + repo, + number, + comment +) { + // If it's a number, treat as installationId (GitHub App) + if ( + typeof userOrInstallationId === 'number' || + (typeof userOrInstallationId === 'string' && !isNaN(userOrInstallationId)) + ) { + return await createIssueCommentApp( + parseInt(userOrInstallationId), + owner, + repo, + number, + comment + ); + } + + // Otherwise, use existing PAT logic + const user = userOrInstallationId; const url = `https://api.github.com/repos/${owner}/${repo}/issues/${number}/comments`; try { @@ -114,9 +179,9 @@ export async function createIssueComment(user, owner, repo, number, comment) { } /** - * setCommitStatus + * setCommitStatus - Hybrid authentication (GitHub App or PAT) * - * @param {object} user + * @param {object|number} userOrInstallationId - User object with githubAccessToken or installationId number * @param {string} owner * @param {string} repo * @param {string} sha - commit SHA @@ -127,7 +192,7 @@ export async function createIssueComment(user, owner, repo, number, comment) { * @return {void} */ export async function setCommitStatus( - user, + userOrInstallationId, owner, repo, sha, @@ -136,6 +201,25 @@ export async function setCommitStatus( description, context = 'World driven' ) { + // If it's a number, treat as installationId (GitHub App) + if ( + typeof userOrInstallationId === 'number' || + (typeof userOrInstallationId === 'string' && !isNaN(userOrInstallationId)) + ) { + return await setCommitStatusApp( + parseInt(userOrInstallationId), + owner, + repo, + sha, + state, + targetUrl, + description, + context + ); + } + + // Otherwise, use existing PAT logic + const user = userOrInstallationId; const url = `https://api.github.com/repos/${owner}/${repo}/statuses/${sha}`; try { @@ -172,15 +256,35 @@ export async function setCommitStatus( } /** - * getLatestCommitSha + * getLatestCommitSha - Hybrid authentication (GitHub App or PAT) * - * @param {object} user + * @param {object|number} userOrInstallationId - User object with githubAccessToken or installationId number * @param {string} owner * @param {string} repo * @param {number} pullNumber * @return {string} commit SHA */ -export async function getLatestCommitSha(user, owner, repo, pullNumber) { +export async function getLatestCommitSha( + userOrInstallationId, + owner, + repo, + pullNumber +) { + // If it's a number, treat as installationId (GitHub App) + if ( + typeof userOrInstallationId === 'number' || + (typeof userOrInstallationId === 'string' && !isNaN(userOrInstallationId)) + ) { + return await getLatestCommitShaApp( + parseInt(userOrInstallationId), + owner, + repo, + pullNumber + ); + } + + // Otherwise, use existing PAT logic + const user = userOrInstallationId; const url = `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/commits`; try { @@ -210,15 +314,35 @@ export async function getLatestCommitSha(user, owner, repo, pullNumber) { } /** - * createWebhook - Create a GitHub webhook for a repository + * createWebhook - Create a GitHub webhook for a repository - Hybrid authentication (GitHub App or PAT) * - * @param {object} user + * @param {object|number} userOrInstallationId - User object with githubAccessToken or installationId number * @param {string} owner * @param {string} repo * @param {string} webhookUrl - The URL to receive webhooks * @return {object} webhook response */ -export async function createWebhook(user, owner, repo, webhookUrl) { +export async function createWebhook( + userOrInstallationId, + owner, + repo, + webhookUrl +) { + // If it's a number, treat as installationId (GitHub App) + if ( + typeof userOrInstallationId === 'number' || + (typeof userOrInstallationId === 'string' && !isNaN(userOrInstallationId)) + ) { + return await createWebhookApp( + parseInt(userOrInstallationId), + owner, + repo, + webhookUrl + ); + } + + // Otherwise, use existing PAT logic + const user = userOrInstallationId; const url = `https://api.github.com/repos/${owner}/${repo}/hooks`; const webhookConfig = { @@ -267,15 +391,35 @@ export async function createWebhook(user, owner, repo, webhookUrl) { } /** - * deleteWebhook - Delete GitHub webhooks for a repository + * deleteWebhook - Delete GitHub webhooks for a repository - Hybrid authentication (GitHub App or PAT) * - * @param {object} user + * @param {object|number} userOrInstallationId - User object with githubAccessToken or installationId number * @param {string} owner * @param {string} repo * @param {string} webhookUrl - The webhook URL to delete * @return {void} */ -export async function deleteWebhook(user, owner, repo, webhookUrl) { +export async function deleteWebhook( + userOrInstallationId, + owner, + repo, + webhookUrl +) { + // If it's a number, treat as installationId (GitHub App) + if ( + typeof userOrInstallationId === 'number' || + (typeof userOrInstallationId === 'string' && !isNaN(userOrInstallationId)) + ) { + return await deleteWebhookApp( + parseInt(userOrInstallationId), + owner, + repo, + webhookUrl + ); + } + + // Otherwise, use existing PAT logic + const user = userOrInstallationId; const url = `https://api.github.com/repos/${owner}/${repo}/hooks`; try { diff --git a/src/helpers/githubApp.js b/src/helpers/githubApp.js new file mode 100644 index 0000000..e2833e3 --- /dev/null +++ b/src/helpers/githubApp.js @@ -0,0 +1,214 @@ +import { createAppAuth } from '@octokit/auth-app'; +import { Octokit } from '@octokit/rest'; + +// App-level client for managing installations (reserved for future use) +// const appOctokit = new Octokit({ +// authStrategy: createAppAuth, +// auth: { +// appId: process.env.GITHUB_APP_ID, +// privateKey: process.env.GITHUB_APP_PRIVATE_KEY, +// }, +// }); + +// Installation-level client for repository operations +export async function getInstallationOctokit(installationId) { + return new Octokit({ + authStrategy: createAppAuth, + auth: { + appId: process.env.GITHUB_APP_ID, + privateKey: process.env.GITHUB_APP_PRIVATE_KEY, + installationId: parseInt(installationId), + }, + }); +} + +// GitHub App API functions +export async function getPullRequestsApp(installationId, owner, repo) { + const octokit = await getInstallationOctokit(installationId); + const { data } = await octokit.rest.pulls.list({ + owner, + repo, + state: 'open', + }); + + return data.map(pull => ({ + id: pull.id, + title: pull.title, + number: pull.number, + })); +} + +export async function mergePullRequestApp(installationId, owner, repo, number) { + const octokit = await getInstallationOctokit(installationId); + try { + return await octokit.rest.pulls.merge({ + owner, + repo, + pull_number: number, + }); + } catch (error) { + if (error.status === 405) return; // Cannot merge + throw error; + } +} + +export async function setCommitStatusApp( + installationId, + owner, + repo, + sha, + state, + targetUrl, + description, + context = 'World driven' +) { + const octokit = await getInstallationOctokit(installationId); + try { + const response = await octokit.rest.repos.createCommitStatus({ + owner, + repo, + sha, + state, + target_url: targetUrl, + description, + context, + }); + console.log( + `✅ Set status for ${owner}/${repo}@${sha}: ${state} - ${description}` + ); + return response; + } catch (error) { + console.error( + `❌ Failed to set status for ${owner}/${repo}@${sha}:`, + error.message + ); + throw error; + } +} + +export async function getLatestCommitShaApp( + installationId, + owner, + repo, + pullNumber +) { + const octokit = await getInstallationOctokit(installationId); + try { + const { data: commits } = await octokit.rest.pulls.listCommits({ + owner, + repo, + pull_number: pullNumber, + }); + + if (commits.length === 0) { + throw new Error('No commits found for pull request'); + } + return commits[commits.length - 1].sha; + } catch (error) { + console.error( + `❌ Failed to get commits for PR ${owner}/${repo}#${pullNumber}:`, + error.message + ); + throw error; + } +} + +export async function createIssueCommentApp( + installationId, + owner, + repo, + number, + comment +) { + const octokit = await getInstallationOctokit(installationId); + try { + return await octokit.rest.issues.createComment({ + owner, + repo, + issue_number: number, + body: comment, + }); + } catch (error) { + console.error( + `❌ Failed to create comment for ${owner}/${repo}#${number}:`, + error.message + ); + throw error; + } +} + +export async function createWebhookApp( + installationId, + owner, + repo, + webhookUrl +) { + const octokit = await getInstallationOctokit(installationId); + + const webhookConfig = { + url: webhookUrl, + insecure_ssl: '0', + content_type: 'json', + }; + + const events = ['pull_request', 'pull_request_review', 'push']; + + try { + const response = await octokit.rest.repos.createWebhook({ + owner, + repo, + name: 'web', + config: webhookConfig, + events: events, + active: true, + }); + + console.log(`✅ Created webhook for ${owner}/${repo} -> ${webhookUrl}`); + return response.data; + } catch (error) { + // If webhook already exists, that's okay + if (error.status === 422) { + console.log(`Webhook already exists for ${owner}/${repo}`); + return { info: 'Webhook already exists' }; + } + console.error( + `❌ Failed to create webhook for ${owner}/${repo}:`, + error.message + ); + throw error; + } +} + +export async function deleteWebhookApp( + installationId, + owner, + repo, + webhookUrl +) { + const octokit = await getInstallationOctokit(installationId); + + try { + // First, get all hooks + const { data: hooks } = await octokit.rest.repos.listWebhooks({ + owner, + repo, + }); + + // Find hooks that match our webhook URL + for (const hook of hooks) { + if (hook.config && hook.config.url === webhookUrl) { + await octokit.rest.repos.deleteWebhook({ + owner, + repo, + hook_id: hook.id, + }); + console.log(`✅ Deleted webhook ${hook.id} for ${owner}/${repo}`); + } + } + } catch (error) { + console.error( + `❌ Failed to delete webhooks for ${owner}/${repo}:`, + error.message + ); + } +} diff --git a/src/helpers/installationHandler.js b/src/helpers/installationHandler.js new file mode 100644 index 0000000..d146fd5 --- /dev/null +++ b/src/helpers/installationHandler.js @@ -0,0 +1,108 @@ +import { Repository } from '../database/models.js'; + +/** + * Handle GitHub App installation webhook + * @param {object} payload + */ +export async function handleInstallationWebhook(payload) { + const { action, installation, repositories } = payload; + + if (action === 'created') { + console.log( + `GitHub App installed by ${installation.account.login} (ID: ${installation.id})` + ); + + // Auto-configure repositories if specific repos were selected + if (repositories) { + for (const repo of repositories) { + const [owner, repoName] = repo.full_name.split('/'); + + // Check if repository already exists (from PAT setup) + const existingRepo = await Repository.findByOwnerAndRepo( + owner, + repoName + ); + + if (existingRepo) { + // Migrate from PAT to GitHub App + await Repository.update(existingRepo._id, { + installationId: installation.id, + // Keep userId for now during migration + }); + console.log(`Migrated ${owner}/${repoName} to GitHub App`); + } else { + // Create new repository + await Repository.create({ + owner, + repo: repoName, + installationId: installation.id, + configured: true, + }); + console.log(`Added ${owner}/${repoName} via GitHub App`); + } + } + } + } else if (action === 'deleted') { + console.log( + `GitHub App uninstalled by ${installation.account.login} (ID: ${installation.id})` + ); + + // Disable repositories for this installation + const repos = await Repository.findByInstallationId(installation.id); + for (const repo of repos) { + await Repository.update(repo._id, { + configured: false, + installationId: null, + }); + console.log(`Disabled ${repo.owner}/${repo.repo} (app uninstalled)`); + } + } +} + +/** + * Handle GitHub App installation repositories webhook + * @param {object} payload + */ +export async function handleInstallationRepositoriesWebhook(payload) { + const { action, installation, repositories_added, repositories_removed } = + payload; + + if (action === 'added') { + for (const repo of repositories_added) { + const [owner, repoName] = repo.full_name.split('/'); + + // Check if repository already exists + const existingRepo = await Repository.findByOwnerAndRepo(owner, repoName); + + if (existingRepo) { + // Update existing repository to use GitHub App + await Repository.update(existingRepo._id, { + installationId: installation.id, + configured: true, + }); + console.log(`Updated ${owner}/${repoName} to use GitHub App`); + } else { + // Create new repository + await Repository.create({ + owner, + repo: repoName, + installationId: installation.id, + configured: true, + }); + console.log(`Added ${owner}/${repoName} to installation`); + } + } + } else if (action === 'removed') { + for (const repo of repositories_removed) { + const [owner, repoName] = repo.full_name.split('/'); + const existingRepo = await Repository.findByOwnerAndRepo(owner, repoName); + if (existingRepo && existingRepo.installationId === installation.id) { + await Repository.update(existingRepo._id, { + configured: false, + installationId: null, + }); + console.log(`Removed ${owner}/${repoName} from installation`); + } + } + } +} diff --git a/src/helpers/pullRequestProcessor.js b/src/helpers/pullRequestProcessor.js index 8cee56d..4fa078b 100644 --- a/src/helpers/pullRequestProcessor.js +++ b/src/helpers/pullRequestProcessor.js @@ -10,21 +10,21 @@ import { User, Repository } from '../database/models.js'; /** * Set GitHub status for a pull request - * @param {object} user + * @param {object|number} authMethod - User object or installationId * @param {string} owner * @param {string} repo * @param {number} pullNumber * @param {object} pullRequestData */ async function setPullRequestStatus( - user, + authMethod, owner, repo, pullNumber, pullRequestData ) { try { - const sha = await getLatestCommitSha(user, owner, repo, pullNumber); + const sha = await getLatestCommitSha(authMethod, owner, repo, pullNumber); const coefficient = pullRequestData.stats.coefficient; const targetUrl = `https://www.worlddriven.org/${owner}/${repo}/pull/${pullNumber}`; @@ -40,7 +40,7 @@ async function setPullRequestStatus( } await setCommitStatus( - user, + authMethod, owner, repo, sha, @@ -90,9 +90,29 @@ export async function processPullRequests() { `Processing repository: ${repository.owner}/${repository.repo}` ); - const user = await User.findById(repository.userId); - if (!user) { - const error = `No user found for repository ${repository.owner}/${repository.repo}`; + let authMethod; + + // Determine authentication method + if (repository.installationId) { + // Use GitHub App + authMethod = repository.installationId; + console.log( + `Using GitHub App authentication (installation: ${repository.installationId})` + ); + } else if (repository.userId) { + // Use PAT (legacy) + const user = await User.findById(repository.userId); + if (!user) { + const error = `No user found for repository ${repository.owner}/${repository.repo}`; + console.log(error); + repoResult.errors.push(error); + results.errors++; + continue; + } + authMethod = user; + console.log(`Using PAT authentication (user: ${user._id})`); + } else { + const error = `No authentication method configured for ${repository.owner}/${repository.repo}`; console.log(error); repoResult.errors.push(error); results.errors++; @@ -101,7 +121,7 @@ export async function processPullRequests() { try { const pullRequests = await getPullRequests( - user, + authMethod, repository.owner, repository.repo ); @@ -122,7 +142,7 @@ export async function processPullRequests() { try { const pullRequestData = await getPullRequestData( - user, + authMethod, repository.owner, repository.repo, pullRequest.number @@ -133,7 +153,7 @@ export async function processPullRequests() { // Set GitHub status for this pull request await setPullRequestStatus( - user, + authMethod, repository.owner, repository.repo, pullRequest.number, @@ -146,7 +166,7 @@ export async function processPullRequests() { ); const mergeResponse = await mergePullRequest( - user, + authMethod, repository.owner, repository.repo, pullRequest.number @@ -156,7 +176,7 @@ export async function processPullRequests() { const comment = 'This pull request was merged by [worlddriven](https://www.worlddriven.org).'; await createIssueComment( - user, + authMethod, repository.owner, repository.repo, pullRequest.number, diff --git a/src/index.js b/src/index.js index c876029..1c81433 100644 --- a/src/index.js +++ b/src/index.js @@ -20,6 +20,10 @@ import { handlePullRequestReviewWebhook, handlePushWebhook, } from './helpers/webhookHandler.js'; +import { + handleInstallationWebhook, + handleInstallationRepositoriesWebhook, +} from './helpers/installationHandler.js'; const mongoSessionStore = MongoStore.create({ clientPromise: client.connect(), @@ -85,6 +89,13 @@ async function startServer() { res.sendFile('./static/privacyPolicy.html', { root: '.' }); }); + app.get('/install-app', function (req, res) { + // Redirect to GitHub App installation page + const appName = process.env.GITHUB_APP_NAME || 'world-driven'; + const installUrl = `https://github.com/apps/${appName}/installations/new`; + res.redirect(installUrl); + }); + app.get('/login', function (req, res) { if (req.session.userId) { res.redirect('/dashboard'); @@ -322,6 +333,14 @@ async function startServer() { let result; switch (eventType) { + case 'installation': + result = await handleInstallationWebhook(data); + break; + + case 'installation_repositories': + result = await handleInstallationRepositoriesWebhook(data); + break; + case 'pull_request': result = await handlePullRequestWebhook(data); break;