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
-