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);
}
}