Integracje

View on GitHub

Dodaj integrację typu import

Przećwiczony przykład: zamiana Linear z zablokowanej karty "coming soon" w katalogu w w pełni działającą integrację typu import, dokładnie tak, jak dziś działa Jira. To jest wejściowy/pull odpowiednik przewodnika 1 (integracje typu notify wysyłające webhooki, jak Discord/Slack) — zupełnie osobna oś, z własnym kontraktem, rejestrem i kształtem panelu ustawień. Jeśli jeszcze tego nie zrobiłeś, przeczytaj wstęp przewodnika 1, żeby rozróżnienie było jasne, zanim zaczniesz:

  • Integracje notify (Discord, Slack, ...) reagują na event domenowy (IssueCreated, CommentAdded, ...) i wypychają wiadomość na zewnątrz przez webhook. Jeden IntegrationNotifier na integrację, rozwiązywany przez IntegrationNotifierRegistry, wywoływany przez NotifyProjectIntegrationsListener.
  • Integracje import (dziś: Jira; Linear, Asana, Trello to na razie same wpisy w katalogu) ściągają issues/epiki/subtaski do Orbita z zewnętrznego systemu na żądanie użytkownika (przepływ "Connect" + "Import"), konwertując statusy/priorytety/etykiety/ hierarchię zewnętrznego systemu na własny, stały model Orbita. Jeden IntegrationImporter na integrację, rozwiązywany przez IntegrationImporterRegistry, wywoływany przez akcję kontrolera, która wysyła zakolejkowanego joba — nie ma tu eventu domenowego, na który trzeba by reagować, bo nic jeszcze nie wydarzyło się w Orbicie.

Linear ma już wpis w katalogu (kind: 'import', comingSoon: true, bez importConfig) i pod-opcje opisujące zachowanie import/sync, więc ten przewodnik pokrywa zarówno "wpis w katalogu już istnieje, trzeba mu tylko nadać importConfig i podłączyć backend" (każdy krok poniżej), jak i, w dopisku na końcu Kroku 1, co zrobić, jeśli startujesz bez żadnego wpisu w katalogu.

Jedna zasada, która ma największe znaczenie

Nigdy nie dodawaj schematu specyficznego dla jednej integracji. project_integrations.credentials (nieprzezroczysty, zaszyfrowany JSON), external_issue_links (deduplikacja/śledzenie) oraz integration_field_mappings (mapowanie status/priorytet/etykieta) są już generyczne dla każdego importera. Jeśli łapiesz się na tym, że sięgasz po tabelę linear_issue_links albo linear_field_mappings — zatrzymaj się, duplikujesz coś, co już istnieje. Dodanie Lineara powinno dotknąć zero nowych migracji. Jedyny naprawdę nowy kod to "jak rozmawiać z API Lineara" i "jak zmapować JSON-a Lineara na ExternalIssueDTO" — wszystko za tą granicą (ImportOrchestratorService, rozwiązywanie hierarchii przez Issue::parent_id, deduplikacja, mapowanie pól) jest już zbudowane i przetestowane na kształcie Jiry.

Krok 1 — Dodaj importConfig do wpisu w katalogu

Plik: resources/js/types/Integrations.ts

Wpis Lineara w katalogu już istnieje z kind: 'import', ale bez importConfig (na razie tylko Jira ma go wypełnionego). Nadaj mu go, zachowując dokładnie ten sam kształt, jakiego używa Jira:

{
    id: 'linear',
    name: 'Linear',
    vendor: 'By Linear Orbit, Inc.',
    category: 'Productivity',
    brand: 'linear',
    accentClassName: 'bg-[#5E6AD2]/15',
    websiteUrl: 'https://linear.app',
    description: 'Import and sync issues between Orbit and Linear.',
    overview:
        'Connect Linear to import existing issues or keep two teams working across both tools in sync, without losing history on either side.\n\n**What you get:**\n- One-time or ongoing issue import from Linear\n- Two-way status sync for linked issues\n- Cycle and priority mapping',
    previewSamples: [
        {
            title: '12 issues imported from Linear team "Core"',
            time: 'Just now',
        },
        {
            title: 'Status synced from Linear for issue #142',
            time: '18m ago',
        },
    ],
    subOptions: [
        {
            id: 'issue-import',
            title: 'Issue import',
            description: 'Import existing Linear issues into a project.',
        },
        {
            id: 'status-sync',
            title: 'Status sync',
            description: 'Keep issue status in sync in both directions.',
        },
    ],
    kind: 'import',
    importConfig: {
        credentialFields: [
            {
                id: 'api_key',
                label: 'Linear API key',
                type: 'password',
            },
        ],
        mappingTypes: ['status', 'priority', 'label'],
    },
    comingSoon: true, // <- odwróć to w ostatnim kroku, gdy wszystko będzie podłączone
},

