Skip to content

Commit 67fb617

Browse files
authored
Merge pull request #337 from ploi/feature/ai-endpoint
Add AI-readable endpoint for items
2 parents d469b05 + e00b3a2 commit 67fb617

File tree

5 files changed

+227
-10
lines changed

5 files changed

+227
-10
lines changed

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,30 @@ public function boot()
215215
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=`
216216

217217

218+
## AI endpoint
219+
220+
Each item has an `/ai` endpoint that returns its data in a machine-readable format, useful for AI agents and automation.
221+
222+
```
223+
GET /projects/{project}/items/{item}/ai
224+
```
225+
226+
**Query parameters:**
227+
228+
| Parameter | Description | Example |
229+
|---|---|---|
230+
| `format` | Response format: `json` (default), `yml`/`yaml`, `markdown`/`md` | `?format=yml` |
231+
| `include[comments]` | Include public comments | `?include[comments]=1` |
232+
233+
**Examples:**
234+
235+
```
236+
/projects/1-bugs/items/2-bug-in-sites-overview/ai
237+
/projects/1-bugs/items/2-bug-in-sites-overview/ai?include[comments]=1
238+
/projects/1-bugs/items/2-bug-in-sites-overview/ai?format=markdown
239+
/projects/1-bugs/items/2-bug-in-sites-overview/ai?format=yml&include[comments]=1
240+
```
241+
218242
## Docker Support
219243

220244
### Getting up and running...

app/Http/Controllers/ItemController.php

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
use App\Models\Project;
77
use App\Enums\ItemActivity;
88
use Illuminate\Http\Request;
9+
use Illuminate\Http\Response;
10+
use Illuminate\Http\JsonResponse;
911
use App\Settings\GeneralSettings;
12+
use Symfony\Component\Yaml\Yaml;
1013
use Illuminate\Http\RedirectResponse;
1114
use Spatie\Activitylog\Models\Activity;
1215
use Filament\Notifications\Notification;
@@ -56,6 +59,67 @@ public function show($projectId, $itemId = null)
5659
]);
5760
}
5861

62+
public function ai(Request $request, $projectSlug, $itemSlug): JsonResponse|Response
63+
{
64+
$project = Project::query()->visibleForCurrentUser()->where('slug', $projectSlug)->firstOrFail();
65+
$item = $project->items()->visibleForCurrentUser()->where('slug', $itemSlug)->firstOrFail();
66+
67+
$data = [
68+
'title' => $item->title,
69+
'content' => $item->content,
70+
'board' => $item->board?->title,
71+
'project' => $project->title,
72+
'votes' => $item->total_votes,
73+
'tags' => $item->tags->pluck('name')->toArray(),
74+
];
75+
76+
$includes = $request->query('include', []);
77+
78+
if (!empty($includes['comments'])) {
79+
$data['comments'] = $item->comments()
80+
->with('user:id,name,username')
81+
->whereNull('parent_id')
82+
->where('private', false)
83+
->oldest()
84+
->get()
85+
->map(fn ($comment) => [
86+
'author' => $comment->user->name ?? $comment->user->username,
87+
'content' => $comment->content,
88+
'created_at' => $comment->created_at->toIso8601String(),
89+
])
90+
->toArray();
91+
}
92+
93+
return match ($request->query('format', 'json')) {
94+
'yml', 'yaml' => response(Yaml::dump($data, 4, 2), 200, ['Content-Type' => 'text/yaml']),
95+
'markdown', 'md' => response($this->toMarkdown($data), 200, ['Content-Type' => 'text/markdown']),
96+
default => response()->json($data),
97+
};
98+
}
99+
100+
protected function toMarkdown(array $data): string
101+
{
102+
$md = "# {$data['title']}\n\n";
103+
$md .= "**Project:** {$data['project']}";
104+
$md .= $data['board'] ? " | **Board:** {$data['board']}" : '';
105+
$md .= " | **Votes:** {$data['votes']}\n";
106+
107+
if (!empty($data['tags'])) {
108+
$md .= '**Tags:** ' . implode(', ', $data['tags']) . "\n";
109+
}
110+
111+
$md .= "\n---\n\n{$data['content']}\n";
112+
113+
if (!empty($data['comments'])) {
114+
$md .= "\n---\n\n## Comments\n\n";
115+
foreach ($data['comments'] as $comment) {
116+
$md .= "**{$comment['author']}** ({$comment['created_at']}):\n{$comment['content']}\n\n";
117+
}
118+
}
119+
120+
return $md;
121+
}
122+
59123
public function edit($id)
60124
{
61125
$item = auth()->user()->items()->findOrFail($id);

routes/web.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
Route::get('items/{item}', [ItemController::class, 'show'])->name('items.show');
3737
Route::get('items/{item}/edit', [ItemController::class, 'edit'])->name('items.edit');
3838
Route::get('projects/{project}/items/{item}', [ItemController::class, 'show'])->name('projects.items.show');
39+
Route::get('projects/{project}/items/{item}/ai', [ItemController::class, 'ai'])->name('projects.items.ai');
3940
Route::post('projects/{project}/items/{item}/vote', [ItemController::class, 'vote'])->middleware('authed')->name('projects.items.vote');
4041
Route::post('projects/{project}/items/{item}/update-board', [ItemController::class, 'updateBoard'])->middleware('authed')->name('projects.items.update-board');
4142
Route::get('projects/{project}/boards/{board}', [BoardsController::class, 'show'])->name('projects.boards.show');
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<?php
2+
3+
use App\Models\Item;
4+
use App\Models\User;
5+
use App\Models\Board;
6+
use App\Models\Comment;
7+
use App\Models\Project;
8+
9+
beforeEach(function () {
10+
Item::unsetEventDispatcher();
11+
12+
$this->user = createUser();
13+
$this->project = Project::factory()->create();
14+
$this->board = Board::factory()->create(['project_id' => $this->project->id]);
15+
$this->item = Item::factory()->create([
16+
'project_id' => $this->project->id,
17+
'board_id' => $this->board->id,
18+
'user_id' => $this->user,
19+
]);
20+
});
21+
22+
test('it returns item data as json by default', function () {
23+
$response = $this->get(route('projects.items.ai', [
24+
'project' => $this->project->slug,
25+
'item' => $this->item->slug,
26+
]));
27+
28+
$response->assertOk()
29+
->assertJsonStructure(['title', 'content', 'board', 'project', 'votes', 'tags'])
30+
->assertJsonMissing(['comments' => []])
31+
->assertJson([
32+
'title' => $this->item->title,
33+
'content' => $this->item->content,
34+
'board' => $this->board->title,
35+
'project' => $this->project->title,
36+
]);
37+
});
38+
39+
test('it excludes comments by default', function () {
40+
Comment::factory()->create([
41+
'item_id' => $this->item->id,
42+
'user_id' => $this->user->id,
43+
'private' => false,
44+
]);
45+
46+
$response = $this->get(route('projects.items.ai', [
47+
'project' => $this->project->slug,
48+
'item' => $this->item->slug,
49+
]));
50+
51+
$response->assertOk()
52+
->assertJsonMissingPath('comments');
53+
});
54+
55+
test('it includes public comments when requested via include[comments]=1', function () {
56+
$comment = Comment::factory()->create([
57+
'item_id' => $this->item->id,
58+
'user_id' => $this->user->id,
59+
'private' => false,
60+
]);
61+
62+
$response = $this->get(route('projects.items.ai', [
63+
'project' => $this->project->slug,
64+
'item' => $this->item->slug,
65+
'include' => ['comments' => 1],
66+
]));
67+
68+
$response->assertOk()
69+
->assertJsonCount(1, 'comments')
70+
->assertJsonPath('comments.0.content', $comment->content);
71+
});
72+
73+
test('it excludes private comments even when comments are included', function () {
74+
Comment::factory()->create([
75+
'item_id' => $this->item->id,
76+
'user_id' => $this->user->id,
77+
'private' => true,
78+
]);
79+
80+
$response = $this->get(route('projects.items.ai', [
81+
'project' => $this->project->slug,
82+
'item' => $this->item->slug,
83+
'include' => ['comments' => 1],
84+
]));
85+
86+
$response->assertOk()
87+
->assertJsonCount(0, 'comments');
88+
});
89+
90+
test('it returns yaml when format=yml', function () {
91+
$response = $this->get(route('projects.items.ai', [
92+
'project' => $this->project->slug,
93+
'item' => $this->item->slug,
94+
'format' => 'yml',
95+
]));
96+
97+
$response->assertOk()
98+
->assertHeader('Content-Type', 'text/yaml; charset=utf-8');
99+
100+
expect($response->getContent())->toContain('title:');
101+
});
102+
103+
test('it returns markdown when format=markdown', function () {
104+
$response = $this->get(route('projects.items.ai', [
105+
'project' => $this->project->slug,
106+
'item' => $this->item->slug,
107+
'format' => 'markdown',
108+
]));
109+
110+
$response->assertOk()
111+
->assertHeader('Content-Type', 'text/markdown; charset=utf-8');
112+
113+
expect($response->getContent())->toContain("# {$this->item->title}");
114+
});
115+
116+
test('it returns 404 for private items', function () {
117+
$item = Item::factory()->create([
118+
'project_id' => $this->project->id,
119+
'board_id' => $this->board->id,
120+
'user_id' => $this->user,
121+
'private' => true,
122+
]);
123+
124+
$this->get(route('projects.items.ai', [
125+
'project' => $this->project->slug,
126+
'item' => $item->slug,
127+
]))->assertNotFound();
128+
});

tests/Feature/ProfileEmailValidationTest.php

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@
2525
'email' => 'invalid-email',
2626
'notification_settings' => [],
2727
'per_page_setting' => [5],
28-
])
28+
], 'form')
2929
->call('submit')
30-
->assertHasFormErrors(['email']);
30+
->assertHasFormErrors(['email'], 'form');
3131
});
3232

3333
test('profile form validates email uniqueness', function () {
@@ -40,9 +40,9 @@
4040
'email' => 'taken@example.com',
4141
'notification_settings' => [],
4242
'per_page_setting' => [5],
43-
])
43+
], 'form')
4444
->call('submit')
45-
->assertHasFormErrors(['email']);
45+
->assertHasFormErrors(['email'], 'form');
4646
});
4747

4848
test('user can keep their current email without validation error', function () {
@@ -53,9 +53,9 @@
5353
'email' => $this->user->email,
5454
'notification_settings' => [],
5555
'per_page_setting' => [5],
56-
])
56+
], 'form')
5757
->call('submit')
58-
->assertHasNoFormErrors();
58+
->assertHasNoFormErrors([], 'form');
5959

6060
expect($this->user->fresh()->email)->toBe('original@example.com');
6161
});
@@ -70,7 +70,7 @@
7070
'email' => 'newemail@example.com',
7171
'notification_settings' => [],
7272
'per_page_setting' => [5],
73-
])
73+
], 'form')
7474
->call('submit');
7575

7676
$this->user->refresh();
@@ -91,7 +91,7 @@
9191
'email' => 'newemail@example.com',
9292
'notification_settings' => [],
9393
'per_page_setting' => [5],
94-
])
94+
], 'form')
9595
->call('submit')
9696
->assertNotified();
9797
});
@@ -183,9 +183,9 @@
183183
'email' => 'not-an-email',
184184
'notification_settings' => [],
185185
'per_page_setting' => [5],
186-
])
186+
], 'form')
187187
->call('submit')
188-
->assertHasFormErrors(['email']);
188+
->assertHasFormErrors(['email'], 'form');
189189

190190
expect($this->user->fresh()->email)->toBe('original@example.com');
191191
});

0 commit comments

Comments
 (0)