Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 12 additions & 0 deletions app/Enums/ExternalLinkSyncStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

namespace App\Enums;

enum ExternalLinkSyncStatus: string
{
case Pending = 'pending';
case Synced = 'synced';
case Failed = 'failed';
case Orphaned = 'orphaned';
case Conflict = 'conflict';
}
11 changes: 11 additions & 0 deletions app/Enums/IntegrationProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace App\Enums;

enum IntegrationProvider: string
{
case GitHub = 'github';
case Jira = 'jira';
case Trello = 'trello';
case Linear = 'linear';
}
11 changes: 11 additions & 0 deletions app/Enums/IntegrationStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace App\Enums;

enum IntegrationStatus: string
{
case Pending = 'pending';
case Connected = 'connected';
case Error = 'error';
case Disabled = 'disabled';
}
11 changes: 11 additions & 0 deletions app/Enums/SyncRunStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace App\Enums;

enum SyncRunStatus: string
{
case Running = 'running';
case Completed = 'completed';
case Failed = 'failed';
case Partial = 'partial';
}
17 changes: 17 additions & 0 deletions app/Events/TasksChanged.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace App\Events;

use App\Models\Project;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class TasksChanged
{
use Dispatchable, SerializesModels;

public function __construct(
public Project $project,
public string $changeType = 'updated'
) {}
}
11 changes: 11 additions & 0 deletions app/Exceptions/GitHubApiException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace App\Exceptions;

class GitHubApiException extends \Exception
{
public function __construct(string $message, int $statusCode = 0)
{
parent::__construct($message, $statusCode);
}
}
5 changes: 5 additions & 0 deletions app/Exceptions/GitHubAuthException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

namespace App\Exceptions;

class GitHubAuthException extends GitHubApiException {}
14 changes: 14 additions & 0 deletions app/Exceptions/GitHubRateLimitException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace App\Exceptions;

class GitHubRateLimitException extends GitHubApiException
{
public int $retryAfter;

public function __construct(string $message, int $retryAfter = 60)
{
parent::__construct($message, 429);
$this->retryAfter = $retryAfter;
}
}
238 changes: 238 additions & 0 deletions app/Http/Controllers/GitHubIntegrationController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
<?php

namespace App\Http\Controllers;

use App\Enums\IntegrationProvider;
use App\Enums\IntegrationStatus;
use App\Models\Integration;
use App\Models\Project;
use App\Models\User;
use App\Services\GitHub\GitHubApiService;
use App\Services\GitHub\GitHubSyncService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;

class GitHubIntegrationController extends Controller
{
use AuthorizesRequests;

/**
* Redirect to GitHub App installation.
*/
public function install(Request $request, Project $project): RedirectResponse
{
$this->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,
]);
}
}
Loading
Loading