diff --git a/README.md b/README.md index 4212ec11..26f34161 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,30 @@ public function boot() Now head over to the login page in your roadmap software and view the log in button in action. The title of the button can be set with the `.env` variable: `SSO_LOGIN_TITLE=` +## AI endpoint + +Each item has an `/ai` endpoint that returns its data in a machine-readable format, useful for AI agents and automation. + +``` +GET /projects/{project}/items/{item}/ai +``` + +**Query parameters:** + +| Parameter | Description | Example | +|---|---|---| +| `format` | Response format: `json` (default), `yml`/`yaml`, `markdown`/`md` | `?format=yml` | +| `include[comments]` | Include public comments | `?include[comments]=1` | + +**Examples:** + +``` +/projects/1-bugs/items/2-bug-in-sites-overview/ai +/projects/1-bugs/items/2-bug-in-sites-overview/ai?include[comments]=1 +/projects/1-bugs/items/2-bug-in-sites-overview/ai?format=markdown +/projects/1-bugs/items/2-bug-in-sites-overview/ai?format=yml&include[comments]=1 +``` + ## Docker Support ### Getting up and running... diff --git a/app/Http/Controllers/ItemController.php b/app/Http/Controllers/ItemController.php index ea6c3f2e..37d8ec95 100644 --- a/app/Http/Controllers/ItemController.php +++ b/app/Http/Controllers/ItemController.php @@ -6,7 +6,10 @@ use App\Models\Project; use App\Enums\ItemActivity; use Illuminate\Http\Request; +use Illuminate\Http\Response; +use Illuminate\Http\JsonResponse; use App\Settings\GeneralSettings; +use Symfony\Component\Yaml\Yaml; use Illuminate\Http\RedirectResponse; use Spatie\Activitylog\Models\Activity; use Filament\Notifications\Notification; @@ -56,6 +59,67 @@ public function show($projectId, $itemId = null) ]); } + public function ai(Request $request, $projectSlug, $itemSlug): JsonResponse|Response + { + $project = Project::query()->visibleForCurrentUser()->where('slug', $projectSlug)->firstOrFail(); + $item = $project->items()->visibleForCurrentUser()->where('slug', $itemSlug)->firstOrFail(); + + $data = [ + 'title' => $item->title, + 'content' => $item->content, + 'board' => $item->board?->title, + 'project' => $project->title, + 'votes' => $item->total_votes, + 'tags' => $item->tags->pluck('name')->toArray(), + ]; + + $includes = $request->query('include', []); + + if (!empty($includes['comments'])) { + $data['comments'] = $item->comments() + ->with('user:id,name,username') + ->whereNull('parent_id') + ->where('private', false) + ->oldest() + ->get() + ->map(fn ($comment) => [ + 'author' => $comment->user->name ?? $comment->user->username, + 'content' => $comment->content, + 'created_at' => $comment->created_at->toIso8601String(), + ]) + ->toArray(); + } + + return match ($request->query('format', 'json')) { + 'yml', 'yaml' => response(Yaml::dump($data, 4, 2), 200, ['Content-Type' => 'text/yaml']), + 'markdown', 'md' => response($this->toMarkdown($data), 200, ['Content-Type' => 'text/markdown']), + default => response()->json($data), + }; + } + + protected function toMarkdown(array $data): string + { + $md = "# {$data['title']}\n\n"; + $md .= "**Project:** {$data['project']}"; + $md .= $data['board'] ? " | **Board:** {$data['board']}" : ''; + $md .= " | **Votes:** {$data['votes']}\n"; + + if (!empty($data['tags'])) { + $md .= '**Tags:** ' . implode(', ', $data['tags']) . "\n"; + } + + $md .= "\n---\n\n{$data['content']}\n"; + + if (!empty($data['comments'])) { + $md .= "\n---\n\n## Comments\n\n"; + foreach ($data['comments'] as $comment) { + $md .= "**{$comment['author']}** ({$comment['created_at']}):\n{$comment['content']}\n\n"; + } + } + + return $md; + } + public function edit($id) { $item = auth()->user()->items()->findOrFail($id); diff --git a/routes/web.php b/routes/web.php index 256e40cc..5ef71bbc 100644 --- a/routes/web.php +++ b/routes/web.php @@ -36,6 +36,7 @@ Route::get('items/{item}', [ItemController::class, 'show'])->name('items.show'); Route::get('items/{item}/edit', [ItemController::class, 'edit'])->name('items.edit'); Route::get('projects/{project}/items/{item}', [ItemController::class, 'show'])->name('projects.items.show'); +Route::get('projects/{project}/items/{item}/ai', [ItemController::class, 'ai'])->name('projects.items.ai'); Route::post('projects/{project}/items/{item}/vote', [ItemController::class, 'vote'])->middleware('authed')->name('projects.items.vote'); Route::post('projects/{project}/items/{item}/update-board', [ItemController::class, 'updateBoard'])->middleware('authed')->name('projects.items.update-board'); Route::get('projects/{project}/boards/{board}', [BoardsController::class, 'show'])->name('projects.boards.show'); diff --git a/tests/Feature/Item/ItemAiEndpointTest.php b/tests/Feature/Item/ItemAiEndpointTest.php new file mode 100644 index 00000000..17e52e75 --- /dev/null +++ b/tests/Feature/Item/ItemAiEndpointTest.php @@ -0,0 +1,128 @@ +user = createUser(); + $this->project = Project::factory()->create(); + $this->board = Board::factory()->create(['project_id' => $this->project->id]); + $this->item = Item::factory()->create([ + 'project_id' => $this->project->id, + 'board_id' => $this->board->id, + 'user_id' => $this->user, + ]); +}); + +test('it returns item data as json by default', function () { + $response = $this->get(route('projects.items.ai', [ + 'project' => $this->project->slug, + 'item' => $this->item->slug, + ])); + + $response->assertOk() + ->assertJsonStructure(['title', 'content', 'board', 'project', 'votes', 'tags']) + ->assertJsonMissing(['comments' => []]) + ->assertJson([ + 'title' => $this->item->title, + 'content' => $this->item->content, + 'board' => $this->board->title, + 'project' => $this->project->title, + ]); +}); + +test('it excludes comments by default', function () { + Comment::factory()->create([ + 'item_id' => $this->item->id, + 'user_id' => $this->user->id, + 'private' => false, + ]); + + $response = $this->get(route('projects.items.ai', [ + 'project' => $this->project->slug, + 'item' => $this->item->slug, + ])); + + $response->assertOk() + ->assertJsonMissingPath('comments'); +}); + +test('it includes public comments when requested via include[comments]=1', function () { + $comment = Comment::factory()->create([ + 'item_id' => $this->item->id, + 'user_id' => $this->user->id, + 'private' => false, + ]); + + $response = $this->get(route('projects.items.ai', [ + 'project' => $this->project->slug, + 'item' => $this->item->slug, + 'include' => ['comments' => 1], + ])); + + $response->assertOk() + ->assertJsonCount(1, 'comments') + ->assertJsonPath('comments.0.content', $comment->content); +}); + +test('it excludes private comments even when comments are included', function () { + Comment::factory()->create([ + 'item_id' => $this->item->id, + 'user_id' => $this->user->id, + 'private' => true, + ]); + + $response = $this->get(route('projects.items.ai', [ + 'project' => $this->project->slug, + 'item' => $this->item->slug, + 'include' => ['comments' => 1], + ])); + + $response->assertOk() + ->assertJsonCount(0, 'comments'); +}); + +test('it returns yaml when format=yml', function () { + $response = $this->get(route('projects.items.ai', [ + 'project' => $this->project->slug, + 'item' => $this->item->slug, + 'format' => 'yml', + ])); + + $response->assertOk() + ->assertHeader('Content-Type', 'text/yaml; charset=utf-8'); + + expect($response->getContent())->toContain('title:'); +}); + +test('it returns markdown when format=markdown', function () { + $response = $this->get(route('projects.items.ai', [ + 'project' => $this->project->slug, + 'item' => $this->item->slug, + 'format' => 'markdown', + ])); + + $response->assertOk() + ->assertHeader('Content-Type', 'text/markdown; charset=utf-8'); + + expect($response->getContent())->toContain("# {$this->item->title}"); +}); + +test('it returns 404 for private items', function () { + $item = Item::factory()->create([ + 'project_id' => $this->project->id, + 'board_id' => $this->board->id, + 'user_id' => $this->user, + 'private' => true, + ]); + + $this->get(route('projects.items.ai', [ + 'project' => $this->project->slug, + 'item' => $item->slug, + ]))->assertNotFound(); +}); diff --git a/tests/Feature/ProfileEmailValidationTest.php b/tests/Feature/ProfileEmailValidationTest.php index 5a100722..9dca2122 100644 --- a/tests/Feature/ProfileEmailValidationTest.php +++ b/tests/Feature/ProfileEmailValidationTest.php @@ -25,9 +25,9 @@ 'email' => 'invalid-email', 'notification_settings' => [], 'per_page_setting' => [5], - ]) + ], 'form') ->call('submit') - ->assertHasFormErrors(['email']); + ->assertHasFormErrors(['email'], 'form'); }); test('profile form validates email uniqueness', function () { @@ -40,9 +40,9 @@ 'email' => 'taken@example.com', 'notification_settings' => [], 'per_page_setting' => [5], - ]) + ], 'form') ->call('submit') - ->assertHasFormErrors(['email']); + ->assertHasFormErrors(['email'], 'form'); }); test('user can keep their current email without validation error', function () { @@ -53,9 +53,9 @@ 'email' => $this->user->email, 'notification_settings' => [], 'per_page_setting' => [5], - ]) + ], 'form') ->call('submit') - ->assertHasNoFormErrors(); + ->assertHasNoFormErrors([], 'form'); expect($this->user->fresh()->email)->toBe('original@example.com'); }); @@ -70,7 +70,7 @@ 'email' => 'newemail@example.com', 'notification_settings' => [], 'per_page_setting' => [5], - ]) + ], 'form') ->call('submit'); $this->user->refresh(); @@ -91,7 +91,7 @@ 'email' => 'newemail@example.com', 'notification_settings' => [], 'per_page_setting' => [5], - ]) + ], 'form') ->call('submit') ->assertNotified(); }); @@ -183,9 +183,9 @@ 'email' => 'not-an-email', 'notification_settings' => [], 'per_page_setting' => [5], - ]) + ], 'form') ->call('submit') - ->assertHasFormErrors(['email']); + ->assertHasFormErrors(['email'], 'form'); expect($this->user->fresh()->email)->toBe('original@example.com'); });