Open a TaskStream board when a project is created through the API
`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:
111
app/Actions/Projects/OpenInTaskStream.php
Normal file
111
app/Actions/Projects/OpenInTaskStream.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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}$/'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
88
app/Services/TaskStream/TaskStreamClient.php
Normal file
88
app/Services/TaskStream/TaskStreamClient.php
Normal 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}";
|
||||
}
|
||||
}
|
||||
41
app/Services/TaskStream/TaskStreamException.php
Normal file
41
app/Services/TaskStream/TaskStreamException.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -35,4 +35,23 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
| TaskStream, the collective's issue tracker. Creating a project here can
|
||||
| open its board there too (`taskstream: true`); it is made public.
|
||||
*/
|
||||
'taskstream' => [
|
||||
'api_url' => env('TASKSTREAM_API_URL', 'https://api.taskstream.pireon.dev'),
|
||||
'web_url' => env('TASKSTREAM_WEB_URL', 'https://taskstream.pireon.dev'),
|
||||
|
||||
/*
|
||||
| TaskStream only understands Pireon tokens. A person signed in with
|
||||
| one has it forwarded, so the board is theirs; a machine token means
|
||||
| nothing there, so its requests use this one instead — a Pireon
|
||||
| personal access token. Without it, they cannot ask for a board.
|
||||
*/
|
||||
'token' => env('TASKSTREAM_TOKEN'),
|
||||
|
||||
'timeout' => (int) env('TASKSTREAM_HTTP_TIMEOUT', 10),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* The board a project has in TaskStream, when it was opened from here: its
|
||||
* id there, and the key its address is made from.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
$table->string('taskstream_project_id', 64)->nullable()->after('demo_url');
|
||||
$table->string('taskstream_key', 10)->nullable()->after('taskstream_project_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
$table->dropColumn(['taskstream_project_id', 'taskstream_key']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -48,6 +48,14 @@ return [
|
||||
'event' => '«:event» no és un esdeveniment al qual subscriure\'s.',
|
||||
],
|
||||
|
||||
'taskstream' => [
|
||||
'machine' => 'TaskStream només entén tokens de Pireon: entra amb un, o configura TASKSTREAM_TOKEN per als tokens de màquina.',
|
||||
'key_needed' => 'No es pot treure una clau de l\'slug: indica\'n una a «taskstream_key» (de 2 a 10 lletres o xifres, començant per lletra).',
|
||||
'refused' => 'TaskStream no ha creat el projecte: :reason',
|
||||
'unauthorised' => 'TaskStream ha rebutjat el token: potser ha caducat o no hi té accés.',
|
||||
'unavailable' => 'TaskStream no respon ara mateix; no s\'ha creat res, torna-ho a provar d\'aquí a una estona.',
|
||||
],
|
||||
|
||||
'tokens' => [
|
||||
'ability' => '«:ability» no és un permís que tinguis, així que un token teu no el pot tenir.',
|
||||
'machine' => 'Els tokens els gestiona una persona, no un altre token.',
|
||||
|
||||
@@ -48,6 +48,14 @@ return [
|
||||
'event' => '":event" is not an event that can be subscribed to.',
|
||||
],
|
||||
|
||||
'taskstream' => [
|
||||
'machine' => 'TaskStream only understands Pireon tokens: sign in with one, or set TASKSTREAM_TOKEN for machine tokens.',
|
||||
'key_needed' => 'No key can be made from the slug: give one in "taskstream_key" (2 to 10 letters or digits, starting with a letter).',
|
||||
'refused' => 'TaskStream did not create the project: :reason',
|
||||
'unauthorised' => 'TaskStream rejected the token: it may have expired or lack access.',
|
||||
'unavailable' => 'TaskStream is not answering right now; nothing was created, try again in a while.',
|
||||
],
|
||||
|
||||
'tokens' => [
|
||||
'ability' => '":ability" is not a permission you hold, so a token of yours cannot hold it.',
|
||||
'machine' => 'Tokens are managed by a person, not by another token.',
|
||||
|
||||
@@ -48,6 +48,14 @@ return [
|
||||
'event' => '«:event» no es un evento al que suscribirse.',
|
||||
],
|
||||
|
||||
'taskstream' => [
|
||||
'machine' => 'TaskStream solo entiende tokens de Pireon: entra con uno, o configura TASKSTREAM_TOKEN para los tokens de máquina.',
|
||||
'key_needed' => 'No se puede sacar una clave del slug: indica una en «taskstream_key» (de 2 a 10 letras o cifras, empezando por letra).',
|
||||
'refused' => 'TaskStream no ha creado el proyecto: :reason',
|
||||
'unauthorised' => 'TaskStream ha rechazado el token: puede que haya caducado o que no tenga acceso.',
|
||||
'unavailable' => 'TaskStream no responde ahora mismo; no se ha creado nada, prueba otra vez en un rato.',
|
||||
],
|
||||
|
||||
'tokens' => [
|
||||
'ability' => '«:ability» no es un permiso que tengas, así que un token tuyo no puede tenerlo.',
|
||||
'machine' => 'Los tokens los gestiona una persona, no otro token.',
|
||||
|
||||
@@ -1566,6 +1566,16 @@ paths:
|
||||
`technology_ids`, `service_ids`, and `members` with each one's role.
|
||||
`published_at` and `unpublish_at` require `projects.publish`. For a
|
||||
cover, upload it with `POST /v1/media` and `for: projects`.
|
||||
|
||||
With `taskstream: true` the project also gets a **public** board in
|
||||
TaskStream, the collective's issue tracker, and the answer carries its
|
||||
link in `taskstream_url`. Its key is `taskstream_key`, or one made from
|
||||
the slug in the default language (one word: its first ten characters;
|
||||
several: their initials). The board is opened first: if TaskStream
|
||||
refuses it (a key in use, say), nothing is created here either and the
|
||||
reason comes back as a validation error. It is opened as the caller,
|
||||
whose Pireon token is passed on; a machine token cannot ask for one
|
||||
unless the server has a `TASKSTREAM_TOKEN` of its own.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -1582,6 +1592,8 @@ paths:
|
||||
data: { $ref: "#/components/schemas/Project" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"422": { $ref: "#/components/responses/ValidationError" }
|
||||
"502":
|
||||
description: TaskStream did not answer; nothing was created.
|
||||
|
||||
/v1/projects/{project}:
|
||||
parameters:
|
||||
@@ -5503,6 +5515,8 @@ components:
|
||||
is_featured: { type: boolean }
|
||||
needs_help: { type: boolean }
|
||||
demo_url: { type: string, nullable: true, format: uri }
|
||||
taskstream_key: { type: string, nullable: true, description: "The project's key in TaskStream, when it has a board there." }
|
||||
taskstream_url: { type: string, nullable: true, format: uri, readOnly: true, description: Where its TaskStream board opens. }
|
||||
cover_image: { type: string, nullable: true, description: The stored path — what a client PATCHes back. }
|
||||
cover_image_media:
|
||||
allOf: [{ $ref: "#/components/schemas/Image" }]
|
||||
@@ -5549,6 +5563,14 @@ components:
|
||||
is_featured: { type: boolean }
|
||||
needs_help: { type: boolean }
|
||||
demo_url: { type: string, format: uri, nullable: true }
|
||||
taskstream:
|
||||
type: boolean
|
||||
description: On creation only. Also open a public board for it in TaskStream; see `POST /v1/projects`.
|
||||
taskstream_key:
|
||||
type: string
|
||||
nullable: true
|
||||
pattern: "^[A-Za-z][A-Za-z0-9]{1,9}$"
|
||||
description: On creation only, with `taskstream`. The key in TaskStream; made from the slug when left out.
|
||||
cover_image: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=projects`. }
|
||||
started_at: { type: string, format: date, nullable: true }
|
||||
ended_at: { type: string, format: date, nullable: true }
|
||||
|
||||
244
tests/Feature/Api/TaskStreamProjectTest.php
Normal file
244
tests/Feature/Api/TaskStreamProjectTest.php
Normal file
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Actions\Projects\OpenInTaskStream;
|
||||
use App\Models\ApiToken;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use App\Services\TaskStream\TaskStreamClient;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* `taskstream: true` on a new project opens its board in TaskStream too, and
|
||||
* the answer carries the link.
|
||||
*/
|
||||
class TaskStreamProjectTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected string $taskstream = 'https://api.taskstream.test';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
|
||||
config([
|
||||
'services.taskstream.api_url' => $this->taskstream,
|
||||
'services.taskstream.web_url' => 'https://taskstream.test',
|
||||
'services.taskstream.token' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function editor(): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->syncRoles(['editor']);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskStream creating whatever it is asked for, or answering $status.
|
||||
*/
|
||||
protected function fakeTaskStream(int $status = 201, array $body = []): void
|
||||
{
|
||||
Http::fake([
|
||||
"{$this->taskstream}/api/projects" => fn (Request $request) => Http::response(
|
||||
$status === 201 ? ['id' => 'ts-1', 'key' => $request['key'], 'version' => 1] : $body,
|
||||
$status,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function sentToTaskStream(): int
|
||||
{
|
||||
return Http::recorded(fn (Request $request) => str_starts_with($request->url(), $this->taskstream))->count();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_new_project_gets_a_public_board_as_the_person_who_asked(): void
|
||||
{
|
||||
$user = $this->editor();
|
||||
$this->fakeTaskStream();
|
||||
$this->withHeaders($this->apiHeadersFor($user));
|
||||
|
||||
$this->postJson('/api/v1/projects', [
|
||||
'title' => ['es' => 'Catálogo abierto'],
|
||||
'slug' => ['es' => 'catalogo'],
|
||||
'tagline' => ['es' => 'Una herramienta pequeña.'],
|
||||
'taskstream' => true,
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.taskstream_key', 'CATALOGO')
|
||||
->assertJsonPath('data.taskstream_url', 'https://taskstream.test/p/CATALOGO');
|
||||
|
||||
Http::assertSent(fn (Request $request) => $request->url() === "{$this->taskstream}/api/projects"
|
||||
&& $request->hasHeader('Authorization', 'Bearer token-for-'.$user->oauth_id)
|
||||
&& $request->data() === [
|
||||
'key' => 'CATALOGO',
|
||||
'name' => 'Catálogo abierto',
|
||||
'description' => 'Una herramienta pequeña.',
|
||||
'isPublic' => true,
|
||||
]);
|
||||
|
||||
$project = Project::query()->sole();
|
||||
$this->assertSame('ts-1', $project->taskstream_project_id);
|
||||
$this->assertSame('CATALOGO', $project->taskstream_key);
|
||||
|
||||
$this->getJson("/api/v1/projects/{$project->id}")
|
||||
->assertJsonPath('data.taskstream_url', 'https://taskstream.test/p/CATALOGO');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_key_can_be_chosen(): void
|
||||
{
|
||||
$this->fakeTaskStream();
|
||||
$this->withHeaders($this->apiHeadersFor($this->editor()));
|
||||
|
||||
$this->postJson('/api/v1/projects', [
|
||||
'title' => ['es' => 'Web'],
|
||||
'slug' => ['es' => 'web'],
|
||||
'taskstream' => true,
|
||||
'taskstream_key' => 'web2',
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.taskstream_key', 'WEB2');
|
||||
|
||||
$this->postJson('/api/v1/projects', [
|
||||
'title' => ['es' => 'Otra'],
|
||||
'slug' => ['es' => 'otra'],
|
||||
'taskstream' => true,
|
||||
'taskstream_key' => '1-no',
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('taskstream_key');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function without_the_flag_taskstream_is_left_alone(): void
|
||||
{
|
||||
$this->fakeTaskStream();
|
||||
$this->withHeaders($this->apiHeadersFor($this->editor()));
|
||||
|
||||
$this->postJson('/api/v1/projects', [
|
||||
'title' => ['es' => 'Solo aquí'],
|
||||
'slug' => ['es' => 'solo-aqui'],
|
||||
'taskstream_key' => 'SOLO',
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.taskstream_key', null)
|
||||
->assertJsonPath('data.taskstream_url', null);
|
||||
|
||||
$this->assertSame(0, $this->sentToTaskStream());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_refusal_from_taskstream_creates_nothing_here(): void
|
||||
{
|
||||
$this->fakeTaskStream(409, ['statusCode' => 409, 'message' => 'Project key CATALOGO is already in use']);
|
||||
$this->withHeaders($this->apiHeadersFor($this->editor()));
|
||||
|
||||
$response = $this->postJson('/api/v1/projects', [
|
||||
'title' => ['es' => 'Catálogo'],
|
||||
'slug' => ['es' => 'catalogo'],
|
||||
'taskstream' => true,
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('taskstream_key');
|
||||
|
||||
// Whatever language the person reads, TaskStream's reason is passed on.
|
||||
$this->assertStringContainsString('Project key CATALOGO is already in use', $response->json('errors.taskstream_key.0'));
|
||||
|
||||
$this->assertSame(0, Project::withTrashed()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function taskstream_being_down_creates_nothing_here(): void
|
||||
{
|
||||
Http::fake(["{$this->taskstream}/*" => Http::failedConnection()]);
|
||||
$this->withHeaders($this->apiHeadersFor($this->editor()));
|
||||
|
||||
$this->postJson('/api/v1/projects', [
|
||||
'title' => ['es' => 'Catálogo'],
|
||||
'slug' => ['es' => 'catalogo'],
|
||||
'taskstream' => true,
|
||||
])->assertStatus(502);
|
||||
|
||||
$this->assertSame(0, Project::withTrashed()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_machine_token_needs_the_servers_own_taskstream_token(): void
|
||||
{
|
||||
$this->fakeTaskStream();
|
||||
[, $plain] = ApiToken::issue($this->editor(), 'CI', ['projects.*'], now()->addDay());
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$plain, 'Accept' => 'application/json']);
|
||||
|
||||
$this->postJson('/api/v1/projects', [
|
||||
'title' => ['es' => 'Catálogo'],
|
||||
'slug' => ['es' => 'catalogo'],
|
||||
'taskstream' => true,
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('taskstream');
|
||||
|
||||
$this->assertSame(0, $this->sentToTaskStream());
|
||||
$this->assertSame(0, Project::withTrashed()->count());
|
||||
|
||||
// With one configured, that token is the one TaskStream sees — never ours.
|
||||
config(['services.taskstream.token' => 'pireon-service-token']);
|
||||
$this->app->forgetInstance(TaskStreamClient::class);
|
||||
|
||||
$this->postJson('/api/v1/projects', [
|
||||
'title' => ['es' => 'Catálogo'],
|
||||
'slug' => ['es' => 'catalogo'],
|
||||
'taskstream' => true,
|
||||
])->assertCreated();
|
||||
|
||||
Http::assertSent(fn (Request $request) => str_starts_with($request->url(), $this->taskstream)
|
||||
&& $request->hasHeader('Authorization', 'Bearer pireon-service-token'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_update_ignores_the_flag(): void
|
||||
{
|
||||
$this->fakeTaskStream();
|
||||
$this->withHeaders($this->apiHeadersFor($this->editor()));
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->patchJson("/api/v1/projects/{$project->id}", [
|
||||
'taskstream' => true,
|
||||
'taskstream_key' => 'OTRA',
|
||||
'needs_help' => true,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.taskstream_key', null);
|
||||
|
||||
$this->assertSame(0, $this->sentToTaskStream());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function keys_are_made_from_the_slug_in_taskstreams_style(): void
|
||||
{
|
||||
$action = app(OpenInTaskStream::class);
|
||||
|
||||
$this->assertSame('CATALOGO', $action->keyFrom('catalogo'));
|
||||
$this->assertSame('TRANSPAREN', $action->keyFrom('transparencia'));
|
||||
$this->assertSame('MWN', $action->keyFrom('mi-web-nueva'));
|
||||
$this->assertSame('ANO', $action->keyFrom('año-nuevo-ok'));
|
||||
$this->assertSame('WEB', $action->keyFrom('2026web'));
|
||||
$this->assertNull($action->keyFrom('2026-web'));
|
||||
$this->assertNull($action->keyFrom('x'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user