Uwagi:

  • importConfig.credentialFields[].id staje się kluczem wewnątrz project_integrations.credentials (zobacz Krok 5) — Linear potrzebuje tylko jednego pola (osobistego klucza API, w przeciwieństwie do trzech pól Jiry), a WorkspaceSettingsImportPanel.tsx renderuje tyle pól, ile tu wymienisz, bez żadnych dalszych zmian na froncie.
  • importConfig.mappingTypes decyduje, które tabele mapowań w panelu się renderują. Tylko status i priority faktycznie pokazują dziś tabelę, bo to dwa rodzaje metadanych zewnętrznych, jakie IntegrationImporter::fetchMappingMetadata() może z góry enumerować (zobacz Krok 3) — mapowania label stosują się automatycznie w trakcie importu (niezmapowana zewnętrzna etykieta/komponent jest po prostu pomijana, bo enum IssueLabel w Orbicie to mały, stały zbiór), na razie nie ma dla nich UI przed importem.
  • Istniejące subOptions (issue-import, status-sync) to na razie czysto kosmetyczny opis dla wpisów kind: 'import' — nie są czytane przez żaden kod backendu (w przeciwieństwie do subOptions integracji kind: 'notify', które mapują się 1:1 na klucze ProjectIntegration.options).

Jeśli startujesz bez żadnego wpisu w katalogu: dodaj nowego członka unii IntegrationId, case w BrandIcon (zobacz przewodnik 1, Krok 1) oraz cały ten obiekt z kind: 'import' od zera.

Krok 2 — Napisz klienta API

Nowy plik: app/Services/Integrations/Linear/LinearApiClient.php

