Point the public site to each project's TaskStream board
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled

A project with a board now carries its key, its address and the address
of the board's public showcase in the public API, so the website can show
its roadmap, releases and tickets for newcomers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 19:13:56 +02:00
parent b32d6bf001
commit 7e7d2847fe
6 changed files with 83 additions and 0 deletions

View File

@@ -567,6 +567,12 @@ El cliente está en [`TaskStreamClient`](app/Services/TaskStream/TaskStreamClien
y la lógica (clave, token, errores) en y la lógica (clave, token, errores) en
[`OpenInTaskStream`](app/Actions/Projects/OpenInTaskStream.php). [`OpenInTaskStream`](app/Actions/Projects/OpenInTaskStream.php).
En la API pública, un proyecto con tablero trae `taskstream` (`key`, `url`,
`web_url` y `showcase_url`). `showcase_url` es público en TaskStream y devuelve
su hoja de ruta, sus versiones, el sprint en curso y los tickets etiquetados
`good-first-issue`, para que la web los enseñe en la página del proyecto.
## API pública (para el frontend) ## API pública (para el frontend)
Sin autenticación y de solo lectura. Todo lo que devuelve ya está publicado en Sin autenticación y de solo lectura. Todo lo que devuelve ya está publicado en

View File

@@ -6,6 +6,7 @@ namespace App\Http\Resources\PublicApi;
use App\Models\Media; use App\Models\Media;
use App\Models\Project; use App\Models\Project;
use App\Services\TaskStream\TaskStreamClient;
use App\Support\ImagePayload; use App\Support\ImagePayload;
use App\Support\SiteUrls; use App\Support\SiteUrls;
use App\Support\TranslatablePayload; use App\Support\TranslatablePayload;
@@ -110,11 +111,30 @@ class PublicProjectResource extends JsonResource
// alive without anyone maintaining it. // alive without anyone maintaining it.
'repositories' => PublicRepositoryResource::collection($this->whenLoaded('repositories')), 'repositories' => PublicRepositoryResource::collection($this->whenLoaded('repositories')),
// Its board in TaskStream, when it has one: the public site reads
// the roadmap, releases and tickets for newcomers from there.
'taskstream' => $this->taskstream_key === null ? null : $this->taskstream(),
'alternates' => SiteUrls::alternates($this->resource, 'project'), 'alternates' => SiteUrls::alternates($this->resource, 'project'),
'path' => SiteUrls::alternates($this->resource, 'project')[$locale] ?? null, 'path' => SiteUrls::alternates($this->resource, 'project')[$locale] ?? null,
]; ];
} }
/**
* @return array{key: string, url: string, web_url: string, showcase_url: string}
*/
protected function taskstream(): array
{
$client = app(TaskStreamClient::class);
return [
'key' => $this->taskstream_key,
'url' => $client->projectUrl($this->taskstream_key),
'web_url' => $client->webUrl(),
'showcase_url' => $client->showcaseUrl($this->taskstream_key),
];
}
/** /**
* The media row when the caller loaded it, and null otherwise — never a * The media row when the caller loaded it, and null otherwise — never a
* lazy load, because this runs once per row in a listing. * lazy load, because this runs once per row in a listing.

View File

@@ -85,4 +85,21 @@ class TaskStreamClient
{ {
return "{$this->webUrl}/p/{$key}"; return "{$this->webUrl}/p/{$key}";
} }
/**
* The public summary of a board (roadmap, releases, sprint, tickets for
* newcomers). Anyone may read it, so the public site can ask directly.
*/
public function showcaseUrl(string $key): string
{
return "{$this->apiUrl}/api/explore/projects/{$key}/showcase";
}
/**
* Where the paths of a showcase (`/p/KEY/issues/KEY-3`) are opened.
*/
public function webUrl(): string
{
return $this->webUrl;
}
} }

View File

@@ -241,6 +241,10 @@ export interface PublicProject {
lead: PublicMember; lead: PublicMember;
calls: PublicCall[]; calls: PublicCall[];
repositories: PublicRepository[]; repositories: PublicRepository[];
/**
* Its board in TaskStream, if it has one. `showcase_url` answers without a token: roadmap, releases, the sprint under way and tickets labelled good-first-issue.
*/
taskstream?: { key: string; url: string; web_url: string; showcase_url: string };
alternates: Alternates; alternates: Alternates;
path?: string; path?: string;
} }

View File

@@ -5041,6 +5041,15 @@ components:
repositories: repositories:
type: array type: array
items: { $ref: "#/components/schemas/PublicRepository" } items: { $ref: "#/components/schemas/PublicRepository" }
taskstream:
type: object
nullable: true
description: "Its board in TaskStream, if it has one. `showcase_url` answers without a token: roadmap, releases, the sprint under way and tickets labelled good-first-issue."
properties:
key: { type: string, example: WEB }
url: { type: string, format: uri }
web_url: { type: string, format: uri }
showcase_url: { type: string, format: uri }
alternates: { $ref: "#/components/schemas/Alternates" } alternates: { $ref: "#/components/schemas/Alternates" }
path: { type: string, nullable: true, example: /proyectos/mi-web } path: { type: string, nullable: true, example: /proyectos/mi-web }

View File

@@ -9,6 +9,7 @@ use App\Models\Member;
use App\Models\Project; use App\Models\Project;
use App\Models\Repository; use App\Models\Repository;
use App\Models\Technology; use App\Models\Technology;
use App\Services\TaskStream\TaskStreamClient;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase; use Tests\TestCase;
@@ -63,6 +64,32 @@ class PublicApiTest extends TestCase
$this->getJson('/api/public/v1/projects/el-meu-projecte')->assertNotFound(); $this->getJson('/api/public/v1/projects/el-meu-projecte')->assertNotFound();
} }
#[Test]
public function it_points_to_the_board_in_taskstream_when_there_is_one(): void
{
config([
'services.taskstream.api_url' => 'https://api.taskstream.test',
'services.taskstream.web_url' => 'https://taskstream.test',
]);
$this->app->forgetInstance(TaskStreamClient::class);
Project::factory()->published()->create([
'slug' => ['es' => 'con-tablero'],
'taskstream_key' => 'WEB',
]);
Project::factory()->published()->create(['slug' => ['es' => 'sin-tablero']]);
$this->getJson('/api/public/v1/projects/con-tablero')
->assertOk()
->assertJsonPath('data.taskstream', [
'key' => 'WEB',
'url' => 'https://taskstream.test/p/WEB',
'web_url' => 'https://taskstream.test',
'showcase_url' => 'https://api.taskstream.test/api/explore/projects/WEB/showcase',
]);
$this->getJson('/api/public/v1/projects/sin-tablero')
->assertJsonPath('data.taskstream', null);
}
#[Test] #[Test]
public function it_returns_hreflang_alternates_for_every_translated_locale(): void public function it_returns_hreflang_alternates_for_every_translated_locale(): void
{ {