From 701fec8b8ae661e5b9572617f738faa8362c62a5 Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 4 Feb 2026 07:33:55 +0100 Subject: [PATCH 1/2] Add AI-readable endpoint for items Adds a /ai route to items that returns title, content, board, project, votes, and tags in a machine-readable format. Supports JSON (default), YAML, and Markdown output via ?format= query parameter. Comments can be optionally included via ?include[comments]=1. Co-Authored-By: Claude Opus 4.5 --- README.md | 24 ++++ app/Http/Controllers/ItemController.php | 64 +++++++++++ routes/web.php | 1 + tests/Feature/Item/ItemAiEndpointTest.php | 128 ++++++++++++++++++++++ 4 files changed, 217 insertions(+) create mode 100644 tests/Feature/Item/ItemAiEndpointTest.php 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(); +}); From e00b3a23a308f316da0ab7547105fc5f74566280 Mon Sep 17 00:00:00 2001 From: Dennis Date: Wed, 4 Feb 2026 07:38:03 +0100 Subject: [PATCH 2/2] Fix ProfileEmailValidationTest for Filament v4 Specify form name in fillForm/assertHasFormErrors calls since the Profile component implements both HasForms and HasTable, causing Filament v4 to default to the table's deferred filters form. Co-Authored-By: Claude Opus 4.5 --- tests/Feature/ProfileEmailValidationTest.php | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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'); });