diff --git a/README.md b/README.md index 672ccf26..31d8cf97 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,8 @@ All this made with the best technologies. + + ## Documentation @@ -122,9 +124,12 @@ The MIT License (MIT). Please see [License File](LICENSE.md) for more informatio - #32 Default user seeder enhancement - #31 Issue resolved - **Release 1.2.0** - - Scrum module #28 - - Design enhancement (Kanban / Scrum boards) - - Referential updates + - Scrum module #28 + - Design enhancement (Kanban / Scrum boards) + - Referential updates +- **Release 1.2.1** + - Add jira integration #36 + - New feature: Import jira projects / tickets ## Support us diff --git a/app/Filament/Pages/JiraImport.php b/app/Filament/Pages/JiraImport.php new file mode 100644 index 00000000..d0336cbb --- /dev/null +++ b/app/Filament/Pages/JiraImport.php @@ -0,0 +1,270 @@ +form->fill(); + } + + protected static function shouldRegisterNavigation(): bool + { + return auth()->user()->can('Import from Jira'); + } + + protected function getSubheading(): string|Htmlable|null + { + return __('Use this section to login into your jira account and import tickets to this application'); + } + + protected static function getNavigationLabel(): string + { + return __('Jira import'); + } + + protected static function getNavigationGroup(): ?string + { + return __('Settings'); + } + + protected function getFormSchema(): array + { + return [ + Card::make() + ->schema([ + Wizard::make([ + Wizard\Step::make(__('Jira login')) + ->schema([ + Placeholder::make('info') + ->extraAttributes([ + 'class' => 'bg-primary-500 rounded-lg border border-primary-600 text-white font-medium text-sm py-3 px-4' + ]) + ->disableLabel() + ->content(__('Important: Your jira credentials are only used to communicate with jira REST API, and will not be stored in this application')), + + Grid::make() + ->schema([ + TextInput::make('host') + ->label(__('Host')) + ->helperText(__('The url used to access your jira account')) + ->required(), + + TextInput::make('username') + ->label(__('Username')) + ->helperText(__('Your jira account username')) + ->required(), + + TextInput::make('token') + ->label(__('API Token')) + ->helperText(__('Your jira account API Token')) + ->password() + ->required(), + ]), + ]) + ->afterValidation(function () { + $this->loadingProjects = true; + $this->emit('updateJiraProjects'); + }), + + Wizard\Step::make(__('Jira projects')) + ->schema([ + Placeholder::make('hint') + ->extraAttributes([ + 'class' => 'bg-primary-500 rounded-lg border border-primary-600 text-white font-medium text-sm py-3 px-4' + ]) + ->disableLabel() + ->visible(fn() => !$this->loadingProjects && $this->projects) + ->content(__('Choose your jira projects to import')), + + Placeholder::make('loading') + ->extraAttributes([ + 'class' => 'bg-warning-500 rounded-lg border border-warning-600 text-white font-medium text-sm py-3 px-4' + ]) + ->disableLabel() + ->visible(fn() => $this->loadingProjects) + ->content(__('Loading projects, please wait...')), + + Placeholder::make('info') + ->extraAttributes([ + 'class' => 'bg-danger-500 rounded-lg border border-danger-600 text-white font-medium text-sm py-3 px-4' + ]) + ->disableLabel() + ->visible(fn() => !$this->loadingProjects && !$this->projects) + ->content(__('Your jira credentials are incorrect, please go to previous step and re-enter your jira credentials')), + + CheckboxList::make('selected_projects') + ->label(__('Jira projects')) + ->required() + ->visible(fn() => $this->projects) + ->options(function () { + $list = []; + if ($this->projects) { + foreach ($this->projects as $project) { + $list[$project->key] = new HtmlString( + "
" + . "
" + . "" + . "" . $project->name . "" + . "
/ " . $project->key . "
" + . "
" + . "
" + ); + } + } + return $list; + }), + + ]) + ->afterValidation(function () { + $this->loadingTickets = true; + $this->emit('updateJiraTickets'); + }), + + Wizard\Step::make(__('Jira tickets')) + ->schema(function () { + $fields = []; + + $fields[] = Placeholder::make('hint') + ->extraAttributes([ + 'class' => 'bg-primary-500 rounded-lg border border-primary-600 text-white font-medium text-sm py-3 px-4' + ]) + ->disableLabel() + ->visible(fn() => !$this->loadingTickets && $this->tickets) + ->content(__('Choose your jira projects to import')); + + $fields[] = Placeholder::make('loading') + ->extraAttributes([ + 'class' => 'bg-warning-500 rounded-lg border border-warning-600 text-white font-medium text-sm py-3 px-4' + ]) + ->disableLabel() + ->visible(fn() => $this->loadingTickets) + ->content(__('Loading tickets, please wait...')); + + if (!$this->loadingTickets) { + if ($this->tickets) { + foreach ($this->tickets as $projectKey => $ticket) { + if ($ticket['total'] > 0) { + $fields[] = Placeholder::make('tickets_' . Str::slug($projectKey)) + ->label(__('Tickets for the project:') . ' ' . $projectKey) + ->extraAttributes([ + 'style' => 'margin-bottom: -15px;' + ]) + ->content(''); + + foreach ($ticket['issues'] as $issue) { + $fields[] = Checkbox::make('data.' . Str::slug($projectKey) . '_' . Str::slug($issue['code'])) + ->label(function () use ($issue) { + return new HtmlString( + "
" + . "
" + . "
" . $issue['code'] . " " . $issue['name'] . "
" + . "
" + . "
" + ); + }); + } + } else { + $fields[] = Placeholder::make('no_tickets_' . Str::slug($projectKey)) + ->label(__('Tickets for the project:') . ' ' . $projectKey) + ->content(__('No tickets found!')); + } + } + } else { + $fields[] = Placeholder::make('info') + ->extraAttributes([ + 'class' => 'bg-warning-500 rounded-lg border border-warning-600 text-white font-medium text-sm py-3 px-4' + ]) + ->disableLabel() + ->visible(fn() => !$this->projects) + ->content(__('No tickets found!')); + } + } + return $fields; + }), + ]) + ->submitAction(new HtmlString("")), + ]), + ]; + } + + public function import(): void + { + if ($this->data && sizeof($this->data)) { + $tickets = []; + foreach (array_keys($this->data) as $item) { + $url = $this->ticketsDataApi[$item]; + $tickets[] = $this->getJiraTicketDetails($this->host, $this->username, $this->token, $url); + } + dispatch(new ImportJiraTicketsJob($tickets, auth()->user())); + $this->notify('success', __('The importation job is started, when finished you will be notified'), true); + $this->redirect(route('filament.pages.jira-import')); + } else { + $this->notify('warning', __('Please choose at least a jira ticket to import')); + } + } + + public function updateJiraProjects(): void + { + $client = $this->connectToJira($this->host, $this->username, $this->token); + $this->projects = $this->getJiraProjects($client); + $this->loadingProjects = false; + } + + public function updateJiraTickets(): void + { + $this->ticketsDataApi = []; + $client = $this->connectToJira($this->host, $this->username, $this->token); + $this->tickets = $this->getJiraTicketsByProject($client, $this->selected_projects); + foreach ($this->tickets as $projectKey => $ticket) { + foreach ($ticket['issues'] as $issue) { + $this->ticketsDataApi[Str::slug($projectKey) . '_' . Str::slug($issue['code'])] = $issue['data']->self; + } + } + $this->loadingTickets = false; + } +} diff --git a/app/Helpers/JiraHelper.php b/app/Helpers/JiraHelper.php new file mode 100644 index 00000000..804f10ed --- /dev/null +++ b/app/Helpers/JiraHelper.php @@ -0,0 +1,78 @@ + $host, + 'headers' => [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'Authorization' => 'Basic ' . base64_encode($username . ":" . $token) + ] + ]); + } + + public function getJiraProjects(Client $client): array|null + { + try { + $response = $client->get('/rest/api/2/project'); + return json_decode($response->getBody()->getContents()); + } catch (GuzzleException $e) { + Log::error($e->getTraceAsString()); + return null; + } + } + + public function getJiraTicketsByProject(Client $client, $projectKeys): array|null + { + try { + $formatIssues = function ($issues) { + $results = []; + foreach ($issues as $issue) { + $results[] = [ + 'code' => $issue->key, + 'name' => $issue->fields->summary, + 'data' => $issue + ]; + } + return $results; + }; + $results = []; + foreach ($projectKeys as $projectKey) { + $response = $client->get('/rest/api/2/search?jql=project=' . $projectKey); + $data = json_decode($response->getBody()->getContents()); + $results[$projectKey] = [ + 'total' => $data->total, + 'issues' => $formatIssues($data->issues) + ]; + } + return $results; + } catch (GuzzleException $e) { + Log::error($e->getTraceAsString()); + return null; + } + } + + public function getJiraTicketDetails($host, $username, $token, $url) + { + try { + $client = $this->connectToJira($host, $username, $token); + $url = explode('/', $url); + $response = $client->get('/rest/api/2/issue/' . $url[sizeof($url) - 1]); + return json_decode($response->getBody()->getContents()); + } catch (GuzzleException $e) { + Log::error($e->getTraceAsString()); + return null; + } + } + +} diff --git a/app/Jobs/ImportJiraTicketsJob.php b/app/Jobs/ImportJiraTicketsJob.php new file mode 100644 index 00000000..854a7aa8 --- /dev/null +++ b/app/Jobs/ImportJiraTicketsJob.php @@ -0,0 +1,84 @@ +tickets = $tickets; + $this->user = $user; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + if ($this->tickets && sizeof($this->tickets)) { + foreach ($this->tickets as $ticket) { + $projectDetails = $ticket->fields->project; + $ticketData = $ticket->fields; + + $project = Project::where('name', $projectDetails->name)->first(); + if (!$project) { + $project = Project::create([ + 'name' => $projectDetails->name, + 'description' => __('Project imported from Jira, project key:') . $projectDetails->key, + 'status_id' => ProjectStatus::where('is_default', true)->first()->id, + 'owner_id' => $this->user->id, + 'ticket_prefix' => $projectDetails->key + ]); + + ProjectUser::create([ + 'project_id' => $project->id, + 'user_id' => $this->user->id, + 'role' => config('system.projects.affectations.roles.can_manage') + ]); + } + + Ticket::create([ + 'name' => $ticketData->summary, + 'content' => $ticketData->description ?? __('No content found in jira ticket'), + 'owner_id' => $this->user->id, + 'status_id' => TicketStatus::where('is_default', true)->first()->id, + 'project_id' => $project->id, + 'type_id' => TicketType::where('is_default', true)->first()->id, + 'priority_id' => TicketPriority::where('is_default', true)->first()->id, + ]); + } + FilamentNotification::make() + ->title(__('Jira importation')) + ->icon('heroicon-o-cloud-download') + ->body(__('Jira tickets successfully imported')) + ->sendToDatabase($this->user); + } + } +} diff --git a/database/seeders/PermissionsSeeder.php b/database/seeders/PermissionsSeeder.php index e76c446b..3c0175ec 100644 --- a/database/seeders/PermissionsSeeder.php +++ b/database/seeders/PermissionsSeeder.php @@ -26,7 +26,7 @@ class PermissionsSeeder extends Seeder ]; private array $extraPermissions = [ - 'Manage general settings' + 'Manage general settings', 'Import from Jira' ]; private string $defaultRole = 'Default role'; diff --git a/docs/_media/23.png b/docs/_media/23.png new file mode 100644 index 00000000..66cbd1d8 Binary files /dev/null and b/docs/_media/23.png differ diff --git a/docs/_media/24.png b/docs/_media/24.png new file mode 100644 index 00000000..b163c609 Binary files /dev/null and b/docs/_media/24.png differ diff --git a/docs/_media/25.png b/docs/_media/25.png new file mode 100644 index 00000000..ad0bd6c9 Binary files /dev/null and b/docs/_media/25.png differ diff --git a/docs/_media/26.png b/docs/_media/26.png new file mode 100644 index 00000000..2bce1795 Binary files /dev/null and b/docs/_media/26.png differ diff --git a/docs/installation.md b/docs/installation.md index 805b5f5e..a5f2c4f3 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -36,6 +36,8 @@ In addition of the main [prerequisites](/?id=prerequisites), you must have this + + diff --git a/github-contents/25.png b/github-contents/25.png new file mode 100644 index 00000000..ad0bd6c9 Binary files /dev/null and b/github-contents/25.png differ diff --git a/github-contents/26.png b/github-contents/26.png new file mode 100644 index 00000000..2bce1795 Binary files /dev/null and b/github-contents/26.png differ diff --git a/lang/fr.json b/lang/fr.json index acfd4130..f80e6d20 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -230,5 +230,31 @@ "Next sprint:": "Sprint suivant :", "Sprint ended at": "Sprint terminé le", "Sprint started at": "Sprint commencé le", - "Started at:": "Commencé le :" + "Started at:": "Commencé le :", + "Jira import": "Import Jira", + "Jira login": "Authentification Jira", + "Jira projects": "Projets Jira", + "Jira tickets": "Tickets Jira", + "Username": "Nom d'utilisateur", + "Password": "Mot de passe", + "Your jira account username": "Le nom d'utilisateur de votre compte jira", + "Your jira account password": "Le mot de passe de votre compte jira", + "Important: Your jira credentials are only used to communicate with jira REST API, and will not be stored in this application": "Important : Vos informations d'identification Jira ne sont utilisées que pour communiquer avec l'API REST Jira et ne seront pas stockées dans cette application.", + "Use this section to login into your jira account and import tickets to this application": "Utilisez cette section pour vous connecter à votre compte jira puis importer vos tickets sur cette application", + "Host": "Hôte", + "The url used to access your jira account": "L'url utilisé pour accéder à votre compte jira", + "Your jira credentials are incorrect, please go to previous step and re-enter your jira credentials": "Vos informations d'identification Jira sont incorrectes, veuillez passer à l'étape précédente et saisir à nouveau vos informations d'identification Jira", + "Loading jira projects, please wait...": "Chargement des projets jira, veuillez patienter...", + "Choose your jira projects to import": "Choisissez vos projets jira à importer", + "No tickets found!": "Aucun ticket trouvé !", + "Tickets for the project:": "Tickets pour le projet :", + "Import": "Importer", + "Loading projects, please wait...": "Chargement des projets, veuillez patienter...", + "Loading tickets, please wait...": "Chargement des tickets, veuillez patienter...", + "Please choose at least a jira ticket to import": "Veuillez sélectionner au moins un ticket jira à importer", + "The importation job is started, when finished you will be notified": "La tâche d'importation est lancée, une fois terminée vous serez notifié", + "Jira importation": "Importation jira", + "Jira tickets successfully imported": "Tickets jira importés avec succès", + "Before you can import jira tickets you need to have all the referentials configured": "Avant de pouvoir importer des tickets jira, vous devez avoir configuré tous les référentiels", + "No content found in jira ticket": "Aucun contenu trouvé sur le ticket jira" } diff --git a/resources/views/filament/pages/jira-import.blade.php b/resources/views/filament/pages/jira-import.blade.php new file mode 100644 index 00000000..91683242 --- /dev/null +++ b/resources/views/filament/pages/jira-import.blade.php @@ -0,0 +1,12 @@ + + +
+ {{ __('Important:') }} + {{ __('Before you can import jira tickets you need to have all the referentials configured') }} +
+ +
+ {{ $this->form }} +
+ +