From 94cae5069ef42074ba0a37d2ea023e23c4a39e3f Mon Sep 17 00:00:00 2001 From: GitButler Date: Mon, 5 Jan 2026 23:25:58 +0500 Subject: [PATCH 1/2] GitButler Workspace Commit This is a merge commit the virtual branches in your workspace. Due to GitButler managing multiple virtual branches, you cannot switch back and forth between git branches and virtual branches easily. If you switch to another branch, GitButler will need to be reinitialized. If you commit on this branch, GitButler will throw it away. Here are the branches that are currently applied: - feature/github-issues-export (refs/gitbutler/feature/github-issues-export) - database/factories/ExternalLinkFactory.php - app/Livewire/Projects/Workspace.php - resources/views/integrations/github/select-repo.blade.php - database/migrations/2026_01_02_081825_create_sync_runs_table.php - app/Enums/SyncRunStatus.php - .gitignore - app/Jobs/GenerateTasksJob.php - composer.lock - app/Services/GitHub/GitHubAuthService.php - app/Exceptions/GitHubRateLimitException.php - app/Services/GitHub/TaskToIssueMapper.php - phpunit.xml - config/services.php - app/Enums/ExternalLinkSyncStatus.php - app/Services/GitHub/GitHubSyncService.php - app/Enums/IntegrationProvider.php - app/Models/Integration.php - app/Models/Task.php - .env.testing - database/factories/SyncRunFactory.php - app/Services/GitHub/GitHubRateLimiter.php - app/Listeners/QueueGitHubSync.php - database/migrations/2026_01_02_081824_create_integrations_table.php - app/Models/ExternalLink.php - app/Jobs/CheckSyncRunCompletion.php - app/Enums/IntegrationStatus.php - app/Models/Project.php - database/migrations/2026_01_02_081825_create_external_links_table.php - tests/Feature/GitHubWebhookTest.php - composer.json - database/factories/IntegrationFactory.php - app/Services/GitHub/GitHubApiService.php - resources/views/components/layouts/app.blade.php - app/Jobs/DebouncedGitHubSync.php - app/Models/SyncRun.php - app/Exceptions/GitHubAuthException.php - resources/views/livewire/projects/tabs/integrations.blade.php - app/Jobs/SyncTaskToGitHub.php - app/Events/TasksChanged.php - app/Providers/AppServiceProvider.php - app/Exceptions/GitHubApiException.php - tests/Pest.php - app/Http/Controllers/GitHubIntegrationController.php - tests/Feature/GitHubSyncTest.php - app/Livewire/Projects/Tabs/Integrations.php - tests/Feature/GitHubAutoSyncTest.php - tests/Feature/GitHubSyncQueueTest.php - routes/web.php - resources/views/livewire/projects/workspace.blade.php - app/Http/Controllers/GitHubWebhookController.php For more information about what we're doing here, check out our docs: https://docs.gitbutler.com/features/branch-management/integration-branch From f9d1d6901128e03ae32d254eaa4272950310cb73 Mon Sep 17 00:00:00 2001 From: Mujahid Abbas Date: Tue, 6 Jan 2026 14:09:01 +0500 Subject: [PATCH 2/2] feat: Add GitHub Issues integration for two-way task sync - Add Integration, ExternalLink, and SyncRun models with migrations - Implement GitHub OAuth flow for repository connection - Add two-way sync: tasks to GitHub issues and webhooks for issue updates - Create Integrations tab in project workspace UI - Implement debounced sync with queue-based processing - Add comprehensive test coverage for sync functionality - Support sync status tracking per task with visual indicators --- .gitignore | 9 + app/Enums/ExternalLinkSyncStatus.php | 12 + app/Enums/IntegrationProvider.php | 11 + app/Enums/IntegrationStatus.php | 11 + app/Enums/SyncRunStatus.php | 11 + app/Events/TasksChanged.php | 17 + app/Exceptions/GitHubApiException.php | 11 + app/Exceptions/GitHubAuthException.php | 5 + app/Exceptions/GitHubRateLimitException.php | 14 + .../GitHubIntegrationController.php | 238 ++++++++++ .../Controllers/GitHubWebhookController.php | 180 ++++++++ app/Jobs/CheckSyncRunCompletion.php | 49 +++ app/Jobs/DebouncedGitHubSync.php | 78 ++++ app/Jobs/GenerateTasksJob.php | 7 + app/Jobs/SyncTaskToGitHub.php | 81 ++++ app/Listeners/QueueGitHubSync.php | 36 ++ app/Livewire/Projects/Tabs/Integrations.php | 132 ++++++ app/Livewire/Projects/Workspace.php | 9 + app/Models/ExternalLink.php | 49 +++ app/Models/Integration.php | 72 +++ app/Models/Project.php | 13 + app/Models/SyncRun.php | 62 +++ app/Models/Task.php | 53 +++ app/Providers/AppServiceProvider.php | 6 + app/Services/GitHub/GitHubApiService.php | 189 ++++++++ app/Services/GitHub/GitHubAuthService.php | 70 +++ app/Services/GitHub/GitHubRateLimiter.php | 54 +++ app/Services/GitHub/GitHubSyncService.php | 153 +++++++ app/Services/GitHub/TaskToIssueMapper.php | 112 +++++ app/Services/ProviderService.php | 3 +- composer.json | 2 + composer.lock | 265 ++++++++++- config/services.php | 9 + database/factories/ExternalLinkFactory.php | 61 +++ database/factories/IntegrationFactory.php | 61 +++ database/factories/SyncRunFactory.php | 62 +++ ...01_02_081824_create_integrations_table.php | 36 ++ ..._02_081825_create_external_links_table.php | 42 ++ ...26_01_02_081825_create_sync_runs_table.php | 40 ++ phpunit.xml | 4 +- .../views/components/layouts/app.blade.php | 2 +- .../integrations/github/select-repo.blade.php | 100 +++++ .../projects/tabs/integrations.blade.php | 416 ++++++++++++++++++ .../livewire/projects/workspace.blade.php | 6 +- routes/web.php | 27 ++ tests/Feature/GitHubAutoSyncTest.php | 266 +++++++++++ tests/Feature/GitHubSyncQueueTest.php | 159 +++++++ tests/Feature/GitHubSyncTest.php | 270 ++++++++++++ tests/Feature/GitHubWebhookTest.php | 300 +++++++++++++ tests/Pest.php | 2 +- 50 files changed, 3870 insertions(+), 7 deletions(-) create mode 100644 app/Enums/ExternalLinkSyncStatus.php create mode 100644 app/Enums/IntegrationProvider.php create mode 100644 app/Enums/IntegrationStatus.php create mode 100644 app/Enums/SyncRunStatus.php create mode 100644 app/Events/TasksChanged.php create mode 100644 app/Exceptions/GitHubApiException.php create mode 100644 app/Exceptions/GitHubAuthException.php create mode 100644 app/Exceptions/GitHubRateLimitException.php create mode 100644 app/Http/Controllers/GitHubIntegrationController.php create mode 100644 app/Http/Controllers/GitHubWebhookController.php create mode 100644 app/Jobs/CheckSyncRunCompletion.php create mode 100644 app/Jobs/DebouncedGitHubSync.php create mode 100644 app/Jobs/SyncTaskToGitHub.php create mode 100644 app/Listeners/QueueGitHubSync.php create mode 100644 app/Livewire/Projects/Tabs/Integrations.php create mode 100644 app/Models/ExternalLink.php create mode 100644 app/Models/Integration.php create mode 100644 app/Models/SyncRun.php create mode 100644 app/Services/GitHub/GitHubApiService.php create mode 100644 app/Services/GitHub/GitHubAuthService.php create mode 100644 app/Services/GitHub/GitHubRateLimiter.php create mode 100644 app/Services/GitHub/GitHubSyncService.php create mode 100644 app/Services/GitHub/TaskToIssueMapper.php create mode 100644 database/factories/ExternalLinkFactory.php create mode 100644 database/factories/IntegrationFactory.php create mode 100644 database/factories/SyncRunFactory.php create mode 100644 database/migrations/2026_01_02_081824_create_integrations_table.php create mode 100644 database/migrations/2026_01_02_081825_create_external_links_table.php create mode 100644 database/migrations/2026_01_02_081825_create_sync_runs_table.php create mode 100644 resources/views/integrations/github/select-repo.blade.php create mode 100644 resources/views/livewire/projects/tabs/integrations.blade.php create mode 100644 tests/Feature/GitHubAutoSyncTest.php create mode 100644 tests/Feature/GitHubSyncQueueTest.php create mode 100644 tests/Feature/GitHubSyncTest.php create mode 100644 tests/Feature/GitHubWebhookTest.php diff --git a/.gitignore b/.gitignore index cd81520..cd19e93 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,15 @@ Thumbs.db /docs /CLAUDE.md +# AI tool configurations +/.cursor +/.gemini +/.junie +/.mcp.json +/GEMINI.md +/boost.json + # SQLite test databases planforge_test_* planforge +.env.testing diff --git a/app/Enums/ExternalLinkSyncStatus.php b/app/Enums/ExternalLinkSyncStatus.php new file mode 100644 index 0000000..e07b7f5 --- /dev/null +++ b/app/Enums/ExternalLinkSyncStatus.php @@ -0,0 +1,12 @@ +retryAfter = $retryAfter; + } +} diff --git a/app/Http/Controllers/GitHubIntegrationController.php b/app/Http/Controllers/GitHubIntegrationController.php new file mode 100644 index 0000000..9745036 --- /dev/null +++ b/app/Http/Controllers/GitHubIntegrationController.php @@ -0,0 +1,238 @@ +authorize('update', $project); + + $state = encrypt([ + 'project_id' => $project->id, + 'user_id' => auth()->id(), + ]); + + $appSlug = config('services.github.app_slug'); + + return redirect("https://github.com/apps/{$appSlug}/installations/new?state={$state}"); + } + + /** + * Handle callback after GitHub App installation. + * + * Note: This route runs without auth middleware since it's called by GitHub. + * We re-authenticate the user from the encrypted state. + */ + public function callback(Request $request): RedirectResponse + { + $installationId = $request->input('installation_id'); + + if (! $installationId) { + return redirect()->route('projects.index') + ->with('error', 'GitHub installation was cancelled.'); + } + + try { + $state = decrypt($request->input('state')); + } catch (\Exception $e) { + return redirect()->route('projects.index') + ->with('error', 'Invalid state parameter. Please try again.'); + } + + $project = Project::findOrFail($state['project_id']); + $user = User::findOrFail($state['user_id']); + + // Re-authenticate the user from state + auth()->login($user); + + // Now we can authorize + $this->authorize('update', $project); + + // Store installation ID temporarily + session(['github_installation_id' => $installationId]); + + return redirect()->route('integrations.github.select-repo', $project); + } + + /** + * Show repository selection page. + */ + public function selectRepo(Request $request, Project $project, GitHubApiService $api): View|RedirectResponse + { + $this->authorize('update', $project); + + $installationId = session('github_installation_id'); + + if (! $installationId) { + return redirect()->route('integrations.github.install', $project) + ->with('error', 'GitHub installation expired. Please try again.'); + } + + try { + $repos = $api->listRepositories($installationId); + } catch (\Exception $e) { + return redirect()->route('projects.workspace', $project) + ->with('error', 'Failed to fetch repositories: '.$e->getMessage()); + } + + return view('integrations.github.select-repo', [ + 'project' => $project, + 'repositories' => $repos['repositories'] ?? [], + ]); + } + + /** + * Complete setup with selected repository. + */ + public function setup(Request $request, Project $project): RedirectResponse + { + $this->authorize('update', $project); + + $validated = $request->validate([ + 'owner' => 'required|string', + 'repo' => 'required|string', + 'default_labels' => 'nullable|array', + 'sync_closed_as' => 'nullable|string|in:done', + 'sync_reopened_as' => 'nullable|string|in:doing,todo', + ]); + + $installationId = session('github_installation_id'); + + if (! $installationId) { + return redirect()->route('integrations.github.install', $project) + ->with('error', 'GitHub installation expired. Please try again.'); + } + + Integration::updateOrCreate( + [ + 'project_id' => $project->id, + 'provider' => IntegrationProvider::GitHub, + ], + [ + 'status' => IntegrationStatus::Connected, + 'settings' => [ + 'installation_id' => $installationId, + 'owner' => $validated['owner'], + 'repo' => $validated['repo'], + 'default_labels' => $validated['default_labels'] ?? ['planforge'], + 'sync_closed_as' => $validated['sync_closed_as'] ?? 'done', + 'sync_reopened_as' => $validated['sync_reopened_as'] ?? 'doing', + ], + 'error_message' => null, + ] + ); + + session()->forget('github_installation_id'); + + return redirect()->route('projects.workspace', $project) + ->with('success', 'GitHub integration connected successfully!'); + } + + /** + * Disconnect GitHub integration. + */ + public function disconnect(Request $request, Project $project): RedirectResponse + { + $this->authorize('update', $project); + + $integration = $project->gitHubIntegration(); + + if ($integration) { + $integration->update([ + 'status' => IntegrationStatus::Disabled, + 'error_message' => 'Disconnected by user', + ]); + } + + return redirect()->route('projects.workspace', $project) + ->with('success', 'GitHub integration disconnected.'); + } + + /** + * Trigger manual sync to GitHub. + */ + public function sync(Request $request, Project $project, GitHubSyncService $syncService): JsonResponse|RedirectResponse + { + $this->authorize('update', $project); + + $integration = $project->gitHubIntegration(); + + if (! $integration || ! $integration->isConnected()) { + if ($request->wantsJson()) { + return response()->json([ + 'error' => 'GitHub integration not connected', + ], 400); + } + + return redirect()->back() + ->with('error', 'GitHub integration not connected.'); + } + + $syncRun = $syncService->syncProject($integration, auth()->id()); + + if ($request->wantsJson()) { + return response()->json([ + 'message' => 'Sync started', + 'sync_run_id' => $syncRun->id, + 'total_tasks' => $syncRun->total_count, + ]); + } + + return redirect()->back() + ->with('success', "Syncing {$syncRun->total_count} tasks to GitHub..."); + } + + /** + * Get sync status. + */ + public function syncStatus(Request $request, Project $project): JsonResponse + { + $this->authorize('view', $project); + + $integration = $project->gitHubIntegration(); + + if (! $integration) { + return response()->json([ + 'connected' => false, + ]); + } + + $latestRun = $integration->syncRuns()->latest()->first(); + + return response()->json([ + 'connected' => $integration->isConnected(), + 'status' => $integration->status->value, + 'repo' => $integration->getRepoFullName(), + 'last_synced_at' => $integration->last_synced_at?->toISOString(), + 'latest_run' => $latestRun ? [ + 'id' => $latestRun->id, + 'status' => $latestRun->status->value, + 'total' => $latestRun->total_count, + 'created' => $latestRun->created_count, + 'updated' => $latestRun->updated_count, + 'failed' => $latestRun->failed_count, + 'started_at' => $latestRun->started_at->toISOString(), + 'completed_at' => $latestRun->completed_at?->toISOString(), + ] : null, + ]); + } +} diff --git a/app/Http/Controllers/GitHubWebhookController.php b/app/Http/Controllers/GitHubWebhookController.php new file mode 100644 index 0000000..b6a01de --- /dev/null +++ b/app/Http/Controllers/GitHubWebhookController.php @@ -0,0 +1,180 @@ +verifySignature($request)) { + Log::warning('GitHub webhook signature verification failed'); + + return response()->json(['error' => 'Invalid signature'], 401); + } + + $event = $request->header('X-GitHub-Event'); + $payload = $request->all(); + + Log::info('GitHub webhook received', [ + 'event' => $event, + 'action' => $payload['action'] ?? null, + ]); + + return match ($event) { + 'issues' => $this->handleIssueEvent($payload), + 'ping' => $this->handlePing($payload), + default => response()->json(['message' => 'Event ignored']), + }; + } + + /** + * Verify the webhook signature from GitHub. + */ + private function verifySignature(Request $request): bool + { + $secret = config('services.github.webhook_secret'); + + // Skip verification if no secret is configured (dev mode) + if (empty($secret)) { + return true; + } + + $signature = $request->header('X-Hub-Signature-256'); + if (! $signature) { + return false; + } + + $payload = $request->getContent(); + $expectedSignature = 'sha256='.hash_hmac('sha256', $payload, $secret); + + return hash_equals($expectedSignature, $signature); + } + + /** + * Handle issue events (opened, closed, reopened, etc.). + */ + private function handleIssueEvent(array $payload): JsonResponse + { + $action = $payload['action'] ?? null; + $issue = $payload['issue'] ?? null; + $repository = $payload['repository'] ?? null; + + if (! $issue || ! $repository) { + return response()->json(['error' => 'Invalid payload'], 400); + } + + $issueNodeId = $issue['node_id']; + $issueNumber = $issue['number']; + $issueState = $issue['state']; + $repoFullName = $repository['full_name']; + + // Find the external link for this issue by node_id (most reliable) + $externalLink = ExternalLink::where('provider', IntegrationProvider::GitHub) + ->where('external_id', $issueNodeId) + ->first(); + + if (! $externalLink) { + // Fallback: find by issue number and matching integration + [$owner, $repo] = explode('/', $repoFullName) + [null, null]; + + if ($owner && $repo) { + $externalLink = ExternalLink::where('provider', IntegrationProvider::GitHub) + ->where('external_number', $issueNumber) + ->whereHas('integration', function ($query) use ($owner, $repo) { + $query->where('provider', IntegrationProvider::GitHub) + ->where('settings->owner', $owner) + ->where('settings->repo', $repo); + }) + ->first(); + } + } + + if (! $externalLink) { + Log::info('GitHub webhook: No matching external link found', [ + 'issue_node_id' => $issueNodeId, + 'issue_number' => $issueNumber, + 'repo' => $repoFullName, + ]); + + return response()->json(['message' => 'Issue not tracked']); + } + + $task = $externalLink->task; + $integration = $externalLink->integration; + + if (! $task || ! $integration) { + return response()->json(['message' => 'Task or integration not found']); + } + + // Update external link state + $externalLink->update([ + 'external_state' => $issueState, + ]); + + // Handle status changes based on action + $settings = $integration->settings; + + if ($action === 'closed') { + $newStatus = match ($settings['sync_closed_as'] ?? 'done') { + 'done' => TaskStatus::Done, + default => TaskStatus::Done, + }; + + if ($task->status !== $newStatus) { + $task->updateQuietly(['status' => $newStatus]); + + Log::info('GitHub webhook: Task status updated to done', [ + 'task_id' => $task->id, + 'issue_number' => $issueNumber, + ]); + } + } elseif ($action === 'reopened') { + $newStatus = match ($settings['sync_reopened_as'] ?? 'doing') { + 'doing' => TaskStatus::Doing, + 'todo' => TaskStatus::Todo, + default => TaskStatus::Doing, + }; + + if ($task->status === TaskStatus::Done) { + $task->updateQuietly(['status' => $newStatus]); + + Log::info('GitHub webhook: Task status updated from done', [ + 'task_id' => $task->id, + 'issue_number' => $issueNumber, + 'new_status' => $newStatus->value, + ]); + } + } + + return response()->json([ + 'message' => 'Webhook processed', + 'action' => $action, + 'task_id' => $task->id, + ]); + } + + /** + * Handle ping event (sent when webhook is first configured). + */ + private function handlePing(array $payload): JsonResponse + { + Log::info('GitHub webhook ping received', [ + 'zen' => $payload['zen'] ?? null, + 'hook_id' => $payload['hook_id'] ?? null, + ]); + + return response()->json(['message' => 'pong']); + } +} diff --git a/app/Jobs/CheckSyncRunCompletion.php b/app/Jobs/CheckSyncRunCompletion.php new file mode 100644 index 0000000..51ab08f --- /dev/null +++ b/app/Jobs/CheckSyncRunCompletion.php @@ -0,0 +1,49 @@ +syncRunId); + + if (! $syncRun || $syncRun->status !== SyncRunStatus::Running) { + return; + } + + $processed = $syncRun->created_count + + $syncRun->updated_count + + $syncRun->skipped_count + + $syncRun->failed_count; + + if ($processed >= $syncRun->total_count) { + $status = $syncRun->failed_count > 0 + ? SyncRunStatus::Partial + : SyncRunStatus::Completed; + + $syncRun->update([ + 'status' => $status, + 'completed_at' => now(), + ]); + } else { + // Still processing, check again in 30 seconds + self::dispatch($this->syncRunId) + ->delay(now()->addSeconds(30)); + } + } +} diff --git a/app/Jobs/DebouncedGitHubSync.php b/app/Jobs/DebouncedGitHubSync.php new file mode 100644 index 0000000..6e0f0d8 --- /dev/null +++ b/app/Jobs/DebouncedGitHubSync.php @@ -0,0 +1,78 @@ +projectId}"; + $latestSyncId = Cache::get($cacheKey); + + if ($latestSyncId !== $this->syncId) { + // A newer sync was requested, skip this one + logger()->debug('Skipping debounced GitHub sync - newer request exists', [ + 'project_id' => $this->projectId, + 'sync_id' => $this->syncId, + 'latest_sync_id' => $latestSyncId, + ]); + + return; + } + + // Clear the pending sync marker + Cache::forget($cacheKey); + + // Load the models + $project = Project::find($this->projectId); + $integration = Integration::find($this->integrationId); + + if (! $project || ! $integration) { + logger()->warning('DebouncedGitHubSync: Project or integration not found', [ + 'project_id' => $this->projectId, + 'integration_id' => $this->integrationId, + ]); + + return; + } + + // Verify integration is still connected + if (! $integration->isConnected()) { + logger()->debug('DebouncedGitHubSync: Integration not connected', [ + 'integration_id' => $this->integrationId, + ]); + + return; + } + + // Execute the sync (no user_id for auto-triggered syncs) + $syncRun = $syncService->syncProject($integration, null); + + logger()->info('Auto-sync triggered for GitHub', [ + 'project_id' => $this->projectId, + 'sync_run_id' => $syncRun->id, + 'total_tasks' => $syncRun->total_count, + ]); + } +} diff --git a/app/Jobs/GenerateTasksJob.php b/app/Jobs/GenerateTasksJob.php index 7149fc6..1fa3b6e 100644 --- a/app/Jobs/GenerateTasksJob.php +++ b/app/Jobs/GenerateTasksJob.php @@ -20,6 +20,8 @@ use Illuminate\Queue\Middleware\RateLimited; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; +use Prism\Prism\Enums\Provider; use Prism\Prism\Exceptions\PrismRateLimitedException; use Prism\Prism\Facades\Prism; use Relaticle\Flowforge\Services\Rank; @@ -81,6 +83,11 @@ public function handle(): void $response = $this->callAI($run, $techSpec, $prdSummary); + Log::info('Task generation completed', [ + 'project_id' => $run->project_id, + 'tasks_count' => count($response->structured['tasks'] ?? []), + ]); + $this->storeRateLimits($step, $response); $this->persistTasks($run->project_id, $taskSet, $response->structured); diff --git a/app/Jobs/SyncTaskToGitHub.php b/app/Jobs/SyncTaskToGitHub.php new file mode 100644 index 0000000..43e205c --- /dev/null +++ b/app/Jobs/SyncTaskToGitHub.php @@ -0,0 +1,81 @@ + + */ + public function middleware(): array + { + // Prevent overlapping syncs for the same task + return [ + (new WithoutOverlapping($this->task->id))->dontRelease(), + ]; + } + + public function handle(GitHubSyncService $syncService): void + { + $result = $syncService->syncTask( + $this->task, + $this->integration, + $this->syncRunId + ); + + // Update sync run stats + $this->updateSyncRunStats($result['action']); + + // Add delay between requests to respect rate limits + usleep(500000); // 0.5 second delay + } + + public function failed(\Throwable $exception): void + { + $this->updateSyncRunStats('failed'); + + logger()->error('Task sync to GitHub failed', [ + 'task_id' => $this->task->id, + 'integration_id' => $this->integration->id, + 'error' => $exception->getMessage(), + ]); + } + + private function updateSyncRunStats(string $action): void + { + $syncRun = SyncRun::find($this->syncRunId); + if ($syncRun) { + $syncRun->incrementStat($action); + } + } + + public function retryUntil(): \DateTime + { + return now()->addHours(1); + } +} diff --git a/app/Listeners/QueueGitHubSync.php b/app/Listeners/QueueGitHubSync.php new file mode 100644 index 0000000..f324697 --- /dev/null +++ b/app/Listeners/QueueGitHubSync.php @@ -0,0 +1,36 @@ +project; + + // Check if project has a connected GitHub integration + $integration = $project->gitHubIntegration(); + + if (! $integration || $integration->status !== IntegrationStatus::Connected) { + return; + } + + // Use cache to implement debounce + $cacheKey = "github_sync_pending_{$project->id}"; + $syncId = uniqid('sync_', true); + + // Store the latest sync request ID + Cache::put($cacheKey, $syncId, now()->addMinutes(5)); + + // Dispatch a delayed job that will only run if this is still the latest request + DebouncedGitHubSync::dispatch($project->id, $integration->id, $syncId) + ->delay(now()->addSeconds(self::DEBOUNCE_SECONDS)); + } +} diff --git a/app/Livewire/Projects/Tabs/Integrations.php b/app/Livewire/Projects/Tabs/Integrations.php new file mode 100644 index 0000000..c37951d --- /dev/null +++ b/app/Livewire/Projects/Tabs/Integrations.php @@ -0,0 +1,132 @@ +projectId = $projectId; + } + + #[Computed] + public function project(): Project + { + return Project::findOrFail($this->projectId); + } + + #[Computed] + public function githubIntegration(): ?Integration + { + return $this->project->gitHubIntegration(); + } + + #[Computed] + public function recentSyncRuns(): array + { + $integration = $this->githubIntegration; + + if (! $integration) { + return []; + } + + return $integration->syncRuns() + ->latest() + ->limit(5) + ->get() + ->map(fn ($run) => [ + 'id' => $run->id, + 'status' => $run->status->value, + 'trigger' => $run->trigger, + 'total' => $run->total_count, + 'created' => $run->created_count, + 'updated' => $run->updated_count, + 'skipped' => $run->skipped_count, + 'failed' => $run->failed_count, + 'started_at' => $run->started_at->diffForHumans(), + 'completed_at' => $run->completed_at?->diffForHumans(), + ]) + ->toArray(); + } + + #[Computed] + public function lastSyncedAt(): ?string + { + $integration = $this->githubIntegration; + + if (! $integration) { + return null; + } + + $lastCompletedSync = $integration->syncRuns() + ->whereNotNull('completed_at') + ->latest('completed_at') + ->first(); + + return $lastCompletedSync?->completed_at->diffForHumans(); + } + + #[Computed] + public function hasRunningSyncs(): bool + { + $integration = $this->githubIntegration; + + if (! $integration) { + return false; + } + + return $integration->syncRuns() + ->where('status', \App\Enums\SyncRunStatus::Running) + ->exists(); + } + + #[On('triggerSync')] + public function triggerSync(): void + { + $integration = $this->githubIntegration; + + if (! $integration || ! $integration->isConnected()) { + $this->syncMessage = 'GitHub integration is not connected.'; + + return; + } + + $this->isSyncing = true; + + try { + $syncService = app(GitHubSyncService::class); + $syncRun = $syncService->syncProject($integration, Auth::id()); + + $this->syncMessage = "Syncing {$syncRun->total_count} tasks to GitHub..."; + } catch (\Exception $e) { + $this->syncMessage = 'Sync failed: '.$e->getMessage(); + + Log::error('GitHub sync failed', [ + 'error' => $e->getMessage(), + 'project_id' => $this->projectId, + ]); + } + + $this->isSyncing = false; + } + + public function render() + { + return view('livewire.projects.tabs.integrations'); + } +} diff --git a/app/Livewire/Projects/Workspace.php b/app/Livewire/Projects/Workspace.php index 4e0df58..7abc2ef 100644 --- a/app/Livewire/Projects/Workspace.php +++ b/app/Livewire/Projects/Workspace.php @@ -87,6 +87,15 @@ public function refreshProject(): void unset($this->project); } + /** + * Proxy method to handle stale Livewire requests after OAuth redirects. + * Dispatches to the child Integrations component. + */ + public function triggerSync(): void + { + $this->dispatch('triggerSync')->to('projects.tabs.integrations'); + } + public function render() { return view('livewire.projects.workspace'); diff --git a/app/Models/ExternalLink.php b/app/Models/ExternalLink.php new file mode 100644 index 0000000..9271779 --- /dev/null +++ b/app/Models/ExternalLink.php @@ -0,0 +1,49 @@ + 'integer', + 'last_synced_at' => 'datetime', + 'provider' => IntegrationProvider::class, + 'sync_status' => ExternalLinkSyncStatus::class, + ]; + } + + public function integration(): BelongsTo + { + return $this->belongsTo(Integration::class); + } + + public function task(): BelongsTo + { + return $this->belongsTo(Task::class); + } +} diff --git a/app/Models/Integration.php b/app/Models/Integration.php new file mode 100644 index 0000000..68828fd --- /dev/null +++ b/app/Models/Integration.php @@ -0,0 +1,72 @@ + 'array', + 'credentials' => 'encrypted:array', + 'last_synced_at' => 'datetime', + 'provider' => IntegrationProvider::class, + 'status' => IntegrationStatus::class, + ]; + } + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + public function externalLinks(): HasMany + { + return $this->hasMany(ExternalLink::class); + } + + public function syncRuns(): HasMany + { + return $this->hasMany(SyncRun::class); + } + + public function isConnected(): bool + { + return $this->status === IntegrationStatus::Connected; + } + + public function getInstallationId(): ?string + { + return $this->settings['installation_id'] ?? null; + } + + public function getRepoFullName(): ?string + { + $owner = $this->settings['owner'] ?? null; + $repo = $this->settings['repo'] ?? null; + + return $owner && $repo ? "{$owner}/{$repo}" : null; + } +} diff --git a/app/Models/Project.php b/app/Models/Project.php index acf9cc7..3ef3a2d 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\IntegrationProvider; use App\Enums\ProjectStatus; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -66,4 +67,16 @@ public function latestTaskSet(): ?TaskSet { return $this->taskSets()->latest()->first(); } + + public function integrations(): HasMany + { + return $this->hasMany(Integration::class); + } + + public function gitHubIntegration(): ?Integration + { + return $this->integrations() + ->where('provider', IntegrationProvider::GitHub) + ->first(); + } } diff --git a/app/Models/SyncRun.php b/app/Models/SyncRun.php new file mode 100644 index 0000000..f12a6f0 --- /dev/null +++ b/app/Models/SyncRun.php @@ -0,0 +1,62 @@ + 'integer', + 'created_count' => 'integer', + 'updated_count' => 'integer', + 'skipped_count' => 'integer', + 'failed_count' => 'integer', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + 'status' => SyncRunStatus::class, + ]; + } + + public function integration(): BelongsTo + { + return $this->belongsTo(Integration::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function incrementStat(string $type): void + { + $column = "{$type}_count"; + if (in_array($column, ['created_count', 'updated_count', 'skipped_count', 'failed_count'])) { + $this->increment($column); + } + } +} diff --git a/app/Models/Task.php b/app/Models/Task.php index 37caf07..97df1da 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -5,16 +5,57 @@ use App\Enums\TaskCategory; use App\Enums\TaskPriority; use App\Enums\TaskStatus; +use App\Events\TasksChanged; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; class Task extends Model { use HasFactory, HasUlids, SoftDeletes; + /** + * Fields that trigger GitHub sync when changed. + */ + private const SYNC_TRIGGER_FIELDS = [ + 'title', + 'description', + 'acceptance_criteria', + 'status', + 'category', + 'priority', + ]; + + protected static function booted(): void + { + static::created(function (Task $task) { + // New task created - always dispatch + if ($task->project) { + TasksChanged::dispatch($task->project, 'created'); + } + }); + + static::updated(function (Task $task) { + // Only trigger sync if sync-relevant fields changed + // Use getDirty() because getChanges() is not populated until after syncChanges() + $changedFields = array_keys($task->getDirty()); + $syncTriggerChanged = array_intersect($changedFields, self::SYNC_TRIGGER_FIELDS); + + if (! empty($syncTriggerChanged) && $task->project) { + TasksChanged::dispatch($task->project, 'updated'); + } + }); + + static::deleted(function (Task $task) { + if ($task->project) { + TasksChanged::dispatch($task->project, 'deleted'); + } + }); + } + protected $fillable = [ 'project_id', 'epic_id', @@ -77,4 +118,16 @@ public function taskSet(): BelongsTo { return $this->belongsTo(TaskSet::class); } + + public function externalLinks(): HasMany + { + return $this->hasMany(ExternalLink::class); + } + + public function getGitHubIssueUrl(): ?string + { + return $this->externalLinks() + ->where('provider', 'github') + ->value('external_url'); + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1d9de07..a9ffe23 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,10 @@ namespace App\Providers; +use App\Events\TasksChanged; +use App\Listeners\QueueGitHubSync; use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; @@ -26,5 +29,8 @@ public function boot(): void RateLimiter::for('llm:requests', function ($job) { return Limit::perMinute(30)->by('llm:global'); }); + + // Register GitHub sync event listener + Event::listen(TasksChanged::class, QueueGitHubSync::class); } } diff --git a/app/Services/GitHub/GitHubApiService.php b/app/Services/GitHub/GitHubApiService.php new file mode 100644 index 0000000..99347d2 --- /dev/null +++ b/app/Services/GitHub/GitHubApiService.php @@ -0,0 +1,189 @@ + + */ + public function createIssue(string $installationId, string $owner, string $repo, array $data): array + { + return $this->post( + $installationId, + "/repos/{$owner}/{$repo}/issues", + $data + ); + } + + /** + * Update an existing issue. + * + * @return array + */ + public function updateIssue(string $installationId, string $owner, string $repo, int $issueNumber, array $data): array + { + return $this->patch( + $installationId, + "/repos/{$owner}/{$repo}/issues/{$issueNumber}", + $data + ); + } + + /** + * Close an issue. + * + * @return array + */ + public function closeIssue(string $installationId, string $owner, string $repo, int $issueNumber): array + { + return $this->updateIssue($installationId, $owner, $repo, $issueNumber, [ + 'state' => 'closed', + ]); + } + + /** + * Get issue by number. + * + * @return array + */ + public function getIssue(string $installationId, string $owner, string $repo, int $issueNumber): array + { + return $this->get( + $installationId, + "/repos/{$owner}/{$repo}/issues/{$issueNumber}" + ); + } + + /** + * List available repositories for installation. + * + * @return array + */ + public function listRepositories(string $installationId): array + { + return $this->get($installationId, '/installation/repositories'); + } + + /** + * List labels for a repository. + * + * @return array + */ + public function listLabels(string $installationId, string $owner, string $repo): array + { + return $this->get($installationId, "/repos/{$owner}/{$repo}/labels"); + } + + /** + * Create a label if it doesn't exist. + * + * @return array + */ + public function createLabel(string $installationId, string $owner, string $repo, string $name, string $color = 'ededed'): array + { + return $this->post($installationId, "/repos/{$owner}/{$repo}/labels", [ + 'name' => $name, + 'color' => $color, + ]); + } + + // ───────────────────────────────────────────────────────────── + // HTTP Methods + // ───────────────────────────────────────────────────────────── + + /** + * @return array + */ + private function get(string $installationId, string $endpoint): array + { + return $this->request('GET', $installationId, $endpoint); + } + + /** + * @return array + */ + private function post(string $installationId, string $endpoint, array $data): array + { + $this->checkRateLimit(); + + return $this->request('POST', $installationId, $endpoint, $data); + } + + /** + * @return array + */ + private function patch(string $installationId, string $endpoint, array $data): array + { + $this->checkRateLimit(); + + return $this->request('PATCH', $installationId, $endpoint, $data); + } + + /** + * @return array + */ + private function request(string $method, string $installationId, string $endpoint, array $data = []): array + { + $token = $this->auth->getInstallationToken($installationId); + + $response = Http::withHeaders([ + 'Authorization' => "Bearer {$token}", + 'Accept' => 'application/vnd.github+json', + 'X-GitHub-Api-Version' => '2022-11-28', + ])->{strtolower($method)}("https://api.github.com{$endpoint}", $data); + + $this->handleRateLimitHeaders($response); + + if ($response->status() === 403 && str_contains($response->body(), 'rate limit')) { + $this->auth->invalidateToken($installationId); + throw new GitHubRateLimitException($response->json('message')); + } + + if ($response->failed()) { + throw new GitHubApiException( + $response->json('message', 'Unknown error'), + $response->status() + ); + } + + return $response->json(); + } + + private function checkRateLimit(): void + { + if (! $this->rateLimiter->canMakeRequest()) { + throw new GitHubRateLimitException( + 'Rate limit reached. Remaining: '. + $this->rateLimiter->getRemainingHour().'/hour' + ); + } + + $this->rateLimiter->recordRequest(); + } + + private function handleRateLimitHeaders(Response $response): void + { + $remaining = $response->header('X-RateLimit-Remaining'); + $reset = $response->header('X-RateLimit-Reset'); + + if ($remaining !== null && (int) $remaining < 100) { + logger()->warning('GitHub API rate limit low', [ + 'remaining' => $remaining, + 'reset' => $reset, + ]); + } + } +} diff --git a/app/Services/GitHub/GitHubAuthService.php b/app/Services/GitHub/GitHubAuthService.php new file mode 100644 index 0000000..640f6dd --- /dev/null +++ b/app/Services/GitHub/GitHubAuthService.php @@ -0,0 +1,70 @@ +appId = $appId ?? config('services.github.app_id'); + $this->privateKey = $privateKey ?? config('services.github.private_key'); + } + + /** + * Generate JWT for GitHub App authentication. + * JWT expires in 10 minutes (GitHub maximum). + */ + public function generateJWT(): string + { + $now = time(); + + $payload = [ + 'iat' => $now - 60, // Issued 60s ago (clock drift) + 'exp' => $now + (10 * 60), // Expires in 10 minutes + 'iss' => $this->appId, // GitHub App ID + ]; + + return JWT::encode($payload, $this->privateKey, 'RS256'); + } + + /** + * Get installation access token (cached for 50 minutes). + */ + public function getInstallationToken(string $installationId): string + { + $cacheKey = "github_installation_token_{$installationId}"; + + return Cache::remember($cacheKey, now()->addMinutes(50), function () use ($installationId) { + $jwt = $this->generateJWT(); + + $response = Http::withHeaders([ + 'Authorization' => "Bearer {$jwt}", + 'Accept' => 'application/vnd.github+json', + 'X-GitHub-Api-Version' => '2022-11-28', + ])->post("https://api.github.com/app/installations/{$installationId}/access_tokens"); + + if ($response->failed()) { + throw new GitHubAuthException('Failed to get installation token: '.$response->body()); + } + + return $response->json('token'); + }); + } + + /** + * Invalidate cached token (call when token fails). + */ + public function invalidateToken(string $installationId): void + { + Cache::forget("github_installation_token_{$installationId}"); + } +} diff --git a/app/Services/GitHub/GitHubRateLimiter.php b/app/Services/GitHub/GitHubRateLimiter.php new file mode 100644 index 0000000..839e4f2 --- /dev/null +++ b/app/Services/GitHub/GitHubRateLimiter.php @@ -0,0 +1,54 @@ +project->latestTaskSet(); + + $tasksQuery = $integration->project->tasks() + ->whereIn('status', [ + TaskStatus::Todo, + TaskStatus::Doing, + TaskStatus::Done, + ]); + + // If there's a latest TaskSet, only sync those tasks + if ($latestTaskSet) { + $tasksQuery->where('task_set_id', $latestTaskSet->id); + } + + $tasks = $tasksQuery->get(); + + // Create sync run record + $syncRun = SyncRun::create([ + 'integration_id' => $integration->id, + 'user_id' => $userId, + 'direction' => 'push', + 'trigger' => $userId ? 'manual' : 'scheduled', + 'status' => SyncRunStatus::Running, + 'started_at' => now(), + 'total_count' => $tasks->count(), + ]); + + // Dispatch individual sync jobs + foreach ($tasks as $task) { + SyncTaskToGitHub::dispatch($task, $integration, $syncRun->id); + } + + // Dispatch completion checker (30 seconds should be enough for most syncs) + CheckSyncRunCompletion::dispatch($syncRun->id) + ->delay(now()->addSeconds(30)); + + return $syncRun; + } + + /** + * Sync a single task to GitHub. + * + * @return array + */ + public function syncTask(Task $task, Integration $integration, string $syncRunId): array + { + $settings = $integration->settings; + $installationId = $settings['installation_id']; + $owner = $settings['owner']; + $repo = $settings['repo']; + + // Check if task already has an external link + $externalLink = $task->externalLinks() + ->where('integration_id', $integration->id) + ->first(); + + // Generate current content hash + $currentHash = $this->mapper->generateHash($task); + + // Skip if unchanged + if ($externalLink && $externalLink->last_synced_hash === $currentHash) { + return ['action' => 'skipped', 'reason' => 'unchanged']; + } + + $issueData = $this->mapper->toIssue($task, $integration); + + try { + if ($externalLink && $externalLink->external_number) { + // Update existing issue + $issue = $this->api->updateIssue( + $installationId, + $owner, + $repo, + $externalLink->external_number, + $issueData + ); + $action = 'updated'; + } else { + // Create new issue + $issue = $this->api->createIssue( + $installationId, + $owner, + $repo, + $issueData + ); + $action = 'created'; + } + + // Update or create external link + $task->externalLinks()->updateOrCreate( + ['integration_id' => $integration->id], + [ + 'provider' => 'github', + 'external_id' => $issue['node_id'], + 'external_number' => $issue['number'], + 'external_url' => $issue['html_url'], + 'external_state' => $issue['state'], + 'sync_status' => 'synced', + 'sync_error' => null, + 'last_synced_at' => now(), + 'last_synced_hash' => $currentHash, + ] + ); + + return ['action' => $action, 'issue_number' => $issue['number']]; + + } catch (\Exception $e) { + // Record failure + if ($externalLink) { + $externalLink->update([ + 'sync_status' => 'failed', + 'sync_error' => $e->getMessage(), + ]); + } else { + // Create a pending external link to track the failure + $task->externalLinks()->create([ + 'integration_id' => $integration->id, + 'provider' => 'github', + 'external_id' => 'pending_'.uniqid(), + 'sync_status' => 'failed', + 'sync_error' => $e->getMessage(), + ]); + } + + throw $e; + } + } +} diff --git a/app/Services/GitHub/TaskToIssueMapper.php b/app/Services/GitHub/TaskToIssueMapper.php new file mode 100644 index 0000000..d9801a2 --- /dev/null +++ b/app/Services/GitHub/TaskToIssueMapper.php @@ -0,0 +1,112 @@ + + */ + public function toIssue(Task $task, Integration $integration): array + { + $settings = $integration->settings; + + return [ + 'title' => $this->formatTitle($task), + 'body' => $this->formatBody($task), + 'labels' => $this->mapLabels($task, $settings), + ]; + } + + /** + * Generate content hash for change detection. + */ + public function generateHash(Task $task): string + { + $content = json_encode([ + 'title' => $task->title, + 'description' => $task->description, + 'acceptance_criteria' => $task->acceptance_criteria, + 'status' => $task->status->value, + 'category' => $task->category?->value, + ]); + + return hash('sha256', $content); + } + + private function formatTitle(Task $task): string + { + $prefix = ''; + + // Add category prefix if exists + if ($task->category) { + $prefix = "[{$task->category->value}] "; + } + + return $prefix.$task->title; + } + + private function formatBody(Task $task): string + { + $body = []; + + // Description + if ($task->description) { + $body[] = "## Description\n\n{$task->description}"; + } + + // Acceptance Criteria (array field) + if (! empty($task->acceptance_criteria)) { + $criteria = $this->formatAcceptanceCriteria($task->acceptance_criteria); + $body[] = "## Acceptance Criteria\n\n{$criteria}"; + } + + // Estimate + if ($task->estimate) { + $body[] = "**Estimate:** {$task->estimate}"; + } + + // Metadata footer + $appUrl = config('app.url'); + $body[] = '---'; + $body[] = "_Synced from [PlanForge]({$appUrl}/projects/{$task->project_id})_"; + + return implode("\n\n", $body); + } + + /** + * @param array $criteria + */ + private function formatAcceptanceCriteria(array $criteria): string + { + return collect($criteria) + ->map(fn ($item) => "- [ ] {$item}") + ->implode("\n"); + } + + /** + * @param array $settings + * @return array + */ + private function mapLabels(Task $task, array $settings): array + { + $labels = $settings['default_labels'] ?? ['planforge']; + + // Map task category to label + if ($task->category) { + $labels[] = $task->category->value; + } + + // Map priority to label + if ($task->priority?->value === 'high') { + $labels[] = 'priority:high'; + } + + return array_values(array_unique($labels)); + } +} diff --git a/app/Services/ProviderService.php b/app/Services/ProviderService.php index 8ceff6a..286af22 100644 --- a/app/Services/ProviderService.php +++ b/app/Services/ProviderService.php @@ -4,6 +4,7 @@ use App\Enums\AiProvider; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Log; use Prism\Prism\Enums\Provider; class ProviderService @@ -83,7 +84,7 @@ public function resolveProvider(?string $providerString): Provider $aiProvider = AiProvider::tryFrom($providerString); if (! $aiProvider) { - \Log::warning("Unknown provider '{$providerString}', falling back to default"); + Log::warning("Unknown AI provider '{$providerString}', falling back to default"); return $this->getDefaultProviderOrFail()->toPrismProvider(); } diff --git a/composer.json b/composer.json index 7599aef..b0e22f4 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ "require": { "php": "^8.2", "filament/filament": "^4.0", + "firebase/php-jwt": "^7.0", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", "livewire/livewire": "^3.6.4", @@ -17,6 +18,7 @@ }, "require-dev": { "fakerphp/faker": "^1.23", + "laravel/boost": "^1.8", "laravel/breeze": "^2.3", "laravel/pail": "^1.2.2", "laravel/pint": "^1.24", diff --git a/composer.lock b/composer.lock index 132c9a8..4bb1c80 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ed7ed2941be60d97b25c3ee38fe36844", + "content-hash": "ab53984dc2e43392e17f25cd02aca89e", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -1475,6 +1475,69 @@ }, "time": "2025-12-30T13:02:08+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v7.0.2", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/5645b43af647b6947daac1d0f659dd1fbe8d3b65", + "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v7.0.2" + }, + "time": "2025-12-16T22:17:28+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", @@ -8869,6 +8932,72 @@ }, "time": "2025-03-19T14:43:43+00:00" }, + { + "name": "laravel/boost", + "version": "v1.8.7", + "source": { + "type": "git", + "url": "https://github.com/laravel/boost.git", + "reference": "7a5709a8134ed59d3e7f34fccbd74689830e296c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/boost/zipball/7a5709a8134ed59d3e7f34fccbd74689830e296c", + "reference": "7a5709a8134ed59d3e7f34fccbd74689830e296c", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.9", + "illuminate/console": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/contracts": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/routing": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/support": "^10.49.0|^11.45.3|^12.41.1", + "laravel/mcp": "^0.5.1", + "laravel/prompts": "0.1.25|^0.3.6", + "laravel/roster": "^0.2.9", + "php": "^8.1" + }, + "require-dev": { + "laravel/pint": "^1.20.0", + "mockery/mockery": "^1.6.12", + "orchestra/testbench": "^8.36.0|^9.15.0|^10.6", + "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Boost\\BoostServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Boost\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.", + "homepage": "https://github.com/laravel/boost", + "keywords": [ + "ai", + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/boost/issues", + "source": "https://github.com/laravel/boost" + }, + "time": "2025-12-19T15:04:12+00:00" + }, { "name": "laravel/breeze", "version": "v2.3.8", @@ -8930,6 +9059,79 @@ }, "time": "2025-07-18T18:49:59+00:00" }, + { + "name": "laravel/mcp", + "version": "v0.5.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "10dedea054fa4eeaa9ef2ccbfdad6c3e1dbd17a4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/10dedea054fa4eeaa9ef2ccbfdad6c3e1dbd17a4", + "reference": "10dedea054fa4eeaa9ef2ccbfdad6c3e1dbd17a4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/container": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/contracts": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/http": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/json-schema": "^12.41.1", + "illuminate/routing": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/support": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/validation": "^10.49.0|^11.45.3|^12.41.1", + "php": "^8.1" + }, + "require-dev": { + "laravel/pint": "^1.20", + "orchestra/testbench": "^8.36|^9.15|^10.8", + "pestphp/pest": "^2.36.0|^3.8.4|^4.1.0", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2025-12-17T06:14:23+00:00" + }, { "name": "laravel/pail", "version": "v1.2.4", @@ -9076,6 +9278,67 @@ }, "time": "2025-11-25T21:15:52+00:00" }, + { + "name": "laravel/roster", + "version": "v0.2.9", + "source": { + "type": "git", + "url": "https://github.com/laravel/roster.git", + "reference": "82bbd0e2de614906811aebdf16b4305956816fa6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/roster/zipball/82bbd0e2de614906811aebdf16b4305956816fa6", + "reference": "82bbd0e2de614906811aebdf16b4305956816fa6", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "php": "^8.1|^8.2", + "symfony/yaml": "^6.4|^7.2" + }, + "require-dev": { + "laravel/pint": "^1.14", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "pestphp/pest": "^2.0|^3.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Roster\\RosterServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Roster\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Detect packages & approaches in use within a Laravel project", + "homepage": "https://github.com/laravel/roster", + "keywords": [ + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/roster/issues", + "source": "https://github.com/laravel/roster" + }, + "time": "2025-10-20T09:56:46+00:00" + }, { "name": "laravel/sail", "version": "v1.51.0", diff --git a/config/services.php b/config/services.php index 6a90eb8..ecb014b 100644 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,13 @@ ], ], + 'github' => [ + 'app_id' => env('GITHUB_APP_ID'), + 'app_slug' => env('GITHUB_APP_SLUG'), + 'client_id' => env('GITHUB_APP_CLIENT_ID'), + 'client_secret' => env('GITHUB_APP_CLIENT_SECRET'), + 'private_key' => env('GITHUB_APP_PRIVATE_KEY'), + 'webhook_secret' => env('GITHUB_WEBHOOK_SECRET'), + ], + ]; diff --git a/database/factories/ExternalLinkFactory.php b/database/factories/ExternalLinkFactory.php new file mode 100644 index 0000000..d33df77 --- /dev/null +++ b/database/factories/ExternalLinkFactory.php @@ -0,0 +1,61 @@ + + */ +class ExternalLinkFactory extends Factory +{ + protected $model = ExternalLink::class; + + public function definition(): array + { + return [ + 'integration_id' => Integration::factory(), + 'task_id' => Task::factory(), + 'provider' => IntegrationProvider::GitHub, + 'external_id' => 'I_'.$this->faker->unique()->regexify('[a-zA-Z0-9]{10}'), + 'external_number' => $this->faker->unique()->numberBetween(1, 10000), + 'external_url' => $this->faker->url(), + 'external_state' => 'open', + 'sync_status' => ExternalLinkSyncStatus::Synced, + 'sync_error' => null, + 'last_synced_at' => now(), + 'last_synced_hash' => hash('sha256', $this->faker->sentence()), + ]; + } + + public function pending(): static + { + return $this->state(fn (array $attributes) => [ + 'sync_status' => ExternalLinkSyncStatus::Pending, + 'last_synced_at' => null, + 'last_synced_hash' => null, + ]); + } + + public function failed(): static + { + return $this->state(fn (array $attributes) => [ + 'sync_status' => ExternalLinkSyncStatus::Failed, + 'sync_error' => 'API request failed', + ]); + } + + public function orphaned(): static + { + return $this->state(fn (array $attributes) => [ + 'sync_status' => ExternalLinkSyncStatus::Orphaned, + 'external_state' => 'deleted', + 'sync_error' => 'Issue was deleted in GitHub', + ]); + } +} diff --git a/database/factories/IntegrationFactory.php b/database/factories/IntegrationFactory.php new file mode 100644 index 0000000..90a93b2 --- /dev/null +++ b/database/factories/IntegrationFactory.php @@ -0,0 +1,61 @@ + + */ +class IntegrationFactory extends Factory +{ + protected $model = Integration::class; + + public function definition(): array + { + return [ + 'project_id' => Project::factory(), + 'provider' => IntegrationProvider::GitHub, + 'status' => IntegrationStatus::Pending, + 'settings' => [], + 'credentials' => null, + 'error_message' => null, + 'last_synced_at' => null, + ]; + } + + public function github(): static + { + return $this->state(fn (array $attributes) => [ + 'provider' => IntegrationProvider::GitHub, + ]); + } + + public function connected(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => IntegrationStatus::Connected, + ]); + } + + public function withGitHubSettings( + string $installationId = '12345', + string $owner = 'test-owner', + string $repo = 'test-repo' + ): static { + return $this->state(fn (array $attributes) => [ + 'settings' => [ + 'installation_id' => $installationId, + 'owner' => $owner, + 'repo' => $repo, + 'default_labels' => ['planforge'], + 'sync_closed_as' => 'done', + 'sync_reopened_as' => 'doing', + ], + ]); + } +} diff --git a/database/factories/SyncRunFactory.php b/database/factories/SyncRunFactory.php new file mode 100644 index 0000000..57a8d9a --- /dev/null +++ b/database/factories/SyncRunFactory.php @@ -0,0 +1,62 @@ + + */ +class SyncRunFactory extends Factory +{ + protected $model = SyncRun::class; + + public function definition(): array + { + return [ + 'integration_id' => Integration::factory(), + 'user_id' => User::factory(), + 'direction' => 'push', + 'trigger' => 'manual', + 'status' => SyncRunStatus::Running, + 'total_count' => 0, + 'created_count' => 0, + 'updated_count' => 0, + 'skipped_count' => 0, + 'failed_count' => 0, + 'error_message' => null, + 'started_at' => now(), + 'completed_at' => null, + ]; + } + + public function completed(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => SyncRunStatus::Completed, + 'completed_at' => now(), + ]); + } + + public function failed(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => SyncRunStatus::Failed, + 'error_message' => 'Sync failed', + 'completed_at' => now(), + ]); + } + + public function partial(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => SyncRunStatus::Partial, + 'failed_count' => 1, + 'completed_at' => now(), + ]); + } +} diff --git a/database/migrations/2026_01_02_081824_create_integrations_table.php b/database/migrations/2026_01_02_081824_create_integrations_table.php new file mode 100644 index 0000000..c1555bc --- /dev/null +++ b/database/migrations/2026_01_02_081824_create_integrations_table.php @@ -0,0 +1,36 @@ +ulid('id')->primary(); + $table->foreignUlid('project_id')->constrained()->cascadeOnDelete(); + $table->string('provider', 50); // github, jira, trello, linear + $table->string('status', 20)->default('pending'); // pending, connected, error, disabled + + // Provider-specific credentials (encrypted) + $table->text('credentials')->nullable(); + + // Provider-specific settings + $table->json('settings')->nullable(); + + $table->text('error_message')->nullable(); + $table->timestamp('last_synced_at')->nullable(); + $table->timestamps(); + + $table->unique(['project_id', 'provider']); + $table->index(['provider', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('integrations'); + } +}; diff --git a/database/migrations/2026_01_02_081825_create_external_links_table.php b/database/migrations/2026_01_02_081825_create_external_links_table.php new file mode 100644 index 0000000..4b71c8a --- /dev/null +++ b/database/migrations/2026_01_02_081825_create_external_links_table.php @@ -0,0 +1,42 @@ +ulid('id')->primary(); + $table->foreignUlid('integration_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('task_id')->constrained()->cascadeOnDelete(); + + $table->string('provider', 50); // Denormalized for faster queries + $table->string('external_id', 255); // GitHub node_id + $table->unsignedInteger('external_number')->nullable(); // Issue #123 + $table->string('external_url', 500)->nullable(); + $table->string('external_state', 50)->nullable(); // open, closed + + $table->string('sync_status', 20)->default('pending'); // pending, synced, failed, orphaned, conflict + $table->text('sync_error')->nullable(); + $table->timestamp('last_synced_at')->nullable(); + + // Track what was last synced to detect changes + $table->string('last_synced_hash', 64)->nullable(); // SHA256 of synced content + + $table->timestamps(); + + $table->unique(['integration_id', 'task_id']); + $table->unique(['provider', 'external_id']); + $table->index(['task_id', 'provider']); + $table->index(['sync_status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('external_links'); + } +}; diff --git a/database/migrations/2026_01_02_081825_create_sync_runs_table.php b/database/migrations/2026_01_02_081825_create_sync_runs_table.php new file mode 100644 index 0000000..d85bb91 --- /dev/null +++ b/database/migrations/2026_01_02_081825_create_sync_runs_table.php @@ -0,0 +1,40 @@ +ulid('id')->primary(); + $table->foreignUlid('integration_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + + $table->string('direction', 20); // push, pull, both + $table->string('trigger', 20); // manual, scheduled, webhook + $table->string('status', 20)->default('running'); // running, completed, failed, partial + + // Stats + $table->unsignedInteger('total_count')->default(0); + $table->unsignedInteger('created_count')->default(0); + $table->unsignedInteger('updated_count')->default(0); + $table->unsignedInteger('skipped_count')->default(0); + $table->unsignedInteger('failed_count')->default(0); + + $table->text('error_message')->nullable(); + $table->timestamp('started_at'); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + + $table->index(['integration_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('sync_runs'); + } +}; diff --git a/phpunit.xml b/phpunit.xml index d703241..1ea2b2b 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -23,8 +23,8 @@ - - + + diff --git a/resources/views/components/layouts/app.blade.php b/resources/views/components/layouts/app.blade.php index 5ab257a..95f511b 100644 --- a/resources/views/components/layouts/app.blade.php +++ b/resources/views/components/layouts/app.blade.php @@ -12,7 +12,7 @@ @filamentScripts
-