Skip to content

Instantly share code, notes, and snippets.

@markshust
Created December 20, 2025 21:07
Show Gist options
  • Select an option

  • Save markshust/5fb0872a5b6a8549e8266b13d3d35da3 to your computer and use it in GitHub Desktop.

Select an option

Save markshust/5fb0872a5b6a8549e8266b13d3d35da3 to your computer and use it in GitHub Desktop.
Results from Claude Code's plan mode for MergeLater

MergeLater - GitHub PR Scheduler

Domain: mergelater.com Project Location: /Users/markshust/Sites/mergelater

Overview

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.

Core Features

  • 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

Technical Architecture

Stack

  • 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)

Database Schema

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

Key Components

  1. User Model - Extended with github_token (encrypted), github_id
  2. ScheduledMerge Model - Belongs to User, with URL parsing logic
  3. GitHubService - Wrapper for GitHub API calls using user's token
  4. MergePullRequest Job - Dispatched at scheduled time, calls GitHub API with user's token
  5. ProcessScheduledMerges Command - Runs every minute, dispatches jobs for due merges
  6. Auth Controllers - GitHub OAuth login/callback via Socialite

GitHub OAuth Setup

  1. Create GitHub OAuth App
  2. Set callback URL to https://mergelater.com/auth/github/callback
  3. Request repo scope (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.

GitHub API Integration

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:

  1. PR exists and is open
  2. User has write access to the repo (can be checked via API)

Scheduler Precision

Laravel scheduler runs via cron every minute. The ProcessScheduledMerges command will:

  1. Query for merges where scheduled_at <= now() and status = pending
  2. Dispatch a job for each (marks as processing immediately)
  3. Job executes merge using the owning user's token and updates status

This gives ~1 minute precision, which is acceptable.

Routes

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

Onboarding Flow

After GitHub OAuth login, users must complete onboarding before accessing the dashboard:

  1. Redirect to /onboarding if onboarding_completed_at is null
  2. Display timezone selector (searchable dropdown with common timezones)
  3. Auto-detect timezone via JavaScript as default suggestion
  4. On submit, set timezone and onboarding_completed_at
  5. Redirect to dashboard

Views (Blade + frontend-design skill)

All views styled with modern developer aesthetic, light/dark mode support:

  1. layouts/app.blade.php - Base layout with Tailwind 4, nav bar, dark mode toggle
  2. auth/login.blade.php - Login page with GitHub OAuth button
  3. onboarding.blade.php - Timezone selection with searchable dropdown
  4. 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
  5. settings.blade.php - Settings page with:
    • Timezone selector
    • Email notification toggle
    • Slack webhook URL input with test button
  6. admin/dashboard.blade.php - Admin overview with:
    • Total users, active users, new signups (daily/weekly/monthly)
    • Total merges (pending, completed, failed)
    • Recent activity feed
  7. admin/users.blade.php - User list with search/filter, disable action
  8. admin/user-detail.blade.php - Single user view with their repos and merge history
  9. admin/merges.blade.php - All scheduled merges across users with filters

Notifications

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

Environment Variables

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

Deployment (Laravel Forge)

  1. Create new site in Forge on a droplet
  2. Connect to GitHub repo containing the app
  3. Add GitHub OAuth credentials to environment
  4. Set up MySQL database via Forge
  5. Enable scheduler in Forge (adds cron for php artisan schedule:run)
  6. Enable queue worker if using async job processing

Development Approach: TDD

All features built using Kent Beck's TDD cycle:

  1. Red: Write a failing test first
  2. Green: Write minimal code to make the test pass
  3. Refactor: Clean up code while keeping tests green

Implementation Steps

  1. Create new Laravel 12 project at /Users/markshust/Sites/mergelater
  2. Install mbox for local dev environment (see https://github.com/markshust/mbox-laravel/)
  3. Install Tailwind CSS 4
  4. Install Pest and configure for testing
  5. Install Laravel Socialite
  6. Create migrations (users modification + scheduled_merges)
  7. Configure GitHub OAuth in Socialite
  8. Create auth routes and controllers (TDD)
  9. Create onboarding flow with timezone selection (TDD)
  10. Create ScheduledMerge model with URL parsing (TDD)
  11. Create GitHubService class for API calls (TDD)
  12. Create MergePullRequest job with notification dispatch (TDD)
  13. Create ProcessScheduledMerges command + schedule it (TDD)
  14. Create notification classes (Email + Slack) (TDD)
  15. Create Blade views using frontend-design skill:
    • Layout with dark mode toggle
    • Login page
    • Onboarding (timezone selection)
    • Dashboard
    • Settings
  16. Create settings controller and routes (TDD)
  17. Create admin middleware and controllers (TDD)
  18. Create admin views using frontend-design skill
  19. Test locally with ngrok (for OAuth callback)
  20. Deploy to Forge

Token Considerations

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment