From ac5fb413448707088c9ddbf77e373a30d3c26e2b Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Mon, 20 Jul 2026 01:00:07 -0300 Subject: [PATCH 1/2] feat(community): multi-source retrospective contract + GitHub/Discord (phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fase 1 da retrospectiva multi-fonte: transforma a retrô (antes só GitHub, read model ao vivo no portal) numa peça plugável por fonte. - community: contrato RetrospectiveSource (key + collect(Period, SourceFilters): SourceResult) e DTOs tipados (Period, SourceFilters, Metric, HeadlineMetrics, SourceResult) + interface Slide. Módulo de domínio, dono do contrato. - integration-github: GithubSource refatora o antigo CommunityRetrospective preservando o cálculo 1:1 (bots, PRs merged/unmerged, repos com PR, destaques), empacotado em slides tipados. - activity: DiscordSource (dono do dado) com 5 slides — voz, mensagens, novos membros, reações e mensagem mais reagida. Agrega tudo em SQL escopado por sent_at/occurred_at, hideBots via source_kind, nome via metadata do Discord. - portal: RetrospectiveDeck resolve as fontes por tagged service (retrospective.source), ordena e descarta vazias; página compõe cover (chips por fonte) + blocos por fonte (kind -> Blade por convenção) + closing. Filtros ricos do visitante aposentados (viram config editorial na Fase 2); restam recorte de período e ocultar bots. Sem tabelas novas nesta fase. Testes: GithubSource (golden 1:1), DiscordSource (agregações), RetrospectiveDeck (ordem/descartes) e página multi-fonte. --- app-modules/activity/composer.json | 5 +- .../activity/src/ActivityServiceProvider.php | 3 + .../src/Retrospective/DiscordSource.php | 321 ++++++++++++++++++ .../Retrospective/Slides/MessagesSlide.php | 43 +++ .../Retrospective/Slides/NewMembersSlide.php | 31 ++ .../Retrospective/Slides/ReactionsSlide.php | 35 ++ .../Retrospective/Slides/TopMessageSlide.php | 34 ++ .../Retrospective/Slides/VoiceBoardSlide.php | 41 +++ .../Retrospective/DiscordSourceTest.php | 187 ++++++++++ .../Contracts/RetrospectiveSource.php | 27 ++ .../src/Retrospective/Contracts/Slide.php | 23 ++ .../Retrospective/DTOs/HeadlineMetrics.php | 24 ++ .../src/Retrospective/DTOs/Metric.php | 19 ++ .../src/Retrospective/DTOs/Period.php | 24 ++ .../src/Retrospective/DTOs/SourceFilters.php | 26 ++ .../src/Retrospective/DTOs/SourceResult.php | 31 ++ app-modules/integration-github/composer.json | 3 + .../src/IntegrationGithubServiceProvider.php | 4 + .../src/Retrospective/GithubSource.php} | 179 +++++----- .../Slides/GithubCommunitySlide.php | 34 ++ .../Retrospective/Slides/GithubCoreSlide.php | 34 ++ .../Slides/GithubHighlightsSlide.php | 34 ++ .../Slides/GithubPanoramaSlide.php | 34 ++ .../Retrospective/Slides/GithubRepoSlide.php | 35 ++ .../Retrospective/GithubSourceTest.php | 246 ++++++++++++++ app-modules/portal/composer.json | 6 +- .../views/community-retrospective.blade.php | 37 +- .../views/components/retro/controls.blade.php | 51 +++ .../views/components/retro/deck.blade.php | 2 +- .../views/components/retro/filters.blade.php | 162 --------- .../components/retro/slides/closing.blade.php | 32 +- .../components/retro/slides/cover.blade.php | 35 +- .../retro/slides/discord/messages.blade.php | 30 ++ .../slides/discord/new-members.blade.php | 21 ++ .../retro/slides/discord/reactions.blade.php | 19 ++ .../slides/discord/top-message.blade.php | 19 ++ .../slides/discord/voice-board.blade.php | 20 ++ .../slides/github}/community.blade.php | 1 - .../slides/github}/core.blade.php | 1 - .../slides/github}/highlights.blade.php | 1 - .../slides/github}/panorama.blade.php | 1 - .../slides/github/repos.blade.php} | 1 - .../Livewire/CommunityRetrospectivePage.php | 123 +------ .../portal/src/PortalServiceProvider.php | 13 +- .../src/Retrospective/RetrospectiveDeck.php | 72 ++++ .../Retrospective/RetrospectiveFilters.php | 68 ---- .../CommunityRetrospectivePageTest.php | 57 ++-- .../Feature/CommunityRetrospectiveTest.php | 261 -------------- .../tests/Feature/RetrospectiveDeckTest.php | 73 ++++ .../Feature/RetrospectiveFiltersTest.php | 35 -- .../tests/Feature/RetrospectiveSlidesTest.php | 62 ++++ 51 files changed, 1873 insertions(+), 807 deletions(-) create mode 100644 app-modules/activity/src/Retrospective/DiscordSource.php create mode 100644 app-modules/activity/src/Retrospective/Slides/MessagesSlide.php create mode 100644 app-modules/activity/src/Retrospective/Slides/NewMembersSlide.php create mode 100644 app-modules/activity/src/Retrospective/Slides/ReactionsSlide.php create mode 100644 app-modules/activity/src/Retrospective/Slides/TopMessageSlide.php create mode 100644 app-modules/activity/src/Retrospective/Slides/VoiceBoardSlide.php create mode 100644 app-modules/activity/tests/Feature/Retrospective/DiscordSourceTest.php create mode 100644 app-modules/community/src/Retrospective/Contracts/RetrospectiveSource.php create mode 100644 app-modules/community/src/Retrospective/Contracts/Slide.php create mode 100644 app-modules/community/src/Retrospective/DTOs/HeadlineMetrics.php create mode 100644 app-modules/community/src/Retrospective/DTOs/Metric.php create mode 100644 app-modules/community/src/Retrospective/DTOs/Period.php create mode 100644 app-modules/community/src/Retrospective/DTOs/SourceFilters.php create mode 100644 app-modules/community/src/Retrospective/DTOs/SourceResult.php rename app-modules/{portal/src/Retrospective/CommunityRetrospective.php => integration-github/src/Retrospective/GithubSource.php} (70%) create mode 100644 app-modules/integration-github/src/Retrospective/Slides/GithubCommunitySlide.php create mode 100644 app-modules/integration-github/src/Retrospective/Slides/GithubCoreSlide.php create mode 100644 app-modules/integration-github/src/Retrospective/Slides/GithubHighlightsSlide.php create mode 100644 app-modules/integration-github/src/Retrospective/Slides/GithubPanoramaSlide.php create mode 100644 app-modules/integration-github/src/Retrospective/Slides/GithubRepoSlide.php create mode 100644 app-modules/integration-github/tests/Feature/Retrospective/GithubSourceTest.php create mode 100644 app-modules/portal/resources/views/components/retro/controls.blade.php delete mode 100644 app-modules/portal/resources/views/components/retro/filters.blade.php create mode 100644 app-modules/portal/resources/views/retro/slides/discord/messages.blade.php create mode 100644 app-modules/portal/resources/views/retro/slides/discord/new-members.blade.php create mode 100644 app-modules/portal/resources/views/retro/slides/discord/reactions.blade.php create mode 100644 app-modules/portal/resources/views/retro/slides/discord/top-message.blade.php create mode 100644 app-modules/portal/resources/views/retro/slides/discord/voice-board.blade.php rename app-modules/portal/resources/views/{components/retro/slides => retro/slides/github}/community.blade.php (97%) rename app-modules/portal/resources/views/{components/retro/slides => retro/slides/github}/core.blade.php (96%) rename app-modules/portal/resources/views/{components/retro/slides => retro/slides/github}/highlights.blade.php (99%) rename app-modules/portal/resources/views/{components/retro/slides => retro/slides/github}/panorama.blade.php (99%) rename app-modules/portal/resources/views/{components/retro/slides/repo.blade.php => retro/slides/github/repos.blade.php} (98%) create mode 100644 app-modules/portal/src/Retrospective/RetrospectiveDeck.php delete mode 100644 app-modules/portal/src/Retrospective/RetrospectiveFilters.php delete mode 100644 app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php create mode 100644 app-modules/portal/tests/Feature/RetrospectiveDeckTest.php delete mode 100644 app-modules/portal/tests/Feature/RetrospectiveFiltersTest.php create mode 100644 app-modules/portal/tests/Feature/RetrospectiveSlidesTest.php diff --git a/app-modules/activity/composer.json b/app-modules/activity/composer.json index 72b9cec1f..6d0ea6f49 100644 --- a/app-modules/activity/composer.json +++ b/app-modules/activity/composer.json @@ -4,7 +4,10 @@ "type": "library", "version": "1.0", "license": "proprietary", - "require": {}, + "require": { + "he4rt/community": "^1.0.0", + "he4rt/identity": "^1.0.0" + }, "autoload": { "psr-4": { "He4rt\\Activity\\": "src/", diff --git a/app-modules/activity/src/ActivityServiceProvider.php b/app-modules/activity/src/ActivityServiceProvider.php index d79f3f9f9..c85f936ff 100644 --- a/app-modules/activity/src/ActivityServiceProvider.php +++ b/app-modules/activity/src/ActivityServiceProvider.php @@ -6,6 +6,7 @@ use He4rt\Activity\Message\Models\Message; use He4rt\Activity\Moderation\Models\ModerationEvent; +use He4rt\Activity\Retrospective\DiscordSource; use He4rt\Activity\Timeline\Delegated\PostEntry; use He4rt\Activity\Timeline\Listeners\PublishModerationToTimeline; use He4rt\Activity\Timeline\Listeners\ReassignTimelineOwnership; @@ -23,6 +24,8 @@ public function register(): void { $this->mergeConfigFrom(__DIR__.'/../config/activity-tracking.php', 'activity-tracking'); + // Fonte da retrospectiva, descoberta pelo portal via tagged services. + $this->app->tag([DiscordSource::class], 'retrospective.source'); } public function boot(): void diff --git a/app-modules/activity/src/Retrospective/DiscordSource.php b/app-modules/activity/src/Retrospective/DiscordSource.php new file mode 100644 index 000000000..750cb1410 --- /dev/null +++ b/app-modules/activity/src/Retrospective/DiscordSource.php @@ -0,0 +1,321 @@ +messages($period, $filters)->count(); + $withReactions = $this->messages($period, $filters)->where('reactions_total', '>', 0)->count(); + $pinned = $this->messages($period, $filters)->where('is_pinned', operator: true)->count(); + $chatters = $this->topChatters($period, $filters); + + $participants = Voice::query() + ->whereBetween('occurred_at', [$period->since, $period->until]) + ->distinct() + ->count('external_identity_id'); + $voiceXp = (int) Voice::query() + ->whereBetween('occurred_at', [$period->since, $period->until]) + ->sum('obtained_experience'); + $channels = $this->topVoiceChannels($period); + + $joins = MembershipEvent::query() + ->whereBetween('occurred_at', [$period->since, $period->until]) + ->where('kind', MembershipEventKind::UserJoin->value) + ->count(); + $boosts = MembershipEvent::query() + ->whereBetween('occurred_at', [$period->since, $period->until]) + ->where('kind', 'like', 'boost%') + ->count(); + + $totalReactions = $this->totalReactions($period, $filters); + $emojis = $this->topEmojis($period, $filters); + $topMessages = $this->topMessages($period, $filters); + + return new SourceResult( + key: $this->key(), + label: 'Discord', + headline: $this->headline($totalMessages, $participants, $joins, $totalReactions), + slides: $this->slides( + participants: $participants, + voiceXp: $voiceXp, + channels: $channels, + totalMessages: $totalMessages, + withReactions: $withReactions, + pinned: $pinned, + chatters: $chatters, + joins: $joins, + boosts: $boosts, + totalReactions: $totalReactions, + emojis: $emojis, + topMessages: $topMessages, + ), + ); + } + + private function headline(int $messages, int $participants, int $joins, int $reactions): HeadlineMetrics + { + $metrics = []; + + if ($messages > 0) { + $metrics[] = new Metric('Mensagens', $messages); + } + + if ($participants > 0) { + $metrics[] = new Metric('Pessoas em call', $participants); + } + + if ($joins > 0) { + $metrics[] = new Metric('Novos membros', $joins); + } + + if ($reactions > 0) { + $metrics[] = new Metric('Reações', $reactions); + } + + return new HeadlineMetrics($metrics); + } + + /** + * @param list $channels + * @param list $chatters + * @param list $emojis + * @param list $topMessages + * @return list + */ + private function slides( + int $participants, + int $voiceXp, + array $channels, + int $totalMessages, + int $withReactions, + int $pinned, + array $chatters, + int $joins, + int $boosts, + int $totalReactions, + array $emojis, + array $topMessages, + ): array { + $slides = []; + + if ($participants > 0) { + $slides[] = new VoiceBoardSlide($participants, $voiceXp, $channels); + } + + if ($totalMessages > 0) { + $slides[] = new MessagesSlide($totalMessages, $withReactions, $pinned, $chatters); + } + + if ($joins > 0 || $boosts > 0) { + $slides[] = new NewMembersSlide($joins, $boosts); + } + + if ($totalReactions > 0) { + $slides[] = new ReactionsSlide($totalReactions, $emojis); + } + + if ($topMessages !== []) { + $slides[] = new TopMessageSlide($topMessages); + } + + return $slides; + } + + /** + * Base de mensagens do recorte. hideBots derruba source_kind='bot' mas mantém + * linhas históricas com source_kind nulo. + * + * @return Builder + */ + private function messages(Period $period, SourceFilters $filters): Builder + { + return Message::query() + ->whereBetween('sent_at', [$period->since, $period->until]) + ->when( + $filters->hideBots, + fn (Builder $query): Builder => $query->where(function (Builder $inner): void { + $inner->whereNull('source_kind') + ->orWhere('source_kind', '!=', MessageSourceKind::Bot->value); + }), + ); + } + + /** + * @return list + */ + private function topChatters(Period $period, SourceFilters $filters): array + { + $rows = $this->messages($period, $filters) + ->groupBy('external_identity_id') + ->orderByRaw('COUNT(*) DESC') + ->limit(8) + ->get(['external_identity_id', DB::raw('COUNT(*) AS messages')]); + + $names = $this->displayNames($this->identityIds($rows)); + + return array_values( + $rows->map(fn (Message $row): array => [ + 'name' => $names[$row->external_identity_id] ?? 'Anônimo', + 'messages' => (int) $row->getAttribute('messages'), + ])->all(), + ); + } + + /** + * @return list + */ + private function topVoiceChannels(Period $period): array + { + return array_values( + Voice::query() + ->whereBetween('occurred_at', [$period->since, $period->until]) + ->whereNotNull('channel_name') + ->groupBy('channel_name') + ->orderByRaw('SUM(obtained_experience) DESC') + ->limit(6) + ->get([ + 'channel_name', + DB::raw('COUNT(*) AS events'), + DB::raw('COALESCE(SUM(obtained_experience), 0) AS xp'), + ]) + ->map(fn (Voice $row): array => [ + 'name' => $row->channel_name, + 'events' => (int) $row->getAttribute('events'), + 'xp' => (int) $row->getAttribute('xp'), + ]) + ->all(), + ); + } + + private function totalReactions(Period $period, SourceFilters $filters): int + { + return (int) Reaction::query() + ->where('reactable_type', 'message') + ->whereIn('reactable_id', $this->messages($period, $filters)->select('id')) + ->sum('count'); + } + + /** + * @return list + */ + private function topEmojis(Period $period, SourceFilters $filters): array + { + return array_values( + Reaction::query() + ->where('reactable_type', 'message') + ->whereIn('reactable_id', $this->messages($period, $filters)->select('id')) + ->groupBy('emoji_key', 'emoji_name') + ->orderByRaw('SUM("count") DESC') + ->limit(10) + ->get([ + 'emoji_key', + 'emoji_name', + DB::raw('MAX(emoji_id) AS emoji_id'), + DB::raw('SUM("count") AS total'), + ]) + ->map(fn (Reaction $row): array => [ + 'name' => $row->emoji_name ?? $row->emoji_key, + 'count' => (int) $row->getAttribute('total'), + 'custom' => $row->getAttribute('emoji_id') !== null, + ]) + ->all(), + ); + } + + /** + * @return list + */ + private function topMessages(Period $period, SourceFilters $filters): array + { + $rows = $this->messages($period, $filters) + ->where('reactions_total', '>', 0) + ->orderByDesc('reactions_total') + ->limit(3) + ->get(['id', 'external_identity_id', 'content', 'reactions_total']); + + $names = $this->displayNames($this->identityIds($rows)); + + return array_values( + $rows->map(fn (Message $row): array => [ + 'content' => (string) str($row->content)->limit(160), + 'author' => $names[$row->external_identity_id] ?? 'Anônimo', + 'reactions' => $row->reactions_total, + ])->all(), + ); + } + + /** + * @param Collection $rows + * @return list + */ + private function identityIds(Collection $rows): array + { + return array_values($rows->map(fn (Message $row): string => $row->external_identity_id)->all()); + } + + /** + * Resolve o nome de exibição do Discord para os external_identity_id do topo + * de um ranking: o username do Discord (metadata), senão o id externo. Só as + * poucas pessoas do topo entram aqui, então a query é barata. + * + * @param list $ids + * @return array + */ + private function displayNames(array $ids): array + { + if ($ids === []) { + return []; + } + + return ExternalIdentity::query() + ->whereIn('id', array_unique($ids)) + ->get() + ->mapWithKeys(function (ExternalIdentity $identity): array { + $metadata = $identity->metadata ?? []; + $username = is_string($metadata['username'] ?? null) ? $metadata['username'] : null; + + return [$identity->id => $username ?? $identity->external_account_id ?? 'Anônimo']; + }) + ->all(); + } +} diff --git a/app-modules/activity/src/Retrospective/Slides/MessagesSlide.php b/app-modules/activity/src/Retrospective/Slides/MessagesSlide.php new file mode 100644 index 000000000..c892d0a83 --- /dev/null +++ b/app-modules/activity/src/Retrospective/Slides/MessagesSlide.php @@ -0,0 +1,43 @@ + $chatters + */ + public function __construct( + private int $total, + private int $withReactions, + private int $pinned, + private array $chatters, + ) {} + + public function kind(): string + { + return 'discord.messages'; + } + + /** + * @return array{total: int, with_reactions: int, pinned: int, chatters: list} + */ + public function toArray(): array + { + return [ + 'total' => $this->total, + 'with_reactions' => $this->withReactions, + 'pinned' => $this->pinned, + 'chatters' => $this->chatters, + ]; + } +} diff --git a/app-modules/activity/src/Retrospective/Slides/NewMembersSlide.php b/app-modules/activity/src/Retrospective/Slides/NewMembersSlide.php new file mode 100644 index 000000000..00b9a816a --- /dev/null +++ b/app-modules/activity/src/Retrospective/Slides/NewMembersSlide.php @@ -0,0 +1,31 @@ + $this->joins, 'boosts' => $this->boosts]; + } +} diff --git a/app-modules/activity/src/Retrospective/Slides/ReactionsSlide.php b/app-modules/activity/src/Retrospective/Slides/ReactionsSlide.php new file mode 100644 index 000000000..47a374b30 --- /dev/null +++ b/app-modules/activity/src/Retrospective/Slides/ReactionsSlide.php @@ -0,0 +1,35 @@ + $emojis + */ + public function __construct( + private int $total, + private array $emojis, + ) {} + + public function kind(): string + { + return 'discord.reactions'; + } + + /** + * @return array{total: int, emojis: list} + */ + public function toArray(): array + { + return ['total' => $this->total, 'emojis' => $this->emojis]; + } +} diff --git a/app-modules/activity/src/Retrospective/Slides/TopMessageSlide.php b/app-modules/activity/src/Retrospective/Slides/TopMessageSlide.php new file mode 100644 index 000000000..1e9d5f5c7 --- /dev/null +++ b/app-modules/activity/src/Retrospective/Slides/TopMessageSlide.php @@ -0,0 +1,34 @@ + $messages + */ + public function __construct( + private array $messages, + ) {} + + public function kind(): string + { + return 'discord.top_message'; + } + + /** + * @return array{messages: list} + */ + public function toArray(): array + { + return ['messages' => $this->messages]; + } +} diff --git a/app-modules/activity/src/Retrospective/Slides/VoiceBoardSlide.php b/app-modules/activity/src/Retrospective/Slides/VoiceBoardSlide.php new file mode 100644 index 000000000..e41af6726 --- /dev/null +++ b/app-modules/activity/src/Retrospective/Slides/VoiceBoardSlide.php @@ -0,0 +1,41 @@ + $channels + */ + public function __construct( + private int $participants, + private int $xp, + private array $channels, + ) {} + + public function kind(): string + { + return 'discord.voice_board'; + } + + /** + * @return array{participants: int, xp: int, channels: list} + */ + public function toArray(): array + { + return [ + 'participants' => $this->participants, + 'xp' => $this->xp, + 'channels' => $this->channels, + ]; + } +} diff --git a/app-modules/activity/tests/Feature/Retrospective/DiscordSourceTest.php b/app-modules/activity/tests/Feature/Retrospective/DiscordSourceTest.php new file mode 100644 index 000000000..360141c5f --- /dev/null +++ b/app-modules/activity/tests/Feature/Retrospective/DiscordSourceTest.php @@ -0,0 +1,187 @@ +since = CarbonImmutable::parse('2026-06-01 00:00:00'); + $this->until = CarbonImmutable::parse('2026-06-07 23:59:59'); + $this->collect = fn (bool $hideBots = true): SourceResult => new DiscordSource()->collect( + Period::of($this->since, $this->until), + new SourceFilters(hideBots: $hideBots), + ); +}); + +/** + * @return array + */ +function dcSlide(SourceResult $result, string $kind): array +{ + foreach ($result->slides as $slide) { + if ($slide->kind() === $kind) { + return $slide->toArray(); + } + } + + return []; +} + +function dcIdentity(string $name): ExternalIdentity +{ + return ExternalIdentity::factory()->create(['metadata' => ['username' => $name]]); +} + +/** + * @param array $attributes + */ +function dcReaction(string $messageId, string $emojiKey, int $count, ?string $emojiId = null): void +{ + $reaction = new Reaction(); + $reaction->reactable_type = 'message'; + $reaction->reactable_id = $messageId; + $reaction->emoji_key = $emojiKey; + $reaction->emoji_name = $emojiKey; + $reaction->emoji_id = $emojiId; + $reaction->count = $count; + $reaction->count_burst = 0; + $reaction->count_normal = $count; + $reaction->save(); +} + +function dcMembership(string $identityId, string $kind, string $occurredAt): void +{ + $event = new MembershipEvent(); + $event->external_identity_id = $identityId; + $event->kind = $kind; + $event->occurred_at = CarbonImmutable::parse($occurredAt); + $event->save(); +} + +it('identifica-se como a fonte discord', function (): void { + expect(new DiscordSource()->key())->toBe('discord'); +}); + +it('devolve resultado vazio sem dado no recorte', function (): void { + expect(($this->collect)()->isEmpty())->toBeTrue(); +}); + +it('conta mensagens do recorte, escondendo bots e mantendo linhas sem source_kind', function (): void { + $alice = dcIdentity('Alice'); + $bob = dcIdentity('Bob'); + + Message::factory()->create(['external_identity_id' => $alice->id, 'sent_at' => '2026-06-02']); + Message::factory()->create(['external_identity_id' => $alice->id, 'sent_at' => '2026-06-03']); + Message::factory()->create(['external_identity_id' => $bob->id, 'sent_at' => '2026-06-02']); + Message::factory()->create(['external_identity_id' => $bob->id, 'sent_at' => '2026-06-02', 'source_kind' => MessageSourceKind::Bot]); + Message::factory()->create(['external_identity_id' => $alice->id, 'sent_at' => '2026-05-15']); + + $messages = dcSlide(($this->collect)(), 'discord.messages'); + + expect($messages['total'])->toBe(3) + ->and($messages['chatters'][0])->toMatchArray(['name' => 'Alice', 'messages' => 2]) + ->and($messages['chatters'][1])->toMatchArray(['name' => 'Bob', 'messages' => 1]); +}); + +it('mantém bots quando hideBots é falso', function (): void { + $bot = dcIdentity('Robô'); + Message::factory()->create(['external_identity_id' => $bot->id, 'sent_at' => '2026-06-02', 'source_kind' => MessageSourceKind::Bot]); + + expect(dcSlide(($this->collect)(false), 'discord.messages')['total'])->toBe(1) + ->and(($this->collect)()->isEmpty())->toBeTrue(); +}); + +it('escopa mensagens por sent_at, não por created_at', function (): void { + $alice = dcIdentity('Alice'); + // sent_at fora do recorte, created_at = agora (dentro): deve ficar de fora. + Message::factory()->create(['external_identity_id' => $alice->id, 'sent_at' => '2026-05-01']); + + expect(($this->collect)()->isEmpty())->toBeTrue(); +}); + +it('destaca mensagens com reação e fixadas, e a mais reagida', function (): void { + $alice = dcIdentity('Alice'); + + $top = Message::factory()->create(['external_identity_id' => $alice->id, 'sent_at' => '2026-06-02', 'reactions_total' => 12, 'content' => 'mensagem campeã']); + Message::factory()->create(['external_identity_id' => $alice->id, 'sent_at' => '2026-06-02', 'reactions_total' => 0]); + Message::factory()->create(['external_identity_id' => $alice->id, 'sent_at' => '2026-06-03', 'is_pinned' => true]); + + $result = ($this->collect)(); + $messages = dcSlide($result, 'discord.messages'); + $topMessage = dcSlide($result, 'discord.top_message'); + + expect($messages['with_reactions'])->toBe(1) + ->and($messages['pinned'])->toBe(1) + ->and($topMessage['messages'][0])->toMatchArray(['author' => 'Alice', 'reactions' => 12]) + ->and($topMessage['messages'][0]['content'])->toBe('mensagem campeã'); +}); + +it('agrega o board de voz por participantes, XP e canais', function (): void { + $alice = dcIdentity('Alice'); + $bob = dcIdentity('Bob'); + + Voice::factory()->create(['external_identity_id' => $alice->id, 'channel_name' => 'geral', 'obtained_experience' => 10, 'occurred_at' => '2026-06-02']); + Voice::factory()->create(['external_identity_id' => $alice->id, 'channel_name' => 'geral', 'obtained_experience' => 20, 'occurred_at' => '2026-06-03']); + Voice::factory()->create(['external_identity_id' => $bob->id, 'channel_name' => 'estudos', 'obtained_experience' => 5, 'occurred_at' => '2026-06-02']); + Voice::factory()->create(['external_identity_id' => $bob->id, 'channel_name' => 'geral', 'obtained_experience' => 99, 'occurred_at' => '2026-05-01']); + + $voice = dcSlide(($this->collect)(), 'discord.voice_board'); + + expect($voice['participants'])->toBe(2) + ->and($voice['xp'])->toBe(35) + ->and($voice['channels'][0])->toMatchArray(['name' => 'geral', 'events' => 2, 'xp' => 30]); +}); + +it('conta novos membros e boosts pelo occurred_at', function (): void { + $a = dcIdentity('A'); + $b = dcIdentity('B'); + $c = dcIdentity('C'); + + dcMembership($a->id, 'user_join', '2026-06-02'); + dcMembership($b->id, 'user_join', '2026-06-03'); + dcMembership($c->id, 'boost_tier_1', '2026-06-03'); + dcMembership($a->id, 'user_join', '2026-05-01'); + + $newMembers = dcSlide(($this->collect)(), 'discord.new_members'); + + expect($newMembers['joins'])->toBe(2) + ->and($newMembers['boosts'])->toBe(1); +}); + +it('agrega reações do recorte por emoji, escopando pelas mensagens do período', function (): void { + $alice = dcIdentity('Alice'); + + $inPeriod = Message::factory()->create(['external_identity_id' => $alice->id, 'sent_at' => '2026-06-02', 'reactions_total' => 8]); + $outside = Message::factory()->create(['external_identity_id' => $alice->id, 'sent_at' => '2026-05-01', 'reactions_total' => 50]); + + dcReaction($inPeriod->id, 'fire', 3); + dcReaction($inPeriod->id, 'heart', 5, emojiId: '123'); + dcReaction($outside->id, 'tada', 50); + + $reactions = dcSlide(($this->collect)(), 'discord.reactions'); + + expect($reactions['total'])->toBe(8) + ->and($reactions['emojis'][0])->toMatchArray(['name' => 'heart', 'count' => 5, 'custom' => true]) + ->and($reactions['emojis'][1])->toMatchArray(['name' => 'fire', 'count' => 3, 'custom' => false]); +}); + +it('expõe os chips do cover só para o que teve dado', function (): void { + $alice = dcIdentity('Alice'); + Message::factory()->create(['external_identity_id' => $alice->id, 'sent_at' => '2026-06-02']); + + $result = ($this->collect)(); + $labels = collect($result->headline->metrics)->map(fn ($metric): string => $metric->label)->all(); + + expect($result->label)->toBe('Discord') + ->and($labels)->toBe(['Mensagens']); +}); diff --git a/app-modules/community/src/Retrospective/Contracts/RetrospectiveSource.php b/app-modules/community/src/Retrospective/Contracts/RetrospectiveSource.php new file mode 100644 index 000000000..b52b32c11 --- /dev/null +++ b/app-modules/community/src/Retrospective/Contracts/RetrospectiveSource.php @@ -0,0 +1,27 @@ + + */ + public function toArray(): array; +} diff --git a/app-modules/community/src/Retrospective/DTOs/HeadlineMetrics.php b/app-modules/community/src/Retrospective/DTOs/HeadlineMetrics.php new file mode 100644 index 000000000..83f612310 --- /dev/null +++ b/app-modules/community/src/Retrospective/DTOs/HeadlineMetrics.php @@ -0,0 +1,24 @@ + $metrics + */ + public function __construct( + public array $metrics = [], + ) {} + + public function isEmpty(): bool + { + return $this->metrics === []; + } +} diff --git a/app-modules/community/src/Retrospective/DTOs/Metric.php b/app-modules/community/src/Retrospective/DTOs/Metric.php new file mode 100644 index 000000000..fc9f64238 --- /dev/null +++ b/app-modules/community/src/Retrospective/DTOs/Metric.php @@ -0,0 +1,19 @@ + $exclusions refs escondidos deck-wide, ex.: "pr:142", "actor:login" + */ + public function __construct( + public bool $hideBots = true, + public array $exclusions = [], + ) {} + + public function excludes(string $ref): bool + { + return in_array($ref, $this->exclusions, strict: true); + } +} diff --git a/app-modules/community/src/Retrospective/DTOs/SourceResult.php b/app-modules/community/src/Retrospective/DTOs/SourceResult.php new file mode 100644 index 000000000..17277fbc8 --- /dev/null +++ b/app-modules/community/src/Retrospective/DTOs/SourceResult.php @@ -0,0 +1,31 @@ + $slides + */ + public function __construct( + public string $key, + public string $label, + public HeadlineMetrics $headline, + public array $slides = [], + ) {} + + public function isEmpty(): bool + { + return $this->slides === [] && $this->headline->isEmpty(); + } +} diff --git a/app-modules/integration-github/composer.json b/app-modules/integration-github/composer.json index 813a972d0..58d8dbf6e 100644 --- a/app-modules/integration-github/composer.json +++ b/app-modules/integration-github/composer.json @@ -4,6 +4,9 @@ "type": "library", "version": "1.0.0", "license": "proprietary", + "require": { + "he4rt/community": "^1.0.0" + }, "autoload": { "psr-4": { "He4rt\\IntegrationGithub\\": "src/", diff --git a/app-modules/integration-github/src/IntegrationGithubServiceProvider.php b/app-modules/integration-github/src/IntegrationGithubServiceProvider.php index 35853a231..e944a91f9 100644 --- a/app-modules/integration-github/src/IntegrationGithubServiceProvider.php +++ b/app-modules/integration-github/src/IntegrationGithubServiceProvider.php @@ -5,6 +5,7 @@ namespace He4rt\IntegrationGithub; use He4rt\IntegrationGithub\Console\BackfillGithubCommand; +use He4rt\IntegrationGithub\Retrospective\GithubSource; use He4rt\IntegrationGithub\Transport\GitHubApiConnector; use He4rt\IntegrationGithub\Transport\GitHubOAuthConnector; use Illuminate\Support\ServiceProvider; @@ -29,6 +30,9 @@ public function register(): void }); $this->app->singleton(GitHubApiConnector::class, fn () => new GitHubApiConnector()); + + // Fonte da retrospectiva, descoberta pelo portal via tagged services. + $this->app->tag([GithubSource::class], 'retrospective.source'); } public function boot(): void diff --git a/app-modules/portal/src/Retrospective/CommunityRetrospective.php b/app-modules/integration-github/src/Retrospective/GithubSource.php similarity index 70% rename from app-modules/portal/src/Retrospective/CommunityRetrospective.php rename to app-modules/integration-github/src/Retrospective/GithubSource.php index 683eb2604..8402b7be7 100644 --- a/app-modules/portal/src/Retrospective/CommunityRetrospective.php +++ b/app-modules/integration-github/src/Retrospective/GithubSource.php @@ -2,92 +2,134 @@ declare(strict_types=1); -namespace He4rt\Portal\Retrospective; - +namespace He4rt\IntegrationGithub\Retrospective; + +use He4rt\Community\Retrospective\Contracts\RetrospectiveSource; +use He4rt\Community\Retrospective\Contracts\Slide; +use He4rt\Community\Retrospective\DTOs\HeadlineMetrics; +use He4rt\Community\Retrospective\DTOs\Metric; +use He4rt\Community\Retrospective\DTOs\Period; +use He4rt\Community\Retrospective\DTOs\SourceFilters; +use He4rt\Community\Retrospective\DTOs\SourceResult; use He4rt\IntegrationGithub\Enums\ContributionType; use He4rt\IntegrationGithub\Models\GithubContribution; +use He4rt\IntegrationGithub\Retrospective\Slides\GithubCommunitySlide; +use He4rt\IntegrationGithub\Retrospective\Slides\GithubCoreSlide; +use He4rt\IntegrationGithub\Retrospective\Slides\GithubHighlightsSlide; +use He4rt\IntegrationGithub\Retrospective\Slides\GithubPanoramaSlide; +use He4rt\IntegrationGithub\Retrospective\Slides\GithubRepoSlide; use Illuminate\Support\Collection; /** - * Read model for the community presentation. Applies the "filter on read" rules - * (decision #10): excludes only bots, counts by occurred_at within the period, and - * groups by contributor login. Closed-unmerged PRs DO count as participation but are - * broken out (prs_merged / prs_unmerged) so the view can distinguish their outcome. + * Fonte GitHub da retrospectiva. Preserva 1:1 o cálculo do antigo read model do + * portal (filtra bots, conta por occurred_at no período, agrupa por login, + * quebra PRs merged/unmerged) e o empacota em slides tipados. Repos só entram + * como card se tiverem PR no recorte; atividade só de review/issue/comentário + * segue contando em meta/people/highlights. */ -final readonly class CommunityRetrospective +final class GithubSource implements RetrospectiveSource { - public function __construct( - private RetrospectiveFilters $filters, - ) {} + public function key(): string + { + return 'github'; + } - /** - * @return array{ - * period: array{since: string, until: string}, - * meta: array, - * people: list>, - * repos: list>, - * highlights: list>, - * } - */ - public function build(): array + public function collect(Period $period, SourceFilters $filters): SourceResult { /** @var Collection $contributions */ $contributions = GithubContribution::query() - ->whereBetween('occurred_at', [$this->filters->since, $this->filters->until]) + ->whereBetween('occurred_at', [$period->since, $period->until]) ->get() ->when( - $this->filters->hideBots, + $filters->hideBots, fn (Collection $items): Collection => $items->reject(fn (GithubContribution $contribution): bool => $this->isBot($contribution)), ) - ->when( - $this->filters->repos !== [], - fn (Collection $items): Collection => $items->filter(fn (GithubContribution $contribution): bool => in_array($contribution->repo, $this->filters->repos, strict: true)), - ) - ->filter(fn (GithubContribution $contribution): bool => in_array($contribution->type, $this->filters->types, strict: true)) - ->reject(fn (GithubContribution $contribution): bool => $this->filteredOutByOutcome($contribution)) - ->when( - $this->filters->person !== null, - fn (Collection $items): Collection => $items->filter(fn (GithubContribution $contribution): bool => $contribution->actor_login === $this->filters->person), - ) ->values(); + if ($contributions->isEmpty()) { + return new SourceResult($this->key(), 'GitHub', new HeadlineMetrics(), []); + } + /** @var list> $people */ $people = $contributions ->groupBy('actor_login') ->map(fn (Collection $items, string $login): array => $this->person($login, $items)) - ->sortByDesc(fn (array $person): int => match ($this->filters->sort) { - 'prs' => (int) $person['prs'] * 1_000 + (int) $person['total'], - 'lines' => (int) $person['additions'] + (int) $person['deletions'], - default => (int) $person['total'], - }) + ->sortByDesc(fn (array $person): int => (int) $person['total']) ->values() ->all(); $repos = $this->repos($contributions); - - return [ - 'period' => ['since' => $this->filters->since->toDateString(), 'until' => $this->filters->until->toDateString()], - 'meta' => [ - 'people' => count($people), - 'prs' => $this->countType($contributions, ContributionType::Pr), - 'prs_merged' => $this->countMergedPrs($contributions), - 'prs_unmerged' => $this->countUnmergedPrs($contributions), - 'reviews' => $this->countType($contributions, ContributionType::Review), - 'issues' => $this->countType($contributions, ContributionType::Issue), - 'comments' => $this->countType($contributions, ContributionType::Comment), - 'review_comments' => $this->countType($contributions, ContributionType::ReviewComment), - 'commits' => $this->countType($contributions, ContributionType::Commit), - 'additions' => $this->sumMeta($contributions, 'additions'), - 'deletions' => $this->sumMeta($contributions, 'deletions'), - 'changed_files' => $this->sumMeta($contributions, 'changed_files'), - // Repos exibidos = só os com PR no recorte (mesmo universo dos cards). - 'repos' => count($repos), - 'total' => $contributions->count(), - ], - 'people' => $people, - 'repos' => $repos, - 'highlights' => $this->highlights($contributions), + $highlights = $this->highlights($contributions); + + $meta = [ + 'people' => count($people), + 'prs' => $this->countType($contributions, ContributionType::Pr), + 'prs_merged' => $this->countMergedPrs($contributions), + 'prs_unmerged' => $this->countUnmergedPrs($contributions), + 'reviews' => $this->countType($contributions, ContributionType::Review), + 'issues' => $this->countType($contributions, ContributionType::Issue), + 'comments' => $this->countType($contributions, ContributionType::Comment), + 'review_comments' => $this->countType($contributions, ContributionType::ReviewComment), + 'commits' => $this->countType($contributions, ContributionType::Commit), + 'additions' => $this->sumMeta($contributions, 'additions'), + 'deletions' => $this->sumMeta($contributions, 'deletions'), + 'changed_files' => $this->sumMeta($contributions, 'changed_files'), + // Repos exibidos = só os com PR no recorte (mesmo universo dos cards). + 'repos' => count($repos), + 'total' => $contributions->count(), ]; + + return new SourceResult( + key: $this->key(), + label: 'GitHub', + headline: $this->headline($meta), + slides: $this->slides($meta, $repos, $highlights, $people), + ); + } + + /** + * @param array $meta + * @param list> $repos + * @param list> $highlights + * @param list> $people + * @return list + */ + private function slides(array $meta, array $repos, array $highlights, array $people): array + { + $slides = [new GithubPanoramaSlide($meta)]; + + foreach ($repos as $i => $repo) { + $slides[] = new GithubRepoSlide($repo, $i + 1); + } + + if ($highlights !== []) { + $slides[] = new GithubHighlightsSlide($highlights); + } + + if ($people !== []) { + $slides[] = new GithubCoreSlide($people); + + if (count($people) > 5) { + $slides[] = new GithubCommunitySlide($people); + } + } + + return $slides; + } + + /** + * @param array $meta + */ + private function headline(array $meta): HeadlineMetrics + { + return new HeadlineMetrics([ + new Metric('Pessoas', $meta['people']), + new Metric('PRs', $meta['prs']), + new Metric('Reviews', $meta['reviews']), + new Metric('Issues', $meta['issues']), + new Metric('Commits', $meta['commits']), + new Metric('Linhas', $meta['additions'] + $meta['deletions']), + ]); } /** @@ -200,23 +242,6 @@ private function avatar(string $login, ?int $actorId): string : 'https://github.com/'.$login.'.png'; } - private function filteredOutByOutcome(GithubContribution $contribution): bool - { - if ($this->filters->outcome === null || $contribution->type !== ContributionType::Pr) { - return false; - } - - $metadata = $contribution->metadata ?? []; - $merged = ($metadata['merged'] ?? false) === true; - $state = $metadata['state'] ?? null; - - return match ($this->filters->outcome) { - 'merged' => !$merged, - 'open' => $state !== 'open', - 'closed' => $state !== 'closed' || $merged, - }; - } - /** * @return array{num: int, title: string, url: string|null, state: string|null, author_login: string, additions: int, deletions: int, changed_files: int} */ diff --git a/app-modules/integration-github/src/Retrospective/Slides/GithubCommunitySlide.php b/app-modules/integration-github/src/Retrospective/Slides/GithubCommunitySlide.php new file mode 100644 index 000000000..64572c1d9 --- /dev/null +++ b/app-modules/integration-github/src/Retrospective/Slides/GithubCommunitySlide.php @@ -0,0 +1,34 @@ +> $people + */ + public function __construct( + private array $people, + ) {} + + public function kind(): string + { + return 'github.community'; + } + + /** + * @return array{people: list>} + */ + public function toArray(): array + { + return ['people' => $this->people]; + } +} diff --git a/app-modules/integration-github/src/Retrospective/Slides/GithubCoreSlide.php b/app-modules/integration-github/src/Retrospective/Slides/GithubCoreSlide.php new file mode 100644 index 000000000..6184edd43 --- /dev/null +++ b/app-modules/integration-github/src/Retrospective/Slides/GithubCoreSlide.php @@ -0,0 +1,34 @@ +> $people + */ + public function __construct( + private array $people, + ) {} + + public function kind(): string + { + return 'github.core'; + } + + /** + * @return array{people: list>} + */ + public function toArray(): array + { + return ['people' => $this->people]; + } +} diff --git a/app-modules/integration-github/src/Retrospective/Slides/GithubHighlightsSlide.php b/app-modules/integration-github/src/Retrospective/Slides/GithubHighlightsSlide.php new file mode 100644 index 000000000..bb42a73ae --- /dev/null +++ b/app-modules/integration-github/src/Retrospective/Slides/GithubHighlightsSlide.php @@ -0,0 +1,34 @@ +> $highlights + */ + public function __construct( + private array $highlights, + ) {} + + public function kind(): string + { + return 'github.highlights'; + } + + /** + * @return array{highlights: list>} + */ + public function toArray(): array + { + return ['highlights' => $this->highlights]; + } +} diff --git a/app-modules/integration-github/src/Retrospective/Slides/GithubPanoramaSlide.php b/app-modules/integration-github/src/Retrospective/Slides/GithubPanoramaSlide.php new file mode 100644 index 000000000..1409a12d7 --- /dev/null +++ b/app-modules/integration-github/src/Retrospective/Slides/GithubPanoramaSlide.php @@ -0,0 +1,34 @@ + $meta + */ + public function __construct( + private array $meta, + ) {} + + public function kind(): string + { + return 'github.panorama'; + } + + /** + * @return array{meta: array} + */ + public function toArray(): array + { + return ['meta' => $this->meta]; + } +} diff --git a/app-modules/integration-github/src/Retrospective/Slides/GithubRepoSlide.php b/app-modules/integration-github/src/Retrospective/Slides/GithubRepoSlide.php new file mode 100644 index 000000000..29cd891aa --- /dev/null +++ b/app-modules/integration-github/src/Retrospective/Slides/GithubRepoSlide.php @@ -0,0 +1,35 @@ + $repo + */ + public function __construct( + private array $repo, + private int $index, + ) {} + + public function kind(): string + { + return 'github.repos'; + } + + /** + * @return array{repo: array, index: int} + */ + public function toArray(): array + { + return ['repo' => $this->repo, 'index' => $this->index]; + } +} diff --git a/app-modules/integration-github/tests/Feature/Retrospective/GithubSourceTest.php b/app-modules/integration-github/tests/Feature/Retrospective/GithubSourceTest.php new file mode 100644 index 000000000..5d81d9f3f --- /dev/null +++ b/app-modules/integration-github/tests/Feature/Retrospective/GithubSourceTest.php @@ -0,0 +1,246 @@ +since = CarbonImmutable::parse('2026-06-01 00:00:00'); + $this->until = CarbonImmutable::parse('2026-06-07 23:59:59'); + $this->collect = fn (bool $hideBots = true): SourceResult => new GithubSource()->collect( + Period::of($this->since, $this->until), + new SourceFilters(hideBots: $hideBots), + ); +}); + +/** + * @param array $attributes + */ +function ghContribution(array $attributes): GithubContribution +{ + return GithubContribution::factory()->create($attributes); +} + +/** + * @return array + */ +function ghSlide(SourceResult $result, string $kind): array +{ + foreach ($result->slides as $slide) { + if ($slide->kind() === $kind) { + return $slide->toArray(); + } + } + + return []; +} + +/** + * @return array + */ +function ghMeta(SourceResult $result): array +{ + return ghSlide($result, 'github.panorama')['meta'] ?? []; +} + +/** + * @return list> + */ +function ghPeople(SourceResult $result): array +{ + return ghSlide($result, 'github.core')['people'] ?? []; +} + +/** + * @return list> + */ +function ghRepos(SourceResult $result): array +{ + return collect($result->slides) + ->filter(fn ($slide): bool => $slide->kind() === 'github.repos') + ->map(fn ($slide): array => $slide->toArray()['repo']) + ->values() + ->all(); +} + +/** + * @return list> + */ +function ghHighlights(SourceResult $result): array +{ + return ghSlide($result, 'github.highlights')['highlights'] ?? []; +} + +it('identifica-se como a fonte github', function (): void { + expect(new GithubSource()->key())->toBe('github'); +}); + +it('agrega contribuições por pessoa com contagem por tipo e total, ordenado desc', function (): void { + ghContribution(['actor_login' => 'maria', 'actor_id' => 42, 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); + ghContribution(['actor_login' => 'maria', 'actor_id' => 42, 'type' => ContributionType::Issue, 'external_ref' => 'issue:1', 'occurred_at' => '2026-06-03']); + ghContribution(['actor_login' => 'joao', 'actor_id' => 7, 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-03']); + + $result = ($this->collect)(); + $meta = ghMeta($result); + $people = ghPeople($result); + + expect($meta['people'])->toBe(2) + ->and($meta['total'])->toBe(3) + ->and($people[0]['login'])->toBe('maria') + ->and($people[0]['total'])->toBe(2) + ->and($people[0]['prs'])->toBe(1) + ->and($people[0]['issues'])->toBe(1) + ->and($people[0]['avatar'])->toContain('42'); +}); + +it('exclui bots do ranking', function (): void { + ghContribution(['actor_login' => 'dependabot[bot]', 'type' => ContributionType::Pr, 'external_ref' => 'pr:9', 'occurred_at' => '2026-06-02', 'metadata' => ['is_bot' => true, 'state' => 'open', 'merged' => false]]); + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); + + $result = ($this->collect)(); + + expect(ghMeta($result)['people'])->toBe(1) + ->and(ghPeople($result)[0]['login'])->toBe('maria'); +}); + +it('mantém bots quando hideBots é falso', function (): void { + ghContribution(['actor_login' => 'dependabot[bot]', 'type' => ContributionType::Pr, 'external_ref' => 'pr:9', 'occurred_at' => '2026-06-02', 'metadata' => ['is_bot' => true, 'state' => 'open', 'merged' => false]]); + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); + + expect(ghMeta(($this->collect)(false))['people'])->toBe(2); +}); + +it('inclui PRs fechados sem merge no total, distinguindo por desfecho', function (): void { + ghContribution(['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'closed', 'merged' => false]]); + ghContribution(['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'closed', 'merged' => true]]); + ghContribution(['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:3', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); + + $result = ($this->collect)(); + $meta = ghMeta($result); + $person = collect(ghPeople($result))->firstWhere('login', 'a'); + + expect($meta['prs'])->toBe(3) + ->and($meta['prs_merged'])->toBe(1) + ->and($meta['prs_unmerged'])->toBe(1) + ->and($person['prs'])->toBe(3) + ->and($person['prs_merged'])->toBe(1) + ->and($person['prs_unmerged'])->toBe(1) + ->and($person['total'])->toBe(3); +}); + +it('conta comentários de issue e de review separadamente', function (): void { + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Comment, 'external_ref' => 'comment:1', 'occurred_at' => '2026-06-02']); + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::ReviewComment, 'external_ref' => 'review_comment:1', 'occurred_at' => '2026-06-02']); + + $result = ($this->collect)(); + $maria = collect(ghPeople($result))->firstWhere('login', 'maria'); + + expect(ghMeta($result)['comments'])->toBe(1) + ->and(ghMeta($result)['review_comments'])->toBe(1) + ->and($maria['comments'])->toBe(1) + ->and($maria['review_comments'])->toBe(1) + ->and($maria['total'])->toBe(2); +}); + +it('respeita a janela de período pelo occurred_at', function (): void { + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Issue, 'external_ref' => 'issue:1', 'occurred_at' => '2026-05-30']); + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Issue, 'external_ref' => 'issue:2', 'occurred_at' => '2026-06-03']); + + $meta = ghMeta(($this->collect)()); + + expect($meta['total'])->toBe(1) + ->and($meta['issues'])->toBe(1); +}); + +it('soma additions/deletions/changed_files de PRs em meta e por pessoa', function (): void { + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true, 'additions' => 100, 'deletions' => 20, 'changed_files' => 5]]); + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false, 'additions' => 30, 'deletions' => 4, 'changed_files' => 2]]); + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-02']); + + $result = ($this->collect)(); + $maria = collect(ghPeople($result))->firstWhere('login', 'maria'); + + expect(ghMeta($result)['additions'])->toBe(130) + ->and(ghMeta($result)['deletions'])->toBe(24) + ->and(ghMeta($result)['changed_files'])->toBe(7) + ->and($maria['additions'])->toBe(130) + ->and($maria['deletions'])->toBe(24); +}); + +it('expõe refs de PR e issue por pessoa para os chips de atividade', function (): void { + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:290', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true, 'title' => 'feat: x', 'url' => 'https://github.com/he4rt/heartdevs.com/pull/290']]); + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Issue, 'external_ref' => 'issue:12', 'occurred_at' => '2026-06-03', 'metadata' => ['title' => 'bug: y', 'url' => 'https://github.com/he4rt/heartdevs.com/issues/12']]); + + $maria = collect(ghPeople(($this->collect)()))->firstWhere('login', 'maria'); + + expect($maria['pr_refs'])->toHaveCount(1) + ->and($maria['pr_refs'][0])->toMatchArray(['num' => 290, 'title' => 'feat: x', 'state' => 'merged']) + ->and($maria['pr_refs'][0]['url'])->toContain('/pull/290') + ->and($maria['issue_refs'])->toHaveCount(1) + ->and($maria['issue_refs'][0])->toMatchArray(['num' => 12, 'title' => 'bug: y']); +}); + +it('ordena os refs de PR do mais recente para o mais antigo', function (): void { + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-01', 'metadata' => ['title' => 'antigo', 'url' => 'u1', 'state' => 'merged']]); + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-05', 'metadata' => ['title' => 'recente', 'url' => 'u2', 'state' => 'open']]); + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:3', 'occurred_at' => '2026-06-03', 'metadata' => ['title' => 'meio', 'url' => 'u3', 'state' => 'open']]); + + $maria = collect(ghPeople(($this->collect)()))->firstWhere('login', 'maria'); + + expect(array_column($maria['pr_refs'], 'num'))->toBe([2, 3, 1]); +}); + +it('agrupa PRs por repositório e lista destaques por linhas mudadas', function (): void { + ghContribution(['actor_login' => 'maria', 'repo' => 'he4rt/heartdevs.com', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true, 'title' => 'grande', 'url' => 'u1', 'additions' => 500, 'deletions' => 100, 'changed_files' => 10]]); + ghContribution(['actor_login' => 'joao', 'repo' => 'he4rt/bot', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false, 'title' => 'pequeno', 'url' => 'u2', 'additions' => 10, 'deletions' => 2, 'changed_files' => 1]]); + + $result = ($this->collect)(); + $repos = ghRepos($result); + $highlights = ghHighlights($result); + + expect($repos)->toHaveCount(2) + ->and(collect($repos)->firstWhere('full_name', 'he4rt/heartdevs.com')['name'])->toBe('heartdevs.com') + ->and(collect($repos)->firstWhere('full_name', 'he4rt/heartdevs.com')['prs'])->toHaveCount(1) + ->and($highlights[0]['num'])->toBe(1) + ->and($highlights[0]['additions'])->toBe(500) + ->and($highlights[0]['repo'])->toBe('he4rt/heartdevs.com'); +}); + +it('esconde da lista repos sem PR no recorte mas mantém suas contribuições nas stats', function (): void { + ghContribution(['actor_login' => 'maria', 'repo' => 'he4rt/com-pr', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true, 'title' => 't', 'url' => 'u', 'additions' => 5, 'deletions' => 1, 'changed_files' => 1]]); + ghContribution(['actor_login' => 'joao', 'repo' => 'he4rt/so-review', 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-02']); + + $result = ($this->collect)(); + + expect(collect(ghRepos($result))->pluck('full_name')->all())->toBe(['he4rt/com-pr']) + ->and(ghMeta($result)['reviews'])->toBe(1) + ->and(ghMeta($result)['repos'])->toBe(1); +}); + +it('devolve resultado vazio quando não há contribuições no recorte', function (): void { + expect(($this->collect)()->isEmpty())->toBeTrue(); +}); + +it('monta os chips do cover a partir do meta', function (): void { + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true, 'additions' => 10, 'deletions' => 5]]); + + $result = ($this->collect)(); + $labels = collect($result->headline->metrics)->map(fn ($metric): string => $metric->label)->all(); + + expect($result->label)->toBe('GitHub') + ->and($labels)->toContain('Pessoas') + ->and($labels)->toContain('PRs') + ->and($labels)->toContain('Linhas'); +}); diff --git a/app-modules/portal/composer.json b/app-modules/portal/composer.json index b2f77872f..818a6fd7f 100644 --- a/app-modules/portal/composer.json +++ b/app-modules/portal/composer.json @@ -4,7 +4,11 @@ "type": "library", "version": "1.0", "license": "proprietary", - "require": {}, + "require": { + "he4rt/activity": "^1.0.0", + "he4rt/community": "^1.0.0", + "he4rt/integration-github": "^1.0.0" + }, "autoload": { "psr-4": { "He4rt\\Portal\\": "src/", diff --git a/app-modules/portal/resources/views/community-retrospective.blade.php b/app-modules/portal/resources/views/community-retrospective.blade.php index 4632b8975..1d0ed5137 100644 --- a/app-modules/portal/resources/views/community-retrospective.blade.php +++ b/app-modules/portal/resources/views/community-retrospective.blade.php @@ -1,37 +1,24 @@ -@php ($noData = count($repoOptions) === 0) +@php ($noData = count($sources) === 0) +{{-- kind -> partial por convenção: "discord.voice_board" => portal::retro.slides.discord.voice-board --}} +@php ($slidePartial = fn (string $kind): string => 'portal::retro.slides.'.str_replace('_', '-', $kind)) @unless ($noData) - + @endunless @if ($noData) @else - - - @if ($byRepo) - @foreach ($data['repos'] as $i => $repo) - + + + @foreach ($sources as $source) + @foreach ($source->slides as $slide) + @include($slidePartial($slide->kind()), $slide->toArray()) @endforeach - @endif - @if ($showHighlights && count($data['highlights'])) - - @endif - @if (count($data['people'])) - - @if (count($data['people']) > 5) - - @endif - @endif - + @endforeach + + @endif diff --git a/app-modules/portal/resources/views/components/retro/controls.blade.php b/app-modules/portal/resources/views/components/retro/controls.blade.php new file mode 100644 index 000000000..8400dbc40 --- /dev/null +++ b/app-modules/portal/resources/views/components/retro/controls.blade.php @@ -0,0 +1,51 @@ +@props(['since', 'until', 'hideBots' => true]) +@php + $toDate = fn ($value): string => $value instanceof \Carbon\CarbonInterface ? $value->format('Y-m-d') : (string) $value; +@endphp +
+
+ +
diff --git a/app-modules/portal/resources/views/components/retro/deck.blade.php b/app-modules/portal/resources/views/components/retro/deck.blade.php index 1ed69fa0f..5b2b4aba5 100644 --- a/app-modules/portal/resources/views/components/retro/deck.blade.php +++ b/app-modules/portal/resources/views/components/retro/deck.blade.php @@ -77,7 +77,7 @@ class="deck-shell" @endunless diff --git a/app-modules/portal/resources/views/components/retro/filters.blade.php b/app-modules/portal/resources/views/components/retro/filters.blade.php deleted file mode 100644 index 8f78c5ca8..000000000 --- a/app-modules/portal/resources/views/components/retro/filters.blade.php +++ /dev/null @@ -1,162 +0,0 @@ -@props ([ - 'repoOptions' => [], - 'repos' => [], - 'types' => [], - 'hideBots' => true, - 'byRepo' => true, - 'showHighlights' => true -]) -@php - $typeOptions = [ - 'pr' => ['PRs', '--t-pr'], - 'review' => ['Reviews', '--t-review'], - 'issue' => ['Issues', '--t-issue'], - 'comment' => ['Comentários', '--t-comment'], - 'review_comment' => ['Coment. de review', '--t-review-comment'], - 'commit' => ['Commits', '--t-commit'], - ]; -@endphp -
-
- -
diff --git a/app-modules/portal/resources/views/components/retro/slides/closing.blade.php b/app-modules/portal/resources/views/components/retro/slides/closing.blade.php index db34d4c22..8c7d66d7a 100644 --- a/app-modules/portal/resources/views/components/retro/slides/closing.blade.php +++ b/app-modules/portal/resources/views/components/retro/slides/closing.blade.php @@ -1,29 +1,23 @@ -@props (['meta', 'people', 'period']) +@props(['sources', 'since', 'until']) +@php + $fmt = fn ($d): string => $d instanceof \Carbon\CarbonInterface + ? $d->timezone(config('app.display_timezone'))->format('d/m/Y') + : (string) $d; + $labels = collect($sources)->map(fn ($source): string => $source->label)->implode(', '); +@endphp

Obrigado a quem fez
o coração bater 💜

- {{ $meta['people'] }} pessoas, {{ $meta['total'] }} interações, {{ number_format($meta['additions'], 0, ',', '.') }} linhas. - Toda contribuição manteve a He4rt viva. + Cada PR, cada mensagem, cada call e cada reação manteve a He4rt viva.

- @foreach ($people as $p) - {{ $p['login'] }} + @foreach ($sources as $source) + {{ $source->label }} @endforeach
-

gerado a partir da GitHub API · {{ $period['since'] }} — {{ $period['until'] }}

+

+ gerado a partir de {{ $labels }} · {{ $fmt($since) }} — {{ $fmt($until) }} +

diff --git a/app-modules/portal/resources/views/components/retro/slides/cover.blade.php b/app-modules/portal/resources/views/components/retro/slides/cover.blade.php index f9ee8ea5d..109bd62a6 100644 --- a/app-modules/portal/resources/views/components/retro/slides/cover.blade.php +++ b/app-modules/portal/resources/views/components/retro/slides/cover.blade.php @@ -1,21 +1,42 @@ -@props (['meta', 'period']) +@props(['sources', 'since', 'until']) +@php + $fmt = fn ($d): string => $d instanceof \Carbon\CarbonInterface + ? $d->timezone(config('app.display_timezone'))->format('d/m/Y') + : (string) $d; +@endphp
He4rt
- RETROSPECTIVA · {{ $period['since'] }} — {{ $period['until'] }} + RETROSPECTIVA · {{ $fmt($since) }} — {{ $fmt($until) }}

Quem fez a He4rt bater

-

Participação da comunidade He4rt nos repositórios públicos, em gente, código e contexto. {{ $meta['people'] }} pessoas, {{ $meta['total'] }} interações, {{ number_format($meta['additions'], 0, ',', '.') }} linhas.

+

+ Participação da comunidade He4rt em cada frente — gente, código, conversa e presença. +

+
+ @foreach ($sources as $source) +
+
{{ $source->label }}
+
+ @foreach ($source->headline->metrics as $metric) + + {{ number_format($metric->value, 0, ',', '.') }} + {{ $metric->label }} + + @endforeach +
+
+ @endforeach +
navegue com
diff --git a/app-modules/portal/resources/views/retro/slides/discord/messages.blade.php b/app-modules/portal/resources/views/retro/slides/discord/messages.blade.php new file mode 100644 index 000000000..595059de3 --- /dev/null +++ b/app-modules/portal/resources/views/retro/slides/discord/messages.blade.php @@ -0,0 +1,30 @@ +
+
+ O papo +

O que rolou no chat

+

+ {{ number_format($total, 0, ',', '.') }} mensagens trocadas no período. +

+
+ {{ number_format($with_reactions, 0, ',', '.') }} com reação + {{ number_format($pinned, 0, ',', '.') }} fixadas +
+ @if (count($chatters)) +
+ @foreach ($chatters as $i => $chatter) +
+ #{{ $i + 1 }} + {{ $chatter['name'] }} + {{ number_format($chatter['messages'], 0, ',', '.') }} msgs +
+ @endforeach +
+ @endif +
+
diff --git a/app-modules/portal/resources/views/retro/slides/discord/new-members.blade.php b/app-modules/portal/resources/views/retro/slides/discord/new-members.blade.php new file mode 100644 index 000000000..c770d751e --- /dev/null +++ b/app-modules/portal/resources/views/retro/slides/discord/new-members.blade.php @@ -0,0 +1,21 @@ +
+
+ Chegando +

Gente nova na He4rt

+

+ Cada pessoa que entrou é uma nova história começando na comunidade. +

+
+
+
{{ number_format($joins, 0, ',', '.') }}
+
Novos membros
+
+ @if ($boosts > 0) +
+
{{ number_format($boosts, 0, ',', '.') }}
+
Boosts
+
+ @endif +
+
+
diff --git a/app-modules/portal/resources/views/retro/slides/discord/reactions.blade.php b/app-modules/portal/resources/views/retro/slides/discord/reactions.blade.php new file mode 100644 index 000000000..153f2b074 --- /dev/null +++ b/app-modules/portal/resources/views/retro/slides/discord/reactions.blade.php @@ -0,0 +1,19 @@ +
+
+ O feedback +

As reações do período

+

+ {{ number_format($total, 0, ',', '.') }} reações distribuídas nas mensagens. +

+ @if (count($emojis)) +
+ @foreach ($emojis as $emoji) + + {{ $emoji['custom'] ? ':'.$emoji['name'].':' : $emoji['name'] }} + {{ number_format($emoji['count'], 0, ',', '.') }} + + @endforeach +
+ @endif +
+
diff --git a/app-modules/portal/resources/views/retro/slides/discord/top-message.blade.php b/app-modules/portal/resources/views/retro/slides/discord/top-message.blade.php new file mode 100644 index 000000000..0b4e0eac6 --- /dev/null +++ b/app-modules/portal/resources/views/retro/slides/discord/top-message.blade.php @@ -0,0 +1,19 @@ +
+
+ O momento +

As mensagens mais reagidas

+
+ @foreach ($messages as $message) +
+
{{ $message['content'] }}
+
+ {{ '@'.$message['author'] }} + {{ number_format($message['reactions'], 0, ',', '.') }} reações +
+
+ @endforeach +
+
+
diff --git a/app-modules/portal/resources/views/retro/slides/discord/voice-board.blade.php b/app-modules/portal/resources/views/retro/slides/discord/voice-board.blade.php new file mode 100644 index 000000000..84f3f9397 --- /dev/null +++ b/app-modules/portal/resources/views/retro/slides/discord/voice-board.blade.php @@ -0,0 +1,20 @@ +
+
+ As calls +

Quem viveu no voice

+

+ {{ number_format($participants, 0, ',', '.') }} pessoas passaram pelas calls, + somando {{ number_format($xp, 0, ',', '.') }} XP de presença. +

+ @if (count($channels)) +
+ @foreach ($channels as $channel) +
+
{{ number_format($channel['xp'], 0, ',', '.') }}
+
{{ $channel['name'] }}
+
+ @endforeach +
+ @endif +
+
diff --git a/app-modules/portal/resources/views/components/retro/slides/community.blade.php b/app-modules/portal/resources/views/retro/slides/github/community.blade.php similarity index 97% rename from app-modules/portal/resources/views/components/retro/slides/community.blade.php rename to app-modules/portal/resources/views/retro/slides/github/community.blade.php index 1ef8ee2a9..996ba7620 100644 --- a/app-modules/portal/resources/views/components/retro/slides/community.blade.php +++ b/app-modules/portal/resources/views/retro/slides/github/community.blade.php @@ -1,4 +1,3 @@ -@props (['people']) @php ($tail = array_slice($people, 5))
diff --git a/app-modules/portal/resources/views/components/retro/slides/core.blade.php b/app-modules/portal/resources/views/retro/slides/github/core.blade.php similarity index 96% rename from app-modules/portal/resources/views/components/retro/slides/core.blade.php rename to app-modules/portal/resources/views/retro/slides/github/core.blade.php index 4eac37b62..9f2918a8a 100644 --- a/app-modules/portal/resources/views/components/retro/slides/core.blade.php +++ b/app-modules/portal/resources/views/retro/slides/github/core.blade.php @@ -1,4 +1,3 @@ -@props (['people']) @php ($top = array_slice($people, 0, 5))
diff --git a/app-modules/portal/resources/views/components/retro/slides/highlights.blade.php b/app-modules/portal/resources/views/retro/slides/github/highlights.blade.php similarity index 99% rename from app-modules/portal/resources/views/components/retro/slides/highlights.blade.php rename to app-modules/portal/resources/views/retro/slides/github/highlights.blade.php index d80ed214e..b58daf06c 100644 --- a/app-modules/portal/resources/views/components/retro/slides/highlights.blade.php +++ b/app-modules/portal/resources/views/retro/slides/github/highlights.blade.php @@ -1,4 +1,3 @@ -@props (['highlights']) @php ($stateColor = fn($state) => [ 'merged' => 'var(--st-merged)', 'open' => 'var(--st-open)', 'closed' => 'var(--st-closed)' ][$state ?? ''] ?? 'var(--st-open)')
diff --git a/app-modules/portal/resources/views/components/retro/slides/panorama.blade.php b/app-modules/portal/resources/views/retro/slides/github/panorama.blade.php similarity index 99% rename from app-modules/portal/resources/views/components/retro/slides/panorama.blade.php rename to app-modules/portal/resources/views/retro/slides/github/panorama.blade.php index bb92585b7..4ece2827d 100644 --- a/app-modules/portal/resources/views/components/retro/slides/panorama.blade.php +++ b/app-modules/portal/resources/views/retro/slides/github/panorama.blade.php @@ -1,4 +1,3 @@ -@props (['meta'])
O panorama diff --git a/app-modules/portal/resources/views/components/retro/slides/repo.blade.php b/app-modules/portal/resources/views/retro/slides/github/repos.blade.php similarity index 98% rename from app-modules/portal/resources/views/components/retro/slides/repo.blade.php rename to app-modules/portal/resources/views/retro/slides/github/repos.blade.php index 9eedb7d46..8b997cc6c 100644 --- a/app-modules/portal/resources/views/components/retro/slides/repo.blade.php +++ b/app-modules/portal/resources/views/retro/slides/github/repos.blade.php @@ -1,4 +1,3 @@ -@props (['repo', 'index' => 1])
diff --git a/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php b/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php index 9fe0df7c8..327d4d089 100644 --- a/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php +++ b/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php @@ -6,10 +6,9 @@ use Carbon\CarbonImmutable; use Carbon\CarbonInterface; -use He4rt\IntegrationGithub\Enums\ContributionType; -use He4rt\IntegrationGithub\Models\GithubContribution; -use He4rt\Portal\Retrospective\CommunityRetrospective; -use He4rt\Portal\Retrospective\RetrospectiveFilters; +use He4rt\Community\Retrospective\DTOs\Period; +use He4rt\Community\Retrospective\DTOs\SourceFilters; +use He4rt\Portal\Retrospective\RetrospectiveDeck; use Illuminate\Contracts\View\View; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; @@ -26,47 +25,16 @@ final class CommunityRetrospectivePage extends Component #[Url] public ?string $until = null; - /** @var list */ - #[Url] - public array $repos = []; - - /** @var list */ - #[Url] - public array $types = []; - - #[Url] - public ?string $outcome = null; - - #[Url] - public ?string $person = null; - #[Url] public bool $hideBots = true; - #[Url] - public string $sort = 'total'; - - #[Url] - public bool $byRepo = true; - - #[Url] - public bool $showHighlights = true; - - public function toggleType(string $type): void - { - $this->types = $this->toggleAware($this->types, $this->allTypes(), $type); - } - - public function toggleRepo(string $repo): void - { - $this->repos = $this->toggleAware($this->repos, $this->allRepos(), $repo); - } - public function setPreset(string $preset): void { $this->since = match ($preset) { 'mes' => CarbonImmutable::now()->subMonth()->toDateString(), - 'tudo' => $this->firstContributionDate(), + // "Tudo" = desde antes da fundação da comunidade; cobre todo o histórico + // sem acoplar o portal à menor data de nenhuma fonte específica. + 'tudo' => CarbonImmutable::create(2_015, 1, 1)->toDateString(), default => CarbonImmutable::now()->startOfWeek(CarbonInterface::MONDAY)->subWeek()->toDateString(), }; @@ -83,78 +51,17 @@ public function render(): View ? CarbonImmutable::parse($this->until)->endOfDay() : CarbonImmutable::now(); - $filters = RetrospectiveFilters::make($since, $until, $this->repos, $this->types, $this->outcome, $this->person, $this->hideBots, $this->sort); - $data = new CommunityRetrospective($filters)->build(); - - $repoOptions = collect($this->allRepos()) - ->mapWithKeys(fn (string $repo): array => [$repo => (string) str($repo)->afterLast('/')]) - ->all(); + $sources = resolve(RetrospectiveDeck::class)->compose( + Period::of($since, $until), + new SourceFilters(hideBots: $this->hideBots), + ); return view('portal::community-retrospective', [ - 'data' => $data, - 'repoOptions' => $repoOptions, - 'stateKey' => md5((string) json_encode([ - $this->since, $this->until, $this->repos, $this->types, $this->outcome, - $this->person, $this->hideBots, $this->sort, $this->byRepo, $this->showHighlights, - ])), + 'sources' => $sources, + 'since' => $since, + 'until' => $until, + 'hideBots' => $this->hideBots, + 'stateKey' => md5((string) json_encode([$this->since, $this->until, $this->hideBots])), ]); } - - /** - * @return list - */ - private function allTypes(): array - { - return array_map(fn (ContributionType $type): string => $type->value, ContributionType::cases()); - } - - /** - * Toggle "ciente de todos": com a lista vazia (= todos), clicar desliga apenas - * aquele item; voltar a ter todos selecionados normaliza de volta para vazio. - * - * @param list $selected - * @param list $all - * @return list - */ - private function toggleAware(array $selected, array $all, string $value): array - { - $current = $selected === [] ? $all : $selected; - - $next = in_array($value, $current, strict: true) - ? array_values(array_diff($current, [$value])) - : array_values(array_unique([...$current, $value])); - - return count($next) === count($all) ? [] : $next; - } - - /** - * Data da contribuição mais antiga dentro do escopo atual (repos - * selecionados), para o preset "tudo" cobrir o histórico real do que está - * sendo apresentado. Sem registros, cai no mesmo default semanal do render(). - */ - private function firstContributionDate(): string - { - $first = GithubContribution::query() - ->when($this->repos !== [], fn ($query) => $query->whereIn('repo', $this->repos)) - ->min('occurred_at'); - - return is_string($first) && $first !== '' - ? CarbonImmutable::parse($first)->toDateString() - : CarbonImmutable::now()->startOfWeek(CarbonInterface::MONDAY)->subWeek()->toDateString(); - } - - /** - * @return list - */ - private function allRepos(): array - { - /** @var list $repos */ - $repos = GithubContribution::query() - ->distinct() - ->orderBy('repo') - ->pluck('repo') - ->all(); - - return $repos; - } } diff --git a/app-modules/portal/src/PortalServiceProvider.php b/app-modules/portal/src/PortalServiceProvider.php index 2bcdc9d33..267cb2777 100644 --- a/app-modules/portal/src/PortalServiceProvider.php +++ b/app-modules/portal/src/PortalServiceProvider.php @@ -4,17 +4,28 @@ namespace He4rt\Portal; +use He4rt\Community\Retrospective\Contracts\RetrospectiveSource; use He4rt\Portal\Livewire\CommunityRetrospectivePage; use He4rt\Portal\Livewire\HeroSection; use He4rt\Portal\Livewire\Homepage; use He4rt\Portal\Livewire\SocialLinksPage; +use He4rt\Portal\Retrospective\RetrospectiveDeck; +use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; use Livewire\Livewire; class PortalServiceProvider extends ServiceProvider { - public function register(): void {} + public function register(): void + { + $this->app->bind(RetrospectiveDeck::class, static function (Application $app): RetrospectiveDeck { + /** @var iterable $sources */ + $sources = $app->tagged('retrospective.source'); + + return new RetrospectiveDeck($sources); + }); + } public function boot(): void { diff --git a/app-modules/portal/src/Retrospective/RetrospectiveDeck.php b/app-modules/portal/src/Retrospective/RetrospectiveDeck.php new file mode 100644 index 000000000..bdf263b90 --- /dev/null +++ b/app-modules/portal/src/Retrospective/RetrospectiveDeck.php @@ -0,0 +1,72 @@ + + */ + private const array ORDER = ['github', 'discord']; + + /** @var list */ + private array $sources; + + /** + * @param iterable $sources + */ + public function __construct(iterable $sources) + { + $this->sources = array_values( + is_array($sources) ? $sources : iterator_to_array($sources, preserve_keys: false), + ); + } + + /** + * @return list + */ + public function compose(Period $period, SourceFilters $filters): array + { + $results = []; + + foreach ($this->sources as $source) { + $result = $source->collect($period, $filters); + + if (!$result->isEmpty()) { + $results[] = $result; + } + } + + usort( + $results, + fn (SourceResult $a, SourceResult $b): int => $this->position($a->key) <=> $this->position($b->key), + ); + + return $results; + } + + private function position(string $key): int + { + $index = array_search($key, self::ORDER, strict: true); + + return $index === false ? count(self::ORDER) : $index; + } +} diff --git a/app-modules/portal/src/Retrospective/RetrospectiveFilters.php b/app-modules/portal/src/Retrospective/RetrospectiveFilters.php deleted file mode 100644 index f8713fa02..000000000 --- a/app-modules/portal/src/Retrospective/RetrospectiveFilters.php +++ /dev/null @@ -1,68 +0,0 @@ - $repos full_names; vazio = todos - * @param list $types - * @param 'merged'|'open'|'closed'|null $outcome - * @param 'total'|'prs'|'lines' $sort - */ - public function __construct( - public CarbonInterface $since, - public CarbonInterface $until, - public array $repos = [], - public array $types = [], - public ?string $outcome = null, - public ?string $person = null, - public bool $hideBots = true, - public string $sort = 'total', - ) {} - - public static function period(CarbonInterface $since, CarbonInterface $until): self - { - return new self($since, $until, types: ContributionType::cases()); - } - - /** - * @param list $repos - * @param list $types - */ - public static function make( - CarbonInterface $since, - CarbonInterface $until, - array $repos = [], - array $types = [], - ?string $outcome = null, - ?string $person = null, - bool $hideBots = true, - string $sort = 'total', - ): self { - $parsedTypes = array_values(array_filter(array_map( - ContributionType::tryFrom(...), - $types, - ))); - - return new self( - since: $since, - until: $until, - repos: array_values(array_filter($repos)), - types: $parsedTypes === [] ? ContributionType::cases() : $parsedTypes, - outcome: in_array($outcome, ['merged', 'open', 'closed'], strict: true) ? $outcome : null, - person: ($person === null || $person === '') ? null : $person, - hideBots: $hideBots, - sort: in_array($sort, ['total', 'prs', 'lines'], strict: true) ? $sort : 'total', - ); - } -} diff --git a/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php b/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php index 38bc9b70f..650aea118 100644 --- a/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php +++ b/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php @@ -3,6 +3,8 @@ declare(strict_types=1); use Carbon\CarbonImmutable; +use He4rt\Activity\Message\Models\Message; +use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; use He4rt\IntegrationGithub\Enums\ContributionType; use He4rt\IntegrationGithub\Models\GithubContribution; use He4rt\Portal\Livewire\CommunityRetrospectivePage; @@ -38,6 +40,22 @@ test()->get('/comunidade/retrospectiva')->assertOk(); }); +it('junta GitHub e Discord no mesmo deck', function (): void { + GithubContribution::factory()->create([ + 'actor_login' => 'maria', 'type' => ContributionType::Pr, + 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false], + ]); + + $identity = ExternalIdentity::factory()->create(); + Message::factory()->create(['external_identity_id' => $identity->id, 'sent_at' => '2026-06-02']); + + livewire(CommunityRetrospectivePage::class, ['since' => '2026-06-01', 'until' => '2026-06-07']) + ->assertOk() + ->assertSee('GitHub') + ->assertSee('Discord') + ->assertSee('O que rolou no chat'); +}); + it('inclui e marca contribuidor cujo único PR foi fechado sem merge', function (): void { GithubContribution::factory()->create([ 'actor_login' => 'rejeitada', 'actor_id' => 99, 'type' => ContributionType::Pr, @@ -63,7 +81,7 @@ }); it('mostra o convite pra reunião quando não há nenhuma contribuição', function (): void { - // banco zerado: nenhum repositório/estatística → estado vazio com CTA, sem o deck normal + // banco zerado: nenhuma fonte com dado → estado vazio com CTA, sem o deck normal test()->get('/comunidade/retrospectiva') ->assertOk() ->assertSee('Métricas') @@ -73,25 +91,13 @@ ->assertDontSee('Filtros'); }); -it('filtra por tipo ao alternar um tipo de contribuição', function (): void { - GithubContribution::factory()->create([ - 'actor_login' => 'soreview', 'type' => ContributionType::Review, - 'external_ref' => 'review:1', 'occurred_at' => '2026-06-02', - ]); - - livewire(CommunityRetrospectivePage::class, ['since' => '2026-06-01', 'until' => '2026-06-07']) - ->assertSee('soreview') - ->call('toggleType', 'review') - ->assertDontSee('soreview'); -}); - -it('mantém o estado dos filtros (toggle de bots)', function (): void { +it('mantém o estado do toggle de bots', function (): void { livewire(CommunityRetrospectivePage::class) ->set('hideBots', value: false) ->assertSet('hideBots', value: false); }); -it('preset "tudo" ancora o período na primeira contribuição e traz o histórico inteiro', function (): void { +it('preset "tudo" traz o histórico inteiro', function (): void { $this->travelTo(CarbonImmutable::parse('2026-06-04 10:00:00')); GithubContribution::factory()->create([ @@ -105,27 +111,6 @@ livewire(CommunityRetrospectivePage::class) ->call('setPreset', 'tudo') - ->assertSet('since', '2020-03-30') ->assertSee('pioneira') ->assertSee('recente'); }); - -it('preset "tudo" com repos filtrados ancora na 1ª contribuição daqueles repos', function (): void { - $this->travelTo(CarbonImmutable::parse('2026-06-04 10:00:00')); - - GithubContribution::factory()->create([ - 'repo' => 'he4rt/antigo', 'actor_login' => 'veterano', 'actor_id' => 1, 'type' => ContributionType::Commit, - 'external_ref' => 'commit:old', 'occurred_at' => '2018-01-01 00:00:00', - ]); - GithubContribution::factory()->create([ - 'repo' => 'he4rt/4noobs', 'actor_login' => 'pioneira', 'actor_id' => 2, 'type' => ContributionType::Commit, - 'external_ref' => 'commit:abc', 'occurred_at' => '2020-03-30 02:13:45', - ]); - - livewire(CommunityRetrospectivePage::class) - ->set('repos', ['he4rt/4noobs']) - ->call('setPreset', 'tudo') - ->assertSet('since', '2020-03-30') - ->assertSee('pioneira') - ->assertDontSee('veterano'); -}); diff --git a/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php b/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php deleted file mode 100644 index 83018a4c3..000000000 --- a/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php +++ /dev/null @@ -1,261 +0,0 @@ -since = CarbonImmutable::parse('2026-06-01 00:00:00'); - $this->until = CarbonImmutable::parse('2026-06-07 23:59:59'); - $this->build = fn (?RetrospectiveFilters $filters = null): array => new CommunityRetrospective( - $filters ?? RetrospectiveFilters::period($this->since, $this->until), - )->build(); -}); - -/** - * @param array $attributes - */ -function contribution(array $attributes): GithubContribution -{ - return GithubContribution::factory()->create($attributes); -} - -it('agrega contribuições por pessoa com contagem por tipo e total, ordenado desc', function (): void { - contribution(['actor_login' => 'maria', 'actor_id' => 42, 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); - contribution(['actor_login' => 'maria', 'actor_id' => 42, 'type' => ContributionType::Issue, 'external_ref' => 'issue:1', 'occurred_at' => '2026-06-03']); - contribution(['actor_login' => 'joao', 'actor_id' => 7, 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-03']); - - $data = ($this->build)(); - - expect($data['meta']['people'])->toBe(2) - ->and($data['meta']['total'])->toBe(3) - ->and($data['people'][0]['login'])->toBe('maria') - ->and($data['people'][0]['total'])->toBe(2) - ->and($data['people'][0]['prs'])->toBe(1) - ->and($data['people'][0]['issues'])->toBe(1) - ->and($data['people'][0]['avatar'])->toContain('42'); -}); - -it('exclui bots do ranking', function (): void { - contribution(['actor_login' => 'dependabot[bot]', 'type' => ContributionType::Pr, 'external_ref' => 'pr:9', 'occurred_at' => '2026-06-02', 'metadata' => ['is_bot' => true, 'state' => 'open', 'merged' => false]]); - contribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); - - $data = ($this->build)(); - - expect($data['meta']['people'])->toBe(1) - ->and($data['people'][0]['login'])->toBe('maria'); -}); - -it('inclui PRs fechados sem merge no total, distinguindo por desfecho', function (): void { - contribution(['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'closed', 'merged' => false]]); - contribution(['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'closed', 'merged' => true]]); - contribution(['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:3', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); - - $data = ($this->build)(); - - $person = collect($data['people'])->firstWhere('login', 'a'); - - expect($data['meta']['prs'])->toBe(3) - ->and($data['meta']['prs_merged'])->toBe(1) - ->and($data['meta']['prs_unmerged'])->toBe(1) - ->and($person['prs'])->toBe(3) - ->and($person['prs_merged'])->toBe(1) - ->and($person['prs_unmerged'])->toBe(1) - ->and($person['total'])->toBe(3); -}); - -it('conta comentários de issue e de review separadamente', function (): void { - contribution(['actor_login' => 'maria', 'type' => ContributionType::Comment, 'external_ref' => 'comment:1', 'occurred_at' => '2026-06-02']); - contribution(['actor_login' => 'maria', 'type' => ContributionType::ReviewComment, 'external_ref' => 'review_comment:1', 'occurred_at' => '2026-06-02']); - - $data = ($this->build)(); - $maria = collect($data['people'])->firstWhere('login', 'maria'); - - expect($data['meta']['comments'])->toBe(1) - ->and($data['meta']['review_comments'])->toBe(1) - ->and($maria['comments'])->toBe(1) - ->and($maria['review_comments'])->toBe(1) - ->and($maria['total'])->toBe(2); -}); - -it('respeita a janela de período pelo occurred_at', function (): void { - contribution(['actor_login' => 'maria', 'type' => ContributionType::Issue, 'external_ref' => 'issue:1', 'occurred_at' => '2026-05-30']); - contribution(['actor_login' => 'maria', 'type' => ContributionType::Issue, 'external_ref' => 'issue:2', 'occurred_at' => '2026-06-03']); - - $data = ($this->build)(); - - expect($data['meta']['total'])->toBe(1) - ->and($data['meta']['issues'])->toBe(1); -}); - -it('soma additions/deletions/changed_files de PRs em meta e por pessoa', function (): void { - contribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true, 'additions' => 100, 'deletions' => 20, 'changed_files' => 5]]); - contribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false, 'additions' => 30, 'deletions' => 4, 'changed_files' => 2]]); - contribution(['actor_login' => 'maria', 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-02']); - - $data = ($this->build)(); - $maria = collect($data['people'])->firstWhere('login', 'maria'); - - expect($data['meta']['additions'])->toBe(130) - ->and($data['meta']['deletions'])->toBe(24) - ->and($data['meta']['changed_files'])->toBe(7) - ->and($maria['additions'])->toBe(130) - ->and($maria['deletions'])->toBe(24); -}); - -it('expõe refs de PR e issue por pessoa para os chips de atividade', function (): void { - contribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:290', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true, 'title' => 'feat: x', 'url' => 'https://github.com/he4rt/heartdevs.com/pull/290']]); - contribution(['actor_login' => 'maria', 'type' => ContributionType::Issue, 'external_ref' => 'issue:12', 'occurred_at' => '2026-06-03', 'metadata' => ['title' => 'bug: y', 'url' => 'https://github.com/he4rt/heartdevs.com/issues/12']]); - - $data = ($this->build)(); - $maria = collect($data['people'])->firstWhere('login', 'maria'); - - expect($maria['pr_refs'])->toHaveCount(1) - ->and($maria['pr_refs'][0])->toMatchArray(['num' => 290, 'title' => 'feat: x', 'state' => 'merged']) - ->and($maria['pr_refs'][0]['url'])->toContain('/pull/290') - ->and($maria['issue_refs'])->toHaveCount(1) - ->and($maria['issue_refs'][0])->toMatchArray(['num' => 12, 'title' => 'bug: y']); -}); - -it('ordena os refs de PR do mais recente para o mais antigo (para o "últimos N")', function (): void { - contribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-01', 'metadata' => ['title' => 'antigo', 'url' => 'u1', 'state' => 'merged']]); - contribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-05', 'metadata' => ['title' => 'recente', 'url' => 'u2', 'state' => 'open']]); - contribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:3', 'occurred_at' => '2026-06-03', 'metadata' => ['title' => 'meio', 'url' => 'u3', 'state' => 'open']]); - - $maria = collect(($this->build)()['people'])->firstWhere('login', 'maria'); - - expect(array_column($maria['pr_refs'], 'num'))->toBe([2, 3, 1]); -}); - -it('na view, mostra só os 3 primeiros refs e um chip "mais X…"', function (): void { - $person = [ - 'pr_refs' => array_map( - fn (int $n): array => ['num' => $n, 'title' => 'pr '.$n, 'url' => null, 'state' => 'open'], - [10, 9, 8, 7, 6], - ), - 'issue_refs' => [], - 'reviews' => 0, - 'comments' => 0, - 'review_comments' => 0, - 'commits' => 0, - ]; - - $html = Blade::render('', ['person' => $person]); - - expect($html)->toContain('#10') - ->and($html)->toContain('#9') - ->and($html)->toContain('#8') - ->and($html)->not->toContain('#7') - ->and($html)->not->toContain('#6') - ->and($html)->toContain('mais 2…'); -}); - -it('o card compacto da comunidade mostra só ícone + somatória dos tipos com contribuição', function (): void { - $person = [ - 'pr_refs' => [ - ['num' => 1, 'title' => 'feat: a', 'url' => null, 'state' => 'open'], - ['num' => 2, 'title' => 'feat: b', 'url' => null, 'state' => 'merged'], - ], - 'issue_refs' => [], - 'reviews' => 5, - 'comments' => 0, - 'review_comments' => 3, - 'commits' => 0, - ]; - - $html = Blade::render('', ['person' => $person]); - - // 3 tipos com n>0: PR(2), review(5), review_comment(3) → 3 pills, sem zeros - expect(mb_substr_count($html, 'class="cstat '))->toBe(3) - ->and($html)->toContain('2') - ->and($html)->toContain('5') - ->and($html)->toContain('3') - ->and($html)->not->toContain('feat:'); // sem títulos de PR -}); - -it('agrupa PRs por repositório e lista destaques por linhas mudadas', function (): void { - contribution(['actor_login' => 'maria', 'repo' => 'he4rt/heartdevs.com', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true, 'title' => 'grande', 'url' => 'u1', 'additions' => 500, 'deletions' => 100, 'changed_files' => 10]]); - contribution(['actor_login' => 'joao', 'repo' => 'he4rt/bot', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false, 'title' => 'pequeno', 'url' => 'u2', 'additions' => 10, 'deletions' => 2, 'changed_files' => 1]]); - - $data = ($this->build)(); - - expect($data['repos'])->toHaveCount(2) - ->and(collect($data['repos'])->firstWhere('full_name', 'he4rt/heartdevs.com')['name'])->toBe('heartdevs.com') - ->and(collect($data['repos'])->firstWhere('full_name', 'he4rt/heartdevs.com')['prs'])->toHaveCount(1) - ->and($data['highlights'][0]['num'])->toBe(1) - ->and($data['highlights'][0]['additions'])->toBe(500) - ->and($data['highlights'][0]['repo'])->toBe('he4rt/heartdevs.com'); -}); - -it('esconde da lista repos sem PR no recorte mas mantém suas contribuições nas stats', function (): void { - contribution(['actor_login' => 'maria', 'repo' => 'he4rt/com-pr', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true, 'title' => 't', 'url' => 'u', 'additions' => 5, 'deletions' => 1, 'changed_files' => 1]]); - contribution(['actor_login' => 'joao', 'repo' => 'he4rt/so-review', 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-02']); - - $data = ($this->build)(); - - expect(collect($data['repos'])->pluck('full_name')->all())->toBe(['he4rt/com-pr']) - ->and($data['meta']['reviews'])->toBe(1) // review segue contando nas stats gerais - ->and($data['meta']['repos'])->toBe(1); // panorama conta só repos com PR -}); - -it('o panorama pluraliza repositórios conforme a contagem', function (int $repos, string $expected): void { - $meta = ['people' => 1, 'total' => 1, 'prs' => 1, 'prs_merged' => 1, 'prs_unmerged' => 0, 'reviews' => 0, 'issues' => 0, 'comments' => 0, 'review_comments' => 0, 'commits' => 0, 'additions' => 1, 'deletions' => 0, 'changed_files' => 1, 'repos' => $repos]; - - $html = Blade::render('', ['meta' => $meta]); - - expect($html)->toContain($expected); -})->with([ - 'singular' => [1, 'em 1 repositório.'], - 'plural' => [3, 'em 3 repositórios.'], -]); - -it('aplica filtros de tipo, repo, desfecho e pessoa', function (): void { - contribution(['actor_login' => 'maria', 'repo' => 'he4rt/a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true]]); - contribution(['actor_login' => 'maria', 'repo' => 'he4rt/a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); - contribution(['actor_login' => 'joao', 'repo' => 'he4rt/b', 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-02']); - - $filters = RetrospectiveFilters::make($this->since, $this->until, repos: ['he4rt/a'], types: ['pr'], outcome: 'merged', person: 'maria'); - $data = ($this->build)($filters); - - expect($data['meta']['total'])->toBe(1) - ->and($data['meta']['people'])->toBe(1) - ->and($data['people'][0]['login'])->toBe('maria') - ->and($data['people'][0]['prs'])->toBe(1); -}); - -it('keeps only truly-closed PRs under outcome=closed: !(A ∧ ¬B) ≡ ¬A ∨ B', function (string $state, bool $merged, bool $shouldKeep): void { - contribution([ - 'actor_login' => 'a', - 'type' => ContributionType::Pr, - 'external_ref' => 'pr:1', - 'occurred_at' => '2026-06-02', - 'metadata' => ['state' => $state, 'merged' => $merged], - ]); - - $filters = RetrospectiveFilters::make($this->since, $this->until, outcome: 'closed'); - $data = ($this->build)($filters); - - // meta.prs counts PRs surviving reject(filteredOutByOutcome). - expect($data['meta']['prs'])->toBe($shouldKeep ? 1 : 0); -})->with([ - // state, merged, keep? - 'closed + unmerged → kept (the only true "closed")' => ['closed', false, true], - 'closed + merged → dropped (it is a merge, not a pure close)' => ['closed', true, false], - 'open + unmerged → dropped (state !== closed)' => ['open', false, false], - 'open + merged → dropped (state !== closed)' => ['open', true, false], -]); - -it('ordena o ranking por linhas quando sort=lines', function (): void { - contribution(['actor_login' => 'poucas', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false, 'additions' => 10, 'deletions' => 0]]); - contribution(['actor_login' => 'muitas', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false, 'additions' => 900, 'deletions' => 50]]); - - $filters = RetrospectiveFilters::make($this->since, $this->until, sort: 'lines'); - $data = ($this->build)($filters); - - expect($data['people'][0]['login'])->toBe('muitas'); -}); diff --git a/app-modules/portal/tests/Feature/RetrospectiveDeckTest.php b/app-modules/portal/tests/Feature/RetrospectiveDeckTest.php new file mode 100644 index 000000000..2c1464b3c --- /dev/null +++ b/app-modules/portal/tests/Feature/RetrospectiveDeckTest.php @@ -0,0 +1,73 @@ +sourceKey; + } + + public function collect(Period $period, SourceFilters $filters): SourceResult + { + return new SourceResult( + $this->sourceKey, + $this->label, + new HeadlineMetrics($this->empty ? [] : [new Metric('Itens', 1)]), + ); + } + }; +} + +function composeWith(RetrospectiveSource ...$sources): array +{ + $period = Period::of(CarbonImmutable::parse('2026-06-01'), CarbonImmutable::parse('2026-06-07')); + + return new RetrospectiveDeck($sources)->compose($period, new SourceFilters()); +} + +it('ordena os blocos como github, depois discord, independentemente do registro', function (): void { + $results = composeWith( + fakeSource('discord', 'Discord', empty: false), + fakeSource('github', 'GitHub', empty: false), + ); + + expect(array_map(fn (SourceResult $result): string => $result->key, $results))->toBe(['github', 'discord']); +}); + +it('joga fontes desconhecidas para o fim', function (): void { + $results = composeWith( + fakeSource('twitch', 'Twitch', empty: false), + fakeSource('github', 'GitHub', empty: false), + ); + + expect(array_map(fn (SourceResult $result): string => $result->key, $results))->toBe(['github', 'twitch']); +}); + +it('descarta fontes sem dado no recorte', function (): void { + $results = composeWith( + fakeSource('github', 'GitHub', empty: false), + fakeSource('discord', 'Discord', empty: true), + ); + + expect($results)->toHaveCount(1) + ->and($results[0]->key)->toBe('github'); +}); diff --git a/app-modules/portal/tests/Feature/RetrospectiveFiltersTest.php b/app-modules/portal/tests/Feature/RetrospectiveFiltersTest.php deleted file mode 100644 index b9166c00c..000000000 --- a/app-modules/portal/tests/Feature/RetrospectiveFiltersTest.php +++ /dev/null @@ -1,35 +0,0 @@ -repos)->toBeEmpty() - ->and($filters->types)->toBe(ContributionType::cases()) - ->and($filters->outcome)->toBeNull() - ->and($filters->person)->toBeNull() - ->and($filters->hideBots)->toBeTrue() - ->and($filters->sort)->toBe('total'); -}); - -it('reconhece um tipo selecionado e ignora valores inválidos', function (): void { - $filters = RetrospectiveFilters::make( - CarbonImmutable::parse('2026-06-01'), - CarbonImmutable::parse('2026-06-07'), - types: ['pr', 'invalido', 'review'], - outcome: 'banana', - sort: 'lines', - ); - - expect($filters->types)->toBe([ContributionType::Pr, ContributionType::Review]) - ->and($filters->outcome)->toBeNull() - ->and($filters->sort)->toBe('lines'); -}); diff --git a/app-modules/portal/tests/Feature/RetrospectiveSlidesTest.php b/app-modules/portal/tests/Feature/RetrospectiveSlidesTest.php new file mode 100644 index 000000000..e503f8931 --- /dev/null +++ b/app-modules/portal/tests/Feature/RetrospectiveSlidesTest.php @@ -0,0 +1,62 @@ + array_map( + fn (int $n): array => ['num' => $n, 'title' => 'pr '.$n, 'url' => null, 'state' => 'open'], + [10, 9, 8, 7, 6], + ), + 'issue_refs' => [], + 'reviews' => 0, + 'comments' => 0, + 'review_comments' => 0, + 'commits' => 0, + ]; + + $html = Blade::render('', ['person' => $person]); + + expect($html)->toContain('#10') + ->and($html)->toContain('#9') + ->and($html)->toContain('#8') + ->and($html)->not->toContain('#7') + ->and($html)->not->toContain('#6') + ->and($html)->toContain('mais 2…'); +}); + +it('o card compacto da comunidade mostra só ícone + somatória dos tipos com contribuição', function (): void { + $person = [ + 'pr_refs' => [ + ['num' => 1, 'title' => 'feat: a', 'url' => null, 'state' => 'open'], + ['num' => 2, 'title' => 'feat: b', 'url' => null, 'state' => 'merged'], + ], + 'issue_refs' => [], + 'reviews' => 5, + 'comments' => 0, + 'review_comments' => 3, + 'commits' => 0, + ]; + + $html = Blade::render('', ['person' => $person]); + + // 3 tipos com n>0: PR(2), review(5), review_comment(3) → 3 pills, sem zeros + expect(mb_substr_count($html, 'class="cstat '))->toBe(3) + ->and($html)->toContain('2') + ->and($html)->toContain('5') + ->and($html)->toContain('3') + ->and($html)->not->toContain('feat:'); +}); + +it('o panorama do github pluraliza repositórios conforme a contagem', function (int $repos, string $expected): void { + $meta = ['people' => 1, 'total' => 1, 'prs' => 1, 'prs_merged' => 1, 'prs_unmerged' => 0, 'reviews' => 0, 'issues' => 0, 'comments' => 0, 'review_comments' => 0, 'commits' => 0, 'additions' => 1, 'deletions' => 0, 'changed_files' => 1, 'repos' => $repos]; + + $html = view('portal::retro.slides.github.panorama', ['meta' => $meta])->render(); + + expect($html)->toContain($expected); +})->with([ + 'singular' => [1, 'em 1 repositório.'], + 'plural' => [3, 'em 3 repositórios.'], +]); From 7be2854a0da663b12c6872d3e8d5c9bf28861a7e Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Tue, 21 Jul 2026 22:54:03 -0300 Subject: [PATCH 2/2] chore: keep module require blocks empty (match repo convention) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove as declarações he4rt/* que eu havia adicionado em activity, portal e integration-github. Embora o guia .ai/02-module-architecture peça declarar as deps intra-repo como ^1.0.0, nenhum dos 17 outros módulos faz isso: todos usam require {} e dependem do autoload global do internachi/modular. Manter os três consistentes com os vizinhos. Sem efeito em runtime (autoload é dir-based). --- app-modules/activity/composer.json | 5 +---- app-modules/integration-github/composer.json | 3 --- app-modules/portal/composer.json | 6 +----- 3 files changed, 2 insertions(+), 12 deletions(-) diff --git a/app-modules/activity/composer.json b/app-modules/activity/composer.json index 6d0ea6f49..72b9cec1f 100644 --- a/app-modules/activity/composer.json +++ b/app-modules/activity/composer.json @@ -4,10 +4,7 @@ "type": "library", "version": "1.0", "license": "proprietary", - "require": { - "he4rt/community": "^1.0.0", - "he4rt/identity": "^1.0.0" - }, + "require": {}, "autoload": { "psr-4": { "He4rt\\Activity\\": "src/", diff --git a/app-modules/integration-github/composer.json b/app-modules/integration-github/composer.json index 58d8dbf6e..813a972d0 100644 --- a/app-modules/integration-github/composer.json +++ b/app-modules/integration-github/composer.json @@ -4,9 +4,6 @@ "type": "library", "version": "1.0.0", "license": "proprietary", - "require": { - "he4rt/community": "^1.0.0" - }, "autoload": { "psr-4": { "He4rt\\IntegrationGithub\\": "src/", diff --git a/app-modules/portal/composer.json b/app-modules/portal/composer.json index 818a6fd7f..b2f77872f 100644 --- a/app-modules/portal/composer.json +++ b/app-modules/portal/composer.json @@ -4,11 +4,7 @@ "type": "library", "version": "1.0", "license": "proprietary", - "require": { - "he4rt/activity": "^1.0.0", - "he4rt/community": "^1.0.0", - "he4rt/integration-github": "^1.0.0" - }, + "require": {}, "autoload": { "psr-4": { "He4rt\\Portal\\": "src/",