Cienki wrapper wokół API HTTP zewnętrznego systemu — nic tutaj nie wie o modelu domenowym Orbita. Zbuduj swojego w tym samym kształcie, co poniższy realny, kompletny wzorzec (to jest faktyczny kod w repo dziś, nie szablon — przeczytaj go, potem dostosuj, nie kopiuj namespace'u/nazwy klasy Jira):

Wzorzec: app/Services/Integrations/Jira/JiraApiClient.php

<?php

namespace App\Services\Integrations\Jira;

use App\Models\ProjectIntegration;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;

/**
 * Thin Jira Cloud REST API (v3) wrapper, Basic Auth (email + API token) per
 * ProjectIntegration::credentials. Never logs the credentials themselves or
 * a raw response body (which may carry sensitive issue content) — only the
 * request path and response status, mirroring SendWebhookNotificationJob's
 * secret-redaction convention.
 */
class JiraApiClient
{
    public function testConnection(ProjectIntegration $projectIntegration): bool
    {
        try {
            return $this->client($projectIntegration)->get('/rest/api/3/myself')->successful();
        } catch (ConnectionException) {
            return false;
        }
    }

    public function getIssueTypes(ProjectIntegration $projectIntegration): array
    {
        return $this->getJson($projectIntegration, '/rest/api/3/issuetype');
    }

    public function getStatuses(ProjectIntegration $projectIntegration): array
    {
        return $this->getJson($projectIntegration, '/rest/api/3/status');
    }

    public function getPriorities(ProjectIntegration $projectIntegration): array
    {
        return $this->getJson($projectIntegration, '/rest/api/3/priority');
    }

    /**
     * One page of a JQL search via /rest/api/3/search/jql — the endpoint
     * that replaced the deprecated GET/POST /rest/api/3/search (which now
     * returns HTTP 410 Gone on every Jira Cloud site). That old endpoint's
     * offset pagination (startAt/total) is gone too: this one is cursor-based
     * — pass back whatever "nextPageToken" the previous page returned, and
     * there is no next page once the response omits that key entirely.
     * Expands each issue's parent link so the caller (JiraIntegrationImporter)
     * can resolve epic/subtask hierarchy.
     */
    public function searchIssues(ProjectIntegration $projectIntegration, string $jql, ?string $nextPageToken = null, int $maxResults = 50): array
    {
        $query = [
            'jql' => $jql,
            'maxResults' => $maxResults,
            'fields' => 'summary,description,status,priority,issuetype,parent,labels,components,assignee,duedate',
        ];

        if ($nextPageToken !== null) {
            $query['nextPageToken'] = $nextPageToken;
        }

        return $this->getJson($projectIntegration, '/rest/api/3/search/jql', $query);
    }

    private function getJson(ProjectIntegration $projectIntegration, string $path, array $query = []): array
    {
        try {
            $response = $this->client($projectIntegration)->get($path, $query);
        } catch (ConnectionException) {
            Log::warning('Jira API request failed to connect', ['path' => $path]);

            throw new RuntimeException("Jira API request failed to connect: $path");
        }

        if ($response->failed()) {
            Log::warning('Jira API request failed', [
                'path' => $path,
                'status' => $response->status(),
            ]);

            $response->throw();
        }

        return $response->json();
    }

    private function client(ProjectIntegration $projectIntegration): PendingRequest
    {
        $credentials = $projectIntegration->credentials ?? [];

        return Http::baseUrl(rtrim($credentials['instance_url'] ?? '', '/'))
            ->withBasicAuth($credentials['email'] ?? '', $credentials['api_token'] ?? '')
            ->acceptJson()
            ->timeout(10);
    }
}

Twój odpowiednik dla Lineara czyta $projectIntegration->credentials['api_key'] i ustawia nagłówek bearer/Authorization zamiast withBasicAuth() (prawdziwe API Lineara jest GraphQL, nie REST, więc kształt getJson() zmieniłby się w pojedynczą metodę query() wysyłającą dokument GraphQL — ważne jest zachowanie: jeden builder client() czytający credentiale z modelu, każda inna metoda przechodząca przez niego, i każda ścieżka błędu logująca path/status/nazwę query, ale nigdy klucz API).

Krok 3 — Napisz IntegrationImporter

Nowy plik: app/Services/Integrations/Linear/LinearIntegrationImporter.php

To jest klasa, która tłumaczy kształt zewnętrznego systemu na ExternalIssueDTOjedyne miejsce, w którym nazwy pól Lineara powinny się w ogóle pojawić w kodzie. Zbuduj swoją w tym samym kształcie, co poniższy realny, kompletny wzorzec (znowu — dostosuj namespace/nazwę klasy oraz samą logikę mapowania pól, nie kopiuj ścieżek pól Jiry):

Wzorzec: app/Services/Integrations/Jira/JiraIntegrationImporter.php

<?php

namespace App\Services\Integrations\Jira;

use App\Contracts\IntegrationImporter;
use App\DataTransferObjects\ExternalIssueDTO;
use App\Models\ProjectIntegration;
use Generator;

/**
 * Translates Jira's REST API shape into the source-agnostic ExternalIssueDTO
 * once, here — nothing past this class knows Jira's field paths
 * (fields.issuetype.name, fields.parent.id, ADF descriptions, ...).
 */
class JiraIntegrationImporter implements IntegrationImporter
{
    private const int PAGE_SIZE = 50;

    public function __construct(protected JiraApiClient $jiraApiClient) {}

    public function testConnection(ProjectIntegration $projectIntegration): bool
    {
        return $this->jiraApiClient->testConnection($projectIntegration);
    }

    public function fetchMappingMetadata(ProjectIntegration $projectIntegration): array
    {
        return [
            'statuses' => $this->toMetadataOptions($this->jiraApiClient->getStatuses($projectIntegration)),
            'priorities' => $this->toMetadataOptions($this->jiraApiClient->getPriorities($projectIntegration)),
            'issueTypes' => $this->toMetadataOptions($this->jiraApiClient->getIssueTypes($projectIntegration)),
        ];
    }

    /**
     * $options['project_key'] selects the Jira project to pull from;
     * $options['jql'] can override the query entirely for callers that need
     * more control (e.g. re-importing a specific set of issues).
     *
     * @return Generator<ExternalIssueDTO>
     */
    public function fetchIssues(ProjectIntegration $projectIntegration, array $options = []): Generator
    {
        $jql = $options['jql'] ?? 'project = "'.($options['project_key'] ?? '').'" ORDER BY key ASC';

        $nextPageToken = null;

        do {
            $page = $this->jiraApiClient->searchIssues($projectIntegration, $jql, $nextPageToken, self::PAGE_SIZE);
            $issues = $page['issues'] ?? [];

            foreach ($issues as $issue) {
                yield $this->mapIssue($projectIntegration, $issue);
            }

            $nextPageToken = $page['nextPageToken'] ?? null;
        } while ($issues !== [] && $nextPageToken !== null);
    }

    private function mapIssue(ProjectIntegration $projectIntegration, array $issue): ExternalIssueDTO
    {
        $fields = $issue['fields'] ?? [];

        $labels = array_values(array_filter([
            ...($fields['labels'] ?? []),
            ...array_map(fn (array $component) => $component['name'] ?? null, $fields['components'] ?? []),
        ]));

        $instanceUrl = rtrim($projectIntegration->credentials['instance_url'] ?? '', '/');

        return new ExternalIssueDTO(
            externalId: (string) $issue['id'],
            externalKey: $issue['key'] ?? null,
            title: $fields['summary'] ?? '(no title)',
            description: $this->adfToPlainText($fields['description'] ?? null),
            externalStatus: $fields['status']['name'] ?? null,
            externalPriority: $fields['priority']['name'] ?? null,
            externalLabels: $labels,
            type: $fields['issuetype']['name'] ?? null,
            parentExternalId: isset($fields['parent']['id']) ? (string) $fields['parent']['id'] : null,
            assigneeExternalId: $fields['assignee']['accountId'] ?? null,
            assigneeEmail: $fields['assignee']['emailAddress'] ?? null,
            endDate: $fields['duedate'] ?? null,
            url: $instanceUrl && isset($issue['key']) ? "$instanceUrl/browse/{$issue['key']}" : null,
        );
    }

    /**
     * Jira Cloud's v3 API returns `description` as Atlassian Document
     * Format (a nested JSON node tree), not plain text — this walks it and
     * concatenates every text leaf. Best-effort: formatting/marks are
     * dropped, which is an acceptable loss for an imported issue body.
     */
    private function adfToPlainText(mixed $description): ?string
    {
        if ($description === null) {
            return null;
        }

        if (is_string($description)) {
            return $description;
        }

        return $this->extractAdfText($description) ?: null;
    }

    private function extractAdfText(array $node): string
    {
        $text = $node['text'] ?? '';

        foreach ($node['content'] ?? [] as $child) {
            $text .= ($text !== '' ? "\n" : '').$this->extractAdfText($child);
        }

        return trim($text);
    }

    private function toMetadataOptions(array $items): array
    {
        return array_map(fn (array $item) => [
            'value' => $item['name'] ?? $item['id'],
            'label' => $item['name'] ?? $item['id'],
        ], $items);
    }
}

To, że fetchIssues() jest generatorem PHP, ma znaczenie: ImportOrchestratorService::import() (zobacz uwagę architektoniczną poniżej) konsumuje go leniwie, po jednym issue naraz, więc projekt z tysiącami issues nie ładuje ich wszystkich do pamięci, zanim powstanie pierwszy. API GraphQL Lineara używa własnej konwencji kursora zamiast nextPageToken Jiry, ale kształt jest ten sam: pętla, yield dla każdego zmapowanego DTO, zatrzymanie, gdy API mówi, że nie ma kolejnej strony.

parentExternalId w fetchIssues() to jest to, co pozwala ImportOrchestratorService generycznie odtworzyć hierarchię — zmapuj go z tego, co Linear nazywa relacją issue nadrzędne/podrzędne, używając tych samych wartości externalId (nie externalKey), których używasz dla samego ExternalIssueDTO::externalId, bo to na nich opiera się klucz deduplikacji ExternalIssueLink.

Krok 4 — Zarejestruj ją

Plik: app/Services/Integrations/IntegrationImporterRegistry.php

private const array MAP = [
    'jira' => JiraIntegrationImporter::class,
    'linear' => LinearIntegrationImporter::class,
];

To jedyne miejsce, w którym pipeline importu sprawdza, która klasa obsługuje który klucz integracji — dokładne odbicie IntegrationNotifierRegistry::MAP.

Krok 5 — Credentiale: zwykle nic do dodania

project_integrations.credentials to już generyczna, zaszyfrowana kolumna JSON (cast 'credentials' => 'encrypted:array' w app/Models/ProjectIntegration.php) — pojedynczy api_key Lineara po prostu staje się {"api_key": "..."} zapisanym pod nim, bez potrzeby migracji. Dodawaj nową migrację tylko wtedy, gdy potrzebujesz typowanego, przeszukiwalnego (nie-JSON-blob) przechowywania credentiali, czego żadna integracja typu import nie potrzebowała jak dotąd.

Krok 6 — Serwis, kontroler i trasy

Nowy plik: app/Services/Integrations/Linear/LinearIntegrationService.php

Odzwierciedla metoda po metodzie app/Services/Integrations/Jira/JiraIntegrationService.php: connect() (zapisuje credentiale, wywołuje testConnection(), rzuca ValidationException przy niepowodzeniu), getMappingMetadata(), saveMappings(), triggerImport() (wysyła zakolejkowanego joba — zobacz Krok 7) oraz getSettingsExtras() (dane po stronie odczytu, potrzebne stronie ustawień, obliczane defensywnie — opakuj żywe wywołanie metadanych w try/catch, żeby przestój Lineara degradował UI mapowania zamiast wysypać całą stronę ustawień).

Nowy plik: app/Http/Controllers/LinearIntegrationController.php + routes/web.php:

Route::post('/projects/{project}/integrations/linear/connect', [LinearIntegrationController::class, 'connect'])->name('projects.integrations.linear.connect');
Route::put('/projects/{project}/integrations/linear/mappings', [LinearIntegrationController::class, 'updateMappings'])->name('projects.integrations.linear.mappings.update');
Route::post('/projects/{project}/integrations/linear/import', [LinearIntegrationController::class, 'import'])->name('projects.integrations.linear.import');

Każda akcja jest bramkowana tym samym sprawdzeniem ProjectPolicy::updateIntegrations, jakiego używa każda inna trasa mutująca integracje — nic nowego do dodania w Policy.

Uwaga architektoniczna — nie ma trasy GET .../metadata ani GET .../import-status i dla Lineara też jej nie powinno być. Orbit nie ma osobnego API JSON (zobacz górę głównego CLAUDE.md) — każda strona jest renderowana po stronie serwera jako props Inertii. Metadane mapowania Jiry, zapisane mapowania pól i podsumowanie ostatniego importu płyną jako zwykły prop Inertii: SettingsController::index() oblicza prop jiraSettings przez JiraIntegrationService::getSettingsExtras(), bramkowany przez canUpdateIntegrations dokładnie tak, jak istniejące maskowanie webhookUrl dla Discorda. Dodanie Lineara oznacza albo uogólnienie tego pojedynczego propa jiraSettings do mapy per-integracja (np. importIntegrationSettings: Record<string, ImportIntegrationSettings>, kluczowanej po id integracji), albo dodanie drugiego, siostrzanego propa w ten sam sposób — obecny kod obsługuje na raz tylko jedną integrację typu import, i to jest znane, celowe uproszczenie (zobacz łańcuch przekazywania propsów na froncie w Kroku 8), nie przeoczenie, które trzeba po cichu obchodzić.

Na koniec dodaj 'linear' do app/Services/ProjectIntegrationService.php::AVAILABLE_INTEGRATIONS (['discord', 'jira', 'linear']) — tej samej stałej, której używa przewodnik 1 dla integracji notify. Kierunek (notify vs import) jest wyrażony przez to, który rejestr rości sobie prawo do danego klucza (IntegrationNotifierRegistry vs IntegrationImporterRegistry), nie przez cokolwiek w tej stałej ani w schemacie ProjectIntegration.

Krok 7 — Job importu

Nowy plik: app/Jobs/ImportLinearIssuesJob.php

Odzwierciedla app/Jobs/ImportJiraIssuesJob.php: ShouldQueue, tries = 3, backoff() zwracające [5, 15, 30] — ten sam kształt ponawiania, jakiego używa SendWebhookNotificationJob. Jego handle() rozwiązuje importera przez IntegrationImporterRegistry, przesyła fetchIssues() do ImportOrchestratorService::import() i zapisuje wynikowy ImportResultDTO w project_integrations.options['last_import'] (imported/updated/skipped/failed/errors/ran_at) przez ProjectIntegrationRepository::updateOrCreate().

import() przyjmuje flagę bool $syncExisting (czytaną z $this->importOptions['sync_existing'] ?? false — tej samej generycznej "torby" opcji, z której fetchIssues() już czyta project_key/jql, więc ImportLinearIssuesJob nie potrzebuje nowej właściwości konstruktora dla niej). false (domyślnie) to oryginalne zachowanie: issue już śledzone w ExternalIssueLink jest zostawiane w spokoju i liczone jako skipped. true zamiast tego je nadpisuje, przez IssueService::syncImportedIssue()zewnętrzny system zawsze wygrywa przy konflikcie, bez łączenia z tym, co użytkownik zmienił lokalnie w Orbicie od ostatniej synchronizacji. To nadpisanie używa dokładnie tej samej logiki mapIssueData()/mapowania pól/hierarchii, która tworzy zupełnie nowe issue, więc zachowanie synchronizacji dla Lineara jest automatycznie poprawne, nie jest czymś, co ImportLinearIssuesJob musi implementować. syncImportedIssue() pisze jeden wpis ActivityLog na zmienione issue (żeby jego historia zostawała czytelna, tak jak przy zwykłej edycji), ale celowo nie odpala IssueUpdated ani nikogo nie powiadamia — to samo uzasadnienie operacji masowej, dla którego importIssue() nie odpala IssueCreated: re-sync może dotknąć setek issues w jednym przebiegu.

Poinformowanie importującego użytkownika, jak poszło, nie jest zadaniem tego joba. Sam ImportOrchestratorService::import() wysyła event App\Events\IssuesImported (project, importedBy, ImportResultDTO) raz, bezwarunkowo, na końcu każdego przebiegu — ta sama zasada "wyślij fakt, niech listener zdecyduje, kogo to obchodzi", której trzyma się każdy inny event w tej aplikacji (zobacz 03-add-a-new-event-type.mdIntegracjeDodaj nowy typ eventuPrzećwiczony przykład: dodanie IssueCreated — rodzaju aktywności, który jeszcze nie istnieje. Dziś stworzenie issue odpala tylko IssueAssigned, i to tylko…). SendNotificationListener::handleIssuesImported() jest na niego zarejestrowany w AppServiceProvider::boot() i wywołuje NotificationService::notify() z NotificationType::IntegrationActivity — dlatego import Lineara dostaje powiadomienie in-app + email "Twój import się zakończył" za darmo, bez żadnego nowego kodu w samym ImportLinearIssuesJob: event pochodzi ze współdzielonego orchestratora, nie z joba. Jedyna rzecz, którą hook failed() na poziomie joba (wywoływany, gdy wyczerpią się wszystkie próby) wciąż musi zrobić bezpośrednio — przez app(NotificationService::class)->notify(...), nie przez kolejny event — to poinformowanie użytkownika, że jego import w ogóle się nie zakończył, ponieważ ImportOrchestratorService::import() nigdy nie zdążył się uruchomić, więc IssuesImported nigdy nie zostało wysłane.

Postęp na żywo, na potrzeby toastu "N zaimportowanych do tej pory"

ImportOrchestratorService::import() przyjmuje opcjonalny parametr ?callable $onProgress, wywoływany po każdym przetworzonym issue z bieżącymi sumami (imported/updated/skipped/failed). Sam orchestrator niczego nie zapisuje ani nie wyświetla — to czysto hook typu callback, więc job przyszłego importera podpina go tak, jak potrzebuje. ImportJiraIssuesJob podpina domknięcie, które zapisuje project_integrations.options['import_progress'] (throttlowane co ~3 przetworzone issue, ale nigdy nie pomijające pierwszego, żeby UI szybko pokazał ruch), oznaczone świeżym run_id (UUID, nie znacznikiem czasu — omija problemy z precyzją zegara) generowanym raz na próbę handle(). JiraIntegrationService::getImportProgress() odczytuje to z powrotem jako osobny, tani prop (jiraImportProgress, podłączony w SettingsController::index()) — celowo nie połączony z getSettingsExtras()/jiraSettings, który też wywołuje żywe API Jiry po metadane mapowania; odpytywanie tego co ~1.5s podczas trwania importu bez potrzeby zarzucałoby Jirę żądaniami.

WorkspaceSettingsIntegrationsTab.tsx to miejsce, które faktycznie zamienia to w toast na żywo: przy triggerowaniu importu otwiera trwały alert (AlertContext::addAlert(msg, type, 0)duration: 0 oznacza "nie znikaj automatycznie"), zapamiętuje runId, jaki był w jiraImportProgress przed kliknięciem (żeby poll, który wciąż zwraca zaległy rekord done/failed z poprzedniego przebiegu — job jeszcze nie podjął nowego — był poprawnie zignorowany zamiast przedwcześnie zamknąć toast), i odpytuje router.reload({ only: ['jiraImportProgress', 'flash'] }) w interwale. 'flash' musi tam być: prop, którego partial reload nie zażąda, nigdy nie jest ponownie ewaluowany po stronie klienta, więc pominięcie go zamroziłoby jakikolwiek flash, który akurat był widoczny tuż przed rozpoczęciem odpytywania, a globalny handler router.on('success', ...) w AlertContext pokazuje od nowa cokolwiek jest w flash przy każdym zakończonym visicie (celowo — zobacz komentarz w AlertContext.tsx o tym, czemu nie może deduplikować po treści) — łącznie z tymi odpytywaniami w tle. AlertContext::updateAlert(id, patch) to jest to, co pozwala tekstowi tego samego toastu zmieniać się w miejscu, w miarę napływania nowego postępu, a App\Events\IssuesImported (powyżej) to jest to, co w końcu mówi pollerowi, żeby przestał i podmienił toast na finalne podsumowanie. Żadna z tych rzeczy na froncie nie jest specyficzna dla Jiry — import Lineara dostaje toast na żywo za darmo w momencie, gdy jego własny job podepnie ten sam callback onProgress.

Jednej rzeczy nie kopiuj odruchowo: ImportJiraIssuesJob nie implementuje ShouldBeEncrypted, w przeciwieństwie do SendWebhookNotificationJob. To dlatego, że jego konstruktor przyjmuje instancje modeli ProjectIntegration/Project, które trait SerializesModels w Laravelu serializuje jako referencję klasa+id, nie surowe atrybuty — kolumna credentials niosąca poświadczenia w ogóle nie trafia do payloadu kolejki, jest odszyfrowywana dopiero, gdy model jest ponownie pobierany wewnątrz handle(). Sięgaj po ShouldBeEncrypted tylko wtedy, gdy job przyszłego importera przyjmuje surowy string sekretu bezpośrednio jako argument konstruktora (tak jak SendWebhookNotificationJob przyjmuje $webhookUrl).

triggerImport() (Krok 6) go wysyła: ImportLinearIssuesJob::dispatch($projectIntegration, $project, $importedBy->id, $importOptions). Do tego, żeby cokolwiek faktycznie się wydarzyło, potrzebny jest działający worker kolejki — zobacz proces queue:listen w composer dev.

Krok 8 — Podłączenie frontendu

Plik: resources/js/Components/Organisms/WorkspaceSettingsContent/WorkspaceSettingsIntegrationsTab.tsx

Dodaj wpis do IMPORT_ROUTE_NAMES:

const IMPORT_ROUTE_NAMES: Partial<
    Record<IntegrationId, { connect: string; mappings: string; import: string }>
> = {
    jira: {
        connect: 'projects.integrations.jira.connect',
        mappings: 'projects.integrations.jira.mappings.update',
        import: 'projects.integrations.jira.import',
    },
    linear: {
        connect: 'projects.integrations.linear.connect',
        mappings: 'projects.integrations.linear.mappings.update',
        import: 'projects.integrations.linear.import',
    },
};

To cała zmiana na froncie potrzebna dla samego przepływu connect/mapping/import — WorkspaceSettingsImportPanel.tsx już renderuje wszystko, co opisuje importConfig (Krok 1), a WorkspaceSettingsIntegrationDetailModal.tsx już kieruje każdą integrację kind: 'import' do tego panelu. Żaden z tych dwóch plików nie jest specyficzny dla Jiry — dotyczy to też przełącznika "Update already-imported issues" obok przycisku Import, który wysyła sync_existing do trasy, jaką rozwiąże IMPORT_ROUTE_NAMES. Linear dostaje go za darmo w momencie, gdy istnieje powyższy wpis — nie ma nic specyficznego dla importera do dodania dla niego na froncie.

To, czego wciąż musisz dotknąć, zgodnie z uwagą architektoniczną z Kroku 6: prop jiraSettings, przekazywany przez SettingsControllerSettings/Index.tsxWorkspaceSettingsContent.tsxWorkspaceSettingsIntegrationsTab.tsx → modal → panel, jest obecnie pojedynczy i specyficzny dla Jiry. Uogólnij go (mapa per-integracja albo drugi, identycznie ukształtowany prop), zanim ustawienia Lineara będą mogły renderować się obok ustawień Jiry — nie używaj po cichu propa jiraSettings dla danych Lineara.

Na koniec odwróć comingSoon: false we wpisie katalogowym Lineara (Krok 1), gdy wszystko powyżej jest podłączone i ręcznie zweryfikowane — ta jedna flaga odblokowuje UI przełącznika/Connect dokładnie tak, jak opisano w przewodniku 1, Kroku 7.

Testy

Każdy test funkcjonalny Pest w tym kodzie leży płasko pod tests/Feature/ (nazwany po klasie testowanej, nie zagnieżdżony po namespace — zobacz dowolny istniejący *Test.php tam) — odzwierciedl to, dodając testy dla Lineara, nie zagnieżdżony-po-namespace kształt, którego mógłbyś się spodziewać. To, co istnieje dziś dla współdzielonego pipeline'u, przećwiczone w całości przez Jirę, ale równie ważne dla każdego przyszłego źródła:

  • tests/Feature/ImportOrchestratorServiceTest.php — importuje nowe issue i je linkuje; pomija już zalinkowane issue domyślnie; przy syncExisting: true zamiast tego je nadpisuje (dane zdalne wygrywają nawet nad lokalnie zmienionym polem); rozwiązuje parent_id zarówno gdy dziecko pojawia się przed rodzicem w tym samym przebiegu, jak i gdy rodzic był zaimportowany w poprzednim przebiegu; liczy porażkę pojedynczego issue (wymuszoną przez trigger porządku dat w tabeli issues) bez przerywania reszty przebiegu; sprawdza, że IssuesImported odpala się dokładnie raz z finalnym wynikiem (Event::fake() + Event::assertDispatchedTimes()); i sprawdza, że onProgress jest wywoływany z bieżącymi sumami po każdym przetworzonym issue.
  • tests/Feature/ImportJiraIssuesJobTest.phphandle() importuje issues i zostawia import_progress jako {status: 'done', ...} z niepustym run_id; zapisuje pasujące podsumowanie last_import; przekazuje sync_existing do orchestratora (zweryfikowane przez drugi przebieg faktycznie nadpisujący issue); nic nie robi poza logiem ostrzeżenia, gdy integracja nie ma zarejestrowanego importera; a failed() zapisuje {status: 'failed'} i powiadamia importującego użytkownika. Używa Http::fake(['*/rest/api/3/search/jql*' => Http::sequence()->push(...)->push(...)]) dla testu dwóch przebiegów sync — drugie, osobne wywołanie Http::fake() w tym samym teście nie zastępuje pierwszej rejestracji dla nakładającego się wzorca URL (wygrywa najwcześniej zarejestrowany stub), więc obie strony trzeba zakolejkować z góry w jednym wywołaniu.
  • tests/Feature/SendNotificationListenerTest.php — dwa przypadki dla IssuesImported obok istniejących dla ProjectInvited/IssueAssigned/itd.: ciężkość 'success', gdy nic nie zawiodło, 'warning', gdy failed > 0.
  • Vitest: blok describe "Jira import live progress toast" w WorkspaceSettingsIntegrationsTab.test.tsx — trwały toast pojawia się natychmiast i zaczyna odpytywać jiraImportProgress + flash dopiero po upłynięciu interwału odpytywania (await waitFor(...), nie fake timers — te nie współgrają dobrze z wewnętrznym taktowaniem userEvent); aktualizacja running zmienia tekst toastu w miejscu; aktualizacja done/failed zastępuje go finalnym podsumowaniem (sprawdzone przez waitFor, bo znikający toast zostaje w DOM w trakcie animacji wyjścia framer-motion — synchroniczne sprawdzenie not.toBeInTheDocument() zaraz po zmianie stanu to tu częsty fałszywy negatyw); a poll, który wciąż zwraca zaległy runId z poprzedniego przebiegu, jest ignorowany zamiast przedwcześnie zamykać toast.

Wciąż do dodania, gdy zbudujesz importera Lineara (dodaj oba naraz, zamiast powiększać zaległość): tests/Unit/ExternalIssueDTOTest.php (konstrukcja/kształt), tests/Feature/FieldMappingResolverServiceTest.php (zapisane mapowanie wygrywa, spada do domyślnej wartości dla danego typu, gdy brak mapowania), tests/Feature/JiraApiClientTest.php / LinearApiClientTest.php (przez Http::fake(), kształt żądania + redakcja credentiali w rzucanych wyjątkach/logach), oraz tests/Feature/JiraIntegrationControllerTest.php / LinearIntegrationControllerTest.php (bramkowanie przez policy na każdej trasie, odzwierciedlające ProjectIntegrationControllerTest.php).

Uruchom php artisan test i npm test -- run przed commitem — zobacz dokładne komendy w głównym CLAUDE.md.