Open a TaskStream board when a project is created through the API
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

`taskstream: true` on POST /v1/projects also creates the project in
TaskStream, public, as the caller (their Pireon token is passed on), and
the answer carries `taskstream_key` and `taskstream_url`. The key is
`taskstream_key` or one made from the default-language slug.

The board is opened before the project is saved: if TaskStream refuses
it, nothing is created here and the reason comes back as a validation
error (502 when TaskStream does not answer). Machine tokens need the
server's own TASKSTREAM_TOKEN. Updates ignore the flag, and duplicates do
not inherit the link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 21:12:36 +02:00
parent 9390db18c2
commit d30dd971d7
15 changed files with 621 additions and 10 deletions

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Actions\Projects;
use App\Models\Project;
use App\Models\User;
use App\Services\TaskStream\TaskStreamClient;
use App\Services\TaskStream\TaskStreamException;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* Opens a board in TaskStream for a project that is about to be created
* here, and points the project at it.
*
* It runs before the project is saved: if TaskStream says no, nothing is
* created here either, and the caller gets the reason to fix and retry.
* The board is public, like the project it tracks is meant to be.
*/
class OpenInTaskStream
{
/** TaskStream's rule for a project key. */
public const KEY_PATTERN = '/^[A-Z][A-Z0-9]{1,9}$/';
public function __construct(protected TaskStreamClient $client) {}
/**
* @param string|null $bearer The token the caller presented here.
* @param string|null $key The key asked for; derived from the slug when null.
*
* @throws ValidationException when the caller can fix the request
*/
public function handle(Project $project, User $user, ?string $bearer, ?string $key = null): void
{
$locale = (string) config('locales.default');
$key = $key !== null ? Str::upper($key) : $this->keyFrom((string) $project->getTranslation('slug', $locale, false));
if ($key === null) {
throw ValidationException::withMessages(['taskstream_key' => __('rules.taskstream.key_needed')]);
}
try {
$created = $this->client->createProject(
token: $this->token($user, $bearer),
key: $key,
name: Str::limit((string) $project->getTranslation('title', $locale, false), 80, '…'),
description: $project->getTranslation('tagline', $locale, false) ?: null,
public: true,
);
} catch (TaskStreamException $e) {
throw $this->explain($e);
}
$project->forceFill([
'taskstream_project_id' => $created['id'],
'taskstream_key' => $created['key'],
]);
}
/**
* A Pireon token is passed on, so the board belongs to whoever asked. A
* machine token is this application's alone and means nothing there.
*/
protected function token(User $user, ?string $bearer): string
{
$token = $user->currentAccessToken() === null ? $bearer : $this->client->serviceToken();
if (blank($token)) {
throw ValidationException::withMessages(['taskstream' => __('rules.taskstream.machine')]);
}
return $token;
}
/**
* A key in TaskStream's style: one word gives its first ten characters
* ("catalogo" → CATALOGO), several give their initials ("mi-web-nueva"
* → MWN). Null when nothing usable is left.
*/
public function keyFrom(string $slug): ?string
{
$words = array_values(array_filter(preg_split('/[^A-Z0-9]+/', Str::upper(Str::ascii($slug))) ?: []));
$key = count($words) === 1
? $words[0]
: implode('', array_map(fn (string $word) => $word[0], $words));
// It must start with a letter.
$key = substr((string) preg_replace('/^[0-9]+/', '', $key), 0, 10);
return preg_match(self::KEY_PATTERN, $key) === 1 ? $key : null;
}
protected function explain(TaskStreamException $e): ValidationException|HttpException
{
if ($e->isRefusal()) {
return ValidationException::withMessages([
'taskstream_key' => __('rules.taskstream.refused', ['reason' => $e->getMessage()]),
]);
}
if ($e->isUnauthorised()) {
return ValidationException::withMessages(['taskstream' => __('rules.taskstream.unauthorised')]);
}
return new HttpException(502, __('rules.taskstream.unavailable'), $e);
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Actions\Projects\OpenInTaskStream;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\ProjectRequest;
use App\Http\Resources\ProjectResource;
@@ -45,11 +46,21 @@ class ProjectController extends Controller
return ProjectResource::collection($projects);
}
public function store(ProjectRequest $request): JsonResponse
/**
* With `taskstream: true`, the project's board is opened in TaskStream
* first; if that fails, nothing is created here either.
*/
public function store(ProjectRequest $request, OpenInTaskStream $openInTaskStream): JsonResponse
{
$this->authorize('create', Project::class);
$project = $this->save(new Project, $request);
$project = $this->fill(new Project, $request);
if ($request->boolean('taskstream')) {
$openInTaskStream->handle($project, $request->user(), $request->bearerToken(), $request->validated('taskstream_key'));
}
$project = $this->persist($project, $request);
return ProjectResource::make($project)->response()->setStatusCode(201);
}
@@ -65,7 +76,7 @@ class ProjectController extends Controller
{
$this->authorize('update', $project);
return ProjectResource::make($this->save($project, $request));
return ProjectResource::make($this->persist($this->fill($project, $request), $request));
}
public function destroy(Project $project): JsonResponse
@@ -86,20 +97,24 @@ class ProjectController extends Controller
return ProjectResource::make($project->load(self::WITH));
}
protected function fill(Project $project, ProjectRequest $request): Project
{
return $project->fill(TranslatablePayload::merge(
$project,
collect($request->validated())->except(['technology_ids', 'service_ids', 'members', 'taskstream', 'taskstream_key'])->all(),
$project->translatable,
));
}
/**
* The record and its relations in one transaction: a project that saved
* but lost its team halfway would be worse than one that did not save.
*/
protected function save(Project $project, ProjectRequest $request): Project
protected function persist(Project $project, ProjectRequest $request): Project
{
$data = $request->validated();
DB::transaction(function () use ($project, $data): void {
$project->fill(TranslatablePayload::merge(
$project,
collect($data)->except(['technology_ids', 'service_ids', 'members'])->all(),
$project->translatable,
));
$project->save();
if (array_key_exists('technology_ids', $data)) {

View File

@@ -19,6 +19,9 @@ use Illuminate\Validation\Validator;
*
* Relations travel as ids and are replaced whole when sent: `technology_ids`,
* `service_ids`, and `members` with each one's role on the project.
*
* `taskstream` (on creation only) opens a public board in TaskStream for the
* project, keyed `taskstream_key` or one derived from the slug.
*/
class ProjectRequest extends FormRequest
{
@@ -74,6 +77,11 @@ class ProjectRequest extends FormRequest
'members' => ['sometimes', 'array'],
'members.*.id' => ['required', 'integer', 'distinct', Rule::exists('members', 'id')->whereNull('deleted_at')],
'members.*.role' => ['nullable', 'string', 'max:120'],
// Only when creating; a client PATCHing back what it read sends
// the key it got, which is ignored rather than refused.
'taskstream' => [$creating ? 'sometimes' : 'exclude', 'boolean'],
'taskstream_key' => [$creating ? 'sometimes' : 'exclude', 'nullable', 'string', 'regex:/^[A-Za-z][A-Za-z0-9]{1,9}$/'],
];
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Project;
use App\Services\TaskStream\TaskStreamClient;
use App\Support\ImagePayload;
use App\Support\TranslatablePayload;
use Illuminate\Http\Request;
@@ -33,6 +34,10 @@ class ProjectResource extends JsonResource
'is_featured' => (bool) $this->is_featured,
'needs_help' => (bool) $this->needs_help,
'demo_url' => $this->demo_url,
'taskstream_key' => $this->taskstream_key,
'taskstream_url' => $this->taskstream_key !== null
? app(TaskStreamClient::class)->projectUrl($this->taskstream_key)
: null,
// The stored path, which is what a client PATCHes back; what was
// derived from it travels alongside, as on a page.
'cover_image' => $this->cover_image,

View File

@@ -13,6 +13,7 @@ use App\Models\User;
use App\Services\Git\GitManager;
use App\Services\Pireon\PireonClient;
use App\Services\Pireon\TokenStore;
use App\Services\TaskStream\TaskStreamClient;
use App\Support\Audit\Auditor;
use App\Support\Webhooks\Webhooks;
use Illuminate\Auth\Events\Login;
@@ -43,6 +44,8 @@ class AppServiceProvider extends ServiceProvider
// Resolves git hosting drivers; see config/git.php.
$this->app->singleton(GitManager::class);
$this->app->singleton(TaskStreamClient::class, fn () => TaskStreamClient::fromConfig(config('services.taskstream')));
}
/**

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Services\TaskStream;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
/**
* The few calls this application makes to TaskStream's REST API.
*
* Every call is made as somebody: TaskStream accepts the same Pireon tokens
* this application does, and whoever's token it is becomes the owner of
* what is created.
*/
class TaskStreamClient
{
public function __construct(
protected string $apiUrl,
protected string $webUrl,
protected ?string $serviceToken = null,
protected int $timeout = 10,
) {}
/**
* @param array<string, mixed> $config
*/
public static function fromConfig(array $config): self
{
return new self(
apiUrl: rtrim((string) ($config['api_url'] ?? ''), '/'),
webUrl: rtrim((string) ($config['web_url'] ?? ''), '/'),
serviceToken: filled($config['token'] ?? null) ? $config['token'] : null,
timeout: (int) ($config['timeout'] ?? 10),
);
}
/**
* The token for requests that do not carry a Pireon token of their own.
*/
public function serviceToken(): ?string
{
return $this->serviceToken;
}
/**
* @return array{id: string, key: string}
*
* @throws TaskStreamException
*/
public function createProject(string $token, string $key, string $name, ?string $description, bool $public): array
{
try {
$response = Http::withToken($token)
->acceptJson()
->timeout($this->timeout)
->post("{$this->apiUrl}/api/projects", array_filter([
'key' => $key,
'name' => $name,
'description' => $description,
'isPublic' => $public,
], fn ($value) => $value !== null));
} catch (ConnectionException $e) {
throw TaskStreamException::unreachable($this->apiUrl, $e);
}
if ($response->failed()) {
// Nest answers `message` as a string, or as a list from validation.
$message = $response->json('message');
throw new TaskStreamException(
is_array($message) ? implode(' ', $message) : (string) ($message ?: $response->reason()),
status: $response->status(),
);
}
return ['id' => (string) $response->json('id'), 'key' => (string) $response->json('key')];
}
/**
* Where people open a project's board.
*/
public function projectUrl(string $key): string
{
return "{$this->webUrl}/p/{$key}";
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Services\TaskStream;
use RuntimeException;
use Throwable;
class TaskStreamException extends RuntimeException
{
public function __construct(
string $message,
public readonly ?int $status = null,
?Throwable $previous = null,
) {
parent::__construct($message, $status ?? 0, $previous);
}
/**
* TaskStream refused what it was asked for (a key in use, a bad name):
* the caller can fix it and try again.
*/
public function isRefusal(): bool
{
return in_array($this->status, [400, 409, 422], true);
}
/**
* The token was not good enough there: expired, or without access.
*/
public function isUnauthorised(): bool
{
return in_array($this->status, [401, 403], true);
}
public static function unreachable(string $url, ?Throwable $previous = null): self
{
return new self("TaskStream could not be reached at [{$url}].", previous: $previous);
}
}

View File

@@ -32,10 +32,12 @@ final class Duplicator
];
/**
* Never copied: where it stands rather than what it says.
* Never copied: where it stands rather than what it says — and the
* TaskStream board, which belongs to the original alone.
*/
protected const RESET = [
'published_at', 'unpublish_at', 'review_requested_at', 'review_requested_by', 'reviewer_id',
'taskstream_project_id', 'taskstream_key',
];
public static function supports(Model $record): bool