Domain: mergelater.com Project Location: /Users/markshust/Sites/mergelater
Build a multi-tenant Laravel web application that schedules GitHub PR merges at exact times. Users authenticate via GitHub OAuth and can schedule merges for any repo (personal or organization) they have write access to. Deploys to a DigitalOcean droplet managed by Laravel Forge.
- GitHub OAuth login (access user + organization repos)
- Web UI to schedule PR merges with exact date/time
- Support any repo the authenticated user has write access to
- Configurable merge method per PR (squash, merge commit, rebase)
- View/cancel scheduled merges (users only see their own)
- Merge status tracking (pending, completed, failed)
- Timezone selection during onboarding (required before scheduling)
- Email notifications (enabled by default): successful merges, failures, token expirations
- Slack notifications: webhook integration for merge events
- Light/dark mode: modern developer aesthetic using frontend-design skill
- Admin dashboard: user management, usage stats, system overview
- Framework: Laravel 12
- Frontend: Blade + Tailwind CSS 4 (styled with frontend-design skill)
- Auth: Laravel Socialite with GitHub provider
- Database: MySQL (multi-tenant, via Forge)
- GitHub Integration: GitHub REST API via user's OAuth token
- Scheduler: Laravel's task scheduler (runs every minute)
- Notifications: Laravel Mail (Amazon SES) + Slack webhook integration
- Testing: Pest (TDD with Kent Beck's red -> green -> refactor cycle)
users
├── id
├── github_id (bigint, unique)
├── name (string)
├── email (string)
├── github_token (text, encrypted) - OAuth access token
├── avatar_url (string, nullable)
├── timezone (string, nullable) - e.g., "America/New_York"
├── onboarding_completed_at (datetime, nullable) - set when timezone is configured
├── email_notifications (boolean, default: true)
├── slack_webhook_url (string, nullable) - user's Slack incoming webhook
├── is_admin (boolean, default: false)
├── is_disabled (boolean, default: false)
├── created_at
├── updated_at
scheduled_merges
├── id
├── user_id (foreign key)
├── github_pr_url (string) - full PR URL for easy input
├── owner (string) - parsed from URL
├── repo (string) - parsed from URL
├── pull_number (integer) - parsed from URL
├── merge_method (enum: merge, squash, rebase)
├── scheduled_at (datetime) - when to merge
├── status (enum: pending, processing, completed, failed)
├── error_message (text, nullable)
├── merged_at (datetime, nullable)
├── created_at
├── updated_at
- User Model - Extended with github_token (encrypted), github_id
- ScheduledMerge Model - Belongs to User, with URL parsing logic
- GitHubService - Wrapper for GitHub API calls using user's token
- MergePullRequest Job - Dispatched at scheduled time, calls GitHub API with user's token
- ProcessScheduledMerges Command - Runs every minute, dispatches jobs for due merges
- Auth Controllers - GitHub OAuth login/callback via Socialite
- Create GitHub OAuth App
- Set callback URL to
https://mergelater.com/auth/github/callback - Request
reposcope (grants access to user repos + org repos user has access to)
Important: The repo scope grants full access to private repositories. This is necessary for merging PRs but is a broad permission. Users should understand this when authorizing.
Use Laravel HTTP client with user's OAuth token:
PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge- Headers:
Authorization: Bearer {user->github_token},Accept: application/vnd.github+json - Body:
{ "merge_method": "squash" }(or merge/rebase)
Validation on schedule: When user submits a PR URL, verify:
- PR exists and is open
- User has write access to the repo (can be checked via API)
Laravel scheduler runs via cron every minute. The ProcessScheduledMerges command will:
- Query for merges where
scheduled_at <= now()andstatus = pending - Dispatch a job for each (marks as
processingimmediately) - Job executes merge using the owning user's token and updates status
This gives ~1 minute precision, which is acceptable.
GET / → redirect to /dashboard or /login
GET /login → show login page with "Login with GitHub" button
GET /auth/github → redirect to GitHub OAuth
GET /auth/github/callback → handle OAuth callback, create/update user, login
POST /logout → logout
GET /onboarding → timezone selection (required after first login)
POST /onboarding → save timezone, mark onboarding complete
GET /dashboard → show scheduled merges + add form (auth required, onboarding required)
POST /merges → create scheduled merge (auth required)
DELETE /merges/{id} → cancel scheduled merge (auth required)
GET /settings → notification preferences, timezone, Slack webhook
POST /settings → update settings
# Admin routes (admin middleware required)
GET /admin → admin dashboard with usage stats
GET /admin/users → list all users with search/filter
GET /admin/users/{id} → view user details, their repos, scheduled merges
POST /admin/users/{id}/disable → disable user account
GET /admin/merges → list all scheduled merges across users
After GitHub OAuth login, users must complete onboarding before accessing the dashboard:
- Redirect to
/onboardingifonboarding_completed_atis null - Display timezone selector (searchable dropdown with common timezones)
- Auto-detect timezone via JavaScript as default suggestion
- On submit, set
timezoneandonboarding_completed_at - Redirect to dashboard
All views styled with modern developer aesthetic, light/dark mode support:
- layouts/app.blade.php - Base layout with Tailwind 4, nav bar, dark mode toggle
- auth/login.blade.php - Login page with GitHub OAuth button
- onboarding.blade.php - Timezone selection with searchable dropdown
- dashboard.blade.php - Main dashboard with:
- Form: PR URL input, merge method dropdown, datetime picker (in user's timezone), submit
- Table: List of user's scheduled merges with status, cancel button
- Times displayed in user's timezone
- settings.blade.php - Settings page with:
- Timezone selector
- Email notification toggle
- Slack webhook URL input with test button
- admin/dashboard.blade.php - Admin overview with:
- Total users, active users, new signups (daily/weekly/monthly)
- Total merges (pending, completed, failed)
- Recent activity feed
- admin/users.blade.php - User list with search/filter, disable action
- admin/user-detail.blade.php - Single user view with their repos and merge history
- admin/merges.blade.php - All scheduled merges across users with filters
Email Notifications (via Laravel Mail):
- Successful merge: PR merged successfully at scheduled time
- Failed merge: PR could not be merged (with error details)
- Token expiration: GitHub token revoked or expired (detected on failed API call)
Slack Notifications (via user's webhook):
- Same events as email
- User provides their own Slack incoming webhook URL in settings
- Test button to verify webhook works
GITHUB_CLIENT_ID=xxx
GITHUB_CLIENT_SECRET=xxx
GITHUB_REDIRECT_URI=https://mergelater.com/auth/github/callback
MAIL_MAILER=ses
MAIL_FROM_ADDRESS=notifications@mergelater.com
MAIL_FROM_NAME="MergeLater"
# AWS SES credentials (SES-only permissions)
AWS_SES_ACCESS_KEY_ID=xxx
AWS_SES_SECRET_ACCESS_KEY=xxx
AWS_SES_REGION=us-east-2
- Create new site in Forge on a droplet
- Connect to GitHub repo containing the app
- Add GitHub OAuth credentials to environment
- Set up MySQL database via Forge
- Enable scheduler in Forge (adds cron for
php artisan schedule:run) - Enable queue worker if using async job processing
All features built using Kent Beck's TDD cycle:
- Red: Write a failing test first
- Green: Write minimal code to make the test pass
- Refactor: Clean up code while keeping tests green
- Create new Laravel 12 project at /Users/markshust/Sites/mergelater
- Install mbox for local dev environment (see https://github.com/markshust/mbox-laravel/)
- Install Tailwind CSS 4
- Install Pest and configure for testing
- Install Laravel Socialite
- Create migrations (users modification + scheduled_merges)
- Configure GitHub OAuth in Socialite
- Create auth routes and controllers (TDD)
- Create onboarding flow with timezone selection (TDD)
- Create ScheduledMerge model with URL parsing (TDD)
- Create GitHubService class for API calls (TDD)
- Create MergePullRequest job with notification dispatch (TDD)
- Create ProcessScheduledMerges command + schedule it (TDD)
- Create notification classes (Email + Slack) (TDD)
- Create Blade views using frontend-design skill:
- Layout with dark mode toggle
- Login page
- Onboarding (timezone selection)
- Dashboard
- Settings
- Create settings controller and routes (TDD)
- Create admin middleware and controllers (TDD)
- Create admin views using frontend-design skill
- Test locally with ngrok (for OAuth callback)
- Deploy to Forge
GitHub OAuth tokens don't expire by default (unlike some OAuth providers). However:
- Tokens can be revoked by the user
- If a merge fails due to auth issues (401/403), send token expiration notification
- User can re-authenticate via settings to refresh their token